@sublang/playbook 6.0.0 → 7.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.
@@ -1,10 +1,10 @@
1
1
  # SPDX-License-Identifier: Apache-2.0
2
2
  # SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
 
4
- # Generic `playbook` launcher config.
4
+ # Shared `playbook` and `playbook run` launcher config.
5
5
  # Top-level host fields only — no `config:` wrapper, no top-level `players`.
6
6
  # The launcher injects captain.from and the namespaced <id>-<role> host
7
- # players, then launches cligent's tmux-play under the Playbook Captain shell.
7
+ # players, then hosts the same Playbook Captain shell interactively or headlessly.
8
8
 
9
9
  # Every agent — the Captain and each playbook role — carries its own
10
10
  # settings inline: an adapter shorthand (claude, codex) or a block with
@@ -33,7 +33,8 @@ captain:
33
33
  permissions:
34
34
  mode: auto
35
35
 
36
- # Host notifications. Omitting turn_aborted resolves it to off.
36
+ # Interactive host notifications. Headless runs ignore presentation fields.
37
+ # Omitting turn_aborted resolves it to off.
37
38
  notifications:
38
39
  player_finished: bell
39
40
  turn_finished: desktop
@@ -90,14 +91,3 @@ playbooks:
90
91
  permissions:
91
92
  mode: auto
92
93
  writablePaths: ['.git']
93
-
94
- # Non-interactive `playbook run` defaults (optional). Each value is an
95
- # agent string <adapter>[:<model>][@<effort>]; `player` is the catch-all
96
- # for any required role without its own `players.<role>` entry, across
97
- # every playbook. Flags override per role (--player / --captain), and
98
- # `playbook run resume` keeps the lineup stored with the parked session.
99
- #run:
100
- # captain: claude:claude-opus-4-8@high
101
- # player: claude:claude-opus-4-8@high
102
- # players:
103
- # coder: claude:claude-opus-4-8[1m]@xhigh
@@ -203,6 +203,22 @@ function parseJudgeJson(raw) {
203
203
  function isPlainObject(value) {
204
204
  return value !== null && typeof value === 'object' && !Array.isArray(value);
205
205
  }
206
+ function sortJson(value) {
207
+ if (Array.isArray(value))
208
+ return value.map((entry) => sortJson(entry));
209
+ if (value !== null && typeof value === 'object') {
210
+ const record = value;
211
+ const sorted = {};
212
+ for (const key of Object.keys(record).sort()) {
213
+ sorted[key] = sortJson(record[key]);
214
+ }
215
+ return sorted;
216
+ }
217
+ return value;
218
+ }
219
+ function stableJson(value, path) {
220
+ return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
221
+ }
206
222
  function stripCodeFence(text) {
207
223
  const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
208
224
  return fence ? fence[1].trim() : text;
@@ -677,13 +693,13 @@ export const createPlaybookRuntime = (options) => {
677
693
  playerResumeTokens.set(playerId, token);
678
694
  }
679
695
  };
680
- const currentState = () => {
696
+ const currentState = (pendingCall = nestedBridge.getPendingCall()) => {
681
697
  const live = actor;
682
698
  if (!live) {
683
699
  throw new Error('decide runtime: actor is not initialized');
684
700
  }
685
701
  return normalizePlaybookSnapshot(live.getSnapshot(), {
686
- pendingCall: nestedBridge.getPendingCall(),
702
+ pendingCall,
687
703
  });
688
704
  };
689
705
  const stateIdentity = (state) => {
@@ -1181,7 +1197,7 @@ export const createPlaybookRuntime = (options) => {
1181
1197
  // The caller rethrows its original failure. A restore failure skips
1182
1198
  // the disposal trace — the parked session was never re-bound in this
1183
1199
  // process, so its persisted snapshot stays authoritative (DR-014 §2).
1184
- const cleanupFailedStart = async (options) => {
1200
+ const cleanupFailedStart = async (cause, options) => {
1185
1201
  let finalState;
1186
1202
  if (options.emitDisposal && actor) {
1187
1203
  try {
@@ -1197,6 +1213,12 @@ export const createPlaybookRuntime = (options) => {
1197
1213
  catch {
1198
1214
  // Preserve the original startup failure.
1199
1215
  }
1216
+ try {
1217
+ await nestedBridge.abortPending(cause);
1218
+ }
1219
+ catch {
1220
+ // Preserve the original startup failure.
1221
+ }
1200
1222
  try {
1201
1223
  await judgeQueue.onIdle();
1202
1224
  await drainBoundaryCallsAndEmissions();
@@ -1269,7 +1291,7 @@ export const createPlaybookRuntime = (options) => {
1269
1291
  await flush();
1270
1292
  }
1271
1293
  catch (error) {
1272
- await cleanupFailedStart({ emitDisposal: true });
1294
+ await cleanupFailedStart(error, { emitDisposal: true });
1273
1295
  throw error;
1274
1296
  }
1275
1297
  finally {
@@ -1278,10 +1300,11 @@ export const createPlaybookRuntime = (options) => {
1278
1300
  initInFlight = undefined;
1279
1301
  }
1280
1302
  },
1281
- // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1303
+ // DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
1304
+ // session, including one already-started suspended REVIEW call.
1282
1305
  // Defined only at a safe capture point — initialized, not disposing
1283
- // or disposed, no active public boundary, and the actor quiescent
1284
- // with status `active` and no pending nested REVIEW call.
1306
+ // or disposed, no active public boundary, and the actor quiescent with
1307
+ // status `active`.
1285
1308
  exportSnapshot() {
1286
1309
  if (!actor ||
1287
1310
  !sessionIdentity ||
@@ -1292,15 +1315,40 @@ export const createPlaybookRuntime = (options) => {
1292
1315
  if (currentTurnId !== undefined || currentSignal !== undefined) {
1293
1316
  return undefined;
1294
1317
  }
1295
- if (nestedBridge.getPendingCall())
1318
+ const pendingCall = nestedBridge.getPendingCall();
1319
+ const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
1320
+ if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
1321
+ return undefined;
1322
+ }
1323
+ if (pendingCall !== undefined &&
1324
+ bridgeSuspendedCall !== undefined &&
1325
+ (pendingCall.callId !== bridgeSuspendedCall.callId ||
1326
+ pendingCall.playbookId !== bridgeSuspendedCall.playbookId ||
1327
+ pendingCall.childSessionId !== bridgeSuspendedCall.childSessionId)) {
1296
1328
  return undefined;
1329
+ }
1330
+ let suspendedCall;
1331
+ if (bridgeSuspendedCall !== undefined) {
1332
+ if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
1333
+ return undefined;
1334
+ }
1335
+ const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
1336
+ if (bridgeSuspendedCall.turnId !== undefined &&
1337
+ bridgeSuspendedCall.turnId !== turnId) {
1338
+ return undefined;
1339
+ }
1340
+ suspendedCall = {
1341
+ ...bridgeSuspendedCall,
1342
+ ...(turnId === undefined ? {} : { turnId }),
1343
+ };
1344
+ }
1297
1345
  const state = currentState();
1298
1346
  if (state.status !== 'active' || !state.quiescent)
1299
1347
  return undefined;
1300
1348
  const machine = detachPersistedMachineSnapshot(actor.getPersistedSnapshot());
1301
1349
  const context = actor.getSnapshot().context;
1302
1350
  return {
1303
- schemaVersion: 1,
1351
+ schemaVersion: 2,
1304
1352
  playbookId: sessionIdentity.playbookId,
1305
1353
  machine,
1306
1354
  playerResumeTokens: snapshotPlayerResumeTokens(),
@@ -1318,6 +1366,7 @@ export const createPlaybookRuntime = (options) => {
1318
1366
  question: pending.question,
1319
1367
  sourceItem: pending.sourceItem,
1320
1368
  })),
1369
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
1321
1370
  };
1322
1371
  },
1323
1372
  // DR-014 §1 / PBRT-45: alternative to `init` that rehydrates an
@@ -1333,7 +1382,10 @@ export const createPlaybookRuntime = (options) => {
1333
1382
  throw new Error('decide runtime: restore(session, snapshot) may only be called once');
1334
1383
  }
1335
1384
  const identity = snapshotPlaybookSession(session);
1336
- const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId);
1385
+ const boundSnapshot = assertPlaybookRuntimeSnapshot(snapshot, identity.playbookId, { allowSuspendedCall: true });
1386
+ const suspendedCall = boundSnapshot.schemaVersion === 2
1387
+ ? boundSnapshot.suspendedCall
1388
+ : undefined;
1337
1389
  let finishInitialization;
1338
1390
  const initialization = new Promise((resolve) => {
1339
1391
  finishInitialization = resolve;
@@ -1353,16 +1405,28 @@ export const createPlaybookRuntime = (options) => {
1353
1405
  priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1354
1406
  }
1355
1407
  restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1408
+ nestedBridge.prepareRestore(suspendedCall);
1409
+ if (suspendedCall !== undefined) {
1410
+ playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
1411
+ }
1356
1412
  suppressInspectionEmissions = true;
1357
1413
  createRuntimeActor(boundSnapshot.machine);
1358
1414
  actor?.start();
1359
- const restoredState = currentState();
1415
+ const restoredState = currentState(suspendedCall);
1360
1416
  if (restoredState.status !== 'active') {
1361
1417
  throw new Error(`decide runtime: restored actor status is ${restoredState.status}, expected active`);
1362
1418
  }
1363
- suppressInspectionEmissions = false;
1419
+ if (stableJson(restoredState, 'restored runtime state') !==
1420
+ stableJson(boundSnapshot.state, 'runtime snapshot state')) {
1421
+ throw new Error('decide runtime: restored actor state does not match snapshot state');
1422
+ }
1364
1423
  previousState = restoredState;
1365
1424
  await flush();
1425
+ suppressInspectionEmissions = false;
1426
+ // Final fallible step: after this publication the authoritative
1427
+ // child has rejoined ordinary resume/abort ownership, so no later
1428
+ // restore validation may trigger failed-start rollback.
1429
+ nestedBridge.confirmRestore();
1366
1430
  }
1367
1431
  catch (error) {
1368
1432
  let failure = error;
@@ -1374,7 +1438,7 @@ export const createPlaybookRuntime = (options) => {
1374
1438
  failure = new AggregateError([error, rollbackError], 'DECIDE restore and player continuation rollback failed');
1375
1439
  }
1376
1440
  }
1377
- await cleanupFailedStart({ emitDisposal: false });
1441
+ await cleanupFailedStart(failure, { emitDisposal: false });
1378
1442
  throw failure;
1379
1443
  }
1380
1444
  finally {
@@ -324,6 +324,23 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
324
324
  return value !== null && typeof value === 'object' && !Array.isArray(value);
325
325
  }
326
326
 
327
+ function sortJson(value: JsonValue): JsonValue {
328
+ if (Array.isArray(value)) return value.map((entry) => sortJson(entry));
329
+ if (value !== null && typeof value === 'object') {
330
+ const record = value as { readonly [key: string]: JsonValue };
331
+ const sorted: Record<string, JsonValue> = {};
332
+ for (const key of Object.keys(record).sort()) {
333
+ sorted[key] = sortJson(record[key]);
334
+ }
335
+ return sorted;
336
+ }
337
+ return value;
338
+ }
339
+
340
+ function stableJson(value: unknown, path: string): string {
341
+ return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
342
+ }
343
+
327
344
  function stripCodeFence(text: string): string {
328
345
  const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
329
346
  return fence ? fence[1].trim() : text;
@@ -920,13 +937,16 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
920
937
  playerResumeTokens.set(playerId, token);
921
938
  }
922
939
  };
923
- const currentState = (): PlaybookState => {
940
+ const currentState = (
941
+ pendingCall: PlaybookPendingCall | undefined =
942
+ nestedBridge.getPendingCall(),
943
+ ): PlaybookState => {
924
944
  const live = actor;
925
945
  if (!live) {
926
946
  throw new Error('decide runtime: actor is not initialized');
927
947
  }
928
948
  return normalizePlaybookSnapshot(live.getSnapshot(), {
929
- pendingCall: nestedBridge.getPendingCall(),
949
+ pendingCall,
930
950
  });
931
951
  };
932
952
  const stateIdentity = (state: PlaybookState): { stateId?: string } => {
@@ -1592,9 +1612,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1592
1612
  // The caller rethrows its original failure. A restore failure skips
1593
1613
  // the disposal trace — the parked session was never re-bound in this
1594
1614
  // process, so its persisted snapshot stays authoritative (DR-014 §2).
1595
- const cleanupFailedStart = async (options: {
1596
- emitDisposal: boolean;
1597
- }): Promise<void> => {
1615
+ const cleanupFailedStart = async (
1616
+ cause: unknown,
1617
+ options: { emitDisposal: boolean },
1618
+ ): Promise<void> => {
1598
1619
  let finalState: PlaybookState | undefined;
1599
1620
  if (options.emitDisposal && actor) {
1600
1621
  try {
@@ -1608,6 +1629,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1608
1629
  } catch {
1609
1630
  // Preserve the original startup failure.
1610
1631
  }
1632
+ try {
1633
+ await nestedBridge.abortPending(cause);
1634
+ } catch {
1635
+ // Preserve the original startup failure.
1636
+ }
1611
1637
  try {
1612
1638
  await judgeQueue.onIdle();
1613
1639
  await drainBoundaryCallsAndEmissions();
@@ -1682,7 +1708,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1682
1708
  actor?.start();
1683
1709
  await flush();
1684
1710
  } catch (error) {
1685
- await cleanupFailedStart({ emitDisposal: true });
1711
+ await cleanupFailedStart(error, { emitDisposal: true });
1686
1712
  throw error;
1687
1713
  } finally {
1688
1714
  finishInitialization();
@@ -1690,10 +1716,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1690
1716
  }
1691
1717
  },
1692
1718
 
1693
- // DR-014 §1 / PBRT-45: JSON-safe capture of a parked session.
1719
+ // DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
1720
+ // session, including one already-started suspended REVIEW call.
1694
1721
  // Defined only at a safe capture point — initialized, not disposing
1695
- // or disposed, no active public boundary, and the actor quiescent
1696
- // with status `active` and no pending nested REVIEW call.
1722
+ // or disposed, no active public boundary, and the actor quiescent with
1723
+ // status `active`.
1697
1724
  exportSnapshot(): PlaybookRuntimeSnapshot | undefined {
1698
1725
  if (
1699
1726
  !actor ||
@@ -1706,7 +1733,37 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1706
1733
  if (currentTurnId !== undefined || currentSignal !== undefined) {
1707
1734
  return undefined;
1708
1735
  }
1709
- if (nestedBridge.getPendingCall()) return undefined;
1736
+ const pendingCall = nestedBridge.getPendingCall();
1737
+ const bridgeSuspendedCall = nestedBridge.getSuspendedCall();
1738
+ if ((pendingCall === undefined) !== (bridgeSuspendedCall === undefined)) {
1739
+ return undefined;
1740
+ }
1741
+ if (
1742
+ pendingCall !== undefined &&
1743
+ bridgeSuspendedCall !== undefined &&
1744
+ (pendingCall.callId !== bridgeSuspendedCall.callId ||
1745
+ pendingCall.playbookId !== bridgeSuspendedCall.playbookId ||
1746
+ pendingCall.childSessionId !== bridgeSuspendedCall.childSessionId)
1747
+ ) {
1748
+ return undefined;
1749
+ }
1750
+ let suspendedCall: typeof bridgeSuspendedCall;
1751
+ if (bridgeSuspendedCall !== undefined) {
1752
+ if (!playbookCallTurnIds.has(bridgeSuspendedCall.callId)) {
1753
+ return undefined;
1754
+ }
1755
+ const turnId = playbookCallTurnIds.get(bridgeSuspendedCall.callId);
1756
+ if (
1757
+ bridgeSuspendedCall.turnId !== undefined &&
1758
+ bridgeSuspendedCall.turnId !== turnId
1759
+ ) {
1760
+ return undefined;
1761
+ }
1762
+ suspendedCall = {
1763
+ ...bridgeSuspendedCall,
1764
+ ...(turnId === undefined ? {} : { turnId }),
1765
+ };
1766
+ }
1710
1767
  const state = currentState();
1711
1768
  if (state.status !== 'active' || !state.quiescent) return undefined;
1712
1769
  const machine = detachPersistedMachineSnapshot(
@@ -1716,7 +1773,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1716
1773
  actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
1717
1774
  ).context as unknown as Record<string, unknown>;
1718
1775
  return {
1719
- schemaVersion: 1,
1776
+ schemaVersion: 2,
1720
1777
  playbookId: sessionIdentity.playbookId,
1721
1778
  machine,
1722
1779
  playerResumeTokens: snapshotPlayerResumeTokens(),
@@ -1736,6 +1793,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1736
1793
  sourceItem: pending.sourceItem,
1737
1794
  }),
1738
1795
  ),
1796
+ ...(suspendedCall === undefined ? {} : { suspendedCall }),
1739
1797
  };
1740
1798
  },
1741
1799
 
@@ -1762,7 +1820,12 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1762
1820
  const boundSnapshot = assertPlaybookRuntimeSnapshot(
1763
1821
  snapshot,
1764
1822
  identity.playbookId,
1823
+ { allowSuspendedCall: true },
1765
1824
  );
1825
+ const suspendedCall =
1826
+ boundSnapshot.schemaVersion === 2
1827
+ ? boundSnapshot.suspendedCall
1828
+ : undefined;
1766
1829
  let finishInitialization!: () => void;
1767
1830
  const initialization = new Promise<void>((resolve) => {
1768
1831
  finishInitialization = resolve;
@@ -1784,18 +1847,34 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1784
1847
  priorExternalPlayerTokens = snapshotPlayerResumeTokens();
1785
1848
  }
1786
1849
  restorePlayerResumeTokens(boundSnapshot.playerResumeTokens);
1850
+ nestedBridge.prepareRestore(suspendedCall);
1851
+ if (suspendedCall !== undefined) {
1852
+ playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
1853
+ }
1787
1854
  suppressInspectionEmissions = true;
1788
1855
  createRuntimeActor(boundSnapshot.machine);
1789
1856
  actor?.start();
1790
- const restoredState = currentState();
1857
+ const restoredState = currentState(suspendedCall);
1791
1858
  if (restoredState.status !== 'active') {
1792
1859
  throw new Error(
1793
1860
  `decide runtime: restored actor status is ${restoredState.status}, expected active`,
1794
1861
  );
1795
1862
  }
1796
- suppressInspectionEmissions = false;
1863
+ if (
1864
+ stableJson(restoredState, 'restored runtime state') !==
1865
+ stableJson(boundSnapshot.state, 'runtime snapshot state')
1866
+ ) {
1867
+ throw new Error(
1868
+ 'decide runtime: restored actor state does not match snapshot state',
1869
+ );
1870
+ }
1797
1871
  previousState = restoredState;
1798
1872
  await flush();
1873
+ suppressInspectionEmissions = false;
1874
+ // Final fallible step: after this publication the authoritative
1875
+ // child has rejoined ordinary resume/abort ownership, so no later
1876
+ // restore validation may trigger failed-start rollback.
1877
+ nestedBridge.confirmRestore();
1799
1878
  } catch (error) {
1800
1879
  let failure = error;
1801
1880
  if (priorExternalPlayerTokens !== undefined) {
@@ -1808,7 +1887,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1808
1887
  );
1809
1888
  }
1810
1889
  }
1811
- await cleanupFailedStart({ emitDisposal: false });
1890
+ await cleanupFailedStart(failure, { emitDisposal: false });
1812
1891
  throw failure;
1813
1892
  } finally {
1814
1893
  finishInitialization();
package/slc/link.md CHANGED
@@ -1240,12 +1240,12 @@ durability, the pair shall behave as follows.
1240
1240
 
1241
1241
  `exportSnapshot()` shall return `undefined` unless the runtime is at a safe
1242
1242
  capture point: initialized, not disposing or disposed, no active
1243
- `handleBossInput`/`resumePlaybookCall` boundary, no pending nested playbook
1244
- call, and the root actor at a quiescent state with actor status `active`.
1243
+ `handleBossInput`/`resumePlaybookCall` boundary, and the root actor at a
1244
+ quiescent state with actor status `active`.
1245
1245
  At a safe capture point it shall return a JSON-safe
1246
1246
  `PlaybookRuntimeSnapshot` carrying:
1247
1247
 
1248
- - `schemaVersion`: literal `1`.
1248
+ - `schemaVersion`: literal `2`.
1249
1249
  - `playbookId`: the bound session's playbook id.
1250
1250
  - `machine`: the root actor's `getPersistedSnapshot()` result, passed
1251
1251
  through the shared JSON detachment with any raw `Error` context value
@@ -1255,19 +1255,33 @@ At a safe capture point it shall return a JSON-safe
1255
1255
  (§PlaybookPorts contract).
1256
1256
  - `sequences`: the live `trace`, `turn`, `judgeCall`, `playerCall`, and
1257
1257
  `playbookCall` counters, plus `captainCall` when the runtime supports direct
1258
- Captain calls. `captainCall` remains optional under schema version `1` for
1259
- backward compatibility; a direct-Captain-capable runtime shall persist it.
1258
+ Captain calls. A direct-Captain-capable runtime shall persist it in current
1259
+ schema-version-2 exports; it remains optional in legacy schema-version-1
1260
+ input, where restore uses `trace` as its collision-safe floor.
1260
1261
  - `state`: the current normalized state descriptor.
1261
1262
  - `pendingBossQuestions`: the pending Boss question(s) from FSM context as
1262
1263
  a list of `{ questionId, player, question, sourceItem? }`, empty when the
1263
1264
  parked state awaits no reply. This list exists so hosts can surface the
1264
1265
  question without parsing status lines or telemetry.
1266
+ - `suspendedCall`: omitted when no nested call is pending; otherwise the
1267
+ shared nested bridge's complete `callId`, source `stateId`, target
1268
+ `playbookId`, exact handed-off `text`, and `childSessionId`, enriched with
1269
+ the call-to-turn map's optional `turnId`.
1270
+
1271
+ A pending nested call is exportable only when the bridge's pending identity
1272
+ and complete descriptor agree and the call-to-turn map owns that exact call
1273
+ id, including ownership whose value is absent. A bridge descriptor that
1274
+ already carries a turn id shall equal that map value. Any missing or
1275
+ inconsistent bridge, descriptor, or turn-ownership record makes the capture
1276
+ unsafe and returns `undefined`.
1265
1277
 
1266
1278
  `restore(session, snapshot)` is an alternative to `init` under the same
1267
1279
  lifecycle guards (§Session lifecycle): it shall reject when already
1268
1280
  initialized, disposing, or disposed, and shall validate
1269
- `snapshot.schemaVersion` and that `snapshot.playbookId` equals
1270
- `session.playbookId` before touching state.
1281
+ schema version `1` or `2` and that `snapshot.playbookId` equals
1282
+ `session.playbookId` before touching state. Schema version `1` remains a
1283
+ descriptor-free legacy input; schema version `2` may carry the suspended-call
1284
+ descriptor above.
1271
1285
  The host supplies the same immutable `PlaybookSession` identity the
1272
1286
  snapshot was exported under and recreates the runtime through the same
1273
1287
  factory with equivalent options; the runtime does not diff options, and
@@ -1278,13 +1292,22 @@ make before calling `restore`.
1278
1292
  sequence counters (using the persisted global `trace` counter as a
1279
1293
  collision-safe floor for an absent legacy `captainCall`), and the
1280
1294
  prior-state descriptor from the snapshot,
1281
- construct the actor with the persisted `machine` snapshot, and start it
1295
+ prepare the shared nested bridge with the suspended-call descriptor or its
1296
+ explicit absence, restore a descriptor's call-to-turn map entry, construct
1297
+ the actor with the persisted `machine` snapshot, and start it
1282
1298
  with root inspection emissions suppressed so rehydration emits no
1283
1299
  `session.started` trace, no transition trace, and no human status — the
1284
1300
  session already started, and the next public boundary continues the
1285
- contiguous trace sequence. After start, a restored actor whose status is
1286
- not `active` or whose state descriptor cannot be normalized shall fail
1287
- `restore` through the same failed-start cleanup path as `init`.
1301
+ contiguous trace sequence. After start, the runtime shall normalize the
1302
+ actual actor state with the prepared suspended call as its pending identity
1303
+ and require it to equal the detached persisted state exactly, including
1304
+ active status. It shall drain suppressed startup work and invoke the bridge's
1305
+ `confirmRestore` as the final fallible restore step, publishing the pending
1306
+ identity only after every other validation succeeds. A missing, extra, or
1307
+ mismatched reconstructed invocation, an actual/persisted state mismatch, or
1308
+ any other failed validation shall fail `restore` through the same
1309
+ failed-start cleanup path as `init`, rolling back provisional bridge and turn
1310
+ ownership without a child-host call or duplicate start/finish boundary.
1288
1311
  A restore failure shall leave the runtime unbound so `dispose` remains
1289
1312
  callable and terminal.
1290
1313
 
@@ -1440,7 +1463,7 @@ runtime's emission-failure channel to surface from the next public boundary
1440
1463
  that drains.
1441
1464
 
1442
1465
  The recorded receipts and the recorded last classified event are
1443
- process-local: the schema-1 parked-session snapshot persists neither, and a
1466
+ process-local: the durable runtime snapshot persists neither, and a
1444
1467
  restored runtime advertises a retry again only after its next classified
1445
1468
  event.
1446
1469
 
package/src/runtime.d.ts CHANGED
@@ -47,6 +47,11 @@ export interface PlaybookPendingCall {
47
47
  playbookId: string;
48
48
  childSessionId: string;
49
49
  }
50
+ export interface PlaybookSuspendedCall extends PlaybookPendingCall {
51
+ stateId: string;
52
+ text: string;
53
+ turnId?: number;
54
+ }
50
55
  export interface PlaybookCallRequest {
51
56
  callId: string;
52
57
  playbookId: string;
@@ -137,8 +142,7 @@ export interface PlaybookPendingBossQuestion {
137
142
  question: string;
138
143
  sourceItem?: string;
139
144
  }
140
- export interface PlaybookRuntimeSnapshot {
141
- schemaVersion: 1;
145
+ interface PlaybookRuntimeSnapshotFields {
142
146
  playbookId: string;
143
147
  machine: JsonValue;
144
148
  playerResumeTokens: {
@@ -155,6 +159,13 @@ export interface PlaybookRuntimeSnapshot {
155
159
  state: PlaybookState;
156
160
  pendingBossQuestions: readonly PlaybookPendingBossQuestion[];
157
161
  }
162
+ export type PlaybookRuntimeSnapshot = PlaybookRuntimeSnapshotFields & ({
163
+ schemaVersion: 1;
164
+ suspendedCall?: never;
165
+ } | {
166
+ schemaVersion: 2;
167
+ suspendedCall?: PlaybookSuspendedCall;
168
+ });
158
169
  export interface PlaybookControlAction {
159
170
  id: string;
160
171
  label: string;
@@ -199,3 +210,4 @@ export interface PlaybookRuntime {
199
210
  dispose(): Promise<void>;
200
211
  }
201
212
  export type PlaybookRuntimeFactory<Options = unknown> = (options: Options) => PlaybookRuntime;
213
+ export {};
package/src/runtime.ts CHANGED
@@ -75,6 +75,15 @@ export interface PlaybookPendingCall {
75
75
  childSessionId: string;
76
76
  }
77
77
 
78
+ // DR-031 §5: complete durable identity for one nested call whose start
79
+ // boundary has already been published and whose child remains suspended.
80
+ // `turnId` is absent when the call was opened outside a Boss-turn boundary.
81
+ export interface PlaybookSuspendedCall extends PlaybookPendingCall {
82
+ stateId: string;
83
+ text: string;
84
+ turnId?: number;
85
+ }
86
+
78
87
  export interface PlaybookCallRequest {
79
88
  callId: string;
80
89
  playbookId: string;
@@ -199,12 +208,11 @@ export interface PlaybookPendingBossQuestion {
199
208
  sourceItem?: string;
200
209
  }
201
210
 
202
- // DR-014 §1: JSON-safe capture of a parked session. `machine` is the
203
- // XState persisted snapshot and is opaque to hosts; the pending Boss
204
- // questions are first-class so a host can surface what was asked
205
- // without parsing status lines or telemetry.
206
- export interface PlaybookRuntimeSnapshot {
207
- schemaVersion: 1;
211
+ // DR-014 §1 / DR-031 §5: JSON-safe capture of a parked or nested-call
212
+ // suspended session. `machine` is the opaque XState persisted snapshot;
213
+ // pending Boss questions and a schema-2 suspended call are first-class so a
214
+ // host never has to reconstruct durable ownership from presentation records.
215
+ interface PlaybookRuntimeSnapshotFields {
208
216
  playbookId: string;
209
217
  machine: JsonValue;
210
218
  playerResumeTokens: { readonly [playerId: string]: string };
@@ -220,6 +228,18 @@ export interface PlaybookRuntimeSnapshot {
220
228
  pendingBossQuestions: readonly PlaybookPendingBossQuestion[];
221
229
  }
222
230
 
231
+ export type PlaybookRuntimeSnapshot = PlaybookRuntimeSnapshotFields &
232
+ (
233
+ | {
234
+ schemaVersion: 1;
235
+ suspendedCall?: never;
236
+ }
237
+ | {
238
+ schemaVersion: 2;
239
+ suspendedCall?: PlaybookSuspendedCall;
240
+ }
241
+ );
242
+
223
243
  // DR-029: one currently valid, runtime-advertised control action. The id
224
244
  // is stable within the returned view; the label is runtime-written,
225
245
  // Boss-appropriate text derived from source state descriptions.