@sublang/playbook 12.2.2 → 13.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 (38) hide show
  1. package/docs/cli.md +56 -52
  2. package/docs/configuration.md +2 -2
  3. package/docs/embedding.md +68 -32
  4. package/package.json +11 -3
  5. package/reference/sdlc/code.playbook/bin/interactive-session.js +1 -0
  6. package/reference/sdlc/code.playbook/bin/launch-config.js +13 -7
  7. package/reference/sdlc/code.playbook/bin/playbook.js +42 -4
  8. package/reference/sdlc/code.playbook/bin/portable-codec.js +190 -0
  9. package/reference/sdlc/code.playbook/bin/replay-observer.js +18 -2
  10. package/reference/sdlc/code.playbook/bin/run.js +62 -18
  11. package/reference/sdlc/code.playbook/bin/session-host.js +104 -0
  12. package/reference/sdlc/code.playbook/bin/session-store.js +638 -70
  13. package/reference/sdlc/code.playbook/code.fsm.js +43 -5
  14. package/reference/sdlc/code.playbook/code.fsm.ts +66 -9
  15. package/reference/sdlc/code.playbook/playbook-captain.d.ts +5 -0
  16. package/reference/sdlc/code.playbook/playbook-captain.js +75 -19
  17. package/reference/sdlc/code.playbook/playbook-captain.ts +93 -26
  18. package/reference/sdlc/code.playbook/session-host.d.ts +75 -0
  19. package/reference/sdlc/code.playbook/session-host.js +18 -0
  20. package/reference/sdlc/code.playbook/session-store.d.ts +176 -1
  21. package/reference/sdlc/code.playbook/session-store.js +22 -6
  22. package/reference/sdlc/decide.playbook/decide.fsm.js +4 -0
  23. package/reference/sdlc/decide.playbook/decide.fsm.ts +4 -0
  24. package/reference/sdlc/decide.playbook/decide.playbook.js +23 -5
  25. package/reference/sdlc/decide.playbook/decide.playbook.ts +25 -4
  26. package/reference/sdlc/dev.playbook/dev.fsm.js +57 -7
  27. package/reference/sdlc/dev.playbook/dev.fsm.ts +80 -11
  28. package/reference/sdlc/review.playbook/review.fsm.js +11 -1
  29. package/reference/sdlc/review.playbook/review.fsm.ts +15 -1
  30. package/slc/gears2fsm.md +21 -3
  31. package/slc/link.md +21 -2
  32. package/src/runtime.d.ts +7 -0
  33. package/src/runtime.ts +12 -0
  34. package/src/xstate-playbook-runtime.d.ts +13 -0
  35. package/src/xstate-playbook-runtime.js +75 -13
  36. package/src/xstate-playbook-runtime.ts +87 -21
  37. package/src/xstate-runtime.js +35 -4
  38. package/src/xstate-runtime.ts +49 -4
@@ -1073,6 +1073,45 @@ export function stateDescriptionsFromMachine(machine) {
1073
1073
  }
1074
1074
  return descriptions;
1075
1075
  }
1076
+ /**
1077
+ * DR-048: each root final state's declared terminal kind, read from
1078
+ * `meta.playbook.terminal` in `machine.config`. The kind is compiled
1079
+ * metadata — the compiler derives it from the Source's own outcome wording,
1080
+ * exactly as it derives the state's description — so a caller learns whether
1081
+ * a completed child succeeded from the machine it reached, never from the
1082
+ * child's output fields or an agent's prose.
1083
+ *
1084
+ * A machine whose final states declare no kind yields an empty map and keeps
1085
+ * the pre-DR-048 delivery. A `terminal` on a non-final state, or a value
1086
+ * other than `success` or `failure`, is a malformed artifact and throws.
1087
+ */
1088
+ export function terminalOutcomesFromMachine(machine, label = 'playbook') {
1089
+ const kinds = new Map();
1090
+ const config = machine.config;
1091
+ if (!isPlainObject(config) || !isPlainObject(config.states))
1092
+ return kinds;
1093
+ for (const [key, stateDef] of Object.entries(config.states)) {
1094
+ if (!isPlainObject(stateDef))
1095
+ continue;
1096
+ const playbook = isPlainObject(stateDef.meta)
1097
+ ? stateDef.meta.playbook
1098
+ : undefined;
1099
+ const declared = isPlainObject(playbook) ? playbook.terminal : undefined;
1100
+ if (declared === undefined)
1101
+ continue;
1102
+ if (declared !== 'success' && declared !== 'failure') {
1103
+ throw new TypeError(`${label} state ${key} declares meta.playbook.terminal ` +
1104
+ `${JSON.stringify(declared)}; only 'success' or 'failure' is a ` +
1105
+ 'terminal kind');
1106
+ }
1107
+ if (stateDef.type !== 'final') {
1108
+ throw new TypeError(`${label} state ${key} declares meta.playbook.terminal but is not ` +
1109
+ 'a final state');
1110
+ }
1111
+ kinds.set(key, declared);
1112
+ }
1113
+ return kinds;
1114
+ }
1076
1115
  /**
1077
1116
  * First configured target of `eventType` from the state with `stateId`,
1078
1117
  * falling back to the machine root's own transitions. Used only to pick the
@@ -1834,6 +1873,10 @@ export function createXStatePlaybookRuntime(machine, spec) {
1834
1873
  // DR-029: source state descriptions label the control actions the
1835
1874
  // runtime advertises through `describe()`.
1836
1875
  const stateDescriptions = stateDescriptionsFromMachine(machine);
1876
+ // DR-048: a malformed terminal declaration is a control-plane defect of the
1877
+ // artifact, so it fails construction rather than at the one run that
1878
+ // happens to reach that final state.
1879
+ const terminalKinds = terminalOutcomesFromMachine(machine, label);
1837
1880
  const roleStatesDescriptor = specDescriptors.roleStates;
1838
1881
  if (roleStatesDescriptor !== undefined &&
1839
1882
  !Object.prototype.hasOwnProperty.call(roleStatesDescriptor, 'value')) {
@@ -2804,7 +2847,7 @@ export function createXStatePlaybookRuntime(machine, spec) {
2804
2847
  };
2805
2848
  }
2806
2849
  function detachedPlayerContinuation(roleId, playerId) {
2807
- return snapshotJsonValue(selectPlayerResume(roleId, playerId), `${label} deferred player continuation`);
2850
+ return snapshotJsonValue({ v: 1, playerId: playerId ?? roleId }, `${label} deferred player continuation`);
2808
2851
  }
2809
2852
  function completionEvidenceFor(input, roleId, playerId, signal, operationId) {
2810
2853
  return async (completion) => {
@@ -4275,10 +4318,18 @@ export function createXStatePlaybookRuntime(machine, spec) {
4275
4318
  const stateDescription = !hasUnresolvedReconciliation()
4276
4319
  ? stateDescriptionFor(state)
4277
4320
  : undefined;
4321
+ // DR-048: the reached final state's compiled terminal meaning, read
4322
+ // from the artifact. It is withheld exactly when the published
4323
+ // description is, so an unresolved reconciliation publishes no
4324
+ // terminal meaning at all.
4325
+ const terminal = hasUnresolvedReconciliation() || state.stateId === undefined
4326
+ ? undefined
4327
+ : terminalOutcomeFor(state.stateId, stateDescription);
4278
4328
  return {
4279
4329
  outcome,
4280
4330
  state,
4281
4331
  ...(stateDescription === undefined ? {} : { stateDescription }),
4332
+ ...(terminal === undefined ? {} : { terminal }),
4282
4333
  ...(output === undefined
4283
4334
  ? {}
4284
4335
  : {
@@ -4629,6 +4680,18 @@ export function createXStatePlaybookRuntime(machine, spec) {
4629
4680
  }
4630
4681
  return undefined;
4631
4682
  }
4683
+ // DR-048: the public terminal record for a reached final state, present
4684
+ // only for an artifact that declares that state's kind.
4685
+ function terminalOutcomeFor(stateId, description) {
4686
+ const kind = terminalKinds.get(stateId);
4687
+ if (kind === undefined)
4688
+ return undefined;
4689
+ return {
4690
+ stateId,
4691
+ kind,
4692
+ ...(description === undefined ? {} : { description }),
4693
+ };
4694
+ }
4632
4695
  function receiptTracePayload(receipt) {
4633
4696
  return {
4634
4697
  disposition: receipt.disposition,
@@ -4916,6 +4979,15 @@ export function createXStatePlaybookRuntime(machine, spec) {
4916
4979
  throw new Error(`${label} deferred continuation requires repository.runDeferred`);
4917
4980
  }
4918
4981
  const effectBoundary = continuationBoundarySeed(operation, turnId);
4982
+ const boundPlayerId = resolvedPlayerId(effectBoundary.roleId);
4983
+ const validateBinding = (value) => {
4984
+ if (!isPlainObject(value) || Object.keys(value).length !== 2 ||
4985
+ value.v !== 1 ||
4986
+ value.playerId !== (boundPlayerId ?? effectBoundary.roleId)) {
4987
+ throw new TypeError(`${label} bound deferred player continuation is invalid`);
4988
+ }
4989
+ };
4990
+ validateBinding(operation.playerContinuation);
4919
4991
  const continuation = {
4920
4992
  operationId: operation.operationId,
4921
4993
  effectBoundary,
@@ -4941,18 +5013,8 @@ export function createXStatePlaybookRuntime(machine, spec) {
4941
5013
  operationId: operation.operationId,
4942
5014
  effectBoundary,
4943
5015
  operation: async ({ playerContinuation }) => {
4944
- const selectedContinuation = retainedEffectSourceSessionId === undefined
4945
- ? playerContinuation
4946
- : selectPlayerResume(effectBoundary.roleId, resolvedPlayerId(effectBoundary.roleId));
4947
- if (selectedContinuation !== false &&
4948
- (typeof selectedContinuation !== 'string' ||
4949
- selectedContinuation.trim().length === 0)) {
4950
- throw new TypeError(`${label} bound deferred player continuation is invalid`);
4951
- }
4952
- // Retained adoption owns a fresh Captain-session player ledger;
4953
- // no source token becomes target ownership. Same-engagement
4954
- // continuation still uses the exact durable binding.
4955
- continuation.playerContinuation = selectedContinuation;
5016
+ validateBinding(playerContinuation);
5017
+ continuation.playerContinuation = selectPlayerResume(effectBoundary.roleId, boundPlayerId);
4956
5018
  continuationStarted = true;
4957
5019
  actor.send(event);
4958
5020
  // The host has durably started this boundary and the FSM has
@@ -71,6 +71,7 @@ import type {
71
71
  PlaybookSession,
72
72
  PlaybookState,
73
73
  PlaybookSuspendedCall,
74
+ PlaybookTerminalOutcome,
74
75
  PlaybookTraceEvent,
75
76
  PlaybookTraceType,
76
77
  PlayerResult,
@@ -1980,6 +1981,50 @@ export function stateDescriptionsFromMachine(
1980
1981
  return descriptions;
1981
1982
  }
1982
1983
 
1984
+ /**
1985
+ * DR-048: each root final state's declared terminal kind, read from
1986
+ * `meta.playbook.terminal` in `machine.config`. The kind is compiled
1987
+ * metadata — the compiler derives it from the Source's own outcome wording,
1988
+ * exactly as it derives the state's description — so a caller learns whether
1989
+ * a completed child succeeded from the machine it reached, never from the
1990
+ * child's output fields or an agent's prose.
1991
+ *
1992
+ * A machine whose final states declare no kind yields an empty map and keeps
1993
+ * the pre-DR-048 delivery. A `terminal` on a non-final state, or a value
1994
+ * other than `success` or `failure`, is a malformed artifact and throws.
1995
+ */
1996
+ export function terminalOutcomesFromMachine(
1997
+ machine: AnyStateMachine,
1998
+ label = 'playbook',
1999
+ ): ReadonlyMap<string, 'success' | 'failure'> {
2000
+ const kinds = new Map<string, 'success' | 'failure'>();
2001
+ const config = (machine as unknown as { config?: unknown }).config;
2002
+ if (!isPlainObject(config) || !isPlainObject(config.states)) return kinds;
2003
+ for (const [key, stateDef] of Object.entries(config.states)) {
2004
+ if (!isPlainObject(stateDef)) continue;
2005
+ const playbook = isPlainObject(stateDef.meta)
2006
+ ? (stateDef.meta as Record<string, unknown>).playbook
2007
+ : undefined;
2008
+ const declared = isPlainObject(playbook) ? playbook.terminal : undefined;
2009
+ if (declared === undefined) continue;
2010
+ if (declared !== 'success' && declared !== 'failure') {
2011
+ throw new TypeError(
2012
+ `${label} state ${key} declares meta.playbook.terminal ` +
2013
+ `${JSON.stringify(declared)}; only 'success' or 'failure' is a ` +
2014
+ 'terminal kind',
2015
+ );
2016
+ }
2017
+ if (stateDef.type !== 'final') {
2018
+ throw new TypeError(
2019
+ `${label} state ${key} declares meta.playbook.terminal but is not ` +
2020
+ 'a final state',
2021
+ );
2022
+ }
2023
+ kinds.set(key, declared);
2024
+ }
2025
+ return kinds;
2026
+ }
2027
+
1983
2028
  /**
1984
2029
  * First configured target of `eventType` from the state with `stateId`,
1985
2030
  * falling back to the machine root's own transitions. Used only to pick the
@@ -3075,6 +3120,10 @@ export function createXStatePlaybookRuntime<
3075
3120
  // DR-029: source state descriptions label the control actions the
3076
3121
  // runtime advertises through `describe()`.
3077
3122
  const stateDescriptions = stateDescriptionsFromMachine(machine);
3123
+ // DR-048: a malformed terminal declaration is a control-plane defect of the
3124
+ // artifact, so it fails construction rather than at the one run that
3125
+ // happens to reach that final state.
3126
+ const terminalKinds = terminalOutcomesFromMachine(machine, label);
3078
3127
  const roleStatesDescriptor = specDescriptors.roleStates;
3079
3128
  if (
3080
3129
  roleStatesDescriptor !== undefined &&
@@ -4474,7 +4523,7 @@ export function createXStatePlaybookRuntime<
4474
4523
  playerId: string | undefined,
4475
4524
  ): JsonValue {
4476
4525
  return snapshotJsonValue(
4477
- selectPlayerResume(roleId, playerId),
4526
+ { v: 1, playerId: playerId ?? roleId },
4478
4527
  `${label} deferred player continuation`,
4479
4528
  );
4480
4529
  }
@@ -6477,10 +6526,19 @@ export function createXStatePlaybookRuntime<
6477
6526
  !hasUnresolvedReconciliation()
6478
6527
  ? stateDescriptionFor(state)
6479
6528
  : undefined;
6529
+ // DR-048: the reached final state's compiled terminal meaning, read
6530
+ // from the artifact. It is withheld exactly when the published
6531
+ // description is, so an unresolved reconciliation publishes no
6532
+ // terminal meaning at all.
6533
+ const terminal =
6534
+ hasUnresolvedReconciliation() || state.stateId === undefined
6535
+ ? undefined
6536
+ : terminalOutcomeFor(state.stateId, stateDescription);
6480
6537
  return {
6481
6538
  outcome,
6482
6539
  state,
6483
6540
  ...(stateDescription === undefined ? {} : { stateDescription }),
6541
+ ...(terminal === undefined ? {} : { terminal }),
6484
6542
  ...(output === undefined
6485
6543
  ? {}
6486
6544
  : {
@@ -6867,6 +6925,21 @@ export function createXStatePlaybookRuntime<
6867
6925
  return undefined;
6868
6926
  }
6869
6927
 
6928
+ // DR-048: the public terminal record for a reached final state, present
6929
+ // only for an artifact that declares that state's kind.
6930
+ function terminalOutcomeFor(
6931
+ stateId: string,
6932
+ description: string | undefined,
6933
+ ): PlaybookTerminalOutcome | undefined {
6934
+ const kind = terminalKinds.get(stateId);
6935
+ if (kind === undefined) return undefined;
6936
+ return {
6937
+ stateId,
6938
+ kind,
6939
+ ...(description === undefined ? {} : { description }),
6940
+ };
6941
+ }
6942
+
6870
6943
  function receiptTracePayload(
6871
6944
  receipt: PlaybookControlReceipt,
6872
6945
  ): Record<string, unknown> {
@@ -7251,6 +7324,15 @@ export function createXStatePlaybookRuntime<
7251
7324
  );
7252
7325
  }
7253
7326
  const effectBoundary = continuationBoundarySeed(operation, turnId);
7327
+ const boundPlayerId = resolvedPlayerId(effectBoundary.roleId);
7328
+ const validateBinding = (value: unknown): void => {
7329
+ if (!isPlainObject(value) || Object.keys(value).length !== 2 ||
7330
+ value.v !== 1 ||
7331
+ value.playerId !== (boundPlayerId ?? effectBoundary.roleId)) {
7332
+ throw new TypeError(`${label} bound deferred player continuation is invalid`);
7333
+ }
7334
+ };
7335
+ validateBinding(operation.playerContinuation);
7254
7336
  const continuation: NonNullable<typeof activeDeferredContinuation> = {
7255
7337
  operationId: operation.operationId,
7256
7338
  effectBoundary,
@@ -7276,26 +7358,10 @@ export function createXStatePlaybookRuntime<
7276
7358
  operationId: operation.operationId,
7277
7359
  effectBoundary,
7278
7360
  operation: async ({ playerContinuation }) => {
7279
- const selectedContinuation =
7280
- retainedEffectSourceSessionId === undefined
7281
- ? playerContinuation
7282
- : selectPlayerResume(
7283
- effectBoundary.roleId,
7284
- resolvedPlayerId(effectBoundary.roleId),
7285
- );
7286
- if (
7287
- selectedContinuation !== false &&
7288
- (typeof selectedContinuation !== 'string' ||
7289
- selectedContinuation.trim().length === 0)
7290
- ) {
7291
- throw new TypeError(
7292
- `${label} bound deferred player continuation is invalid`,
7293
- );
7294
- }
7295
- // Retained adoption owns a fresh Captain-session player ledger;
7296
- // no source token becomes target ownership. Same-engagement
7297
- // continuation still uses the exact durable binding.
7298
- continuation.playerContinuation = selectedContinuation;
7361
+ validateBinding(playerContinuation);
7362
+ continuation.playerContinuation = selectPlayerResume(
7363
+ effectBoundary.roleId, boundPlayerId,
7364
+ );
7299
7365
  continuationStarted = true;
7300
7366
  actor!.send(event);
7301
7367
  // The host has durably started this boundary and the FSM has
@@ -1617,7 +1617,13 @@ export function assertPlaybookRuntimeSnapshot(value, expectedPlaybookId, options
1617
1617
  export class NestedPlaybookCallError extends Error {
1618
1618
  result;
1619
1619
  constructor(result) {
1620
- const fallback = `Child playbook ${result.playbookId} ${result.status}`;
1620
+ // DR-048: a completed child that reached an authored failure terminal is
1621
+ // rejected through the same error path as an abort or an error, so its
1622
+ // message names that final state rather than reporting `ok`.
1623
+ const fallback = result.status === 'ok'
1624
+ ? `Child playbook ${result.playbookId} reached failure terminal ` +
1625
+ `${result.terminal?.stateId ?? 'unknown'}`
1626
+ : `Child playbook ${result.playbookId} ${result.status}`;
1621
1627
  const normalized = result.status === 'ok' ? undefined : result.error;
1622
1628
  super(normalized?.message ?? fallback);
1623
1629
  this.name = normalized?.name ?? 'NestedPlaybookCallError';
@@ -1723,6 +1729,26 @@ function validateNormalizedError(error, path) {
1723
1729
  throw new TypeError(`${path}.stack must be a string`);
1724
1730
  }
1725
1731
  }
1732
+ // DR-048: the completed child's compiled terminal record. It is runtime-owned
1733
+ // data read from the child's artifact, so a malformed one is a control-plane
1734
+ // error rather than an authored child outcome.
1735
+ function validateTerminalOutcome(value) {
1736
+ if (!isRecord(value)) {
1737
+ throw new TypeError('playbook result terminal must be an object');
1738
+ }
1739
+ rejectUnknownKeys(value, ['stateId', 'kind', 'description'], 'playbook result terminal');
1740
+ requireNonEmptyString(value.stateId, 'playbook result terminal stateId');
1741
+ if (value.kind !== 'success' && value.kind !== 'failure') {
1742
+ throw new TypeError("playbook result terminal kind must be 'success' or 'failure'");
1743
+ }
1744
+ if (own(value, 'description') && typeof value.description !== 'string') {
1745
+ throw new TypeError('playbook result terminal description must be a string');
1746
+ }
1747
+ }
1748
+ /** DR-048: a completed child that reached an authored failure terminal. */
1749
+ function isFailureTerminal(result) {
1750
+ return result.status === 'ok' && result.terminal?.kind === 'failure';
1751
+ }
1726
1752
  export function validatePlaybookCallResult(result, expectedPlaybookId, expectedChildSessionId) {
1727
1753
  const capturedResult = snapshotJsonValue(result, 'playbook result');
1728
1754
  if (!isRecord(capturedResult)) {
@@ -1737,8 +1763,11 @@ export function validatePlaybookCallResult(result, expectedPlaybookId, expectedC
1737
1763
  throw new PlaybookCallIdentityError(`playbook result target ${String(capturedResult.playbookId)} does not match ${expectedPlaybookId}`);
1738
1764
  }
1739
1765
  if (capturedResult.status === 'ok') {
1740
- rejectUnknownKeys(capturedResult, ['status', 'playbookId', 'childSessionId', 'state', 'output'], 'playbook result');
1766
+ rejectUnknownKeys(capturedResult, ['status', 'playbookId', 'childSessionId', 'state', 'output', 'terminal'], 'playbook result');
1741
1767
  requireNonEmptyString(capturedResult.childSessionId, 'playbook result childSessionId');
1768
+ if (own(capturedResult, 'terminal')) {
1769
+ validateTerminalOutcome(capturedResult.terminal);
1770
+ }
1742
1771
  }
1743
1772
  else {
1744
1773
  rejectUnknownKeys(capturedResult, ['status', 'playbookId', 'childSessionId', 'state', 'error'], 'playbook result');
@@ -1832,8 +1861,9 @@ function resultFromThrown(playbookId, childSessionId, error, aborted) {
1832
1861
  return snapshotJsonValue(result, 'playbook result');
1833
1862
  }
1834
1863
  function outputOrThrow(result) {
1835
- if (result.status === 'ok')
1864
+ if (result.status === 'ok' && !isFailureTerminal(result)) {
1836
1865
  return result.output;
1866
+ }
1837
1867
  throw new NestedPlaybookCallError(result);
1838
1868
  }
1839
1869
  export function createNestedPlaybookBridge(options) {
@@ -2014,7 +2044,8 @@ export function createNestedPlaybookBridge(options) {
2014
2044
  else if (cleanupControlError !== undefined) {
2015
2045
  active.deferred.reject(cleanupControlError);
2016
2046
  }
2017
- else if (effectiveResult.status === 'ok') {
2047
+ else if (effectiveResult.status === 'ok' &&
2048
+ !isFailureTerminal(effectiveResult)) {
2018
2049
  active.deferred.resolve(effectiveResult.output);
2019
2050
  }
2020
2051
  else {
@@ -2596,7 +2596,14 @@ export class NestedPlaybookCallError extends Error {
2596
2596
  readonly result: PlaybookCallResult;
2597
2597
 
2598
2598
  constructor(result: PlaybookCallResult) {
2599
- const fallback = `Child playbook ${result.playbookId} ${result.status}`;
2599
+ // DR-048: a completed child that reached an authored failure terminal is
2600
+ // rejected through the same error path as an abort or an error, so its
2601
+ // message names that final state rather than reporting `ok`.
2602
+ const fallback =
2603
+ result.status === 'ok'
2604
+ ? `Child playbook ${result.playbookId} reached failure terminal ` +
2605
+ `${result.terminal?.stateId ?? 'unknown'}`
2606
+ : `Child playbook ${result.playbookId} ${result.status}`;
2600
2607
  const normalized = result.status === 'ok' ? undefined : result.error;
2601
2608
  super(normalized?.message ?? fallback);
2602
2609
  this.name = normalized?.name ?? 'NestedPlaybookCallError';
@@ -2794,6 +2801,36 @@ function validateNormalizedError(error: unknown, path: string): void {
2794
2801
  }
2795
2802
  }
2796
2803
 
2804
+ // DR-048: the completed child's compiled terminal record. It is runtime-owned
2805
+ // data read from the child's artifact, so a malformed one is a control-plane
2806
+ // error rather than an authored child outcome.
2807
+ function validateTerminalOutcome(value: unknown): void {
2808
+ if (!isRecord(value)) {
2809
+ throw new TypeError('playbook result terminal must be an object');
2810
+ }
2811
+ rejectUnknownKeys(
2812
+ value,
2813
+ ['stateId', 'kind', 'description'],
2814
+ 'playbook result terminal',
2815
+ );
2816
+ requireNonEmptyString(value.stateId, 'playbook result terminal stateId');
2817
+ if (value.kind !== 'success' && value.kind !== 'failure') {
2818
+ throw new TypeError(
2819
+ "playbook result terminal kind must be 'success' or 'failure'",
2820
+ );
2821
+ }
2822
+ if (own(value, 'description') && typeof value.description !== 'string') {
2823
+ throw new TypeError(
2824
+ 'playbook result terminal description must be a string',
2825
+ );
2826
+ }
2827
+ }
2828
+
2829
+ /** DR-048: a completed child that reached an authored failure terminal. */
2830
+ function isFailureTerminal(result: PlaybookCallResult): boolean {
2831
+ return result.status === 'ok' && result.terminal?.kind === 'failure';
2832
+ }
2833
+
2797
2834
  export function validatePlaybookCallResult(
2798
2835
  result: unknown,
2799
2836
  expectedPlaybookId: string,
@@ -2818,13 +2855,16 @@ export function validatePlaybookCallResult(
2818
2855
  if (capturedResult.status === 'ok') {
2819
2856
  rejectUnknownKeys(
2820
2857
  capturedResult,
2821
- ['status', 'playbookId', 'childSessionId', 'state', 'output'],
2858
+ ['status', 'playbookId', 'childSessionId', 'state', 'output', 'terminal'],
2822
2859
  'playbook result',
2823
2860
  );
2824
2861
  requireNonEmptyString(
2825
2862
  capturedResult.childSessionId,
2826
2863
  'playbook result childSessionId',
2827
2864
  );
2865
+ if (own(capturedResult, 'terminal')) {
2866
+ validateTerminalOutcome(capturedResult.terminal);
2867
+ }
2828
2868
  } else {
2829
2869
  rejectUnknownKeys(
2830
2870
  capturedResult,
@@ -2973,7 +3013,9 @@ function resultFromThrown(
2973
3013
  }
2974
3014
 
2975
3015
  function outputOrThrow(result: PlaybookCallResult): JsonValue | undefined {
2976
- if (result.status === 'ok') return result.output;
3016
+ if (result.status === 'ok' && !isFailureTerminal(result)) {
3017
+ return result.output;
3018
+ }
2977
3019
  throw new NestedPlaybookCallError(result);
2978
3020
  }
2979
3021
 
@@ -3206,7 +3248,10 @@ export function createNestedPlaybookBridge<
3206
3248
  active.deferred.reject(controlError);
3207
3249
  } else if (cleanupControlError !== undefined) {
3208
3250
  active.deferred.reject(cleanupControlError);
3209
- } else if (effectiveResult.status === 'ok') {
3251
+ } else if (
3252
+ effectiveResult.status === 'ok' &&
3253
+ !isFailureTerminal(effectiveResult)
3254
+ ) {
3210
3255
  active.deferred.resolve(effectiveResult.output);
3211
3256
  } else {
3212
3257
  active.deferred.reject(new NestedPlaybookCallError(effectiveResult));