@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
@@ -7,8 +7,8 @@
7
7
  // Linker inputs:
8
8
  // FSM artifact: ./decide.fsm.ts
9
9
  // Link target: @sublang/playbook/runtime
10
- // Player binding: Coder -> coder, Reviewer -> reviewer
11
- // (default binding: lowercased player name)
10
+ // Role binding: canonical coder and reviewer roles; concrete players
11
+ // and prompt identities are supplied by the host session
12
12
  // Adjudication: LLM-judge per state (default)
13
13
  // Boss-event mapping: free-text judge classification (default)
14
14
  // Abort strategy: natural rejection; every player-invoking state's
@@ -98,89 +98,88 @@ export type {
98
98
  PlaybookTraceType,
99
99
  };
100
100
 
101
- type PlayerName = 'Coder' | 'Reviewer';
101
+ type RoleId = 'coder' | 'reviewer';
102
102
 
103
- export interface PlaybookRuntimeOptions extends DecideInput {
104
- playerBinding?: Partial<Record<PlayerName, string>>;
105
- }
106
-
107
- const DEFAULT_PLAYER_BINDING: Readonly<Record<PlayerName, string>> = {
108
- Coder: 'coder',
109
- Reviewer: 'reviewer',
110
- };
103
+ export type PlaybookRuntimeOptions = DecideInput;
111
104
 
112
105
  function snapshotDecideRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
113
106
  const captured = snapshotJsonValue(value, 'DECIDE runtime options');
114
107
  if (!isPlainObject(captured)) {
115
108
  throw new TypeError('DECIDE runtime options must be an object');
116
109
  }
117
- const allowed = new Set(['coderLlm', 'playerBinding']);
118
- for (const key of Object.keys(captured)) {
119
- if (!allowed.has(key)) {
120
- throw new TypeError(`DECIDE runtime options.${key} is not declared`);
121
- }
122
- }
123
- if (
124
- typeof captured.coderLlm !== 'string' ||
125
- captured.coderLlm.trim().length === 0
126
- ) {
127
- throw new TypeError(
128
- 'DECIDE runtime options.coderLlm must be a non-empty string',
129
- );
110
+ const [unknown] = Object.keys(captured);
111
+ if (unknown !== undefined) {
112
+ throw new TypeError(`DECIDE runtime options.${unknown} is not declared`);
130
113
  }
131
- if ('playerBinding' in captured) {
132
- const playerBinding = captured.playerBinding;
133
- if (!isPlainObject(playerBinding)) {
134
- throw new TypeError(
135
- 'DECIDE runtime options.playerBinding must be an object',
136
- );
137
- }
138
- const playerNames = new Set<PlayerName>(['Coder', 'Reviewer']);
139
- for (const [player, playerId] of Object.entries(playerBinding)) {
140
- if (!playerNames.has(player as PlayerName)) {
141
- throw new TypeError(
142
- `DECIDE runtime options.playerBinding.${player} is not declared`,
143
- );
144
- }
145
- if (typeof playerId !== 'string' || playerId.trim().length === 0) {
146
- throw new TypeError(
147
- `DECIDE runtime options.playerBinding.${player} must be a non-empty string`,
148
- );
114
+ return Object.freeze({});
115
+ }
116
+
117
+ interface AuthoredStateConfig {
118
+ readonly meta?: {
119
+ readonly playbook?: {
120
+ readonly stateId?: unknown;
121
+ readonly description?: unknown;
122
+ };
123
+ };
124
+ readonly states?: Readonly<Record<string, AuthoredStateConfig>>;
125
+ }
126
+
127
+ function authoredStateDescriptions(
128
+ states: Readonly<Record<string, AuthoredStateConfig>> | undefined,
129
+ ): Readonly<Record<string, string>> {
130
+ const descriptions: Record<string, string> = {};
131
+ const visit = (
132
+ children: Readonly<Record<string, AuthoredStateConfig>> | undefined,
133
+ ): void => {
134
+ for (const state of Object.values(children ?? {})) {
135
+ const stateId = state.meta?.playbook?.stateId;
136
+ const description = state.meta?.playbook?.description;
137
+ if (
138
+ typeof stateId === 'string' &&
139
+ typeof description === 'string' &&
140
+ description.trim().length > 0
141
+ ) {
142
+ const existing = descriptions[stateId];
143
+ if (existing !== undefined && existing !== description) {
144
+ throw new Error(
145
+ `DECIDE state ${stateId} declares conflicting descriptions`,
146
+ );
147
+ }
148
+ descriptions[stateId] = description;
149
149
  }
150
+ visit(state.states);
150
151
  }
151
- }
152
- return captured as unknown as PlaybookRuntimeOptions;
152
+ };
153
+ visit(states);
154
+ return Object.freeze(descriptions);
153
155
  }
154
156
 
155
- const STATE_DESCRIPTIONS: Readonly<Record<string, string>> = {
156
- ready: 'Waiting for a topic to decide.',
157
- askCoderProposal: 'Coder independently proposes a spec design.',
158
- askReviewerProposal: 'Reviewer independently proposes a spec design.',
159
- waitCoderProposalReply: 'Coder waits for Boss to answer a question.',
160
- waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
161
- commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
162
- awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
163
- reviewCommit: 'REVIEW examines the committed proposal.',
164
- failed: 'DECIDE failed and is waiting for a new topic.',
165
- reportedReviewFailure:
166
- 'DECIDE reports REVIEW’s failure and its last commit.',
167
- done: 'DECIDE completed with an approved commit.',
168
- };
157
+ const STATE_DESCRIPTIONS = authoredStateDescriptions(
158
+ decideMachine.config.states as
159
+ | Readonly<Record<string, AuthoredStateConfig>>
160
+ | undefined,
161
+ );
169
162
 
170
- const PLAYER_STATES = [
171
- { stateId: 'askCoderProposal', player: 'Coder', sourceItem: 'DECIDE-1' },
163
+ const ROLE_STATES = [
164
+ { stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
172
165
  {
173
166
  stateId: 'askReviewerProposal',
174
- player: 'Reviewer',
167
+ role: 'reviewer',
175
168
  sourceItem: 'DECIDE-2',
176
169
  },
177
- { stateId: 'commitCoderProposal', player: 'Coder', sourceItem: 'DECIDE-3' },
170
+ { stateId: 'commitCoderProposal', role: 'coder', sourceItem: 'DECIDE-3' },
178
171
  ] as const;
179
172
 
180
- const PLAYER_STATE_IDS: ReadonlySet<string> = new Set(
181
- PLAYER_STATES.map((state) => state.stateId),
173
+ const ROLE_STATE_IDS: ReadonlySet<string> = new Set(
174
+ ROLE_STATES.map((state) => state.stateId),
182
175
  );
183
176
 
177
+ const ROLE_IDS = ['coder', 'reviewer'] as const;
178
+ const ROLE_ID_SET: ReadonlySet<string> = new Set(ROLE_IDS);
179
+
180
+ const roleLabel = (roleId: RoleId): string =>
181
+ roleId === 'coder' ? 'Coder' : 'Reviewer';
182
+
184
183
  const BOSS_INTERRUPT_TARGETS = ['independentProposals'] as const;
185
184
 
186
185
  const BOSS_INTERRUPT_TARGET_IDS: ReadonlySet<string> = new Set(
@@ -194,10 +193,7 @@ const CONTINUATION_PREAMBLE =
194
193
  'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
195
194
 
196
195
  const PLACEHOLDER_FIELDS: ReadonlyArray<readonly [string, keyof PlayerInput]> =
197
- [
198
- ['<caller-topic>', 'callerTopic'],
199
- ['<coder-llm>', 'coderLlm'],
200
- ];
196
+ [['<caller-topic>', 'callerTopic']];
201
197
 
202
198
  const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
203
199
  'coderProposal',
@@ -205,7 +201,12 @@ const VERBATIM_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
205
201
  'coderOutput',
206
202
  ]);
207
203
 
208
- function composePlayerPrompt(input: PlayerInput): string {
204
+ type PromptIdentity = (roleId: RoleId) => string;
205
+
206
+ function composePlayerPrompt(
207
+ input: PlayerInput,
208
+ promptIdentity: PromptIdentity,
209
+ ): string {
209
210
  const blocks: string[] = [];
210
211
 
211
212
  if (input.pendingBossQuestion && input.bossReply !== undefined) {
@@ -227,6 +228,9 @@ function composePlayerPrompt(input: PlayerInput): string {
227
228
  const value = input[field];
228
229
  if (typeof value === 'string') replacements.set(placeholder, value);
229
230
  }
231
+ if (input.prompt.includes('<coder-llm>')) {
232
+ replacements.set('<coder-llm>', promptIdentity('coder'));
233
+ }
230
234
  const body = input.prompt.replace(
231
235
  /<caller-topic>|<coder-llm>/g,
232
236
  (placeholder, offset: number, source: string) => {
@@ -243,22 +247,6 @@ function composePlayerPrompt(input: PlayerInput): string {
243
247
  return blocks.join('\n\n');
244
248
  }
245
249
 
246
- function resolvePlayerId(
247
- input: PlayerInput,
248
- binding: Record<PlayerName, string>,
249
- ): string {
250
- switch (input.player) {
251
- case 'Coder':
252
- return binding.Coder;
253
- case 'Reviewer':
254
- return binding.Reviewer;
255
- default: {
256
- const exhaustive: never = input.player;
257
- throw new Error(`unknown player ${String(exhaustive)}`);
258
- }
259
- }
260
- }
261
-
262
250
  // A `result` description names required payload fields in its
263
251
  // "Output shall include ..." sentence.
264
252
  function requiredFieldsFor(description: string): string[] {
@@ -413,7 +401,7 @@ function buildClassifierPrompt(
413
401
  lines.push('Pending Boss questions:');
414
402
  for (const pending of ctx.pendingQuestions) {
415
403
  lines.push(
416
- `- ${pending.questionId} (${pending.player}): ${pending.question}`,
404
+ `- ${pending.questionId} (${pending.asker.roleId}): ${pending.question}`,
417
405
  );
418
406
  }
419
407
  lines.push(
@@ -513,7 +501,7 @@ function buildAdjudicatorPrompt(
513
501
  'and guard descriptions. Reply with exactly one JSON object and no prose.',
514
502
  );
515
503
  lines.push(
516
- `The player "${input.player}" produced the output below for source item ${input.sourceItem}.`,
504
+ `The role "${roleLabel(input.role)}" produced the output below for source item ${input.sourceItem}.`,
517
505
  );
518
506
  lines.push('Choose exactly one guard whose description matches that output.');
519
507
  lines.push('');
@@ -627,6 +615,27 @@ function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
627
615
  return signal.aborted && Object.is(error, signal.reason);
628
616
  }
629
617
 
618
+ interface AbortReasonClassifier {
619
+ isAbortReason(error: unknown): boolean;
620
+ }
621
+
622
+ function abortReasonClassifier(
623
+ ...sources: readonly (AbortSignal | AbortReasonClassifier | undefined)[]
624
+ ): AbortReasonClassifier {
625
+ const captured = sources.filter(
626
+ (source): source is AbortSignal | AbortReasonClassifier =>
627
+ source !== undefined,
628
+ );
629
+ return Object.freeze({
630
+ isAbortReason: (error: unknown): boolean =>
631
+ captured.some((source) =>
632
+ source instanceof AbortSignal
633
+ ? isAbortFailure(error, source)
634
+ : source.isAbortReason(error),
635
+ ),
636
+ });
637
+ }
638
+
630
639
  function pendingQuestionsFromContext(
631
640
  context: Record<string, unknown>,
632
641
  ): PendingBossQuestion[] {
@@ -649,7 +658,9 @@ function pendingQuestionsFromContext(
649
658
  obj.questionId === key &&
650
659
  typeof obj.resumeStateId === 'string' &&
651
660
  typeof obj.sourceItem === 'string' &&
652
- typeof obj.player === 'string' &&
661
+ isPlainObject(obj.asker) &&
662
+ obj.asker.kind === 'role' &&
663
+ ROLE_ID_SET.has(String(obj.asker.roleId)) &&
653
664
  typeof obj.question === 'string'
654
665
  ) {
655
666
  questions.push(obj as unknown as PendingBossQuestion);
@@ -671,11 +682,38 @@ const WAIT_STATE_IDS: ReadonlySet<string> = new Set([
671
682
  ]);
672
683
 
673
684
  const STATUS_STATE_IDS: ReadonlySet<string> = new Set([
674
- ...PLAYER_STATE_IDS,
685
+ ...ROLE_STATE_IDS,
675
686
  ...WAIT_STATE_IDS,
676
687
  'failed',
677
688
  ]);
678
689
 
690
+ // PBRT-45: a question is pending only while its authored reply-wait state
691
+ // is active. The context retains an answered question through the resumed
692
+ // player call so the Q+A continuation prompt can quote it, and each branch
693
+ // keeps its own entry through the parallel region — so an unfiltered
694
+ // projection would report the answered question as still awaiting during
695
+ // the resume, and both branch questions after only one remains pending.
696
+ const RESUME_WAIT_STATE_IDS: Readonly<Record<string, string>> = {
697
+ ...Object.fromEntries(
698
+ Object.entries(WAIT_STATE_RESUME_IDS).map(([waitStateId, resumeStateId]) => [
699
+ resumeStateId,
700
+ waitStateId,
701
+ ]),
702
+ ),
703
+ commitCoderProposal: 'awaitBossReply',
704
+ };
705
+
706
+ function pendingQuestionsForState(
707
+ state: PlaybookState,
708
+ context: Record<string, unknown>,
709
+ ): PendingBossQuestion[] {
710
+ return pendingQuestionsFromContext(context).filter((pending) =>
711
+ state.activeStateIds.includes(
712
+ RESUME_WAIT_STATE_IDS[pending.resumeStateId] ?? '',
713
+ ),
714
+ );
715
+ }
716
+
679
717
  function questionForWaitState(
680
718
  stateId: string,
681
719
  pendingQuestions: readonly PendingBossQuestion[],
@@ -729,7 +767,7 @@ function telemetryPayload(
729
767
  event: unknown,
730
768
  context: Record<string, unknown>,
731
769
  ): JsonValue {
732
- const pendingBossQuestions = pendingQuestionsFromContext(context);
770
+ const pendingBossQuestions = pendingQuestionsForState(state, context);
733
771
  const prior = previousState ?? state;
734
772
  const payload = {
735
773
  from: prior.value,
@@ -749,14 +787,7 @@ function telemetryPayload(
749
787
  export const createPlaybookRuntime: PlaybookRuntimeFactory<
750
788
  PlaybookRuntimeOptions
751
789
  > = (options) => {
752
- const boundOptions = snapshotDecideRuntimeOptions(options);
753
- const binding: Record<PlayerName, string> = {
754
- ...DEFAULT_PLAYER_BINDING,
755
- ...(boundOptions.playerBinding ?? {}),
756
- };
757
- const fsmInput: DecideInput = {
758
- coderLlm: boundOptions.coderLlm,
759
- };
790
+ const fsmInput = snapshotDecideRuntimeOptions(options);
760
791
 
761
792
  type SessionIdentity = Readonly<PlaybookSession>;
762
793
 
@@ -764,6 +795,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
764
795
  let sessionIdentity: SessionIdentity | undefined;
765
796
  let actor: ReturnType<typeof createActor> | undefined;
766
797
  let currentSignal: AbortSignal | undefined;
798
+ let currentAborts: AbortReasonClassifier | undefined;
799
+ const actorSettlementAborts: AbortReasonClassifier[] = [];
800
+ let actorSettlementErrorAborts: AbortReasonClassifier | undefined;
767
801
  let currentTurnId: number | undefined;
768
802
  let previousState: PlaybookState | undefined;
769
803
  let suppressInspectionEmissions = false;
@@ -779,9 +813,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
779
813
  let disposalPromise: Promise<void> | undefined;
780
814
  let controlPlaneError: unknown;
781
815
  let nestedBridge: NestedPlaybookBridge<PlaybookInput>;
782
- const playerResumeTokens = new Map<string, string>();
816
+ const privateResumeTokens = new Map<string, string>();
783
817
  const playbookCallTurnIds = new Map<string, number | undefined>();
784
- const inFlightPlayerIds = new Set<string>();
818
+ const inFlightPlayerKeys = new Set<string>();
785
819
  const activeBoundaryCalls = new Set<Promise<unknown>>();
786
820
  const activeEmissionCalls = new Set<Promise<void>>();
787
821
  const emissionQueue = new PQueue({ concurrency: 1 });
@@ -802,26 +836,35 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
802
836
  ): void => {
803
837
  if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
804
838
  };
805
- const latchInspectionError = (error: unknown): void => {
806
- if (currentSignal !== undefined) {
807
- latchControlPlaneError(error, currentSignal);
808
- } else {
809
- collectFailure(emissionFailures, error);
810
- }
839
+ const latchInspectionError = (
840
+ error: unknown,
841
+ aborts: AbortReasonClassifier | undefined = currentAborts,
842
+ ): void => {
843
+ if (aborts?.isAbortReason(error)) return;
844
+ if (currentSignal !== undefined) controlPlaneError ??= error;
845
+ else collectFailure(emissionFailures, error);
811
846
  };
812
- const enqueue = (fn: () => Promise<void>): Promise<void> => {
847
+ const enqueue = (
848
+ fn: () => Promise<void>,
849
+ aborts: AbortReasonClassifier | undefined = currentAborts,
850
+ ): Promise<void> => {
851
+ const enqueueAborts = aborts;
813
852
  const queued = emissionQueue.add(fn);
814
853
  activeEmissionCalls.add(queued);
815
854
  void queued.then(
816
855
  () => activeEmissionCalls.delete(queued),
817
856
  (error: unknown) => {
818
857
  activeEmissionCalls.delete(queued);
819
- collectFailure(emissionFailures, error);
858
+ if (!enqueueAborts?.isAbortReason(error)) {
859
+ collectFailure(emissionFailures, error);
860
+ }
820
861
  },
821
862
  );
822
863
  return queued;
823
864
  };
824
- const flush = async (): Promise<void> => {
865
+ const flush = async (
866
+ _aborts: AbortReasonClassifier | undefined = currentAborts,
867
+ ): Promise<void> => {
825
868
  while (true) {
826
869
  const active = [...activeEmissionCalls];
827
870
  if (active.length > 0) await Promise.allSettled(active);
@@ -837,8 +880,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
837
880
  if (emissionFailures.length === 0) return;
838
881
  const failures = emissionFailures;
839
882
  emissionFailures = [];
840
- if (failures.length === 1) throw failures[0];
841
- throw new AggregateError(failures, 'decide runtime emissions failed');
883
+ const failure =
884
+ failures.length === 1
885
+ ? failures[0]
886
+ : new AggregateError(failures, 'decide runtime emissions failed');
887
+ // Enqueue ownership already classified every stored failure as distinct.
888
+ // Preserve that classification if an unrelated public boundary drains
889
+ // it with a signal whose reason happens to be the same object.
890
+ if (currentSignal !== undefined) controlPlaneError ??= failure;
891
+ throw failure;
842
892
  };
843
893
  const drainBoundaryCallsAndEmissions = async (): Promise<void> => {
844
894
  while (true) {
@@ -869,73 +919,176 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
869
919
  }
870
920
  return sessionIdentity;
871
921
  };
872
- const selectPlayerResume = (playerId: string): string | false => {
922
+
923
+ const bindSession = (nextSession: PlaybookSession): SessionIdentity => {
924
+ const bound = snapshotPlaybookSession(nextSession);
925
+ if (bound.roleBindings === undefined) return bound;
926
+ const actual = Object.keys(bound.roleBindings).sort();
927
+ const expected = [...ROLE_IDS].sort();
928
+ const missing = expected.filter((roleId) => !actual.includes(roleId));
929
+ const extra = actual.filter((roleId) => !ROLE_ID_SET.has(roleId));
930
+ if (missing.length > 0 || extra.length > 0) {
931
+ throw new TypeError(
932
+ `DECIDE session roleBindings must cover exactly [${expected.join(', ')}]` +
933
+ `${missing.length === 0 ? '' : `; missing [${missing.join(', ')}]`}` +
934
+ `${extra.length === 0 ? '' : `; extra [${extra.join(', ')}]`}`,
935
+ );
936
+ }
937
+ return bound;
938
+ };
939
+
940
+ const resolvedPlayerId = (roleId: RoleId): string | undefined =>
941
+ requireSessionIdentity().roleBindings?.[roleId]?.playerId;
942
+
943
+ const promptIdentity = (roleId: RoleId): string =>
944
+ requireSessionIdentity().roleBindings?.[roleId]?.promptIdentity ?? roleId;
945
+
946
+ const composeInvocationPrompt = (input: PlayerInput): string => {
947
+ let active = true;
948
+ const lookup: PromptIdentity = (roleId) => {
949
+ if (!active) {
950
+ throw new Error(
951
+ 'DECIDE prompt identity lookup is no longer active for this invocation',
952
+ );
953
+ }
954
+ if (!ROLE_ID_SET.has(roleId)) {
955
+ throw new TypeError(
956
+ `DECIDE prompt identity lookup rejected undeclared role ${String(roleId)}`,
957
+ );
958
+ }
959
+ return promptIdentity(roleId);
960
+ };
961
+ try {
962
+ return composePlayerPrompt(input, lookup);
963
+ } finally {
964
+ active = false;
965
+ }
966
+ };
967
+
968
+ const continuationKey = (
969
+ roleId: RoleId,
970
+ playerId: string | undefined,
971
+ ): string => playerId ?? roleId;
972
+
973
+ const tokensByContinuationKey = (
974
+ tokens: Readonly<Record<string, string>>,
975
+ ): Map<string, string> => {
976
+ const byKey = new Map<string, string>();
977
+ for (const [roleId, token] of Object.entries(tokens)) {
978
+ if (!ROLE_ID_SET.has(roleId)) {
979
+ throw new TypeError(
980
+ `DECIDE role tokens contain unknown role ${roleId}`,
981
+ );
982
+ }
983
+ const typedRole = roleId as RoleId;
984
+ const key = continuationKey(typedRole, resolvedPlayerId(typedRole));
985
+ const prior = byKey.get(key);
986
+ if (prior !== undefined && prior !== token) {
987
+ throw new TypeError(
988
+ `DECIDE runtime snapshot assigns conflicting tokens to roles bound to player ${key}`,
989
+ );
990
+ }
991
+ byKey.set(key, token);
992
+ }
993
+ const rolesByKey = new Map<string, RoleId[]>();
994
+ for (const roleId of ROLE_IDS) {
995
+ const key = continuationKey(roleId, resolvedPlayerId(roleId));
996
+ rolesByKey.set(key, [...(rolesByKey.get(key) ?? []), roleId]);
997
+ }
998
+ for (const [key, roles] of rolesByKey) {
999
+ if (roles.length < 2) continue;
1000
+ const present = roles.filter((roleId) => tokens[roleId] !== undefined);
1001
+ if (present.length !== 0 && present.length !== roles.length) {
1002
+ throw new TypeError(
1003
+ `DECIDE role tokens must project player ${key} through every aliased role [${roles.join(', ')}]`,
1004
+ );
1005
+ }
1006
+ }
1007
+ return byKey;
1008
+ };
1009
+
1010
+ const selectPlayerResume = (
1011
+ roleId: RoleId,
1012
+ playerId: string | undefined,
1013
+ ): string | false => {
873
1014
  const session = requireSessionIdentity();
874
1015
  const selected = session.playerSessions
875
- ? session.playerSessions.select(playerId)
876
- : playerResumeTokens.get(playerId) ?? false;
1016
+ ? session.playerSessions.select(roleId)
1017
+ : privateResumeTokens.get(continuationKey(roleId, playerId)) ?? false;
877
1018
  if (
878
1019
  selected !== false &&
879
1020
  (typeof selected !== 'string' || selected.trim().length === 0)
880
1021
  ) {
881
1022
  throw new TypeError(
882
- `player session store returned an invalid resume token for ${playerId}`,
1023
+ `player session store returned an invalid resume token for role ${roleId}`,
883
1024
  );
884
1025
  }
885
1026
  return selected;
886
1027
  };
887
1028
  const updatePlayerResume = (
888
- playerId: string,
889
- resumeToken: string | undefined,
1029
+ roleId: RoleId,
1030
+ playerId: string | undefined,
1031
+ result: PlayerResult,
890
1032
  ): void => {
1033
+ if (result.resumeToken === undefined && result.status !== 'ok') return;
891
1034
  const session = requireSessionIdentity();
892
1035
  if (session.playerSessions) {
893
- session.playerSessions.update(playerId, resumeToken);
894
- } else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
895
- playerResumeTokens.set(playerId, resumeToken);
1036
+ session.playerSessions.update(roleId, result.resumeToken);
1037
+ } else if (result.resumeToken !== undefined) {
1038
+ privateResumeTokens.set(
1039
+ continuationKey(roleId, playerId),
1040
+ result.resumeToken,
1041
+ );
896
1042
  } else {
897
- playerResumeTokens.delete(playerId);
1043
+ privateResumeTokens.delete(continuationKey(roleId, playerId));
898
1044
  }
899
1045
  };
900
- const snapshotPlayerResumeTokens = (): Record<string, string> => {
1046
+ const snapshotRoleResumeTokens = (): Record<string, string> => {
901
1047
  const session = requireSessionIdentity();
902
1048
  const captured = snapshotJsonValue(
903
1049
  session.playerSessions
904
1050
  ? session.playerSessions.snapshot()
905
- : Object.fromEntries(playerResumeTokens),
1051
+ : Object.fromEntries(
1052
+ ROLE_IDS.flatMap((roleId) => {
1053
+ const token = privateResumeTokens.get(
1054
+ continuationKey(roleId, resolvedPlayerId(roleId)),
1055
+ );
1056
+ return token === undefined ? [] : [[roleId, token]];
1057
+ }),
1058
+ ),
906
1059
  'player session store snapshot',
907
1060
  );
908
1061
  if (!isPlainObject(captured)) {
909
1062
  throw new TypeError('player session store snapshot must be an object');
910
1063
  }
911
1064
  const tokens: Record<string, string> = {};
912
- for (const [playerId, token] of Object.entries(captured)) {
913
- if (playerId.trim().length === 0) {
1065
+ for (const [roleId, token] of Object.entries(captured)) {
1066
+ if (!ROLE_ID_SET.has(roleId)) {
914
1067
  throw new TypeError(
915
- 'player session store snapshot player ids must be non-empty',
1068
+ `player session store snapshot contains unknown role ${roleId}`,
916
1069
  );
917
1070
  }
918
1071
  if (typeof token !== 'string' || token.trim().length === 0) {
919
1072
  throw new TypeError(
920
- `player session store snapshot token for ${playerId} must be a non-empty string`,
1073
+ `player session store snapshot token for ${roleId} must be a non-empty string`,
921
1074
  );
922
1075
  }
923
- tokens[playerId] = token;
1076
+ tokens[roleId] = token;
924
1077
  }
1078
+ tokensByContinuationKey(tokens);
925
1079
  return tokens;
926
1080
  };
927
- const restorePlayerResumeTokens = (
1081
+ const restoreRoleResumeTokens = (
928
1082
  tokens: Readonly<Record<string, string>>,
929
1083
  ): void => {
1084
+ const byKey = tokensByContinuationKey(tokens);
930
1085
  const session = requireSessionIdentity();
931
1086
  if (session.playerSessions) {
932
1087
  session.playerSessions.restore(tokens);
933
1088
  return;
934
1089
  }
935
- playerResumeTokens.clear();
936
- for (const [playerId, token] of Object.entries(tokens)) {
937
- playerResumeTokens.set(playerId, token);
938
- }
1090
+ privateResumeTokens.clear();
1091
+ for (const [key, token] of byKey) privateResumeTokens.set(key, token);
939
1092
  };
940
1093
  const currentState = (
941
1094
  pendingCall: PlaybookPendingCall | undefined =
@@ -957,12 +1110,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
957
1110
  payload: unknown,
958
1111
  meta: { turnId?: number; callId?: string } = {},
959
1112
  describedEmission?: (runtimePorts: PlaybookPorts) => Promise<void>,
1113
+ aborts?: AbortReasonClassifier,
960
1114
  ): Promise<void> => {
961
1115
  const runtimePorts = requirePorts();
962
1116
  const identity = requireSessionIdentity();
963
1117
  const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
964
1118
  const trace: PlaybookTraceEvent = Object.freeze({
965
- schemaVersion: 2,
1119
+ schemaVersion: 3,
966
1120
  sessionId: identity.sessionId,
967
1121
  playbookId: identity.playbookId,
968
1122
  rootSessionId: identity.rootSessionId,
@@ -980,16 +1134,20 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
980
1134
  ...(meta.callId !== undefined ? { callId: meta.callId } : {}),
981
1135
  payload: jsonPayload,
982
1136
  });
983
- return enqueue(async () => {
984
- await runtimePorts.emitTelemetry({ topic: TRACE_TOPIC, payload: trace });
985
- await describedEmission?.(runtimePorts);
986
- });
1137
+ return enqueue(
1138
+ async () => {
1139
+ await runtimePorts.emitTelemetry({ topic: TRACE_TOPIC, payload: trace });
1140
+ await describedEmission?.(runtimePorts);
1141
+ },
1142
+ aborts,
1143
+ );
987
1144
  };
988
1145
  const emitTrace = (
989
1146
  type: PlaybookTraceType,
990
1147
  payload: unknown,
991
1148
  meta: { turnId?: number; callId?: string } = {},
992
- ): Promise<void> => enqueueTracedEmission(type, payload, meta);
1149
+ aborts?: AbortReasonClassifier,
1150
+ ): Promise<void> => enqueueTracedEmission(type, payload, meta, undefined, aborts);
993
1151
  const emitBoundaryStatus = async (
994
1152
  message: string,
995
1153
  state: PlaybookState,
@@ -1018,8 +1176,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1018
1176
  meta: { turnId?: number; callId?: string },
1019
1177
  signal: AbortSignal,
1020
1178
  ): Promise<void> => {
1179
+ const aborts = abortReasonClassifier(signal);
1021
1180
  try {
1022
- await emitTrace(startedType, identity, meta);
1181
+ await emitTrace(startedType, identity, meta, aborts);
1023
1182
  } catch (error) {
1024
1183
  latchControlPlaneError(error, signal);
1025
1184
  try {
@@ -1027,13 +1186,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1027
1186
  finishedType,
1028
1187
  {
1029
1188
  ...identity,
1030
- status: 'error',
1189
+ // A started-trace sink rejection causally identical to the
1190
+ // boundary reason is the abort's own evidence: the pair
1191
+ // finishes 'aborted', not 'error' (DR-036 §4).
1192
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1031
1193
  error: normalizeErrorFull(error) ?? {
1032
1194
  name: 'Error',
1033
1195
  message: String(error),
1034
1196
  },
1035
1197
  },
1036
1198
  meta,
1199
+ aborts,
1037
1200
  );
1038
1201
  } catch {
1039
1202
  // Preserve the start failure after one best-effort finish attempt.
@@ -1048,6 +1211,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1048
1211
  purpose: 'boss-input-classification' | 'player-output-adjudication',
1049
1212
  callStateId: string | undefined,
1050
1213
  ): Promise<string> => {
1214
+ const aborts = abortReasonClassifier(signal);
1051
1215
  const identity = {
1052
1216
  purpose,
1053
1217
  ...(callStateId !== undefined ? { stateId: callStateId } : {}),
@@ -1083,13 +1247,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1083
1247
  'judge.call.finished',
1084
1248
  {
1085
1249
  ...identity,
1086
- status: signal.aborted ? 'aborted' : 'error',
1250
+ // Only the exact abort reason is cancellation; a distinct
1251
+ // failure under an aborted signal stays an error
1252
+ // (slc/link.md §Abort).
1253
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1087
1254
  error: normalizeErrorFull(error) ?? {
1088
1255
  name: 'Error',
1089
1256
  message: String(error),
1090
1257
  },
1091
1258
  },
1092
1259
  { turnId: currentTurnId, callId },
1260
+ aborts,
1093
1261
  );
1094
1262
  throw error;
1095
1263
  }
@@ -1098,6 +1266,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1098
1266
  'judge.call.finished',
1099
1267
  { ...identity, status: 'ok', reply: finalText },
1100
1268
  { turnId: currentTurnId, callId },
1269
+ aborts,
1101
1270
  );
1102
1271
  return finalText;
1103
1272
  });
@@ -1118,28 +1287,35 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1118
1287
  const runPlayerCall = async (
1119
1288
  input: PlayerInput,
1120
1289
  signal: AbortSignal,
1121
- ): Promise<{ playerId: string; result: PlayerResult }> => {
1122
- const playerId = resolvePlayerId(input, binding);
1123
- if (inFlightPlayerIds.has(playerId)) {
1124
- throw new Error(
1125
- `resolved player "${playerId}" already has an in-flight call`,
1290
+ ): Promise<{
1291
+ roleId: RoleId;
1292
+ playerId?: string;
1293
+ result: PlayerResult;
1294
+ }> => {
1295
+ const aborts = abortReasonClassifier(signal);
1296
+ if (!ROLE_ID_SET.has(input.role)) {
1297
+ throw new TypeError(
1298
+ `DECIDE player input role must name a declared local role`,
1126
1299
  );
1127
1300
  }
1128
- const prompt = composePlayerPrompt(input);
1301
+ const roleId = input.role;
1302
+ const playerId = resolvedPlayerId(roleId);
1303
+ const playerKey = continuationKey(roleId, playerId);
1304
+ const prompt = composeInvocationPrompt(input);
1129
1305
  let resume: PlayerCallOptions['resume'];
1130
1306
  try {
1131
1307
  signal.throwIfAborted();
1132
- resume = selectPlayerResume(playerId);
1308
+ resume = selectPlayerResume(roleId, playerId);
1133
1309
  } catch (error) {
1134
1310
  latchControlPlaneError(error, signal);
1135
1311
  throw error;
1136
1312
  }
1137
1313
  const callId = `player-${++playerCallSequence}`;
1138
1314
  const identity = {
1139
- purpose: 'captain',
1140
1315
  stateId: input.stateId,
1141
1316
  sourceItem: input.sourceItem,
1142
- playerId,
1317
+ roleId,
1318
+ ...(playerId === undefined ? {} : { playerId }),
1143
1319
  resume,
1144
1320
  };
1145
1321
  const emitFailure = (error: unknown): Promise<void> =>
@@ -1147,15 +1323,33 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1147
1323
  'player.call.finished',
1148
1324
  {
1149
1325
  ...identity,
1150
- status: signal.aborted ? 'aborted' : 'error',
1326
+ // Only the exact abort reason is cancellation; a distinct
1327
+ // failure under an aborted signal stays an error
1328
+ // (slc/link.md §Abort).
1329
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1151
1330
  error: normalizeErrorFull(error) ?? {
1152
1331
  name: 'Error',
1153
1332
  message: String(error),
1154
1333
  },
1155
1334
  },
1156
1335
  { turnId: currentTurnId, callId },
1336
+ aborts,
1337
+ );
1338
+ if (inFlightPlayerKeys.has(playerKey)) {
1339
+ const error = new Error(
1340
+ `resolved player key "${playerKey}" already has an in-flight call`,
1341
+ );
1342
+ await emitCallStarted(
1343
+ 'player.call.started',
1344
+ 'player.call.finished',
1345
+ { ...identity, prompt },
1346
+ { turnId: currentTurnId, callId },
1347
+ signal,
1157
1348
  );
1158
- inFlightPlayerIds.add(playerId);
1349
+ await emitFailure(error);
1350
+ throw error;
1351
+ }
1352
+ inFlightPlayerKeys.add(playerKey);
1159
1353
  try {
1160
1354
  await emitCallStarted(
1161
1355
  'player.call.started',
@@ -1169,7 +1363,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1169
1363
  try {
1170
1364
  signal.throwIfAborted();
1171
1365
  const boundary = Promise.resolve(
1172
- requirePorts().callPlayer(playerId, prompt, signal, { resume }),
1366
+ requirePorts().callPlayer(roleId, prompt, signal, { resume }),
1173
1367
  );
1174
1368
  rawResult = await boundary;
1175
1369
  // An XState sibling cancellation does not cancel an arbitrary coder
@@ -1205,13 +1399,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1205
1399
  // The resolved result is authoritative even on aborted/error status.
1206
1400
  // Update continuation state before interpreting that status.
1207
1401
  try {
1208
- updatePlayerResume(
1209
- playerId,
1210
- typeof result.resumeToken === 'string' &&
1211
- result.resumeToken.trim().length > 0
1212
- ? result.resumeToken
1213
- : undefined,
1214
- );
1402
+ updatePlayerResume(roleId, playerId, result);
1215
1403
  } catch (error) {
1216
1404
  latchControlPlaneError(error, signal);
1217
1405
  try {
@@ -1241,77 +1429,96 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1241
1429
  : {}),
1242
1430
  },
1243
1431
  { turnId: currentTurnId, callId },
1432
+ aborts,
1244
1433
  );
1245
- return { playerId, result };
1434
+ return {
1435
+ roleId,
1436
+ ...(playerId === undefined ? {} : { playerId }),
1437
+ result,
1438
+ };
1246
1439
  } finally {
1247
- inFlightPlayerIds.delete(playerId);
1440
+ inFlightPlayerKeys.delete(playerKey);
1248
1441
  }
1249
1442
  };
1250
1443
 
1251
1444
  const callPlayer = (
1252
1445
  input: PlayerInput,
1253
1446
  signal: AbortSignal,
1254
- ): Promise<{ playerId: string; result: PlayerResult }> => {
1447
+ ): Promise<{
1448
+ roleId: RoleId;
1449
+ playerId?: string;
1450
+ result: PlayerResult;
1451
+ }> => {
1255
1452
  return trackBoundaryCall(runPlayerCall(input, signal));
1256
1453
  };
1257
1454
 
1258
1455
  const player = fromPromise<PlayerOutput, PlayerInput>(
1259
1456
  async ({ input, signal }) => {
1260
1457
  const combined = combineSignals(signal, currentSignal);
1261
- // XState starts invoked actors while publishing the entering snapshot.
1262
- // Yield through the runtime emission queue before crossing the player
1263
- // boundary so state trace/status always precede its call-start trace.
1458
+ const settlementAborts = abortReasonClassifier(combined);
1264
1459
  try {
1265
- await flush();
1266
- } catch (error) {
1267
- latchControlPlaneError(error, combined);
1268
- throw error;
1269
- }
1270
- combined.throwIfAborted();
1271
-
1272
- let { playerId, result } = await callPlayer(input, combined);
1273
- if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
1274
- // DR-028: an `ok` result whose finalText is missing, empty, or
1275
- // whitespace-only earns exactly one corrective re-ask — the same
1276
- // composed call repeated, traced by runPlayerCall as its own
1277
- // player-call pair, with the resume selection re-read from the
1278
- // token map the first result left (PBRT-38). An abort that lands
1279
- // between the two calls ends the turn without the re-ask (aborts
1280
- // are never retried), and a rejecting finish emission rejects
1281
- // `callPlayer` itself, so it never reaches this branch (PBRT-47).
1460
+ // XState starts invoked actors while publishing the entering snapshot.
1461
+ // Yield through the runtime emission queue before crossing the player
1462
+ // boundary so state trace/status always precede its call-start trace.
1463
+ combined.throwIfAborted();
1464
+ try {
1465
+ await flush(settlementAborts);
1466
+ } catch (error) {
1467
+ latchControlPlaneError(error, combined);
1468
+ throw error;
1469
+ }
1282
1470
  combined.throwIfAborted();
1283
- ({ playerId, result } = await callPlayer(input, combined));
1284
- }
1285
- if (result.status !== 'ok') {
1286
- throw new Error(
1287
- `player "${playerId}" returned status "${result.status}"${
1288
- result.error ? `: ${result.error}` : ''
1289
- }`,
1290
- );
1291
- }
1292
- const finalText = result.finalText ?? '';
1293
- if (isEmptyFinalText(finalText)) {
1294
- throw new Error(
1295
- `player "${playerId}" returned status "ok" with no finalText`,
1296
- );
1297
- }
1298
- combined.throwIfAborted();
1299
1471
 
1300
- try {
1301
- const prompt = buildAdjudicatorPrompt(input, finalText);
1302
- return parseAdjudication(
1303
- await callJudge(
1304
- prompt,
1305
- combined,
1306
- 'player-output-adjudication',
1307
- input.stateId,
1308
- ),
1309
- input,
1310
- finalText,
1311
- );
1312
- } catch (error) {
1313
- latchControlPlaneError(error, combined);
1314
- throw error;
1472
+ let { roleId, playerId, result } = await callPlayer(input, combined);
1473
+ if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
1474
+ // DR-028: an `ok` result whose finalText is missing, empty, or
1475
+ // whitespace-only earns exactly one corrective re-ask — the same
1476
+ // composed call repeated, traced by runPlayerCall as its own
1477
+ // player-call pair, with the resume selection re-read from the
1478
+ // token map the first result left (PBRT-38). An abort that lands
1479
+ // between the two calls ends the turn without the re-ask (aborts
1480
+ // are never retried), and a rejecting finish emission rejects
1481
+ // `callPlayer` itself, so it never reaches this branch (PBRT-47).
1482
+ combined.throwIfAborted();
1483
+ ({ roleId, playerId, result } = await callPlayer(input, combined));
1484
+ }
1485
+ if (result.status !== 'ok') {
1486
+ throw new Error(
1487
+ `${roleLabel(roleId)}${
1488
+ playerId === undefined ? '' : ` (${playerId})`
1489
+ } returned status "${result.status}"${
1490
+ result.error ? `: ${result.error}` : ''
1491
+ }`,
1492
+ );
1493
+ }
1494
+ const finalText = result.finalText ?? '';
1495
+ if (isEmptyFinalText(finalText)) {
1496
+ throw new Error(
1497
+ `${roleLabel(roleId)}${
1498
+ playerId === undefined ? '' : ` (${playerId})`
1499
+ } returned status "ok" with no finalText`,
1500
+ );
1501
+ }
1502
+ combined.throwIfAborted();
1503
+
1504
+ try {
1505
+ const prompt = buildAdjudicatorPrompt(input, finalText);
1506
+ return parseAdjudication(
1507
+ await callJudge(
1508
+ prompt,
1509
+ combined,
1510
+ 'player-output-adjudication',
1511
+ input.stateId,
1512
+ ),
1513
+ input,
1514
+ finalText,
1515
+ );
1516
+ } catch (error) {
1517
+ latchControlPlaneError(error, combined);
1518
+ throw error;
1519
+ }
1520
+ } finally {
1521
+ actorSettlementAborts.push(settlementAborts);
1315
1522
  }
1316
1523
  },
1317
1524
  );
@@ -1323,7 +1530,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1323
1530
  trackBoundaryCall(
1324
1531
  Promise.resolve(requirePorts().callPlaybook(request, signal)),
1325
1532
  ),
1326
- emitStarted: async (event) => {
1533
+ emitStarted: async (event, aborts) => {
1327
1534
  playbookCallTurnIds.set(event.callId, currentTurnId);
1328
1535
  await emitTrace(
1329
1536
  'playbook.call.started',
@@ -1336,9 +1543,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1336
1543
  ...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
1337
1544
  callId: event.callId,
1338
1545
  },
1546
+ aborts,
1339
1547
  );
1340
1548
  },
1341
- emitFinished: async (event) => {
1549
+ emitFinished: async (event, aborts) => {
1342
1550
  const turnId = playbookCallTurnIds.get(event.callId);
1343
1551
  try {
1344
1552
  await emitTrace(
@@ -1353,23 +1561,32 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1353
1561
  ...(turnId === undefined ? {} : { turnId }),
1354
1562
  callId: event.callId,
1355
1563
  },
1564
+ aborts,
1356
1565
  );
1357
1566
  } finally {
1358
1567
  playbookCallTurnIds.delete(event.callId);
1359
1568
  }
1360
1569
  },
1361
1570
  drain: flush,
1362
- bindResumeSignal: (signal) => {
1571
+ bindResumeSignal: (signal, aborts) => {
1363
1572
  currentSignal = signal;
1573
+ currentAborts = aborts ?? abortReasonClassifier(signal);
1574
+ },
1575
+ bindActorSettlement: (aborts) => {
1576
+ actorSettlementAborts.push(aborts);
1364
1577
  },
1365
- onControlPlaneError: (error) => {
1366
- const signal = currentSignal;
1367
- if (!signal || !isAbortFailure(error, signal)) {
1578
+ onControlPlaneError: (error, aborts) => {
1579
+ if (
1580
+ !aborts?.isAbortReason(error) &&
1581
+ !currentAborts?.isAbortReason(error)
1582
+ ) {
1368
1583
  controlPlaneError ??= error;
1369
1584
  }
1370
1585
  },
1371
- onBackgroundError: (error) => {
1372
- collectFailure(emissionFailures, error);
1586
+ onBackgroundError: (error, aborts) => {
1587
+ if (!aborts?.isAbortReason(error)) {
1588
+ collectFailure(emissionFailures, error);
1589
+ }
1373
1590
  },
1374
1591
  });
1375
1592
 
@@ -1377,10 +1594,27 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1377
1594
  actors: { player, playbook: nestedBridge.actorLogic },
1378
1595
  });
1379
1596
 
1597
+ const consumeActorSettlementAborts = (
1598
+ forSnapshot = false,
1599
+ ): AbortReasonClassifier | undefined => {
1600
+ const aborts = actorSettlementAborts.shift() ?? actorSettlementErrorAborts;
1601
+ actorSettlementErrorAborts = undefined;
1602
+ if (forSnapshot && aborts !== undefined) {
1603
+ actorSettlementErrorAborts = aborts;
1604
+ queueMicrotask(() => {
1605
+ if (actorSettlementErrorAborts === aborts) {
1606
+ actorSettlementErrorAborts = undefined;
1607
+ }
1608
+ });
1609
+ }
1610
+ return aborts;
1611
+ };
1612
+
1380
1613
  const inspect = (event: InspectionEvent): void => {
1381
1614
  if (event.type !== '@xstate.snapshot') return;
1382
1615
  if (actor === undefined || event.actorRef !== actor) return;
1383
1616
  if (suppressInspectionEmissions) return;
1617
+ const settlementAborts = consumeActorSettlementAborts(true);
1384
1618
  try {
1385
1619
  const snapshot = event.snapshot as SnapshotFrom<typeof decideMachine>;
1386
1620
  const state = normalizePlaybookSnapshot(snapshot);
@@ -1401,6 +1635,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1401
1635
  topic: TELEMETRY_TOPIC,
1402
1636
  payload: describedFsmPayload,
1403
1637
  }),
1638
+ settlementAborts,
1404
1639
  ).catch(() => undefined);
1405
1640
 
1406
1641
  const priorIds = new Set(previousState?.activeStateIds ?? []);
@@ -1426,6 +1661,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1426
1661
  tracePayload,
1427
1662
  { turnId: currentTurnId },
1428
1663
  (emissionPorts) => emissionPorts.emitStatus(message, data),
1664
+ settlementAborts,
1429
1665
  ).catch(() => undefined);
1430
1666
  };
1431
1667
 
@@ -1443,11 +1679,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1443
1679
  );
1444
1680
  if (pending) {
1445
1681
  scheduleStatus(
1446
- `${pending.player} asks: ${pending.question}`,
1682
+ `${pending.asker.roleId} asks: ${pending.question}`,
1447
1683
  activeStateId,
1448
1684
  );
1449
1685
  scheduleStatus(
1450
- `◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.player} · ${pending.sourceItem}`,
1686
+ `◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.asker.roleId} · ${pending.sourceItem}`,
1451
1687
  activeStateId,
1452
1688
  );
1453
1689
  }
@@ -1459,19 +1695,19 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1459
1695
  : undefined;
1460
1696
  const description = STATE_DESCRIPTIONS[activeStateId];
1461
1697
  if (description === undefined) continue;
1462
- const playerState = PLAYER_STATES.find(
1698
+ const roleState = ROLE_STATES.find(
1463
1699
  (candidate) => candidate.stateId === activeStateId,
1464
1700
  );
1465
1701
  scheduleStatus(
1466
- playerState === undefined
1702
+ roleState === undefined
1467
1703
  ? '◆ workflow failed; awaiting Boss recovery.'
1468
- : `⤷ ${playerState.player}: ${description}`,
1704
+ : `⤷ ${roleLabel(roleState.role)}: ${description}`,
1469
1705
  activeStateId,
1470
1706
  lastError === undefined ? undefined : { lastError },
1471
1707
  );
1472
1708
  }
1473
1709
  } catch (error) {
1474
- latchInspectionError(error);
1710
+ latchInspectionError(error, settlementAborts);
1475
1711
  }
1476
1712
  };
1477
1713
 
@@ -1490,6 +1726,16 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1490
1726
  }),
1491
1727
  inspect,
1492
1728
  });
1729
+ // A synchronous FSM action throw errors the actor without any pending
1730
+ // boundary await to observe it; unobserved, XState would surface it via
1731
+ // reportUnhandledError as an uncaughtException. Observe it here: latch
1732
+ // it as a control error while a turn signal is active (unless it is
1733
+ // the abort reason itself), otherwise collect it with the emission
1734
+ // failures (slc/link.md §Abort).
1735
+ actor.subscribe({
1736
+ error: (error) =>
1737
+ latchInspectionError(error, consumeActorSettlementAborts()),
1738
+ });
1493
1739
  };
1494
1740
 
1495
1741
  // PBRT-6: the single seam that stops this runtime's actor. Stopping a
@@ -1529,7 +1775,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1529
1775
  const state = normalizePlaybookSnapshot(snapshot, {
1530
1776
  pendingCall: nestedBridge.getPendingCall(),
1531
1777
  });
1532
- const pendingQuestions = pendingQuestionsFromContext(context);
1778
+ const pendingQuestions = pendingQuestionsForState(state, context);
1533
1779
  if (
1534
1780
  pendingQuestions.length === 0 &&
1535
1781
  (snapshot.status === 'done' ||
@@ -1563,34 +1809,58 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1563
1809
  const pendingCall = nestedBridge.getPendingCall();
1564
1810
  const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
1565
1811
  const context = snapshot.context as unknown as Record<string, unknown>;
1566
- if (signal?.aborted) {
1567
- return {
1568
- outcome: 'aborted',
1569
- state,
1570
- ...(signal.reason === undefined
1571
- ? {}
1572
- : {
1573
- error: normalizeErrorFull(signal.reason) ?? {
1574
- name: 'AbortError',
1575
- message: String(signal.reason),
1576
- },
1577
- }),
1578
- };
1812
+ const abortedResult = (abortSignal: AbortSignal): PlaybookRunResult => ({
1813
+ outcome: 'aborted',
1814
+ state,
1815
+ ...(abortSignal.reason === undefined
1816
+ ? {}
1817
+ : {
1818
+ error: normalizeErrorFull(abortSignal.reason) ?? {
1819
+ name: 'AbortError',
1820
+ message: String(abortSignal.reason),
1821
+ },
1822
+ }),
1823
+ });
1824
+ if (snapshot.status === 'error') {
1825
+ // An errored actor outranks a coincident abort unless the actor's
1826
+ // error is the abort reason itself (slc/link.md §Abort).
1827
+ const actorError = (snapshot as { error?: unknown }).error;
1828
+ if (
1829
+ actorError !== undefined &&
1830
+ signal !== undefined &&
1831
+ isAbortFailure(actorError, signal)
1832
+ ) {
1833
+ return abortedResult(signal);
1834
+ }
1835
+ throw (
1836
+ actorError ?? new Error('decide runtime actor entered error status')
1837
+ );
1579
1838
  }
1839
+ // Terminal completion outranks a coincident abort (DR-036 §3): reporting
1840
+ // 'aborted' over a completed machine would hide a terminal state that the
1841
+ // next turn silently restarts, duplicating the workflow's side effects.
1580
1842
  if (snapshot.status === 'done') {
1581
1843
  const output = (snapshot as { output?: unknown }).output;
1582
1844
  if (output !== undefined) assertJsonSafe(output, 'terminal output');
1845
+ const stateDescription = state.activeStateIds.includes('done')
1846
+ ? STATE_DESCRIPTIONS.done
1847
+ : state.activeStateIds.includes('reportedReviewFailure')
1848
+ ? STATE_DESCRIPTIONS.reportedReviewFailure
1849
+ : undefined;
1850
+ if (stateDescription === undefined) {
1851
+ throw new Error(
1852
+ 'decide runtime: completed actor has no authored final-state description',
1853
+ );
1854
+ }
1583
1855
  return {
1584
1856
  outcome: 'terminal',
1585
1857
  state,
1858
+ stateDescription,
1586
1859
  ...(output === undefined ? {} : { output }),
1587
1860
  };
1588
1861
  }
1589
- if (snapshot.status === 'error') {
1590
- throw (
1591
- (snapshot as { error?: unknown }).error ??
1592
- new Error('decide runtime actor entered error status')
1593
- );
1862
+ if (signal?.aborted) {
1863
+ return abortedResult(signal);
1594
1864
  }
1595
1865
  if (state.activeStateIds.includes('failed')) {
1596
1866
  const error = normalizeErrorFull(context.lastError);
@@ -1652,14 +1922,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1652
1922
  // The session-start error remains authoritative.
1653
1923
  }
1654
1924
  }
1655
- playerResumeTokens.clear();
1656
- inFlightPlayerIds.clear();
1925
+ privateResumeTokens.clear();
1926
+ inFlightPlayerKeys.clear();
1657
1927
  activeBoundaryCalls.clear();
1658
1928
  activeEmissionCalls.clear();
1659
1929
  emissionQueue.clear();
1660
1930
  judgeQueue.clear();
1661
1931
  actor = undefined;
1662
1932
  currentSignal = undefined;
1933
+ currentAborts = undefined;
1934
+ actorSettlementAborts.length = 0;
1935
+ actorSettlementErrorAborts = undefined;
1663
1936
  currentTurnId = undefined;
1664
1937
  ports = undefined;
1665
1938
  sessionIdentity = undefined;
@@ -1688,7 +1961,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1688
1961
  'decide runtime: init(session) may only be called once',
1689
1962
  );
1690
1963
  }
1691
- const identity = snapshotPlaybookSession(session);
1964
+ const identity = bindSession(session);
1692
1965
  let finishInitialization!: () => void;
1693
1966
  const initialization = new Promise<void>((resolve) => {
1694
1967
  finishInitialization = resolve;
@@ -1773,10 +2046,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1773
2046
  actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
1774
2047
  ).context as unknown as Record<string, unknown>;
1775
2048
  return {
1776
- schemaVersion: 2,
2049
+ schemaVersion: 3,
1777
2050
  playbookId: sessionIdentity.playbookId,
1778
2051
  machine,
1779
- playerResumeTokens: snapshotPlayerResumeTokens(),
2052
+ roleResumeTokens: snapshotRoleResumeTokens(),
1780
2053
  sequences: {
1781
2054
  trace: traceSequence,
1782
2055
  turn: turnSequence,
@@ -1785,10 +2058,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1785
2058
  playbookCall: playbookCallSequence,
1786
2059
  },
1787
2060
  state,
1788
- pendingBossQuestions: pendingQuestionsFromContext(context).map(
2061
+ pendingBossQuestions: pendingQuestionsForState(state, context).map(
1789
2062
  (pending) => ({
1790
2063
  questionId: pending.questionId,
1791
- player: pending.player,
2064
+ asker: pending.asker,
1792
2065
  question: pending.question,
1793
2066
  sourceItem: pending.sourceItem,
1794
2067
  }),
@@ -1816,16 +2089,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1816
2089
  'decide runtime: restore(session, snapshot) may only be called once',
1817
2090
  );
1818
2091
  }
1819
- const identity = snapshotPlaybookSession(session);
2092
+ const identity = bindSession(session);
1820
2093
  const boundSnapshot = assertPlaybookRuntimeSnapshot(
1821
2094
  snapshot,
1822
2095
  identity.playbookId,
1823
2096
  { allowSuspendedCall: true },
1824
2097
  );
1825
- const suspendedCall =
1826
- boundSnapshot.schemaVersion === 2
1827
- ? boundSnapshot.suspendedCall
1828
- : undefined;
2098
+ const suspendedCall = boundSnapshot.suspendedCall;
1829
2099
  let finishInitialization!: () => void;
1830
2100
  const initialization = new Promise<void>((resolve) => {
1831
2101
  finishInitialization = resolve;
@@ -1834,7 +2104,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1834
2104
  lifecycleStarted = true;
1835
2105
  ports = identity.ports;
1836
2106
  sessionIdentity = identity;
1837
- let priorExternalPlayerTokens:
2107
+ let priorExternalRoleTokens:
1838
2108
  | Readonly<Record<string, string>>
1839
2109
  | undefined;
1840
2110
  try {
@@ -1844,9 +2114,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1844
2114
  playerCallSequence = boundSnapshot.sequences.playerCall;
1845
2115
  playbookCallSequence = boundSnapshot.sequences.playbookCall;
1846
2116
  if (identity.playerSessions) {
1847
- priorExternalPlayerTokens = snapshotPlayerResumeTokens();
2117
+ priorExternalRoleTokens = snapshotRoleResumeTokens();
1848
2118
  }
1849
- restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
2119
+ restoreRoleResumeTokens(boundSnapshot.roleResumeTokens);
1850
2120
  nestedBridge.prepareRestore(suspendedCall);
1851
2121
  if (suspendedCall !== undefined) {
1852
2122
  playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
@@ -1877,9 +2147,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1877
2147
  nestedBridge.confirmRestore();
1878
2148
  } catch (error) {
1879
2149
  let failure = error;
1880
- if (priorExternalPlayerTokens !== undefined) {
2150
+ if (priorExternalRoleTokens !== undefined) {
1881
2151
  try {
1882
- identity.playerSessions!.restore(priorExternalPlayerTokens);
2152
+ identity.playerSessions!.restore(priorExternalRoleTokens);
1883
2153
  } catch (rollbackError) {
1884
2154
  failure = new AggregateError(
1885
2155
  [error, rollbackError],
@@ -1915,12 +2185,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1915
2185
  const turnId = ++turnSequence;
1916
2186
  currentTurnId = turnId;
1917
2187
  currentSignal = turn.signal;
2188
+ currentAborts = abortReasonClassifier(turn.signal);
1918
2189
  controlPlaneError = undefined;
1919
2190
  let result: PlaybookRunResult = resultForSnapshot(turn.signal);
1920
2191
  let settlement: unknown = result;
1921
2192
  const failures: unknown[] = [];
1922
2193
  try {
1923
2194
  await emitTrace('boss.input.received', { text: turn.text }, { turnId });
2195
+ // A boundary entered aborted records the attempted input, then refuses
2196
+ // delivery before deterministic mapping or the classifier can perform
2197
+ // any host-visible work (DR-036 §5).
2198
+ turn.signal.throwIfAborted();
1924
2199
  if (turn.text.trim().length === 0) {
1925
2200
  const state = currentState();
1926
2201
  result = { outcome: 'no-action', state };
@@ -1953,15 +2228,18 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1953
2228
  };
1954
2229
  } catch (error) {
1955
2230
  const primaryError = controlPlaneError;
2231
+ // Only a rejection that is the exact abort reason settles as the
2232
+ // cancellation; a distinct failure observed while the signal is
2233
+ // aborted remains a control error (slc/link.md §Abort).
1956
2234
  if (primaryError !== undefined) {
1957
2235
  collectFailure(failures, primaryError);
1958
- } else if (!turn.signal.aborted) {
2236
+ } else if (!isAbortFailure(error, turn.signal)) {
1959
2237
  collectFailure(failures, error);
1960
2238
  }
1961
2239
  const state = currentState();
1962
2240
  const effectiveError = primaryError ?? error;
1963
2241
  result =
1964
- turn.signal.aborted && primaryError === undefined
2242
+ isAbortFailure(error, turn.signal) && primaryError === undefined
1965
2243
  ? resultForSnapshot(turn.signal)
1966
2244
  : {
1967
2245
  outcome: 'failed',
@@ -1982,13 +2260,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1982
2260
  } catch (error) {
1983
2261
  const primaryError = controlPlaneError;
1984
2262
  const effectiveError = primaryError ?? error;
1985
- collectFailure(failures, effectiveError);
2263
+ // A drain rejection that is the exact abort reason evidences the
2264
+ // cancellation, not a control-plane failure (slc/link.md §Abort).
2265
+ const drainAborted = isAbortFailure(effectiveError, turn.signal);
2266
+ if (!drainAborted) collectFailure(failures, effectiveError);
1986
2267
  const state = currentState();
1987
2268
  result = {
1988
- outcome:
1989
- turn.signal.aborted && primaryError === undefined
1990
- ? 'aborted'
1991
- : 'failed',
2269
+ outcome: drainAborted ? 'aborted' : 'failed',
1992
2270
  state,
1993
2271
  error: normalizeErrorFull(effectiveError) ?? {
1994
2272
  name: 'Error',
@@ -1997,18 +2275,28 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1997
2275
  };
1998
2276
  settlement = { ...result, ...stateIdentity(state) };
1999
2277
  }
2000
- currentSignal = undefined;
2001
2278
  try {
2002
2279
  await emitTrace('boss.input.settled', settlement, { turnId });
2003
2280
  } catch (error) {
2004
- collectFailure(failures, error);
2281
+ // A settlement-trace rejection that is the exact abort reason also
2282
+ // evidences the cancellation (slc/link.md §Abort).
2283
+ if (!isAbortFailure(error, turn.signal)) {
2284
+ collectFailure(failures, error);
2285
+ }
2005
2286
  }
2006
2287
  try {
2007
2288
  await flush();
2008
2289
  } catch (error) {
2009
- collectFailure(failures, error);
2290
+ // A late flush rejection that is the exact abort reason likewise
2291
+ // evidences the cancellation; the settled result already labels
2292
+ // the turn aborted then (slc/link.md §Abort).
2293
+ if (!isAbortFailure(error, turn.signal)) {
2294
+ collectFailure(failures, error);
2295
+ }
2010
2296
  } finally {
2011
2297
  const primaryError = controlPlaneError;
2298
+ currentSignal = undefined;
2299
+ currentAborts = undefined;
2012
2300
  currentTurnId = undefined;
2013
2301
  controlPlaneError = undefined;
2014
2302
  if (primaryError !== undefined) throw primaryError;
@@ -2044,6 +2332,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2044
2332
  }
2045
2333
  currentTurnId = playbookCallTurnIds.get(callId);
2046
2334
  currentSignal = signal;
2335
+ currentAborts = abortReasonClassifier(signal);
2047
2336
  controlPlaneError = undefined;
2048
2337
  let runResult: PlaybookRunResult | undefined;
2049
2338
  let operationError: unknown;
@@ -2070,13 +2359,56 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2070
2359
  } catch (error) {
2071
2360
  drainError = error;
2072
2361
  }
2073
- const failure = controlPlaneError ?? drainError ?? operationError;
2362
+ const aborts = currentAborts ?? abortReasonClassifier(signal);
2363
+ // The control latch has already classified its failure as distinct
2364
+ // under the operation that owned it. Only still-unclassified drain and
2365
+ // operation candidates may be cancellation evidence for this resume.
2366
+ const controlFailure = controlPlaneError;
2367
+ const drainAbort =
2368
+ controlFailure === undefined &&
2369
+ drainError !== undefined &&
2370
+ aborts.isAbortReason(drainError);
2371
+ const operationAbort =
2372
+ controlFailure === undefined &&
2373
+ operationError !== undefined &&
2374
+ aborts.isAbortReason(operationError);
2375
+ const abortEvidence =
2376
+ (drainAbort ? drainError : undefined) ??
2377
+ (operationAbort ? operationError : undefined);
2378
+ const failure =
2379
+ controlFailure ??
2380
+ (drainAbort ? undefined : drainError) ??
2381
+ (operationAbort ? undefined : operationError);
2074
2382
  currentSignal = undefined;
2383
+ currentAborts = undefined;
2075
2384
  currentTurnId = undefined;
2076
2385
  controlPlaneError = undefined;
2077
2386
  if (failure !== undefined) throw failure;
2387
+ if (
2388
+ abortEvidence !== undefined &&
2389
+ runResult?.outcome !== 'terminal' &&
2390
+ runResult?.outcome !== 'suspended'
2391
+ ) {
2392
+ const state = currentState();
2393
+ runResult = {
2394
+ outcome: 'aborted',
2395
+ state,
2396
+ error: normalizeErrorFull(abortEvidence) ?? {
2397
+ name: 'AbortError',
2398
+ message: String(abortEvidence),
2399
+ },
2400
+ };
2401
+ }
2078
2402
  if (runResult === undefined) {
2079
- throw new Error('decide runtime: playbook resume produced no result');
2403
+ if (signal.aborted) {
2404
+ // Every candidate was the abort's own evidence: settle on the
2405
+ // machine's state under the aborted boundary signal (DR-036 §4).
2406
+ runResult = resultForSnapshot(signal);
2407
+ } else {
2408
+ throw new Error(
2409
+ 'decide runtime: playbook resume produced no result',
2410
+ );
2411
+ }
2080
2412
  }
2081
2413
  return runResult;
2082
2414
  },
@@ -2123,15 +2455,18 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2123
2455
  } catch (error) {
2124
2456
  collectFailure(failures, error);
2125
2457
  } finally {
2126
- playerResumeTokens.clear();
2458
+ privateResumeTokens.clear();
2127
2459
  playbookCallTurnIds.clear();
2128
- inFlightPlayerIds.clear();
2460
+ inFlightPlayerKeys.clear();
2129
2461
  activeBoundaryCalls.clear();
2130
2462
  activeEmissionCalls.clear();
2131
2463
  emissionQueue.clear();
2132
2464
  judgeQueue.clear();
2133
2465
  actor = undefined;
2134
2466
  currentSignal = undefined;
2467
+ currentAborts = undefined;
2468
+ actorSettlementAborts.length = 0;
2469
+ actorSettlementErrorAborts = undefined;
2135
2470
  currentTurnId = undefined;
2136
2471
  ports = undefined;
2137
2472
  sessionIdentity = undefined;
@@ -2146,12 +2481,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2146
2481
  })();
2147
2482
  return disposalPromise;
2148
2483
  },
2484
+
2485
+ // @internal — test-only parity with the shared factory's bridge escape
2486
+ // hatch. This is hidden by the PlaybookRuntime return type.
2487
+ _getNestedBridge() {
2488
+ return nestedBridge;
2489
+ },
2149
2490
  };
2150
2491
  };
2151
2492
 
2152
2493
  export const _internal = {
2153
2494
  composePlayerPrompt,
2154
- resolvePlayerId,
2155
2495
  requiredFieldsFor,
2156
2496
  extractJson,
2157
2497
  buildClassifierPrompt,
@@ -2160,12 +2500,12 @@ export const _internal = {
2160
2500
  parseAdjudication,
2161
2501
  combineSignals,
2162
2502
  pendingQuestionsFromContext,
2503
+ pendingQuestionsForState,
2163
2504
  normalizeErrorCompact,
2164
2505
  normalizeErrorFull,
2165
- DEFAULT_PLAYER_BINDING,
2166
2506
  STATE_DESCRIPTIONS,
2167
- PLAYER_STATES,
2168
- PLAYER_STATE_IDS,
2507
+ ROLE_STATES,
2508
+ ROLE_STATE_IDS,
2169
2509
  VERBATIM_PAYLOAD_FIELDS,
2170
2510
  BOSS_INTERRUPT_TARGETS,
2171
2511
  CONTINUATION_PREAMBLE,