@sublang/playbook 7.0.0 → 9.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 (62) hide show
  1. package/README.md +20 -7
  2. package/docs/cli.md +88 -43
  3. package/docs/configuration.md +221 -119
  4. package/docs/embedding.md +78 -27
  5. package/package.json +4 -3
  6. package/reference/sdlc/captain.playbook/captain.playbook.js +16 -5
  7. package/reference/sdlc/captain.playbook/captain.playbook.ts +20 -6
  8. package/reference/sdlc/code.md +1 -1
  9. package/reference/sdlc/code.playbook/bin/interactive-session.js +816 -0
  10. package/reference/sdlc/code.playbook/bin/launch-config.js +1078 -116
  11. package/reference/sdlc/code.playbook/bin/playbook.js +489 -34
  12. package/reference/sdlc/code.playbook/bin/run.js +283 -298
  13. package/reference/sdlc/code.playbook/bin/session-store.js +818 -26
  14. package/reference/sdlc/code.playbook/code.fsm.d.ts +9 -6
  15. package/reference/sdlc/code.playbook/code.fsm.introspect.js +2 -2
  16. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +2 -2
  17. package/reference/sdlc/code.playbook/code.fsm.js +18 -15
  18. package/reference/sdlc/code.playbook/code.fsm.ts +21 -21
  19. package/reference/sdlc/code.playbook/code.gears.md +1 -1
  20. package/reference/sdlc/code.playbook/code.playbook.d.ts +2 -1
  21. package/reference/sdlc/code.playbook/code.playbook.js +25 -15
  22. package/reference/sdlc/code.playbook/code.playbook.ts +34 -17
  23. package/reference/sdlc/code.playbook/code.registry.d.ts +5 -13
  24. package/reference/sdlc/code.playbook/code.registry.js +3 -10
  25. package/reference/sdlc/code.playbook/code.registry.ts +7 -32
  26. package/reference/sdlc/code.playbook/playbook-captain.d.ts +39 -14
  27. package/reference/sdlc/code.playbook/playbook-captain.js +1014 -299
  28. package/reference/sdlc/code.playbook/playbook-captain.ts +1450 -406
  29. package/reference/sdlc/code.playbook/playbook.config.template.yaml +41 -49
  30. package/reference/sdlc/decide.md +4 -4
  31. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +10 -10
  32. package/reference/sdlc/decide.playbook/decide.fsm.js +21 -14
  33. package/reference/sdlc/decide.playbook/decide.fsm.ts +27 -23
  34. package/reference/sdlc/decide.playbook/decide.gears.md +3 -5
  35. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +11 -13
  36. package/reference/sdlc/decide.playbook/decide.playbook.js +465 -246
  37. package/reference/sdlc/decide.playbook/decide.playbook.ts +623 -283
  38. package/reference/sdlc/decide.playbook/decide.registry.d.ts +5 -13
  39. package/reference/sdlc/decide.playbook/decide.registry.js +3 -9
  40. package/reference/sdlc/decide.playbook/decide.registry.ts +7 -31
  41. package/reference/sdlc/review.md +4 -5
  42. package/reference/sdlc/review.playbook/review.fsm.d.ts +9 -11
  43. package/reference/sdlc/review.playbook/review.fsm.js +30 -24
  44. package/reference/sdlc/review.playbook/review.fsm.ts +39 -35
  45. package/reference/sdlc/review.playbook/review.gears.md +6 -5
  46. package/reference/sdlc/review.playbook/review.playbook.d.ts +2 -1
  47. package/reference/sdlc/review.playbook/review.playbook.js +29 -23
  48. package/reference/sdlc/review.playbook/review.playbook.ts +38 -28
  49. package/reference/sdlc/review.playbook/review.registry.d.ts +5 -13
  50. package/reference/sdlc/review.playbook/review.registry.js +3 -16
  51. package/reference/sdlc/review.playbook/review.registry.ts +7 -38
  52. package/slc/gears2fsm.md +45 -24
  53. package/slc/link.md +297 -135
  54. package/slc/text2gears.md +19 -18
  55. package/src/runtime.d.ts +21 -16
  56. package/src/runtime.ts +20 -23
  57. package/src/xstate-playbook-runtime.d.ts +34 -20
  58. package/src/xstate-playbook-runtime.js +973 -400
  59. package/src/xstate-playbook-runtime.ts +1203 -457
  60. package/src/xstate-runtime.d.ts +17 -7
  61. package/src/xstate-runtime.js +198 -81
  62. package/src/xstate-runtime.ts +339 -112
@@ -68,11 +68,45 @@ function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
68
68
  });
69
69
  }
70
70
 
71
+ // slc/link.md §Abort: cancellation is causal identity with the applicable
72
+ // signal's reason; an `AbortError`-named rejection that is not that exact
73
+ // reason is a control-plane failure to surface, never an abort to swallow.
71
74
  function isAbortReason(error: unknown, signal: AbortSignal): boolean {
72
- return (
73
- signal.aborted &&
74
- (error === signal.reason || normalizeError(error).name === 'AbortError')
75
+ return signal.aborted && Object.is(error, signal.reason);
76
+ }
77
+
78
+ /**
79
+ * Immutable cancellation provenance for one runtime operation. The captured
80
+ * signal identities do not change when a mutable runtime advances to another
81
+ * public boundary, while each signal's eventual reason remains observable.
82
+ */
83
+ interface AbortReasonClassifier {
84
+ isAbortReason(error: unknown): boolean;
85
+ }
86
+
87
+ function createAbortReasonClassifier(
88
+ ...sources: readonly (
89
+ | AbortSignal
90
+ | AbortReasonClassifier
91
+ | undefined
92
+ )[]
93
+ ): AbortReasonClassifier {
94
+ const captured = Object.freeze(
95
+ sources.filter(
96
+ (
97
+ source,
98
+ ): source is AbortSignal | AbortReasonClassifier =>
99
+ source !== undefined,
100
+ ),
75
101
  );
102
+ return Object.freeze({
103
+ isAbortReason: (error: unknown): boolean =>
104
+ captured.some((source) =>
105
+ source instanceof AbortSignal
106
+ ? isAbortReason(error, source)
107
+ : source.isAbortReason(error),
108
+ ),
109
+ });
76
110
  }
77
111
 
78
112
  const NEVER_ABORTED_SIGNAL = new AbortController().signal;
@@ -119,7 +153,10 @@ export function registerPlaybookAbortCleanup(
119
153
  void cleanup.catch(() => undefined);
120
154
  }
121
155
 
122
- async function drainPlaybookAbortCleanups(signal: AbortSignal): Promise<void> {
156
+ async function drainPlaybookAbortCleanups(
157
+ signal: AbortSignal,
158
+ aborts: AbortReasonClassifier,
159
+ ): Promise<void> {
123
160
  const failures: unknown[] = [];
124
161
  while (true) {
125
162
  const pending = abortCleanups.get(signal);
@@ -128,7 +165,12 @@ async function drainPlaybookAbortCleanups(signal: AbortSignal): Promise<void> {
128
165
  pending.clear();
129
166
  const outcomes = await Promise.allSettled(batch);
130
167
  for (const outcome of outcomes) {
131
- if (outcome.status === 'rejected') failures.push(outcome.reason);
168
+ if (
169
+ outcome.status === 'rejected' &&
170
+ !aborts.isAbortReason(outcome.reason)
171
+ ) {
172
+ failures.push(outcome.reason);
173
+ }
132
174
  }
133
175
  }
134
176
  abortCleanups.delete(signal);
@@ -386,6 +428,50 @@ function capturedSessionStore(
386
428
  });
387
429
  }
388
430
 
431
+ function capturedRoleBindings(
432
+ descriptors: PropertyDescriptorMap,
433
+ ): NonNullable<PlaybookSession['roleBindings']> {
434
+ const captured = snapshotJsonValue(
435
+ capturedDataValue(
436
+ descriptors,
437
+ 'roleBindings',
438
+ 'playbook session roleBindings',
439
+ ),
440
+ 'playbook session roleBindings',
441
+ );
442
+ if (!isRecord(captured)) {
443
+ throw new TypeError('playbook session roleBindings must be an object');
444
+ }
445
+ const bindings: Record<
446
+ string,
447
+ { readonly playerId: string; readonly promptIdentity: string }
448
+ > = {};
449
+ for (const [roleId, value] of Object.entries(captured)) {
450
+ requireNonEmptyString(roleId, 'playbook session roleBindings role id');
451
+ if (!isRecord(value)) {
452
+ throw new TypeError(
453
+ `playbook session roleBindings.${roleId} must be an object`,
454
+ );
455
+ }
456
+ rejectUnknownKeys(
457
+ value,
458
+ ['playerId', 'promptIdentity'],
459
+ `playbook session roleBindings.${roleId}`,
460
+ );
461
+ defineEnumerableDataProperty(bindings, roleId, Object.freeze({
462
+ playerId: requireNonEmptyString(
463
+ value.playerId,
464
+ `playbook session roleBindings.${roleId}.playerId`,
465
+ ),
466
+ promptIdentity: requireNonEmptyString(
467
+ value.promptIdentity,
468
+ `playbook session roleBindings.${roleId}.promptIdentity`,
469
+ ),
470
+ }));
471
+ }
472
+ return Object.freeze(bindings);
473
+ }
474
+
389
475
  /** Validate session causality and detach its immutable identity from the host. */
390
476
  export function snapshotPlaybookSession(
391
477
  session: PlaybookSession,
@@ -453,6 +539,10 @@ export function snapshotPlaybookSession(
453
539
  sessionDescriptors,
454
540
  'playerSessions',
455
541
  );
542
+ const hasRoleBindings = Object.prototype.hasOwnProperty.call(
543
+ sessionDescriptors,
544
+ 'roleBindings',
545
+ );
456
546
  let parentSessionId: string | undefined;
457
547
  let parentCallId: string | undefined;
458
548
  if (depth === 0) {
@@ -501,6 +591,9 @@ export function snapshotPlaybookSession(
501
591
  const playerSessions = hasPlayerSessions
502
592
  ? capturedSessionStore(sessionDescriptors)
503
593
  : undefined;
594
+ const roleBindings = hasRoleBindings
595
+ ? capturedRoleBindings(sessionDescriptors)
596
+ : undefined;
504
597
  return Object.freeze({
505
598
  sessionId,
506
599
  playbookId,
@@ -508,6 +601,7 @@ export function snapshotPlaybookSession(
508
601
  ...(parentSessionId === undefined ? {} : { parentSessionId }),
509
602
  ...(parentCallId === undefined ? {} : { parentCallId }),
510
603
  depth,
604
+ ...(roleBindings === undefined ? {} : { roleBindings }),
511
605
  ...(playerSessions === undefined ? {} : { playerSessions }),
512
606
  ports,
513
607
  });
@@ -619,7 +713,10 @@ function normalizeStateValue(
619
713
 
620
714
  export interface PlaybookStateMetadata {
621
715
  stateId: string;
622
- description: string;
716
+ // Optional by contract: a state whose source declares no description
717
+ // carries none, and no id is ever promoted into one
718
+ // (slc/link.md §Snapshot normalization).
719
+ description?: string;
623
720
  }
624
721
 
625
722
  interface MachineSnapshotLike {
@@ -666,17 +763,31 @@ export function activePlaybookStateMetadata(
666
763
  meta.playbook.stateId,
667
764
  `${nodeId}.meta.playbook.stateId`,
668
765
  );
669
- const description = requireNonEmptyString(
670
- meta.playbook.description,
671
- `${nodeId}.meta.playbook.description`,
672
- );
766
+ // Description is optional: a state may declare none and stay fully
767
+ // usable, merely carrying no `stateDescription` downstream. A declared
768
+ // description must still be a nonempty string.
769
+ const description =
770
+ meta.playbook.description === undefined
771
+ ? undefined
772
+ : requireNonEmptyString(
773
+ meta.playbook.description,
774
+ `${nodeId}.meta.playbook.description`,
775
+ );
673
776
  const previous = byStateId.get(stateId);
674
- if (previous && previous.description !== description) {
777
+ if (
778
+ previous?.description !== undefined &&
779
+ description !== undefined &&
780
+ previous.description !== description
781
+ ) {
675
782
  throw new TypeError(
676
783
  `active state id ${stateId} has conflicting descriptions`,
677
784
  );
678
785
  }
679
- byStateId.set(stateId, { stateId, description });
786
+ const effective = description ?? previous?.description;
787
+ byStateId.set(stateId, {
788
+ stateId,
789
+ ...(effective === undefined ? {} : { description: effective }),
790
+ });
680
791
  }
681
792
  return [...byStateId.values()].sort((left, right) =>
682
793
  left.stateId.localeCompare(right.stateId),
@@ -805,9 +916,10 @@ function snapshotSuspendedCall(
805
916
  return Object.freeze(call);
806
917
  }
807
918
 
808
- // DR-014 §1 / DR-031 §5: validate and detach a host-supplied runtime
809
- // snapshot before restore touches any state. A suspended schema-2 call is
810
- // rejected unless the restore path explicitly promises to seed and claim it.
919
+ // DR-014 §1 / DR-031 §5 / DR-032: validate and detach a host-supplied
920
+ // schema-3 runtime snapshot before restore touches any state. A suspended
921
+ // call is rejected unless the restore path explicitly promises to seed and
922
+ // claim it; older schemas are rejected rather than guessing role identity.
811
923
  export function assertPlaybookRuntimeSnapshot(
812
924
  value: unknown,
813
925
  expectedPlaybookId: string,
@@ -838,19 +950,18 @@ export function assertPlaybookRuntimeSnapshot(
838
950
  );
839
951
  }
840
952
  const allowSuspendedCall = capturedOptions.allowSuspendedCall ?? false;
841
- if (snapshot.schemaVersion !== 1 && snapshot.schemaVersion !== 2) {
953
+ if (snapshot.schemaVersion !== 3) {
842
954
  throw new TypeError(
843
- `runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 1 or 2)`,
955
+ `runtime snapshot schemaVersion ${String(snapshot.schemaVersion)} is not supported (expected 3)`,
844
956
  );
845
957
  }
846
- const schemaVersion = snapshot.schemaVersion;
847
958
  rejectUnknownKeys(
848
959
  snapshot,
849
960
  [
850
961
  'schemaVersion',
851
962
  'playbookId',
852
963
  'machine',
853
- 'playerResumeTokens',
964
+ 'roleResumeTokens',
854
965
  'sequences',
855
966
  'state',
856
967
  'pendingBossQuestions',
@@ -858,13 +969,8 @@ export function assertPlaybookRuntimeSnapshot(
858
969
  ],
859
970
  'runtime snapshot',
860
971
  );
861
- if (schemaVersion === 1 && own(snapshot, 'suspendedCall')) {
862
- throw new TypeError(
863
- 'runtime snapshot schemaVersion 1 must not carry suspendedCall',
864
- );
865
- }
866
972
  let suspendedCall: PlaybookSuspendedCall | undefined;
867
- if (schemaVersion === 2 && own(snapshot, 'suspendedCall')) {
973
+ if (own(snapshot, 'suspendedCall')) {
868
974
  suspendedCall = snapshotSuspendedCall(snapshot.suspendedCall);
869
975
  if (!allowSuspendedCall) {
870
976
  throw new TypeError(
@@ -885,19 +991,19 @@ export function assertPlaybookRuntimeSnapshot(
885
991
  throw new TypeError('runtime snapshot machine must be an object');
886
992
  }
887
993
  const machine = snapshot.machine;
888
- if (!isRecord(snapshot.playerResumeTokens)) {
994
+ if (!isRecord(snapshot.roleResumeTokens)) {
889
995
  throw new TypeError(
890
- 'runtime snapshot playerResumeTokens must be an object',
996
+ 'runtime snapshot roleResumeTokens must be an object',
891
997
  );
892
998
  }
893
- const playerResumeTokens: Record<string, string> = {};
894
- for (const [playerId, token] of Object.entries(snapshot.playerResumeTokens)) {
999
+ const roleResumeTokens: Record<string, string> = {};
1000
+ for (const [roleId, token] of Object.entries(snapshot.roleResumeTokens)) {
895
1001
  defineEnumerableDataProperty(
896
- playerResumeTokens,
897
- playerId,
1002
+ roleResumeTokens,
1003
+ requireNonEmptyString(roleId, 'runtime snapshot roleResumeTokens role id'),
898
1004
  requireNonEmptyString(
899
1005
  token,
900
- `runtime snapshot playerResumeTokens.${playerId}`,
1006
+ `runtime snapshot roleResumeTokens.${roleId}`,
901
1007
  ),
902
1008
  );
903
1009
  }
@@ -932,7 +1038,7 @@ export function assertPlaybookRuntimeSnapshot(
932
1038
  const state = snapshot.state as unknown as PlaybookState;
933
1039
  if (state.tags.includes(SUSPENDED_TAG) && suspendedCall === undefined) {
934
1040
  throw new TypeError(
935
- `runtime snapshot state tagged ${SUSPENDED_TAG} requires schemaVersion 2 suspendedCall`,
1041
+ `runtime snapshot state tagged ${SUSPENDED_TAG} requires suspendedCall`,
936
1042
  );
937
1043
  }
938
1044
  if (suspendedCall) {
@@ -976,15 +1082,36 @@ export function assertPlaybookRuntimeSnapshot(
976
1082
  if (!isRecord(entry)) throw new TypeError(`${path} must be an object`);
977
1083
  rejectUnknownKeys(
978
1084
  entry,
979
- ['questionId', 'player', 'question', 'sourceItem'],
1085
+ ['questionId', 'asker', 'question', 'sourceItem'],
980
1086
  path,
981
1087
  );
1088
+ if (!isRecord(entry.asker)) {
1089
+ throw new TypeError(`${path}.asker must be an object`);
1090
+ }
1091
+ let asker: PlaybookPendingBossQuestion['asker'];
1092
+ if (entry.asker.kind === 'captain') {
1093
+ rejectUnknownKeys(entry.asker, ['kind'], `${path}.asker`);
1094
+ asker = Object.freeze({ kind: 'captain' });
1095
+ } else if (entry.asker.kind === 'role') {
1096
+ rejectUnknownKeys(entry.asker, ['kind', 'roleId'], `${path}.asker`);
1097
+ asker = Object.freeze({
1098
+ kind: 'role',
1099
+ roleId: requireNonEmptyString(
1100
+ entry.asker.roleId,
1101
+ `${path}.asker.roleId`,
1102
+ ),
1103
+ });
1104
+ } else {
1105
+ throw new TypeError(
1106
+ `${path}.asker.kind must be "captain" or "role"`,
1107
+ );
1108
+ }
982
1109
  const question: PlaybookPendingBossQuestion = {
983
1110
  questionId: requireNonEmptyString(
984
1111
  entry.questionId,
985
1112
  `${path}.questionId`,
986
1113
  ),
987
- player: requireNonEmptyString(entry.player, `${path}.player`),
1114
+ asker,
988
1115
  question: requireNonEmptyString(entry.question, `${path}.question`),
989
1116
  ...(entry.sourceItem === undefined
990
1117
  ? {}
@@ -1001,16 +1128,13 @@ export function assertPlaybookRuntimeSnapshot(
1001
1128
  const fields = {
1002
1129
  playbookId,
1003
1130
  machine,
1004
- playerResumeTokens: Object.freeze(playerResumeTokens),
1131
+ roleResumeTokens: Object.freeze(roleResumeTokens),
1005
1132
  sequences: Object.freeze(sequences),
1006
1133
  state,
1007
1134
  pendingBossQuestions: Object.freeze(pendingBossQuestions),
1008
1135
  };
1009
- if (schemaVersion === 1) {
1010
- return Object.freeze({ schemaVersion: 1, ...fields });
1011
- }
1012
1136
  return Object.freeze({
1013
- schemaVersion: 2,
1137
+ schemaVersion: 3,
1014
1138
  ...fields,
1015
1139
  ...(suspendedCall === undefined ? {} : { suspendedCall }),
1016
1140
  });
@@ -1041,12 +1165,29 @@ export interface NestedPlaybookBridgeOptions {
1041
1165
  request: PlaybookCallRequest,
1042
1166
  signal: AbortSignal,
1043
1167
  ): Promise<PlaybookCallStart>;
1044
- emitStarted(event: PlaybookCallStarted): Promise<void>;
1045
- emitFinished(event: PlaybookCallFinished): Promise<void>;
1046
- drain(): Promise<void>;
1047
- bindResumeSignal?(signal: AbortSignal): void;
1048
- onControlPlaneError?(error: unknown): void;
1049
- onBackgroundError?(error: unknown): void;
1168
+ emitStarted(
1169
+ event: PlaybookCallStarted,
1170
+ aborts?: AbortReasonClassifier,
1171
+ ): Promise<void>;
1172
+ emitFinished(
1173
+ event: PlaybookCallFinished,
1174
+ aborts?: AbortReasonClassifier,
1175
+ ): Promise<void>;
1176
+ drain(aborts?: AbortReasonClassifier): Promise<void>;
1177
+ bindResumeSignal?(
1178
+ signal: AbortSignal,
1179
+ aborts?: AbortReasonClassifier,
1180
+ ): void;
1181
+ /** Bind provenance to the root transition caused by this child result. */
1182
+ bindActorSettlement?(aborts: AbortReasonClassifier): void;
1183
+ onControlPlaneError?(
1184
+ error: unknown,
1185
+ aborts?: AbortReasonClassifier,
1186
+ ): void;
1187
+ onBackgroundError?(
1188
+ error: unknown,
1189
+ aborts?: AbortReasonClassifier,
1190
+ ): void;
1050
1191
  }
1051
1192
 
1052
1193
  export class NestedPlaybookCallError extends Error {
@@ -1070,6 +1211,7 @@ interface ActiveCall {
1070
1211
  readonly finished: Deferred<void>;
1071
1212
  readonly controller: AbortController;
1072
1213
  readonly signal: AbortSignal;
1214
+ readonly aborts: AbortReasonClassifier;
1073
1215
  phase: 'starting' | 'restoring' | 'suspended' | 'settling';
1074
1216
  childSessionId?: string;
1075
1217
  abortListener?: () => void;
@@ -1228,6 +1370,9 @@ export function validatePlayerResult(
1228
1370
  );
1229
1371
  validateRunStatus(result.status, `${path}.status`);
1230
1372
  validateOptionalString(result, 'resumeToken', path);
1373
+ if (result.resumeToken !== undefined) {
1374
+ requireNonEmptyString(result.resumeToken, `${path}.resumeToken`);
1375
+ }
1231
1376
  validateOptionalString(result, 'finalText', path);
1232
1377
  validateOptionalString(result, 'error', path);
1233
1378
  return result as unknown as PlayerResult;
@@ -1441,21 +1586,29 @@ export function createNestedPlaybookBridge<
1441
1586
  (pendingCall: PlaybookPendingCall) => void
1442
1587
  >();
1443
1588
 
1444
- const reportBackgroundError = (error: unknown): void => {
1589
+ const reportBackgroundError = (
1590
+ error: unknown,
1591
+ aborts?: AbortReasonClassifier,
1592
+ ): void => {
1593
+ if (aborts?.isAbortReason(error)) return;
1445
1594
  try {
1446
- options.onBackgroundError?.(error);
1595
+ options.onBackgroundError?.(error, aborts);
1447
1596
  } catch {
1448
1597
  // Background observers are a terminal sink and cannot own cleanup.
1449
1598
  }
1450
1599
  };
1451
1600
 
1452
- const reportControlPlaneError = (error: unknown): void => {
1601
+ const reportControlPlaneError = (
1602
+ error: unknown,
1603
+ aborts?: AbortReasonClassifier,
1604
+ ): void => {
1605
+ if (aborts?.isAbortReason(error)) return;
1453
1606
  try {
1454
- options.onControlPlaneError?.(error);
1607
+ options.onControlPlaneError?.(error, aborts);
1455
1608
  } catch (callbackError) {
1456
1609
  // Observability callbacks must never prevent terminal cleanup of the
1457
1610
  // invocation they are observing.
1458
- reportBackgroundError(callbackError);
1611
+ reportBackgroundError(callbackError, aborts);
1459
1612
  }
1460
1613
  };
1461
1614
 
@@ -1510,18 +1663,31 @@ export function createNestedPlaybookBridge<
1510
1663
  if (current === active) current = undefined;
1511
1664
  };
1512
1665
 
1666
+ // A failure causally identical to an applicable abort reason is the
1667
+ // cancellation's own evidence, never a control-plane error.
1668
+ const reportNonAbortControlError = (
1669
+ error: unknown,
1670
+ aborts: AbortReasonClassifier,
1671
+ ): void => {
1672
+ reportControlPlaneError(error, aborts);
1673
+ };
1674
+
1513
1675
  const emitFinish = async (
1514
1676
  active: ActiveCall,
1515
1677
  result: PlaybookCallResult,
1678
+ aborts: AbortReasonClassifier,
1516
1679
  ): Promise<void> => {
1517
- await options.emitFinished({
1518
- callId: active.callId,
1519
- stateId: active.input.stateId,
1520
- playbookId: active.input.playbookId,
1521
- text: active.input.text,
1522
- result,
1523
- });
1524
- await options.drain();
1680
+ await options.emitFinished(
1681
+ {
1682
+ callId: active.callId,
1683
+ stateId: active.input.stateId,
1684
+ playbookId: active.input.playbookId,
1685
+ text: active.input.text,
1686
+ result,
1687
+ },
1688
+ aborts,
1689
+ );
1690
+ await options.drain(aborts);
1525
1691
  };
1526
1692
 
1527
1693
  const finishImmediate = async (
@@ -1530,20 +1696,25 @@ export function createNestedPlaybookBridge<
1530
1696
  controlError?: unknown,
1531
1697
  resultAfterAbortCleanup?: () => PlaybookCallResult,
1532
1698
  ): Promise<JsonValue | undefined> => {
1699
+ const aborts = active.aborts;
1533
1700
  let effectiveResult = result;
1534
1701
  let cleanupControlError: unknown;
1535
1702
  if (result.status === 'aborted' || active.signal.aborted) {
1536
1703
  try {
1537
- await drainPlaybookAbortCleanups(active.signal);
1704
+ await drainPlaybookAbortCleanups(active.signal, aborts);
1538
1705
  } catch (error) {
1539
- cleanupControlError = error;
1540
- reportControlPlaneError(error);
1541
- effectiveResult = resultFromThrown(
1542
- active.input.playbookId,
1543
- active.childSessionId,
1544
- error,
1545
- false,
1546
- );
1706
+ // A cleanup rejection identical to an applicable abort reason is
1707
+ // the cancellation's own evidence — no latch, no result override.
1708
+ if (!aborts.isAbortReason(error)) {
1709
+ cleanupControlError = error;
1710
+ reportControlPlaneError(error, aborts);
1711
+ effectiveResult = resultFromThrown(
1712
+ active.input.playbookId,
1713
+ active.childSessionId,
1714
+ error,
1715
+ false,
1716
+ );
1717
+ }
1547
1718
  }
1548
1719
  if (cleanupControlError === undefined && resultAfterAbortCleanup) {
1549
1720
  effectiveResult = resultAfterAbortCleanup();
@@ -1551,15 +1722,16 @@ export function createNestedPlaybookBridge<
1551
1722
  }
1552
1723
  let finishControlError: unknown;
1553
1724
  try {
1554
- await emitFinish(active, effectiveResult);
1725
+ await emitFinish(active, effectiveResult, aborts);
1555
1726
  } catch (error) {
1556
- reportControlPlaneError(error);
1727
+ reportNonAbortControlError(error, aborts);
1557
1728
  finishControlError = error;
1558
1729
  } finally {
1559
1730
  // An immediate call can never be resumed. Even when its finish
1560
1731
  // emission fails, do not leave a permanently unresumable call in the
1561
1732
  // bridge and prevent disposal or a later invocation.
1562
1733
  clear(active);
1734
+ options.bindActorSettlement?.(aborts);
1563
1735
  }
1564
1736
  if (controlError !== undefined) throw controlError;
1565
1737
  if (cleanupControlError !== undefined) throw cleanupControlError;
@@ -1571,6 +1743,7 @@ export function createNestedPlaybookBridge<
1571
1743
  active: ActiveCall,
1572
1744
  result: PlaybookCallResult,
1573
1745
  controlError?: unknown,
1746
+ aborts: AbortReasonClassifier = active.aborts,
1574
1747
  ): Promise<void> => {
1575
1748
  if (active.phase === 'settling' && active.settlement) {
1576
1749
  await active.settlement;
@@ -1588,37 +1761,45 @@ export function createNestedPlaybookBridge<
1588
1761
  effectiveResult = resultFromThrown(
1589
1762
  active.input.playbookId,
1590
1763
  active.childSessionId,
1591
- active.signal.reason ??
1592
- new Error('Nested playbook invocation aborted'),
1764
+ active.signal.reason,
1593
1765
  true,
1594
1766
  );
1595
1767
  }
1596
1768
  try {
1597
- await drainPlaybookAbortCleanups(active.signal);
1769
+ await drainPlaybookAbortCleanups(active.signal, aborts);
1598
1770
  } catch (cleanupError) {
1599
- cleanupControlError = cleanupError;
1600
- reportControlPlaneError(cleanupError);
1601
- effectiveResult = resultFromThrown(
1602
- active.input.playbookId,
1603
- active.childSessionId,
1604
- cleanupError,
1605
- false,
1606
- );
1771
+ // A cleanup rejection identical to an applicable abort reason is
1772
+ // the cancellation's own evidence — no latch, no result override.
1773
+ if (!aborts.isAbortReason(cleanupError)) {
1774
+ cleanupControlError = cleanupError;
1775
+ reportControlPlaneError(cleanupError, aborts);
1776
+ effectiveResult = resultFromThrown(
1777
+ active.input.playbookId,
1778
+ active.childSessionId,
1779
+ cleanupError,
1780
+ false,
1781
+ );
1782
+ }
1607
1783
  }
1608
1784
  }
1609
1785
  try {
1610
- await emitFinish(active, effectiveResult);
1786
+ await emitFinish(active, effectiveResult, aborts);
1611
1787
  } catch (error) {
1612
1788
  // A finish event is the durable return boundary. If it cannot be
1613
1789
  // emitted and drained, the child result must not remain retryable:
1614
1790
  // clear the identity and fail the promise actor so its parent takes
1615
- // onError instead of observing a phantom suspended child.
1616
- reportControlPlaneError(error);
1791
+ // onError instead of observing a phantom suspended child. A finish
1792
+ // rejection that is an applicable abort reason — the invocation's
1793
+ // or the settling resume's — evidences cancellation, not a
1794
+ // control-plane failure (slc/link.md §Abort).
1795
+ reportControlPlaneError(error, aborts);
1617
1796
  clear(active);
1797
+ options.bindActorSettlement?.(aborts);
1618
1798
  active.deferred.reject(error);
1619
1799
  throw error;
1620
1800
  }
1621
1801
  clear(active);
1802
+ options.bindActorSettlement?.(aborts);
1622
1803
  if (controlError !== undefined) {
1623
1804
  active.deferred.reject(controlError);
1624
1805
  } else if (cleanupControlError !== undefined) {
@@ -1652,6 +1833,7 @@ export function createNestedPlaybookBridge<
1652
1833
  active.restoreRolledBack = true;
1653
1834
  clear(active);
1654
1835
  usedCallIds.delete(active.callId);
1836
+ options.bindActorSettlement?.(active.aborts);
1655
1837
  active.deferred.reject(error);
1656
1838
  return active;
1657
1839
  };
@@ -1665,11 +1847,11 @@ export function createNestedPlaybookBridge<
1665
1847
  const result = resultFromThrown(
1666
1848
  active.input.playbookId,
1667
1849
  active.childSessionId,
1668
- active.signal.reason ?? new Error('Nested playbook invocation aborted'),
1850
+ active.signal.reason,
1669
1851
  true,
1670
1852
  );
1671
1853
  void settlePending(active, result).catch((error: unknown) => {
1672
- reportBackgroundError(error);
1854
+ reportBackgroundError(error, active.aborts);
1673
1855
  });
1674
1856
  };
1675
1857
  active.abortListener = abortListener;
@@ -1682,7 +1864,7 @@ export function createNestedPlaybookBridge<
1682
1864
  try {
1683
1865
  listener(pendingCall);
1684
1866
  } catch (error) {
1685
- reportBackgroundError(error);
1867
+ reportBackgroundError(error, active.aborts);
1686
1868
  }
1687
1869
  }
1688
1870
  if (active.signal.aborted) abortListener();
@@ -1766,10 +1948,17 @@ export function createNestedPlaybookBridge<
1766
1948
  }
1767
1949
  const controller = new AbortController();
1768
1950
  let callSignal: AbortSignal;
1951
+ let callAborts: AbortReasonClassifier;
1769
1952
  try {
1953
+ const boundarySignal = options.getBoundarySignal?.();
1770
1954
  callSignal = combineAbortSignals(
1771
1955
  invocationSignal,
1772
- options.getBoundarySignal?.(),
1956
+ boundarySignal,
1957
+ controller.signal,
1958
+ );
1959
+ callAborts = createAbortReasonClassifier(
1960
+ invocationSignal,
1961
+ boundarySignal,
1773
1962
  controller.signal,
1774
1963
  );
1775
1964
  } catch (error) {
@@ -1786,6 +1975,7 @@ export function createNestedPlaybookBridge<
1786
1975
  finished: deferred<void>(),
1787
1976
  controller,
1788
1977
  signal: callSignal,
1978
+ aborts: callAborts,
1789
1979
  phase: 'restoring',
1790
1980
  childSessionId: seed.childSessionId,
1791
1981
  };
@@ -1804,8 +1994,7 @@ export function createNestedPlaybookBridge<
1804
1994
  }
1805
1995
  rollbackRestoredCall(
1806
1996
  mode,
1807
- active.signal.reason ??
1808
- new Error('Restored nested playbook invocation aborted'),
1997
+ active.signal.reason,
1809
1998
  );
1810
1999
  };
1811
2000
  active.abortListener = restoreAbortListener;
@@ -1847,10 +2036,17 @@ export function createNestedPlaybookBridge<
1847
2036
  usedCallIds.add(callId);
1848
2037
  const controller = new AbortController();
1849
2038
  let callSignal: AbortSignal;
2039
+ let callAborts: AbortReasonClassifier;
1850
2040
  try {
2041
+ const boundarySignal = options.getBoundarySignal?.();
1851
2042
  callSignal = combineAbortSignals(
1852
2043
  invocationSignal,
1853
- options.getBoundarySignal?.(),
2044
+ boundarySignal,
2045
+ controller.signal,
2046
+ );
2047
+ callAborts = createAbortReasonClassifier(
2048
+ invocationSignal,
2049
+ boundarySignal,
1854
2050
  controller.signal,
1855
2051
  );
1856
2052
  } catch (error) {
@@ -1863,6 +2059,7 @@ export function createNestedPlaybookBridge<
1863
2059
  finished: deferred<void>(),
1864
2060
  controller,
1865
2061
  signal: callSignal,
2062
+ aborts: callAborts,
1866
2063
  phase: 'starting',
1867
2064
  };
1868
2065
  current = active;
@@ -1872,40 +2069,56 @@ export function createNestedPlaybookBridge<
1872
2069
  // for their entering state. Yield through the runtime's global queue so
1873
2070
  // that transition/status telemetry is enqueued before call.started.
1874
2071
  try {
1875
- await options.drain();
2072
+ await options.drain(active.aborts);
1876
2073
  } catch (error) {
1877
- reportControlPlaneError(error);
2074
+ reportNonAbortControlError(error, active.aborts);
1878
2075
  clear(active);
1879
2076
  throw error;
1880
2077
  }
1881
2078
  try {
1882
- await options.emitStarted({ callId, ...normalizedInput });
2079
+ await options.emitStarted(
2080
+ { callId, ...normalizedInput },
2081
+ active.aborts,
2082
+ );
1883
2083
  } catch (error) {
1884
- reportControlPlaneError(error);
2084
+ // A start-sink rejection identical to the applicable abort
2085
+ // reason is the cancellation itself: the pair finishes
2086
+ // `aborted` and nothing is reported (slc/link.md §Abort).
2087
+ const controlError = active.aborts.isAbortReason(error)
2088
+ ? undefined
2089
+ : error;
2090
+ if (controlError !== undefined) {
2091
+ reportControlPlaneError(controlError, active.aborts);
2092
+ }
1885
2093
  return await finishImmediate(
1886
2094
  active,
1887
2095
  resultFromThrown(
1888
2096
  normalizedInput.playbookId,
1889
2097
  undefined,
1890
2098
  error,
1891
- false,
2099
+ controlError === undefined,
1892
2100
  ),
1893
- error,
2101
+ controlError,
1894
2102
  );
1895
2103
  }
1896
2104
  try {
1897
- await options.drain();
2105
+ await options.drain(active.aborts);
1898
2106
  } catch (error) {
1899
- reportControlPlaneError(error);
2107
+ const controlError = active.aborts.isAbortReason(error)
2108
+ ? undefined
2109
+ : error;
2110
+ if (controlError !== undefined) {
2111
+ reportControlPlaneError(controlError, active.aborts);
2112
+ }
1900
2113
  return await finishImmediate(
1901
2114
  active,
1902
2115
  resultFromThrown(
1903
2116
  normalizedInput.playbookId,
1904
2117
  undefined,
1905
2118
  error,
1906
- false,
2119
+ controlError === undefined,
1907
2120
  ),
1908
- error,
2121
+ controlError,
1909
2122
  );
1910
2123
  }
1911
2124
 
@@ -1934,7 +2147,7 @@ export function createNestedPlaybookBridge<
1934
2147
  const openingCleanup = starting.then(
1935
2148
  () => undefined,
1936
2149
  (error: unknown) => {
1937
- if (isAbortReason(error, active.signal)) return;
2150
+ if (active.aborts.isAbortReason(error)) return;
1938
2151
  throw error;
1939
2152
  },
1940
2153
  );
@@ -1951,19 +2164,23 @@ export function createNestedPlaybookBridge<
1951
2164
  }
1952
2165
  rawStart = await withAbort(starting, active.signal);
1953
2166
  } catch (error) {
1954
- const controlError = active.signal.aborted ? undefined : error;
1955
- if (controlError !== undefined) reportControlPlaneError(controlError);
2167
+ const controlError = active.aborts.isAbortReason(error)
2168
+ ? undefined
2169
+ : error;
2170
+ if (controlError !== undefined) {
2171
+ reportControlPlaneError(controlError, active.aborts);
2172
+ }
1956
2173
  const result = resultFromThrown(
1957
2174
  normalizedInput.playbookId,
1958
2175
  undefined,
1959
2176
  error,
1960
- active.signal.aborted,
2177
+ controlError === undefined && active.signal.aborted,
1961
2178
  );
1962
2179
  return await finishImmediate(
1963
2180
  active,
1964
2181
  result,
1965
2182
  controlError,
1966
- active.signal.aborted
2183
+ controlError === undefined && active.signal.aborted
1967
2184
  ? () =>
1968
2185
  resultFromThrown(
1969
2186
  normalizedInput.playbookId,
@@ -2140,9 +2357,7 @@ export function createNestedPlaybookBridge<
2140
2357
  }
2141
2358
  const active = mode.active;
2142
2359
  if (active.signal.aborted) {
2143
- const error =
2144
- active.signal.reason ??
2145
- new Error('Restored nested playbook invocation aborted');
2360
+ const error = active.signal.reason;
2146
2361
  rollbackRestoredCall(mode, error);
2147
2362
  restoreMode = undefined;
2148
2363
  throw error;
@@ -2167,7 +2382,7 @@ export function createNestedPlaybookBridge<
2167
2382
  try {
2168
2383
  listener(pendingCall);
2169
2384
  } catch (error) {
2170
- reportBackgroundError(error);
2385
+ reportBackgroundError(error, current?.aborts);
2171
2386
  }
2172
2387
  }
2173
2388
  return () => pendingListeners.delete(listener);
@@ -2209,8 +2424,20 @@ export function createNestedPlaybookBridge<
2209
2424
  }
2210
2425
  throw error;
2211
2426
  }
2212
- options.bindResumeSignal?.(signal);
2213
- await settlePending(active, validatedResult);
2427
+ // A resume whose signal is already aborted delivers nothing: the
2428
+ // validated child result is not consumed, no finish is emitted, and
2429
+ // the pending call survives for a later resume with a fresh signal
2430
+ // (slc/link.md §Nested playbook bridge). Identity and validation
2431
+ // control errors above still win — they are the caller's defects.
2432
+ if (signal.aborted) {
2433
+ throw signal.reason;
2434
+ }
2435
+ const resumeAborts = createAbortReasonClassifier(
2436
+ active.aborts,
2437
+ signal,
2438
+ );
2439
+ options.bindResumeSignal?.(signal, resumeAborts);
2440
+ await settlePending(active, validatedResult, undefined, resumeAborts);
2214
2441
  },
2215
2442
  abortPending,
2216
2443
  async dispose() {