@sublang/playbook 3.1.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.
Files changed (31) hide show
  1. package/README.md +64 -99
  2. package/docs/assets/playbook-venn.svg +13 -0
  3. package/docs/cli.md +83 -9
  4. package/docs/configuration.md +5 -3
  5. package/package.json +7 -4
  6. package/reference/sdlc/captain.md +70 -83
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +127 -142
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +349 -470
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +535 -598
  10. package/reference/sdlc/captain.playbook/captain.gears.md +37 -41
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +90 -15
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +464 -968
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +696 -993
  14. package/reference/sdlc/code.playbook/bin/adapter-sdk.js +247 -0
  15. package/reference/sdlc/code.playbook/bin/playbook.js +54 -9
  16. package/reference/sdlc/code.playbook/bin/run.js +97 -0
  17. package/reference/sdlc/code.playbook/code.playbook.js +17 -0
  18. package/reference/sdlc/code.playbook/code.playbook.ts +17 -0
  19. package/reference/sdlc/code.playbook/playbook-captain.d.ts +2 -0
  20. package/reference/sdlc/code.playbook/playbook-captain.js +1784 -215
  21. package/reference/sdlc/code.playbook/playbook-captain.ts +2293 -330
  22. package/reference/sdlc/code.playbook/playbook.config.template.yaml +7 -0
  23. package/reference/sdlc/discuss.playbook/discuss.playbook.js +41 -9
  24. package/reference/sdlc/discuss.playbook/discuss.playbook.ts +42 -9
  25. package/slc/gears2fsm.md +54 -2
  26. package/slc/link.md +293 -25
  27. package/src/runtime.d.ts +29 -1
  28. package/src/runtime.ts +47 -0
  29. package/src/xstate-playbook-runtime.d.ts +97 -5
  30. package/src/xstate-playbook-runtime.js +769 -29
  31. package/src/xstate-playbook-runtime.ts +962 -34
@@ -42,6 +42,30 @@ function isFsmResultFailure(error) {
42
42
  fsmResultFailures.has(error));
43
43
  }
44
44
  // ---------------------------------------------------------------------------
45
+ // DR-028: both call boundaries treat an `ok` result whose `finalText` is
46
+ // missing, empty, or whitespace-only under one empty predicate, and that
47
+ // shape earns exactly one corrective re-ask — the same composed call
48
+ // re-issued once through the same boundary — before a second such result
49
+ // follows the existing failure path. The retry marker distinguishes the
50
+ // re-askable empty-`ok` Captain failure from the never-retried non-`ok`
51
+ // statuses; it is applied only when the failure's finish trace emitted
52
+ // cleanly, because a rejecting finish sink is a control-plane error whose
53
+ // turn gets no corrective re-ask (PBRT-47).
54
+ // ---------------------------------------------------------------------------
55
+ function isEmptyFinalText(finalText) {
56
+ return finalText === undefined || finalText.trim().length === 0;
57
+ }
58
+ const emptyOkRetryFailures = new WeakSet();
59
+ function markEmptyOkRetryFailure(error) {
60
+ emptyOkRetryFailures.add(error);
61
+ return error;
62
+ }
63
+ function isEmptyOkRetryFailure(error) {
64
+ return (typeof error === 'object' &&
65
+ error !== null &&
66
+ emptyOkRetryFailures.has(error));
67
+ }
68
+ // ---------------------------------------------------------------------------
45
69
  // DR-022: the engine's compatibility self-report. A linked thin module
46
70
  // records the values current at link time in `spec.compat`; the factory
47
71
  // checks that declaration against this very module — the engine instance
@@ -425,19 +449,39 @@ export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onCon
425
449
  const activeSignal = combineAbortSignals(signal, getActiveSignal?.());
426
450
  const playerId = spec.resolvePlayerId(input);
427
451
  const prompt = spec.composePlayerPrompt(input);
428
- const result = boundary
429
- ? await boundary.callPlayer(input, playerId, prompt, activeSignal)
430
- : await ports.callPlayer(playerId, prompt, activeSignal, {
431
- resume: false,
432
- });
452
+ const callPlayer = (resume) => boundary
453
+ ? boundary.callPlayer(input, playerId, prompt, activeSignal)
454
+ : ports.callPlayer(playerId, prompt, activeSignal, { resume });
455
+ let result = await callPlayer(false);
456
+ if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
457
+ // An abort that lands between the empty first result and the
458
+ // corrective call ends the turn as ordinary abort settlement with
459
+ // no second host call — aborts are never retried (DR-028 via
460
+ // DR-025's transport exclusion) — matching the direct-Captain
461
+ // boundary, whose queued corrective call re-checks the signal
462
+ // before starting.
463
+ activeSignal.throwIfAborted();
464
+ // DR-028: exactly one corrective re-ask of the same composed call
465
+ // through the same path, traced by the boundary as its own
466
+ // player-call pair. The traced boundary re-reads its token map
467
+ // (PBRT-38), so the corrective call continues the player session
468
+ // when the first result carried a resume token and starts fresh
469
+ // when it cleared one; the portless verification path mirrors that
470
+ // by carrying the first result's token.
471
+ result = await callPlayer(typeof result.resumeToken === 'string' &&
472
+ result.resumeToken.trim().length > 0
473
+ ? result.resumeToken
474
+ : false);
475
+ }
433
476
  if (result.status !== 'ok') {
434
477
  throw new Error(result.error ?? `captainBridge: callPlayer status "${result.status}"`);
435
478
  }
436
- if (result.finalText === undefined) {
479
+ const finalText = result.finalText ?? '';
480
+ if (isEmptyFinalText(finalText)) {
437
481
  throw new Error('captainBridge: callPlayer returned status=ok with no finalText');
438
482
  }
439
483
  try {
440
- const output = await adjudicatePlayerOutput(spec.adjudication, input, result.finalText, ports, activeSignal, boundary);
484
+ const output = await adjudicatePlayerOutput(spec.adjudication, input, finalText, ports, activeSignal, boundary);
441
485
  validateBossReplyOutput(input, output, spec.resumableStateIds);
442
486
  return output;
443
487
  }
@@ -454,7 +498,12 @@ export function createPlayerBridge(spec, ports, getActiveSignal, boundary, onCon
454
498
  // `response` and rejects a judge reply that supplies either presentation
455
499
  // field as an undeclared extra key.
456
500
  // ---------------------------------------------------------------------------
457
- function buildCaptainJudgePrompt(input, finalText) {
501
+ /**
502
+ * Default direct-Captain adjudicator prompt (DR-025). The single statement of
503
+ * the `{ guard, …structuralPayloadFields }` reply contract, shared with the
504
+ * compiled default Captain artifact so the wording cannot drift.
505
+ */
506
+ export function defaultBuildCaptainJudgePrompt(input, finalText) {
458
507
  const lines = [];
459
508
  lines.push('Adjudicate the direct Captain output for this FSM state.');
460
509
  lines.push(`State id: ${input.stateId}`);
@@ -569,6 +618,95 @@ export function resumableStateIdsFromMachine(machine) {
569
618
  return new Set(transitionTargets(bossReply));
570
619
  }
571
620
  // ---------------------------------------------------------------------------
621
+ // DR-029 control surface: the FSM's explicit-state-jump event and the
622
+ // source state descriptions that label runtime-advertised actions.
623
+ // ---------------------------------------------------------------------------
624
+ /** The FSM's explicit-state-jump event type (slc/link.md §Boss-event mapping). */
625
+ const JUMP_EVENT_TYPE = 'BOSS_INTERRUPT';
626
+ /**
627
+ * Source state descriptions by state key, node id, and `meta.playbook`
628
+ * state id, read from `machine.config`. Control actions are labeled from
629
+ * these descriptions (DR-029); a state without one has no entry.
630
+ */
631
+ export function stateDescriptionsFromMachine(machine) {
632
+ const descriptions = new Map();
633
+ const record = (key, description) => {
634
+ if (typeof key !== 'string' || key.length === 0)
635
+ return;
636
+ if (!descriptions.has(key))
637
+ descriptions.set(key, description);
638
+ };
639
+ const visit = (key, stateDef) => {
640
+ if (!isPlainObject(stateDef))
641
+ return;
642
+ const playbook = isPlainObject(stateDef.meta)
643
+ ? stateDef.meta.playbook
644
+ : undefined;
645
+ const description = isPlainObject(playbook) && typeof playbook.description === 'string'
646
+ ? playbook.description
647
+ : typeof stateDef.description === 'string'
648
+ ? stateDef.description
649
+ : undefined;
650
+ if (description !== undefined && description.length > 0) {
651
+ record(key, description);
652
+ record(stateDef.id, description);
653
+ if (isPlainObject(playbook))
654
+ record(playbook.stateId, description);
655
+ }
656
+ if (isPlainObject(stateDef.states)) {
657
+ for (const [childKey, child] of Object.entries(stateDef.states)) {
658
+ visit(childKey, child);
659
+ }
660
+ }
661
+ };
662
+ const config = machine.config;
663
+ if (isPlainObject(config) && isPlainObject(config.states)) {
664
+ for (const [key, stateDef] of Object.entries(config.states)) {
665
+ visit(key, stateDef);
666
+ }
667
+ }
668
+ return descriptions;
669
+ }
670
+ /**
671
+ * First configured target of `eventType` from the state with `stateId`,
672
+ * falling back to the machine root's own transitions. Used only to pick the
673
+ * source description that labels a retry action, and only for events that
674
+ * carry no recorded `targetId`: a guarded multi-arm list keyed on the
675
+ * event's `targetId` (the root `BOSS_INTERRUPT` shape) resumes the recorded
676
+ * target, not the first configured arm, so the recorded event outranks this
677
+ * fallback.
678
+ */
679
+ function firstTransitionTarget(machine, stateId, eventType) {
680
+ const config = machine.config;
681
+ if (!isPlainObject(config))
682
+ return undefined;
683
+ const candidates = [];
684
+ if (stateId !== undefined && isPlainObject(config.states)) {
685
+ const state = config.states[stateId];
686
+ if (isPlainObject(state) && isPlainObject(state.on)) {
687
+ candidates.push(state.on[eventType]);
688
+ }
689
+ }
690
+ if (isPlainObject(config.on))
691
+ candidates.push(config.on[eventType]);
692
+ for (const candidate of candidates) {
693
+ if (candidate === undefined)
694
+ continue;
695
+ const targets = transitionTargets(candidate);
696
+ if (targets.length > 0)
697
+ return targets[0];
698
+ }
699
+ return undefined;
700
+ }
701
+ function deepFreeze(value) {
702
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
703
+ Object.freeze(value);
704
+ for (const member of Object.values(value))
705
+ deepFreeze(member);
706
+ }
707
+ return value;
708
+ }
709
+ // ---------------------------------------------------------------------------
572
710
  // Default transition/status derivation.
573
711
  // ---------------------------------------------------------------------------
574
712
  const SUPPRESSED_ENTRY_STATES = new Set(['ready', 'done']);
@@ -887,6 +1025,18 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
887
1025
  return event;
888
1026
  };
889
1027
  }
1028
+ function machineDeclaresParallelState(machine) {
1029
+ const visit = (stateDef) => {
1030
+ if (!isPlainObject(stateDef))
1031
+ return false;
1032
+ if (stateDef.type === 'parallel')
1033
+ return true;
1034
+ if (!isPlainObject(stateDef.states))
1035
+ return false;
1036
+ return Object.values(stateDef.states).some(visit);
1037
+ };
1038
+ return visit(machine.config);
1039
+ }
890
1040
  /**
891
1041
  * Build a `PlaybookRuntimeFactory` that interprets the given FSM artifact
892
1042
  * under the slc/link.md contract. The factory provides every actor kind the
@@ -894,16 +1044,37 @@ function makeDefaultClassifyBossText(machine, entryEvent, bossEvents) {
894
1044
  * (literal and dynamic) — and implements the full runtime lifecycle including
895
1045
  * the optional parked-session snapshot capability (DR-014).
896
1046
  *
897
- * Scope: single-region root machines (each snapshot exposes exactly one
898
- * playbook state id). Parallel-region FSMs keep their own linked runtimes.
1047
+ * Scope: machines that declare no parallel state (each snapshot exposes
1048
+ * exactly one playbook state id). Parallel-region FSMs keep their own linked
1049
+ * runtimes.
899
1050
  */
900
1051
  export function createXStatePlaybookRuntime(machine, spec) {
901
1052
  const label = spec.label ?? 'playbook';
902
1053
  // DR-022 / PBRT-50: reject an incompatible artifact declaration before any
903
1054
  // machine interpretation, against this loaded engine's own self-report.
904
1055
  assertRuntimeCompat(spec.compat, label);
1056
+ if (machineDeclaresParallelState(machine)) {
1057
+ throw new Error(`${label} uses a parallel state; the shared runtime supports only single-region FSMs`);
1058
+ }
905
1059
  const declaredActors = collectInvokeSources(machine);
906
1060
  const resumableStateIds = spec.resumableStateIds ?? resumableStateIdsFromMachine(machine);
1061
+ // DR-029: source state descriptions label the control actions the
1062
+ // runtime advertises through `describe()`.
1063
+ const stateDescriptions = stateDescriptionsFromMachine(machine);
1064
+ // PBRT-52: the artifact's own ControlView context projection. Nothing is
1065
+ // exported by default, so an FSM context member — including one added
1066
+ // after this artifact was linked — is private until named here. The two
1067
+ // members the view surfaces first-class are rejected at construction
1068
+ // rather than silently ignored, so an artifact cannot believe it is
1069
+ // exporting them through this list.
1070
+ const controlContextFields = spec.controlContextFields
1071
+ ? [...spec.controlContextFields]
1072
+ : [];
1073
+ for (const field of controlContextFields) {
1074
+ if (field === 'pendingBossQuestion' || field === 'lastError') {
1075
+ throw new Error(`${label} controlContextFields must not name ${field}: the control view surfaces it first-class`);
1076
+ }
1077
+ }
907
1078
  const resolvePlayerIdSpec = spec.resolvePlayerId;
908
1079
  const composePlayerPrompt = spec.composePlayerPrompt ??
909
1080
  ((input) => defaultComposePlayerPrompt(input, spec.placeholderFields));
@@ -963,6 +1134,19 @@ export function createXStatePlaybookRuntime(machine, spec) {
963
1134
  let playerCallSequence = 0;
964
1135
  let playbookCallSequence = 0;
965
1136
  let captainCallSequence = 0;
1137
+ let applyCallSequence = 0;
1138
+ // DR-029: the last event a public Boss boundary sent into the
1139
+ // machine — classified, deterministic entry, or Boss reply — kept with
1140
+ // its recorded payload so a failure-state retry action can replay the
1141
+ // event that drove the run into `failed`. Process-local: the schema-1
1142
+ // parked snapshot does not persist it (PBRT-50: no schema bump).
1143
+ let lastBossEvent;
1144
+ // DR-029: process-local at-most-once `apply` execution — the accepted receipt
1145
+ // recorded for each idempotency key, returned verbatim on a repeated
1146
+ // key. A key whose call settled `rejected` or threw before reaching
1147
+ // acceptance records nothing, so a later call with that key may still
1148
+ // execute.
1149
+ const appliedReceipts = new Map();
966
1150
  const playerResumeTokens = new Map();
967
1151
  const activePlayerIds = new Set();
968
1152
  const playbookCallTurnIds = new Map();
@@ -1100,14 +1284,25 @@ export function createXStatePlaybookRuntime(machine, spec) {
1100
1284
  },
1101
1285
  };
1102
1286
  }
1103
- async function emitCallStarted(startedType, finishedType, identity, position) {
1287
+ async function emitCallStarted(startedType, finishedType, identity, position,
1288
+ // Base payload of the best-effort finish emitted when the start sink
1289
+ // rejects; it defaults to the payload the start carried, which the
1290
+ // player, judge, and captain pairs take as-is. The apply pair cannot:
1291
+ // its finish carries the receipt disposition and none of the
1292
+ // start-only fields, so it passes its own canonical pre-acceptance
1293
+ // base (slc/link.md §Playbook trace).
1294
+ finishIdentity = identity) {
1104
1295
  try {
1105
1296
  await emitTrace(startedType, identity, position);
1106
1297
  }
1107
1298
  catch (error) {
1108
1299
  controlPlaneError ??= error;
1109
1300
  try {
1110
- await emitTrace(finishedType, { ...identity, status: 'error', error: normalizeError(error) }, position);
1301
+ await emitTrace(finishedType, {
1302
+ ...finishIdentity,
1303
+ status: 'error',
1304
+ error: normalizeError(error),
1305
+ }, position);
1111
1306
  }
1112
1307
  catch {
1113
1308
  // Preserve the start failure after one best-effort finish attempt.
@@ -1145,6 +1340,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
1145
1340
  await emitTrace('player.call.started', { ...identity, prompt }, position);
1146
1341
  let rawResult;
1147
1342
  try {
1343
+ // An abort may land while the awaited started emission drains
1344
+ // (e.g. fired from the trace sink itself); the host call must
1345
+ // never start after abort, so settle the already-started pair
1346
+ // as `aborted` through the catch below.
1347
+ signal.throwIfAborted();
1148
1348
  rawResult = await requireHostPorts().callPlayer(playerId, prompt, signal, { resume });
1149
1349
  // A host promise is not required to honor cancellation. Do not let
1150
1350
  // a late result mutate continuity or publish a successful finish.
@@ -1225,6 +1425,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
1225
1425
  await emitCallStarted('judge.call.started', 'judge.call.finished', { ...identity, prompt }, position);
1226
1426
  let reply;
1227
1427
  try {
1428
+ // An abort may land while the awaited started emission drains
1429
+ // (e.g. fired from the trace sink itself); the host call must
1430
+ // never start after abort, so settle the already-started pair
1431
+ // as `aborted` through the catch below.
1432
+ signal.throwIfAborted();
1228
1433
  reply = await requireHostPorts().callJudge(prompt, signal);
1229
1434
  signal.throwIfAborted();
1230
1435
  }
@@ -1256,18 +1461,23 @@ export function createXStatePlaybookRuntime(machine, spec) {
1256
1461
  return reply;
1257
1462
  });
1258
1463
  },
1259
- async callCaptain(input, prompt, signal) {
1464
+ async callCaptain(input, prompt, signal, callOptions) {
1260
1465
  return judgeQueue.add(async () => {
1261
1466
  signal.throwIfAborted();
1262
1467
  await drainEmissions();
1263
1468
  signal.throwIfAborted();
1264
1469
  const turnId = activeTurnId;
1265
1470
  const callId = `captain-${++captainCallSequence}`;
1471
+ const visibility = callOptions?.visibility ?? 'visible';
1266
1472
  const identity = {
1267
1473
  ...stateIdentity(input.stateId),
1268
1474
  sourceItem: input.sourceItem,
1269
- visibility: 'visible',
1270
- resume: false,
1475
+ visibility,
1476
+ // The visible workflow form owns its `resume: false` selection;
1477
+ // a hidden controller call's durable-conversation resume
1478
+ // selection is host-owned (DR-029), so its trace pair carries
1479
+ // no resume member and no token.
1480
+ ...(visibility === 'visible' ? { resume: false } : {}),
1271
1481
  ...(input.allowedTools === undefined
1272
1482
  ? {}
1273
1483
  : { allowedTools: [...input.allowedTools] }),
@@ -1279,8 +1489,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
1279
1489
  await emitCallStarted('captain.call.started', 'captain.call.finished', { ...identity, prompt }, position);
1280
1490
  let rawResult;
1281
1491
  try {
1492
+ // An abort may land while the awaited started emission drains
1493
+ // (e.g. fired from the trace sink itself); the host call must
1494
+ // never start after abort, so settle the already-started pair
1495
+ // as `aborted` through the catch below.
1496
+ signal.throwIfAborted();
1282
1497
  rawResult = await requireHostPorts().callCaptain(prompt, signal, {
1283
- visibility: 'visible',
1498
+ visibility,
1284
1499
  resume: false,
1285
1500
  ...(input.allowedTools !== undefined
1286
1501
  ? { allowedTools: input.allowedTools }
@@ -1312,12 +1527,14 @@ export function createXStatePlaybookRuntime(machine, spec) {
1312
1527
  // authoritative for the actor's error path even when the required
1313
1528
  // finish emission fails or a coincident boundary abort lands.
1314
1529
  let resultFailure;
1530
+ let emptyOkRetry = false;
1315
1531
  if (result.status !== 'ok') {
1316
1532
  resultFailure = markFsmResultFailure(new Error(result.error ??
1317
1533
  `captainActor: callCaptain status "${result.status}"`));
1318
1534
  }
1319
- else if (result.finalText === undefined || result.finalText === '') {
1535
+ else if (isEmptyFinalText(result.finalText)) {
1320
1536
  resultFailure = markFsmResultFailure(new Error('captainActor: callCaptain returned status=ok with no finalText'));
1537
+ emptyOkRetry = true;
1321
1538
  }
1322
1539
  try {
1323
1540
  await emitTrace('captain.call.finished', {
@@ -1336,13 +1553,18 @@ export function createXStatePlaybookRuntime(machine, spec) {
1336
1553
  catch (error) {
1337
1554
  // Keep the finish-sink failure in the emission queue for public
1338
1555
  // cleanup evidence, but do not replace an authoritative result
1339
- // failure on the invoked actor's XState onError path.
1556
+ // failure on the invoked actor's XState onError path. A failure
1557
+ // thrown here is never marked re-askable: a rejecting finish
1558
+ // sink stays a control-plane error with no corrective re-ask
1559
+ // (PBRT-47).
1340
1560
  if (resultFailure !== undefined)
1341
1561
  throw resultFailure;
1342
1562
  throw error;
1343
1563
  }
1344
1564
  if (resultFailure !== undefined) {
1345
- throw resultFailure;
1565
+ throw emptyOkRetry
1566
+ ? markEmptyOkRetryFailure(resultFailure)
1567
+ : resultFailure;
1346
1568
  }
1347
1569
  return result;
1348
1570
  });
@@ -1374,17 +1596,52 @@ export function createXStatePlaybookRuntime(machine, spec) {
1374
1596
  try {
1375
1597
  await drainEmissions();
1376
1598
  const prompt = composeCaptainPrompt(input);
1377
- const result = await boundary.callCaptain(input, prompt, active);
1599
+ if (spec.captainStrategy !== undefined) {
1600
+ // Controller form (slc/link.md §Captain adjudication): the
1601
+ // spec's strategy owns the call pipeline; the engine still
1602
+ // owns tracing, the shared lane, signal combination, and the
1603
+ // control-plane latch in the catch below.
1604
+ const output = await spec.captainStrategy({
1605
+ input,
1606
+ prompt,
1607
+ signal: active,
1608
+ options: boundOptions,
1609
+ session: requireSession(),
1610
+ callCaptain: (callPrompt, callOptions) => boundary.callCaptain(input, callPrompt, active, callOptions),
1611
+ isEmptyOkRetry: isEmptyOkRetryFailure,
1612
+ recoverableFailure: (error) => {
1613
+ markFsmResultFailure(error);
1614
+ return error;
1615
+ },
1616
+ });
1617
+ validateBossReplyOutput(input, output, resumableStateIds);
1618
+ return output;
1619
+ }
1620
+ let result;
1621
+ try {
1622
+ result = await boundary.callCaptain(input, prompt, active);
1623
+ }
1624
+ catch (error) {
1625
+ if (!isEmptyOkRetryFailure(error))
1626
+ throw error;
1627
+ // DR-028: exactly one corrective re-ask of the same composed
1628
+ // call through the same boundary, traced as its own
1629
+ // started/finished pair, its result read under the unchanged
1630
+ // rules — a second empty `ok` result throws from the boundary
1631
+ // exactly as the first did, with no further re-ask.
1632
+ result = await boundary.callCaptain(input, prompt, active);
1633
+ }
1378
1634
  // The boundary owns result validation (PBRT-47) and throws the
1379
1635
  // authoritative failure itself, so a returned result is always
1380
1636
  // `ok` with visible text. Assert that invariant rather than
1381
1637
  // restating the failure semantics, which would drift.
1382
- if (result.status !== 'ok' || !result.finalText) {
1638
+ const finalText = result.finalText ?? '';
1639
+ if (result.status !== 'ok' || isEmptyFinalText(finalText)) {
1383
1640
  throw new Error('captainActor: boundary returned an unvalidated Captain result');
1384
1641
  }
1385
- const judgePrompt = buildCaptainJudgePrompt(input, result.finalText);
1642
+ const judgePrompt = defaultBuildCaptainJudgePrompt(input, finalText);
1386
1643
  const raw = await boundary.callJudge('captain-output-adjudication', input.stateId, judgePrompt, active);
1387
- const output = adjudicateCaptainOutput(extractFields, input, result.finalText, raw);
1644
+ const output = adjudicateCaptainOutput(extractFields, input, finalText, raw);
1388
1645
  validateBossReplyOutput(input, output, resumableStateIds);
1389
1646
  return output;
1390
1647
  }
@@ -1564,6 +1821,20 @@ export function createXStatePlaybookRuntime(machine, spec) {
1564
1821
  else
1565
1822
  emissionFailure ??= error;
1566
1823
  }
1824
+ // PBRT-6: the single seam that stops this runtime's actor. Stopping a
1825
+ // still-running actor fires one more `@xstate.snapshot` for the
1826
+ // *unchanged* state value with `status: 'stopped'`, which the inspect
1827
+ // callback cannot distinguish from a state entry — unsuppressed it
1828
+ // re-emits the parked state's statuses and a phantom self-loop
1829
+ // transition. Suppression is a property of stopping, not a rule each
1830
+ // caller must remember, so every stop goes through here; a caller that
1831
+ // builds a replacement actor clears the flag before starting it.
1832
+ function stopActor() {
1833
+ if (!actor)
1834
+ return;
1835
+ suppressInspectionEmissions = true;
1836
+ actor.stop();
1837
+ }
1567
1838
  function buildActor(ports, machineSnapshot) {
1568
1839
  priorState = undefined;
1569
1840
  const actors = {};
@@ -1690,9 +1961,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
1690
1961
  // A state that cannot even normalize has no disposal descriptor.
1691
1962
  }
1692
1963
  }
1693
- suppressInspectionEmissions = true;
1694
1964
  try {
1695
- actor?.stop();
1965
+ stopActor();
1696
1966
  }
1697
1967
  catch {
1698
1968
  // Preserve the original startup failure.
@@ -1730,6 +2000,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1730
2000
  activeEmissionCalls.clear();
1731
2001
  emissionQueue.clear();
1732
2002
  judgeQueue.clear();
2003
+ appliedReceipts.clear();
1733
2004
  actor = undefined;
1734
2005
  session = undefined;
1735
2006
  savedPorts = undefined;
@@ -1739,6 +2010,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1739
2010
  controlPlaneError = undefined;
1740
2011
  emissionFailure = undefined;
1741
2012
  priorState = undefined;
2013
+ lastBossEvent = undefined;
1742
2014
  suppressInspectionEmissions = false;
1743
2015
  initialized = false;
1744
2016
  traceSequence = 0;
@@ -1747,6 +2019,159 @@ export function createXStatePlaybookRuntime(machine, spec) {
1747
2019
  playerCallSequence = 0;
1748
2020
  playbookCallSequence = 0;
1749
2021
  captainCallSequence = 0;
2022
+ applyCallSequence = 0;
2023
+ }
2024
+ function snapshotCan(snapshot, event) {
2025
+ const can = snapshot?.can;
2026
+ return (typeof can === 'function' &&
2027
+ can.call(snapshot, event) ===
2028
+ true);
2029
+ }
2030
+ // The failure-state retry entry replays the recorded last classified
2031
+ // event with its recorded payload. A candidate whose event the live
2032
+ // snapshot does not accept — or whose payload the runtime never
2033
+ // recorded — is excluded rather than completed with invented text.
2034
+ function retryActionFor(snapshot, stateId) {
2035
+ if (stateId !== 'failed' || lastBossEvent === undefined) {
2036
+ return undefined;
2037
+ }
2038
+ if (!snapshotCan(snapshot, lastBossEvent))
2039
+ return undefined;
2040
+ // A recorded explicit-state-jump event names the exact state its
2041
+ // replay re-enters: the root BOSS_INTERRUPT shape is a guarded
2042
+ // multi-arm list keyed on `targetId`, so the first configured arm
2043
+ // may label a different state than the one the recorded event
2044
+ // actually resumes.
2045
+ const recordedTargetId = lastBossEvent.type === JUMP_EVENT_TYPE
2046
+ ? lastBossEvent.targetId
2047
+ : undefined;
2048
+ const target = typeof recordedTargetId === 'string' &&
2049
+ recordedTargetId.trim().length > 0
2050
+ ? recordedTargetId
2051
+ : firstTransitionTarget(machine, stateId, lastBossEvent.type);
2052
+ // PBRT-52: a label is written from a source state description, never
2053
+ // from an identifier. Falling back to the target id — or, with no
2054
+ // resolvable target, to the FSM event type — makes the label *be* the
2055
+ // internal name, which defeats the substitution the label exists for
2056
+ // and puts a machine identifier into Boss-facing text
2057
+ // (CAPPLAY-5). A candidate whose label can only be an id is excluded
2058
+ // exactly like one whose payload cannot be sourced.
2059
+ const description = (target === undefined ? undefined : stateDescriptions.get(target)) ??
2060
+ stateDescriptions.get(stateId);
2061
+ if (description === undefined)
2062
+ return undefined;
2063
+ return {
2064
+ action: {
2065
+ id: `retry:${lastBossEvent.type}`,
2066
+ label: `Retry: ${description}`,
2067
+ },
2068
+ event: lastBossEvent,
2069
+ };
2070
+ }
2071
+ function deriveControlActions(snapshot) {
2072
+ // Actions derive only at the safe point the parked snapshot also
2073
+ // uses — quiescent actor with status `active` and no pending nested
2074
+ // call. Anywhere else the view still describes the state while
2075
+ // advertising nothing.
2076
+ let state;
2077
+ try {
2078
+ state = normalizePlaybookSnapshot(snapshot, {
2079
+ pendingCall: nestedBridge.getPendingCall(),
2080
+ });
2081
+ }
2082
+ catch {
2083
+ return [];
2084
+ }
2085
+ if (state.status !== 'active' ||
2086
+ !state.quiescent ||
2087
+ nestedBridge.getPendingCall()) {
2088
+ return [];
2089
+ }
2090
+ const derived = [];
2091
+ const retry = retryActionFor(snapshot, state.stateId);
2092
+ if (retry !== undefined)
2093
+ derived.push(retry);
2094
+ // Jump entries: resumable targets whose explicit-state-jump event the
2095
+ // live snapshot accepts (state guards included), sent with the
2096
+ // advertised target id and optional textual fields omitted.
2097
+ for (const targetId of [...resumableStateIds].sort()) {
2098
+ const event = { type: JUMP_EVENT_TYPE, targetId };
2099
+ if (!snapshotCan(snapshot, event))
2100
+ continue;
2101
+ // PBRT-52: no published description for the target, no Boss-appropriate
2102
+ // label. A jump cannot borrow another state's meaning without naming
2103
+ // the wrong state, so the entry is not advertised at all rather than
2104
+ // labeled with its own target id.
2105
+ const description = stateDescriptions.get(targetId);
2106
+ if (description === undefined)
2107
+ continue;
2108
+ derived.push({
2109
+ action: {
2110
+ id: `jump:${targetId}`,
2111
+ label: `Resume from: ${description}`,
2112
+ },
2113
+ event,
2114
+ });
2115
+ }
2116
+ return derived;
2117
+ }
2118
+ // PBRT-52: the control view's context is the artifact's declared
2119
+ // projection, not a serialization of whatever the FSM happens to hold.
2120
+ // Only the runtime knows which of its context members are safe and
2121
+ // relevant for a controller prompt — an allow-by-default export cannot
2122
+ // keep player output, resolved player identities, or option values out
2123
+ // of a prompt whose host is required to exclude them
2124
+ // (CAPTAIN-9) — so nothing is exported unless
2125
+ // `controlContextFields` names it, in the order it names them. Each
2126
+ // named member is still sanitized: raw `Error` values are normalized
2127
+ // and a value that cannot be made JSON-safe is dropped, never thrown,
2128
+ // since `describe` must stay side-effect free and total.
2129
+ function projectControlContext(context) {
2130
+ const projected = {};
2131
+ for (const key of controlContextFields) {
2132
+ const value = context[key];
2133
+ if (value === undefined)
2134
+ continue;
2135
+ try {
2136
+ projected[key] = snapshotJsonValue(value instanceof Error ? normalizeError(value) : value, `control context ${key}`);
2137
+ }
2138
+ catch {
2139
+ // Declared but not JSON-safe — dropped.
2140
+ }
2141
+ }
2142
+ return Object.keys(projected).length === 0 ? undefined : projected;
2143
+ }
2144
+ // PBRT-52: the view's Boss-facing state description — the meaning of the
2145
+ // state the runtime is in, written by the artifact's own source, from the
2146
+ // same descriptions its action labels are written from. A control view is
2147
+ // the only grounding a controller host has for a status answer, and an
2148
+ // internal state id is not Boss-appropriate text
2149
+ // (CAPPLAY-5), so the runtime publishes the meaning
2150
+ // rather than leaving the host to substitute the identifier for it. A
2151
+ // state whose source declares no description publishes none: an id is
2152
+ // never promoted into a description by default.
2153
+ function stateDescriptionFor(state) {
2154
+ const keys = [
2155
+ ...(state.stateId === undefined ? [] : [state.stateId]),
2156
+ ...(typeof state.value === 'string' ? [state.value] : []),
2157
+ ...state.activeStateIds,
2158
+ ];
2159
+ for (const key of keys) {
2160
+ const description = stateDescriptions.get(key);
2161
+ if (description !== undefined)
2162
+ return description;
2163
+ }
2164
+ return undefined;
2165
+ }
2166
+ function receiptTracePayload(receipt) {
2167
+ return {
2168
+ disposition: receipt.disposition,
2169
+ ...(receipt.disposition === 'rejected'
2170
+ ? { reason: receipt.reason }
2171
+ : {}),
2172
+ ...(receipt.disposition === 'failed' ? { error: receipt.error } : {}),
2173
+ ...(receipt.disposition === 'executed' ? { run: receipt.run } : {}),
2174
+ };
1750
2175
  }
1751
2176
  const runtime = {
1752
2177
  async init(nextSession) {
@@ -1862,6 +2287,11 @@ export function createXStatePlaybookRuntime(machine, spec) {
1862
2287
  // Every Captain call already consumed at least one trace number,
1863
2288
  // so the global trace counter is a collision-safe id floor.
1864
2289
  boundSnapshot.sequences.trace;
2290
+ // The schema-1 snapshot carries no apply counter (PBRT-50: no
2291
+ // schema bump); every apply boundary consumed trace numbers, so
2292
+ // the persisted trace counter is a collision-safe id floor here
2293
+ // too, keeping `apply-<n>` call ids unique across restore.
2294
+ applyCallSequence = boundSnapshot.sequences.trace;
1865
2295
  playerResumeTokens.clear();
1866
2296
  for (const [playerId, token] of Object.entries(boundSnapshot.playerResumeTokens)) {
1867
2297
  playerResumeTokens.set(playerId, token);
@@ -1890,6 +2320,301 @@ export function createXStatePlaybookRuntime(machine, spec) {
1890
2320
  initInFlight = undefined;
1891
2321
  }
1892
2322
  },
2323
+ // DR-029 / PBRT-52: side-effect-free control view over the live
2324
+ // snapshot, valid at parked quiescence outside an active boundary.
2325
+ // The view is detached and frozen; producing it emits nothing and
2326
+ // moves nothing.
2327
+ describe() {
2328
+ if (disposed || disposalPromise !== undefined) {
2329
+ throw new Error('createPlaybookRuntime.describe: runtime is disposing or disposed');
2330
+ }
2331
+ if (!actor || !savedPorts) {
2332
+ throw new Error('createPlaybookRuntime.describe: init must be called first');
2333
+ }
2334
+ if (activeSignal !== undefined) {
2335
+ throw new Error('createPlaybookRuntime.describe: another runtime turn is active');
2336
+ }
2337
+ const snapshot = actor.getSnapshot();
2338
+ const state = currentState();
2339
+ const context = (snapshot.context ??
2340
+ {});
2341
+ const pending = pendingBossQuestionFromContext(context);
2342
+ const lastError = normalizeErrorFull(context.lastError);
2343
+ const projectedContext = projectControlContext(context);
2344
+ const stateDescription = stateDescriptionFor(state);
2345
+ return deepFreeze({
2346
+ state,
2347
+ ...(stateDescription === undefined ? {} : { stateDescription }),
2348
+ ...(projectedContext !== undefined
2349
+ ? { context: projectedContext }
2350
+ : {}),
2351
+ pendingQuestions: pending === undefined
2352
+ ? []
2353
+ : [
2354
+ {
2355
+ questionId: pending.questionId,
2356
+ player: pending.player,
2357
+ question: pending.question,
2358
+ sourceItem: pending.sourceItem,
2359
+ },
2360
+ ],
2361
+ ...(lastError !== undefined ? { lastError } : {}),
2362
+ actions: deriveControlActions(snapshot).map(({ action }) => action),
2363
+ });
2364
+ },
2365
+ // DR-029 / PBRT-52: revalidate the named action against the live
2366
+ // state and execute it at most once per idempotency key. The receipt
2367
+ // discriminates rejected-before-any-effect from executed and from
2368
+ // failed-after-effects-may-exist; a repeated key returns the recorded
2369
+ // receipt without re-execution. A rejection settles before acceptance,
2370
+ // so — like a key whose call threw before reaching acceptance — it
2371
+ // records nothing and the key may execute later, once the action is
2372
+ // advertised.
2373
+ async apply(input) {
2374
+ if (input === null || typeof input !== 'object') {
2375
+ throw new TypeError('createPlaybookRuntime.apply: input must be an object');
2376
+ }
2377
+ const { actionId, key, signal } = input;
2378
+ if (typeof actionId !== 'string' || actionId.length === 0) {
2379
+ throw new TypeError('createPlaybookRuntime.apply: actionId must be a non-empty string');
2380
+ }
2381
+ if (typeof key !== 'string' || key.length === 0) {
2382
+ throw new TypeError('createPlaybookRuntime.apply: key must be a non-empty string');
2383
+ }
2384
+ if (!(signal instanceof AbortSignal)) {
2385
+ throw new TypeError('createPlaybookRuntime.apply: signal must be an AbortSignal');
2386
+ }
2387
+ if (disposed || disposalPromise !== undefined) {
2388
+ throw new Error('createPlaybookRuntime.apply: runtime is disposing or disposed');
2389
+ }
2390
+ if (!actor || !savedPorts) {
2391
+ throw new Error('createPlaybookRuntime.apply: init must be called first');
2392
+ }
2393
+ if (activeSignal !== undefined) {
2394
+ throw new Error('createPlaybookRuntime.apply: another runtime turn is active');
2395
+ }
2396
+ // Settlement is final: a repeated key returns the recorded receipt
2397
+ // with no revalidation, no execution, and no new trace pair.
2398
+ const recorded = appliedReceipts.get(key);
2399
+ if (recorded !== undefined)
2400
+ return recorded;
2401
+ // An abort before acceptance ends the call with no receipt
2402
+ // recorded, like every other pre-acceptance failure.
2403
+ signal.throwIfAborted();
2404
+ const turnId = ++turnSequence;
2405
+ const callId = `apply-${++applyCallSequence}`;
2406
+ const position = { turnId, callId };
2407
+ activeTurnId = turnId;
2408
+ activeSignal = signal;
2409
+ controlPlaneError = undefined;
2410
+ // Every receipt variant is normalized and frozen where it is built,
2411
+ // inside the guarded region, so the recording step below cannot
2412
+ // throw after effects exist.
2413
+ const settledReceipt = (value) => deepFreeze(snapshotJsonValue(value, 'apply receipt'));
2414
+ let receipt;
2415
+ let operationError;
2416
+ let settlementError;
2417
+ // Acceptance is the line past which this boundary owes a receipt and
2418
+ // can no longer signal by throwing: the action may have run, and a
2419
+ // caller that gets an exception instead of a receipt is left with an
2420
+ // executed effect it cannot record and a key it will not reuse.
2421
+ let accepted = false;
2422
+ // Publication is the second line this boundary respects. Before it,
2423
+ // nothing has left the runtime: a settlement failure past acceptance
2424
+ // is a post-acceptance control-plane error PBRT-52 settles as the
2425
+ // `failed` receipt, and folding it in replaces the receipt recorded
2426
+ // at acceptance so the finish trace, the returned receipt, and any
2427
+ // replay of the key all report one settlement. Past publication that
2428
+ // agreement is no longer achievable — the disposition is already on
2429
+ // the wire — so the fold refuses to run, by construction rather than
2430
+ // by call ordering. Only the first settlement error is latched, so
2431
+ // one fold is all there is to do.
2432
+ let folded = false;
2433
+ let published = false;
2434
+ const foldSettlementFailure = () => {
2435
+ if (published || !accepted || folded)
2436
+ return;
2437
+ if (settlementError === undefined)
2438
+ return;
2439
+ folded = true;
2440
+ receipt = settledReceipt({
2441
+ disposition: 'failed',
2442
+ error: normalizeError(settlementError),
2443
+ });
2444
+ appliedReceipts.set(key, receipt);
2445
+ };
2446
+ // A settlement failure that lands after the receipt is published says
2447
+ // nothing about the effect: the action ran, the caller's receipt is
2448
+ // true, and only the telemetry delivery failed. Rewriting `executed`
2449
+ // to `failed` there would make the runtime lie to its only caller
2450
+ // about work that succeeded, irrecoverably — accepted receipts are
2451
+ // final for their key. Past publication such a failure is therefore
2452
+ // re-latched onto the emission channel, surfacing from the next
2453
+ // public boundary's drain, and `apply` still does not throw past
2454
+ // acceptance (PBRT-52).
2455
+ const latchDeliveryFailure = (error) => {
2456
+ emissionFailure ??= error;
2457
+ };
2458
+ try {
2459
+ try {
2460
+ const identity = {
2461
+ actionId,
2462
+ key,
2463
+ ...stateIdentity(currentState().stateId),
2464
+ };
2465
+ // Every apply finish carries the receipt disposition and no
2466
+ // start-only field — `stateId` is on the start alone
2467
+ // (slc/link.md §Playbook trace). Both finishes reachable
2468
+ // before acceptance settle with no effect behind them, so both
2469
+ // carry the canonical `rejected` disposition and the reason
2470
+ // that ended the call, alongside the transport marker.
2471
+ const preAcceptanceFinish = (reason) => ({
2472
+ actionId,
2473
+ key,
2474
+ ...receiptTracePayload({ disposition: 'rejected', reason }),
2475
+ });
2476
+ await emitCallStarted('apply.started', 'apply.finished', identity, position, preAcceptanceFinish('apply.started trace sink rejected'));
2477
+ // An abort may land while the awaited started emission drains
2478
+ // (e.g. fired from the trace sink itself); the action must
2479
+ // never execute after abort. Settle the already-started pair
2480
+ // as `aborted` — carrying the canonical rejected-before-any-
2481
+ // effect receipt disposition required of every apply finish —
2482
+ // and end the call pre-acceptance: no receipt is recorded and
2483
+ // the key stays free.
2484
+ if (signal.aborted) {
2485
+ try {
2486
+ await emitTrace('apply.finished', {
2487
+ ...preAcceptanceFinish('aborted before acceptance'),
2488
+ status: 'aborted',
2489
+ error: normalizeError(signal.reason),
2490
+ }, position);
2491
+ }
2492
+ catch (error) {
2493
+ // A rejecting finish sink surfaces at the boundary like
2494
+ // any settlement failure (see the precedence below).
2495
+ settlementError ??= error;
2496
+ }
2497
+ signal.throwIfAborted();
2498
+ }
2499
+ const snapshot = actor.getSnapshot();
2500
+ const candidate = deriveControlActions(snapshot).find(({ action }) => action.id === actionId);
2501
+ if (candidate === undefined) {
2502
+ receipt = settledReceipt({
2503
+ disposition: 'rejected',
2504
+ reason: `action ${JSON.stringify(actionId)} is not currently advertised`,
2505
+ });
2506
+ }
2507
+ else {
2508
+ // Acceptance: from here every outcome records a receipt under
2509
+ // the key, so the action can never execute twice.
2510
+ accepted = true;
2511
+ try {
2512
+ actor.send(candidate.event);
2513
+ await waitForPlaybookQuiescence(actor, {
2514
+ pendingCalls: nestedBridge,
2515
+ });
2516
+ if (controlPlaneError !== undefined)
2517
+ throw controlPlaneError;
2518
+ const run = runResultFor(settledOutcome(signal));
2519
+ receipt = settledReceipt(run.outcome === 'failed' || run.outcome === 'aborted'
2520
+ ? {
2521
+ disposition: 'failed',
2522
+ error: ('error' in run ? run.error : undefined) ??
2523
+ normalizeError(new Error(`apply settled with outcome ${run.outcome}`)),
2524
+ }
2525
+ : { disposition: 'executed', run });
2526
+ }
2527
+ catch (error) {
2528
+ // Effects may exist: a post-acceptance failure is the
2529
+ // receipt, not a control-plane rejection (DR-029).
2530
+ receipt = settledReceipt({
2531
+ disposition: 'failed',
2532
+ error: normalizeError(error),
2533
+ });
2534
+ }
2535
+ }
2536
+ }
2537
+ catch (error) {
2538
+ operationError = error; // pre-acceptance: no receipt is recorded
2539
+ }
2540
+ // Record acceptance before the settlement emissions, so a crash
2541
+ // between acceptance and settlement can never re-execute the
2542
+ // action: the recorded receipt survives and a replayed key
2543
+ // returns it. A rejection settled before acceptance: it is
2544
+ // returned and traced but never recorded, so its key stays free
2545
+ // to execute once the action is advertised.
2546
+ if (receipt !== undefined && receipt.disposition !== 'rejected') {
2547
+ appliedReceipts.set(key, receipt);
2548
+ }
2549
+ try {
2550
+ await drainEmissions();
2551
+ }
2552
+ catch (error) {
2553
+ settlementError = error;
2554
+ }
2555
+ // Fold before the finish emission, the last point at which the
2556
+ // traced disposition and the returned one can still be made the
2557
+ // same value.
2558
+ foldSettlementFailure();
2559
+ if (receipt !== undefined) {
2560
+ // Publication: this disposition is now the settlement, for the
2561
+ // trace, for the caller, and for every replay of the key.
2562
+ published = true;
2563
+ try {
2564
+ await emitTrace('apply.finished', { actionId, key, ...receiptTracePayload(receipt) }, position);
2565
+ }
2566
+ catch (error) {
2567
+ if (accepted)
2568
+ latchDeliveryFailure(error);
2569
+ else
2570
+ settlementError ??= error;
2571
+ }
2572
+ // Drain even when the finish emission rejected, so this call
2573
+ // leaves no queued emission behind it. Before acceptance the
2574
+ // failure is consumed and thrown, as every pre-acceptance failure
2575
+ // is; past it the failure is re-latched instead — the effect
2576
+ // happened, so the delivery failure travels on the emission
2577
+ // channel to the next boundary rather than rewriting what
2578
+ // happened or vanishing here.
2579
+ try {
2580
+ await drainEmissions();
2581
+ }
2582
+ catch (error) {
2583
+ if (accepted)
2584
+ latchDeliveryFailure(error);
2585
+ else
2586
+ settlementError ??= error;
2587
+ }
2588
+ }
2589
+ }
2590
+ finally {
2591
+ // Always release the boundary sentinel, even on a path no
2592
+ // constructible input reaches today, so a defect here can never
2593
+ // wedge every later public boundary behind "another runtime turn
2594
+ // is active".
2595
+ activeSignal = undefined;
2596
+ activeTurnId = undefined;
2597
+ controlPlaneError = undefined;
2598
+ }
2599
+ // Past acceptance every settlement failure has been folded into the
2600
+ // receipt, so nothing is left to throw and the caller always leaves
2601
+ // with the settlement of the effect it may have caused (PBRT-52).
2602
+ if (accepted && receipt !== undefined)
2603
+ return receipt;
2604
+ // Before acceptance no effect exists and no receipt is owed, so a
2605
+ // failure still surfaces by throwing. Settlement failures (a
2606
+ // rejecting finish sink, a drain-latched emission failure) outrank
2607
+ // the operation error, matching the `drainError ?? operationError`
2608
+ // precedence of the other public boundaries. A start-sink failure is
2609
+ // unaffected: its latched drain error is the start error itself.
2610
+ const failure = settlementError ?? operationError;
2611
+ if (failure !== undefined)
2612
+ throw failure;
2613
+ if (receipt === undefined) {
2614
+ throw new Error('createPlaybookRuntime.apply: no receipt was produced');
2615
+ }
2616
+ return receipt;
2617
+ },
1893
2618
  async handleBossInput({ text, signal, }) {
1894
2619
  if (!actor || !savedPorts) {
1895
2620
  throw new Error('createPlaybookRuntime.handleBossInput: init must be called first');
@@ -1925,7 +2650,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
1925
2650
  };
1926
2651
  }
1927
2652
  else {
1928
- event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary);
2653
+ event = await classifyBossText(text, runtimePorts, signal, snapshot, boundary, boundOptions);
1929
2654
  }
1930
2655
  signal.throwIfAborted();
1931
2656
  }
@@ -1945,10 +2670,22 @@ export function createXStatePlaybookRuntime(machine, spec) {
1945
2670
  // 3. A final actor cannot accept new events; reconstruct only
1946
2671
  // after classification produced a real event.
1947
2672
  if (actor.getSnapshot().status === 'done') {
1948
- actor.stop();
2673
+ stopActor();
1949
2674
  actor = buildActor(runtimePorts);
2675
+ // The replacement actor's snapshots are real state entries.
2676
+ suppressInspectionEmissions = false;
1950
2677
  actor.start();
1951
2678
  }
2679
+ // DR-029: keep the classified event with its recorded payload
2680
+ // as the retry-replay source. Recording is sanitizing, not
2681
+ // load-bearing: an override classifier's non-JSON-safe event is
2682
+ // simply not recorded, and the turn proceeds unchanged.
2683
+ try {
2684
+ lastBossEvent = snapshotJsonValue(event, 'recorded Boss event');
2685
+ }
2686
+ catch {
2687
+ lastBossEvent = undefined;
2688
+ }
1952
2689
  actor.send(event);
1953
2690
  await waitForPlaybookQuiescence(actor, {
1954
2691
  pendingCalls: nestedBridge,
@@ -2075,8 +2812,9 @@ export function createXStatePlaybookRuntime(machine, spec) {
2075
2812
  const finalState = actor ? currentState() : undefined;
2076
2813
  // Stop the root before settling a suspended child. Its rejection
2077
2814
  // must not re-enter the FSM and start fresh work during disposal.
2078
- if (actor)
2079
- actor.stop();
2815
+ // `stopActor` suppresses inspection first, so the stop snapshot
2816
+ // adds nothing beside the `session.disposed` trace below (PBRT-6).
2817
+ stopActor();
2080
2818
  try {
2081
2819
  await nestedBridge.dispose();
2082
2820
  }
@@ -2111,11 +2849,13 @@ export function createXStatePlaybookRuntime(machine, spec) {
2111
2849
  activeEmissionCalls.clear();
2112
2850
  emissionQueue.clear();
2113
2851
  judgeQueue.clear();
2852
+ appliedReceipts.clear();
2114
2853
  actor = undefined;
2115
2854
  activeSignal = undefined;
2116
2855
  activeTurnId = undefined;
2117
2856
  controlPlaneError = undefined;
2118
2857
  emissionFailure = undefined;
2858
+ lastBossEvent = undefined;
2119
2859
  savedPorts = undefined;
2120
2860
  runtimePorts = undefined;
2121
2861
  session = undefined;