@sublang/playbook 4.0.0 → 5.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.
@@ -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,9 +127,24 @@ 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;
@@ -177,6 +195,37 @@ function isFsmResultFailure(error: unknown): boolean {
177
195
  );
178
196
  }
179
197
 
198
+ // ---------------------------------------------------------------------------
199
+ // DR-028: both call boundaries treat an `ok` result whose `finalText` is
200
+ // missing, empty, or whitespace-only under one empty predicate, and that
201
+ // shape earns exactly one corrective re-ask — the same composed call
202
+ // re-issued once through the same boundary — before a second such result
203
+ // follows the existing failure path. The retry marker distinguishes the
204
+ // re-askable empty-`ok` Captain failure from the never-retried non-`ok`
205
+ // statuses; it is applied only when the failure's finish trace emitted
206
+ // cleanly, because a rejecting finish sink is a control-plane error whose
207
+ // turn gets no corrective re-ask (PBRT-47).
208
+ // ---------------------------------------------------------------------------
209
+
210
+ function isEmptyFinalText(finalText: string | undefined): boolean {
211
+ return finalText === undefined || finalText.trim().length === 0;
212
+ }
213
+
214
+ const emptyOkRetryFailures = new WeakSet<object>();
215
+
216
+ function markEmptyOkRetryFailure(error: Error): Error {
217
+ emptyOkRetryFailures.add(error);
218
+ return error;
219
+ }
220
+
221
+ function isEmptyOkRetryFailure(error: unknown): boolean {
222
+ return (
223
+ typeof error === 'object' &&
224
+ error !== null &&
225
+ emptyOkRetryFailures.has(error as object)
226
+ );
227
+ }
228
+
180
229
  // ---------------------------------------------------------------------------
181
230
  // DR-022: the engine's compatibility self-report. A linked thin module
182
231
  // records the values current at link time in `spec.compat`; the factory
@@ -248,6 +297,53 @@ function assertRuntimeCompat(
248
297
  // to preserve their existing observable behavior exactly.
249
298
  // ---------------------------------------------------------------------------
250
299
 
300
+ /**
301
+ * One direct-Captain actor invocation handed to a spec's `captainStrategy`
302
+ * (slc/link.md §Captain adjudication, controller form). The engine owns
303
+ * signal combination, emission draining, trace pairing, the shared
304
+ * Captain/judge lane, and control-plane latching; the strategy owns the
305
+ * playbook-specific call pipeline — e.g. the controller's hidden decision
306
+ * call, `{ action, … }` control-JSON validation with its single corrective
307
+ * re-ask, and controller-port submission.
308
+ */
309
+ export interface XStateCaptainStrategyRun<TOptions> {
310
+ input: PlaybookCaptainInput;
311
+ /** The prompt composed by the spec's Captain composer for `input`. */
312
+ prompt: string;
313
+ /** Combined invocation-lifetime + active-boundary abort signal. */
314
+ signal: AbortSignal;
315
+ /** The immutable validated runtime options. */
316
+ options: TOptions;
317
+ /** The bound immutable playbook session identity. */
318
+ session: PlaybookSession;
319
+ /**
320
+ * One traced Captain call through the shared serialized lane; every call —
321
+ * initial or corrective — emits its own paired `captain.call.started` /
322
+ * `captain.call.finished` boundary. Throws the boundary's authoritative
323
+ * failure for non-`ok` and empty-`ok` results exactly as the default
324
+ * pipeline does.
325
+ */
326
+ callCaptain(
327
+ prompt: string,
328
+ callOptions?: XStateCaptainCallOptions,
329
+ ): Promise<CaptainResult>;
330
+ /**
331
+ * DR-028: true when `error` is the boundary's re-askable empty-`ok`
332
+ * marker; the strategy may re-issue the same composed call exactly once.
333
+ */
334
+ isEmptyOkRetry(error: unknown): boolean;
335
+ /**
336
+ * Mark `error` as a recoverable FSM-result failure: it travels the invoked
337
+ * actor's XState `onError` path without being latched as a control-plane
338
+ * error, so the machine's authored recovery arms can route it.
339
+ */
340
+ recoverableFailure<E extends Error>(error: E): E;
341
+ }
342
+
343
+ export type XStateCaptainStrategy<TOptions> = (
344
+ run: XStateCaptainStrategyRun<TOptions>,
345
+ ) => Promise<PlaybookActorOutput>;
346
+
251
347
  export interface XStatePlaybookRuntimeSpec<TOptions> {
252
348
  /** Diagnostic label used in internal invariant errors. Default 'playbook'. */
253
349
  label?: string;
@@ -276,14 +372,25 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
276
372
  * the XState machine alone.
277
373
  */
278
374
  bossEvents?: readonly XStateBossEventSpec[];
279
- /** Boss-input classifier override; default: generic parked-state classifier. */
375
+ /** 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
376
  classifyBossText?: (
281
377
  text: string,
282
378
  ports: PlaybookPorts,
283
379
  signal: AbortSignal,
284
380
  snapshotOrState: unknown,
285
381
  boundary?: RuntimeBoundaryCalls,
382
+ options?: TOptions,
286
383
  ) => Promise<EventObject | undefined>;
384
+ /**
385
+ * Direct-Captain actor strategy override (slc/link.md §Captain
386
+ * adjudication, controller form): replaces the default visible-call +
387
+ * hidden-judge pipeline for every `captain` state of this machine. The
388
+ * engine still composes the prompt, combines signals, traces each call as
389
+ * its own pair, and latches control-plane errors; failures the strategy
390
+ * marks with `recoverableFailure` travel the actor's `onError` path as
391
+ * recoverable FSM-result failures instead.
392
+ */
393
+ captainStrategy?: XStateCaptainStrategy<TOptions>;
287
394
  /** Status line emitted after classification names an event. Default: none. */
288
395
  classificationStatus?: (event: EventObject) => string | undefined;
289
396
  /** Map a player-invoking state's input to the host player id. Default: lowercased player name. */
@@ -300,6 +407,18 @@ export interface XStatePlaybookRuntimeSpec<TOptions> {
300
407
  extractRequiredFields?: (description: string) => string[];
301
408
  /** Required fields carried verbatim from the player's finalText instead of judge JSON. Default: none. */
302
409
  verbatimPayloadFields?: ReadonlySet<string>;
410
+ /**
411
+ * DR-029 / PBRT-52: the runtime-authored ControlView context
412
+ * projection — the exact FSM context members `describe()` may expose,
413
+ * in the order the view lists them. Only this artifact knows which of
414
+ * its context members are safe and relevant for a controller prompt, so
415
+ * the engine exports what is named here and nothing else: a member the
416
+ * artifact has not named stays private, and a member added to the FSM
417
+ * later stays private until someone names it. Absent or empty: the view
418
+ * carries no context at all. `pendingBossQuestion` and `lastError` are
419
+ * surfaced first-class by the view and shall not be named here.
420
+ */
421
+ controlContextFields?: readonly string[];
303
422
  /** States that may suspend for a Boss reply. Default: targets of the FSM's `awaitBossReply` BOSS_REPLY transitions. */
304
423
  resumableStateIds?: ReadonlySet<string>;
305
424
  /** Human status lines for a root transition. Default: entry lines with question/failure surfacing. */
@@ -730,8 +849,10 @@ function validateBossReplyOutput(
730
849
  // ---------------------------------------------------------------------------
731
850
  // Delegated-player actor bridge. One PromiseActorLogic the machine invokes
732
851
  // 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.
852
+ // await callPlayer, adjudicate the finalText. An `ok` result with a missing,
853
+ // empty, or whitespace-only finalText earns exactly one corrective re-ask of
854
+ // the same composed call (DR-028); a non-`ok` result, or a second such empty
855
+ // result, throws so XState routes via onError to the FSM's failure sink.
735
856
  //
736
857
  // `getActiveSignal` flows the Boss's public-boundary signal into the host
737
858
  // port calls — fromPromise hands the bridge XState's actor-scoped signal,
@@ -757,17 +878,40 @@ export function createPlayerBridge(
757
878
  const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
758
879
  const playerId = spec.resolvePlayerId(input);
759
880
  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
- });
881
+ const callPlayer = (resume: string | false) =>
882
+ boundary
883
+ ? boundary.callPlayer(input, playerId, prompt, activeSignal)
884
+ : ports.callPlayer(playerId, prompt, activeSignal, { resume });
885
+ let result = await callPlayer(false);
886
+ if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
887
+ // An abort that lands between the empty first result and the
888
+ // corrective call ends the turn as ordinary abort settlement with
889
+ // no second host call — aborts are never retried (DR-028 via
890
+ // DR-025's transport exclusion) — matching the direct-Captain
891
+ // boundary, whose queued corrective call re-checks the signal
892
+ // before starting.
893
+ activeSignal.throwIfAborted();
894
+ // DR-028: exactly one corrective re-ask of the same composed call
895
+ // through the same path, traced by the boundary as its own
896
+ // player-call pair. The traced boundary re-reads its token map
897
+ // (PBRT-38), so the corrective call continues the player session
898
+ // when the first result carried a resume token and starts fresh
899
+ // when it cleared one; the portless verification path mirrors that
900
+ // by carrying the first result's token.
901
+ result = await callPlayer(
902
+ typeof result.resumeToken === 'string' &&
903
+ result.resumeToken.trim().length > 0
904
+ ? result.resumeToken
905
+ : false,
906
+ );
907
+ }
765
908
  if (result.status !== 'ok') {
766
909
  throw new Error(
767
910
  result.error ?? `captainBridge: callPlayer status "${result.status}"`,
768
911
  );
769
912
  }
770
- if (result.finalText === undefined) {
913
+ const finalText = result.finalText ?? '';
914
+ if (isEmptyFinalText(finalText)) {
771
915
  throw new Error(
772
916
  'captainBridge: callPlayer returned status=ok with no finalText',
773
917
  );
@@ -776,7 +920,7 @@ export function createPlayerBridge(
776
920
  const output = await adjudicatePlayerOutput(
777
921
  spec.adjudication,
778
922
  input,
779
- result.finalText,
923
+ finalText,
780
924
  ports,
781
925
  activeSignal,
782
926
  boundary,
@@ -941,6 +1085,98 @@ export function resumableStateIdsFromMachine(
941
1085
  return new Set(transitionTargets(bossReply));
942
1086
  }
943
1087
 
1088
+ // ---------------------------------------------------------------------------
1089
+ // DR-029 control surface: the FSM's explicit-state-jump event and the
1090
+ // source state descriptions that label runtime-advertised actions.
1091
+ // ---------------------------------------------------------------------------
1092
+
1093
+ /** The FSM's explicit-state-jump event type (slc/link.md §Boss-event mapping). */
1094
+ const JUMP_EVENT_TYPE = 'BOSS_INTERRUPT';
1095
+
1096
+ /**
1097
+ * Source state descriptions by state key, node id, and `meta.playbook`
1098
+ * state id, read from `machine.config`. Control actions are labeled from
1099
+ * these descriptions (DR-029); a state without one has no entry.
1100
+ */
1101
+ export function stateDescriptionsFromMachine(
1102
+ machine: AnyStateMachine,
1103
+ ): ReadonlyMap<string, string> {
1104
+ const descriptions = new Map<string, string>();
1105
+ const record = (key: unknown, description: string): void => {
1106
+ if (typeof key !== 'string' || key.length === 0) return;
1107
+ if (!descriptions.has(key)) descriptions.set(key, description);
1108
+ };
1109
+ const visit = (key: string, stateDef: unknown): void => {
1110
+ if (!isPlainObject(stateDef)) return;
1111
+ const playbook = isPlainObject(stateDef.meta)
1112
+ ? (stateDef.meta as Record<string, unknown>).playbook
1113
+ : undefined;
1114
+ const description =
1115
+ isPlainObject(playbook) && typeof playbook.description === 'string'
1116
+ ? playbook.description
1117
+ : typeof stateDef.description === 'string'
1118
+ ? stateDef.description
1119
+ : undefined;
1120
+ if (description !== undefined && description.length > 0) {
1121
+ record(key, description);
1122
+ record(stateDef.id, description);
1123
+ if (isPlainObject(playbook)) record(playbook.stateId, description);
1124
+ }
1125
+ if (isPlainObject(stateDef.states)) {
1126
+ for (const [childKey, child] of Object.entries(stateDef.states)) {
1127
+ visit(childKey, child);
1128
+ }
1129
+ }
1130
+ };
1131
+ const config = (machine as unknown as { config?: unknown }).config;
1132
+ if (isPlainObject(config) && isPlainObject(config.states)) {
1133
+ for (const [key, stateDef] of Object.entries(config.states)) {
1134
+ visit(key, stateDef);
1135
+ }
1136
+ }
1137
+ return descriptions;
1138
+ }
1139
+
1140
+ /**
1141
+ * First configured target of `eventType` from the state with `stateId`,
1142
+ * falling back to the machine root's own transitions. Used only to pick the
1143
+ * source description that labels a retry action, and only for events that
1144
+ * carry no recorded `targetId`: a guarded multi-arm list keyed on the
1145
+ * event's `targetId` (the root `BOSS_INTERRUPT` shape) resumes the recorded
1146
+ * target, not the first configured arm, so the recorded event outranks this
1147
+ * fallback.
1148
+ */
1149
+ function firstTransitionTarget(
1150
+ machine: AnyStateMachine,
1151
+ stateId: string | undefined,
1152
+ eventType: string,
1153
+ ): string | undefined {
1154
+ const config = (machine as unknown as { config?: unknown }).config;
1155
+ if (!isPlainObject(config)) return undefined;
1156
+ const candidates: unknown[] = [];
1157
+ if (stateId !== undefined && isPlainObject(config.states)) {
1158
+ const state = config.states[stateId];
1159
+ if (isPlainObject(state) && isPlainObject(state.on)) {
1160
+ candidates.push(state.on[eventType]);
1161
+ }
1162
+ }
1163
+ if (isPlainObject(config.on)) candidates.push(config.on[eventType]);
1164
+ for (const candidate of candidates) {
1165
+ if (candidate === undefined) continue;
1166
+ const targets = transitionTargets(candidate);
1167
+ if (targets.length > 0) return targets[0];
1168
+ }
1169
+ return undefined;
1170
+ }
1171
+
1172
+ function deepFreeze<T>(value: T): T {
1173
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
1174
+ Object.freeze(value);
1175
+ for (const member of Object.values(value)) deepFreeze(member);
1176
+ }
1177
+ return value;
1178
+ }
1179
+
944
1180
  // ---------------------------------------------------------------------------
945
1181
  // Default transition/status derivation.
946
1182
  // ---------------------------------------------------------------------------
@@ -1399,6 +1635,16 @@ interface TracePosition {
1399
1635
  callId?: string;
1400
1636
  }
1401
1637
 
1638
+ function machineDeclaresParallelState(machine: AnyStateMachine): boolean {
1639
+ const visit = (stateDef: unknown): boolean => {
1640
+ if (!isPlainObject(stateDef)) return false;
1641
+ if (stateDef.type === 'parallel') return true;
1642
+ if (!isPlainObject(stateDef.states)) return false;
1643
+ return Object.values(stateDef.states).some(visit);
1644
+ };
1645
+ return visit((machine as unknown as { config?: unknown }).config);
1646
+ }
1647
+
1402
1648
  /**
1403
1649
  * Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
1404
1650
  * under the slc/link.md contract. The factory provides every actor kind the
@@ -1406,8 +1652,9 @@ interface TracePosition {
1406
1652
  * (literal and dynamic) — and implements the full runtime lifecycle including
1407
1653
  * the optional parked-session snapshot capability (DR-014).
1408
1654
  *
1409
- * Scope: single-region root machines (each snapshot exposes exactly one
1410
- * playbook state id). Parallel-region FSMs keep their own linked runtimes.
1655
+ * Scope: machines that declare no parallel state (each snapshot exposes
1656
+ * exactly one playbook state id). Parallel-region FSMs keep their own linked
1657
+ * runtimes.
1411
1658
  */
1412
1659
  export function createXStatePlaybookRuntime<TOptions>(
1413
1660
  machine: AnyStateMachine,
@@ -1417,9 +1664,33 @@ export function createXStatePlaybookRuntime<TOptions>(
1417
1664
  // DR-022 / PBRT-50: reject an incompatible artifact declaration before any
1418
1665
  // machine interpretation, against this loaded engine's own self-report.
1419
1666
  assertRuntimeCompat(spec.compat, label);
1667
+ if (machineDeclaresParallelState(machine)) {
1668
+ throw new Error(
1669
+ `${label} uses a parallel state; the shared runtime supports only single-region FSMs`,
1670
+ );
1671
+ }
1420
1672
  const declaredActors = collectInvokeSources(machine);
1421
1673
  const resumableStateIds =
1422
1674
  spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
1675
+ // DR-029: source state descriptions label the control actions the
1676
+ // runtime advertises through `describe()`.
1677
+ const stateDescriptions = stateDescriptionsFromMachine(machine);
1678
+ // PBRT-52: the artifact's own ControlView context projection. Nothing is
1679
+ // exported by default, so an FSM context member — including one added
1680
+ // after this artifact was linked — is private until named here. The two
1681
+ // members the view surfaces first-class are rejected at construction
1682
+ // rather than silently ignored, so an artifact cannot believe it is
1683
+ // exporting them through this list.
1684
+ const controlContextFields: readonly string[] = spec.controlContextFields
1685
+ ? [...spec.controlContextFields]
1686
+ : [];
1687
+ for (const field of controlContextFields) {
1688
+ if (field === 'pendingBossQuestion' || field === 'lastError') {
1689
+ throw new Error(
1690
+ `${label} controlContextFields must not name ${field}: the control view surfaces it first-class`,
1691
+ );
1692
+ }
1693
+ }
1423
1694
  const resolvePlayerIdSpec = spec.resolvePlayerId;
1424
1695
  const composePlayerPrompt =
1425
1696
  spec.composePlayerPrompt ??
@@ -1451,7 +1722,9 @@ export function createXStatePlaybookRuntime<TOptions>(
1451
1722
  spec.entryEvent,
1452
1723
  spec.bossEvents ?? [],
1453
1724
  );
1454
- const classifyBossText = spec.classifyBossText ?? derivedClassifyBossText;
1725
+ const classifyBossText: NonNullable<
1726
+ XStatePlaybookRuntimeSpec<TOptions>['classifyBossText']
1727
+ > = spec.classifyBossText ?? derivedClassifyBossText;
1455
1728
  const normalizeTransitionEvent =
1456
1729
  spec.normalizeTransitionEvent ??
1457
1730
  makeDefaultNormalizeTransitionEvent(spec.transitionEventFields ?? []);
@@ -1493,6 +1766,19 @@ export function createXStatePlaybookRuntime<TOptions>(
1493
1766
  let playerCallSequence = 0;
1494
1767
  let playbookCallSequence = 0;
1495
1768
  let captainCallSequence = 0;
1769
+ let applyCallSequence = 0;
1770
+ // DR-029: the last event a public Boss boundary sent into the
1771
+ // machine — classified, deterministic entry, or Boss reply — kept with
1772
+ // its recorded payload so a failure-state retry action can replay the
1773
+ // event that drove the run into `failed`. Process-local: the schema-1
1774
+ // parked snapshot does not persist it (PBRT-50: no schema bump).
1775
+ let lastBossEvent: EventObject | undefined;
1776
+ // DR-029: process-local at-most-once `apply` execution — the accepted receipt
1777
+ // recorded for each idempotency key, returned verbatim on a repeated
1778
+ // key. A key whose call settled `rejected` or threw before reaching
1779
+ // acceptance records nothing, so a later call with that key may still
1780
+ // execute.
1781
+ const appliedReceipts = new Map<string, PlaybookControlReceipt>();
1496
1782
  const playerResumeTokens = new Map<string, string>();
1497
1783
  const activePlayerIds = new Set<string>();
1498
1784
  const playbookCallTurnIds = new Map<string, number | undefined>();
@@ -1672,13 +1958,22 @@ export function createXStatePlaybookRuntime<TOptions>(
1672
1958
  startedType:
1673
1959
  | 'player.call.started'
1674
1960
  | 'judge.call.started'
1675
- | 'captain.call.started',
1961
+ | 'captain.call.started'
1962
+ | 'apply.started',
1676
1963
  finishedType:
1677
1964
  | 'player.call.finished'
1678
1965
  | 'judge.call.finished'
1679
- | 'captain.call.finished',
1966
+ | 'captain.call.finished'
1967
+ | 'apply.finished',
1680
1968
  identity: Record<string, unknown>,
1681
1969
  position: TracePosition,
1970
+ // Base payload of the best-effort finish emitted when the start sink
1971
+ // rejects; it defaults to the payload the start carried, which the
1972
+ // player, judge, and captain pairs take as-is. The apply pair cannot:
1973
+ // its finish carries the receipt disposition and none of the
1974
+ // start-only fields, so it passes its own canonical pre-acceptance
1975
+ // base (slc/link.md §Playbook trace).
1976
+ finishIdentity: Record<string, unknown> = identity,
1682
1977
  ): Promise<void> {
1683
1978
  try {
1684
1979
  await emitTrace(startedType, identity, position);
@@ -1687,7 +1982,11 @@ export function createXStatePlaybookRuntime<TOptions>(
1687
1982
  try {
1688
1983
  await emitTrace(
1689
1984
  finishedType,
1690
- { ...identity, status: 'error', error: normalizeError(error) },
1985
+ {
1986
+ ...finishIdentity,
1987
+ status: 'error',
1988
+ error: normalizeError(error),
1989
+ },
1691
1990
  position,
1692
1991
  );
1693
1992
  } catch {
@@ -1745,6 +2044,11 @@ export function createXStatePlaybookRuntime<TOptions>(
1745
2044
 
1746
2045
  let rawResult: unknown;
1747
2046
  try {
2047
+ // An abort may land while the awaited started emission drains
2048
+ // (e.g. fired from the trace sink itself); the host call must
2049
+ // never start after abort, so settle the already-started pair
2050
+ // as `aborted` through the catch below.
2051
+ signal.throwIfAborted();
1748
2052
  rawResult = await requireHostPorts().callPlayer(
1749
2053
  playerId,
1750
2054
  prompt,
@@ -1846,6 +2150,11 @@ export function createXStatePlaybookRuntime<TOptions>(
1846
2150
  );
1847
2151
  let reply: unknown;
1848
2152
  try {
2153
+ // An abort may land while the awaited started emission drains
2154
+ // (e.g. fired from the trace sink itself); the host call must
2155
+ // never start after abort, so settle the already-started pair
2156
+ // as `aborted` through the catch below.
2157
+ signal.throwIfAborted();
1849
2158
  reply = await requireHostPorts().callJudge(prompt, signal);
1850
2159
  signal.throwIfAborted();
1851
2160
  } catch (error) {
@@ -1889,18 +2198,23 @@ export function createXStatePlaybookRuntime<TOptions>(
1889
2198
  }) as Promise<string>;
1890
2199
  },
1891
2200
 
1892
- async callCaptain(input, prompt, signal): Promise<CaptainResult> {
2201
+ async callCaptain(input, prompt, signal, callOptions): Promise<CaptainResult> {
1893
2202
  return judgeQueue.add(async () => {
1894
2203
  signal.throwIfAborted();
1895
2204
  await drainEmissions();
1896
2205
  signal.throwIfAborted();
1897
2206
  const turnId = activeTurnId;
1898
2207
  const callId = `captain-${++captainCallSequence}`;
2208
+ const visibility = callOptions?.visibility ?? 'visible';
1899
2209
  const identity = {
1900
2210
  ...stateIdentity(input.stateId),
1901
2211
  sourceItem: input.sourceItem,
1902
- visibility: 'visible' as const,
1903
- resume: false as const,
2212
+ visibility,
2213
+ // The visible workflow form owns its `resume: false` selection;
2214
+ // a hidden controller call's durable-conversation resume
2215
+ // selection is host-owned (DR-029), so its trace pair carries
2216
+ // no resume member and no token.
2217
+ ...(visibility === 'visible' ? { resume: false as const } : {}),
1904
2218
  ...(input.allowedTools === undefined
1905
2219
  ? {}
1906
2220
  : { allowedTools: [...input.allowedTools] }),
@@ -1918,8 +2232,13 @@ export function createXStatePlaybookRuntime<TOptions>(
1918
2232
  );
1919
2233
  let rawResult: unknown;
1920
2234
  try {
2235
+ // An abort may land while the awaited started emission drains
2236
+ // (e.g. fired from the trace sink itself); the host call must
2237
+ // never start after abort, so settle the already-started pair
2238
+ // as `aborted` through the catch below.
2239
+ signal.throwIfAborted();
1921
2240
  rawResult = await requireHostPorts().callCaptain(prompt, signal, {
1922
- visibility: 'visible',
2241
+ visibility,
1923
2242
  resume: false,
1924
2243
  ...(input.allowedTools !== undefined
1925
2244
  ? { allowedTools: input.allowedTools }
@@ -1956,6 +2275,7 @@ export function createXStatePlaybookRuntime<TOptions>(
1956
2275
  // authoritative for the actor's error path even when the required
1957
2276
  // finish emission fails or a coincident boundary abort lands.
1958
2277
  let resultFailure: Error | undefined;
2278
+ let emptyOkRetry = false;
1959
2279
  if (result.status !== 'ok') {
1960
2280
  resultFailure = markFsmResultFailure(
1961
2281
  new Error(
@@ -1963,12 +2283,13 @@ export function createXStatePlaybookRuntime<TOptions>(
1963
2283
  `captainActor: callCaptain status "${result.status}"`,
1964
2284
  ),
1965
2285
  );
1966
- } else if (result.finalText === undefined || result.finalText === '') {
2286
+ } else if (isEmptyFinalText(result.finalText)) {
1967
2287
  resultFailure = markFsmResultFailure(
1968
2288
  new Error(
1969
2289
  'captainActor: callCaptain returned status=ok with no finalText',
1970
2290
  ),
1971
2291
  );
2292
+ emptyOkRetry = true;
1972
2293
  }
1973
2294
  try {
1974
2295
  await emitTrace(
@@ -1990,12 +2311,17 @@ export function createXStatePlaybookRuntime<TOptions>(
1990
2311
  } catch (error) {
1991
2312
  // Keep the finish-sink failure in the emission queue for public
1992
2313
  // cleanup evidence, but do not replace an authoritative result
1993
- // failure on the invoked actor's XState onError path.
2314
+ // failure on the invoked actor's XState onError path. A failure
2315
+ // thrown here is never marked re-askable: a rejecting finish
2316
+ // sink stays a control-plane error with no corrective re-ask
2317
+ // (PBRT-47).
1994
2318
  if (resultFailure !== undefined) throw resultFailure;
1995
2319
  throw error;
1996
2320
  }
1997
2321
  if (resultFailure !== undefined) {
1998
- throw resultFailure;
2322
+ throw emptyOkRetry
2323
+ ? markEmptyOkRetryFailure(resultFailure)
2324
+ : resultFailure;
1999
2325
  }
2000
2326
  return result;
2001
2327
  }) as Promise<CaptainResult>;
@@ -2041,19 +2367,53 @@ export function createXStatePlaybookRuntime<TOptions>(
2041
2367
  try {
2042
2368
  await drainEmissions();
2043
2369
  const prompt = composeCaptainPrompt(input);
2044
- const result = await boundary.callCaptain!(input, prompt, active);
2370
+ if (spec.captainStrategy !== undefined) {
2371
+ // Controller form (slc/link.md §Captain adjudication): the
2372
+ // spec's strategy owns the call pipeline; the engine still
2373
+ // owns tracing, the shared lane, signal combination, and the
2374
+ // control-plane latch in the catch below.
2375
+ const output = await spec.captainStrategy({
2376
+ input,
2377
+ prompt,
2378
+ signal: active,
2379
+ options: boundOptions,
2380
+ session: requireSession(),
2381
+ callCaptain: (callPrompt, callOptions) =>
2382
+ boundary.callCaptain!(input, callPrompt, active, callOptions),
2383
+ isEmptyOkRetry: isEmptyOkRetryFailure,
2384
+ recoverableFailure: <E extends Error>(error: E): E => {
2385
+ markFsmResultFailure(error);
2386
+ return error;
2387
+ },
2388
+ });
2389
+ validateBossReplyOutput(input, output, resumableStateIds);
2390
+ return output;
2391
+ }
2392
+ let result: CaptainResult;
2393
+ try {
2394
+ result = await boundary.callCaptain!(input, prompt, active);
2395
+ } catch (error) {
2396
+ if (!isEmptyOkRetryFailure(error)) throw error;
2397
+ // DR-028: exactly one corrective re-ask of the same composed
2398
+ // call through the same boundary, traced as its own
2399
+ // started/finished pair, its result read under the unchanged
2400
+ // rules — a second empty `ok` result throws from the boundary
2401
+ // exactly as the first did, with no further re-ask.
2402
+ result = await boundary.callCaptain!(input, prompt, active);
2403
+ }
2045
2404
  // The boundary owns result validation (PBRT-47) and throws the
2046
2405
  // authoritative failure itself, so a returned result is always
2047
2406
  // `ok` with visible text. Assert that invariant rather than
2048
2407
  // restating the failure semantics, which would drift.
2049
- if (result.status !== 'ok' || !result.finalText) {
2408
+ const finalText = result.finalText ?? '';
2409
+ if (result.status !== 'ok' || isEmptyFinalText(finalText)) {
2050
2410
  throw new Error(
2051
2411
  'captainActor: boundary returned an unvalidated Captain result',
2052
2412
  );
2053
2413
  }
2054
2414
  const judgePrompt = defaultBuildCaptainJudgePrompt(
2055
2415
  input,
2056
- result.finalText,
2416
+ finalText,
2057
2417
  );
2058
2418
  const raw = await boundary.callJudge(
2059
2419
  'captain-output-adjudication',
@@ -2064,7 +2424,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2064
2424
  const output = adjudicateCaptainOutput(
2065
2425
  extractFields,
2066
2426
  input,
2067
- result.finalText,
2427
+ finalText,
2068
2428
  raw,
2069
2429
  );
2070
2430
  validateBossReplyOutput(input, output, resumableStateIds);
@@ -2284,6 +2644,20 @@ export function createXStatePlaybookRuntime<TOptions>(
2284
2644
  else emissionFailure ??= error;
2285
2645
  }
2286
2646
 
2647
+ // PBRT-6: the single seam that stops this runtime's actor. Stopping a
2648
+ // still-running actor fires one more `@xstate.snapshot` for the
2649
+ // *unchanged* state value with `status: 'stopped'`, which the inspect
2650
+ // callback cannot distinguish from a state entry — unsuppressed it
2651
+ // re-emits the parked state's statuses and a phantom self-loop
2652
+ // transition. Suppression is a property of stopping, not a rule each
2653
+ // caller must remember, so every stop goes through here; a caller that
2654
+ // builds a replacement actor clears the flag before starting it.
2655
+ function stopActor(): void {
2656
+ if (!actor) return;
2657
+ suppressInspectionEmissions = true;
2658
+ actor.stop();
2659
+ }
2660
+
2287
2661
  function buildActor(
2288
2662
  ports: PlaybookPorts,
2289
2663
  machineSnapshot?: JsonValue,
@@ -2434,9 +2808,8 @@ export function createXStatePlaybookRuntime<TOptions>(
2434
2808
  // A state that cannot even normalize has no disposal descriptor.
2435
2809
  }
2436
2810
  }
2437
- suppressInspectionEmissions = true;
2438
2811
  try {
2439
- actor?.stop();
2812
+ stopActor();
2440
2813
  } catch {
2441
2814
  // Preserve the original startup failure.
2442
2815
  }
@@ -2473,6 +2846,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2473
2846
  activeEmissionCalls.clear();
2474
2847
  emissionQueue.clear();
2475
2848
  judgeQueue.clear();
2849
+ appliedReceipts.clear();
2476
2850
  actor = undefined;
2477
2851
  session = undefined;
2478
2852
  savedPorts = undefined;
@@ -2482,6 +2856,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2482
2856
  controlPlaneError = undefined;
2483
2857
  emissionFailure = undefined;
2484
2858
  priorState = undefined;
2859
+ lastBossEvent = undefined;
2485
2860
  suppressInspectionEmissions = false;
2486
2861
  initialized = false;
2487
2862
  traceSequence = 0;
@@ -2490,6 +2865,183 @@ export function createXStatePlaybookRuntime<TOptions>(
2490
2865
  playerCallSequence = 0;
2491
2866
  playbookCallSequence = 0;
2492
2867
  captainCallSequence = 0;
2868
+ applyCallSequence = 0;
2869
+ }
2870
+
2871
+ // -----------------------------------------------------------------
2872
+ // DR-029 control surface: action derivation shared by `describe`
2873
+ // and by `apply`'s live revalidation.
2874
+ // -----------------------------------------------------------------
2875
+
2876
+ interface DerivedControlAction {
2877
+ action: PlaybookControlAction;
2878
+ event: EventObject;
2879
+ }
2880
+
2881
+ function snapshotCan(snapshot: unknown, event: EventObject): boolean {
2882
+ const can = (snapshot as { can?: unknown } | null)?.can;
2883
+ return (
2884
+ typeof can === 'function' &&
2885
+ (can as (candidate: EventObject) => boolean).call(snapshot, event) ===
2886
+ true
2887
+ );
2888
+ }
2889
+
2890
+ // The failure-state retry entry replays the recorded last classified
2891
+ // event with its recorded payload. A candidate whose event the live
2892
+ // snapshot does not accept — or whose payload the runtime never
2893
+ // recorded — is excluded rather than completed with invented text.
2894
+ function retryActionFor(
2895
+ snapshot: unknown,
2896
+ stateId: string | undefined,
2897
+ ): DerivedControlAction | undefined {
2898
+ if (stateId !== 'failed' || lastBossEvent === undefined) {
2899
+ return undefined;
2900
+ }
2901
+ if (!snapshotCan(snapshot, lastBossEvent)) return undefined;
2902
+ // A recorded explicit-state-jump event names the exact state its
2903
+ // replay re-enters: the root BOSS_INTERRUPT shape is a guarded
2904
+ // multi-arm list keyed on `targetId`, so the first configured arm
2905
+ // may label a different state than the one the recorded event
2906
+ // actually resumes.
2907
+ const recordedTargetId =
2908
+ lastBossEvent.type === JUMP_EVENT_TYPE
2909
+ ? (lastBossEvent as { targetId?: unknown }).targetId
2910
+ : undefined;
2911
+ const target =
2912
+ typeof recordedTargetId === 'string' &&
2913
+ recordedTargetId.trim().length > 0
2914
+ ? recordedTargetId
2915
+ : firstTransitionTarget(machine, stateId, lastBossEvent.type);
2916
+ // PBRT-52: a label is written from a source state description, never
2917
+ // from an identifier. Falling back to the target id — or, with no
2918
+ // resolvable target, to the FSM event type — makes the label *be* the
2919
+ // internal name, which defeats the substitution the label exists for
2920
+ // and puts a machine identifier into Boss-facing text
2921
+ // (CAPPLAY-5). A candidate whose label can only be an id is excluded
2922
+ // exactly like one whose payload cannot be sourced.
2923
+ const description =
2924
+ (target === undefined ? undefined : stateDescriptions.get(target)) ??
2925
+ stateDescriptions.get(stateId);
2926
+ if (description === undefined) return undefined;
2927
+ return {
2928
+ action: {
2929
+ id: `retry:${lastBossEvent.type}`,
2930
+ label: `Retry: ${description}`,
2931
+ },
2932
+ event: lastBossEvent,
2933
+ };
2934
+ }
2935
+
2936
+ function deriveControlActions(snapshot: unknown): DerivedControlAction[] {
2937
+ // Actions derive only at the safe point the parked snapshot also
2938
+ // uses — quiescent actor with status `active` and no pending nested
2939
+ // call. Anywhere else the view still describes the state while
2940
+ // advertising nothing.
2941
+ let state: PlaybookState;
2942
+ try {
2943
+ state = normalizePlaybookSnapshot(snapshot, {
2944
+ pendingCall: nestedBridge.getPendingCall(),
2945
+ });
2946
+ } catch {
2947
+ return [];
2948
+ }
2949
+ if (
2950
+ state.status !== 'active' ||
2951
+ !state.quiescent ||
2952
+ nestedBridge.getPendingCall()
2953
+ ) {
2954
+ return [];
2955
+ }
2956
+ const derived: DerivedControlAction[] = [];
2957
+ const retry = retryActionFor(snapshot, state.stateId);
2958
+ if (retry !== undefined) derived.push(retry);
2959
+ // Jump entries: resumable targets whose explicit-state-jump event the
2960
+ // live snapshot accepts (state guards included), sent with the
2961
+ // advertised target id and optional textual fields omitted.
2962
+ for (const targetId of [...resumableStateIds].sort()) {
2963
+ const event = { type: JUMP_EVENT_TYPE, targetId } as EventObject;
2964
+ if (!snapshotCan(snapshot, event)) continue;
2965
+ // PBRT-52: no published description for the target, no Boss-appropriate
2966
+ // label. A jump cannot borrow another state's meaning without naming
2967
+ // the wrong state, so the entry is not advertised at all rather than
2968
+ // labeled with its own target id.
2969
+ const description = stateDescriptions.get(targetId);
2970
+ if (description === undefined) continue;
2971
+ derived.push({
2972
+ action: {
2973
+ id: `jump:${targetId}`,
2974
+ label: `Resume from: ${description}`,
2975
+ },
2976
+ event,
2977
+ });
2978
+ }
2979
+ return derived;
2980
+ }
2981
+
2982
+ // PBRT-52: the control view's context is the artifact's declared
2983
+ // projection, not a serialization of whatever the FSM happens to hold.
2984
+ // Only the runtime knows which of its context members are safe and
2985
+ // relevant for a controller prompt — an allow-by-default export cannot
2986
+ // keep player output, resolved player identities, or option values out
2987
+ // of a prompt whose host is required to exclude them
2988
+ // (CAPTAIN-9) — so nothing is exported unless
2989
+ // `controlContextFields` names it, in the order it names them. Each
2990
+ // named member is still sanitized: raw `Error` values are normalized
2991
+ // and a value that cannot be made JSON-safe is dropped, never thrown,
2992
+ // since `describe` must stay side-effect free and total.
2993
+ function projectControlContext(
2994
+ context: Record<string, unknown>,
2995
+ ): JsonValue | undefined {
2996
+ const projected: Record<string, JsonValue> = {};
2997
+ for (const key of controlContextFields) {
2998
+ const value = context[key];
2999
+ if (value === undefined) continue;
3000
+ try {
3001
+ projected[key] = snapshotJsonValue(
3002
+ value instanceof Error ? normalizeError(value) : value,
3003
+ `control context ${key}`,
3004
+ );
3005
+ } catch {
3006
+ // Declared but not JSON-safe — dropped.
3007
+ }
3008
+ }
3009
+ return Object.keys(projected).length === 0 ? undefined : projected;
3010
+ }
3011
+
3012
+ // PBRT-52: the view's Boss-facing state description — the meaning of the
3013
+ // state the runtime is in, written by the artifact's own source, from the
3014
+ // same descriptions its action labels are written from. A control view is
3015
+ // the only grounding a controller host has for a status answer, and an
3016
+ // internal state id is not Boss-appropriate text
3017
+ // (CAPPLAY-5), so the runtime publishes the meaning
3018
+ // rather than leaving the host to substitute the identifier for it. A
3019
+ // state whose source declares no description publishes none: an id is
3020
+ // never promoted into a description by default.
3021
+ function stateDescriptionFor(state: PlaybookState): string | undefined {
3022
+ const keys = [
3023
+ ...(state.stateId === undefined ? [] : [state.stateId]),
3024
+ ...(typeof state.value === 'string' ? [state.value] : []),
3025
+ ...state.activeStateIds,
3026
+ ];
3027
+ for (const key of keys) {
3028
+ const description = stateDescriptions.get(key);
3029
+ if (description !== undefined) return description;
3030
+ }
3031
+ return undefined;
3032
+ }
3033
+
3034
+ function receiptTracePayload(
3035
+ receipt: PlaybookControlReceipt,
3036
+ ): Record<string, unknown> {
3037
+ return {
3038
+ disposition: receipt.disposition,
3039
+ ...(receipt.disposition === 'rejected'
3040
+ ? { reason: receipt.reason }
3041
+ : {}),
3042
+ ...(receipt.disposition === 'failed' ? { error: receipt.error } : {}),
3043
+ ...(receipt.disposition === 'executed' ? { run: receipt.run } : {}),
3044
+ };
2493
3045
  }
2494
3046
 
2495
3047
  const runtime = {
@@ -2611,6 +3163,11 @@ export function createXStatePlaybookRuntime<TOptions>(
2611
3163
  // Every Captain call already consumed at least one trace number,
2612
3164
  // so the global trace counter is a collision-safe id floor.
2613
3165
  boundSnapshot.sequences.trace;
3166
+ // The schema-1 snapshot carries no apply counter (PBRT-50: no
3167
+ // schema bump); every apply boundary consumed trace numbers, so
3168
+ // the persisted trace counter is a collision-safe id floor here
3169
+ // too, keeping `apply-<n>` call ids unique across restore.
3170
+ applyCallSequence = boundSnapshot.sequences.trace;
2614
3171
  playerResumeTokens.clear();
2615
3172
  for (const [playerId, token] of Object.entries(
2616
3173
  boundSnapshot.playerResumeTokens,
@@ -2641,6 +3198,349 @@ export function createXStatePlaybookRuntime<TOptions>(
2641
3198
  }
2642
3199
  },
2643
3200
 
3201
+ // DR-029 / PBRT-52: side-effect-free control view over the live
3202
+ // snapshot, valid at parked quiescence outside an active boundary.
3203
+ // The view is detached and frozen; producing it emits nothing and
3204
+ // moves nothing.
3205
+ describe(): PlaybookControlView {
3206
+ if (disposed || disposalPromise !== undefined) {
3207
+ throw new Error(
3208
+ 'createPlaybookRuntime.describe: runtime is disposing or disposed',
3209
+ );
3210
+ }
3211
+ if (!actor || !savedPorts) {
3212
+ throw new Error(
3213
+ 'createPlaybookRuntime.describe: init must be called first',
3214
+ );
3215
+ }
3216
+ if (activeSignal !== undefined) {
3217
+ throw new Error(
3218
+ 'createPlaybookRuntime.describe: another runtime turn is active',
3219
+ );
3220
+ }
3221
+ const snapshot = actor.getSnapshot();
3222
+ const state = currentState();
3223
+ const context = ((snapshot as { context?: unknown }).context ??
3224
+ {}) as Record<string, unknown>;
3225
+ const pending = pendingBossQuestionFromContext(context);
3226
+ const lastError = normalizeErrorFull(context.lastError);
3227
+ const projectedContext = projectControlContext(context);
3228
+ const stateDescription = stateDescriptionFor(state);
3229
+ return deepFreeze({
3230
+ state,
3231
+ ...(stateDescription === undefined ? {} : { stateDescription }),
3232
+ ...(projectedContext !== undefined
3233
+ ? { context: projectedContext }
3234
+ : {}),
3235
+ pendingQuestions:
3236
+ pending === undefined
3237
+ ? []
3238
+ : [
3239
+ {
3240
+ questionId: pending.questionId,
3241
+ player: pending.player,
3242
+ question: pending.question,
3243
+ sourceItem: pending.sourceItem,
3244
+ },
3245
+ ],
3246
+ ...(lastError !== undefined ? { lastError } : {}),
3247
+ actions: deriveControlActions(snapshot).map(({ action }) => action),
3248
+ });
3249
+ },
3250
+
3251
+ // DR-029 / PBRT-52: revalidate the named action against the live
3252
+ // state and execute it at most once per idempotency key. The receipt
3253
+ // discriminates rejected-before-any-effect from executed and from
3254
+ // failed-after-effects-may-exist; a repeated key returns the recorded
3255
+ // receipt without re-execution. A rejection settles before acceptance,
3256
+ // so — like a key whose call threw before reaching acceptance — it
3257
+ // records nothing and the key may execute later, once the action is
3258
+ // advertised.
3259
+ async apply(input: {
3260
+ actionId: string;
3261
+ key: string;
3262
+ signal: AbortSignal;
3263
+ }): Promise<PlaybookControlReceipt> {
3264
+ if (input === null || typeof input !== 'object') {
3265
+ throw new TypeError(
3266
+ 'createPlaybookRuntime.apply: input must be an object',
3267
+ );
3268
+ }
3269
+ const { actionId, key, signal } = input;
3270
+ if (typeof actionId !== 'string' || actionId.length === 0) {
3271
+ throw new TypeError(
3272
+ 'createPlaybookRuntime.apply: actionId must be a non-empty string',
3273
+ );
3274
+ }
3275
+ if (typeof key !== 'string' || key.length === 0) {
3276
+ throw new TypeError(
3277
+ 'createPlaybookRuntime.apply: key must be a non-empty string',
3278
+ );
3279
+ }
3280
+ if (!(signal instanceof AbortSignal)) {
3281
+ throw new TypeError(
3282
+ 'createPlaybookRuntime.apply: signal must be an AbortSignal',
3283
+ );
3284
+ }
3285
+ if (disposed || disposalPromise !== undefined) {
3286
+ throw new Error(
3287
+ 'createPlaybookRuntime.apply: runtime is disposing or disposed',
3288
+ );
3289
+ }
3290
+ if (!actor || !savedPorts) {
3291
+ throw new Error(
3292
+ 'createPlaybookRuntime.apply: init must be called first',
3293
+ );
3294
+ }
3295
+ if (activeSignal !== undefined) {
3296
+ throw new Error(
3297
+ 'createPlaybookRuntime.apply: another runtime turn is active',
3298
+ );
3299
+ }
3300
+ // Settlement is final: a repeated key returns the recorded receipt
3301
+ // with no revalidation, no execution, and no new trace pair.
3302
+ const recorded = appliedReceipts.get(key);
3303
+ if (recorded !== undefined) return recorded;
3304
+ // An abort before acceptance ends the call with no receipt
3305
+ // recorded, like every other pre-acceptance failure.
3306
+ signal.throwIfAborted();
3307
+
3308
+ const turnId = ++turnSequence;
3309
+ const callId = `apply-${++applyCallSequence}`;
3310
+ const position: TracePosition = { turnId, callId };
3311
+ activeTurnId = turnId;
3312
+ activeSignal = signal;
3313
+ controlPlaneError = undefined;
3314
+ // Every receipt variant is normalized and frozen where it is built,
3315
+ // inside the guarded region, so the recording step below cannot
3316
+ // throw after effects exist.
3317
+ const settledReceipt = (
3318
+ value: PlaybookControlReceipt,
3319
+ ): PlaybookControlReceipt =>
3320
+ deepFreeze(
3321
+ snapshotJsonValue(
3322
+ value,
3323
+ 'apply receipt',
3324
+ ) as unknown as PlaybookControlReceipt,
3325
+ );
3326
+ let receipt: PlaybookControlReceipt | undefined;
3327
+ let operationError: unknown;
3328
+ let settlementError: unknown;
3329
+ // Acceptance is the line past which this boundary owes a receipt and
3330
+ // can no longer signal by throwing: the action may have run, and a
3331
+ // caller that gets an exception instead of a receipt is left with an
3332
+ // executed effect it cannot record and a key it will not reuse.
3333
+ let accepted = false;
3334
+ // Publication is the second line this boundary respects. Before it,
3335
+ // nothing has left the runtime: a settlement failure past acceptance
3336
+ // is a post-acceptance control-plane error PBRT-52 settles as the
3337
+ // `failed` receipt, and folding it in replaces the receipt recorded
3338
+ // at acceptance so the finish trace, the returned receipt, and any
3339
+ // replay of the key all report one settlement. Past publication that
3340
+ // agreement is no longer achievable — the disposition is already on
3341
+ // the wire — so the fold refuses to run, by construction rather than
3342
+ // by call ordering. Only the first settlement error is latched, so
3343
+ // one fold is all there is to do.
3344
+ let folded = false;
3345
+ let published = false;
3346
+ const foldSettlementFailure = (): void => {
3347
+ if (published || !accepted || folded) return;
3348
+ if (settlementError === undefined) return;
3349
+ folded = true;
3350
+ receipt = settledReceipt({
3351
+ disposition: 'failed',
3352
+ error: normalizeError(settlementError),
3353
+ });
3354
+ appliedReceipts.set(key, receipt);
3355
+ };
3356
+ // A settlement failure that lands after the receipt is published says
3357
+ // nothing about the effect: the action ran, the caller's receipt is
3358
+ // true, and only the telemetry delivery failed. Rewriting `executed`
3359
+ // to `failed` there would make the runtime lie to its only caller
3360
+ // about work that succeeded, irrecoverably — accepted receipts are
3361
+ // final for their key. Past publication such a failure is therefore
3362
+ // re-latched onto the emission channel, surfacing from the next
3363
+ // public boundary's drain, and `apply` still does not throw past
3364
+ // acceptance (PBRT-52).
3365
+ const latchDeliveryFailure = (error: unknown): void => {
3366
+ emissionFailure ??= error;
3367
+ };
3368
+ try {
3369
+ try {
3370
+ const identity = {
3371
+ actionId,
3372
+ key,
3373
+ ...stateIdentity(currentState().stateId),
3374
+ };
3375
+ // Every apply finish carries the receipt disposition and no
3376
+ // start-only field — `stateId` is on the start alone
3377
+ // (slc/link.md §Playbook trace). Both finishes reachable
3378
+ // before acceptance settle with no effect behind them, so both
3379
+ // carry the canonical `rejected` disposition and the reason
3380
+ // that ended the call, alongside the transport marker.
3381
+ const preAcceptanceFinish = (
3382
+ reason: string,
3383
+ ): Record<string, unknown> => ({
3384
+ actionId,
3385
+ key,
3386
+ ...receiptTracePayload({ disposition: 'rejected', reason }),
3387
+ });
3388
+ await emitCallStarted(
3389
+ 'apply.started',
3390
+ 'apply.finished',
3391
+ identity,
3392
+ position,
3393
+ preAcceptanceFinish('apply.started trace sink rejected'),
3394
+ );
3395
+ // An abort may land while the awaited started emission drains
3396
+ // (e.g. fired from the trace sink itself); the action must
3397
+ // never execute after abort. Settle the already-started pair
3398
+ // as `aborted` — carrying the canonical rejected-before-any-
3399
+ // effect receipt disposition required of every apply finish —
3400
+ // and end the call pre-acceptance: no receipt is recorded and
3401
+ // the key stays free.
3402
+ if (signal.aborted) {
3403
+ try {
3404
+ await emitTrace(
3405
+ 'apply.finished',
3406
+ {
3407
+ ...preAcceptanceFinish('aborted before acceptance'),
3408
+ status: 'aborted',
3409
+ error: normalizeError(signal.reason),
3410
+ },
3411
+ position,
3412
+ );
3413
+ } catch (error) {
3414
+ // A rejecting finish sink surfaces at the boundary like
3415
+ // any settlement failure (see the precedence below).
3416
+ settlementError ??= error;
3417
+ }
3418
+ signal.throwIfAborted();
3419
+ }
3420
+ const snapshot = actor.getSnapshot();
3421
+ const candidate = deriveControlActions(snapshot).find(
3422
+ ({ action }) => action.id === actionId,
3423
+ );
3424
+ if (candidate === undefined) {
3425
+ receipt = settledReceipt({
3426
+ disposition: 'rejected',
3427
+ reason: `action ${JSON.stringify(
3428
+ actionId,
3429
+ )} is not currently advertised`,
3430
+ });
3431
+ } else {
3432
+ // Acceptance: from here every outcome records a receipt under
3433
+ // the key, so the action can never execute twice.
3434
+ accepted = true;
3435
+ try {
3436
+ actor.send(candidate.event);
3437
+ await waitForPlaybookQuiescence(actor, {
3438
+ pendingCalls: nestedBridge,
3439
+ });
3440
+ if (controlPlaneError !== undefined) throw controlPlaneError;
3441
+ const run = runResultFor(settledOutcome(signal));
3442
+ receipt = settledReceipt(
3443
+ run.outcome === 'failed' || run.outcome === 'aborted'
3444
+ ? {
3445
+ disposition: 'failed',
3446
+ error:
3447
+ ('error' in run ? run.error : undefined) ??
3448
+ normalizeError(
3449
+ new Error(
3450
+ `apply settled with outcome ${run.outcome}`,
3451
+ ),
3452
+ ),
3453
+ }
3454
+ : { disposition: 'executed', run },
3455
+ );
3456
+ } catch (error) {
3457
+ // Effects may exist: a post-acceptance failure is the
3458
+ // receipt, not a control-plane rejection (DR-029).
3459
+ receipt = settledReceipt({
3460
+ disposition: 'failed',
3461
+ error: normalizeError(error),
3462
+ });
3463
+ }
3464
+ }
3465
+ } catch (error) {
3466
+ operationError = error; // pre-acceptance: no receipt is recorded
3467
+ }
3468
+
3469
+ // Record acceptance before the settlement emissions, so a crash
3470
+ // between acceptance and settlement can never re-execute the
3471
+ // action: the recorded receipt survives and a replayed key
3472
+ // returns it. A rejection settled before acceptance: it is
3473
+ // returned and traced but never recorded, so its key stays free
3474
+ // to execute once the action is advertised.
3475
+ if (receipt !== undefined && receipt.disposition !== 'rejected') {
3476
+ appliedReceipts.set(key, receipt);
3477
+ }
3478
+ try {
3479
+ await drainEmissions();
3480
+ } catch (error) {
3481
+ settlementError = error;
3482
+ }
3483
+ // Fold before the finish emission, the last point at which the
3484
+ // traced disposition and the returned one can still be made the
3485
+ // same value.
3486
+ foldSettlementFailure();
3487
+ if (receipt !== undefined) {
3488
+ // Publication: this disposition is now the settlement, for the
3489
+ // trace, for the caller, and for every replay of the key.
3490
+ published = true;
3491
+ try {
3492
+ await emitTrace(
3493
+ 'apply.finished',
3494
+ { actionId, key, ...receiptTracePayload(receipt) },
3495
+ position,
3496
+ );
3497
+ } catch (error) {
3498
+ if (accepted) latchDeliveryFailure(error);
3499
+ else settlementError ??= error;
3500
+ }
3501
+ // Drain even when the finish emission rejected, so this call
3502
+ // leaves no queued emission behind it. Before acceptance the
3503
+ // failure is consumed and thrown, as every pre-acceptance failure
3504
+ // is; past it the failure is re-latched instead — the effect
3505
+ // happened, so the delivery failure travels on the emission
3506
+ // channel to the next boundary rather than rewriting what
3507
+ // happened or vanishing here.
3508
+ try {
3509
+ await drainEmissions();
3510
+ } catch (error) {
3511
+ if (accepted) latchDeliveryFailure(error);
3512
+ else settlementError ??= error;
3513
+ }
3514
+ }
3515
+ } finally {
3516
+ // Always release the boundary sentinel, even on a path no
3517
+ // constructible input reaches today, so a defect here can never
3518
+ // wedge every later public boundary behind "another runtime turn
3519
+ // is active".
3520
+ activeSignal = undefined;
3521
+ activeTurnId = undefined;
3522
+ controlPlaneError = undefined;
3523
+ }
3524
+ // Past acceptance every settlement failure has been folded into the
3525
+ // receipt, so nothing is left to throw and the caller always leaves
3526
+ // with the settlement of the effect it may have caused (PBRT-52).
3527
+ if (accepted && receipt !== undefined) return receipt;
3528
+ // Before acceptance no effect exists and no receipt is owed, so a
3529
+ // failure still surfaces by throwing. Settlement failures (a
3530
+ // rejecting finish sink, a drain-latched emission failure) outrank
3531
+ // the operation error, matching the `drainError ?? operationError`
3532
+ // precedence of the other public boundaries. A start-sink failure is
3533
+ // unaffected: its latched drain error is the start error itself.
3534
+ const failure = settlementError ?? operationError;
3535
+ if (failure !== undefined) throw failure;
3536
+ if (receipt === undefined) {
3537
+ throw new Error(
3538
+ 'createPlaybookRuntime.apply: no receipt was produced',
3539
+ );
3540
+ }
3541
+ return receipt;
3542
+ },
3543
+
2644
3544
  async handleBossInput({
2645
3545
  text,
2646
3546
  signal,
@@ -2695,6 +3595,7 @@ export function createXStatePlaybookRuntime<TOptions>(
2695
3595
  signal,
2696
3596
  snapshot,
2697
3597
  boundary,
3598
+ boundOptions,
2698
3599
  );
2699
3600
  }
2700
3601
  signal.throwIfAborted();
@@ -2714,10 +3615,24 @@ export function createXStatePlaybookRuntime<TOptions>(
2714
3615
  // 3. A final actor cannot accept new events; reconstruct only
2715
3616
  // after classification produced a real event.
2716
3617
  if (actor.getSnapshot().status === 'done') {
2717
- actor.stop();
3618
+ stopActor();
2718
3619
  actor = buildActor(runtimePorts!);
3620
+ // The replacement actor's snapshots are real state entries.
3621
+ suppressInspectionEmissions = false;
2719
3622
  actor.start();
2720
3623
  }
3624
+ // DR-029: keep the classified event with its recorded payload
3625
+ // as the retry-replay source. Recording is sanitizing, not
3626
+ // load-bearing: an override classifier's non-JSON-safe event is
3627
+ // simply not recorded, and the turn proceeds unchanged.
3628
+ try {
3629
+ lastBossEvent = snapshotJsonValue(
3630
+ event,
3631
+ 'recorded Boss event',
3632
+ ) as unknown as EventObject;
3633
+ } catch {
3634
+ lastBossEvent = undefined;
3635
+ }
2721
3636
  actor.send(event);
2722
3637
  await waitForPlaybookQuiescence(actor, {
2723
3638
  pendingCalls: nestedBridge,
@@ -2861,7 +3776,9 @@ export function createXStatePlaybookRuntime<TOptions>(
2861
3776
  const finalState = actor ? currentState() : undefined;
2862
3777
  // Stop the root before settling a suspended child. Its rejection
2863
3778
  // must not re-enter the FSM and start fresh work during disposal.
2864
- if (actor) actor.stop();
3779
+ // `stopActor` suppresses inspection first, so the stop snapshot
3780
+ // adds nothing beside the `session.disposed` trace below (PBRT-6).
3781
+ stopActor();
2865
3782
  try {
2866
3783
  await nestedBridge.dispose();
2867
3784
  } catch (error) {
@@ -2895,11 +3812,13 @@ export function createXStatePlaybookRuntime<TOptions>(
2895
3812
  activeEmissionCalls.clear();
2896
3813
  emissionQueue.clear();
2897
3814
  judgeQueue.clear();
3815
+ appliedReceipts.clear();
2898
3816
  actor = undefined;
2899
3817
  activeSignal = undefined;
2900
3818
  activeTurnId = undefined;
2901
3819
  controlPlaneError = undefined;
2902
3820
  emissionFailure = undefined;
3821
+ lastBossEvent = undefined;
2903
3822
  savedPorts = undefined;
2904
3823
  runtimePorts = undefined;
2905
3824
  session = undefined;