@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
@@ -66,13 +66,13 @@ export interface PlaybookPendingBossQuestionContext {
66
66
  questionId: string;
67
67
  resumeStateId: string;
68
68
  sourceItem: string;
69
- player: string;
69
+ asker: { kind: 'captain' } | { kind: 'role'; roleId: string };
70
70
  question: string;
71
71
  }
72
72
 
73
73
  export interface PlaybookPlayerInput {
74
74
  stateId: string;
75
- player: string;
75
+ role: string;
76
76
  sourceItem: string;
77
77
  prompt: string;
78
78
  result: Readonly<Record<string, string>>;
@@ -113,7 +113,7 @@ export type JudgePurpose =
113
113
  export interface RuntimeBoundaryCalls {
114
114
  callPlayer(
115
115
  input: PlaybookPlayerInput,
116
- playerId: string,
116
+ roleId: string,
117
117
  prompt: string,
118
118
  signal: AbortSignal,
119
119
  ): Promise<PlayerResult>;
@@ -151,11 +151,14 @@ export interface ScheduledStatus {
151
151
  }
152
152
 
153
153
  /** Boss-facing identity for one FSM state whose invoked actor is `player`. */
154
- export interface XStatePlayerStateStatus {
155
- player: string;
154
+ export interface XStateRoleStateStatus {
155
+ role: string;
156
156
  label: string;
157
157
  }
158
158
 
159
+ /** Invocation-scoped lookup exposed only while composing a player prompt. */
160
+ export type XStatePromptIdentity = (roleId: string) => string;
161
+
159
162
  export interface XStateBossEventFieldSpec {
160
163
  /** The judge supplies routing data; the runtime supplies exact Boss text. */
161
164
  source: 'judge' | 'text';
@@ -247,7 +250,7 @@ export const RUNTIME_ABI = 1;
247
250
 
248
251
  /** The linked-artifact schema versions this engine accepts (DR-022). */
249
252
  export const SUPPORTED_ARTIFACT_SCHEMAS: readonly number[] = Object.freeze([
250
- 1,
253
+ 2,
251
254
  ]);
252
255
 
253
256
  /** A linked artifact's declared link-time compatibility values (DR-022). */
@@ -259,14 +262,17 @@ export interface XStatePlaybookRuntimeCompat {
259
262
  }
260
263
 
261
264
  // PBRT-50: validate a declaration against the loaded engine, schema first,
262
- // so one clear diagnostic covers a fully skewed artifact. Absent means a
263
- // legacy artifact emitted before the DR-022 contract; those must keep
264
- // loading unchanged (DR-019 §4), so there is nothing to check.
265
+ // so one clear diagnostic covers a fully skewed artifact. Declaration-free
266
+ // artifacts are schema 1 and cannot be interpreted as local-role artifacts.
265
267
  function assertRuntimeCompat(
266
268
  compat: XStatePlaybookRuntimeCompat | undefined,
267
269
  label: string,
268
270
  ): void {
269
- if (compat === undefined) return;
271
+ if (compat === undefined) {
272
+ throw new TypeError(
273
+ `${label} spec.compat is required for local-role artifacts`,
274
+ );
275
+ }
270
276
  if (compat === null || typeof compat !== 'object') {
271
277
  throw new TypeError(`${label} spec.compat must be an object`);
272
278
  }
@@ -355,8 +361,8 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
355
361
  label?: string;
356
362
  /**
357
363
  * Link-time compatibility declaration checked at construction against the
358
- * loaded engine's self-report (DR-022). Absent: a legacy artifact emitted
359
- * before the contract constructed with no compatibility check.
364
+ * loaded engine's self-report (DR-022). Absent declarations reject because
365
+ * their overloaded player metadata has no safe local-role interpretation.
360
366
  */
361
367
  compat?: XStatePlaybookRuntimeCompat;
362
368
  /** Validate and JSON-snapshot the caller's per-run options. */
@@ -369,7 +375,19 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
369
375
  * ordinary textual entry event, send it without a judge call, carrying the
370
376
  * exact Boss text in `textField`. Absent: every non-empty turn classifies.
371
377
  */
372
- entryEvent?: { type: string; textField: string };
378
+ entryEvent?: {
379
+ type: string;
380
+ textField: string;
381
+ /**
382
+ * DR-034: the FSM context member this machine's entry action copies the
383
+ * exact Boss text into. Where it is named, the failure-state retry
384
+ * builds its payload from that member of the live snapshot instead of
385
+ * from the process-local recorded event, so the action derives the same
386
+ * before and after `restore`. Absent: the recorded event stays the
387
+ * source and the action lives only as long as the process.
388
+ */
389
+ contextField?: string;
390
+ };
373
391
  /**
374
392
  * Exact flat Boss-event contracts whose non-text fields the judge may
375
393
  * select. `entryEvent` and scalar `BOSS_REPLY` contracts are supplied by
@@ -397,14 +415,15 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
397
415
  * recoverable FSM-result failures instead.
398
416
  */
399
417
  captainStrategy?: XStateCaptainStrategy<TOptions>;
400
- /** Status line emitted after classification; canonical metadata defaults to the event type, legacy artifacts to none. */
418
+ /** Status line emitted after classification; metadata defaults to the event type. */
401
419
  classificationStatus?: (event: EventObject) => string | undefined;
402
- /** Complete FSM-derived Boss-facing metadata for every `player` state; its presence selects the canonical status profile. */
403
- playerStates?: Readonly<Record<string, XStatePlayerStateStatus>>;
404
- /** Map a player-invoking state's input to the host player id. Default: lowercased player name. */
405
- resolvePlayerId?: (input: PlaybookPlayerInput, options: TOptions) => string;
420
+ /** Complete FSM-derived Boss-facing metadata for every `player` state. */
421
+ roleStates?: Readonly<Record<string, XStateRoleStateStatus>>;
406
422
  /** Compose the player prompt. Default: continuation blocks + `<field>` placeholder substitution. */
407
- composePlayerPrompt?: (input: PlaybookPlayerInput) => string;
423
+ composePlayerPrompt?: (
424
+ input: PlaybookPlayerInput,
425
+ promptIdentity: XStatePromptIdentity,
426
+ ) => string;
408
427
  /** Compose the direct-Captain prompt. Default: continuation blocks + placeholder substitution with deterministic JSON rendering. */
409
428
  composeCaptainPrompt?: (input: PlaybookCaptainInput) => string;
410
429
  /** Linker-known exceptions to the default kebab-token → camel-field mapping. */
@@ -568,11 +587,54 @@ export function normalizeErrorFull(
568
587
  return normalizeError(err);
569
588
  }
570
589
 
590
+ // slc/link.md §Abort: cancellation is causal identity with the applicable
591
+ // signal's reason — never an `AbortError` name, never bare signal state. A
592
+ // distinct failure observed while the signal is aborted stays a non-abort
593
+ // control error and takes precedence (mirrors DECIDE's bespoke reference).
571
594
  function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
572
- return (
573
- signal.aborted &&
574
- (error === signal.reason || normalizeError(error).name === 'AbortError')
595
+ return signal.aborted && Object.is(error, signal.reason);
596
+ }
597
+
598
+ interface AbortReasonClassifier {
599
+ isAbortReason(error: unknown): boolean;
600
+ }
601
+
602
+ function abortReasonClassifier(
603
+ ...sources: readonly (AbortSignal | AbortReasonClassifier | undefined)[]
604
+ ): AbortReasonClassifier {
605
+ const captured = sources.filter(
606
+ (source): source is AbortSignal | AbortReasonClassifier =>
607
+ source !== undefined,
575
608
  );
609
+ return Object.freeze({
610
+ isAbortReason: (error: unknown): boolean =>
611
+ captured.some((source) =>
612
+ source instanceof AbortSignal
613
+ ? isAbortFailure(error, source)
614
+ : source.isAbortReason(error),
615
+ ),
616
+ });
617
+ }
618
+
619
+ /**
620
+ * gears2fsm's canonical Boss-reply wait state. On the runtime's Boss-facing
621
+ * surfaces — state telemetry, status lines, the exported snapshot, and the
622
+ * control view — a context question counts as *pending* only while the
623
+ * machine sits in this state awaiting the reply. Later states retain the
624
+ * answered question in context (the resumed player prompt is composed from
625
+ * it), so an unconditional projection would resurrect it: a failure the
626
+ * resumed player reached would export a question nobody is waiting on,
627
+ * disagreeing with the gated telemetry a mirroring host's ledger follows
628
+ * and failing the shell's snapshot-equality settlement check.
629
+ */
630
+ const BOSS_REPLY_WAIT_STATE_ID = 'awaitBossReply';
631
+
632
+ function pendingBossQuestionForState(
633
+ state: PlaybookState,
634
+ context: Record<string, unknown>,
635
+ ): PlaybookPendingBossQuestionContext | undefined {
636
+ if (state.stateId !== BOSS_REPLY_WAIT_STATE_ID) return undefined;
637
+ return pendingBossQuestionFromContext(context);
576
638
  }
577
639
 
578
640
  /** Read the FSM context's single pending Boss question, when well-formed. */
@@ -594,16 +656,34 @@ export function pendingBossQuestionFromContext(
594
656
  typeof candidate.questionId !== 'string' ||
595
657
  typeof candidate.resumeStateId !== 'string' ||
596
658
  typeof candidate.sourceItem !== 'string' ||
597
- typeof candidate.player !== 'string' ||
659
+ !isPlainObject(candidate.asker) ||
598
660
  typeof candidate.question !== 'string'
599
661
  ) {
600
662
  return undefined;
601
663
  }
664
+ let asker: PlaybookPendingBossQuestionContext['asker'];
665
+ if (candidate.asker.kind === 'captain') {
666
+ if (Object.keys(candidate.asker).some((key) => key !== 'kind')) {
667
+ return undefined;
668
+ }
669
+ asker = { kind: 'captain' };
670
+ } else if (
671
+ candidate.asker.kind === 'role' &&
672
+ typeof candidate.asker.roleId === 'string' &&
673
+ candidate.asker.roleId.trim().length > 0 &&
674
+ Object.keys(candidate.asker).every(
675
+ (key) => key === 'kind' || key === 'roleId',
676
+ )
677
+ ) {
678
+ asker = { kind: 'role', roleId: candidate.asker.roleId };
679
+ } else {
680
+ return undefined;
681
+ }
602
682
  return {
603
683
  questionId: candidate.questionId,
604
684
  resumeStateId: candidate.resumeStateId,
605
685
  sourceItem: candidate.sourceItem,
606
- player: candidate.player,
686
+ asker,
607
687
  question: candidate.question,
608
688
  };
609
689
  }
@@ -707,11 +787,6 @@ export function defaultComposeCaptainPrompt(
707
787
  return blocks.join('\n\n');
708
788
  }
709
789
 
710
- /** Default player binding: each player to its lowercased name. */
711
- export function defaultResolvePlayerId(input: PlaybookPlayerInput): string {
712
- return input.player.toLowerCase();
713
- }
714
-
715
790
  /**
716
791
  * Default required-field extraction (slc/link.md §Captain adjudication).
717
792
  * Limited to the description's `Output shall include` / `输出应包含` clause;
@@ -748,7 +823,7 @@ export function defaultBuildJudgePrompt(
748
823
  'and outcome descriptions. Reply with exactly one JSON object and no prose.',
749
824
  );
750
825
  lines.push('');
751
- lines.push(`The ${input.player} just produced this output:`);
826
+ lines.push(`The ${input.role} role just produced this output:`);
752
827
  lines.push('');
753
828
  lines.push('```');
754
829
  lines.push(finalText);
@@ -856,7 +931,8 @@ function validateBossReplyOutput(
856
931
 
857
932
  // ---------------------------------------------------------------------------
858
933
  // Delegated-player actor bridge. One PromiseActorLogic the machine invokes
859
- // from every player-invoking state: resolve the playerId, compose the prompt,
934
+ // from every player-invoking state: retain the role, resolve any bound player
935
+ // identity privately, compose the prompt through the ephemeral identity lookup,
860
936
  // await callPlayer, adjudicate the finalText. An `ok` result with a missing,
861
937
  // empty, or whitespace-only finalText earns exactly one corrective re-ask of
862
938
  // the same composed call (DR-028); a non-`ok` result, or a second such empty
@@ -867,8 +943,8 @@ function validateBossReplyOutput(
867
943
  // which only fires on actor.stop(), not on Boss abort.
868
944
  // ---------------------------------------------------------------------------
869
945
 
870
- export interface PlayerBridgeSpec {
871
- resolvePlayerId: (input: PlaybookPlayerInput) => string;
946
+ interface PlayerBridgeSpec {
947
+ resolveRoleId: (input: PlaybookPlayerInput) => string;
872
948
  composePlayerPrompt: (input: PlaybookPlayerInput) => string;
873
949
  adjudication: PlayerAdjudicationSpec;
874
950
  resumableStateIds: ReadonlySet<string>;
@@ -884,12 +960,26 @@ export function createPlayerBridge(
884
960
  return fromPromise<PlaybookActorOutput, PlaybookPlayerInput>(
885
961
  async ({ input, signal }) => {
886
962
  const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
887
- const playerId = spec.resolvePlayerId(input);
888
- const prompt = spec.composePlayerPrompt(input);
963
+ let roleId: string;
964
+ let prompt: string;
965
+ try {
966
+ roleId = spec.resolveRoleId(input);
967
+ prompt = spec.composePlayerPrompt(input);
968
+ } catch (error) {
969
+ if (!isAbortFailure(error, activeSignal)) {
970
+ onControlPlaneError?.(error);
971
+ }
972
+ throw error;
973
+ }
889
974
  const callPlayer = (resume: string | false) =>
890
975
  boundary
891
- ? boundary.callPlayer(input, playerId, prompt, activeSignal)
892
- : ports.callPlayer(playerId, prompt, activeSignal, { resume });
976
+ ? boundary.callPlayer(
977
+ input,
978
+ roleId,
979
+ prompt,
980
+ activeSignal,
981
+ )
982
+ : ports.callPlayer(roleId, prompt, activeSignal, { resume });
893
983
  let result = await callPlayer(false);
894
984
  if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
895
985
  // An abort that lands between the empty first result and the
@@ -936,7 +1026,9 @@ export function createPlayerBridge(
936
1026
  validateBossReplyOutput(input, output, spec.resumableStateIds);
937
1027
  return output;
938
1028
  } catch (error) {
939
- onControlPlaneError?.(error);
1029
+ if (!isAbortFailure(error, activeSignal)) {
1030
+ onControlPlaneError?.(error);
1031
+ }
940
1032
  throw error;
941
1033
  }
942
1034
  },
@@ -1063,10 +1155,10 @@ function collectInvokeSources(machine: AnyStateMachine): ReadonlySet<string> {
1063
1155
  return sources;
1064
1156
  }
1065
1157
 
1066
- function collectPlayerStatePlayers(
1158
+ function collectPlayerStateRoles(
1067
1159
  machine: AnyStateMachine,
1068
1160
  ): ReadonlyMap<string, string> {
1069
- const players = new Map<string, string>();
1161
+ const roles = new Map<string, string>();
1070
1162
  const visit = (stateDef: unknown, stateKey: string): void => {
1071
1163
  if (!isPlainObject(stateDef)) return;
1072
1164
  const invoke = stateDef.invoke;
@@ -1092,15 +1184,15 @@ function collectPlayerStatePlayers(
1092
1184
  'player state metadata must use a non-empty state id',
1093
1185
  );
1094
1186
  }
1095
- const player = isPlainObject(playbookMeta)
1096
- ? playbookMeta.player
1187
+ const role = isPlainObject(playbookMeta)
1188
+ ? playbookMeta.role
1097
1189
  : undefined;
1098
- if (typeof player !== 'string' || player.trim().length === 0) {
1190
+ if (typeof role !== 'string' || role.trim().length === 0) {
1099
1191
  throw new TypeError(
1100
- `player state ${stateId} meta.playbook.player must be a non-empty string`,
1192
+ `player state ${stateId} meta.playbook.role must be a non-empty string`,
1101
1193
  );
1102
1194
  }
1103
- players.set(stateId, player);
1195
+ roles.set(stateId, role);
1104
1196
  }
1105
1197
  if (isPlainObject(stateDef.states)) {
1106
1198
  for (const [childKey, child] of Object.entries(stateDef.states)) {
@@ -1114,7 +1206,7 @@ function collectPlayerStatePlayers(
1114
1206
  visit(stateDef, stateKey);
1115
1207
  }
1116
1208
  }
1117
- return players;
1209
+ return roles;
1118
1210
  }
1119
1211
 
1120
1212
  function transitionTargets(transition: unknown): string[] {
@@ -1138,7 +1230,7 @@ export function resumableStateIdsFromMachine(
1138
1230
  if (!isPlainObject(config) || !isPlainObject(config.states)) {
1139
1231
  return new Set();
1140
1232
  }
1141
- const awaitState = config.states.awaitBossReply;
1233
+ const awaitState = config.states[BOSS_REPLY_WAIT_STATE_ID];
1142
1234
  if (!isPlainObject(awaitState) || !isPlainObject(awaitState.on)) {
1143
1235
  return new Set();
1144
1236
  }
@@ -1245,6 +1337,43 @@ function deepFreeze<T>(value: T): T {
1245
1337
 
1246
1338
  const SUPPRESSED_ENTRY_STATES: ReadonlySet<string> = new Set(['ready', 'done']);
1247
1339
 
1340
+ // Bounded escalation for aborted script process groups: SIGTERM first, then
1341
+ // SIGKILL after this grace, so settlement (gated on the shell's own exit)
1342
+ // stays bounded even for TERM-immune commands.
1343
+ const SCRIPT_ABORT_KILL_GRACE_MS = 2000;
1344
+
1345
+ class ScriptProcessGroupTeardownError extends Error {
1346
+ constructor(
1347
+ pid: number,
1348
+ message: string,
1349
+ cause?: unknown,
1350
+ ) {
1351
+ super(
1352
+ `script process group ${pid} teardown could not be confirmed: ${message}`,
1353
+ cause === undefined ? undefined : { cause },
1354
+ );
1355
+ this.name = 'ScriptProcessGroupTeardownError';
1356
+ }
1357
+ }
1358
+
1359
+ function isNoSuchProcess(error: unknown): boolean {
1360
+ return (
1361
+ typeof error === 'object' &&
1362
+ error !== null &&
1363
+ 'code' in error &&
1364
+ (error as { code?: unknown }).code === 'ESRCH'
1365
+ );
1366
+ }
1367
+
1368
+ function isProcessPermissionDenied(error: unknown): boolean {
1369
+ return (
1370
+ typeof error === 'object' &&
1371
+ error !== null &&
1372
+ 'code' in error &&
1373
+ (error as { code?: unknown }).code === 'EPERM'
1374
+ );
1375
+ }
1376
+
1248
1377
  function makeDefaultNormalizeTransitionEvent(
1249
1378
  transitionEventFields: readonly string[],
1250
1379
  ): (event: unknown) => JsonValue {
@@ -1268,55 +1397,68 @@ function makeDefaultNormalizeTransitionEvent(
1268
1397
  };
1269
1398
  }
1270
1399
 
1271
- function snapshotPlayerStateStatuses(
1272
- value: Readonly<Record<string, XStatePlayerStateStatus>> | undefined,
1400
+ function snapshotRoleStateStatuses(
1401
+ value: unknown,
1273
1402
  label: string,
1274
1403
  machine: AnyStateMachine,
1275
1404
  stateDescriptions: ReadonlyMap<string, string>,
1276
- ): ReadonlyMap<string, XStatePlayerStateStatus> {
1277
- if (value === undefined) return new Map();
1278
- if (!isPlainObject(value)) {
1279
- throw new TypeError(`${label} playerStates must be an object`);
1405
+ ): ReadonlyMap<string, XStateRoleStateStatus> {
1406
+ if (value === undefined) {
1407
+ throw new TypeError(`${label} roleStates must be supplied for schema 2`);
1408
+ }
1409
+ const captured = snapshotJsonValue(value, `${label} roleStates`);
1410
+ if (!isPlainObject(captured)) {
1411
+ throw new TypeError(`${label} roleStates must be an object`);
1280
1412
  }
1281
- const declared = collectPlayerStatePlayers(machine);
1282
- const statuses = new Map<string, XStatePlayerStateStatus>();
1283
- for (const [stateId, candidate] of Object.entries(value)) {
1413
+ const declared = collectPlayerStateRoles(machine);
1414
+ const statuses = new Map<string, XStateRoleStateStatus>();
1415
+ for (const [stateId, candidate] of Object.entries(captured)) {
1284
1416
  if (!declared.has(stateId)) {
1285
1417
  throw new TypeError(
1286
- `${label} playerStates.${stateId} does not name a player state`,
1418
+ `${label} roleStates.${stateId} does not name a player state`,
1287
1419
  );
1288
1420
  }
1421
+ if (isPlainObject(candidate)) {
1422
+ const extra = Object.keys(candidate).find(
1423
+ (key) => key !== 'role' && key !== 'label',
1424
+ );
1425
+ if (extra !== undefined) {
1426
+ throw new TypeError(
1427
+ `${label} roleStates.${stateId}.${extra} is not allowed`,
1428
+ );
1429
+ }
1430
+ }
1289
1431
  if (
1290
1432
  !isPlainObject(candidate) ||
1291
- typeof candidate.player !== 'string' ||
1292
- candidate.player.trim().length === 0 ||
1433
+ typeof candidate.role !== 'string' ||
1434
+ candidate.role.trim().length === 0 ||
1293
1435
  typeof candidate.label !== 'string' ||
1294
1436
  candidate.label.trim().length === 0
1295
1437
  ) {
1296
1438
  throw new TypeError(
1297
- `${label} playerStates.${stateId} must carry non-empty player and label strings`,
1439
+ `${label} roleStates.${stateId} must carry non-empty role and label strings`,
1298
1440
  );
1299
1441
  }
1300
1442
  const expectedLabel = stateDescriptions.get(stateId);
1301
1443
  if (candidate.label !== expectedLabel) {
1302
1444
  throw new TypeError(
1303
- `${label} playerStates.${stateId}.label must equal its FSM description`,
1445
+ `${label} roleStates.${stateId}.label must equal its FSM description`,
1304
1446
  );
1305
1447
  }
1306
- if (candidate.player !== declared.get(stateId)) {
1448
+ if (candidate.role !== declared.get(stateId)) {
1307
1449
  throw new TypeError(
1308
- `${label} playerStates.${stateId}.player must equal its FSM player`,
1450
+ `${label} roleStates.${stateId}.role must equal its FSM role`,
1309
1451
  );
1310
1452
  }
1311
1453
  statuses.set(stateId, {
1312
- player: candidate.player,
1454
+ role: candidate.role,
1313
1455
  label: candidate.label,
1314
1456
  });
1315
1457
  }
1316
1458
  for (const stateId of declared.keys()) {
1317
1459
  if (!statuses.has(stateId)) {
1318
1460
  throw new TypeError(
1319
- `${label} playerStates must declare player state ${stateId}`,
1461
+ `${label} roleStates must declare player state ${stateId}`,
1320
1462
  );
1321
1463
  }
1322
1464
  }
@@ -1331,39 +1473,14 @@ function settlingGuard(event: unknown): string | undefined {
1331
1473
  : undefined;
1332
1474
  }
1333
1475
 
1334
- function legacyStatusesForState(
1335
- state: PlaybookState,
1336
- context: Record<string, unknown>,
1337
- ): ScheduledStatus[] {
1338
- const stateId = state.stateId;
1339
- if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) return [];
1340
- if (stateId === 'awaitBossReply') {
1341
- const pending = pendingBossQuestionFromContext(context);
1342
- return [
1343
- {
1344
- message:
1345
- pending === undefined
1346
- ? 'Awaiting Boss reply.'
1347
- : `${pending.player} asks: ${pending.question}`,
1348
- },
1349
- ];
1350
- }
1351
- if (stateId === 'failed') {
1352
- const lastError = normalizeErrorFull(context.lastError);
1353
- return [
1354
- {
1355
- message: 'Workflow failed; awaiting Boss recovery.',
1356
- ...(lastError === undefined
1357
- ? {}
1358
- : { data: snapshotJsonValue({ lastError }, 'failed status data') }),
1359
- },
1360
- ];
1361
- }
1362
- return [{ message: `Entered ${stateId}.` }];
1476
+ function askerLabel(
1477
+ asker: PlaybookPendingBossQuestionContext['asker'],
1478
+ ): string {
1479
+ return asker.kind === 'captain' ? 'Captain' : asker.roleId;
1363
1480
  }
1364
1481
 
1365
1482
  function makeDefaultStatusesForState(
1366
- playerStates: ReadonlyMap<string, XStatePlayerStateStatus>,
1483
+ roleStates: ReadonlyMap<string, XStateRoleStateStatus>,
1367
1484
  ): NonNullable<XStatePlaybookRuntimeSpec<unknown>['statusesForState']> {
1368
1485
  return (state, context, event): ScheduledStatus[] => {
1369
1486
  const statuses: ScheduledStatus[] = [];
@@ -1374,18 +1491,18 @@ function makeDefaultStatusesForState(
1374
1491
  if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
1375
1492
  return statuses;
1376
1493
  }
1377
- if (stateId === 'awaitBossReply') {
1494
+ if (stateId === BOSS_REPLY_WAIT_STATE_ID) {
1378
1495
  const pending = pendingBossQuestionFromContext(context);
1379
1496
  if (pending === undefined) {
1380
1497
  return [...statuses, { message: 'Awaiting Boss reply.' }];
1381
1498
  }
1382
1499
  return [
1383
1500
  ...statuses,
1384
- { message: `${pending.player} asks: ${pending.question}` },
1501
+ { message: `${askerLabel(pending.asker)} asks: ${pending.question}` },
1385
1502
  {
1386
1503
  message:
1387
1504
  `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
1388
- `${pending.player} · ${pending.sourceItem}`,
1505
+ `${askerLabel(pending.asker)} · ${pending.sourceItem}`,
1389
1506
  },
1390
1507
  ];
1391
1508
  }
@@ -1406,10 +1523,10 @@ function makeDefaultStatusesForState(
1406
1523
  },
1407
1524
  ];
1408
1525
  }
1409
- const playerState = playerStates.get(stateId);
1410
- if (playerState !== undefined) {
1526
+ const roleState = roleStates.get(stateId);
1527
+ if (roleState !== undefined) {
1411
1528
  statuses.push({
1412
- message: `⤷ ${playerState.player}: ${playerState.label}`,
1529
+ message: `⤷ ${roleState.role}: ${roleState.label}`,
1413
1530
  });
1414
1531
  }
1415
1532
  return statuses;
@@ -1654,7 +1771,15 @@ function makeDefaultClassifyBossText(
1654
1771
  const state = classifierState(snapshotOrState);
1655
1772
  const stateId = typeof state.value === 'string' ? state.value : undefined;
1656
1773
  const currentState = stateId ?? JSON.stringify(state.value ?? null);
1657
- const pending = pendingBossQuestionFromContext(state.context);
1774
+ // The classifier shares the reply-wait pendingness of every other
1775
+ // surface: outside the wait, a context question a later state retains
1776
+ // is answered history, so the prompt must not present it as pending —
1777
+ // a judge told a question awaits at the failure state is steered toward
1778
+ // a reply it cannot select or toward no action at all.
1779
+ const pending =
1780
+ stateId === BOSS_REPLY_WAIT_STATE_ID
1781
+ ? pendingBossQuestionFromContext(state.context)
1782
+ : undefined;
1658
1783
  const configuredTypes = configuredEventTypesForState(machine, stateId);
1659
1784
  const applicable = [...contracts.values()].filter(
1660
1785
  (contract) =>
@@ -1672,7 +1797,7 @@ function makeDefaultClassifyBossText(
1672
1797
  if (pending !== undefined) {
1673
1798
  lines.push(
1674
1799
  `Pending question id: ${pending.questionId}`,
1675
- `Pending asking player: ${pending.player}`,
1800
+ `Pending asker: ${askerLabel(pending.asker)}`,
1676
1801
  `Pending Boss question: ${pending.question}`,
1677
1802
  );
1678
1803
  }
@@ -1827,6 +1952,74 @@ function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
1827
1952
  return visit((machine as unknown as { config?: unknown }).config);
1828
1953
  }
1829
1954
 
1955
+ // PBRT-52: the factory's domain is FLAT single-region machines — every
1956
+ // state a direct child of the root, so each snapshot exposes exactly one
1957
+ // playbook state id and every state-keyed lookup (deterministic entries,
1958
+ // retry, reply-wait pendingness, configured events, descriptions) indexes
1959
+ // one unambiguous identity. A compound child would be accepted and then
1960
+ // silently misbehave on all of those gates, so it is rejected up front
1961
+ // exactly like a parallel region.
1962
+ function machineDeclaresNestedState(machine: AnyStateMachine): boolean {
1963
+ const config = (machine as unknown as { config?: unknown }).config;
1964
+ if (!isPlainObject(config) || !isPlainObject(config.states)) return false;
1965
+ return Object.values(config.states).some(
1966
+ (stateDef) =>
1967
+ isPlainObject(stateDef) &&
1968
+ isPlainObject(stateDef.states) &&
1969
+ Object.keys(stateDef.states).length > 0,
1970
+ );
1971
+ }
1972
+
1973
+ // PBRT-52: the factory's lookups index states by their root key, and the
1974
+ // published playbook identity is `meta.playbook.stateId` — the two must
1975
+ // coincide or a machine can advertise a pending question or retry under an
1976
+ // identity no lookup resolves. A state with no string stateId is just as
1977
+ // dead: every snapshot identity derives from that member, so the first
1978
+ // entry would fail the exactly-one-state-id inspection at runtime.
1979
+ // gears2fsm keeps identity and key equal by construction; a hand-authored
1980
+ // artifact that splits or omits them fails here instead of at a silently
1981
+ // dead gate.
1982
+ function assertFlatStateIdentity(
1983
+ machine: AnyStateMachine,
1984
+ label: string,
1985
+ ): void {
1986
+ const config = (machine as unknown as { config?: unknown }).config;
1987
+ const states =
1988
+ isPlainObject(config) && isPlainObject(config.states)
1989
+ ? config.states
1990
+ : undefined;
1991
+ // A machine with no root states has no playbook identity to expose; its
1992
+ // first snapshot would fail the exactly-one-state-id inspection, so it
1993
+ // fails construction with the defect named instead.
1994
+ if (states === undefined || Object.keys(states).length === 0) {
1995
+ throw new Error(
1996
+ `${label} declares no root states; the shared runtime requires at ` +
1997
+ 'least one flat playbook state',
1998
+ );
1999
+ }
2000
+ for (const [key, stateDef] of Object.entries(states)) {
2001
+ if (!isPlainObject(stateDef)) continue;
2002
+ const meta = isPlainObject(stateDef.meta) ? stateDef.meta : undefined;
2003
+ const playbook =
2004
+ meta !== undefined && isPlainObject(meta.playbook)
2005
+ ? meta.playbook
2006
+ : undefined;
2007
+ const stateId = playbook?.stateId;
2008
+ if (typeof stateId !== 'string') {
2009
+ throw new Error(
2010
+ `${label} state ${key} declares no string meta.playbook.stateId; ` +
2011
+ 'the shared runtime derives every playbook state identity from it',
2012
+ );
2013
+ }
2014
+ if (stateId !== key) {
2015
+ throw new Error(
2016
+ `${label} state ${key} declares meta.playbook.stateId ${stateId}; ` +
2017
+ 'the shared runtime requires the playbook state id to equal the state key',
2018
+ );
2019
+ }
2020
+ }
2021
+ }
2022
+
1830
2023
  /**
1831
2024
  * Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
1832
2025
  * under the slc/link.md contract. The factory provides every actor kind the
@@ -1834,9 +2027,10 @@ function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
1834
2027
  * (literal and dynamic) — and implements the full runtime lifecycle including
1835
2028
  * the optional parked-session snapshot capability (DR-014).
1836
2029
  *
1837
- * Scope: machines that declare no parallel state (each snapshot exposes
1838
- * exactly one playbook state id). Parallel-region FSMs keep their own linked
1839
- * runtimes.
2030
+ * Scope: flat single-region machines no parallel state, no compound
2031
+ * child states, and every root state's `meta.playbook.stateId` equal to its
2032
+ * state key — so each snapshot exposes exactly one playbook state id.
2033
+ * Parallel-region FSMs keep their own linked runtimes.
1840
2034
  */
1841
2035
  export function createXStatePlaybookRuntime<TOptions>(
1842
2036
  machine: AnyStateMachine,
@@ -1846,24 +2040,50 @@ export function createXStatePlaybookRuntime<TOptions>(
1846
2040
  // DR-022 / PBRT-50: reject an incompatible artifact declaration before any
1847
2041
  // machine interpretation, against this loaded engine's own self-report.
1848
2042
  assertRuntimeCompat(spec.compat, label);
2043
+ const specDescriptors = Object.getOwnPropertyDescriptors(spec);
2044
+ if (Object.prototype.hasOwnProperty.call(specDescriptors, 'playerStates')) {
2045
+ throw new TypeError(
2046
+ `${label} schema-2 artifacts must supply roleStates, not playerStates`,
2047
+ );
2048
+ }
2049
+ if (Object.prototype.hasOwnProperty.call(specDescriptors, 'resolvePlayerId')) {
2050
+ throw new TypeError(
2051
+ `${label} schema-2 artifacts must not derive concrete player bindings`,
2052
+ );
2053
+ }
1849
2054
  if (machineDeclaresParallelState(machine)) {
1850
2055
  throw new Error(
1851
2056
  `${label} uses a parallel state; the shared runtime supports only single-region FSMs`,
1852
2057
  );
1853
2058
  }
2059
+ if (machineDeclaresNestedState(machine)) {
2060
+ throw new Error(
2061
+ `${label} declares a compound state; the shared runtime supports only flat single-region FSMs`,
2062
+ );
2063
+ }
2064
+ assertFlatStateIdentity(machine, label);
1854
2065
  const declaredActors = collectInvokeSources(machine);
1855
2066
  const resumableStateIds =
1856
2067
  spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
1857
2068
  // DR-029: source state descriptions label the control actions the
1858
2069
  // runtime advertises through `describe()`.
1859
2070
  const stateDescriptions = stateDescriptionsFromMachine(machine);
1860
- const hasCanonicalStatusProfile = spec.playerStates !== undefined;
1861
- const playerStates = snapshotPlayerStateStatuses(
1862
- spec.playerStates,
2071
+ const roleStatesDescriptor = specDescriptors.roleStates;
2072
+ if (
2073
+ roleStatesDescriptor !== undefined &&
2074
+ !Object.prototype.hasOwnProperty.call(roleStatesDescriptor, 'value')
2075
+ ) {
2076
+ throw new TypeError(`${label} roleStates must be an own data property`);
2077
+ }
2078
+ const roleStates = snapshotRoleStateStatuses(
2079
+ roleStatesDescriptor?.value,
1863
2080
  label,
1864
2081
  machine,
1865
2082
  stateDescriptions,
1866
2083
  );
2084
+ const declaredRoleIds = Object.freeze([
2085
+ ...new Set([...roleStates.values()].map(({ role }) => role)),
2086
+ ]);
1867
2087
  // PBRT-52: the artifact's own ControlView context projection. Nothing is
1868
2088
  // exported by default, so an FSM context member — including one added
1869
2089
  // after this artifact was linked — is private until named here. The two
@@ -1880,7 +2100,6 @@ export function createXStatePlaybookRuntime<TOptions>(
1880
2100
  );
1881
2101
  }
1882
2102
  }
1883
- const resolvePlayerIdSpec = spec.resolvePlayerId;
1884
2103
  const composePlayerPrompt =
1885
2104
  spec.composePlayerPrompt ??
1886
2105
  ((input: PlaybookPlayerInput) =>
@@ -1919,14 +2138,10 @@ export function createXStatePlaybookRuntime<TOptions>(
1919
2138
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
1920
2139
  const statusesForState =
1921
2140
  spec.statusesForState ??
1922
- (hasCanonicalStatusProfile
1923
- ? makeDefaultStatusesForState(playerStates)
1924
- : legacyStatusesForState);
2141
+ makeDefaultStatusesForState(roleStates);
1925
2142
  const classificationStatus =
1926
2143
  spec.classificationStatus ??
1927
- (hasCanonicalStatusProfile
1928
- ? (event: EventObject) => event.type
1929
- : () => undefined);
2144
+ ((event: EventObject) => event.type);
1930
2145
  const machineInput =
1931
2146
  spec.machineInput ?? ((options: TOptions) => options as unknown);
1932
2147
  const scriptCwd =
@@ -1951,6 +2166,20 @@ export function createXStatePlaybookRuntime<TOptions>(
1951
2166
  // ports.callPlayer / callCaptain / callJudge see the right cancellation
1952
2167
  // source. undefined between turns; set by the public boundaries.
1953
2168
  let activeSignal: AbortSignal | undefined;
2169
+ // Immutable cancellation provenance for the active public boundary. A
2170
+ // nested resume widens it to include both invocation and resume signals;
2171
+ // mutable `activeSignal` alone cannot classify a late invocation reason.
2172
+ let activeAborts: AbortReasonClassifier | undefined;
2173
+ // The bridge binds the provenance of a child result immediately before
2174
+ // its promise actor settles. The next root snapshot/error consumes this
2175
+ // one-shot so background settlement emissions retain their owner.
2176
+ let actorSettlementAborts: AbortReasonClassifier | undefined;
2177
+ let actorSettlementErrorAborts: AbortReasonClassifier | undefined;
2178
+ // Exact cancellation observed by an emission owned by the active
2179
+ // boundary. Ordinary runs settle from their signal/state; apply also
2180
+ // needs this phase-local evidence to fold a pre-publication failure into
2181
+ // its accepted receipt.
2182
+ let activeAbortEmission: unknown;
1954
2183
  let activeTurnId: number | undefined;
1955
2184
  let controlPlaneError: unknown;
1956
2185
  // Previous root-machine state for the inspect-driven telemetry /
@@ -1977,8 +2206,8 @@ export function createXStatePlaybookRuntime<TOptions>(
1977
2206
  // acceptance records nothing, so a later call with that key may still
1978
2207
  // execute.
1979
2208
  const appliedReceipts = new Map<string, PlaybookControlReceipt>();
1980
- const playerResumeTokens = new Map<string, string>();
1981
- const activePlayerIds = new Set<string>();
2209
+ const privateResumeTokens = new Map<string, string>();
2210
+ const activePlayerKeys = new Set<string>();
1982
2211
  const playbookCallTurnIds = new Map<string, number | undefined>();
1983
2212
  // Captain and judge work share one serialized lane (slc/link.md
1984
2213
  // §Session lifecycle).
@@ -1989,87 +2218,229 @@ export function createXStatePlaybookRuntime<TOptions>(
1989
2218
  // All trace, state-telemetry, and status work shares this one queue.
1990
2219
  // Inspection callbacks enqueue a complete ordered batch synchronously;
1991
2220
  // imperative boundaries await their queued work directly.
1992
- let emissionFailure: unknown;
2221
+ let emissionFailure: { readonly error: unknown } | undefined;
2222
+
2223
+ function bindSession(nextSession: PlaybookSession): PlaybookSession {
2224
+ const bound = snapshotPlaybookSession(nextSession);
2225
+ if (bound.roleBindings === undefined) return bound;
2226
+ const actual = Object.keys(bound.roleBindings).sort();
2227
+ const expected = [...declaredRoleIds].sort();
2228
+ const missing = expected.filter((roleId) => !actual.includes(roleId));
2229
+ const extra = actual.filter((roleId) => !expected.includes(roleId));
2230
+ if (missing.length > 0 || extra.length > 0) {
2231
+ throw new TypeError(
2232
+ `${label} session roleBindings must cover exactly [${expected.join(', ')}]` +
2233
+ `${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
2234
+ `${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`,
2235
+ );
2236
+ }
2237
+ return bound;
2238
+ }
2239
+
2240
+ function requireRoleId(input: PlaybookPlayerInput): string {
2241
+ const roleId = input.role;
2242
+ if (
2243
+ typeof roleId !== 'string' ||
2244
+ roleId.trim().length === 0 ||
2245
+ !declaredRoleIds.includes(roleId)
2246
+ ) {
2247
+ throw new TypeError(
2248
+ `${label} player input role must name a declared local role`,
2249
+ );
2250
+ }
2251
+ return roleId;
2252
+ }
2253
+
2254
+ function resolvedPlayerId(roleId: string): string | undefined {
2255
+ return session?.roleBindings?.[roleId]?.playerId;
2256
+ }
2257
+
2258
+ function promptIdentity(roleId: string): string {
2259
+ if (!declaredRoleIds.includes(roleId)) {
2260
+ throw new TypeError(
2261
+ `${label} prompt identity lookup rejected undeclared role ${roleId}`,
2262
+ );
2263
+ }
2264
+ return session?.roleBindings?.[roleId]?.promptIdentity ?? roleId;
2265
+ }
2266
+
2267
+ function composeBoundPlayerPrompt(input: PlaybookPlayerInput): string {
2268
+ let active = true;
2269
+ const lookup: XStatePromptIdentity = (roleId) => {
2270
+ if (!active) {
2271
+ throw new Error(
2272
+ `${label} prompt identity lookup is no longer active`,
2273
+ );
2274
+ }
2275
+ return promptIdentity(roleId);
2276
+ };
2277
+ try {
2278
+ return composePlayerPrompt(input, lookup);
2279
+ } finally {
2280
+ active = false;
2281
+ }
2282
+ }
1993
2283
 
1994
- function selectPlayerResume(playerId: string): string | false {
2284
+ function continuationKey(
2285
+ roleId: string,
2286
+ playerId: string | undefined,
2287
+ ): string {
2288
+ return playerId ?? roleId;
2289
+ }
2290
+
2291
+ function roleTokensByContinuationKey(
2292
+ tokens: Readonly<Record<string, string>>,
2293
+ ): Map<string, string> {
2294
+ const byKey = new Map<string, string>();
2295
+ for (const [roleId, token] of Object.entries(tokens)) {
2296
+ if (!declaredRoleIds.includes(roleId)) {
2297
+ throw new TypeError(
2298
+ `runtime role tokens contain unknown role ${roleId}`,
2299
+ );
2300
+ }
2301
+ const key = continuationKey(roleId, resolvedPlayerId(roleId));
2302
+ const existing = byKey.get(key);
2303
+ if (existing !== undefined && existing !== token) {
2304
+ throw new TypeError(
2305
+ `runtime snapshot assigns conflicting tokens to roles bound to player ${key}`,
2306
+ );
2307
+ }
2308
+ byKey.set(key, token);
2309
+ }
2310
+ const rolesByKey = new Map<string, string[]>();
2311
+ for (const roleId of declaredRoleIds) {
2312
+ const key = continuationKey(roleId, resolvedPlayerId(roleId));
2313
+ rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
2314
+ }
2315
+ for (const [key, roles] of rolesByKey) {
2316
+ if (roles.length < 2) continue;
2317
+ const present = roles.filter((roleId) => tokens[roleId] !== undefined);
2318
+ if (present.length !== 0 && present.length !== roles.length) {
2319
+ throw new TypeError(
2320
+ `runtime role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`,
2321
+ );
2322
+ }
2323
+ }
2324
+ return byKey;
2325
+ }
2326
+
2327
+ function selectPlayerResume(
2328
+ roleId: string,
2329
+ playerId: string | undefined,
2330
+ ): string | false {
2331
+ const key = continuationKey(roleId, playerId);
1995
2332
  const selected = session?.playerSessions
1996
- ? session.playerSessions.select(playerId)
1997
- : playerResumeTokens.get(playerId) ?? false;
2333
+ ? session.playerSessions.select(roleId)
2334
+ : privateResumeTokens.get(key) ?? false;
1998
2335
  if (
1999
2336
  selected !== false &&
2000
2337
  (typeof selected !== 'string' || selected.trim().length === 0)
2001
2338
  ) {
2002
2339
  throw new TypeError(
2003
- `player session store returned an invalid resume token for ${playerId}`,
2340
+ `player session store returned an invalid resume token for role ${roleId}`,
2004
2341
  );
2005
2342
  }
2006
2343
  return selected;
2007
2344
  }
2008
2345
 
2009
2346
  function updatePlayerResume(
2010
- playerId: string,
2011
- resumeToken: string | undefined,
2347
+ roleId: string,
2348
+ playerId: string | undefined,
2349
+ result: PlayerResult,
2012
2350
  ): void {
2351
+ const resumeToken = result.resumeToken;
2352
+ if (resumeToken === undefined && result.status !== 'ok') return;
2353
+ const key = continuationKey(roleId, playerId);
2013
2354
  if (session?.playerSessions) {
2014
- session.playerSessions.update(playerId, resumeToken);
2015
- } else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
2016
- playerResumeTokens.set(playerId, resumeToken);
2355
+ session.playerSessions.update(roleId, resumeToken);
2356
+ } else if (resumeToken !== undefined) {
2357
+ privateResumeTokens.set(key, resumeToken);
2017
2358
  } else {
2018
- playerResumeTokens.delete(playerId);
2359
+ privateResumeTokens.delete(key);
2019
2360
  }
2020
2361
  }
2021
2362
 
2022
- function snapshotPlayerResumeTokens(): Record<string, string> {
2023
- const raw = session?.playerSessions
2024
- ? session.playerSessions.snapshot()
2025
- : Object.fromEntries(playerResumeTokens);
2363
+ function snapshotRoleResumeTokens(): Record<string, string> {
2364
+ const raw = snapshotJsonValue(
2365
+ session?.playerSessions
2366
+ ? session.playerSessions.snapshot()
2367
+ : Object.fromEntries(
2368
+ declaredRoleIds.flatMap((roleId) => {
2369
+ const token = privateResumeTokens.get(
2370
+ continuationKey(roleId, resolvedPlayerId(roleId)),
2371
+ );
2372
+ return token === undefined ? [] : [[roleId, token]];
2373
+ }),
2374
+ ),
2375
+ 'player session store snapshot',
2376
+ );
2026
2377
  if (!isPlainObject(raw)) {
2027
2378
  throw new TypeError('player session store snapshot must be an object');
2028
2379
  }
2029
2380
  const detached: Record<string, string> = {};
2030
- for (const [playerId, token] of Object.entries(raw)) {
2031
- if (playerId.trim().length === 0) {
2381
+ for (const [roleId, token] of Object.entries(raw)) {
2382
+ if (!declaredRoleIds.includes(roleId)) {
2032
2383
  throw new TypeError(
2033
- 'player session store snapshot player ids must be non-empty',
2384
+ `player session store snapshot contains unknown role ${roleId}`,
2034
2385
  );
2035
2386
  }
2036
2387
  if (typeof token !== 'string' || token.trim().length === 0) {
2037
2388
  throw new TypeError(
2038
- `player session store snapshot token for ${playerId} must be a non-empty string`,
2389
+ `player session store snapshot token for ${roleId} must be a non-empty string`,
2039
2390
  );
2040
2391
  }
2041
- detached[playerId] = token;
2392
+ detached[roleId] = token;
2042
2393
  }
2394
+ roleTokensByContinuationKey(detached);
2043
2395
  return detached;
2044
2396
  }
2045
2397
 
2046
- function restorePlayerResumeTokens(
2398
+ function restoreRoleResumeTokens(
2047
2399
  tokens: Readonly<Record<string, string>>,
2048
2400
  ): void {
2401
+ const byKey = roleTokensByContinuationKey(tokens);
2049
2402
  if (session?.playerSessions) {
2050
2403
  session.playerSessions.restore(tokens);
2051
2404
  return;
2052
2405
  }
2053
- playerResumeTokens.clear();
2054
- for (const [playerId, token] of Object.entries(tokens)) {
2055
- playerResumeTokens.set(playerId, token);
2056
- }
2406
+ privateResumeTokens.clear();
2407
+ for (const [key, token] of byKey) privateResumeTokens.set(key, token);
2057
2408
  }
2058
2409
 
2059
- function enqueueEmission(fn: () => Promise<void>): Promise<void> {
2410
+ function enqueueEmission(
2411
+ fn: () => Promise<void>,
2412
+ aborts: AbortReasonClassifier | undefined = activeAborts,
2413
+ ): Promise<void> {
2414
+ // The emission belongs to the boundary enqueueing it: a rejection
2415
+ // causally identical to that boundary's abort reason is the
2416
+ // cancellation's own evidence — never latched, so it cannot poison a
2417
+ // later unrelated boundary (DR-036).
2418
+ const enqueueAborts = aborts;
2060
2419
  const queued = emissionQueue.add(fn).then(() => undefined);
2061
2420
  activeEmissionCalls.add(queued);
2062
2421
  void queued.then(
2063
2422
  () => activeEmissionCalls.delete(queued),
2064
2423
  (error: unknown) => {
2065
2424
  activeEmissionCalls.delete(queued);
2066
- emissionFailure ??= error;
2425
+ if (enqueueAborts?.isAbortReason(error)) {
2426
+ // Record evidence only when it also belongs to the public
2427
+ // boundary that is still active. A background A cancellation
2428
+ // racing an unrelated B boundary is forgiven under A and must
2429
+ // not change B's settlement.
2430
+ if (activeAborts?.isAbortReason(error)) {
2431
+ activeAbortEmission ??= error;
2432
+ }
2433
+ return;
2434
+ }
2435
+ emissionFailure ??= { error };
2067
2436
  },
2068
2437
  );
2069
2438
  return queued;
2070
2439
  }
2071
2440
 
2072
- async function drainEmissions(): Promise<void> {
2441
+ async function drainEmissions(
2442
+ _aborts: AbortReasonClassifier | undefined = activeAborts,
2443
+ ): Promise<void> {
2073
2444
  while (true) {
2074
2445
  const active = [...activeEmissionCalls];
2075
2446
  if (active.length > 0) await Promise.allSettled(active);
@@ -2083,8 +2454,13 @@ export function createXStatePlaybookRuntime<TOptions>(
2083
2454
  }
2084
2455
  }
2085
2456
  if (emissionFailure !== undefined) {
2086
- const error = emissionFailure;
2457
+ const { error } = emissionFailure;
2087
2458
  emissionFailure = undefined;
2459
+ // The failure was classified as distinct by its enqueue owner. If a
2460
+ // later public boundary drains it, retain that classification in the
2461
+ // boundary latch before throwing; its signal must not reinterpret
2462
+ // the same object as cancellation (DR-036 decision 2).
2463
+ if (activeSignal !== undefined) controlPlaneError ??= error;
2088
2464
  throw error;
2089
2465
  }
2090
2466
  }
@@ -2111,7 +2487,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2111
2487
  const currentSession = requireSession();
2112
2488
  const safePayload = snapshotJsonValue(payload, `trace ${type} payload`);
2113
2489
  return {
2114
- schemaVersion: 2,
2490
+ schemaVersion: 3,
2115
2491
  sessionId: currentSession.sessionId,
2116
2492
  playbookId: currentSession.playbookId,
2117
2493
  rootSessionId: currentSession.rootSessionId,
@@ -2135,14 +2511,17 @@ export function createXStatePlaybookRuntime<TOptions>(
2135
2511
  type: PlaybookTraceType,
2136
2512
  payload: unknown,
2137
2513
  position: TracePosition = {},
2514
+ aborts?: AbortReasonClassifier,
2138
2515
  ): Promise<void> {
2139
2516
  const currentSession = requireSession();
2140
2517
  const event = createTraceEvent(type, payload, position);
2141
- return enqueueEmission(() =>
2142
- currentSession.ports.emitTelemetry({
2143
- topic: 'playbook.trace',
2144
- payload: event,
2145
- }),
2518
+ return enqueueEmission(
2519
+ () =>
2520
+ currentSession.ports.emitTelemetry({
2521
+ topic: 'playbook.trace',
2522
+ payload: event,
2523
+ }),
2524
+ aborts,
2146
2525
  );
2147
2526
  }
2148
2527
 
@@ -2230,6 +2609,11 @@ export function createXStatePlaybookRuntime<TOptions>(
2230
2609
  | 'apply.finished',
2231
2610
  identity: Record<string, unknown>,
2232
2611
  position: TracePosition,
2612
+ // The applicable combined signal: a start-sink rejection causally
2613
+ // identical to its reason is the cancellation itself, not a control
2614
+ // error — the pair finishes `aborted` and nothing latches
2615
+ // (slc/link.md §Abort).
2616
+ signal: AbortSignal,
2233
2617
  // Base payload of the best-effort finish emitted when the start sink
2234
2618
  // rejects; it defaults to the payload the start carried, which the
2235
2619
  // player, judge, and captain pairs take as-is. The apply pair cannot:
@@ -2241,13 +2625,13 @@ export function createXStatePlaybookRuntime<TOptions>(
2241
2625
  try {
2242
2626
  await emitTrace(startedType, identity, position);
2243
2627
  } catch (error) {
2244
- controlPlaneError ??= error;
2628
+ if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
2245
2629
  try {
2246
2630
  await emitTrace(
2247
2631
  finishedType,
2248
2632
  {
2249
2633
  ...finishIdentity,
2250
- status: 'error',
2634
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
2251
2635
  error: normalizeError(error),
2252
2636
  },
2253
2637
  position,
@@ -2260,25 +2644,31 @@ export function createXStatePlaybookRuntime<TOptions>(
2260
2644
  }
2261
2645
 
2262
2646
  const boundary: RuntimeBoundaryCalls = {
2263
- async callPlayer(input, playerId, prompt, signal): Promise<PlayerResult> {
2647
+ async callPlayer(
2648
+ input,
2649
+ roleId,
2650
+ prompt,
2651
+ signal,
2652
+ ): Promise<PlayerResult> {
2264
2653
  // State-entry telemetry/status must precede the call they describe.
2265
2654
  await drainEmissions();
2266
2655
  const turnId = activeTurnId;
2267
2656
  const stateId = input.stateId;
2657
+ const playerId = resolvedPlayerId(roleId);
2268
2658
  let resume: string | false;
2269
2659
  try {
2270
2660
  signal.throwIfAborted();
2271
- resume = selectPlayerResume(playerId);
2661
+ resume = selectPlayerResume(roleId, playerId);
2272
2662
  } catch (error) {
2273
- if (!signal.aborted) controlPlaneError ??= error;
2663
+ if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
2274
2664
  throw error;
2275
2665
  }
2276
2666
  const callId = `player-${++playerCallSequence}`;
2277
2667
  const identity = {
2278
- purpose: 'captain' as const,
2279
2668
  ...stateIdentity(stateId),
2280
2669
  sourceItem: input.sourceItem,
2281
- playerId,
2670
+ roleId,
2671
+ ...(playerId === undefined ? {} : { playerId }),
2282
2672
  resume,
2283
2673
  };
2284
2674
  const position: TracePosition = {
@@ -2286,15 +2676,17 @@ export function createXStatePlaybookRuntime<TOptions>(
2286
2676
  callId,
2287
2677
  };
2288
2678
 
2289
- if (activePlayerIds.has(playerId)) {
2679
+ const playerKey = continuationKey(roleId, playerId);
2680
+ if (activePlayerKeys.has(playerKey)) {
2290
2681
  const error = new Error(
2291
- `simultaneous calls to resolved player ${playerId} are not allowed`,
2682
+ `simultaneous calls to player key ${playerKey} are not allowed`,
2292
2683
  );
2293
2684
  await emitCallStarted(
2294
2685
  'player.call.started',
2295
2686
  'player.call.finished',
2296
2687
  { ...identity, prompt },
2297
2688
  position,
2689
+ signal,
2298
2690
  );
2299
2691
  await emitTrace(
2300
2692
  'player.call.finished',
@@ -2303,13 +2695,15 @@ export function createXStatePlaybookRuntime<TOptions>(
2303
2695
  );
2304
2696
  throw error;
2305
2697
  }
2306
- activePlayerIds.add(playerId);
2698
+ activePlayerKeys.add(playerKey);
2307
2699
 
2308
2700
  try {
2309
- await emitTrace(
2701
+ await emitCallStarted(
2310
2702
  'player.call.started',
2703
+ 'player.call.finished',
2311
2704
  { ...identity, prompt },
2312
2705
  position,
2706
+ signal,
2313
2707
  );
2314
2708
 
2315
2709
  let rawResult: unknown;
@@ -2320,7 +2714,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2320
2714
  // as `aborted` through the catch below.
2321
2715
  signal.throwIfAborted();
2322
2716
  rawResult = await requireHostPorts().callPlayer(
2323
- playerId,
2717
+ roleId,
2324
2718
  prompt,
2325
2719
  signal,
2326
2720
  { resume },
@@ -2329,13 +2723,13 @@ export function createXStatePlaybookRuntime<TOptions>(
2329
2723
  // a late result mutate continuity or publish a successful finish.
2330
2724
  signal.throwIfAborted();
2331
2725
  } catch (error) {
2332
- if (!signal.aborted) controlPlaneError ??= error;
2726
+ if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
2333
2727
  try {
2334
2728
  await emitTrace(
2335
2729
  'player.call.finished',
2336
2730
  {
2337
2731
  ...identity,
2338
- status: signal.aborted ? 'aborted' : 'error',
2732
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
2339
2733
  error: normalizeError(error),
2340
2734
  },
2341
2735
  position,
@@ -2352,7 +2746,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2352
2746
  try {
2353
2747
  result = validatePlayerResult(rawResult);
2354
2748
  } catch (error) {
2355
- if (!signal.aborted) controlPlaneError ??= error;
2749
+ if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
2356
2750
  try {
2357
2751
  await emitTrace(
2358
2752
  'player.call.finished',
@@ -2366,15 +2760,9 @@ export function createXStatePlaybookRuntime<TOptions>(
2366
2760
  }
2367
2761
 
2368
2762
  try {
2369
- updatePlayerResume(
2370
- playerId,
2371
- typeof result.resumeToken === 'string' &&
2372
- result.resumeToken.trim().length > 0
2373
- ? result.resumeToken
2374
- : undefined,
2375
- );
2763
+ updatePlayerResume(roleId, playerId, result);
2376
2764
  } catch (error) {
2377
- if (!signal.aborted) controlPlaneError ??= error;
2765
+ if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
2378
2766
  try {
2379
2767
  await emitTrace(
2380
2768
  'player.call.finished',
@@ -2406,7 +2794,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2406
2794
  );
2407
2795
  return result;
2408
2796
  } finally {
2409
- activePlayerIds.delete(playerId);
2797
+ activePlayerKeys.delete(playerKey);
2410
2798
  }
2411
2799
  },
2412
2800
 
@@ -2430,6 +2818,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2430
2818
  'judge.call.finished',
2431
2819
  { ...identity, prompt },
2432
2820
  position,
2821
+ signal,
2433
2822
  );
2434
2823
  let reply: unknown;
2435
2824
  try {
@@ -2448,7 +2837,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2448
2837
  'judge.call.finished',
2449
2838
  {
2450
2839
  ...identity,
2451
- status: signal.aborted ? 'aborted' : 'error',
2840
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
2452
2841
  error: normalizeError(error),
2453
2842
  },
2454
2843
  position,
@@ -2512,6 +2901,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2512
2901
  'captain.call.finished',
2513
2902
  { ...identity, prompt },
2514
2903
  position,
2904
+ signal,
2515
2905
  );
2516
2906
  let rawResult: unknown;
2517
2907
  try {
@@ -2534,7 +2924,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2534
2924
  'captain.call.finished',
2535
2925
  {
2536
2926
  ...identity,
2537
- status: signal.aborted ? 'aborted' : 'error',
2927
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
2538
2928
  error: normalizeError(error),
2539
2929
  },
2540
2930
  position,
@@ -2611,19 +3001,13 @@ export function createXStatePlaybookRuntime<TOptions>(
2611
3001
  },
2612
3002
  };
2613
3003
 
2614
- function resolvePlayerId(input: PlaybookPlayerInput): string {
2615
- return resolvePlayerIdSpec
2616
- ? resolvePlayerIdSpec(input, boundOptions)
2617
- : defaultResolvePlayerId(input);
2618
- }
2619
-
2620
3004
  function playerActor(
2621
3005
  ports: PlaybookPorts,
2622
3006
  ): PromiseActorLogic<PlaybookActorOutput, PlaybookPlayerInput> {
2623
3007
  return createPlayerBridge(
2624
3008
  {
2625
- resolvePlayerId,
2626
- composePlayerPrompt,
3009
+ resolveRoleId: requireRoleId,
3010
+ composePlayerPrompt: composeBoundPlayerPrompt,
2627
3011
  adjudication,
2628
3012
  resumableStateIds,
2629
3013
  },
@@ -2631,7 +3015,9 @@ export function createXStatePlaybookRuntime<TOptions>(
2631
3015
  () => activeSignal,
2632
3016
  boundary,
2633
3017
  (error) => {
2634
- if (!activeSignal?.aborted) controlPlaneError ??= error;
3018
+ if (activeSignal === undefined || !isAbortFailure(error, activeSignal)) {
3019
+ controlPlaneError ??= error;
3020
+ }
2635
3021
  },
2636
3022
  );
2637
3023
  }
@@ -2717,7 +3103,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2717
3103
  // failure state (PBRT-47); everything else here — a drained
2718
3104
  // emission failure, prompt composition, the port itself,
2719
3105
  // adjudication — is control plane.
2720
- if (!active.aborted && !isFsmResultFailure(error)) {
3106
+ if (!isAbortFailure(error, active) && !isFsmResultFailure(error)) {
2721
3107
  controlPlaneError ??= error;
2722
3108
  }
2723
3109
  throw error;
@@ -2744,53 +3130,187 @@ export function createXStatePlaybookRuntime<TOptions>(
2744
3130
  const failedGuard = guards[1] ?? guards[0];
2745
3131
  const cwd = boundScriptCwd ?? process.cwd();
2746
3132
  const ports = runtimePorts ?? requireHostPorts();
2747
-
2748
- const exitStatus = await new Promise<number>((resolve, reject) => {
2749
- let child: ReturnType<typeof spawn>;
2750
- try {
2751
- child = spawn('sh', ['-c', input.command], {
2752
- cwd,
2753
- stdio: 'ignore',
2754
- });
2755
- } catch (error) {
2756
- reject(error);
2757
- return;
2758
- }
2759
- const onAbort = (): void => {
2760
- child.kill('SIGTERM');
2761
- reject(active.reason ?? new Error('script aborted'));
2762
- };
2763
- if (active.aborted) {
2764
- onAbort();
2765
- return;
3133
+ // slc/link.md §Script execution: an already-aborted turn spawns
3134
+ // nothing, and the thrown signal reason keeps the rejection
3135
+ // causally classified as the abort it is.
3136
+ active.throwIfAborted();
3137
+
3138
+ // Abort ownership — the listener that terminates the group and
3139
+ // the escalation timer — spans the whole invocation body, not
3140
+ // just the spawn-to-close window: an abort landing during the
3141
+ // post-exit emission tail must still kill surviving group
3142
+ // members before the actor settles (slc/link.md §Script
3143
+ // execution). One finally releases both.
3144
+ let child: ReturnType<typeof spawn> | undefined;
3145
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
3146
+ const signalGroup = (sig: NodeJS.Signals): void => {
3147
+ if (child?.pid !== undefined) {
3148
+ try {
3149
+ process.kill(-child.pid, sig);
3150
+ } catch {
3151
+ // Confirmation belongs to the bounded liveness probe below:
3152
+ // a failed signal can mean ESRCH, EPERM, or another fault.
3153
+ }
2766
3154
  }
2767
- active.addEventListener('abort', onAbort, { once: true });
2768
- child.on('error', (error) => {
2769
- active.removeEventListener('abort', onAbort);
2770
- reject(error);
2771
- });
2772
- child.on('close', (code) => {
2773
- active.removeEventListener('abort', onAbort);
2774
- resolve(typeof code === 'number' ? code : 1);
2775
- });
2776
- });
3155
+ };
3156
+ // After a SIGKILL is posted, settlement waits for the group to
3157
+ // stop being signalable — bounded by the same grace so an
3158
+ // unreapable member outside the runtime's control cannot stall
3159
+ // the turn forever. Observed teardown is milliseconds.
3160
+ let groupGonePromise: Promise<void> | undefined;
3161
+ const awaitGroupGone = (): Promise<void> => {
3162
+ const pid = child?.pid;
3163
+ if (pid === undefined) return Promise.resolve();
3164
+ groupGonePromise ??= (async () => {
3165
+ const teardownFailure = (
3166
+ message: string,
3167
+ cause?: unknown,
3168
+ ): ScriptProcessGroupTeardownError => {
3169
+ const failure = new ScriptProcessGroupTeardownError(
3170
+ pid,
3171
+ message,
3172
+ cause,
3173
+ );
3174
+ // A teardown failure is not an authored script result.
3175
+ // Surface it at the active public boundary even though
3176
+ // XState also routes the rejected actor through onError.
3177
+ controlPlaneError ??= failure;
3178
+ return failure;
3179
+ };
3180
+ const deadline = Date.now() + SCRIPT_ABORT_KILL_GRACE_MS;
3181
+ let lastProbeError: unknown;
3182
+ for (;;) {
3183
+ try {
3184
+ process.kill(-pid, 0);
3185
+ } catch (error) {
3186
+ if (isNoSuchProcess(error)) return;
3187
+ // EPERM confirms that at least one process in the group
3188
+ // still exists but is not signalable by this process. Keep
3189
+ // waiting for ESRCH within the bound; every other probe
3190
+ // error makes confirmation itself unreliable immediately.
3191
+ if (!isProcessPermissionDenied(error)) {
3192
+ throw teardownFailure(
3193
+ 'the liveness probe failed',
3194
+ error,
3195
+ );
3196
+ }
3197
+ lastProbeError = error;
3198
+ }
3199
+ if (Date.now() >= deadline) {
3200
+ throw teardownFailure(
3201
+ `the group remained signalable after ${SCRIPT_ABORT_KILL_GRACE_MS}ms`,
3202
+ lastProbeError,
3203
+ );
3204
+ }
3205
+ await new Promise((tick) => setTimeout(tick, 5));
3206
+ }
3207
+ })();
3208
+ return groupGonePromise;
3209
+ };
3210
+ const onAbort = (): void => {
3211
+ signalGroup('SIGTERM');
3212
+ killTimer = setTimeout(
3213
+ () => signalGroup('SIGKILL'),
3214
+ SCRIPT_ABORT_KILL_GRACE_MS,
3215
+ );
3216
+ };
3217
+ // An abort observed once the shell has already exited rejects
3218
+ // with the signal's reason before guard resolution and before
3219
+ // starting any further script emission — after killing whatever
3220
+ // group members outlived the shell. The shell's own exit ended
3221
+ // the TERM grace's purpose, so escalation is immediate here.
3222
+ const settleIfAborted = async (): Promise<void> => {
3223
+ if (!active.aborted) return;
3224
+ signalGroup('SIGKILL');
3225
+ await awaitGroupGone();
3226
+ active.throwIfAborted();
3227
+ };
3228
+ let invocationFailed = false;
3229
+ try {
3230
+ const exitStatus = await new Promise<number>(
3231
+ (resolve, reject) => {
3232
+ try {
3233
+ // detached: the shell leads its own POSIX process group,
3234
+ // so an abort can terminate the command's whole group — a
3235
+ // lone SIGTERM to the wrapper never reaches backgrounded
3236
+ // members.
3237
+ child = spawn('sh', ['-c', input.command], {
3238
+ cwd,
3239
+ stdio: 'ignore',
3240
+ detached: true,
3241
+ });
3242
+ } catch (error) {
3243
+ reject(error);
3244
+ return;
3245
+ }
3246
+ // On abort, terminate the group and escalate — but settle
3247
+ // only from 'close', after the shell itself has exited, so
3248
+ // the turn never reports quiescence while the script still
3249
+ // runs (slc/link.md §Abort). SIGKILL is untrappable, so
3250
+ // 'close' is bounded by the grace.
3251
+ active.addEventListener('abort', onAbort, { once: true });
3252
+ child.on('error', (error) => {
3253
+ reject(error);
3254
+ });
3255
+ child.on('close', (code) => {
3256
+ if (active.aborted) {
3257
+ // The shell may exit cooperatively on the group SIGTERM
3258
+ // while a TERM-immune same-group descendant survives;
3259
+ // the group stays addressable while any member lives,
3260
+ // so kill it and await its disappearance before
3261
+ // settling (slc/link.md §Script execution).
3262
+ signalGroup('SIGKILL');
3263
+ void awaitGroupGone().then(
3264
+ () =>
3265
+ reject(active.reason),
3266
+ reject,
3267
+ );
3268
+ return;
3269
+ }
3270
+ resolve(typeof code === 'number' ? code : 1);
3271
+ });
3272
+ },
3273
+ );
2777
3274
 
2778
- await ports.emitStatus(
2779
- `Executed script for ${input.stateId} (exit ${exitStatus}).`,
2780
- );
2781
- await ports.emitTelemetry({
2782
- topic: 'playbook.script',
2783
- payload: {
2784
- stateId: input.stateId,
2785
- sourceItem: input.sourceItem,
2786
- exitStatus,
2787
- },
2788
- });
3275
+ await settleIfAborted();
3276
+
3277
+ await ports.emitStatus(
3278
+ `Executed script for ${input.stateId} (exit ${exitStatus}).`,
3279
+ );
3280
+ await settleIfAborted();
3281
+ await ports.emitTelemetry({
3282
+ topic: 'playbook.script',
3283
+ payload: {
3284
+ stateId: input.stateId,
3285
+ sourceItem: input.sourceItem,
3286
+ exitStatus,
3287
+ },
3288
+ });
3289
+ await settleIfAborted();
2789
3290
 
2790
- if (exitStatus === 0) {
2791
- return { guard: okGuard, exitStatus: 0 };
3291
+ if (exitStatus === 0) {
3292
+ return { guard: okGuard, exitStatus: 0 };
3293
+ }
3294
+ return { guard: failedGuard, exitStatus };
3295
+ } catch (error) {
3296
+ // Preserve the invocation's authoritative exact cancellation or
3297
+ // distinct sink failure after teardown succeeds. The finally
3298
+ // block may replace it only with a distinct teardown failure
3299
+ // when the process group cannot be confirmed gone.
3300
+ invocationFailed = true;
3301
+ throw error;
3302
+ } finally {
3303
+ try {
3304
+ if (active.aborted) {
3305
+ signalGroup('SIGKILL');
3306
+ await awaitGroupGone();
3307
+ if (!invocationFailed) active.throwIfAborted();
3308
+ }
3309
+ } finally {
3310
+ active.removeEventListener('abort', onAbort);
3311
+ if (killTimer !== undefined) clearTimeout(killTimer);
3312
+ }
2792
3313
  }
2793
- return { guard: failedGuard, exitStatus };
2794
3314
  },
2795
3315
  );
2796
3316
  }
@@ -2800,7 +3320,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2800
3320
  getBoundarySignal: () => activeSignal,
2801
3321
  callPlaybook: (request, signal) =>
2802
3322
  requireHostPorts().callPlaybook(request, signal),
2803
- emitStarted: async (event) => {
3323
+ emitStarted: async (event, aborts) => {
2804
3324
  playbookCallTurnIds.set(event.callId, activeTurnId);
2805
3325
  await emitTrace(
2806
3326
  'playbook.call.started',
@@ -2813,9 +3333,10 @@ export function createXStatePlaybookRuntime<TOptions>(
2813
3333
  ...(activeTurnId !== undefined ? { turnId: activeTurnId } : {}),
2814
3334
  callId: event.callId,
2815
3335
  },
3336
+ aborts,
2816
3337
  );
2817
3338
  },
2818
- emitFinished: async (event) => {
3339
+ emitFinished: async (event, aborts) => {
2819
3340
  const turnId = playbookCallTurnIds.get(event.callId);
2820
3341
  try {
2821
3342
  await emitTrace(
@@ -2830,20 +3351,35 @@ export function createXStatePlaybookRuntime<TOptions>(
2830
3351
  ...(turnId !== undefined ? { turnId } : {}),
2831
3352
  callId: event.callId,
2832
3353
  },
3354
+ aborts,
2833
3355
  );
2834
3356
  } finally {
2835
3357
  playbookCallTurnIds.delete(event.callId);
2836
3358
  }
2837
3359
  },
2838
3360
  drain: drainEmissions,
2839
- bindResumeSignal: (signal) => {
3361
+ bindResumeSignal: (signal, aborts) => {
2840
3362
  activeSignal = signal;
3363
+ activeAborts = aborts ?? abortReasonClassifier(signal);
3364
+ },
3365
+ bindActorSettlement: (aborts) => {
3366
+ actorSettlementAborts = aborts;
2841
3367
  },
2842
- onControlPlaneError: (error) => {
2843
- if (!activeSignal?.aborted) controlPlaneError ??= error;
3368
+ onControlPlaneError: (error, aborts) => {
3369
+ // The shared bridge classifies before reporting against its own
3370
+ // invocation-and-resume signals; classify once more here against
3371
+ // the boundary signal so a report that is the active boundary's
3372
+ // exact abort reason can never masquerade as a control error
3373
+ // (slc/link.md §Abort).
3374
+ if (
3375
+ !aborts?.isAbortReason(error) &&
3376
+ !activeAborts?.isAbortReason(error)
3377
+ ) {
3378
+ controlPlaneError ??= error;
3379
+ }
2844
3380
  },
2845
- onBackgroundError: (error) => {
2846
- emissionFailure ??= error;
3381
+ onBackgroundError: (error, aborts) => {
3382
+ if (!aborts?.isAbortReason(error)) emissionFailure ??= { error };
2847
3383
  },
2848
3384
  });
2849
3385
 
@@ -2864,11 +3400,9 @@ export function createXStatePlaybookRuntime<TOptions>(
2864
3400
  previousState: previousState ?? null,
2865
3401
  state,
2866
3402
  };
2867
- if (state.stateId === 'awaitBossReply') {
2868
- const pendingBossQuestion = pendingBossQuestionFromContext(context);
2869
- if (pendingBossQuestion !== undefined) {
2870
- payload.pendingBossQuestion = pendingBossQuestion;
2871
- }
3403
+ const pendingBossQuestion = pendingBossQuestionForState(state, context);
3404
+ if (pendingBossQuestion !== undefined) {
3405
+ payload.pendingBossQuestion = pendingBossQuestion;
2872
3406
  }
2873
3407
  if (state.stateId === 'failed') {
2874
3408
  const lastError = normalizeErrorFull(context.lastError);
@@ -2882,6 +3416,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2882
3416
  state: PlaybookState,
2883
3417
  statuses: readonly ScheduledStatus[],
2884
3418
  position: TracePosition,
3419
+ aborts?: AbortReasonClassifier,
2885
3420
  ): void {
2886
3421
  const currentSession = requireSession();
2887
3422
  const transitionTrace = createTraceEvent(
@@ -2903,28 +3438,61 @@ export function createXStatePlaybookRuntime<TOptions>(
2903
3438
  position,
2904
3439
  ),
2905
3440
  }));
2906
- void enqueueEmission(async () => {
2907
- await currentSession.ports.emitTelemetry({
2908
- topic: 'playbook.trace',
2909
- payload: transitionTrace,
2910
- });
2911
- await currentSession.ports.emitTelemetry({
2912
- topic: 'playbook.fsm.state',
2913
- payload,
2914
- });
2915
- for (const status of statusEmissions) {
3441
+ void enqueueEmission(
3442
+ async () => {
2916
3443
  await currentSession.ports.emitTelemetry({
2917
3444
  topic: 'playbook.trace',
2918
- payload: status.trace,
3445
+ payload: transitionTrace,
2919
3446
  });
2920
- await currentSession.ports.emitStatus(status.message, status.data);
2921
- }
2922
- }).catch(() => undefined);
3447
+ await currentSession.ports.emitTelemetry({
3448
+ topic: 'playbook.fsm.state',
3449
+ payload,
3450
+ });
3451
+ for (const status of statusEmissions) {
3452
+ await currentSession.ports.emitTelemetry({
3453
+ topic: 'playbook.trace',
3454
+ payload: status.trace,
3455
+ });
3456
+ await currentSession.ports.emitStatus(status.message, status.data);
3457
+ }
3458
+ },
3459
+ aborts,
3460
+ ).catch(() => undefined);
3461
+ }
3462
+
3463
+ // One classifying latch for every runtime-observed error — inspection
3464
+ // failures and root-actor errors alike. Outside a boundary the error
3465
+ // rides the emission channel, which the next boundary's (or init's)
3466
+ // drain throws; inside a boundary it is a control-plane error unless it
3467
+ // is the boundary signal's own abort reason (slc/link.md §Abort).
3468
+ function latchRuntimeError(
3469
+ error: unknown,
3470
+ aborts: AbortReasonClassifier | undefined = activeAborts,
3471
+ ): void {
3472
+ if (aborts?.isAbortReason(error)) return;
3473
+ if (activeSignal === undefined) emissionFailure ??= { error };
3474
+ else controlPlaneError ??= error;
2923
3475
  }
2924
3476
 
2925
- function latchInspectionError(error: unknown): void {
2926
- if (activeSignal !== undefined) controlPlaneError ??= error;
2927
- else emissionFailure ??= error;
3477
+ function consumeActorSettlementAborts(
3478
+ forSnapshot = false,
3479
+ ): AbortReasonClassifier | undefined {
3480
+ const aborts = actorSettlementAborts ?? actorSettlementErrorAborts;
3481
+ actorSettlementAborts = undefined;
3482
+ actorSettlementErrorAborts = undefined;
3483
+ if (forSnapshot && aborts !== undefined) {
3484
+ // XState can report an errored root through both its inspection
3485
+ // snapshot and subscriber. Keep the same provenance through that
3486
+ // synchronous notification only; an ordinary transition must not
3487
+ // lend it to a later unrelated actor error.
3488
+ actorSettlementErrorAborts = aborts;
3489
+ queueMicrotask(() => {
3490
+ if (actorSettlementErrorAborts === aborts) {
3491
+ actorSettlementErrorAborts = undefined;
3492
+ }
3493
+ });
3494
+ }
3495
+ return aborts;
2928
3496
  }
2929
3497
 
2930
3498
  // PBRT-6: the single seam that stops this runtime's actor. Stopping a
@@ -2968,6 +3536,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2968
3536
  if (inspectionEvent.type !== '@xstate.snapshot') return;
2969
3537
  if (inspectionEvent.actorRef !== builtActor) return;
2970
3538
  if (suppressInspectionEmissions) return;
3539
+ const settlementAborts = consumeActorSettlementAborts(true);
2971
3540
  try {
2972
3541
  const snap = inspectionEvent.snapshot;
2973
3542
  const state = normalizePlaybookSnapshot(snap);
@@ -2995,13 +3564,25 @@ export function createXStatePlaybookRuntime<TOptions>(
2995
3564
  state,
2996
3565
  statuses,
2997
3566
  tracePositionForActiveTurn(),
3567
+ settlementAborts,
2998
3568
  );
2999
3569
  priorState = state;
3000
3570
  } catch (error) {
3001
- latchInspectionError(error);
3571
+ latchRuntimeError(error, settlementAborts);
3002
3572
  }
3003
3573
  },
3004
3574
  });
3575
+ // A synchronously-errored actor is already quiescent, so the turn's
3576
+ // quiescence wait never subscribes and XState would report the error
3577
+ // as unhandled after the boundary returns. Observe it through the
3578
+ // classifying latch: mid-boundary it is the control-plane error
3579
+ // unless it is the abort reason itself; at startup it rides the
3580
+ // emission channel so `init`'s own drain rejects with it and the
3581
+ // failed-start cleanup runs (slc/link.md §Session lifecycle).
3582
+ builtActor.subscribe({
3583
+ error: (error) =>
3584
+ latchRuntimeError(error, consumeActorSettlementAborts()),
3585
+ });
3005
3586
  return builtActor;
3006
3587
  }
3007
3588
 
@@ -3024,14 +3605,20 @@ export function createXStatePlaybookRuntime<TOptions>(
3024
3605
  const output = (
3025
3606
  actor?.getSnapshot() as { output?: unknown } | undefined
3026
3607
  )?.output;
3027
- if (output !== undefined) {
3028
- return {
3029
- outcome,
3030
- state,
3031
- output: snapshotJsonValue(output, 'terminal playbook output'),
3032
- };
3033
- }
3034
- return { outcome, state };
3608
+ const stateDescription = stateDescriptionFor(state);
3609
+ return {
3610
+ outcome,
3611
+ state,
3612
+ ...(stateDescription === undefined ? {} : { stateDescription }),
3613
+ ...(output === undefined
3614
+ ? {}
3615
+ : {
3616
+ output: snapshotJsonValue(
3617
+ output,
3618
+ 'terminal playbook output',
3619
+ ),
3620
+ }),
3621
+ };
3035
3622
  }
3036
3623
  const failure =
3037
3624
  error ??
@@ -3050,15 +3637,23 @@ export function createXStatePlaybookRuntime<TOptions>(
3050
3637
 
3051
3638
  function settledOutcome(signal: AbortSignal): BossSettlementOutcome {
3052
3639
  if (nestedBridge.getPendingCall()) return 'suspended';
3053
- if (signal.aborted) return 'aborted';
3054
3640
  const state = currentState();
3055
3641
  if (state.status === 'error') {
3642
+ // An errored actor outranks a coincident abort unless the actor's
3643
+ // error is the abort reason itself (slc/link.md §Abort).
3056
3644
  const actorError = (
3057
3645
  actor?.getSnapshot() as { error?: unknown } | undefined
3058
3646
  )?.error;
3647
+ if (actorError !== undefined && isAbortFailure(actorError, signal)) {
3648
+ return 'aborted';
3649
+ }
3059
3650
  throw actorError ?? new Error(`${label} actor entered error status`);
3060
3651
  }
3652
+ // Terminal completion outranks a coincident abort: the work finished,
3653
+ // and reporting `aborted` would hide a terminal machine behind a
3654
+ // settlement a later turn silently restarts (slc/link.md §Abort).
3061
3655
  if (state.status === 'done') return 'terminal';
3656
+ if (signal.aborted) return 'aborted';
3062
3657
  if (state.stateId === 'failed') return 'failed';
3063
3658
  return 'quiescent';
3064
3659
  }
@@ -3123,8 +3718,8 @@ export function createXStatePlaybookRuntime<TOptions>(
3123
3718
  // The session-start error remains authoritative.
3124
3719
  }
3125
3720
  }
3126
- playerResumeTokens.clear();
3127
- activePlayerIds.clear();
3721
+ privateResumeTokens.clear();
3722
+ activePlayerKeys.clear();
3128
3723
  playbookCallTurnIds.clear();
3129
3724
  activeEmissionCalls.clear();
3130
3725
  emissionQueue.clear();
@@ -3135,6 +3730,10 @@ export function createXStatePlaybookRuntime<TOptions>(
3135
3730
  savedPorts = undefined;
3136
3731
  runtimePorts = undefined;
3137
3732
  activeSignal = undefined;
3733
+ activeAborts = undefined;
3734
+ actorSettlementAborts = undefined;
3735
+ actorSettlementErrorAborts = undefined;
3736
+ activeAbortEmission = undefined;
3138
3737
  activeTurnId = undefined;
3139
3738
  controlPlaneError = undefined;
3140
3739
  emissionFailure = undefined;
@@ -3170,32 +3769,57 @@ export function createXStatePlaybookRuntime<TOptions>(
3170
3769
  );
3171
3770
  }
3172
3771
 
3772
+ // DR-034: where the artifact names the FSM context member its entry
3773
+ // action copies the exact Boss text into, that member of the live
3774
+ // snapshot is the retry payload's source. The persisted machine snapshot
3775
+ // carries it, so the candidate derives identically in the process that
3776
+ // exported the snapshot and in one that restored it, and a failure
3777
+ // reached after a Boss reply — whose recorded event the failure state
3778
+ // refuses — is recoverable too. Naming the member is the artifact's
3779
+ // statement that it holds the entry text: a same-named member is never
3780
+ // assumed, since inferring one would turn any matching context member
3781
+ // into a replay payload without its author saying so.
3782
+ // Declared and absent or empty excludes the candidate rather than
3783
+ // falling back to the record, which would make the action depend on the
3784
+ // process again — the very thing this source exists to end.
3785
+ function retryEventFrom(snapshot: unknown): EventObject | undefined {
3786
+ const entryEvent = spec.entryEvent;
3787
+ if (entryEvent?.contextField === undefined) return lastBossEvent;
3788
+ const context = (snapshot as { context?: unknown } | null)?.context;
3789
+ const text = isPlainObject(context)
3790
+ ? context[entryEvent.contextField]
3791
+ : undefined;
3792
+ if (typeof text !== 'string' || text.trim() === '') return undefined;
3793
+ return { type: entryEvent.type, [entryEvent.textField]: text };
3794
+ }
3795
+
3173
3796
  // The failure-state retry entry replays the recorded last classified
3174
- // event with its recorded payload. A candidate whose event the live
3175
- // snapshot does not accept or whose payload the runtime never
3176
- // recordedis excluded rather than completed with invented text.
3797
+ // event with its recorded payload, or the entry event the declared
3798
+ // context member above sources. A candidate whose event the live
3799
+ // snapshot does not accept or whose payload the runtime can source
3800
+ // from neither — is excluded rather than completed with invented text.
3177
3801
  function retryActionFor(
3178
3802
  snapshot: unknown,
3179
3803
  stateId: string | undefined,
3180
3804
  ): DerivedControlAction | undefined {
3181
- if (stateId !== 'failed' || lastBossEvent === undefined) {
3182
- return undefined;
3183
- }
3184
- if (!snapshotCan(snapshot, lastBossEvent)) return undefined;
3805
+ if (stateId !== 'failed') return undefined;
3806
+ const retryEvent = retryEventFrom(snapshot);
3807
+ if (retryEvent === undefined) return undefined;
3808
+ if (!snapshotCan(snapshot, retryEvent)) return undefined;
3185
3809
  // A recorded explicit-state-jump event names the exact state its
3186
3810
  // replay re-enters: the root BOSS_INTERRUPT shape is a guarded
3187
3811
  // multi-arm list keyed on `targetId`, so the first configured arm
3188
3812
  // may label a different state than the one the recorded event
3189
3813
  // actually resumes.
3190
3814
  const recordedTargetId =
3191
- lastBossEvent.type === JUMP_EVENT_TYPE
3192
- ? (lastBossEvent as { targetId?: unknown }).targetId
3815
+ retryEvent.type === JUMP_EVENT_TYPE
3816
+ ? (retryEvent as { targetId?: unknown }).targetId
3193
3817
  : undefined;
3194
3818
  const target =
3195
3819
  typeof recordedTargetId === 'string' &&
3196
3820
  recordedTargetId.trim().length > 0
3197
3821
  ? recordedTargetId
3198
- : firstTransitionTarget(machine, stateId, lastBossEvent.type);
3822
+ : firstTransitionTarget(machine, stateId, retryEvent.type);
3199
3823
  // PBRT-52: a label is written from a source state description, never
3200
3824
  // from an identifier. Falling back to the target id — or, with no
3201
3825
  // resolvable target, to the FSM event type — makes the label *be* the
@@ -3209,10 +3833,10 @@ export function createXStatePlaybookRuntime<TOptions>(
3209
3833
  if (description === undefined) return undefined;
3210
3834
  return {
3211
3835
  action: {
3212
- id: `retry:${lastBossEvent.type}`,
3836
+ id: `retry:${retryEvent.type}`,
3213
3837
  label: `Retry: ${description}`,
3214
3838
  },
3215
- event: lastBossEvent,
3839
+ event: retryEvent,
3216
3840
  };
3217
3841
  }
3218
3842
 
@@ -3332,7 +3956,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3332
3956
  if (initialized || disposed || disposalPromise !== undefined) {
3333
3957
  throw new Error('createPlaybookRuntime.init: already initialized');
3334
3958
  }
3335
- const boundSession = snapshotPlaybookSession(nextSession);
3959
+ const boundSession = bindSession(nextSession);
3336
3960
  initialized = true;
3337
3961
  let finishInitialization!: () => void;
3338
3962
  const initialization = new Promise<void>((resolve) => {
@@ -3406,12 +4030,12 @@ export function createXStatePlaybookRuntime<TOptions>(
3406
4030
  );
3407
4031
  const context = (actor.getSnapshot() as { context?: unknown })
3408
4032
  .context as Record<string, unknown>;
3409
- const pending = pendingBossQuestionFromContext(context ?? {});
4033
+ const pending = pendingBossQuestionForState(state, context ?? {});
3410
4034
  return {
3411
- schemaVersion: 2,
4035
+ schemaVersion: 3,
3412
4036
  playbookId: session.playbookId,
3413
4037
  machine: machineSnapshot,
3414
- playerResumeTokens: snapshotPlayerResumeTokens(),
4038
+ roleResumeTokens: snapshotRoleResumeTokens(),
3415
4039
  sequences: {
3416
4040
  trace: traceSequence,
3417
4041
  turn: turnSequence,
@@ -3429,7 +4053,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3429
4053
  : [
3430
4054
  {
3431
4055
  questionId: pending.questionId,
3432
- player: pending.player,
4056
+ asker: pending.asker,
3433
4057
  question: pending.question,
3434
4058
  sourceItem: pending.sourceItem,
3435
4059
  },
@@ -3450,16 +4074,21 @@ export function createXStatePlaybookRuntime<TOptions>(
3450
4074
  if (initialized || disposed || disposalPromise !== undefined) {
3451
4075
  throw new Error('createPlaybookRuntime.restore: already initialized');
3452
4076
  }
3453
- const boundSession = snapshotPlaybookSession(nextSession);
4077
+ const boundSession = bindSession(nextSession);
3454
4078
  const boundSnapshot = assertPlaybookRuntimeSnapshot(
3455
4079
  snapshot,
3456
4080
  boundSession.playbookId,
3457
4081
  { allowSuspendedCall: true },
3458
4082
  );
3459
- const suspendedCall =
3460
- boundSnapshot.schemaVersion === 2
3461
- ? boundSnapshot.suspendedCall
3462
- : undefined;
4083
+ if (
4084
+ declaredActors.has('captain') &&
4085
+ boundSnapshot.sequences.captainCall === undefined
4086
+ ) {
4087
+ throw new TypeError(
4088
+ 'runtime snapshot sequences.captainCall is required for a direct-Captain artifact',
4089
+ );
4090
+ }
4091
+ const suspendedCall = boundSnapshot.suspendedCall;
3463
4092
  let priorExternalPlayerTokens:
3464
4093
  | Readonly<Record<string, string>>
3465
4094
  | undefined;
@@ -3479,22 +4108,17 @@ export function createXStatePlaybookRuntime<TOptions>(
3479
4108
  judgeCallSequence = boundSnapshot.sequences.judgeCall;
3480
4109
  playerCallSequence = boundSnapshot.sequences.playerCall;
3481
4110
  playbookCallSequence = boundSnapshot.sequences.playbookCall;
3482
- captainCallSequence =
3483
- boundSnapshot.sequences.captainCall ??
3484
- // Legacy schema-v1 snapshots predate this dedicated counter.
3485
- // Every Captain call already consumed at least one trace number,
3486
- // so the global trace counter is a collision-safe id floor.
3487
- boundSnapshot.sequences.trace;
4111
+ captainCallSequence = boundSnapshot.sequences.captainCall ?? 0;
3488
4112
  // The runtime snapshot carries no apply counter (PBRT-50); every
3489
4113
  // apply boundary consumed trace numbers, so the persisted trace
3490
4114
  // counter is a collision-safe id floor here too, keeping
3491
4115
  // `apply-<n>` call ids unique across restore.
3492
4116
  applyCallSequence = boundSnapshot.sequences.trace;
3493
4117
  if (boundSession.playerSessions) {
3494
- priorExternalPlayerTokens = snapshotPlayerResumeTokens();
4118
+ priorExternalPlayerTokens = snapshotRoleResumeTokens();
3495
4119
  externalStoreRestoreAttempted = true;
3496
4120
  }
3497
- restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
4121
+ restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
3498
4122
  nestedBridge.prepareRestore(suspendedCall);
3499
4123
  if (suspendedCall !== undefined) {
3500
4124
  playbookCallTurnIds.set(
@@ -3505,7 +4129,26 @@ export function createXStatePlaybookRuntime<TOptions>(
3505
4129
  suppressInspectionEmissions = true;
3506
4130
  actor = buildActor(runtimePorts, boundSnapshot.machine);
3507
4131
  actor.start();
3508
- if (controlPlaneError !== undefined) throw controlPlaneError;
4132
+ // A start-time actor error rides the startup emission channel
4133
+ // (latchRuntimeError); consume both latches here so the original
4134
+ // error outranks the derived status check below.
4135
+ {
4136
+ const startupFailure = emissionFailure;
4137
+ if (
4138
+ controlPlaneError !== undefined ||
4139
+ startupFailure !== undefined
4140
+ ) {
4141
+ const startupError =
4142
+ controlPlaneError !== undefined
4143
+ ? controlPlaneError
4144
+ : startupFailure!.error;
4145
+ controlPlaneError = undefined;
4146
+ if (emissionFailure === startupFailure) {
4147
+ emissionFailure = undefined;
4148
+ }
4149
+ throw startupError;
4150
+ }
4151
+ }
3509
4152
  const restoredState = normalizePlaybookSnapshot(
3510
4153
  actor.getSnapshot(),
3511
4154
  suspendedCall === undefined
@@ -3588,7 +4231,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3588
4231
  const state = currentState();
3589
4232
  const context = ((snapshot as { context?: unknown }).context ??
3590
4233
  {}) as Record<string, unknown>;
3591
- const pending = pendingBossQuestionFromContext(context);
4234
+ const pending = pendingBossQuestionForState(state, context);
3592
4235
  const lastError = normalizeErrorFull(context.lastError);
3593
4236
  const projectedContext = projectControlContext(context);
3594
4237
  const stateDescription = stateDescriptionFor(state);
@@ -3604,7 +4247,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3604
4247
  : [
3605
4248
  {
3606
4249
  questionId: pending.questionId,
3607
- player: pending.player,
4250
+ asker: pending.asker,
3608
4251
  question: pending.question,
3609
4252
  sourceItem: pending.sourceItem,
3610
4253
  },
@@ -3676,6 +4319,8 @@ export function createXStatePlaybookRuntime<TOptions>(
3676
4319
  const position: TracePosition = { turnId, callId };
3677
4320
  activeTurnId = turnId;
3678
4321
  activeSignal = signal;
4322
+ activeAborts = abortReasonClassifier(signal);
4323
+ activeAbortEmission = undefined;
3679
4324
  controlPlaneError = undefined;
3680
4325
  // Every receipt variant is normalized and frozen where it is built,
3681
4326
  // inside the guarded region, so the recording step below cannot
@@ -3727,9 +4372,13 @@ export function createXStatePlaybookRuntime<TOptions>(
3727
4372
  // final for their key. Past publication such a failure is therefore
3728
4373
  // re-latched onto the emission channel, surfacing from the next
3729
4374
  // public boundary's drain, and `apply` still does not throw past
3730
- // acceptance (PBRT-52).
4375
+ // acceptance (PBRT-52). A delivery rejection causally identical to
4376
+ // this call's own abort reason evidences the cancellation and is
4377
+ // dropped — never carried to a later unrelated boundary
4378
+ // (slc/link.md §Abort).
3731
4379
  const latchDeliveryFailure = (error: unknown): void => {
3732
- emissionFailure ??= error;
4380
+ if (isAbortFailure(error, signal)) return;
4381
+ emissionFailure ??= { error };
3733
4382
  };
3734
4383
  try {
3735
4384
  try {
@@ -3756,6 +4405,7 @@ export function createXStatePlaybookRuntime<TOptions>(
3756
4405
  'apply.finished',
3757
4406
  identity,
3758
4407
  position,
4408
+ signal,
3759
4409
  preAcceptanceFinish('apply.started trace sink rejected'),
3760
4410
  );
3761
4411
  // An abort may land while the awaited started emission drains
@@ -3846,6 +4496,10 @@ export function createXStatePlaybookRuntime<TOptions>(
3846
4496
  } catch (error) {
3847
4497
  settlementError = error;
3848
4498
  }
4499
+ // Exact cancellation is not a control-plane latch, but after apply
4500
+ // acceptance and before publication it is still settlement evidence
4501
+ // and therefore folds into the owed failed receipt (DR-036 §4).
4502
+ settlementError ??= activeAbortEmission;
3849
4503
  // Fold before the finish emission, the last point at which the
3850
4504
  // traced disposition and the returned one can still be made the
3851
4505
  // same value.
@@ -3884,6 +4538,8 @@ export function createXStatePlaybookRuntime<TOptions>(
3884
4538
  // wedge every later public boundary behind "another runtime turn
3885
4539
  // is active".
3886
4540
  activeSignal = undefined;
4541
+ activeAborts = undefined;
4542
+ activeAbortEmission = undefined;
3887
4543
  activeTurnId = undefined;
3888
4544
  controlPlaneError = undefined;
3889
4545
  }
@@ -3932,136 +4588,171 @@ export function createXStatePlaybookRuntime<TOptions>(
3932
4588
  const turnId = ++turnSequence;
3933
4589
  activeTurnId = turnId;
3934
4590
  activeSignal = signal;
4591
+ activeAborts = abortReasonClassifier(signal);
4592
+ activeAbortEmission = undefined;
3935
4593
  controlPlaneError = undefined;
3936
4594
  let result: PlaybookRunResult | undefined;
3937
4595
  let operationError: unknown;
4596
+ // The boundary sentinel releases on every exit: a settlement defect
4597
+ // past the drain — a snapshot normalization throw inside
4598
+ // `runResultFor` included — must never wedge every later public
4599
+ // boundary and `dispose` itself behind "another runtime turn is
4600
+ // active". Mirrors the apply boundary's finally.
3938
4601
  try {
3939
- await emitTrace('boss.input.received', { text }, { turnId });
3940
- // 1. Map the Boss text to an FSM event: deterministic exact entry
3941
- // where applicable (slc/link.md §Boss-event mapping), judge
3942
- // classification otherwise.
3943
- let event: EventObject | undefined;
3944
- const trimmed = text.trim();
3945
- if (trimmed !== '') {
3946
- const snapshot = actor.getSnapshot();
3947
- const terminal = snapshot.status === 'done';
3948
- const stateId = normalizePlaybookSnapshot(snapshot).stateId;
3949
- if (
3950
- spec.entryEvent !== undefined &&
3951
- (stateId === 'ready' || terminal)
3952
- ) {
3953
- event = {
3954
- type: spec.entryEvent.type,
3955
- [spec.entryEvent.textField]: text,
3956
- };
3957
- } else {
3958
- event = await classifyBossText(
3959
- text,
3960
- runtimePorts!,
3961
- signal,
3962
- snapshot,
3963
- boundary,
3964
- boundOptions,
3965
- );
3966
- }
3967
- signal.throwIfAborted();
3968
- }
3969
- // Empty input, no-action classifier output, or invalid classifier
3970
- // output — nothing to send.
3971
- if (event === undefined) {
3972
- result = runResultFor('no-action');
3973
- } else {
3974
- // 2. Optional Captain-pane classification line: the bare FSM
3975
- // event type, emitted before the FSM advances.
3976
- const statusLine = classificationStatus(event);
3977
- if (statusLine !== undefined) {
3978
- await runtimePorts!.emitStatus(statusLine);
3979
- }
4602
+ try {
4603
+ await emitTrace('boss.input.received', { text }, { turnId });
4604
+ // Record the attempted input, then refuse a boundary that entered
4605
+ // aborted before deterministic mapping or the classifier can
4606
+ // perform host-visible work (DR-036 §5).
3980
4607
  signal.throwIfAborted();
3981
- // 3. A final actor cannot accept new events; reconstruct only
3982
- // after classification produced a real event.
3983
- if (actor.getSnapshot().status === 'done') {
3984
- stopActor();
3985
- actor = buildActor(runtimePorts!);
3986
- // The replacement actor's snapshots are real state entries.
3987
- suppressInspectionEmissions = false;
3988
- actor.start();
4608
+ // 1. Map the Boss text to an FSM event: deterministic exact entry
4609
+ // where applicable (slc/link.md §Boss-event mapping), judge
4610
+ // classification otherwise.
4611
+ let event: EventObject | undefined;
4612
+ const trimmed = text.trim();
4613
+ if (trimmed !== '') {
4614
+ const snapshot = actor.getSnapshot();
4615
+ const terminal = snapshot.status === 'done';
4616
+ const stateId = normalizePlaybookSnapshot(snapshot).stateId;
4617
+ // PBRT-1 / slc/link.md §Boss-event mapping: the idle entry, the
4618
+ // recoverable failure state, and the reconstructed terminal all
4619
+ // accept exactly one ordinary textual entry event, so delivered
4620
+ // text enters deterministically — no judge call to spend and no
4621
+ // classifier whim to settle a restart as no action. Every other
4622
+ // parked state — a reply wait or an authored mid-workflow
4623
+ // checkpoint — classifies under its own Boss-event contracts.
4624
+ if (
4625
+ spec.entryEvent !== undefined &&
4626
+ (stateId === 'ready' || stateId === 'failed' || terminal)
4627
+ ) {
4628
+ event = {
4629
+ type: spec.entryEvent.type,
4630
+ [spec.entryEvent.textField]: text,
4631
+ };
4632
+ } else {
4633
+ event = await classifyBossText(
4634
+ text,
4635
+ runtimePorts!,
4636
+ signal,
4637
+ snapshot,
4638
+ boundary,
4639
+ boundOptions,
4640
+ );
4641
+ }
4642
+ signal.throwIfAborted();
3989
4643
  }
3990
- // DR-029: keep the classified event with its recorded payload
3991
- // as the retry-replay source. Recording is sanitizing, not
3992
- // load-bearing: an override classifier's non-JSON-safe event is
3993
- // simply not recorded, and the turn proceeds unchanged.
3994
- try {
3995
- lastBossEvent = snapshotJsonValue(
3996
- event,
3997
- 'recorded Boss event',
3998
- ) as unknown as EventObject;
3999
- } catch {
4000
- lastBossEvent = undefined;
4644
+ // Empty input, no-action classifier output, or invalid classifier
4645
+ // output nothing to send.
4646
+ if (event === undefined) {
4647
+ result = runResultFor('no-action');
4648
+ } else {
4649
+ // 2. Optional Captain-pane classification line: the bare FSM
4650
+ // event type, emitted before the FSM advances.
4651
+ const statusLine = classificationStatus(event);
4652
+ if (statusLine !== undefined) {
4653
+ await runtimePorts!.emitStatus(statusLine);
4654
+ }
4655
+ signal.throwIfAborted();
4656
+ // 3. A final actor cannot accept new events; reconstruct only
4657
+ // after classification produced a real event.
4658
+ if (actor.getSnapshot().status === 'done') {
4659
+ stopActor();
4660
+ actor = buildActor(runtimePorts!);
4661
+ // The replacement actor's snapshots are real state entries.
4662
+ suppressInspectionEmissions = false;
4663
+ actor.start();
4664
+ }
4665
+ // DR-029: keep the classified event with its recorded payload
4666
+ // as the retry-replay source. Recording is sanitizing, not
4667
+ // load-bearing: an override classifier's non-JSON-safe event is
4668
+ // simply not recorded, and the turn proceeds unchanged.
4669
+ try {
4670
+ lastBossEvent = snapshotJsonValue(
4671
+ event,
4672
+ 'recorded Boss event',
4673
+ ) as unknown as EventObject;
4674
+ } catch {
4675
+ lastBossEvent = undefined;
4676
+ }
4677
+ actor.send(event);
4678
+ await waitForPlaybookQuiescence(actor, {
4679
+ pendingCalls: nestedBridge,
4680
+ });
4681
+ if (controlPlaneError !== undefined) throw controlPlaneError;
4682
+ result = runResultFor(settledOutcome(signal));
4001
4683
  }
4002
- actor.send(event);
4003
- await waitForPlaybookQuiescence(actor, {
4004
- pendingCalls: nestedBridge,
4005
- });
4006
- if (controlPlaneError !== undefined) throw controlPlaneError;
4007
- result = runResultFor(settledOutcome(signal));
4684
+ } catch (error) {
4685
+ operationError = error;
4008
4686
  }
4009
- } catch (error) {
4010
- operationError = error;
4011
- }
4012
4687
 
4013
- let drainError: unknown;
4014
- try {
4015
- await drainEmissions();
4016
- } catch (error) {
4017
- drainError = error;
4018
- }
4019
- const latchedControlError = controlPlaneError;
4020
- const primaryError =
4021
- latchedControlError ?? drainError ?? operationError;
4022
- const abortError =
4023
- latchedControlError === undefined &&
4024
- drainError === undefined &&
4025
- operationError !== undefined &&
4026
- isAbortFailure(operationError, signal);
4027
- const settlementResult =
4028
- primaryError === undefined
4029
- ? (result ?? runResultFor('no-action'))
4030
- : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
4031
-
4032
- let settlementEmissionError: unknown;
4033
- try {
4034
- await emitTrace(
4035
- 'boss.input.settled',
4036
- settlementTracePayload(settlementResult),
4037
- { turnId },
4038
- );
4039
- } catch (error) {
4040
- settlementEmissionError = error;
4041
- }
4042
- try {
4043
- await drainEmissions();
4044
- } catch (error) {
4045
- settlementEmissionError ??= error;
4046
- }
4047
- const failure =
4048
- controlPlaneError ??
4049
- latchedControlError ??
4050
- drainError ??
4051
- (abortError
4052
- ? (settlementEmissionError ?? operationError)
4053
- : (operationError ?? settlementEmissionError));
4054
- activeSignal = undefined;
4055
- activeTurnId = undefined;
4056
- controlPlaneError = undefined;
4688
+ let drainError: unknown;
4689
+ try {
4690
+ await drainEmissions();
4691
+ } catch (error) {
4692
+ drainError = error;
4693
+ }
4694
+ const latchedControlError = controlPlaneError;
4695
+ // A drain rejection that is the exact abort reason evidences the
4696
+ // cancellation, not a control-plane failure (slc/link.md §Abort).
4697
+ const drainAbort =
4698
+ drainError !== undefined && isAbortFailure(drainError, signal);
4699
+ const effectiveDrainError = drainAbort ? undefined : drainError;
4700
+ const primaryError =
4701
+ latchedControlError ?? effectiveDrainError ?? operationError;
4702
+ const abortError =
4703
+ latchedControlError === undefined &&
4704
+ effectiveDrainError === undefined &&
4705
+ ((operationError !== undefined &&
4706
+ isAbortFailure(operationError, signal)) ||
4707
+ (drainAbort && operationError === undefined));
4708
+ const settlementResult =
4709
+ primaryError === undefined
4710
+ ? (result ?? runResultFor('no-action'))
4711
+ : runResultFor(abortError ? 'aborted' : 'failed', primaryError);
4712
+
4713
+ let settlementEmissionError: unknown;
4714
+ try {
4715
+ await emitTrace(
4716
+ 'boss.input.settled',
4717
+ settlementTracePayload(settlementResult),
4718
+ { turnId },
4719
+ );
4720
+ } catch (error) {
4721
+ settlementEmissionError = error;
4722
+ }
4723
+ try {
4724
+ await drainEmissions();
4725
+ } catch (error) {
4726
+ settlementEmissionError ??= error;
4727
+ }
4728
+ if (
4729
+ settlementEmissionError !== undefined &&
4730
+ isAbortFailure(settlementEmissionError, signal)
4731
+ ) {
4732
+ settlementEmissionError = undefined;
4733
+ }
4734
+ const failure =
4735
+ controlPlaneError ??
4736
+ latchedControlError ??
4737
+ effectiveDrainError ??
4738
+ (abortError
4739
+ ? (settlementEmissionError ?? operationError)
4740
+ : (operationError ?? settlementEmissionError));
4057
4741
 
4058
- if (
4059
- failure !== undefined &&
4060
- !(abortError && settlementEmissionError === undefined)
4061
- ) {
4062
- throw failure;
4742
+ if (
4743
+ failure !== undefined &&
4744
+ !(abortError && settlementEmissionError === undefined)
4745
+ ) {
4746
+ throw failure;
4747
+ }
4748
+ return settlementResult;
4749
+ } finally {
4750
+ activeSignal = undefined;
4751
+ activeAborts = undefined;
4752
+ activeAbortEmission = undefined;
4753
+ activeTurnId = undefined;
4754
+ controlPlaneError = undefined;
4063
4755
  }
4064
- return settlementResult;
4065
4756
  },
4066
4757
 
4067
4758
  async resumePlaybookCall(input: {
@@ -4086,37 +4777,88 @@ export function createXStatePlaybookRuntime<TOptions>(
4086
4777
  }
4087
4778
  activeTurnId = playbookCallTurnIds.get(input.callId);
4088
4779
  activeSignal = input.signal;
4780
+ activeAborts = abortReasonClassifier(input.signal);
4781
+ activeAbortEmission = undefined;
4089
4782
  controlPlaneError = undefined;
4090
- let result: PlaybookRunResult | undefined;
4091
- let operationError: unknown;
4092
- try {
4093
- await nestedBridge.resume(input);
4094
- } catch (error) {
4095
- operationError = error;
4096
- }
4783
+ // The boundary sentinel releases on every exit, mirroring
4784
+ // `handleBossInput` and the apply boundary.
4097
4785
  try {
4098
- await waitForPlaybookQuiescence(actor, {
4099
- pendingCalls: nestedBridge,
4100
- });
4101
- result = runResultFor(settledOutcome(input.signal));
4102
- } catch (error) {
4103
- operationError ??= error;
4104
- }
4105
- let drainError: unknown;
4106
- try {
4107
- await drainEmissions();
4108
- } catch (error) {
4109
- drainError = error;
4110
- }
4111
- const failure = controlPlaneError ?? drainError ?? operationError;
4112
- activeSignal = undefined;
4113
- activeTurnId = undefined;
4114
- controlPlaneError = undefined;
4115
- if (failure !== undefined) throw failure;
4116
- if (result === undefined) {
4117
- throw new Error('playbook resume produced no runtime result');
4786
+ let result: PlaybookRunResult | undefined;
4787
+ let operationError: unknown;
4788
+ try {
4789
+ await nestedBridge.resume(input);
4790
+ } catch (error) {
4791
+ operationError = error;
4792
+ }
4793
+ try {
4794
+ await waitForPlaybookQuiescence(actor, {
4795
+ pendingCalls: nestedBridge,
4796
+ });
4797
+ result = runResultFor(settledOutcome(input.signal));
4798
+ } catch (error) {
4799
+ operationError ??= error;
4800
+ }
4801
+ // A resume refused because its signal was already aborted
4802
+ // delivers nothing: the pending call survives for a later
4803
+ // resume, and the boundary settles `aborted` rather than
4804
+ // advertising `suspended` (slc/link.md §Nested playbook bridge).
4805
+ if (
4806
+ operationError !== undefined &&
4807
+ isAbortFailure(operationError, input.signal) &&
4808
+ nestedBridge.getPendingCall()?.callId === input.callId
4809
+ ) {
4810
+ result = {
4811
+ outcome: 'aborted',
4812
+ state: currentState(),
4813
+ error: normalizeError(input.signal.reason),
4814
+ };
4815
+ }
4816
+ let drainError: unknown;
4817
+ try {
4818
+ await drainEmissions();
4819
+ } catch (error) {
4820
+ drainError = error;
4821
+ }
4822
+ const aborts = activeAborts ?? abortReasonClassifier(input.signal);
4823
+ // A control-plane latch has already classified its failure as
4824
+ // distinct under the owning operation. Never reinterpret it
4825
+ // against this later resume signal (DR-036 decision 2).
4826
+ const controlFailure = controlPlaneError;
4827
+ const drainAbort =
4828
+ controlFailure === undefined &&
4829
+ drainError !== undefined &&
4830
+ aborts.isAbortReason(drainError);
4831
+ const operationAbort =
4832
+ controlFailure === undefined &&
4833
+ operationError !== undefined &&
4834
+ aborts.isAbortReason(operationError);
4835
+ const abortEvidence =
4836
+ activeAbortEmission ??
4837
+ (drainAbort ? drainError : undefined) ??
4838
+ (operationAbort ? operationError : undefined);
4839
+ const failure =
4840
+ controlFailure ??
4841
+ (drainAbort ? undefined : drainError) ??
4842
+ (operationAbort ? undefined : operationError);
4843
+ if (failure !== undefined) throw failure;
4844
+ if (
4845
+ abortEvidence !== undefined &&
4846
+ result?.outcome !== 'terminal' &&
4847
+ result?.outcome !== 'suspended'
4848
+ ) {
4849
+ result = runResultFor('aborted', abortEvidence);
4850
+ }
4851
+ if (result === undefined) {
4852
+ throw new Error('playbook resume produced no runtime result');
4853
+ }
4854
+ return result;
4855
+ } finally {
4856
+ activeSignal = undefined;
4857
+ activeAborts = undefined;
4858
+ activeAbortEmission = undefined;
4859
+ activeTurnId = undefined;
4860
+ controlPlaneError = undefined;
4118
4861
  }
4119
- return result;
4120
4862
  },
4121
4863
 
4122
4864
  dispose(): Promise<void> {
@@ -4176,9 +4918,9 @@ export function createXStatePlaybookRuntime<TOptions>(
4176
4918
  // engagement tree. Child disposal must not erase a token its
4177
4919
  // caller will resume. The private fallback remains runtime-owned.
4178
4920
  if (session?.playerSessions === undefined) {
4179
- playerResumeTokens.clear();
4921
+ privateResumeTokens.clear();
4180
4922
  }
4181
- activePlayerIds.clear();
4923
+ activePlayerKeys.clear();
4182
4924
  playbookCallTurnIds.clear();
4183
4925
  activeEmissionCalls.clear();
4184
4926
  emissionQueue.clear();
@@ -4186,6 +4928,10 @@ export function createXStatePlaybookRuntime<TOptions>(
4186
4928
  appliedReceipts.clear();
4187
4929
  actor = undefined;
4188
4930
  activeSignal = undefined;
4931
+ activeAborts = undefined;
4932
+ actorSettlementAborts = undefined;
4933
+ actorSettlementErrorAborts = undefined;
4934
+ activeAbortEmission = undefined;
4189
4935
  activeTurnId = undefined;
4190
4936
  controlPlaneError = undefined;
4191
4937
  emissionFailure = undefined;