@sublang/playbook 14.0.0 → 15.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 (51) hide show
  1. package/README.md +1 -1
  2. package/docs/cli.md +14 -14
  3. package/docs/configuration.md +15 -9
  4. package/package.json +1 -1
  5. package/reference/sdlc/branch.playbook/branch.fsm.d.ts +1 -1
  6. package/reference/sdlc/code.md +1 -1
  7. package/reference/sdlc/code.playbook/bin/launch-config.js +55 -16
  8. package/reference/sdlc/code.playbook/bin/playbook.js +14 -9
  9. package/reference/sdlc/code.playbook/bin/repository-effects.js +187 -14
  10. package/reference/sdlc/code.playbook/bin/run.js +12 -8
  11. package/reference/sdlc/code.playbook/code.fsm.d.ts +1 -1
  12. package/reference/sdlc/code.playbook/code.fsm.js +1 -1
  13. package/reference/sdlc/code.playbook/code.fsm.ts +1 -1
  14. package/reference/sdlc/code.playbook/code.gears.md +2 -2
  15. package/reference/sdlc/code.playbook/host-capabilities.d.ts +6 -0
  16. package/reference/sdlc/code.playbook/playbook-captain.d.ts +3 -1
  17. package/reference/sdlc/code.playbook/playbook-captain.js +357 -16
  18. package/reference/sdlc/code.playbook/playbook-captain.ts +459 -34
  19. package/reference/sdlc/code.playbook/session-host.d.ts +6 -3
  20. package/reference/sdlc/decide.md +1 -1
  21. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +1 -1
  22. package/reference/sdlc/decide.playbook/decide.fsm.js +1 -1
  23. package/reference/sdlc/decide.playbook/decide.fsm.ts +1 -1
  24. package/reference/sdlc/decide.playbook/decide.gears.md +1 -1
  25. package/reference/sdlc/decide.playbook/decide.playbook.js +25 -1
  26. package/reference/sdlc/decide.playbook/decide.playbook.ts +35 -2
  27. package/reference/sdlc/dev.md +15 -8
  28. package/reference/sdlc/dev.playbook/dev.fsm.d.ts +1 -1
  29. package/reference/sdlc/dev.playbook/dev.fsm.js +18 -12
  30. package/reference/sdlc/dev.playbook/dev.fsm.ts +18 -13
  31. package/reference/sdlc/dev.playbook/dev.gears.md +19 -12
  32. package/reference/sdlc/dev.playbook/dev.playbook.js +1 -1
  33. package/reference/sdlc/dev.playbook/dev.playbook.ts +1 -2
  34. package/reference/sdlc/dev.playbook/dev.registry.js +1 -1
  35. package/reference/sdlc/dev.playbook/dev.registry.ts +1 -1
  36. package/reference/sdlc/pr.playbook/pr.fsm.d.ts +1 -1
  37. package/reference/sdlc/review.md +1 -1
  38. package/reference/sdlc/review.playbook/review.fsm.d.ts +1 -1
  39. package/reference/sdlc/review.playbook/review.fsm.js +1 -1
  40. package/reference/sdlc/review.playbook/review.fsm.ts +1 -1
  41. package/reference/sdlc/review.playbook/review.gears.md +1 -1
  42. package/slc/link.md +32 -0
  43. package/src/runtime.d.ts +70 -0
  44. package/src/runtime.js +232 -1
  45. package/src/runtime.ts +358 -1
  46. package/src/xstate-playbook-runtime.d.ts +9 -10
  47. package/src/xstate-playbook-runtime.js +372 -20
  48. package/src/xstate-playbook-runtime.ts +460 -19
  49. package/src/xstate-runtime.d.ts +9 -1
  50. package/src/xstate-runtime.js +299 -19
  51. package/src/xstate-runtime.ts +362 -21
@@ -1,5 +1,5 @@
1
1
  import { type AnyActorRef, type PromiseActorLogic, type SnapshotFrom } from 'xstate';
2
- import type { CaptainResult, JsonValue, NormalizedError, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookPendingCall, PlaybookEffectLedger, PlaybookRepositoryDisposition, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlaybookSuspendedCall, PlayerResult } from './runtime.js';
2
+ import type { CaptainResult, JsonValue, NormalizedError, PlaybookFailureCause, PlaybookCallRequest, PlaybookCallResult, PlaybookCallStart, PlaybookPendingCall, PlaybookEffectLedger, PlaybookRepositoryDisposition, PlaybookRuntimeSnapshot, PlaybookSession, PlaybookState, PlaybookSuspendedCall, PlayerResult } from './runtime.js';
3
3
  export * from './xstate-playbook-runtime.js';
4
4
  /**
5
5
  * Immutable cancellation provenance for one runtime operation. The captured
@@ -27,6 +27,12 @@ export declare function snapshotJsonValue(value: unknown, path?: string): JsonVa
27
27
  /** Validate session causality and detach its immutable identity from the host. */
28
28
  export declare function snapshotPlaybookSession(session: PlaybookSession): PlaybookSession;
29
29
  export declare function hiddenControlEnvelope(prompt: string): string;
30
+ /**
31
+ * Attach one validated failure cause to an error object, returning it. A cause
32
+ * the closed validator rejects is dropped rather than published, and an error
33
+ * that refuses the property keeps its own cause-free identity (DR-063 §1).
34
+ */
35
+ export declare function attachPlaybookFailureCause<E>(error: E, cause: unknown): E;
30
36
  export declare function normalizeError(error: unknown): NormalizedError;
31
37
  export interface PlaybookStateMetadata {
32
38
  stateId: string;
@@ -74,6 +80,8 @@ export type PlaybookSemanticReconciliation = {
74
80
  } | {
75
81
  readonly status: 'unresolved';
76
82
  readonly reason: PlaybookSemanticReconciliationReason;
83
+ /** DR-063 §1: why it stays unresolved, as a closed structured cause. */
84
+ readonly cause: PlaybookFailureCause;
77
85
  readonly evidence: PlaybookRetainedSemanticEvidence;
78
86
  };
79
87
  /**
@@ -1,7 +1,9 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
3
  import { createHash } from 'node:crypto';
4
+ import { isDeepStrictEqual } from 'node:util';
4
5
  import { fromPromise, waitFor, } from 'xstate';
6
+ import { assertPlaybookFailureCause } from './runtime.js';
5
7
  // DR-019: the generic linked-runtime factory and its strategy helpers live
6
8
  // in the sibling module and are re-exported here so linked artifacts import
7
9
  // one shared engine surface.
@@ -387,7 +389,74 @@ export function hiddenControlEnvelope(prompt) {
387
389
  'Now return exactly one JSON object and nothing else.',
388
390
  ].join('\n\n');
389
391
  }
392
+ /**
393
+ * DR-063 §2: the marker property the runtime writes on the error it marks as
394
+ * the FSM failure. It is non-enumerable, so decorating a host-owned error
395
+ * changes nothing a host can observe by enumeration or serialization.
396
+ */
397
+ const PLAYBOOK_FAILURE_CAUSE_PROPERTY = 'playbookCause';
398
+ /**
399
+ * Attach one validated failure cause to an error object, returning it. A cause
400
+ * the closed validator rejects is dropped rather than published, and an error
401
+ * that refuses the property keeps its own cause-free identity (DR-063 §1).
402
+ */
403
+ export function attachPlaybookFailureCause(error, cause) {
404
+ if (typeof error !== 'object' || error === null)
405
+ return error;
406
+ let validated;
407
+ try {
408
+ validated = assertPlaybookFailureCause(cause);
409
+ }
410
+ catch {
411
+ return error;
412
+ }
413
+ try {
414
+ Object.defineProperty(error, PLAYBOOK_FAILURE_CAUSE_PROPERTY, {
415
+ value: validated,
416
+ enumerable: false,
417
+ writable: true,
418
+ configurable: true,
419
+ });
420
+ }
421
+ catch {
422
+ // A frozen or hostile error keeps no cause; the runtime's own slot still
423
+ // supplies one at the failed state.
424
+ }
425
+ return error;
426
+ }
427
+ /**
428
+ * DR-063 §2: a normalized error carries `cause` exactly when the underlying
429
+ * error carries a valid one. An `Error` carries it on the marker property the
430
+ * runtime writes; an already-normalized record carries it as `cause`, so the
431
+ * cause survives every re-normalization along the durable round trip.
432
+ */
433
+ function playbookFailureCauseOf(error) {
434
+ if (typeof error !== 'object' || error === null)
435
+ return undefined;
436
+ let candidate;
437
+ try {
438
+ if (Object.hasOwn(error, PLAYBOOK_FAILURE_CAUSE_PROPERTY)) {
439
+ candidate = error[PLAYBOOK_FAILURE_CAUSE_PROPERTY];
440
+ }
441
+ else if (!(error instanceof Error) && Object.hasOwn(error, 'cause')) {
442
+ candidate = error.cause;
443
+ }
444
+ }
445
+ catch {
446
+ return undefined;
447
+ }
448
+ if (candidate === undefined)
449
+ return undefined;
450
+ try {
451
+ return assertPlaybookFailureCause(candidate);
452
+ }
453
+ catch {
454
+ return undefined;
455
+ }
456
+ }
390
457
  export function normalizeError(error) {
458
+ const cause = playbookFailureCauseOf(error);
459
+ const withCause = (normalized) => cause === undefined ? normalized : { ...normalized, cause };
391
460
  if (error instanceof Error) {
392
461
  let name = 'Error';
393
462
  let message = 'Unknown error';
@@ -414,11 +483,11 @@ export function normalizeError(error) {
414
483
  catch {
415
484
  // A stack is optional at the public boundary.
416
485
  }
417
- return {
486
+ return withCause({
418
487
  name,
419
488
  message,
420
489
  ...(stack ? { stack } : {}),
421
- };
490
+ });
422
491
  }
423
492
  if (typeof error === 'string') {
424
493
  return { name: 'Error', message: error };
@@ -426,22 +495,22 @@ export function normalizeError(error) {
426
495
  try {
427
496
  assertJsonSafe(error);
428
497
  if (isRecord(error) && typeof error.message === 'string') {
429
- return {
498
+ return withCause({
430
499
  name: typeof error.name === 'string' && error.name.length > 0
431
500
  ? error.name
432
501
  : 'Error',
433
502
  message: error.message,
434
503
  ...(typeof error.stack === 'string' ? { stack: error.stack } : {}),
435
- };
504
+ });
436
505
  }
437
- return { name: 'Error', message: JSON.stringify(error) };
506
+ return withCause({ name: 'Error', message: JSON.stringify(error) });
438
507
  }
439
508
  catch {
440
509
  try {
441
- return { name: 'Error', message: String(error) };
510
+ return withCause({ name: 'Error', message: String(error) });
442
511
  }
443
512
  catch {
444
- return { name: 'Error', message: 'Unknown error' };
513
+ return withCause({ name: 'Error', message: 'Unknown error' });
445
514
  }
446
515
  }
447
516
  }
@@ -629,9 +698,56 @@ function projectionsEqual(left, right) {
629
698
  return (left.projectionDigest === right.projectionDigest &&
630
699
  projectionText(left.projection) === projectionText(right.projection));
631
700
  }
632
- function projectionPreservesBaseline(baseline, after) {
633
- return Object.entries(baseline.projection).every(([path, entry]) => own(after.projection, path) &&
634
- JSON.stringify(entry) === JSON.stringify(after.projection[path]));
701
+ function projectionEntriesEqual(left, right, path) {
702
+ return (own(right.projection, path) &&
703
+ JSON.stringify(left.projection[path]) ===
704
+ JSON.stringify(right.projection[path]));
705
+ }
706
+ const EFFECT_PRE_EXISTING_LISTS = ['absorbed', 'altered', 'lost'];
707
+ const EMPTY_PRE_EXISTING = Object.freeze({
708
+ absorbed: Object.freeze([]),
709
+ altered: Object.freeze([]),
710
+ lost: Object.freeze([]),
711
+ });
712
+ /**
713
+ * The pre-existing accounting a physical or logical receipt records
714
+ * (DR-062 §3): sorted unique baseline paths, pairwise disjoint, present
715
+ * exactly when one list is nonempty.
716
+ */
717
+ function effectPreExisting(value, path, baseline) {
718
+ if (!isRecord(value))
719
+ throw new TypeError(`${path} must be an object`);
720
+ rejectUnknownKeys(value, EFFECT_PRE_EXISTING_LISTS, path);
721
+ const claimed = new Set();
722
+ const lists = EFFECT_PRE_EXISTING_LISTS.map((name) => {
723
+ const entries = value[name];
724
+ if (!Array.isArray(entries)) {
725
+ throw new TypeError(`${path}.${name} must be an array of baseline paths`);
726
+ }
727
+ let previous;
728
+ for (const entry of entries) {
729
+ if (typeof entry !== 'string' || entry.length === 0) {
730
+ throw new TypeError(`${path}.${name} must hold nonempty baseline paths`);
731
+ }
732
+ const member = entry;
733
+ if (previous !== undefined && member <= previous) {
734
+ throw new TypeError(`${path}.${name} must be sorted and unique`);
735
+ }
736
+ previous = member;
737
+ if (!own(baseline.projection, member)) {
738
+ throw new TypeError(`${path}.${name} names a path outside the baseline projection`);
739
+ }
740
+ if (claimed.has(member)) {
741
+ throw new TypeError(`${path} lists ${JSON.stringify(member)} twice`);
742
+ }
743
+ claimed.add(member);
744
+ }
745
+ return [name, entries];
746
+ });
747
+ if (claimed.size === 0) {
748
+ throw new TypeError(`${path} must name at least one baseline path`);
749
+ }
750
+ return Object.fromEntries(lists);
635
751
  }
636
752
  function effectObservation(value, path) {
637
753
  if (!isRecord(value))
@@ -672,7 +788,7 @@ function assertObservationIdentity(observation, identity, path) {
672
788
  function effectReceipt(value, path, expectedBaseline, expectedAfter, matchExpectedAfter = false, dispositions) {
673
789
  if (!isRecord(value))
674
790
  throw new TypeError(`${path} must be an object`);
675
- rejectUnknownKeys(value, ['classification', 'baseline', 'after', 'commitOid'], path);
791
+ rejectUnknownKeys(value, ['classification', 'baseline', 'after', 'commitOid', 'preExisting'], path);
676
792
  if (typeof value.classification !== 'string' ||
677
793
  !EFFECT_RECEIPT_CLASSIFICATIONS.has(value.classification)) {
678
794
  throw new TypeError(`${path}.classification is not supported`);
@@ -718,18 +834,38 @@ function effectReceipt(value, path, expectedBaseline, expectedAfter, matchExpect
718
834
  !jsonValuesEqual(baseline, after)) {
719
835
  throw new TypeError(`${path} classified unchanged observations that differ`);
720
836
  }
837
+ // DR-062 §3: a receipt records the fate of every baseline entry, so the
838
+ // accounting and the two observations must agree exactly.
839
+ if (own(value, 'preExisting') &&
840
+ classification !== 'one-descendant-commit' &&
841
+ classification !== 'worktree-only-change' &&
842
+ classification !== 'observation-ambiguous') {
843
+ throw new TypeError(`${path}.preExisting is not permitted for ${classification}`);
844
+ }
845
+ const preExisting = own(value, 'preExisting')
846
+ ? effectPreExisting(value.preExisting, `${path}.preExisting`, baseline)
847
+ : EMPTY_PRE_EXISTING;
848
+ const consumed = new Set([...preExisting.absorbed, ...preExisting.altered]);
721
849
  if (classification === 'one-descendant-commit' &&
722
850
  after !== undefined &&
723
851
  (baseline.head === after.head ||
724
- !projectionsEqual(baseline, after))) {
725
- throw new TypeError(`${path} one-descendant-commit must change HEAD and preserve the projection`);
852
+ preExisting.lost.length > 0 ||
853
+ !Object.keys(after.projection).every((member) => own(baseline.projection, member) &&
854
+ projectionEntriesEqual(baseline, after, member)) ||
855
+ !Object.keys(baseline.projection).every((member) => own(after.projection, member) !== consumed.has(member)) ||
856
+ (consumed.size === 0) !== projectionsEqual(baseline, after))) {
857
+ throw new TypeError(`${path} one-descendant-commit must change HEAD and account for its complete baseline projection`);
726
858
  }
727
859
  if (classification === 'worktree-only-change' &&
728
860
  after !== undefined &&
729
861
  (baseline.head !== after.head ||
730
862
  projectionsEqual(baseline, after) ||
731
- !projectionPreservesBaseline(baseline, after))) {
732
- throw new TypeError(`${path} worktree-only-change must preserve HEAD and change the projection`);
863
+ preExisting.absorbed.length > 0 ||
864
+ preExisting.lost.length > 0 ||
865
+ !Object.keys(baseline.projection).every((member) => own(after.projection, member) &&
866
+ projectionEntriesEqual(baseline, after, member) !==
867
+ consumed.has(member)))) {
868
+ throw new TypeError(`${path} worktree-only-change must preserve HEAD, change the projection, and account for every altered baseline entry`);
733
869
  }
734
870
  if ((classification === 'multiple-commits' ||
735
871
  classification === 'rewritten-or-non-descendant') &&
@@ -820,8 +956,137 @@ function retainedSemanticEvidence(candidate, finalText) {
820
956
  ...(typeof finalText === 'string' ? { finalText } : {}),
821
957
  });
822
958
  }
823
- function unresolvedSemanticEvidence(reason, evidence) {
824
- return Object.freeze({ status: 'unresolved', reason, evidence });
959
+ // ---------------------------------------------------------------------------
960
+ // DR-063 §1: the evidence builder for a repository-decided failure. Every list
961
+ // is sorted, unique, and bounded; `truncated` carries the count the bound
962
+ // omitted. Nothing but repository paths, dispositions, classifications, and
963
+ // revision identities enters a cause.
964
+ // ---------------------------------------------------------------------------
965
+ const FAILURE_PATH_BOUND = 32;
966
+ function boundedFailurePaths(lists) {
967
+ const bounded = {};
968
+ let omitted = 0;
969
+ for (const [member, paths] of Object.entries(lists)) {
970
+ bounded[member] = paths.slice(0, FAILURE_PATH_BOUND);
971
+ omitted += Math.max(0, paths.length - FAILURE_PATH_BOUND);
972
+ }
973
+ if (omitted > 0)
974
+ bounded.truncated = omitted;
975
+ return bounded;
976
+ }
977
+ function sortedUniquePaths(paths) {
978
+ return [...new Set(paths)].sort();
979
+ }
980
+ /** After-projection entries the baseline did not hold, or holds differently. */
981
+ function newOrChangedProjectionPaths(baseline, after) {
982
+ return sortedUniquePaths(Object.keys(after.projection).filter((path) => !Object.hasOwn(baseline.projection, path) ||
983
+ !isDeepStrictEqual(baseline.projection[path], after.projection[path])));
984
+ }
985
+ /** Entries that differ or are missing on either side of the two projections. */
986
+ function divergentProjectionPaths(baseline, after) {
987
+ return sortedUniquePaths([
988
+ ...Object.keys(baseline.projection),
989
+ ...Object.keys(after.projection),
990
+ ].filter((path) => !Object.hasOwn(baseline.projection, path) ||
991
+ !Object.hasOwn(after.projection, path) ||
992
+ !isDeepStrictEqual(baseline.projection[path], after.projection[path])));
993
+ }
994
+ /**
995
+ * DR-063 §1: the cause of an unresolved repository-disposition mismatch, read
996
+ * from the disposition the accepted arm required and the receipt the host
997
+ * proved. The receipt states the failure; nothing here infers one.
998
+ */
999
+ function playbookRepositoryFailureCause(required, receipt) {
1000
+ const observed = receipt.classification;
1001
+ const baselineHead = receipt.baseline.head;
1002
+ const after = receipt.after;
1003
+ const common = { required, observed, baselineHead };
1004
+ const ambiguous = () => assertPlaybookFailureCause({
1005
+ code: 'attribution-ambiguous',
1006
+ evidence: {
1007
+ ...common,
1008
+ ...(after === undefined ? {} : { afterHead: after.head }),
1009
+ },
1010
+ });
1011
+ const lost = receipt.preExisting?.lost ?? [];
1012
+ if (lost.length > 0) {
1013
+ return assertPlaybookFailureCause({
1014
+ code: 'pre-existing-lost',
1015
+ evidence: {
1016
+ ...common,
1017
+ ...(after === undefined ? {} : { afterHead: after.head }),
1018
+ paths: boundedFailurePaths({ lost: sortedUniquePaths(lost) }),
1019
+ },
1020
+ });
1021
+ }
1022
+ if (observed === 'observation-ambiguous' && after === undefined) {
1023
+ return assertPlaybookFailureCause({
1024
+ code: 'observation-unstable',
1025
+ evidence: common,
1026
+ });
1027
+ }
1028
+ if (after === undefined)
1029
+ return ambiguous();
1030
+ const afterHead = after.head;
1031
+ if (observed === 'multiple-commits') {
1032
+ return assertPlaybookFailureCause({
1033
+ code: 'commits-more-than-one',
1034
+ evidence: { ...common, afterHead },
1035
+ });
1036
+ }
1037
+ if (observed === 'rewritten-or-non-descendant') {
1038
+ return assertPlaybookFailureCause({
1039
+ code: 'history-rewritten',
1040
+ evidence: { ...common, afterHead },
1041
+ });
1042
+ }
1043
+ if (observed === 'concurrent-or-foreign-change') {
1044
+ return assertPlaybookFailureCause({
1045
+ code: 'foreign-change',
1046
+ evidence: {
1047
+ ...common,
1048
+ afterHead,
1049
+ paths: boundedFailurePaths({
1050
+ changed: divergentProjectionPaths(receipt.baseline, after),
1051
+ }),
1052
+ },
1053
+ });
1054
+ }
1055
+ const uncommitted = newOrChangedProjectionPaths(receipt.baseline, after);
1056
+ const altered = sortedUniquePaths(receipt.preExisting?.altered ?? []);
1057
+ if (observed === 'observation-ambiguous' &&
1058
+ afterHead !== baselineHead &&
1059
+ (uncommitted.length > 0 || altered.length > 0)) {
1060
+ return assertPlaybookFailureCause({
1061
+ code: 'commit-residual',
1062
+ evidence: {
1063
+ ...common,
1064
+ afterHead,
1065
+ ...(receipt.commitOid === undefined
1066
+ ? {}
1067
+ : { commitOid: receipt.commitOid }),
1068
+ paths: boundedFailurePaths({ uncommitted, altered }),
1069
+ },
1070
+ });
1071
+ }
1072
+ if (required === 'one-descendant-commit' &&
1073
+ (observed === 'unchanged' || observed === 'worktree-only-change')) {
1074
+ return assertPlaybookFailureCause({
1075
+ code: 'commit-missing',
1076
+ evidence: {
1077
+ ...common,
1078
+ afterHead,
1079
+ paths: boundedFailurePaths({ uncommitted }),
1080
+ },
1081
+ });
1082
+ }
1083
+ return ambiguous();
1084
+ }
1085
+ function unresolvedSemanticEvidence(reason, evidence, cause = assertPlaybookFailureCause({
1086
+ code: 'runtime-defect',
1087
+ evidence: { reason },
1088
+ })) {
1089
+ return Object.freeze({ status: 'unresolved', reason, cause, evidence });
825
1090
  }
826
1091
  /**
827
1092
  * Reconcile one exact semantic candidate with host-owned effect evidence.
@@ -860,7 +1125,7 @@ export function reconcilePlaybookSemanticEvidence(input) {
860
1125
  receipt.after !== undefined &&
861
1126
  receipt.after.head === receipt.baseline.head);
862
1127
  if (!dispositionMatches) {
863
- return unresolvedSemanticEvidence('repository-disposition-mismatch', evidence);
1128
+ return unresolvedSemanticEvidence('repository-disposition-mismatch', evidence, playbookRepositoryFailureCause(disposition, receipt));
864
1129
  }
865
1130
  let runtimeFields = Object.freeze({});
866
1131
  if (input.runtimeFields !== undefined) {
@@ -1630,6 +1895,17 @@ export class NestedPlaybookCallError extends Error {
1630
1895
  if (normalized?.stack)
1631
1896
  this.stack = normalized.stack;
1632
1897
  this.result = result;
1898
+ // DR-063 §2: the child's failure reaching its parent is `child-failed`,
1899
+ // carrying the child's own cause when its normalized error published one.
1900
+ attachPlaybookFailureCause(this, {
1901
+ code: 'child-failed',
1902
+ evidence: {
1903
+ playbookId: result.playbookId,
1904
+ ...(normalized?.cause === undefined
1905
+ ? {}
1906
+ : { cause: normalized.cause }),
1907
+ },
1908
+ });
1633
1909
  }
1634
1910
  }
1635
1911
  function validateState(state, path) {
@@ -1720,7 +1996,7 @@ function validateNormalizedError(error, path) {
1720
1996
  if (!isRecord(error)) {
1721
1997
  throw new TypeError(`${path} must be a normalized error`);
1722
1998
  }
1723
- rejectUnknownKeys(error, ['name', 'message', 'stack'], path);
1999
+ rejectUnknownKeys(error, ['name', 'message', 'stack', 'cause'], path);
1724
2000
  requireNonEmptyString(error.name, `${path}.name`);
1725
2001
  if (typeof error.message !== 'string') {
1726
2002
  throw new TypeError(`${path}.message must be a string`);
@@ -1728,6 +2004,10 @@ function validateNormalizedError(error, path) {
1728
2004
  if (error.stack !== undefined && typeof error.stack !== 'string') {
1729
2005
  throw new TypeError(`${path}.stack must be a string`);
1730
2006
  }
2007
+ // DR-063 §2: a normalized error may carry its structured cause, and an
2008
+ // unrecognized shape is not a cause.
2009
+ if (error.cause !== undefined)
2010
+ assertPlaybookFailureCause(error.cause);
1731
2011
  }
1732
2012
  // DR-048: the completed child's compiled terminal record. It is runtime-owned
1733
2013
  // data read from the child's artifact, so a malformed one is a control-plane