@sublang/playbook 4.0.0 → 6.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 (73) hide show
  1. package/README.md +69 -122
  2. package/docs/assets/playbook-venn.svg +13 -0
  3. package/docs/cli.md +43 -26
  4. package/docs/configuration.md +63 -18
  5. package/docs/embedding.md +24 -16
  6. package/package.json +43 -22
  7. package/reference/sdlc/captain.md +70 -83
  8. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +127 -142
  9. package/reference/sdlc/captain.playbook/captain.fsm.js +349 -470
  10. package/reference/sdlc/captain.playbook/captain.fsm.ts +535 -598
  11. package/reference/sdlc/captain.playbook/captain.gears.md +37 -41
  12. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +90 -15
  13. package/reference/sdlc/captain.playbook/captain.playbook.js +466 -976
  14. package/reference/sdlc/captain.playbook/captain.playbook.ts +698 -1001
  15. package/reference/sdlc/code.md +55 -97
  16. package/reference/sdlc/code.playbook/code.fsm.d.ts +229 -94
  17. package/reference/sdlc/code.playbook/code.fsm.introspect.d.ts +26 -44
  18. package/reference/sdlc/code.playbook/code.fsm.introspect.js +61 -66
  19. package/reference/sdlc/code.playbook/code.fsm.introspect.ts +100 -149
  20. package/reference/sdlc/code.playbook/code.fsm.js +587 -1347
  21. package/reference/sdlc/code.playbook/code.fsm.ts +809 -1650
  22. package/reference/sdlc/code.playbook/code.gears.md +51 -263
  23. package/reference/sdlc/code.playbook/code.playbook.d.ts +8 -47
  24. package/reference/sdlc/code.playbook/code.playbook.js +69 -639
  25. package/reference/sdlc/code.playbook/code.playbook.ts +90 -850
  26. package/reference/sdlc/code.playbook/code.registry.d.ts +9 -25
  27. package/reference/sdlc/code.playbook/code.registry.js +20 -78
  28. package/reference/sdlc/code.playbook/code.registry.ts +58 -122
  29. package/reference/sdlc/code.playbook/playbook-captain.d.ts +2 -0
  30. package/reference/sdlc/code.playbook/playbook-captain.js +1877 -251
  31. package/reference/sdlc/code.playbook/playbook-captain.ts +2385 -352
  32. package/reference/sdlc/code.playbook/playbook.config.template.yaml +33 -22
  33. package/reference/sdlc/decide.md +54 -0
  34. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +261 -0
  35. package/reference/sdlc/decide.playbook/decide.fsm.js +894 -0
  36. package/reference/sdlc/decide.playbook/decide.fsm.ts +1152 -0
  37. package/reference/sdlc/decide.playbook/decide.gears.md +88 -0
  38. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +67 -0
  39. package/reference/sdlc/{discuss.playbook/discuss.playbook.js → decide.playbook/decide.playbook.js} +511 -370
  40. package/reference/sdlc/{discuss.playbook/discuss.playbook.ts → decide.playbook/decide.playbook.ts} +616 -451
  41. package/reference/sdlc/decide.playbook/decide.registry.d.ts +41 -0
  42. package/reference/sdlc/decide.playbook/decide.registry.js +60 -0
  43. package/reference/sdlc/decide.playbook/decide.registry.ts +125 -0
  44. package/reference/sdlc/review.md +81 -0
  45. package/reference/sdlc/review.playbook/review.fsm.d.ts +183 -0
  46. package/reference/sdlc/review.playbook/review.fsm.js +524 -0
  47. package/reference/sdlc/review.playbook/review.fsm.ts +652 -0
  48. package/reference/sdlc/review.playbook/review.gears.md +112 -0
  49. package/reference/sdlc/review.playbook/review.playbook.d.ts +12 -0
  50. package/reference/sdlc/review.playbook/review.playbook.js +112 -0
  51. package/reference/sdlc/review.playbook/review.playbook.ts +201 -0
  52. package/reference/sdlc/review.playbook/review.registry.d.ts +43 -0
  53. package/reference/sdlc/review.playbook/review.registry.js +73 -0
  54. package/reference/sdlc/review.playbook/review.registry.ts +138 -0
  55. package/slc/gears2fsm.md +67 -6
  56. package/slc/link.md +339 -25
  57. package/slc/text2gears.md +22 -2
  58. package/src/runtime.d.ts +36 -1
  59. package/src/runtime.ts +59 -0
  60. package/src/xstate-playbook-runtime.d.ts +96 -7
  61. package/src/xstate-playbook-runtime.js +1018 -49
  62. package/src/xstate-playbook-runtime.ts +1283 -59
  63. package/src/xstate-runtime.js +25 -0
  64. package/src/xstate-runtime.ts +51 -0
  65. package/reference/sdlc/discuss.md +0 -93
  66. package/reference/sdlc/discuss.playbook/discuss.fsm.d.ts +0 -396
  67. package/reference/sdlc/discuss.playbook/discuss.fsm.js +0 -2067
  68. package/reference/sdlc/discuss.playbook/discuss.fsm.ts +0 -2465
  69. package/reference/sdlc/discuss.playbook/discuss.gears.md +0 -258
  70. package/reference/sdlc/discuss.playbook/discuss.playbook.d.ts +0 -113
  71. package/reference/sdlc/discuss.playbook/discuss.registry.d.ts +0 -58
  72. package/reference/sdlc/discuss.playbook/discuss.registry.js +0 -97
  73. package/reference/sdlc/discuss.playbook/discuss.registry.ts +0 -153
@@ -41,6 +41,9 @@ import type {
41
41
  CaptainResult,
42
42
  JsonValue,
43
43
  PlaybookCallResult,
44
+ PlaybookControlAction,
45
+ PlaybookControlReceipt,
46
+ PlaybookControlView,
44
47
  PlaybookPorts,
45
48
  PlaybookRunResult,
46
49
  PlaybookRuntime,
@@ -124,14 +127,35 @@ export interface RuntimeBoundaryCalls {
124
127
  input: PlaybookCaptainInput,
125
128
  prompt: string,
126
129
  signal: AbortSignal,
130
+ callOptions?: XStateCaptainCallOptions,
127
131
  ): Promise<CaptainResult>;
128
132
  }
129
133
 
134
+ /**
135
+ * Presentation selection for one traced direct-Captain call
136
+ * (slc/link.md §Captain adjudication). `'visible'` (the default) is the
137
+ * workflow form: the port receives `{ visibility: 'visible', resume: false }`
138
+ * and the trace pair carries both members. `'hidden'` is the controller form
139
+ * (DR-029): the port receives `{ visibility: 'hidden', resume: false }`
140
+ * while the host's session-Captain wrapper owns the actual durable-conversation
141
+ * resume selection, so the trace pair carries `visibility: 'hidden'` and no
142
+ * `resume` member — the pinned token never enters runtime telemetry.
143
+ */
144
+ export interface XStateCaptainCallOptions {
145
+ visibility?: 'visible' | 'hidden';
146
+ }
147
+
130
148
  export interface ScheduledStatus {
131
149
  message: string;
132
150
  data?: JsonValue;
133
151
  }
134
152
 
153
+ /** Boss-facing identity for one FSM state whose invoked actor is `player`. */
154
+ export interface XStatePlayerStateStatus {
155
+ player: string;
156
+ label: string;
157
+ }
158
+
135
159
  export interface XStateBossEventFieldSpec {
136
160
  /** The judge supplies routing data; the runtime supplies exact Boss text. */
137
161
  source: 'judge' | 'text';
@@ -177,6 +201,37 @@ function isFsmResultFailure(error: unknown): boolean {
177
201
  );
178
202
  }
179
203
 
204
+ // ---------------------------------------------------------------------------
205
+ // DR-028: both call boundaries treat an `ok` result whose `finalText` is
206
+ // missing, empty, or whitespace-only under one empty predicate, and that
207
+ // shape earns exactly one corrective re-ask — the same composed call
208
+ // re-issued once through the same boundary — before a second such result
209
+ // follows the existing failure path. The retry marker distinguishes the
210
+ // re-askable empty-`ok` Captain failure from the never-retried non-`ok`
211
+ // statuses; it is applied only when the failure's finish trace emitted
212
+ // cleanly, because a rejecting finish sink is a control-plane error whose
213
+ // turn gets no corrective re-ask (PBRT-47).
214
+ // ---------------------------------------------------------------------------
215
+
216
+ function isEmptyFinalText(finalText: string | undefined): boolean {
217
+ return finalText === undefined || finalText.trim().length === 0;
218
+ }
219
+
220
+ const emptyOkRetryFailures = new WeakSet<object>();
221
+
222
+ function markEmptyOkRetryFailure(error: Error): Error {
223
+ emptyOkRetryFailures.add(error);
224
+ return error;
225
+ }
226
+
227
+ function isEmptyOkRetryFailure(error: unknown): boolean {
228
+ return (
229
+ typeof error === 'object' &&
230
+ error !== null &&
231
+ emptyOkRetryFailures.has(error as object)
232
+ );
233
+ }
234
+
180
235
  // ---------------------------------------------------------------------------
181
236
  // DR-022: the engine's compatibility self-report. A linked thin module
182
237
  // records the values current at link time in `spec.compat`; the factory
@@ -248,6 +303,53 @@ function assertRuntimeCompat(
248
303
  // to preserve their existing observable behavior exactly.
249
304
  // ---------------------------------------------------------------------------
250
305
 
306
+ /**
307
+ * One direct-Captain actor invocation handed to a spec's `captainStrategy`
308
+ * (slc/link.md §Captain adjudication, controller form). The engine owns
309
+ * signal combination, emission draining, trace pairing, the shared
310
+ * Captain/judge lane, and control-plane latching; the strategy owns the
311
+ * playbook-specific call pipeline — e.g. the controller's hidden decision
312
+ * call, `{ action, … }` control-JSON validation with its single corrective
313
+ * re-ask, and controller-port submission.
314
+ */
315
+ export interface XStateCaptainStrategyRun<TOptions> {
316
+ input: PlaybookCaptainInput;
317
+ /** The prompt composed by the spec's Captain composer for `input`. */
318
+ prompt: string;
319
+ /** Combined invocation-lifetime + active-boundary abort signal. */
320
+ signal: AbortSignal;
321
+ /** The immutable validated runtime options. */
322
+ options: TOptions;
323
+ /** The bound immutable playbook session identity. */
324
+ session: PlaybookSession;
325
+ /**
326
+ * One traced Captain call through the shared serialized lane; every call —
327
+ * initial or corrective — emits its own paired `captain.call.started` /
328
+ * `captain.call.finished` boundary. Throws the boundary's authoritative
329
+ * failure for non-`ok` and empty-`ok` results exactly as the default
330
+ * pipeline does.
331
+ */
332
+ callCaptain(
333
+ prompt: string,
334
+ callOptions?: XStateCaptainCallOptions,
335
+ ): Promise<CaptainResult>;
336
+ /**
337
+ * DR-028: true when `error` is the boundary's re-askable empty-`ok`
338
+ * marker; the strategy may re-issue the same composed call exactly once.
339
+ */
340
+ isEmptyOkRetry(error: unknown): boolean;
341
+ /**
342
+ * Mark `error` as a recoverable FSM-result failure: it travels the invoked
343
+ * actor's XState `onError` path without being latched as a control-plane
344
+ * error, so the machine's authored recovery arms can route it.
345
+ */
346
+ recoverableFailure<E extends Error>(error: E): E;
347
+ }
348
+
349
+ export type XStateCaptainStrategy<TOptions> = (
350
+ run: XStateCaptainStrategyRun<TOptions>,
351
+ ) => Promise<PlaybookActorOutput>;
352
+
251
353
  export interface XStatePlaybookRuntimeSpec<TOptions> {
252
354
  /** Diagnostic label used in internal invariant errors. Default 'playbook'. */
253
355
  label?: string;
@@ -276,16 +378,29 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
276
378
  * the XState machine alone.
277
379
  */
278
380
  bossEvents?: readonly XStateBossEventSpec[];
279
- /** Boss-input classifier override; default: generic parked-state classifier. */
381
+ /** Boss-input classifier override; default: generic parked-state classifier. Receives the bound validated options last so a fully deterministic controller mapping can consult host-supplied option members (slc/link.md §Boss-event mapping). */
280
382
  classifyBossText?: (
281
383
  text: string,
282
384
  ports: PlaybookPorts,
283
385
  signal: AbortSignal,
284
386
  snapshotOrState: unknown,
285
387
  boundary?: RuntimeBoundaryCalls,
388
+ options?: TOptions,
286
389
  ) => Promise<EventObject | undefined>;
287
- /** Status line emitted after classification names an event. Default: none. */
390
+ /**
391
+ * Direct-Captain actor strategy override (slc/link.md §Captain
392
+ * adjudication, controller form): replaces the default visible-call +
393
+ * hidden-judge pipeline for every `captain` state of this machine. The
394
+ * engine still composes the prompt, combines signals, traces each call as
395
+ * its own pair, and latches control-plane errors; failures the strategy
396
+ * marks with `recoverableFailure` travel the actor's `onError` path as
397
+ * recoverable FSM-result failures instead.
398
+ */
399
+ captainStrategy?: XStateCaptainStrategy<TOptions>;
400
+ /** Status line emitted after classification; canonical metadata defaults to the event type, legacy artifacts to none. */
288
401
  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>>;
289
404
  /** Map a player-invoking state's input to the host player id. Default: lowercased player name. */
290
405
  resolvePlayerId?: (input: PlaybookPlayerInput, options: TOptions) => string;
291
406
  /** Compose the player prompt. Default: continuation blocks + `<field>` placeholder substitution. */
@@ -300,9 +415,21 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
300
415
  extractRequiredFields?: (description: string) => string[];
301
416
  /** Required fields carried verbatim from the player's finalText instead of judge JSON. Default: none. */
302
417
  verbatimPayloadFields?: ReadonlySet<string>;
418
+ /**
419
+ * DR-029 / PBRT-52: the runtime-authored ControlView context
420
+ * projection — the exact FSM context members `describe()` may expose,
421
+ * in the order the view lists them. Only this artifact knows which of
422
+ * its context members are safe and relevant for a controller prompt, so
423
+ * the engine exports what is named here and nothing else: a member the
424
+ * artifact has not named stays private, and a member added to the FSM
425
+ * later stays private until someone names it. Absent or empty: the view
426
+ * carries no context at all. `pendingBossQuestion` and `lastError` are
427
+ * surfaced first-class by the view and shall not be named here.
428
+ */
429
+ controlContextFields?: readonly string[];
303
430
  /** States that may suspend for a Boss reply. Default: targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
304
431
  resumableStateIds?: ReadonlySet<string>;
305
- /** Human status lines for a root transition. Default: entry lines with question/failure surfacing. */
432
+ /** Human status lines for a root transition. Default: guard, declared-player, question, and failure lines. */
306
433
  statusesForState?: (
307
434
  state: PlaybookState,
308
435
  context: Record<string, unknown>,
@@ -730,8 +857,10 @@ function validateBossReplyOutput(
730
857
  // ---------------------------------------------------------------------------
731
858
  // Delegated-player actor bridge. One PromiseActorLogic the machine invokes
732
859
  // from every player-invoking state: resolve the playerId, compose the prompt,
733
- // await callPlayer, adjudicate the finalText. A non-`ok` result or missing
734
- // finalText throws so XState routes via onError to the FSM's failure sink.
860
+ // await callPlayer, adjudicate the finalText. An `ok` result with a missing,
861
+ // empty, or whitespace-only finalText earns exactly one corrective re-ask of
862
+ // the same composed call (DR-028); a non-`ok` result, or a second such empty
863
+ // result, throws so XState routes via onError to the FSM's failure sink.
735
864
  //
736
865
  // `getActiveSignal` flows the Boss's public-boundary signal into the host
737
866
  // port calls — fromPromise hands the bridge XState's actor-scoped signal,
@@ -757,17 +886,40 @@ export function createPlayerBridge(
757
886
  const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
758
887
  const playerId = spec.resolvePlayerId(input);
759
888
  const prompt = spec.composePlayerPrompt(input);
760
- const result = boundary
761
- ? await boundary.callPlayer(input, playerId, prompt, activeSignal)
762
- : await ports.callPlayer(playerId, prompt, activeSignal, {
763
- resume: false,
764
- });
889
+ const callPlayer = (resume: string | false) =>
890
+ boundary
891
+ ? boundary.callPlayer(input, playerId, prompt, activeSignal)
892
+ : ports.callPlayer(playerId, prompt, activeSignal, { resume });
893
+ let result = await callPlayer(false);
894
+ if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
895
+ // An abort that lands between the empty first result and the
896
+ // corrective call ends the turn as ordinary abort settlement with
897
+ // no second host call — aborts are never retried (DR-028 via
898
+ // DR-025's transport exclusion) — matching the direct-Captain
899
+ // boundary, whose queued corrective call re-checks the signal
900
+ // before starting.
901
+ activeSignal.throwIfAborted();
902
+ // DR-028: exactly one corrective re-ask of the same composed call
903
+ // through the same path, traced by the boundary as its own
904
+ // player-call pair. The traced boundary re-reads its token map
905
+ // (PBRT-38), so the corrective call continues the player session
906
+ // when the first result carried a resume token and starts fresh
907
+ // when it cleared one; the portless verification path mirrors that
908
+ // by carrying the first result's token.
909
+ result = await callPlayer(
910
+ typeof result.resumeToken === 'string' &&
911
+ result.resumeToken.trim().length > 0
912
+ ? result.resumeToken
913
+ : false,
914
+ );
915
+ }
765
916
  if (result.status !== 'ok') {
766
917
  throw new Error(
767
918
  result.error ?? `captainBridge: callPlayer status "${result.status}"`,
768
919
  );
769
920
  }
770
- if (result.finalText === undefined) {
921
+ const finalText = result.finalText ?? '';
922
+ if (isEmptyFinalText(finalText)) {
771
923
  throw new Error(
772
924
  'captainBridge: callPlayer returned status=ok with no finalText',
773
925
  );
@@ -776,7 +928,7 @@ export function createPlayerBridge(
776
928
  const output = await adjudicatePlayerOutput(
777
929
  spec.adjudication,
778
930
  input,
779
- result.finalText,
931
+ finalText,
780
932
  ports,
781
933
  activeSignal,
782
934
  boundary,
@@ -911,6 +1063,60 @@ function collectInvokeSources(machine: AnyStateMachine): ReadonlySet<string> {
911
1063
  return sources;
912
1064
  }
913
1065
 
1066
+ function collectPlayerStatePlayers(
1067
+ machine: AnyStateMachine,
1068
+ ): ReadonlyMap<string, string> {
1069
+ const players = new Map<string, string>();
1070
+ const visit = (stateDef: unknown, stateKey: string): void => {
1071
+ if (!isPlainObject(stateDef)) return;
1072
+ const invoke = stateDef.invoke;
1073
+ const invokes = Array.isArray(invoke) ? invoke : invoke ? [invoke] : [];
1074
+ if (
1075
+ invokes.some(
1076
+ (entry) =>
1077
+ isPlainObject(entry) && entry.src === 'player',
1078
+ )
1079
+ ) {
1080
+ const playbookMeta = isPlainObject(stateDef.meta)
1081
+ ? stateDef.meta.playbook
1082
+ : undefined;
1083
+ const stateId =
1084
+ isPlainObject(playbookMeta) &&
1085
+ typeof playbookMeta.stateId === 'string'
1086
+ ? playbookMeta.stateId
1087
+ : typeof stateDef.id === 'string'
1088
+ ? stateDef.id
1089
+ : stateKey;
1090
+ if (stateId.trim().length === 0) {
1091
+ throw new TypeError(
1092
+ 'player state metadata must use a non-empty state id',
1093
+ );
1094
+ }
1095
+ const player = isPlainObject(playbookMeta)
1096
+ ? playbookMeta.player
1097
+ : undefined;
1098
+ if (typeof player !== 'string' || player.trim().length === 0) {
1099
+ throw new TypeError(
1100
+ `player state ${stateId} meta.playbook.player must be a non-empty string`,
1101
+ );
1102
+ }
1103
+ players.set(stateId, player);
1104
+ }
1105
+ if (isPlainObject(stateDef.states)) {
1106
+ for (const [childKey, child] of Object.entries(stateDef.states)) {
1107
+ visit(child, childKey);
1108
+ }
1109
+ }
1110
+ };
1111
+ const config = (machine as unknown as { config?: unknown }).config;
1112
+ if (isPlainObject(config) && isPlainObject(config.states)) {
1113
+ for (const [stateKey, stateDef] of Object.entries(config.states)) {
1114
+ visit(stateDef, stateKey);
1115
+ }
1116
+ }
1117
+ return players;
1118
+ }
1119
+
914
1120
  function transitionTargets(transition: unknown): string[] {
915
1121
  const arms = Array.isArray(transition) ? transition : [transition];
916
1122
  const targets: string[] = [];
@@ -941,6 +1147,98 @@ export function resumableStateIdsFromMachine(
941
1147
  return new Set(transitionTargets(bossReply));
942
1148
  }
943
1149
 
1150
+ // ---------------------------------------------------------------------------
1151
+ // DR-029 control surface: the FSM's explicit-state-jump event and the
1152
+ // source state descriptions that label runtime-advertised actions.
1153
+ // ---------------------------------------------------------------------------
1154
+
1155
+ /** The FSM's explicit-state-jump event type (slc/link.md §Boss-event mapping). */
1156
+ const JUMP_EVENT_TYPE = 'BOSS_INTERRUPT';
1157
+
1158
+ /**
1159
+ * Source state descriptions by state key, node id, and `meta.playbook`
1160
+ * state id, read from `machine.config`. Control actions are labeled from
1161
+ * these descriptions (DR-029); a state without one has no entry.
1162
+ */
1163
+ export function stateDescriptionsFromMachine(
1164
+ machine: AnyStateMachine,
1165
+ ): ReadonlyMap<string, string> {
1166
+ const descriptions = new Map<string, string>();
1167
+ const record = (key: unknown, description: string): void => {
1168
+ if (typeof key !== 'string' || key.length === 0) return;
1169
+ if (!descriptions.has(key)) descriptions.set(key, description);
1170
+ };
1171
+ const visit = (key: string, stateDef: unknown): void => {
1172
+ if (!isPlainObject(stateDef)) return;
1173
+ const playbook = isPlainObject(stateDef.meta)
1174
+ ? (stateDef.meta as Record<string, unknown>).playbook
1175
+ : undefined;
1176
+ const description =
1177
+ isPlainObject(playbook) && typeof playbook.description === 'string'
1178
+ ? playbook.description
1179
+ : typeof stateDef.description === 'string'
1180
+ ? stateDef.description
1181
+ : undefined;
1182
+ if (description !== undefined && description.length > 0) {
1183
+ record(key, description);
1184
+ record(stateDef.id, description);
1185
+ if (isPlainObject(playbook)) record(playbook.stateId, description);
1186
+ }
1187
+ if (isPlainObject(stateDef.states)) {
1188
+ for (const [childKey, child] of Object.entries(stateDef.states)) {
1189
+ visit(childKey, child);
1190
+ }
1191
+ }
1192
+ };
1193
+ const config = (machine as unknown as { config?: unknown }).config;
1194
+ if (isPlainObject(config) && isPlainObject(config.states)) {
1195
+ for (const [key, stateDef] of Object.entries(config.states)) {
1196
+ visit(key, stateDef);
1197
+ }
1198
+ }
1199
+ return descriptions;
1200
+ }
1201
+
1202
+ /**
1203
+ * First configured target of `eventType` from the state with `stateId`,
1204
+ * falling back to the machine root's own transitions. Used only to pick the
1205
+ * source description that labels a retry action, and only for events that
1206
+ * carry no recorded `targetId`: a guarded multi-arm list keyed on the
1207
+ * event's `targetId` (the root `BOSS_INTERRUPT` shape) resumes the recorded
1208
+ * target, not the first configured arm, so the recorded event outranks this
1209
+ * fallback.
1210
+ */
1211
+ function firstTransitionTarget(
1212
+ machine: AnyStateMachine,
1213
+ stateId: string | undefined,
1214
+ eventType: string,
1215
+ ): string | undefined {
1216
+ const config = (machine as unknown as { config?: unknown }).config;
1217
+ if (!isPlainObject(config)) return undefined;
1218
+ const candidates: unknown[] = [];
1219
+ if (stateId !== undefined && isPlainObject(config.states)) {
1220
+ const state = config.states[stateId];
1221
+ if (isPlainObject(state) && isPlainObject(state.on)) {
1222
+ candidates.push(state.on[eventType]);
1223
+ }
1224
+ }
1225
+ if (isPlainObject(config.on)) candidates.push(config.on[eventType]);
1226
+ for (const candidate of candidates) {
1227
+ if (candidate === undefined) continue;
1228
+ const targets = transitionTargets(candidate);
1229
+ if (targets.length > 0) return targets[0];
1230
+ }
1231
+ return undefined;
1232
+ }
1233
+
1234
+ function deepFreeze<T>(value: T): T {
1235
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
1236
+ Object.freeze(value);
1237
+ for (const member of Object.values(value)) deepFreeze(member);
1238
+ }
1239
+ return value;
1240
+ }
1241
+
944
1242
  // ---------------------------------------------------------------------------
945
1243
  // Default transition/status derivation.
946
1244
  // ---------------------------------------------------------------------------
@@ -970,7 +1268,70 @@ function makeDefaultNormalizeTransitionEvent(
970
1268
  };
971
1269
  }
972
1270
 
973
- function defaultStatusesForState(
1271
+ function snapshotPlayerStateStatuses(
1272
+ value: Readonly<Record<string, XStatePlayerStateStatus>> | undefined,
1273
+ label: string,
1274
+ machine: AnyStateMachine,
1275
+ 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`);
1280
+ }
1281
+ const declared = collectPlayerStatePlayers(machine);
1282
+ const statuses = new Map<string, XStatePlayerStateStatus>();
1283
+ for (const [stateId, candidate] of Object.entries(value)) {
1284
+ if (!declared.has(stateId)) {
1285
+ throw new TypeError(
1286
+ `${label} playerStates.${stateId} does not name a player state`,
1287
+ );
1288
+ }
1289
+ if (
1290
+ !isPlainObject(candidate) ||
1291
+ typeof candidate.player !== 'string' ||
1292
+ candidate.player.trim().length === 0 ||
1293
+ typeof candidate.label !== 'string' ||
1294
+ candidate.label.trim().length === 0
1295
+ ) {
1296
+ throw new TypeError(
1297
+ `${label} playerStates.${stateId} must carry non-empty player and label strings`,
1298
+ );
1299
+ }
1300
+ const expectedLabel = stateDescriptions.get(stateId);
1301
+ if (candidate.label !== expectedLabel) {
1302
+ throw new TypeError(
1303
+ `${label} playerStates.${stateId}.label must equal its FSM description`,
1304
+ );
1305
+ }
1306
+ if (candidate.player !== declared.get(stateId)) {
1307
+ throw new TypeError(
1308
+ `${label} playerStates.${stateId}.player must equal its FSM player`,
1309
+ );
1310
+ }
1311
+ statuses.set(stateId, {
1312
+ player: candidate.player,
1313
+ label: candidate.label,
1314
+ });
1315
+ }
1316
+ for (const stateId of declared.keys()) {
1317
+ if (!statuses.has(stateId)) {
1318
+ throw new TypeError(
1319
+ `${label} playerStates must declare player state ${stateId}`,
1320
+ );
1321
+ }
1322
+ }
1323
+ return statuses;
1324
+ }
1325
+
1326
+ function settlingGuard(event: unknown): string | undefined {
1327
+ if (!isPlainObject(event) || !isPlainObject(event.output)) return undefined;
1328
+ const guard = event.output.guard;
1329
+ return typeof guard === 'string' && guard.trim().length > 0
1330
+ ? guard
1331
+ : undefined;
1332
+ }
1333
+
1334
+ function legacyStatusesForState(
974
1335
  state: PlaybookState,
975
1336
  context: Record<string, unknown>,
976
1337
  ): ScheduledStatus[] {
@@ -978,11 +1339,14 @@ function defaultStatusesForState(
978
1339
  if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) return [];
979
1340
  if (stateId === 'awaitBossReply') {
980
1341
  const pending = pendingBossQuestionFromContext(context);
981
- const message =
982
- pending === undefined
983
- ? 'Awaiting Boss reply.'
984
- : `${pending.player} asks: ${pending.question}`;
985
- return [{ message }];
1342
+ return [
1343
+ {
1344
+ message:
1345
+ pending === undefined
1346
+ ? 'Awaiting Boss reply.'
1347
+ : `${pending.player} asks: ${pending.question}`,
1348
+ },
1349
+ ];
986
1350
  }
987
1351
  if (stateId === 'failed') {
988
1352
  const lastError = normalizeErrorFull(context.lastError);
@@ -998,6 +1362,60 @@ function defaultStatusesForState(
998
1362
  return [{ message: `Entered ${stateId}.` }];
999
1363
  }
1000
1364
 
1365
+ function makeDefaultStatusesForState(
1366
+ playerStates: ReadonlyMap<string, XStatePlayerStateStatus>,
1367
+ ): NonNullable<XStatePlaybookRuntimeSpec<unknown>['statusesForState']> {
1368
+ return (state, context, event): ScheduledStatus[] => {
1369
+ const statuses: ScheduledStatus[] = [];
1370
+ const guard = settlingGuard(event);
1371
+ if (guard !== undefined) statuses.push({ message: `→ ${guard}` });
1372
+
1373
+ const stateId = state.stateId;
1374
+ if (stateId === undefined || SUPPRESSED_ENTRY_STATES.has(stateId)) {
1375
+ return statuses;
1376
+ }
1377
+ if (stateId === 'awaitBossReply') {
1378
+ const pending = pendingBossQuestionFromContext(context);
1379
+ if (pending === undefined) {
1380
+ return [...statuses, { message: 'Awaiting Boss reply.' }];
1381
+ }
1382
+ return [
1383
+ ...statuses,
1384
+ { message: `${pending.player} asks: ${pending.question}` },
1385
+ {
1386
+ message:
1387
+ `◆ awaiting Boss reply · ${pending.resumeStateId} · ` +
1388
+ `${pending.player} · ${pending.sourceItem}`,
1389
+ },
1390
+ ];
1391
+ }
1392
+ if (stateId === 'failed') {
1393
+ const lastError = normalizeErrorCompact(context.lastError);
1394
+ return [
1395
+ ...statuses,
1396
+ {
1397
+ message: '◆ workflow failed; awaiting Boss recovery.',
1398
+ ...(lastError === undefined
1399
+ ? {}
1400
+ : {
1401
+ data: snapshotJsonValue(
1402
+ { lastError },
1403
+ 'failed status data',
1404
+ ),
1405
+ }),
1406
+ },
1407
+ ];
1408
+ }
1409
+ const playerState = playerStates.get(stateId);
1410
+ if (playerState !== undefined) {
1411
+ statuses.push({
1412
+ message: `⤷ ${playerState.player}: ${playerState.label}`,
1413
+ });
1414
+ }
1415
+ return statuses;
1416
+ };
1417
+ }
1418
+
1001
1419
  // ---------------------------------------------------------------------------
1002
1420
  // Default parked-state classifier (slc/link.md §Boss-event mapping): the
1003
1421
  // runtime-owned textual fields are never requested from the judge; only the
@@ -1399,6 +1817,16 @@ interface TracePosition {
1399
1817
  callId?: string;
1400
1818
  }
1401
1819
 
1820
+ function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
1821
+ const visit = (stateDef: unknown): boolean => {
1822
+ if (!isPlainObject(stateDef)) return false;
1823
+ if (stateDef.type === 'parallel') return true;
1824
+ if (!isPlainObject(stateDef.states)) return false;
1825
+ return Object.values(stateDef.states).some(visit);
1826
+ };
1827
+ return visit((machine as unknown as { config?: unknown }).config);
1828
+ }
1829
+
1402
1830
  /**
1403
1831
  * Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
1404
1832
  * under the slc/link.md contract. The factory provides every actor kind the
@@ -1406,8 +1834,9 @@ interface TracePosition {
1406
1834
  * (literal and dynamic) — and implements the full runtime lifecycle including
1407
1835
  * the optional parked-session snapshot capability (DR-014).
1408
1836
  *
1409
- * Scope: single-region root machines (each snapshot exposes exactly one
1410
- * playbook state id). Parallel-region FSMs keep their own linked runtimes.
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.
1411
1840
  */
1412
1841
  export function createXStatePlaybookRuntime<TOptions>(
1413
1842
  machine: AnyStateMachine,
@@ -1417,9 +1846,40 @@ export function createXStatePlaybookRuntime<TOptions>(
1417
1846
  // DR-022 / PBRT-50: reject an incompatible artifact declaration before any
1418
1847
  // machine interpretation, against this loaded engine's own self-report.
1419
1848
  assertRuntimeCompat(spec.compat, label);
1849
+ if (machineDeclaresParallelState(machine)) {
1850
+ throw new Error(
1851
+ `${label} uses a parallel state; the shared runtime supports only single-region FSMs`,
1852
+ );
1853
+ }
1420
1854
  const declaredActors = collectInvokeSources(machine);
1421
1855
  const resumableStateIds =
1422
1856
  spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
1857
+ // DR-029: source state descriptions label the control actions the
1858
+ // runtime advertises through `describe()`.
1859
+ const stateDescriptions = stateDescriptionsFromMachine(machine);
1860
+ const hasCanonicalStatusProfile = spec.playerStates !== undefined;
1861
+ const playerStates = snapshotPlayerStateStatuses(
1862
+ spec.playerStates,
1863
+ label,
1864
+ machine,
1865
+ stateDescriptions,
1866
+ );
1867
+ // PBRT-52: the artifact's own ControlView context projection. Nothing is
1868
+ // exported by default, so an FSM context member — including one added
1869
+ // after this artifact was linked — is private until named here. The two
1870
+ // members the view surfaces first-class are rejected at construction
1871
+ // rather than silently ignored, so an artifact cannot believe it is
1872
+ // exporting them through this list.
1873
+ const controlContextFields: readonly string[] = spec.controlContextFields
1874
+ ? [...spec.controlContextFields]
1875
+ : [];
1876
+ for (const field of controlContextFields) {
1877
+ if (field === 'pendingBossQuestion' || field === 'lastError') {
1878
+ throw new Error(
1879
+ `${label} controlContextFields must not name ${field}: the control view surfaces it first-class`,
1880
+ );
1881
+ }
1882
+ }
1423
1883
  const resolvePlayerIdSpec = spec.resolvePlayerId;
1424
1884
  const composePlayerPrompt =
1425
1885
  spec.composePlayerPrompt ??
@@ -1451,11 +1911,22 @@ export function createXStatePlaybookRuntime<TOptions>(
1451
1911
  spec.entryEvent,
1452
1912
  spec.bossEvents ?? [],
1453
1913
  );
1454
- const classifyBossText = spec.classifyBossText ?? derivedClassifyBossText;
1914
+ const classifyBossText: NonNullable<
1915
+ XStatePlaybookRuntimeSpec<TOptions>['classifyBossText']
1916
+ > = spec.classifyBossText ?? derivedClassifyBossText;
1455
1917
  const normalizeTransitionEvent =
1456
1918
  spec.normalizeTransitionEvent ??
1457
1919
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
1458
- const statusesForState = spec.statusesForState ?? defaultStatusesForState;
1920
+ const statusesForState =
1921
+ spec.statusesForState ??
1922
+ (hasCanonicalStatusProfile
1923
+ ? makeDefaultStatusesForState(playerStates)
1924
+ : legacyStatusesForState);
1925
+ const classificationStatus =
1926
+ spec.classificationStatus ??
1927
+ (hasCanonicalStatusProfile
1928
+ ? (event: EventObject) => event.type
1929
+ : () => undefined);
1459
1930
  const machineInput =
1460
1931
  spec.machineInput ?? ((options: TOptions) => options as unknown);
1461
1932
  const scriptCwd =
@@ -1493,6 +1964,19 @@ export function createXStatePlaybookRuntime<TOptions>(
1493
1964
  let playerCallSequence = 0;
1494
1965
  let playbookCallSequence = 0;
1495
1966
  let captainCallSequence = 0;
1967
+ let applyCallSequence = 0;
1968
+ // DR-029: the last event a public Boss boundary sent into the
1969
+ // machine — classified, deterministic entry, or Boss reply — kept with
1970
+ // its recorded payload so a failure-state retry action can replay the
1971
+ // event that drove the run into `failed`. Process-local: the schema-1
1972
+ // parked snapshot does not persist it (PBRT-50: no schema bump).
1973
+ let lastBossEvent: EventObject | undefined;
1974
+ // DR-029: process-local at-most-once `apply` execution — the accepted receipt
1975
+ // recorded for each idempotency key, returned verbatim on a repeated
1976
+ // key. A key whose call settled `rejected` or threw before reaching
1977
+ // acceptance records nothing, so a later call with that key may still
1978
+ // execute.
1979
+ const appliedReceipts = new Map<string, PlaybookControlReceipt>();
1496
1980
  const playerResumeTokens = new Map<string, string>();
1497
1981
  const activePlayerIds = new Set<string>();
1498
1982
  const playbookCallTurnIds = new Map<string, number | undefined>();
@@ -1507,6 +1991,71 @@ export function createXStatePlaybookRuntime<TOptions>(
1507
1991
  // imperative boundaries await their queued work directly.
1508
1992
  let emissionFailure: unknown;
1509
1993
 
1994
+ function selectPlayerResume(playerId: string): string | false {
1995
+ const selected = session?.playerSessions
1996
+ ? session.playerSessions.select(playerId)
1997
+ : playerResumeTokens.get(playerId) ?? false;
1998
+ if (
1999
+ selected !== false &&
2000
+ (typeof selected !== 'string' || selected.trim().length === 0)
2001
+ ) {
2002
+ throw new TypeError(
2003
+ `player session store returned an invalid resume token for ${playerId}`,
2004
+ );
2005
+ }
2006
+ return selected;
2007
+ }
2008
+
2009
+ function updatePlayerResume(
2010
+ playerId: string,
2011
+ resumeToken: string | undefined,
2012
+ ): void {
2013
+ if (session?.playerSessions) {
2014
+ session.playerSessions.update(playerId, resumeToken);
2015
+ } else if (resumeToken !== undefined && resumeToken.trim().length > 0) {
2016
+ playerResumeTokens.set(playerId, resumeToken);
2017
+ } else {
2018
+ playerResumeTokens.delete(playerId);
2019
+ }
2020
+ }
2021
+
2022
+ function snapshotPlayerResumeTokens(): Record<string, string> {
2023
+ const raw = session?.playerSessions
2024
+ ? session.playerSessions.snapshot()
2025
+ : Object.fromEntries(playerResumeTokens);
2026
+ if (!isPlainObject(raw)) {
2027
+ throw new TypeError('player session store snapshot must be an object');
2028
+ }
2029
+ const detached: Record<string, string> = {};
2030
+ for (const [playerId, token] of Object.entries(raw)) {
2031
+ if (playerId.trim().length === 0) {
2032
+ throw new TypeError(
2033
+ 'player session store snapshot player ids must be non-empty',
2034
+ );
2035
+ }
2036
+ if (typeof token !== 'string' || token.trim().length === 0) {
2037
+ throw new TypeError(
2038
+ `player session store snapshot token for ${playerId} must be a non-empty string`,
2039
+ );
2040
+ }
2041
+ detached[playerId] = token;
2042
+ }
2043
+ return detached;
2044
+ }
2045
+
2046
+ function restorePlayerResumeTokens(
2047
+ tokens: Readonly<Record<string, string>>,
2048
+ ): void {
2049
+ if (session?.playerSessions) {
2050
+ session.playerSessions.restore(tokens);
2051
+ return;
2052
+ }
2053
+ playerResumeTokens.clear();
2054
+ for (const [playerId, token] of Object.entries(tokens)) {
2055
+ playerResumeTokens.set(playerId, token);
2056
+ }
2057
+ }
2058
+
1510
2059
  function enqueueEmission(fn: () => Promise<void>): Promise<void> {
1511
2060
  const queued = emissionQueue.add(fn).then(() => undefined);
1512
2061
  activeEmissionCalls.add(queued);
@@ -1672,13 +2221,22 @@ export function createXStatePlaybookRuntime<TOptions>(
1672
2221
  startedType:
1673
2222
  | 'player.call.started'
1674
2223
  | 'judge.call.started'
1675
- | 'captain.call.started',
2224
+ | 'captain.call.started'
2225
+ | 'apply.started',
1676
2226
  finishedType:
1677
2227
  | 'player.call.finished'
1678
2228
  | 'judge.call.finished'
1679
- | 'captain.call.finished',
2229
+ | 'captain.call.finished'
2230
+ | 'apply.finished',
1680
2231
  identity: Record<string, unknown>,
1681
2232
  position: TracePosition,
2233
+ // Base payload of the best-effort finish emitted when the start sink
2234
+ // rejects; it defaults to the payload the start carried, which the
2235
+ // player, judge, and captain pairs take as-is. The apply pair cannot:
2236
+ // its finish carries the receipt disposition and none of the
2237
+ // start-only fields, so it passes its own canonical pre-acceptance
2238
+ // base (slc/link.md §Playbook trace).
2239
+ finishIdentity: Record<string, unknown> = identity,
1682
2240
  ): Promise<void> {
1683
2241
  try {
1684
2242
  await emitTrace(startedType, identity, position);
@@ -1687,7 +2245,11 @@ export function createXStatePlaybookRuntime<TOptions>(
1687
2245
  try {
1688
2246
  await emitTrace(
1689
2247
  finishedType,
1690
- { ...identity, status: 'error', error: normalizeError(error) },
2248
+ {
2249
+ ...finishIdentity,
2250
+ status: 'error',
2251
+ error: normalizeError(error),
2252
+ },
1691
2253
  position,
1692
2254
  );
1693
2255
  } catch {
@@ -1702,9 +2264,16 @@ export function createXStatePlaybookRuntime<TOptions>(
1702
2264
  // State-entry telemetry/status must precede the call they describe.
1703
2265
  await drainEmissions();
1704
2266
  const turnId = activeTurnId;
1705
- const callId = `player-${++playerCallSequence}`;
1706
2267
  const stateId = input.stateId;
1707
- const resume = playerResumeTokens.get(playerId) ?? false;
2268
+ let resume: string | false;
2269
+ try {
2270
+ signal.throwIfAborted();
2271
+ resume = selectPlayerResume(playerId);
2272
+ } catch (error) {
2273
+ if (!signal.aborted) controlPlaneError ??= error;
2274
+ throw error;
2275
+ }
2276
+ const callId = `player-${++playerCallSequence}`;
1708
2277
  const identity = {
1709
2278
  purpose: 'captain' as const,
1710
2279
  ...stateIdentity(stateId),
@@ -1745,6 +2314,11 @@ export function createXStatePlaybookRuntime<TOptions>(
1745
2314
 
1746
2315
  let rawResult: unknown;
1747
2316
  try {
2317
+ // An abort may land while the awaited started emission drains
2318
+ // (e.g. fired from the trace sink itself); the host call must
2319
+ // never start after abort, so settle the already-started pair
2320
+ // as `aborted` through the catch below.
2321
+ signal.throwIfAborted();
1748
2322
  rawResult = await requireHostPorts().callPlayer(
1749
2323
  playerId,
1750
2324
  prompt,
@@ -1791,13 +2365,26 @@ export function createXStatePlaybookRuntime<TOptions>(
1791
2365
  throw error;
1792
2366
  }
1793
2367
 
1794
- if (
1795
- typeof result.resumeToken === 'string' &&
1796
- result.resumeToken.trim().length > 0
1797
- ) {
1798
- playerResumeTokens.set(playerId, result.resumeToken);
1799
- } else {
1800
- playerResumeTokens.delete(playerId);
2368
+ try {
2369
+ updatePlayerResume(
2370
+ playerId,
2371
+ typeof result.resumeToken === 'string' &&
2372
+ result.resumeToken.trim().length > 0
2373
+ ? result.resumeToken
2374
+ : undefined,
2375
+ );
2376
+ } catch (error) {
2377
+ if (!signal.aborted) controlPlaneError ??= error;
2378
+ try {
2379
+ await emitTrace(
2380
+ 'player.call.finished',
2381
+ { ...identity, status: 'error', error: normalizeError(error) },
2382
+ position,
2383
+ );
2384
+ } catch {
2385
+ // The continuation-store failure remains authoritative.
2386
+ }
2387
+ throw error;
1801
2388
  }
1802
2389
 
1803
2390
  await emitTrace(
@@ -1846,6 +2433,11 @@ export function createXStatePlaybookRuntime<TOptions>(
1846
2433
  );
1847
2434
  let reply: unknown;
1848
2435
  try {
2436
+ // An abort may land while the awaited started emission drains
2437
+ // (e.g. fired from the trace sink itself); the host call must
2438
+ // never start after abort, so settle the already-started pair
2439
+ // as `aborted` through the catch below.
2440
+ signal.throwIfAborted();
1849
2441
  reply = await requireHostPorts().callJudge(prompt, signal);
1850
2442
  signal.throwIfAborted();
1851
2443
  } catch (error) {
@@ -1889,18 +2481,23 @@ export function createXStatePlaybookRuntime<TOptions>(
1889
2481
  }) as Promise<string>;
1890
2482
  },
1891
2483
 
1892
- async callCaptain(input, prompt, signal): Promise<CaptainResult> {
2484
+ async callCaptain(input, prompt, signal, callOptions): Promise<CaptainResult> {
1893
2485
  return judgeQueue.add(async () => {
1894
2486
  signal.throwIfAborted();
1895
2487
  await drainEmissions();
1896
2488
  signal.throwIfAborted();
1897
2489
  const turnId = activeTurnId;
1898
2490
  const callId = `captain-${++captainCallSequence}`;
2491
+ const visibility = callOptions?.visibility ?? 'visible';
1899
2492
  const identity = {
1900
2493
  ...stateIdentity(input.stateId),
1901
2494
  sourceItem: input.sourceItem,
1902
- visibility: 'visible' as const,
1903
- resume: false as const,
2495
+ visibility,
2496
+ // The visible workflow form owns its `resume: false` selection;
2497
+ // a hidden controller call's durable-conversation resume
2498
+ // selection is host-owned (DR-029), so its trace pair carries
2499
+ // no resume member and no token.
2500
+ ...(visibility === 'visible' ? { resume: false as const } : {}),
1904
2501
  ...(input.allowedTools === undefined
1905
2502
  ? {}
1906
2503
  : { allowedTools: [...input.allowedTools] }),
@@ -1918,8 +2515,13 @@ export function createXStatePlaybookRuntime<TOptions>(
1918
2515
  );
1919
2516
  let rawResult: unknown;
1920
2517
  try {
2518
+ // An abort may land while the awaited started emission drains
2519
+ // (e.g. fired from the trace sink itself); the host call must
2520
+ // never start after abort, so settle the already-started pair
2521
+ // as `aborted` through the catch below.
2522
+ signal.throwIfAborted();
1921
2523
  rawResult = await requireHostPorts().callCaptain(prompt, signal, {
1922
- visibility: 'visible',
2524
+ visibility,
1923
2525
  resume: false,
1924
2526
  ...(input.allowedTools !== undefined
1925
2527
  ? { allowedTools: input.allowedTools }
@@ -1956,6 +2558,7 @@ export function createXStatePlaybookRuntime<TOptions>(
1956
2558
  // authoritative for the actor's error path even when the required
1957
2559
  // finish emission fails or a coincident boundary abort lands.
1958
2560
  let resultFailure: Error | undefined;
2561
+ let emptyOkRetry = false;
1959
2562
  if (result.status !== 'ok') {
1960
2563
  resultFailure = markFsmResultFailure(
1961
2564
  new Error(
@@ -1963,12 +2566,13 @@ export function createXStatePlaybookRuntime<TOptions>(
1963
2566
  `captainActor: callCaptain status "${result.status}"`,
1964
2567
  ),
1965
2568
  );
1966
- } else if (result.finalText === undefined || result.finalText === '') {
2569
+ } else if (isEmptyFinalText(result.finalText)) {
1967
2570
  resultFailure = markFsmResultFailure(
1968
2571
  new Error(
1969
2572
  'captainActor: callCaptain returned status=ok with no finalText',
1970
2573
  ),
1971
2574
  );
2575
+ emptyOkRetry = true;
1972
2576
  }
1973
2577
  try {
1974
2578
  await emitTrace(
@@ -1990,12 +2594,17 @@ export function createXStatePlaybookRuntime<TOptions>(
1990
2594
  } catch (error) {
1991
2595
  // Keep the finish-sink failure in the emission queue for public
1992
2596
  // cleanup evidence, but do not replace an authoritative result
1993
- // failure on the invoked actor's XState onError path.
2597
+ // failure on the invoked actor's XState onError path. A failure
2598
+ // thrown here is never marked re-askable: a rejecting finish
2599
+ // sink stays a control-plane error with no corrective re-ask
2600
+ // (PBRT-47).
1994
2601
  if (resultFailure !== undefined) throw resultFailure;
1995
2602
  throw error;
1996
2603
  }
1997
2604
  if (resultFailure !== undefined) {
1998
- throw resultFailure;
2605
+ throw emptyOkRetry
2606
+ ? markEmptyOkRetryFailure(resultFailure)
2607
+ : resultFailure;
1999
2608
  }
2000
2609
  return result;
2001
2610
  }) as Promise<CaptainResult>;
@@ -2041,19 +2650,53 @@ export function createXStatePlaybookRuntime<TOptions>(
2041
2650
  try {
2042
2651
  await drainEmissions();
2043
2652
  const prompt = composeCaptainPrompt(input);
2044
- const result = await boundary.callCaptain!(input, prompt, active);
2653
+ if (spec.captainStrategy !== undefined) {
2654
+ // Controller form (slc/link.md §Captain adjudication): the
2655
+ // spec's strategy owns the call pipeline; the engine still
2656
+ // owns tracing, the shared lane, signal combination, and the
2657
+ // control-plane latch in the catch below.
2658
+ const output = await spec.captainStrategy({
2659
+ input,
2660
+ prompt,
2661
+ signal: active,
2662
+ options: boundOptions,
2663
+ session: requireSession(),
2664
+ callCaptain: (callPrompt, callOptions) =>
2665
+ boundary.callCaptain!(input, callPrompt, active, callOptions),
2666
+ isEmptyOkRetry: isEmptyOkRetryFailure,
2667
+ recoverableFailure: <E extends Error>(error: E): E => {
2668
+ markFsmResultFailure(error);
2669
+ return error;
2670
+ },
2671
+ });
2672
+ validateBossReplyOutput(input, output, resumableStateIds);
2673
+ return output;
2674
+ }
2675
+ let result: CaptainResult;
2676
+ try {
2677
+ result = await boundary.callCaptain!(input, prompt, active);
2678
+ } catch (error) {
2679
+ if (!isEmptyOkRetryFailure(error)) throw error;
2680
+ // DR-028: exactly one corrective re-ask of the same composed
2681
+ // call through the same boundary, traced as its own
2682
+ // started/finished pair, its result read under the unchanged
2683
+ // rules — a second empty `ok` result throws from the boundary
2684
+ // exactly as the first did, with no further re-ask.
2685
+ result = await boundary.callCaptain!(input, prompt, active);
2686
+ }
2045
2687
  // The boundary owns result validation (PBRT-47) and throws the
2046
2688
  // authoritative failure itself, so a returned result is always
2047
2689
  // `ok` with visible text. Assert that invariant rather than
2048
2690
  // restating the failure semantics, which would drift.
2049
- if (result.status !== 'ok' || !result.finalText) {
2691
+ const finalText = result.finalText ?? '';
2692
+ if (result.status !== 'ok' || isEmptyFinalText(finalText)) {
2050
2693
  throw new Error(
2051
2694
  'captainActor: boundary returned an unvalidated Captain result',
2052
2695
  );
2053
2696
  }
2054
2697
  const judgePrompt = defaultBuildCaptainJudgePrompt(
2055
2698
  input,
2056
- result.finalText,
2699
+ finalText,
2057
2700
  );
2058
2701
  const raw = await boundary.callJudge(
2059
2702
  'captain-output-adjudication',
@@ -2064,7 +2707,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2064
2707
  const output = adjudicateCaptainOutput(
2065
2708
  extractFields,
2066
2709
  input,
2067
- result.finalText,
2710
+ finalText,
2068
2711
  raw,
2069
2712
  );
2070
2713
  validateBossReplyOutput(input, output, resumableStateIds);
@@ -2284,6 +2927,20 @@ export function createXStatePlaybookRuntime<TOptions>(
2284
2927
  else emissionFailure ??= error;
2285
2928
  }
2286
2929
 
2930
+ // PBRT-6: the single seam that stops this runtime's actor. Stopping a
2931
+ // still-running actor fires one more `@xstate.snapshot` for the
2932
+ // *unchanged* state value with `status: 'stopped'`, which the inspect
2933
+ // callback cannot distinguish from a state entry — unsuppressed it
2934
+ // re-emits the parked state's statuses and a phantom self-loop
2935
+ // transition. Suppression is a property of stopping, not a rule each
2936
+ // caller must remember, so every stop goes through here; a caller that
2937
+ // builds a replacement actor clears the flag before starting it.
2938
+ function stopActor(): void {
2939
+ if (!actor) return;
2940
+ suppressInspectionEmissions = true;
2941
+ actor.stop();
2942
+ }
2943
+
2287
2944
  function buildActor(
2288
2945
  ports: PlaybookPorts,
2289
2946
  machineSnapshot?: JsonValue,
@@ -2434,9 +3091,8 @@ export function createXStatePlaybookRuntime<TOptions>(
2434
3091
  // A state that cannot even normalize has no disposal descriptor.
2435
3092
  }
2436
3093
  }
2437
- suppressInspectionEmissions = true;
2438
3094
  try {
2439
- actor?.stop();
3095
+ stopActor();
2440
3096
  } catch {
2441
3097
  // Preserve the original startup failure.
2442
3098
  }
@@ -2473,6 +3129,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2473
3129
  activeEmissionCalls.clear();
2474
3130
  emissionQueue.clear();
2475
3131
  judgeQueue.clear();
3132
+ appliedReceipts.clear();
2476
3133
  actor = undefined;
2477
3134
  session = undefined;
2478
3135
  savedPorts = undefined;
@@ -2482,6 +3139,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2482
3139
  controlPlaneError = undefined;
2483
3140
  emissionFailure = undefined;
2484
3141
  priorState = undefined;
3142
+ lastBossEvent = undefined;
2485
3143
  suppressInspectionEmissions = false;
2486
3144
  initialized = false;
2487
3145
  traceSequence = 0;
@@ -2490,6 +3148,183 @@ export function createXStatePlaybookRuntime<TOptions>(
2490
3148
  playerCallSequence = 0;
2491
3149
  playbookCallSequence = 0;
2492
3150
  captainCallSequence = 0;
3151
+ applyCallSequence = 0;
3152
+ }
3153
+
3154
+ // -----------------------------------------------------------------
3155
+ // DR-029 control surface: action derivation shared by `describe`
3156
+ // and by `apply`'s live revalidation.
3157
+ // -----------------------------------------------------------------
3158
+
3159
+ interface DerivedControlAction {
3160
+ action: PlaybookControlAction;
3161
+ event: EventObject;
3162
+ }
3163
+
3164
+ function snapshotCan(snapshot: unknown, event: EventObject): boolean {
3165
+ const can = (snapshot as { can?: unknown } | null)?.can;
3166
+ return (
3167
+ typeof can === 'function' &&
3168
+ (can as (candidate: EventObject) => boolean).call(snapshot, event) ===
3169
+ true
3170
+ );
3171
+ }
3172
+
3173
+ // 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
+ // recorded — is excluded rather than completed with invented text.
3177
+ function retryActionFor(
3178
+ snapshot: unknown,
3179
+ stateId: string | undefined,
3180
+ ): DerivedControlAction | undefined {
3181
+ if (stateId !== 'failed' || lastBossEvent === undefined) {
3182
+ return undefined;
3183
+ }
3184
+ if (!snapshotCan(snapshot, lastBossEvent)) return undefined;
3185
+ // A recorded explicit-state-jump event names the exact state its
3186
+ // replay re-enters: the root BOSS_INTERRUPT shape is a guarded
3187
+ // multi-arm list keyed on `targetId`, so the first configured arm
3188
+ // may label a different state than the one the recorded event
3189
+ // actually resumes.
3190
+ const recordedTargetId =
3191
+ lastBossEvent.type === JUMP_EVENT_TYPE
3192
+ ? (lastBossEvent as { targetId?: unknown }).targetId
3193
+ : undefined;
3194
+ const target =
3195
+ typeof recordedTargetId === 'string' &&
3196
+ recordedTargetId.trim().length > 0
3197
+ ? recordedTargetId
3198
+ : firstTransitionTarget(machine, stateId, lastBossEvent.type);
3199
+ // PBRT-52: a label is written from a source state description, never
3200
+ // from an identifier. Falling back to the target id — or, with no
3201
+ // resolvable target, to the FSM event type — makes the label *be* the
3202
+ // internal name, which defeats the substitution the label exists for
3203
+ // and puts a machine identifier into Boss-facing text
3204
+ // (CAPPLAY-5). A candidate whose label can only be an id is excluded
3205
+ // exactly like one whose payload cannot be sourced.
3206
+ const description =
3207
+ (target === undefined ? undefined : stateDescriptions.get(target)) ??
3208
+ stateDescriptions.get(stateId);
3209
+ if (description === undefined) return undefined;
3210
+ return {
3211
+ action: {
3212
+ id: `retry:${lastBossEvent.type}`,
3213
+ label: `Retry: ${description}`,
3214
+ },
3215
+ event: lastBossEvent,
3216
+ };
3217
+ }
3218
+
3219
+ function deriveControlActions(snapshot: unknown): DerivedControlAction[] {
3220
+ // Actions derive only at the safe point the parked snapshot also
3221
+ // uses — quiescent actor with status `active` and no pending nested
3222
+ // call. Anywhere else the view still describes the state while
3223
+ // advertising nothing.
3224
+ let state: PlaybookState;
3225
+ try {
3226
+ state = normalizePlaybookSnapshot(snapshot, {
3227
+ pendingCall: nestedBridge.getPendingCall(),
3228
+ });
3229
+ } catch {
3230
+ return [];
3231
+ }
3232
+ if (
3233
+ state.status !== 'active' ||
3234
+ !state.quiescent ||
3235
+ nestedBridge.getPendingCall()
3236
+ ) {
3237
+ return [];
3238
+ }
3239
+ const derived: DerivedControlAction[] = [];
3240
+ const retry = retryActionFor(snapshot, state.stateId);
3241
+ if (retry !== undefined) derived.push(retry);
3242
+ // Jump entries: resumable targets whose explicit-state-jump event the
3243
+ // live snapshot accepts (state guards included), sent with the
3244
+ // advertised target id and optional textual fields omitted.
3245
+ for (const targetId of [...resumableStateIds].sort()) {
3246
+ const event = { type: JUMP_EVENT_TYPE, targetId } as EventObject;
3247
+ if (!snapshotCan(snapshot, event)) continue;
3248
+ // PBRT-52: no published description for the target, no Boss-appropriate
3249
+ // label. A jump cannot borrow another state's meaning without naming
3250
+ // the wrong state, so the entry is not advertised at all rather than
3251
+ // labeled with its own target id.
3252
+ const description = stateDescriptions.get(targetId);
3253
+ if (description === undefined) continue;
3254
+ derived.push({
3255
+ action: {
3256
+ id: `jump:${targetId}`,
3257
+ label: `Resume from: ${description}`,
3258
+ },
3259
+ event,
3260
+ });
3261
+ }
3262
+ return derived;
3263
+ }
3264
+
3265
+ // PBRT-52: the control view's context is the artifact's declared
3266
+ // projection, not a serialization of whatever the FSM happens to hold.
3267
+ // Only the runtime knows which of its context members are safe and
3268
+ // relevant for a controller prompt — an allow-by-default export cannot
3269
+ // keep player output, resolved player identities, or option values out
3270
+ // of a prompt whose host is required to exclude them
3271
+ // (CAPTAIN-9) — so nothing is exported unless
3272
+ // `controlContextFields` names it, in the order it names them. Each
3273
+ // named member is still sanitized: raw `Error` values are normalized
3274
+ // and a value that cannot be made JSON-safe is dropped, never thrown,
3275
+ // since `describe` must stay side-effect free and total.
3276
+ function projectControlContext(
3277
+ context: Record<string, unknown>,
3278
+ ): JsonValue | undefined {
3279
+ const projected: Record<string, JsonValue> = {};
3280
+ for (const key of controlContextFields) {
3281
+ const value = context[key];
3282
+ if (value === undefined) continue;
3283
+ try {
3284
+ projected[key] = snapshotJsonValue(
3285
+ value instanceof Error ? normalizeError(value) : value,
3286
+ `control context ${key}`,
3287
+ );
3288
+ } catch {
3289
+ // Declared but not JSON-safe — dropped.
3290
+ }
3291
+ }
3292
+ return Object.keys(projected).length === 0 ? undefined : projected;
3293
+ }
3294
+
3295
+ // PBRT-52: the view's Boss-facing state description — the meaning of the
3296
+ // state the runtime is in, written by the artifact's own source, from the
3297
+ // same descriptions its action labels are written from. A control view is
3298
+ // the only grounding a controller host has for a status answer, and an
3299
+ // internal state id is not Boss-appropriate text
3300
+ // (CAPPLAY-5), so the runtime publishes the meaning
3301
+ // rather than leaving the host to substitute the identifier for it. A
3302
+ // state whose source declares no description publishes none: an id is
3303
+ // never promoted into a description by default.
3304
+ function stateDescriptionFor(state: PlaybookState): string | undefined {
3305
+ const keys = [
3306
+ ...(state.stateId === undefined ? [] : [state.stateId]),
3307
+ ...(typeof state.value === 'string' ? [state.value] : []),
3308
+ ...state.activeStateIds,
3309
+ ];
3310
+ for (const key of keys) {
3311
+ const description = stateDescriptions.get(key);
3312
+ if (description !== undefined) return description;
3313
+ }
3314
+ return undefined;
3315
+ }
3316
+
3317
+ function receiptTracePayload(
3318
+ receipt: PlaybookControlReceipt,
3319
+ ): Record<string, unknown> {
3320
+ return {
3321
+ disposition: receipt.disposition,
3322
+ ...(receipt.disposition === 'rejected'
3323
+ ? { reason: receipt.reason }
3324
+ : {}),
3325
+ ...(receipt.disposition === 'failed' ? { error: receipt.error } : {}),
3326
+ ...(receipt.disposition === 'executed' ? { run: receipt.run } : {}),
3327
+ };
2493
3328
  }
2494
3329
 
2495
3330
  const runtime = {
@@ -2547,7 +3382,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2547
3382
  schemaVersion: 1,
2548
3383
  playbookId: session.playbookId,
2549
3384
  machine: machineSnapshot,
2550
- playerResumeTokens: Object.fromEntries(playerResumeTokens),
3385
+ playerResumeTokens: snapshotPlayerResumeTokens(),
2551
3386
  sequences: {
2552
3387
  trace: traceSequence,
2553
3388
  turn: turnSequence,
@@ -2590,6 +3425,10 @@ export function createXStatePlaybookRuntime<TOptions>(
2590
3425
  snapshot,
2591
3426
  boundSession.playbookId,
2592
3427
  );
3428
+ let priorExternalPlayerTokens:
3429
+ | Readonly<Record<string, string>>
3430
+ | undefined;
3431
+ let externalStoreRestoreAttempted = false;
2593
3432
  initialized = true;
2594
3433
  let finishInitialization!: () => void;
2595
3434
  const initialization = new Promise<void>((resolve) => {
@@ -2611,12 +3450,16 @@ export function createXStatePlaybookRuntime<TOptions>(
2611
3450
  // Every Captain call already consumed at least one trace number,
2612
3451
  // so the global trace counter is a collision-safe id floor.
2613
3452
  boundSnapshot.sequences.trace;
2614
- playerResumeTokens.clear();
2615
- for (const [playerId, token] of Object.entries(
2616
- boundSnapshot.playerResumeTokens,
2617
- )) {
2618
- playerResumeTokens.set(playerId, token);
3453
+ // The schema-1 snapshot carries no apply counter (PBRT-50: no
3454
+ // schema bump); every apply boundary consumed trace numbers, so
3455
+ // the persisted trace counter is a collision-safe id floor here
3456
+ // too, keeping `apply-<n>` call ids unique across restore.
3457
+ applyCallSequence = boundSnapshot.sequences.trace;
3458
+ if (boundSession.playerSessions) {
3459
+ priorExternalPlayerTokens = snapshotPlayerResumeTokens();
3460
+ externalStoreRestoreAttempted = true;
2619
3461
  }
3462
+ restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
2620
3463
  suppressInspectionEmissions = true;
2621
3464
  actor = buildActor(runtimePorts, boundSnapshot.machine);
2622
3465
  actor.start();
@@ -2633,14 +3476,371 @@ export function createXStatePlaybookRuntime<TOptions>(
2633
3476
  try {
2634
3477
  await initTask;
2635
3478
  } catch (error) {
2636
- await cleanupFailedStart(error, { emitDisposal: false });
2637
- throw error;
3479
+ let failure = error;
3480
+ if (
3481
+ externalStoreRestoreAttempted &&
3482
+ priorExternalPlayerTokens !== undefined
3483
+ ) {
3484
+ try {
3485
+ boundSession.playerSessions!.restore(priorExternalPlayerTokens);
3486
+ } catch (rollbackError) {
3487
+ failure = new AggregateError(
3488
+ [error, rollbackError],
3489
+ 'createPlaybookRuntime.restore and player continuation rollback failed',
3490
+ );
3491
+ }
3492
+ }
3493
+ await cleanupFailedStart(failure, { emitDisposal: false });
3494
+ throw failure;
2638
3495
  } finally {
2639
3496
  finishInitialization();
2640
3497
  if (initInFlight === initialization) initInFlight = undefined;
2641
3498
  }
2642
3499
  },
2643
3500
 
3501
+ // DR-029 / PBRT-52: side-effect-free control view over the live
3502
+ // snapshot, valid at parked quiescence outside an active boundary.
3503
+ // The view is detached and frozen; producing it emits nothing and
3504
+ // moves nothing.
3505
+ describe(): PlaybookControlView {
3506
+ if (disposed || disposalPromise !== undefined) {
3507
+ throw new Error(
3508
+ 'createPlaybookRuntime.describe: runtime is disposing or disposed',
3509
+ );
3510
+ }
3511
+ if (!actor || !savedPorts) {
3512
+ throw new Error(
3513
+ 'createPlaybookRuntime.describe: init must be called first',
3514
+ );
3515
+ }
3516
+ if (activeSignal !== undefined) {
3517
+ throw new Error(
3518
+ 'createPlaybookRuntime.describe: another runtime turn is active',
3519
+ );
3520
+ }
3521
+ const snapshot = actor.getSnapshot();
3522
+ const state = currentState();
3523
+ const context = ((snapshot as { context?: unknown }).context ??
3524
+ {}) as Record<string, unknown>;
3525
+ const pending = pendingBossQuestionFromContext(context);
3526
+ const lastError = normalizeErrorFull(context.lastError);
3527
+ const projectedContext = projectControlContext(context);
3528
+ const stateDescription = stateDescriptionFor(state);
3529
+ return deepFreeze({
3530
+ state,
3531
+ ...(stateDescription === undefined ? {} : { stateDescription }),
3532
+ ...(projectedContext !== undefined
3533
+ ? { context: projectedContext }
3534
+ : {}),
3535
+ pendingQuestions:
3536
+ pending === undefined
3537
+ ? []
3538
+ : [
3539
+ {
3540
+ questionId: pending.questionId,
3541
+ player: pending.player,
3542
+ question: pending.question,
3543
+ sourceItem: pending.sourceItem,
3544
+ },
3545
+ ],
3546
+ ...(lastError !== undefined ? { lastError } : {}),
3547
+ actions: deriveControlActions(snapshot).map(({ action }) => action),
3548
+ });
3549
+ },
3550
+
3551
+ // DR-029 / PBRT-52: revalidate the named action against the live
3552
+ // state and execute it at most once per idempotency key. The receipt
3553
+ // discriminates rejected-before-any-effect from executed and from
3554
+ // failed-after-effects-may-exist; a repeated key returns the recorded
3555
+ // receipt without re-execution. A rejection settles before acceptance,
3556
+ // so — like a key whose call threw before reaching acceptance — it
3557
+ // records nothing and the key may execute later, once the action is
3558
+ // advertised.
3559
+ async apply(input: {
3560
+ actionId: string;
3561
+ key: string;
3562
+ signal: AbortSignal;
3563
+ }): Promise<PlaybookControlReceipt> {
3564
+ if (input === null || typeof input !== 'object') {
3565
+ throw new TypeError(
3566
+ 'createPlaybookRuntime.apply: input must be an object',
3567
+ );
3568
+ }
3569
+ const { actionId, key, signal } = input;
3570
+ if (typeof actionId !== 'string' || actionId.length === 0) {
3571
+ throw new TypeError(
3572
+ 'createPlaybookRuntime.apply: actionId must be a non-empty string',
3573
+ );
3574
+ }
3575
+ if (typeof key !== 'string' || key.length === 0) {
3576
+ throw new TypeError(
3577
+ 'createPlaybookRuntime.apply: key must be a non-empty string',
3578
+ );
3579
+ }
3580
+ if (!(signal instanceof AbortSignal)) {
3581
+ throw new TypeError(
3582
+ 'createPlaybookRuntime.apply: signal must be an AbortSignal',
3583
+ );
3584
+ }
3585
+ if (disposed || disposalPromise !== undefined) {
3586
+ throw new Error(
3587
+ 'createPlaybookRuntime.apply: runtime is disposing or disposed',
3588
+ );
3589
+ }
3590
+ if (!actor || !savedPorts) {
3591
+ throw new Error(
3592
+ 'createPlaybookRuntime.apply: init must be called first',
3593
+ );
3594
+ }
3595
+ if (activeSignal !== undefined) {
3596
+ throw new Error(
3597
+ 'createPlaybookRuntime.apply: another runtime turn is active',
3598
+ );
3599
+ }
3600
+ // Settlement is final: a repeated key returns the recorded receipt
3601
+ // with no revalidation, no execution, and no new trace pair.
3602
+ const recorded = appliedReceipts.get(key);
3603
+ if (recorded !== undefined) return recorded;
3604
+ // An abort before acceptance ends the call with no receipt
3605
+ // recorded, like every other pre-acceptance failure.
3606
+ signal.throwIfAborted();
3607
+
3608
+ const turnId = ++turnSequence;
3609
+ const callId = `apply-${++applyCallSequence}`;
3610
+ const position: TracePosition = { turnId, callId };
3611
+ activeTurnId = turnId;
3612
+ activeSignal = signal;
3613
+ controlPlaneError = undefined;
3614
+ // Every receipt variant is normalized and frozen where it is built,
3615
+ // inside the guarded region, so the recording step below cannot
3616
+ // throw after effects exist.
3617
+ const settledReceipt = (
3618
+ value: PlaybookControlReceipt,
3619
+ ): PlaybookControlReceipt =>
3620
+ deepFreeze(
3621
+ snapshotJsonValue(
3622
+ value,
3623
+ 'apply receipt',
3624
+ ) as unknown as PlaybookControlReceipt,
3625
+ );
3626
+ let receipt: PlaybookControlReceipt | undefined;
3627
+ let operationError: unknown;
3628
+ let settlementError: unknown;
3629
+ // Acceptance is the line past which this boundary owes a receipt and
3630
+ // can no longer signal by throwing: the action may have run, and a
3631
+ // caller that gets an exception instead of a receipt is left with an
3632
+ // executed effect it cannot record and a key it will not reuse.
3633
+ let accepted = false;
3634
+ // Publication is the second line this boundary respects. Before it,
3635
+ // nothing has left the runtime: a settlement failure past acceptance
3636
+ // is a post-acceptance control-plane error PBRT-52 settles as the
3637
+ // `failed` receipt, and folding it in replaces the receipt recorded
3638
+ // at acceptance so the finish trace, the returned receipt, and any
3639
+ // replay of the key all report one settlement. Past publication that
3640
+ // agreement is no longer achievable — the disposition is already on
3641
+ // the wire — so the fold refuses to run, by construction rather than
3642
+ // by call ordering. Only the first settlement error is latched, so
3643
+ // one fold is all there is to do.
3644
+ let folded = false;
3645
+ let published = false;
3646
+ const foldSettlementFailure = (): void => {
3647
+ if (published || !accepted || folded) return;
3648
+ if (settlementError === undefined) return;
3649
+ folded = true;
3650
+ receipt = settledReceipt({
3651
+ disposition: 'failed',
3652
+ error: normalizeError(settlementError),
3653
+ });
3654
+ appliedReceipts.set(key, receipt);
3655
+ };
3656
+ // A settlement failure that lands after the receipt is published says
3657
+ // nothing about the effect: the action ran, the caller's receipt is
3658
+ // true, and only the telemetry delivery failed. Rewriting `executed`
3659
+ // to `failed` there would make the runtime lie to its only caller
3660
+ // about work that succeeded, irrecoverably — accepted receipts are
3661
+ // final for their key. Past publication such a failure is therefore
3662
+ // re-latched onto the emission channel, surfacing from the next
3663
+ // public boundary's drain, and `apply` still does not throw past
3664
+ // acceptance (PBRT-52).
3665
+ const latchDeliveryFailure = (error: unknown): void => {
3666
+ emissionFailure ??= error;
3667
+ };
3668
+ try {
3669
+ try {
3670
+ const identity = {
3671
+ actionId,
3672
+ key,
3673
+ ...stateIdentity(currentState().stateId),
3674
+ };
3675
+ // Every apply finish carries the receipt disposition and no
3676
+ // start-only field — `stateId` is on the start alone
3677
+ // (slc/link.md §Playbook trace). Both finishes reachable
3678
+ // before acceptance settle with no effect behind them, so both
3679
+ // carry the canonical `rejected` disposition and the reason
3680
+ // that ended the call, alongside the transport marker.
3681
+ const preAcceptanceFinish = (
3682
+ reason: string,
3683
+ ): Record<string, unknown> => ({
3684
+ actionId,
3685
+ key,
3686
+ ...receiptTracePayload({ disposition: 'rejected', reason }),
3687
+ });
3688
+ await emitCallStarted(
3689
+ 'apply.started',
3690
+ 'apply.finished',
3691
+ identity,
3692
+ position,
3693
+ preAcceptanceFinish('apply.started trace sink rejected'),
3694
+ );
3695
+ // An abort may land while the awaited started emission drains
3696
+ // (e.g. fired from the trace sink itself); the action must
3697
+ // never execute after abort. Settle the already-started pair
3698
+ // as `aborted` — carrying the canonical rejected-before-any-
3699
+ // effect receipt disposition required of every apply finish —
3700
+ // and end the call pre-acceptance: no receipt is recorded and
3701
+ // the key stays free.
3702
+ if (signal.aborted) {
3703
+ try {
3704
+ await emitTrace(
3705
+ 'apply.finished',
3706
+ {
3707
+ ...preAcceptanceFinish('aborted before acceptance'),
3708
+ status: 'aborted',
3709
+ error: normalizeError(signal.reason),
3710
+ },
3711
+ position,
3712
+ );
3713
+ } catch (error) {
3714
+ // A rejecting finish sink surfaces at the boundary like
3715
+ // any settlement failure (see the precedence below).
3716
+ settlementError ??= error;
3717
+ }
3718
+ signal.throwIfAborted();
3719
+ }
3720
+ const snapshot = actor.getSnapshot();
3721
+ const candidate = deriveControlActions(snapshot).find(
3722
+ ({ action }) => action.id === actionId,
3723
+ );
3724
+ if (candidate === undefined) {
3725
+ receipt = settledReceipt({
3726
+ disposition: 'rejected',
3727
+ reason: `action ${JSON.stringify(
3728
+ actionId,
3729
+ )} is not currently advertised`,
3730
+ });
3731
+ } else {
3732
+ // Acceptance: from here every outcome records a receipt under
3733
+ // the key, so the action can never execute twice.
3734
+ accepted = true;
3735
+ try {
3736
+ actor.send(candidate.event);
3737
+ await waitForPlaybookQuiescence(actor, {
3738
+ pendingCalls: nestedBridge,
3739
+ });
3740
+ if (controlPlaneError !== undefined) throw controlPlaneError;
3741
+ const run = runResultFor(settledOutcome(signal));
3742
+ receipt = settledReceipt(
3743
+ run.outcome === 'failed' || run.outcome === 'aborted'
3744
+ ? {
3745
+ disposition: 'failed',
3746
+ error:
3747
+ ('error' in run ? run.error : undefined) ??
3748
+ normalizeError(
3749
+ new Error(
3750
+ `apply settled with outcome ${run.outcome}`,
3751
+ ),
3752
+ ),
3753
+ }
3754
+ : { disposition: 'executed', run },
3755
+ );
3756
+ } catch (error) {
3757
+ // Effects may exist: a post-acceptance failure is the
3758
+ // receipt, not a control-plane rejection (DR-029).
3759
+ receipt = settledReceipt({
3760
+ disposition: 'failed',
3761
+ error: normalizeError(error),
3762
+ });
3763
+ }
3764
+ }
3765
+ } catch (error) {
3766
+ operationError = error; // pre-acceptance: no receipt is recorded
3767
+ }
3768
+
3769
+ // Record acceptance before the settlement emissions, so a crash
3770
+ // between acceptance and settlement can never re-execute the
3771
+ // action: the recorded receipt survives and a replayed key
3772
+ // returns it. A rejection settled before acceptance: it is
3773
+ // returned and traced but never recorded, so its key stays free
3774
+ // to execute once the action is advertised.
3775
+ if (receipt !== undefined && receipt.disposition !== 'rejected') {
3776
+ appliedReceipts.set(key, receipt);
3777
+ }
3778
+ try {
3779
+ await drainEmissions();
3780
+ } catch (error) {
3781
+ settlementError = error;
3782
+ }
3783
+ // Fold before the finish emission, the last point at which the
3784
+ // traced disposition and the returned one can still be made the
3785
+ // same value.
3786
+ foldSettlementFailure();
3787
+ if (receipt !== undefined) {
3788
+ // Publication: this disposition is now the settlement, for the
3789
+ // trace, for the caller, and for every replay of the key.
3790
+ published = true;
3791
+ try {
3792
+ await emitTrace(
3793
+ 'apply.finished',
3794
+ { actionId, key, ...receiptTracePayload(receipt) },
3795
+ position,
3796
+ );
3797
+ } catch (error) {
3798
+ if (accepted) latchDeliveryFailure(error);
3799
+ else settlementError ??= error;
3800
+ }
3801
+ // Drain even when the finish emission rejected, so this call
3802
+ // leaves no queued emission behind it. Before acceptance the
3803
+ // failure is consumed and thrown, as every pre-acceptance failure
3804
+ // is; past it the failure is re-latched instead — the effect
3805
+ // happened, so the delivery failure travels on the emission
3806
+ // channel to the next boundary rather than rewriting what
3807
+ // happened or vanishing here.
3808
+ try {
3809
+ await drainEmissions();
3810
+ } catch (error) {
3811
+ if (accepted) latchDeliveryFailure(error);
3812
+ else settlementError ??= error;
3813
+ }
3814
+ }
3815
+ } finally {
3816
+ // Always release the boundary sentinel, even on a path no
3817
+ // constructible input reaches today, so a defect here can never
3818
+ // wedge every later public boundary behind "another runtime turn
3819
+ // is active".
3820
+ activeSignal = undefined;
3821
+ activeTurnId = undefined;
3822
+ controlPlaneError = undefined;
3823
+ }
3824
+ // Past acceptance every settlement failure has been folded into the
3825
+ // receipt, so nothing is left to throw and the caller always leaves
3826
+ // with the settlement of the effect it may have caused (PBRT-52).
3827
+ if (accepted && receipt !== undefined) return receipt;
3828
+ // Before acceptance no effect exists and no receipt is owed, so a
3829
+ // failure still surfaces by throwing. Settlement failures (a
3830
+ // rejecting finish sink, a drain-latched emission failure) outrank
3831
+ // the operation error, matching the `drainError ?? operationError`
3832
+ // precedence of the other public boundaries. A start-sink failure is
3833
+ // unaffected: its latched drain error is the start error itself.
3834
+ const failure = settlementError ?? operationError;
3835
+ if (failure !== undefined) throw failure;
3836
+ if (receipt === undefined) {
3837
+ throw new Error(
3838
+ 'createPlaybookRuntime.apply: no receipt was produced',
3839
+ );
3840
+ }
3841
+ return receipt;
3842
+ },
3843
+
2644
3844
  async handleBossInput({
2645
3845
  text,
2646
3846
  signal,
@@ -2695,6 +3895,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2695
3895
  signal,
2696
3896
  snapshot,
2697
3897
  boundary,
3898
+ boundOptions,
2698
3899
  );
2699
3900
  }
2700
3901
  signal.throwIfAborted();
@@ -2706,7 +3907,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2706
3907
  } else {
2707
3908
  // 2. Optional Captain-pane classification line: the bare FSM
2708
3909
  // event type, emitted before the FSM advances.
2709
- const statusLine = spec.classificationStatus?.(event);
3910
+ const statusLine = classificationStatus(event);
2710
3911
  if (statusLine !== undefined) {
2711
3912
  await runtimePorts!.emitStatus(statusLine);
2712
3913
  }
@@ -2714,10 +3915,24 @@ export function createXStatePlaybookRuntime<TOptions>(
2714
3915
  // 3. A final actor cannot accept new events; reconstruct only
2715
3916
  // after classification produced a real event.
2716
3917
  if (actor.getSnapshot().status === 'done') {
2717
- actor.stop();
3918
+ stopActor();
2718
3919
  actor = buildActor(runtimePorts!);
3920
+ // The replacement actor's snapshots are real state entries.
3921
+ suppressInspectionEmissions = false;
2719
3922
  actor.start();
2720
3923
  }
3924
+ // DR-029: keep the classified event with its recorded payload
3925
+ // as the retry-replay source. Recording is sanitizing, not
3926
+ // load-bearing: an override classifier's non-JSON-safe event is
3927
+ // simply not recorded, and the turn proceeds unchanged.
3928
+ try {
3929
+ lastBossEvent = snapshotJsonValue(
3930
+ event,
3931
+ 'recorded Boss event',
3932
+ ) as unknown as EventObject;
3933
+ } catch {
3934
+ lastBossEvent = undefined;
3935
+ }
2721
3936
  actor.send(event);
2722
3937
  await waitForPlaybookQuiescence(actor, {
2723
3938
  pendingCalls: nestedBridge,
@@ -2861,7 +4076,9 @@ export function createXStatePlaybookRuntime<TOptions>(
2861
4076
  const finalState = actor ? currentState() : undefined;
2862
4077
  // Stop the root before settling a suspended child. Its rejection
2863
4078
  // must not re-enter the FSM and start fresh work during disposal.
2864
- if (actor) actor.stop();
4079
+ // `stopActor` suppresses inspection first, so the stop snapshot
4080
+ // adds nothing beside the `session.disposed` trace below (PBRT-6).
4081
+ stopActor();
2865
4082
  try {
2866
4083
  await nestedBridge.dispose();
2867
4084
  } catch (error) {
@@ -2889,17 +4106,24 @@ export function createXStatePlaybookRuntime<TOptions>(
2889
4106
  }
2890
4107
  }
2891
4108
  } finally {
2892
- playerResumeTokens.clear();
4109
+ // A composing host owns the shared store for the complete root
4110
+ // engagement tree. Child disposal must not erase a token its
4111
+ // caller will resume. The private fallback remains runtime-owned.
4112
+ if (session?.playerSessions === undefined) {
4113
+ playerResumeTokens.clear();
4114
+ }
2893
4115
  activePlayerIds.clear();
2894
4116
  playbookCallTurnIds.clear();
2895
4117
  activeEmissionCalls.clear();
2896
4118
  emissionQueue.clear();
2897
4119
  judgeQueue.clear();
4120
+ appliedReceipts.clear();
2898
4121
  actor = undefined;
2899
4122
  activeSignal = undefined;
2900
4123
  activeTurnId = undefined;
2901
4124
  controlPlaneError = undefined;
2902
4125
  emissionFailure = undefined;
4126
+ lastBossEvent = undefined;
2903
4127
  savedPorts = undefined;
2904
4128
  runtimePorts = undefined;
2905
4129
  session = undefined;