@sublang/playbook 8.0.0 → 10.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 (66) hide show
  1. package/README.md +3 -3
  2. package/docs/cli.md +66 -23
  3. package/docs/configuration.md +13 -8
  4. package/docs/embedding.md +45 -14
  5. package/package.json +7 -3
  6. package/reference/sdlc/captain.md +14 -10
  7. package/reference/sdlc/captain.playbook/captain.fsm.d.ts +33 -13
  8. package/reference/sdlc/captain.playbook/captain.fsm.js +80 -9
  9. package/reference/sdlc/captain.playbook/captain.fsm.ts +137 -18
  10. package/reference/sdlc/captain.playbook/captain.gears.md +10 -6
  11. package/reference/sdlc/captain.playbook/captain.playbook.d.ts +5 -1
  12. package/reference/sdlc/captain.playbook/captain.playbook.js +151 -10
  13. package/reference/sdlc/captain.playbook/captain.playbook.ts +200 -14
  14. package/reference/sdlc/code.md +0 -1
  15. package/reference/sdlc/code.playbook/bin/interactive-session.js +170 -17
  16. package/reference/sdlc/code.playbook/bin/launch-config.js +136 -4
  17. package/reference/sdlc/code.playbook/bin/playbook.js +81 -4
  18. package/reference/sdlc/code.playbook/bin/repository-effects.js +2930 -0
  19. package/reference/sdlc/code.playbook/bin/run.js +365 -63
  20. package/reference/sdlc/code.playbook/bin/session-store.js +2877 -209
  21. package/reference/sdlc/code.playbook/code.fsm.d.ts +11 -1
  22. package/reference/sdlc/code.playbook/code.fsm.js +85 -29
  23. package/reference/sdlc/code.playbook/code.fsm.ts +95 -33
  24. package/reference/sdlc/code.playbook/code.gears.md +0 -2
  25. package/reference/sdlc/code.playbook/code.playbook.d.ts +5 -2
  26. package/reference/sdlc/code.playbook/code.playbook.js +67 -4
  27. package/reference/sdlc/code.playbook/code.playbook.ts +87 -8
  28. package/reference/sdlc/code.playbook/code.registry.d.ts +10 -3
  29. package/reference/sdlc/code.playbook/code.registry.js +10 -3
  30. package/reference/sdlc/code.playbook/code.registry.ts +23 -5
  31. package/reference/sdlc/code.playbook/playbook-captain.d.ts +99 -7
  32. package/reference/sdlc/code.playbook/playbook-captain.js +1894 -82
  33. package/reference/sdlc/code.playbook/playbook-captain.ts +2809 -109
  34. package/reference/sdlc/decide.md +0 -1
  35. package/reference/sdlc/decide.playbook/decide.fsm.d.ts +8 -1
  36. package/reference/sdlc/decide.playbook/decide.fsm.js +80 -29
  37. package/reference/sdlc/decide.playbook/decide.fsm.ts +89 -31
  38. package/reference/sdlc/decide.playbook/decide.gears.md +0 -1
  39. package/reference/sdlc/decide.playbook/decide.playbook.d.ts +15 -5
  40. package/reference/sdlc/decide.playbook/decide.playbook.js +1994 -191
  41. package/reference/sdlc/decide.playbook/decide.playbook.ts +3209 -404
  42. package/reference/sdlc/decide.playbook/decide.registry.d.ts +7 -3
  43. package/reference/sdlc/decide.playbook/decide.registry.js +10 -3
  44. package/reference/sdlc/decide.playbook/decide.registry.ts +20 -5
  45. package/reference/sdlc/review.playbook/review.fsm.d.ts +7 -0
  46. package/reference/sdlc/review.playbook/review.fsm.js +133 -12
  47. package/reference/sdlc/review.playbook/review.fsm.ts +140 -12
  48. package/reference/sdlc/review.playbook/review.playbook.d.ts +5 -2
  49. package/reference/sdlc/review.playbook/review.playbook.js +78 -4
  50. package/reference/sdlc/review.playbook/review.playbook.ts +95 -8
  51. package/reference/sdlc/review.playbook/review.registry.d.ts +10 -3
  52. package/reference/sdlc/review.playbook/review.registry.js +10 -3
  53. package/reference/sdlc/review.playbook/review.registry.ts +23 -5
  54. package/slc/gears2fsm.md +25 -7
  55. package/slc/link.md +727 -82
  56. package/src/accepted-outcome.d.ts +18 -0
  57. package/src/accepted-outcome.js +94 -0
  58. package/src/accepted-outcome.ts +140 -0
  59. package/src/runtime.d.ts +165 -3
  60. package/src/runtime.ts +214 -2
  61. package/src/xstate-playbook-runtime.d.ts +162 -13
  62. package/src/xstate-playbook-runtime.js +3344 -564
  63. package/src/xstate-playbook-runtime.ts +4873 -637
  64. package/src/xstate-runtime.d.ts +76 -8
  65. package/src/xstate-runtime.js +1001 -64
  66. package/src/xstate-runtime.ts +1640 -91
@@ -16,24 +16,37 @@
16
16
  // Output profile: bespoke parallel runtime with the shared nested-call
17
17
  // bridge (slc/link.md §Output)
18
18
 
19
+ import { randomUUID } from 'node:crypto';
20
+
19
21
  import PQueue from 'p-queue';
20
22
  import { createActor, fromPromise } from 'xstate';
21
23
  import type { InspectionEvent, SnapshotFrom } from 'xstate';
22
24
 
25
+ import {
26
+ createAcceptedOutcomeConsumer,
27
+ type AcceptedOutcomeReceipt,
28
+ } from '../../../src/accepted-outcome.js';
29
+
23
30
  import {
24
31
  assertJsonSafe,
32
+ assertPlaybookEffectLedger,
25
33
  assertPlaybookRuntimeSnapshot,
26
34
  combineAbortSignals,
27
35
  createNestedPlaybookBridge,
28
36
  detachPersistedMachineSnapshot,
29
37
  normalizeError,
30
38
  normalizePlaybookSnapshot,
39
+ PlaybookSemanticCandidateStructureError,
40
+ reconcilePlaybookSemanticEvidence,
31
41
  snapshotJsonValue,
32
42
  snapshotPlaybookSession,
33
43
  validatePlayerResult,
34
44
  waitForPlaybookQuiescence,
35
45
  } from '../../../src/xstate-runtime.js';
36
- import type { NestedPlaybookBridge } from '../../../src/xstate-runtime.js';
46
+ import type {
47
+ NestedPlaybookBridge,
48
+ PlaybookSemanticOutcomeSpec,
49
+ } from '../../../src/xstate-runtime.js';
37
50
 
38
51
  import decideMachine, {
39
52
  type PlayerInput,
@@ -44,6 +57,8 @@ import decideMachine, {
44
57
  type PlaybookInput,
45
58
  } from './decide.fsm.js';
46
59
 
60
+ import type { PlaybookHostConstructionCapabilities } from '../code.playbook/playbook-captain.js';
61
+
47
62
  import type {
48
63
  CaptainCallOptions,
49
64
  CaptainResult,
@@ -58,8 +73,14 @@ import type {
58
73
  PlaybookControlAction,
59
74
  PlaybookControlReceipt,
60
75
  PlaybookControlView,
76
+ PlaybookEffectBoundary,
77
+ PlaybookEffectBoundaryStart,
78
+ PlaybookEffectLedger,
79
+ PlaybookEffectLedgerCapability,
80
+ PlaybookPendingBossQuestion,
61
81
  PlaybookPendingCall,
62
82
  PlaybookPorts,
83
+ PlaybookRepositoryReceipt,
63
84
  PlaybookRunResult,
64
85
  PlaybookRuntime,
65
86
  PlaybookRuntimeFactory,
@@ -102,6 +123,14 @@ type RoleId = 'coder' | 'reviewer';
102
123
 
103
124
  export type PlaybookRuntimeOptions = DecideInput;
104
125
 
126
+ export type DecidePlaybookHostCapabilities =
127
+ PlaybookHostConstructionCapabilities;
128
+
129
+ export interface DecidePlaybookRuntimeConstruction {
130
+ readonly configuredOptions: PlaybookRuntimeOptions;
131
+ readonly hostCapabilities: DecidePlaybookHostCapabilities;
132
+ }
133
+
105
134
  function snapshotDecideRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
106
135
  const captured = snapshotJsonValue(value, 'DECIDE runtime options');
107
136
  if (!isPlainObject(captured)) {
@@ -114,20 +143,51 @@ function snapshotDecideRuntimeOptions(value: unknown): PlaybookRuntimeOptions {
114
143
  return Object.freeze({});
115
144
  }
116
145
 
117
- const STATE_DESCRIPTIONS: Readonly<Record<string, string>> = {
118
- ready: 'Waiting for a topic to decide.',
119
- askCoderProposal: 'Coder independently proposes a spec design.',
120
- askReviewerProposal: 'Reviewer independently proposes a spec design.',
121
- waitCoderProposalReply: 'Coder waits for Boss to answer a question.',
122
- waitReviewerProposalReply: 'Reviewer waits for Boss to answer a question.',
123
- commitCoderProposal: 'Coder writes and commits Coder’s independent proposal.',
124
- awaitBossReply: 'Waiting for Boss to answer Coder’s question.',
125
- reviewCommit: 'REVIEW examines the committed proposal.',
126
- failed: 'DECIDE failed and is waiting for a new topic.',
127
- reportedReviewFailure:
128
- 'DECIDE reports REVIEW’s failure and its last commit.',
129
- done: 'DECIDE completed with an approved commit.',
130
- };
146
+ interface AuthoredStateConfig {
147
+ readonly meta?: {
148
+ readonly playbook?: {
149
+ readonly stateId?: unknown;
150
+ readonly description?: unknown;
151
+ };
152
+ };
153
+ readonly states?: Readonly<Record<string, AuthoredStateConfig>>;
154
+ }
155
+
156
+ function authoredStateDescriptions(
157
+ states: Readonly<Record<string, AuthoredStateConfig>> | undefined,
158
+ ): Readonly<Record<string, string>> {
159
+ const descriptions: Record<string, string> = {};
160
+ const visit = (
161
+ children: Readonly<Record<string, AuthoredStateConfig>> | undefined,
162
+ ): void => {
163
+ for (const state of Object.values(children ?? {})) {
164
+ const stateId = state.meta?.playbook?.stateId;
165
+ const description = state.meta?.playbook?.description;
166
+ if (
167
+ typeof stateId === 'string' &&
168
+ typeof description === 'string' &&
169
+ description.trim().length > 0
170
+ ) {
171
+ const existing = descriptions[stateId];
172
+ if (existing !== undefined && existing !== description) {
173
+ throw new Error(
174
+ `DECIDE state ${stateId} declares conflicting descriptions`,
175
+ );
176
+ }
177
+ descriptions[stateId] = description;
178
+ }
179
+ visit(state.states);
180
+ }
181
+ };
182
+ visit(states);
183
+ return Object.freeze(descriptions);
184
+ }
185
+
186
+ const STATE_DESCRIPTIONS = authoredStateDescriptions(
187
+ decideMachine.config.states as
188
+ | Readonly<Record<string, AuthoredStateConfig>>
189
+ | undefined,
190
+ );
131
191
 
132
192
  const ROLE_STATES = [
133
193
  { stateId: 'askCoderProposal', role: 'coder', sourceItem: 'DECIDE-1' },
@@ -143,6 +203,61 @@ const ROLE_STATE_IDS: ReadonlySet<string> = new Set(
143
203
  ROLE_STATES.map((state) => state.stateId),
144
204
  );
145
205
 
206
+ const ACCEPTED_OUTCOME_DECLARATIONS: Readonly<
207
+ Record<string, ReadonlySet<string>>
208
+ > = Object.freeze({
209
+ askCoderProposal: new Set(['proposed', 'needsBossReply']),
210
+ askReviewerProposal: new Set(['proposed', 'needsBossReply']),
211
+ commitCoderProposal: new Set(['committed', 'needsBossReply']),
212
+ });
213
+
214
+ const DECIDE_OUTCOME_AUTHORITY = Object.freeze({
215
+ governedPlayerStates: Object.freeze({
216
+ askCoderProposal: Object.freeze({
217
+ proposed: Object.freeze({
218
+ fields: Object.freeze({ coderProposal: 'presentation' as const }),
219
+ repositoryDisposition: 'unchanged' as const,
220
+ }),
221
+ needsBossReply: Object.freeze({
222
+ fields: Object.freeze({ question: 'presentation' as const }),
223
+ repositoryDisposition: 'unchanged' as const,
224
+ }),
225
+ }),
226
+ askReviewerProposal: Object.freeze({
227
+ proposed: Object.freeze({
228
+ fields: Object.freeze({ reviewerProposal: 'presentation' as const }),
229
+ repositoryDisposition: 'unchanged' as const,
230
+ }),
231
+ needsBossReply: Object.freeze({
232
+ fields: Object.freeze({ question: 'presentation' as const }),
233
+ repositoryDisposition: 'unchanged' as const,
234
+ }),
235
+ }),
236
+ commitCoderProposal: Object.freeze({
237
+ committed: Object.freeze({
238
+ fields: Object.freeze({
239
+ coderOutput: 'presentation' as const,
240
+ latestCommit: 'effect' as const,
241
+ }),
242
+ repositoryDisposition: 'one-descendant-commit' as const,
243
+ }),
244
+ needsBossReply: Object.freeze({
245
+ fields: Object.freeze({ question: 'presentation' as const }),
246
+ repositoryDisposition: 'deferred' as const,
247
+ }),
248
+ }),
249
+ }),
250
+ }) satisfies {
251
+ readonly governedPlayerStates: Readonly<
252
+ Record<string, Readonly<Record<string, PlaybookSemanticOutcomeSpec>>>
253
+ >;
254
+ };
255
+
256
+ const PROPOSAL_STATE_BY_ROLE = Object.freeze({
257
+ coder: 'askCoderProposal',
258
+ reviewer: 'askReviewerProposal',
259
+ } as const);
260
+
146
261
  const ROLE_IDS = ['coder', 'reviewer'] as const;
147
262
  const ROLE_ID_SET: ReadonlySet<string> = new Set(ROLE_IDS);
148
263
 
@@ -154,9 +269,16 @@ const BOSS_INTERRUPT_TARGETS = ['independentProposals'] as const;
154
269
  const BOSS_INTERRUPT_TARGET_IDS: ReadonlySet<string> = new Set(
155
270
  BOSS_INTERRUPT_TARGETS,
156
271
  );
272
+ const UNFINISHED_FINAL_STATE_IDS: ReadonlySet<string> = new Set([
273
+ 'reportedReviewFailure',
274
+ ]);
157
275
 
158
276
  const TELEMETRY_TOPIC = 'playbook.fsm.state';
159
277
  const TRACE_TOPIC = 'playbook.trace';
278
+ const UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID =
279
+ 'reconcile:unresolved-effect';
280
+ const UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID =
281
+ 'abandon:unresolved-effect';
160
282
 
161
283
  const CONTINUATION_PREAMBLE =
162
284
  'You previously paused this task to ask Boss a question; Boss has now replied. Continue the same task using the reply below.';
@@ -230,6 +352,44 @@ function requiredFieldsFor(description: string): string[] {
230
352
  return fields;
231
353
  }
232
354
 
355
+ function governedOutcomesFor(
356
+ input: PlayerInput,
357
+ ): Readonly<Record<string, PlaybookSemanticOutcomeSpec>> {
358
+ const outcomes = (
359
+ DECIDE_OUTCOME_AUTHORITY.governedPlayerStates as Readonly<
360
+ Record<string, Readonly<Record<string, PlaybookSemanticOutcomeSpec>>>
361
+ >
362
+ )[input.stateId];
363
+ if (outcomes === undefined) {
364
+ throw new TypeError(
365
+ `DECIDE governed player state ${JSON.stringify(input.stateId)} has no outcome authority`,
366
+ );
367
+ }
368
+ const declaredGuards = Object.keys(outcomes).sort();
369
+ const authoredGuards = Object.keys(input.result).sort();
370
+ if (
371
+ declaredGuards.length !== authoredGuards.length ||
372
+ declaredGuards.some((guard, index) => guard !== authoredGuards[index])
373
+ ) {
374
+ throw new TypeError(
375
+ `DECIDE governed player state ${input.stateId} changed its authored outcome set`,
376
+ );
377
+ }
378
+ for (const guard of declaredGuards) {
379
+ const required = requiredFieldsFor(input.result[guard]!).sort();
380
+ const authoritative = Object.keys(outcomes[guard]!.fields).sort();
381
+ if (
382
+ required.length !== authoritative.length ||
383
+ required.some((field, index) => field !== authoritative[index])
384
+ ) {
385
+ throw new TypeError(
386
+ `DECIDE governed outcome ${input.stateId}.${guard} changed its payload fields`,
387
+ );
388
+ }
389
+ }
390
+ return outcomes;
391
+ }
392
+
233
393
  // LLM judges routinely wrap JSON in prose/fences or damage its tail. Match
234
394
  // CODE's recovery contract: scan candidate starts in document order, prefer a
235
395
  // strict balanced value at each position, then repair trailing commas and
@@ -298,6 +458,14 @@ function stableJson(value: unknown, path: string): string {
298
458
  return JSON.stringify(sortJson(snapshotJsonValue(value, path)));
299
459
  }
300
460
 
461
+ function deepFreeze<T>(value: T): T {
462
+ if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
463
+ Object.freeze(value);
464
+ for (const member of Object.values(value)) deepFreeze(member);
465
+ }
466
+ return value;
467
+ }
468
+
301
469
  function stripCodeFence(text: string): string {
302
470
  const fence = text.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
303
471
  return fence ? fence[1].trim() : text;
@@ -461,7 +629,9 @@ function parseClassification(
461
629
  function buildAdjudicatorPrompt(
462
630
  input: PlayerInput,
463
631
  playerOutput: string,
632
+ correction?: { readonly reply: string; readonly error: string },
464
633
  ): string {
634
+ const outcomes = governedOutcomesFor(input);
465
635
  const lines: string[] = [];
466
636
  lines.push('You are the guard adjudicator for a playbook state machine.');
467
637
  lines.push(
@@ -483,72 +653,44 @@ function buildAdjudicatorPrompt(
483
653
  'Guards (choose exactly one; the descriptions are authoritative and must be applied as written):',
484
654
  );
485
655
  for (const [guard, description] of Object.entries(input.result)) {
486
- lines.push(`- ${guard}: ${description}`);
487
- }
488
- const runtimeOwnedFields = new Set<string>();
489
- for (const description of Object.values(input.result)) {
490
- for (const field of requiredFieldsFor(description)) {
491
- if (VERBATIM_PAYLOAD_FIELDS.has(field)) runtimeOwnedFields.add(field);
492
- }
493
- }
494
- if (runtimeOwnedFields.size > 0) {
495
- lines.push('');
656
+ const semanticFields = Object.entries(outcomes[guard]?.fields ?? {})
657
+ .filter(([, authority]) => authority === 'semantic')
658
+ .map(([field]) => field);
496
659
  lines.push(
497
- `The runtime owns these verbatim fields; do not include them in your JSON: ${[...runtimeOwnedFields].join(', ')}.`,
660
+ `- ${guard}: semantic fields: ${
661
+ semanticFields.length === 0 ? '(none)' : semanticFields.join(', ')
662
+ }; ${description}`,
498
663
  );
499
664
  }
500
665
  lines.push('');
501
666
  lines.push(
502
- 'Reply with a single JSON object: { "guard": "<one of the guard names above>", ...any payload fields the chosen guard description requires }.',
667
+ 'Reply with exactly the chosen `guard` and every semantic-owned field for that guard, and no other field.',
668
+ );
669
+ lines.push(
670
+ 'Do not include presentation-, effect-, or runtime-owned fields; the runtime supplies those from authoritative evidence.',
503
671
  );
672
+ if (correction !== undefined) {
673
+ lines.push('');
674
+ lines.push('Your first reply was structurally invalid:');
675
+ lines.push('"""');
676
+ lines.push(correction.reply);
677
+ lines.push('"""');
678
+ lines.push(`Validation error: ${correction.error}`);
679
+ lines.push(
680
+ 'Correct only that structure using the same player output and outcome schema.',
681
+ );
682
+ }
504
683
  return lines.join('\n');
505
684
  }
506
685
 
507
- function parseAdjudication(
508
- raw: string,
509
- input: PlayerInput,
510
- finalText: string,
511
- ): PlayerOutput {
512
- const obj = extractJson(raw);
513
- if (!obj || typeof obj.guard !== 'string' || obj.guard.trim() === '') {
514
- throw new Error('adjudicator returned empty or malformed JSON');
515
- }
516
-
517
- const guard = obj.guard;
518
- if (!Object.prototype.hasOwnProperty.call(input.result, guard)) {
519
- throw new Error(
520
- `adjudicator returned undeclared guard "${guard}" for ${input.sourceItem}`,
686
+ function parseGovernedSemanticCandidate(raw: string): unknown {
687
+ try {
688
+ return parseJudgeJson(raw);
689
+ } catch (error) {
690
+ throw new PlaybookSemanticCandidateStructureError(
691
+ error instanceof Error ? error.message : 'reply is not valid JSON',
521
692
  );
522
693
  }
523
-
524
- const requiredFields = requiredFieldsFor(input.result[guard]);
525
- const allowedFields = new Set(['guard', ...requiredFields]);
526
- for (const key of Reflect.ownKeys(obj)) {
527
- if (typeof key !== 'string' || !allowedFields.has(key)) {
528
- throw new Error(
529
- `adjudicator response for guard "${guard}" included undeclared field "${String(key)}"`,
530
- );
531
- }
532
- }
533
- const output: Record<string, unknown> = { guard };
534
- for (const field of requiredFields) {
535
- if (VERBATIM_PAYLOAD_FIELDS.has(field)) {
536
- output[field] = finalText;
537
- continue;
538
- }
539
- const value = obj[field];
540
- if (
541
- value === undefined ||
542
- value === null ||
543
- (typeof value === 'string' && value.trim() === '')
544
- ) {
545
- throw new Error(
546
- `adjudicator response for guard "${guard}" missing required field "${field}"`,
547
- );
548
- }
549
- output[field] = value;
550
- }
551
- return output as PlayerOutput;
552
694
  }
553
695
 
554
696
  function combineSignals(
@@ -580,10 +722,394 @@ function isEmptyFinalText(finalText: string | undefined): boolean {
580
722
  return finalText === undefined || finalText.trim().length === 0;
581
723
  }
582
724
 
725
+ interface AutomaticReplayPolicy {
726
+ allowsEmptyOkCorrection(runtimeSessionId: string, callId: string): boolean;
727
+ allowsFailureStateRetry(): boolean;
728
+ }
729
+
730
+ interface Schema3AutomaticReplayEvidence {
731
+ readonly effectLedger: PlaybookEffectLedgerCapability;
732
+ }
733
+
734
+ interface DecideRepositoryOperationSettlement<T> {
735
+ readonly status: 'fulfilled';
736
+ readonly value: T;
737
+ }
738
+
739
+ interface DecideRepositoryOperationRejection {
740
+ readonly status: 'rejected';
741
+ readonly reason: unknown;
742
+ }
743
+
744
+ interface DecideRepositoryCompletion<T> {
745
+ readonly boundary: PlaybookEffectBoundary;
746
+ readonly operation:
747
+ | DecideRepositoryOperationSettlement<T>
748
+ | DecideRepositoryOperationRejection;
749
+ readonly receipt: PlaybookRepositoryReceipt;
750
+ readonly outcomeReceipt: PlaybookRepositoryReceipt;
751
+ readonly roleId?: RoleId;
752
+ }
753
+
754
+ interface DecideDeferredBinding {
755
+ readonly operationId: string;
756
+ readonly pendingQuestion: PlaybookPendingBossQuestion;
757
+ readonly playerContinuation: JsonValue;
758
+ }
759
+
760
+ interface DecideRepositoryCompletionEvidence {
761
+ readonly finalText?: string;
762
+ readonly semanticCandidate?: JsonValue;
763
+ readonly deferred?: DecideDeferredBinding;
764
+ readonly unresolved?: true;
765
+ }
766
+
767
+ interface DecideRepositoryExclusiveResult<T> {
768
+ readonly operation:
769
+ | DecideRepositoryOperationSettlement<T>
770
+ | DecideRepositoryOperationRejection;
771
+ readonly receipt: PlaybookRepositoryReceipt;
772
+ readonly effectLedger: PlaybookEffectLedger;
773
+ readonly deferredStatus?: 'bound' | 'unresolved';
774
+ }
775
+
776
+ interface DecideRepositoryCohortResult<T> {
777
+ readonly baseline: PlaybookRepositoryReceipt['baseline'];
778
+ readonly invocationId: string;
779
+ readonly operations: Readonly<
780
+ Record<
781
+ RoleId,
782
+ | DecideRepositoryOperationSettlement<T>
783
+ | DecideRepositoryOperationRejection
784
+ >
785
+ >;
786
+ readonly receipts: Readonly<Record<RoleId, PlaybookRepositoryReceipt>>;
787
+ readonly effectLedger: PlaybookEffectLedger;
788
+ }
789
+
790
+ interface DecideRepositoryDeferredContinuationResult<T>
791
+ extends DecideRepositoryExclusiveResult<T> {
792
+ readonly status: 'continued';
793
+ readonly baseline: PlaybookRepositoryReceipt['baseline'];
794
+ readonly logicalReceipt?: PlaybookRepositoryReceipt;
795
+ }
796
+
797
+ interface DecideRepositoryDeferredCheckpointMismatch {
798
+ readonly status: 'checkpoint-mismatch' | 'ineligible';
799
+ readonly effectLedger: PlaybookEffectLedger;
800
+ }
801
+
802
+ interface DecideRepositoryDeferredParked {
803
+ readonly status: 'parked';
804
+ readonly effectLedger: PlaybookEffectLedger;
805
+ }
806
+
807
+ interface DecideRepositoryDeferredRestoreResult {
808
+ readonly status: 'restored' | 'checkpoint-mismatch' | 'ineligible';
809
+ readonly effectLedger: PlaybookEffectLedger;
810
+ }
811
+
812
+ type DecideEffectBoundarySeed = Omit<
813
+ PlaybookEffectBoundaryStart,
814
+ 'playbookId' | 'canonicalWorktree' | 'baseline' | 'cohortId'
815
+ >;
816
+
817
+ interface DecideRepositoryCapability {
818
+ runExclusive<T>(options: {
819
+ readonly signal: AbortSignal;
820
+ readonly effectBoundary: DecideEffectBoundarySeed;
821
+ readonly operation: (context: {
822
+ readonly baseline: PlaybookRepositoryReceipt['baseline'];
823
+ readonly identity: unknown;
824
+ }) => Promise<T>;
825
+ readonly completeEffectBoundary: (
826
+ completion: DecideRepositoryCompletion<T>,
827
+ ) =>
828
+ | DecideRepositoryCompletionEvidence
829
+ | Promise<DecideRepositoryCompletionEvidence>;
830
+ }): Promise<DecideRepositoryExclusiveResult<T>>;
831
+ runCohort<T>(options: {
832
+ readonly signal: AbortSignal;
833
+ readonly invocationId: string;
834
+ readonly roleIds: readonly [RoleId, RoleId];
835
+ readonly dispositionsByRole: Readonly<
836
+ Record<RoleId, readonly ['unchanged']>
837
+ >;
838
+ readonly effectBoundaries: Readonly<
839
+ Record<RoleId, DecideEffectBoundarySeed>
840
+ >;
841
+ readonly operations: Readonly<
842
+ Record<
843
+ RoleId,
844
+ (context: {
845
+ readonly baseline: PlaybookRepositoryReceipt['baseline'];
846
+ readonly identity: unknown;
847
+ readonly invocationId: string;
848
+ readonly roleId: RoleId;
849
+ }) => Promise<T>
850
+ >
851
+ >;
852
+ readonly completeEffectBoundary: (
853
+ completion: DecideRepositoryCompletion<T> & { readonly roleId: RoleId },
854
+ ) =>
855
+ | DecideRepositoryCompletionEvidence
856
+ | Promise<DecideRepositoryCompletionEvidence>;
857
+ }): Promise<DecideRepositoryCohortResult<T>>;
858
+ runDeferred<T>(options: {
859
+ readonly mode: 'continue';
860
+ readonly signal: AbortSignal;
861
+ readonly operationId: string;
862
+ readonly effectBoundary: DecideEffectBoundarySeed;
863
+ readonly operation: (context: {
864
+ readonly baseline: PlaybookRepositoryReceipt['baseline'];
865
+ readonly identity: unknown;
866
+ readonly playerContinuation: JsonValue;
867
+ }) => Promise<T>;
868
+ readonly completeEffectBoundary: (
869
+ completion: DecideRepositoryCompletion<T>,
870
+ ) =>
871
+ | DecideRepositoryCompletionEvidence
872
+ | Promise<DecideRepositoryCompletionEvidence>;
873
+ }): Promise<
874
+ | DecideRepositoryDeferredContinuationResult<T>
875
+ | DecideRepositoryDeferredCheckpointMismatch
876
+ >;
877
+ runDeferred(options: {
878
+ readonly mode: 'park' | 'restore';
879
+ readonly signal: AbortSignal;
880
+ readonly operationId: string;
881
+ }): Promise<
882
+ DecideRepositoryDeferredParked | DecideRepositoryDeferredRestoreResult
883
+ >;
884
+ }
885
+
886
+ interface Schema3DeferredEffectEvidence extends Schema3AutomaticReplayEvidence {
887
+ readonly authority: DecidePlaybookHostCapabilities['authority'];
888
+ readonly repository: DecideRepositoryCapability;
889
+ }
890
+
891
+ function schema3Construction(
892
+ value: unknown,
893
+ ): {
894
+ readonly configuredOptions: PlaybookRuntimeOptions;
895
+ readonly hostCapabilities: Schema3DeferredEffectEvidence;
896
+ } {
897
+ if (!isPlainObject(value)) {
898
+ throw new TypeError('DECIDE schema-3 factory input must be a plain object');
899
+ }
900
+ const descriptors = Object.getOwnPropertyDescriptors(value);
901
+ const keys = Reflect.ownKeys(value);
902
+ if (
903
+ keys.length !== 2 ||
904
+ !keys.includes('configuredOptions') ||
905
+ !keys.includes('hostCapabilities') ||
906
+ keys.some((key) => {
907
+ const descriptor = descriptors[key as keyof typeof descriptors];
908
+ return (
909
+ descriptor?.get !== undefined ||
910
+ descriptor?.set !== undefined ||
911
+ descriptor?.enumerable !== true ||
912
+ !Object.prototype.hasOwnProperty.call(descriptor, 'value')
913
+ );
914
+ })
915
+ ) {
916
+ throw new TypeError(
917
+ 'DECIDE schema-3 factory input must contain exactly configuredOptions and hostCapabilities data properties',
918
+ );
919
+ }
920
+ const configuredOptions = descriptors.configuredOptions!.value;
921
+ if (
922
+ configuredOptions !== null &&
923
+ typeof configuredOptions === 'object' &&
924
+ Object.prototype.hasOwnProperty.call(
925
+ configuredOptions,
926
+ 'hostCapabilities',
927
+ )
928
+ ) {
929
+ throw new TypeError(
930
+ 'DECIDE configured options must not contain hostCapabilities',
931
+ );
932
+ }
933
+ const hostCapabilities = descriptors.hostCapabilities!.value;
934
+ if (
935
+ hostCapabilities === null ||
936
+ typeof hostCapabilities !== 'object' ||
937
+ Array.isArray(hostCapabilities)
938
+ ) {
939
+ throw new TypeError(
940
+ 'DECIDE schema-3 factory input hostCapabilities must be a live object',
941
+ );
942
+ }
943
+ const repositoryDescriptor = Object.getOwnPropertyDescriptor(
944
+ hostCapabilities,
945
+ 'repository',
946
+ );
947
+ const authorityDescriptor = Object.getOwnPropertyDescriptor(
948
+ hostCapabilities,
949
+ 'authority',
950
+ );
951
+ const effectLedgerDescriptor = Object.getOwnPropertyDescriptor(
952
+ hostCapabilities,
953
+ 'effectLedger',
954
+ );
955
+ const repository = repositoryDescriptor?.value;
956
+ const authority = authorityDescriptor?.value;
957
+ const effectLedger = effectLedgerDescriptor?.value;
958
+ if (
959
+ authorityDescriptor === undefined ||
960
+ authorityDescriptor.get !== undefined ||
961
+ authorityDescriptor.set !== undefined ||
962
+ !Object.prototype.hasOwnProperty.call(authorityDescriptor, 'value') ||
963
+ !isPlainObject(authority) ||
964
+ authority.artifactSchema !== 3 ||
965
+ authority.playbookId !== 'decide' ||
966
+ typeof authority.sessionId !== 'string' ||
967
+ authority.sessionId.length === 0 ||
968
+ !Array.isArray(authority.requiredRoleIds) ||
969
+ stableJson(
970
+ [...authority.requiredRoleIds].sort(),
971
+ 'DECIDE host authority required roles',
972
+ ) !== stableJson([...ROLE_IDS].sort(), 'DECIDE required roles') ||
973
+ !Array.isArray(authority.concurrentRoleSets) ||
974
+ authority.concurrentRoleSets.length !== 1 ||
975
+ !Array.isArray(authority.concurrentRoleSets[0]) ||
976
+ stableJson(
977
+ [...authority.concurrentRoleSets[0]].sort(),
978
+ 'DECIDE host authority concurrent roles',
979
+ ) !== stableJson([...ROLE_IDS].sort(), 'DECIDE concurrent roles')
980
+ ) {
981
+ throw new TypeError(
982
+ 'DECIDE schema-3 factory input hostCapabilities.authority must identify one schema-3 playbook session',
983
+ );
984
+ }
985
+ if (
986
+ repositoryDescriptor === undefined ||
987
+ repositoryDescriptor.get !== undefined ||
988
+ repositoryDescriptor.set !== undefined ||
989
+ !Object.prototype.hasOwnProperty.call(repositoryDescriptor, 'value') ||
990
+ !isPlainObject(repository) ||
991
+ typeof repository.runExclusive !== 'function' ||
992
+ typeof repository.runCohort !== 'function' ||
993
+ typeof repository.runDeferred !== 'function'
994
+ ) {
995
+ throw new TypeError(
996
+ 'DECIDE schema-3 factory input hostCapabilities.repository must expose runExclusive, runCohort, and runDeferred functions',
997
+ );
998
+ }
999
+ if (
1000
+ effectLedgerDescriptor === undefined ||
1001
+ effectLedgerDescriptor.get !== undefined ||
1002
+ effectLedgerDescriptor.set !== undefined ||
1003
+ !Object.prototype.hasOwnProperty.call(effectLedgerDescriptor, 'value') ||
1004
+ !isPlainObject(effectLedger) ||
1005
+ typeof effectLedger.snapshot !== 'function' ||
1006
+ typeof effectLedger.writeAhead !== 'function'
1007
+ ) {
1008
+ throw new TypeError(
1009
+ 'DECIDE schema-3 factory input hostCapabilities.effectLedger must expose snapshot and writeAhead functions',
1010
+ );
1011
+ }
1012
+ return {
1013
+ configuredOptions: configuredOptions as PlaybookRuntimeOptions,
1014
+ hostCapabilities: {
1015
+ authority:
1016
+ authority as unknown as DecidePlaybookHostCapabilities['authority'],
1017
+ repository: repository as unknown as DecideRepositoryCapability,
1018
+ effectLedger: effectLedger as unknown as PlaybookEffectLedgerCapability,
1019
+ },
1020
+ };
1021
+ }
1022
+
1023
+ interface DeferredValue<T> {
1024
+ readonly promise: Promise<T>;
1025
+ resolve(value: T): void;
1026
+ reject(reason: unknown): void;
1027
+ }
1028
+
1029
+ function deferredValue<T>(): DeferredValue<T> {
1030
+ let resolve!: (value: T) => void;
1031
+ let reject!: (reason: unknown) => void;
1032
+ const promise = new Promise<T>((onResolve, onReject) => {
1033
+ resolve = onResolve;
1034
+ reject = onReject;
1035
+ });
1036
+ return { promise, resolve, reject };
1037
+ }
1038
+
1039
+ function hasCompleteUnchangedReceipt(
1040
+ boundary: PlaybookEffectBoundary,
1041
+ ): boolean {
1042
+ return boundary.physicalReceipt?.classification === 'unchanged';
1043
+ }
1044
+
1045
+ // A corrective call is bound to the exact physical boundary it would repeat.
1046
+ // A failed-state restart replays the whole entry event. Cooperative host
1047
+ // attempts are serialized in ledger order, so the latest durable boundary
1048
+ // identifies the causal host attempt even when a nested or sibling runtime
1049
+ // wrote it; every boundary in that attempt must have a complete unchanged
1050
+ // receipt.
1051
+ // The durable ledger remains the authority in both cases; no process-local
1052
+ // player result or presentation text can make a replay safe.
1053
+ function createAutomaticReplayPolicy(
1054
+ evidence: Schema3AutomaticReplayEvidence,
1055
+ ): AutomaticReplayPolicy {
1056
+ const readLedger = (): PlaybookEffectLedger =>
1057
+ assertPlaybookEffectLedger(
1058
+ evidence.effectLedger.snapshot(),
1059
+ 'DECIDE automatic-replay effect ledger',
1060
+ );
1061
+
1062
+ return Object.freeze({
1063
+ allowsEmptyOkCorrection(runtimeSessionId: string, callId: string) {
1064
+ const matching = readLedger().boundaries.filter(
1065
+ (boundary) =>
1066
+ boundary.runtimeSessionId === runtimeSessionId &&
1067
+ boundary.callId === callId,
1068
+ );
1069
+ return (
1070
+ matching.length === 1 && hasCompleteUnchangedReceipt(matching[0]!)
1071
+ );
1072
+ },
1073
+
1074
+ allowsFailureStateRetry() {
1075
+ const ledger = readLedger();
1076
+ const latest = ledger.boundaries.at(-1);
1077
+ if (latest === undefined) return false;
1078
+ const attempt = ledger.boundaries.filter(
1079
+ (boundary) => boundary.attemptId === latest.attemptId,
1080
+ );
1081
+ return (
1082
+ attempt.length > 0 && attempt.every(hasCompleteUnchangedReceipt)
1083
+ );
1084
+ },
1085
+ });
1086
+ }
1087
+
583
1088
  function isAbortFailure(error: unknown, signal: AbortSignal): boolean {
584
1089
  return signal.aborted && Object.is(error, signal.reason);
585
1090
  }
586
1091
 
1092
+ interface AbortReasonClassifier {
1093
+ isAbortReason(error: unknown): boolean;
1094
+ }
1095
+
1096
+ function abortReasonClassifier(
1097
+ ...sources: readonly (AbortSignal | AbortReasonClassifier | undefined)[]
1098
+ ): AbortReasonClassifier {
1099
+ const captured = sources.filter(
1100
+ (source): source is AbortSignal | AbortReasonClassifier =>
1101
+ source !== undefined,
1102
+ );
1103
+ return Object.freeze({
1104
+ isAbortReason: (error: unknown): boolean =>
1105
+ captured.some((source) =>
1106
+ source instanceof AbortSignal
1107
+ ? isAbortFailure(error, source)
1108
+ : source.isAbortReason(error),
1109
+ ),
1110
+ });
1111
+ }
1112
+
587
1113
  function pendingQuestionsFromContext(
588
1114
  context: Record<string, unknown>,
589
1115
  ): PendingBossQuestion[] {
@@ -635,6 +1161,33 @@ const STATUS_STATE_IDS: ReadonlySet<string> = new Set([
635
1161
  'failed',
636
1162
  ]);
637
1163
 
1164
+ // PBRT-45: a question is pending only while its authored reply-wait state
1165
+ // is active. The context retains an answered question through the resumed
1166
+ // player call so the Q+A continuation prompt can quote it, and each branch
1167
+ // keeps its own entry through the parallel region — so an unfiltered
1168
+ // projection would report the answered question as still awaiting during
1169
+ // the resume, and both branch questions after only one remains pending.
1170
+ const RESUME_WAIT_STATE_IDS: Readonly<Record<string, string>> = {
1171
+ ...Object.fromEntries(
1172
+ Object.entries(WAIT_STATE_RESUME_IDS).map(([waitStateId, resumeStateId]) => [
1173
+ resumeStateId,
1174
+ waitStateId,
1175
+ ]),
1176
+ ),
1177
+ commitCoderProposal: 'awaitBossReply',
1178
+ };
1179
+
1180
+ function pendingQuestionsForState(
1181
+ state: PlaybookState,
1182
+ context: Record<string, unknown>,
1183
+ ): PendingBossQuestion[] {
1184
+ return pendingQuestionsFromContext(context).filter((pending) =>
1185
+ state.activeStateIds.includes(
1186
+ RESUME_WAIT_STATE_IDS[pending.resumeStateId] ?? '',
1187
+ ),
1188
+ );
1189
+ }
1190
+
638
1191
  function questionForWaitState(
639
1192
  stateId: string,
640
1193
  pendingQuestions: readonly PendingBossQuestion[],
@@ -687,8 +1240,12 @@ function telemetryPayload(
687
1240
  state: PlaybookState,
688
1241
  event: unknown,
689
1242
  context: Record<string, unknown>,
1243
+ hiddenQuestionId?: string,
690
1244
  ): JsonValue {
691
- const pendingBossQuestions = pendingQuestionsFromContext(context);
1245
+ const pendingBossQuestions = pendingQuestionsForState(
1246
+ state,
1247
+ context,
1248
+ ).filter(({ questionId }) => questionId !== hiddenQuestionId);
692
1249
  const prior = previousState ?? state;
693
1250
  const payload = {
694
1251
  from: prior.value,
@@ -705,10 +1262,56 @@ function telemetryPayload(
705
1262
  return payload;
706
1263
  }
707
1264
 
708
- export const createPlaybookRuntime: PlaybookRuntimeFactory<
709
- PlaybookRuntimeOptions
710
- > = (options) => {
1265
+ type DecidePlaybookRuntime = PlaybookRuntime & {
1266
+ _getNestedBridge(): NestedPlaybookBridge<PlaybookInput>;
1267
+ };
1268
+
1269
+ type DeferredContinuationEnvelope = {
1270
+ readonly roleId: RoleId;
1271
+ readonly playerId?: string;
1272
+ readonly callId: string;
1273
+ readonly result: PlayerResult;
1274
+ };
1275
+
1276
+ interface ActiveDeferredContinuation {
1277
+ readonly operationId: string;
1278
+ readonly effectBoundary: DecideEffectBoundarySeed;
1279
+ readonly result: DeferredValue<PlayerResult>;
1280
+ readonly acknowledged: DeferredValue<PlayerResult>;
1281
+ playerContinuation?: string | false;
1282
+ input?: PlayerInput;
1283
+ playerId?: string;
1284
+ }
1285
+
1286
+ interface PendingProposalCohortMember {
1287
+ readonly input: PlayerInput;
1288
+ readonly signal: AbortSignal;
1289
+ readonly callId: string;
1290
+ readonly effectBoundary: DecideEffectBoundarySeed;
1291
+ readonly result: DeferredValue<DeferredContinuationEnvelope>;
1292
+ envelope?: DeferredContinuationEnvelope;
1293
+ }
1294
+
1295
+ function createDecidePlaybookRuntime(
1296
+ options: PlaybookRuntimeOptions,
1297
+ deferredEffects: Schema3DeferredEffectEvidence,
1298
+ ): DecidePlaybookRuntime {
1299
+ const automaticReplayPolicy = createAutomaticReplayPolicy(deferredEffects);
711
1300
  const fsmInput = snapshotDecideRuntimeOptions(options);
1301
+ const readEffectLedger = (): PlaybookEffectLedger =>
1302
+ assertPlaybookEffectLedger(
1303
+ deferredEffects.effectLedger.snapshot(),
1304
+ 'DECIDE current host effect ledger',
1305
+ );
1306
+ let effectLedgerMirror = readEffectLedger();
1307
+ const acceptedOutcomeConsumer = createAcceptedOutcomeConsumer(
1308
+ (source, acceptedOutcome) =>
1309
+ Object.prototype.hasOwnProperty.call(
1310
+ ACCEPTED_OUTCOME_DECLARATIONS,
1311
+ source,
1312
+ ) &&
1313
+ ACCEPTED_OUTCOME_DECLARATIONS[source]?.has(acceptedOutcome) === true,
1314
+ );
712
1315
 
713
1316
  type SessionIdentity = Readonly<PlaybookSession>;
714
1317
 
@@ -716,6 +1319,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
716
1319
  let sessionIdentity: SessionIdentity | undefined;
717
1320
  let actor: ReturnType<typeof createActor> | undefined;
718
1321
  let currentSignal: AbortSignal | undefined;
1322
+ let currentAborts: AbortReasonClassifier | undefined;
1323
+ const actorSettlementAborts: AbortReasonClassifier[] = [];
1324
+ let actorSettlementErrorAborts: AbortReasonClassifier | undefined;
719
1325
  let currentTurnId: number | undefined;
720
1326
  let previousState: PlaybookState | undefined;
721
1327
  let suppressInspectionEmissions = false;
@@ -725,6 +1331,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
725
1331
  let judgeCallSequence = 0;
726
1332
  let playerCallSequence = 0;
727
1333
  let playbookCallSequence = 0;
1334
+ let applyCallSequence = 0;
728
1335
  let lifecycleStarted = false;
729
1336
  let initInFlight: Promise<void> | undefined;
730
1337
  let disposed = false;
@@ -738,6 +1345,35 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
738
1345
  const activeEmissionCalls = new Set<Promise<void>>();
739
1346
  const emissionQueue = new PQueue({ concurrency: 1 });
740
1347
  const judgeQueue = new PQueue({ concurrency: 1 });
1348
+ // A proposal cohort completes both semantic callbacks concurrently at the
1349
+ // repository seam. Serialize each whole adjudication/correction transaction
1350
+ // so their durable correction-budget compare-and-swaps cannot race.
1351
+ const semanticCompletionQueue = new PQueue({ concurrency: 1 });
1352
+ const governedOutputsByBoundaryId = new Map<string, PlayerOutput>();
1353
+ const governedFailuresByBoundaryId = new Map<string, Error>();
1354
+ const governedEvidenceByBoundaryId = new Map<
1355
+ string,
1356
+ { readonly finalText: string; readonly semanticCandidate?: JsonValue }
1357
+ >();
1358
+ const governedReceiptsByBoundaryId = new Map<
1359
+ string,
1360
+ {
1361
+ readonly physicalReceipt: PlaybookRepositoryReceipt;
1362
+ readonly outcomeReceipt: PlaybookRepositoryReceipt;
1363
+ }
1364
+ >();
1365
+ const governedPlayerOutputs = new WeakMap<object, PlayerOutput>();
1366
+ const unresolvedSemanticBoundaryIds = new Set<string>();
1367
+ const appliedControlReceipts = new Map<string, PlaybookControlReceipt>();
1368
+ const pendingProposalCohort = new Map<
1369
+ RoleId,
1370
+ PendingProposalCohortMember
1371
+ >();
1372
+ let activeProposalCohort: Promise<void> | undefined;
1373
+ let completedProposalCohortTurnId: number | undefined;
1374
+ let deferredOperationId: string | undefined;
1375
+ let hiddenDeferredOperationId: string | undefined;
1376
+ let activeDeferredContinuation: ActiveDeferredContinuation | undefined;
741
1377
 
742
1378
  const collectFailure = (failures: unknown[], error: unknown): void => {
743
1379
  if (error instanceof AggregateError) {
@@ -754,26 +1390,35 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
754
1390
  ): void => {
755
1391
  if (!isAbortFailure(error, signal)) controlPlaneError ??= error;
756
1392
  };
757
- const latchInspectionError = (error: unknown): void => {
758
- if (currentSignal !== undefined) {
759
- latchControlPlaneError(error, currentSignal);
760
- } else {
761
- collectFailure(emissionFailures, error);
762
- }
1393
+ const latchInspectionError = (
1394
+ error: unknown,
1395
+ aborts: AbortReasonClassifier | undefined = currentAborts,
1396
+ ): void => {
1397
+ if (aborts?.isAbortReason(error)) return;
1398
+ if (currentSignal !== undefined) controlPlaneError ??= error;
1399
+ else collectFailure(emissionFailures, error);
763
1400
  };
764
- const enqueue = (fn: () => Promise<void>): Promise<void> => {
1401
+ const enqueue = (
1402
+ fn: () => Promise<void>,
1403
+ aborts: AbortReasonClassifier | undefined = currentAborts,
1404
+ ): Promise<void> => {
1405
+ const enqueueAborts = aborts;
765
1406
  const queued = emissionQueue.add(fn);
766
1407
  activeEmissionCalls.add(queued);
767
1408
  void queued.then(
768
1409
  () => activeEmissionCalls.delete(queued),
769
1410
  (error: unknown) => {
770
1411
  activeEmissionCalls.delete(queued);
771
- collectFailure(emissionFailures, error);
1412
+ if (!enqueueAborts?.isAbortReason(error)) {
1413
+ collectFailure(emissionFailures, error);
1414
+ }
772
1415
  },
773
1416
  );
774
1417
  return queued;
775
1418
  };
776
- const flush = async (): Promise<void> => {
1419
+ const flush = async (
1420
+ _aborts: AbortReasonClassifier | undefined = currentAborts,
1421
+ ): Promise<void> => {
777
1422
  while (true) {
778
1423
  const active = [...activeEmissionCalls];
779
1424
  if (active.length > 0) await Promise.allSettled(active);
@@ -789,8 +1434,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
789
1434
  if (emissionFailures.length === 0) return;
790
1435
  const failures = emissionFailures;
791
1436
  emissionFailures = [];
792
- if (failures.length === 1) throw failures[0];
793
- throw new AggregateError(failures, 'decide runtime emissions failed');
1437
+ const failure =
1438
+ failures.length === 1
1439
+ ? failures[0]
1440
+ : new AggregateError(failures, 'decide runtime emissions failed');
1441
+ // Enqueue ownership already classified every stored failure as distinct.
1442
+ // Preserve that classification if an unrelated public boundary drains
1443
+ // it with a signal whose reason happens to be the same object.
1444
+ if (currentSignal !== undefined) controlPlaneError ??= failure;
1445
+ throw failure;
794
1446
  };
795
1447
  const drainBoundaryCallsAndEmissions = async (): Promise<void> => {
796
1448
  while (true) {
@@ -824,6 +1476,11 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
824
1476
 
825
1477
  const bindSession = (nextSession: PlaybookSession): SessionIdentity => {
826
1478
  const bound = snapshotPlaybookSession(nextSession);
1479
+ if (bound.playbookId !== deferredEffects.authority.playbookId) {
1480
+ throw new TypeError(
1481
+ 'DECIDE runtime playbook identity must match its bound schema-3 host authority',
1482
+ );
1483
+ }
827
1484
  if (bound.roleBindings === undefined) return bound;
828
1485
  const actual = Object.keys(bound.roleBindings).sort();
829
1486
  const expected = [...ROLE_IDS].sort();
@@ -1004,68 +1661,379 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1004
1661
  pendingCall,
1005
1662
  });
1006
1663
  };
1007
- const stateIdentity = (state: PlaybookState): { stateId?: string } => {
1008
- return state.stateId === undefined ? {} : { stateId: state.stateId };
1009
- };
1010
- const enqueueTracedEmission = (
1011
- type: PlaybookTraceType,
1012
- payload: unknown,
1013
- meta: { turnId?: number; callId?: string } = {},
1014
- describedEmission?: (runtimePorts: PlaybookPorts) => Promise<void>,
1015
- ): Promise<void> => {
1016
- const runtimePorts = requirePorts();
1017
- const identity = requireSessionIdentity();
1018
- const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
1019
- const trace: PlaybookTraceEvent = Object.freeze({
1020
- schemaVersion: 3,
1021
- sessionId: identity.sessionId,
1022
- playbookId: identity.playbookId,
1023
- rootSessionId: identity.rootSessionId,
1024
- ...(identity.parentSessionId !== undefined
1025
- ? { parentSessionId: identity.parentSessionId }
1026
- : {}),
1027
- ...(identity.parentCallId !== undefined
1028
- ? { parentCallId: identity.parentCallId }
1029
- : {}),
1030
- depth: identity.depth,
1031
- sequence: ++traceSequence,
1032
- timestamp: Date.now(),
1033
- type,
1034
- ...(meta.turnId !== undefined ? { turnId: meta.turnId } : {}),
1035
- ...(meta.callId !== undefined ? { callId: meta.callId } : {}),
1036
- payload: jsonPayload,
1037
- });
1038
- return enqueue(async () => {
1039
- await runtimePorts.emitTelemetry({ topic: TRACE_TOPIC, payload: trace });
1040
- await describedEmission?.(runtimePorts);
1041
- });
1042
- };
1043
- const emitTrace = (
1044
- type: PlaybookTraceType,
1045
- payload: unknown,
1046
- meta: { turnId?: number; callId?: string } = {},
1047
- ): Promise<void> => enqueueTracedEmission(type, payload, meta);
1048
- const emitBoundaryStatus = async (
1049
- message: string,
1664
+ const visiblePendingQuestionsForState = (
1050
1665
  state: PlaybookState,
1051
- ): Promise<void> => {
1052
- const bossRelevantStateIds = state.activeStateIds.filter((stateId) =>
1053
- STATUS_STATE_IDS.has(stateId),
1666
+ context: Record<string, unknown>,
1667
+ ): PendingBossQuestion[] =>
1668
+ pendingQuestionsForState(state, context).filter(
1669
+ ({ questionId }) =>
1670
+ questionId !== 'commitCoderProposal' ||
1671
+ hiddenDeferredOperationId === undefined,
1054
1672
  );
1055
- await enqueueTracedEmission(
1056
- 'status.emitted',
1057
- {
1058
- ...(bossRelevantStateIds.length === 1
1059
- ? { stateId: bossRelevantStateIds[0] }
1060
- : {}),
1061
- message,
1062
- state,
1063
- },
1064
- { turnId: currentTurnId },
1065
- (runtimePorts) => runtimePorts.emitStatus(message),
1673
+ const openDeferredOperation = (
1674
+ ledger: PlaybookEffectLedger = effectLedgerMirror,
1675
+ ) => {
1676
+ if (sessionIdentity === undefined) return undefined;
1677
+ const open = ledger.logicalOperations.filter(
1678
+ (operation) =>
1679
+ operation.playbookId === sessionIdentity!.playbookId &&
1680
+ operation.runtimeSessionId === sessionIdentity!.sessionId &&
1681
+ operation.logicalReceipt === undefined,
1066
1682
  );
1683
+ if (open.length > 1) {
1684
+ throw new TypeError(
1685
+ 'DECIDE runtime has multiple open deferred logical operations',
1686
+ );
1687
+ }
1688
+ return open[0];
1067
1689
  };
1068
-
1690
+ const runtimeBoundaryIsOwned = (
1691
+ boundary: PlaybookEffectBoundary,
1692
+ ): boolean =>
1693
+ sessionIdentity !== undefined &&
1694
+ boundary.playbookId === sessionIdentity.playbookId &&
1695
+ boundary.runtimeSessionId === sessionIdentity.sessionId;
1696
+ const governedOutcomesForBoundary = (
1697
+ boundary: PlaybookEffectBoundary,
1698
+ ): Readonly<Record<string, PlaybookSemanticOutcomeSpec>> | undefined => {
1699
+ const outcomes = (
1700
+ DECIDE_OUTCOME_AUTHORITY.governedPlayerStates as Readonly<
1701
+ Record<string, Readonly<Record<string, PlaybookSemanticOutcomeSpec>>>
1702
+ >
1703
+ )[boundary.sourceStateId];
1704
+ if (
1705
+ outcomes === undefined ||
1706
+ !isPlainObject(boundary.sourceOutcomeSchema)
1707
+ ) {
1708
+ return undefined;
1709
+ }
1710
+ const authoredGuards = Object.keys(boundary.sourceOutcomeSchema).sort();
1711
+ const governedGuards = Object.keys(outcomes).sort();
1712
+ if (
1713
+ authoredGuards.length !== governedGuards.length ||
1714
+ authoredGuards.some((guard, index) => guard !== governedGuards[index])
1715
+ ) {
1716
+ return undefined;
1717
+ }
1718
+ for (const guard of governedGuards) {
1719
+ const description = boundary.sourceOutcomeSchema[guard];
1720
+ if (typeof description !== 'string') return undefined;
1721
+ const authoredFields = [
1722
+ ...new Set(requiredFieldsFor(description)),
1723
+ ].sort();
1724
+ const governedFields = Object.keys(outcomes[guard]!.fields).sort();
1725
+ if (
1726
+ authoredFields.length !== governedFields.length ||
1727
+ authoredFields.some(
1728
+ (field, index) => field !== governedFields[index],
1729
+ )
1730
+ ) {
1731
+ return undefined;
1732
+ }
1733
+ }
1734
+ const dispositions = [
1735
+ ...new Set(
1736
+ Object.values(outcomes).map(
1737
+ ({ repositoryDisposition }) => repositoryDisposition,
1738
+ ),
1739
+ ),
1740
+ ];
1741
+ const actualDispositions = new Set(boundary.dispositions);
1742
+ return actualDispositions.size === boundary.dispositions.length &&
1743
+ actualDispositions.size === dispositions.length &&
1744
+ dispositions.every((disposition) => actualDispositions.has(disposition))
1745
+ ? outcomes
1746
+ : undefined;
1747
+ };
1748
+ const persistedBoundaryReconciliation = (
1749
+ boundary: PlaybookEffectBoundary,
1750
+ ledger: PlaybookEffectLedger,
1751
+ ) => {
1752
+ const outcomes = governedOutcomesForBoundary(boundary);
1753
+ if (outcomes === undefined || boundary.semanticCandidate === undefined) {
1754
+ return undefined;
1755
+ }
1756
+ let receipt = boundary.physicalReceipt;
1757
+ let awaitingLogicalReceipt = false;
1758
+ let historicalDeferred = false;
1759
+ const logicalOperation =
1760
+ boundary.logicalOperationId === undefined
1761
+ ? undefined
1762
+ : ledger.logicalOperations.find(
1763
+ ({ operationId }) =>
1764
+ operationId === boundary.logicalOperationId,
1765
+ );
1766
+ if (
1767
+ boundary.logicalOperationId !== undefined &&
1768
+ logicalOperation === undefined
1769
+ ) {
1770
+ return undefined;
1771
+ }
1772
+ if (logicalOperation !== undefined) {
1773
+ if (logicalOperation.boundaryIds.at(-1) !== boundary.boundaryId) {
1774
+ historicalDeferred = true;
1775
+ } else if (logicalOperation.logicalReceipt !== undefined) {
1776
+ receipt = logicalOperation.logicalReceipt;
1777
+ } else if (
1778
+ logicalOperation.pendingQuestion === undefined ||
1779
+ logicalOperation.checkpoint === undefined ||
1780
+ !Object.prototype.hasOwnProperty.call(
1781
+ logicalOperation,
1782
+ 'playerContinuation',
1783
+ )
1784
+ ) {
1785
+ return undefined;
1786
+ } else {
1787
+ awaitingLogicalReceipt = true;
1788
+ }
1789
+ }
1790
+ try {
1791
+ const reconciliation = reconcilePlaybookSemanticEvidence({
1792
+ outcomes,
1793
+ semanticCandidate: boundary.semanticCandidate,
1794
+ finalText: boundary.finalText,
1795
+ receipt,
1796
+ });
1797
+ if (awaitingLogicalReceipt && reconciliation.status !== 'deferred') {
1798
+ return undefined;
1799
+ }
1800
+ return { reconciliation, historicalDeferred };
1801
+ } catch {
1802
+ return undefined;
1803
+ }
1804
+ };
1805
+ const boundaryNeedsSemanticReconciliation = (
1806
+ boundary: PlaybookEffectBoundary,
1807
+ ledger: PlaybookEffectLedger,
1808
+ ): boolean => {
1809
+ if (!runtimeBoundaryIsOwned(boundary)) return false;
1810
+ if (governedOutcomesForBoundary(boundary) === undefined) return true;
1811
+ const persisted = persistedBoundaryReconciliation(boundary, ledger);
1812
+ if (persisted !== undefined) {
1813
+ if (persisted.reconciliation.status === 'unresolved') return true;
1814
+ if (persisted.historicalDeferred) {
1815
+ return persisted.reconciliation.status !== 'deferred';
1816
+ }
1817
+ if (
1818
+ persisted.reconciliation.status === 'deferred' &&
1819
+ boundary.logicalOperationId === undefined
1820
+ ) {
1821
+ return true;
1822
+ }
1823
+ return false;
1824
+ }
1825
+ if (boundary.physicalReceipt === undefined) return true;
1826
+ if (
1827
+ typeof boundary.finalText === 'string' &&
1828
+ boundary.finalText.trim().length > 0
1829
+ ) {
1830
+ return true;
1831
+ }
1832
+ return boundary.physicalReceipt.classification !== 'unchanged';
1833
+ };
1834
+ const refreshUnresolvedSemanticReconciliation = (
1835
+ ledger: PlaybookEffectLedger = effectLedgerMirror,
1836
+ ): void => {
1837
+ unresolvedSemanticBoundaryIds.clear();
1838
+ for (const boundary of ledger.boundaries) {
1839
+ if (boundaryNeedsSemanticReconciliation(boundary, ledger)) {
1840
+ unresolvedSemanticBoundaryIds.add(boundary.boundaryId);
1841
+ }
1842
+ }
1843
+ };
1844
+ const synchronizeDeferredProjection = (
1845
+ ledger: PlaybookEffectLedger = effectLedgerMirror,
1846
+ ): void => {
1847
+ if (deferredEffects === undefined) return;
1848
+ effectLedgerMirror = ledger;
1849
+ refreshUnresolvedSemanticReconciliation(ledger);
1850
+ const operation = openDeferredOperation(ledger);
1851
+ if (operation === undefined) {
1852
+ deferredOperationId = undefined;
1853
+ hiddenDeferredOperationId = undefined;
1854
+ return;
1855
+ }
1856
+ deferredOperationId = operation.operationId;
1857
+ hiddenDeferredOperationId =
1858
+ operation.logicalReceipt === undefined &&
1859
+ (operation.checkpointRestorationEligible ||
1860
+ operation.pendingQuestion === undefined)
1861
+ ? operation.operationId
1862
+ : undefined;
1863
+ };
1864
+ const hasUnresolvedReconciliation = (): boolean =>
1865
+ hiddenDeferredOperationId !== undefined ||
1866
+ unresolvedSemanticBoundaryIds.size > 0;
1867
+ const refreshReconciliationProjection = (): void => {
1868
+ synchronizeDeferredProjection(readEffectLedger());
1869
+ };
1870
+ const unresolvedEffectEnvelopeIdentities = (): readonly (
1871
+ | { readonly kind: 'boundary'; readonly boundaryId: string }
1872
+ | { readonly kind: 'logical-operation'; readonly operationId: string }
1873
+ )[] => {
1874
+ if (sessionIdentity === undefined) return [];
1875
+ refreshReconciliationProjection();
1876
+ if (!hasUnresolvedReconciliation()) return [];
1877
+ const boundaryIds = new Set(unresolvedSemanticBoundaryIds);
1878
+ const operationIds = new Set<string>();
1879
+ if (hiddenDeferredOperationId !== undefined) {
1880
+ operationIds.add(hiddenDeferredOperationId);
1881
+ }
1882
+ for (const boundaryId of [...boundaryIds]) {
1883
+ const boundary = effectLedgerMirror.boundaries.find(
1884
+ (candidate) => candidate.boundaryId === boundaryId,
1885
+ );
1886
+ const operation =
1887
+ boundary?.logicalOperationId === undefined
1888
+ ? undefined
1889
+ : effectLedgerMirror.logicalOperations.find(
1890
+ ({ operationId }) =>
1891
+ operationId === boundary.logicalOperationId,
1892
+ );
1893
+ if (operation === undefined) continue;
1894
+ operationIds.add(operation.operationId);
1895
+ for (const memberId of operation.boundaryIds) {
1896
+ boundaryIds.delete(memberId);
1897
+ }
1898
+ }
1899
+ const ordered = [
1900
+ ...[...boundaryIds].map((boundaryId) => ({
1901
+ order:
1902
+ effectLedgerMirror.boundaries.find(
1903
+ (candidate) => candidate.boundaryId === boundaryId,
1904
+ )?.sequence ?? Number.MAX_SAFE_INTEGER,
1905
+ value: { kind: 'boundary' as const, boundaryId },
1906
+ })),
1907
+ ...[...operationIds].map((operationId) => {
1908
+ const operation = effectLedgerMirror.logicalOperations.find(
1909
+ (candidate) => candidate.operationId === operationId,
1910
+ );
1911
+ return {
1912
+ order:
1913
+ effectLedgerMirror.boundaries.find(
1914
+ ({ boundaryId }) =>
1915
+ boundaryId === operation?.boundaryIds[0],
1916
+ )?.sequence ?? Number.MAX_SAFE_INTEGER,
1917
+ value: { kind: 'logical-operation' as const, operationId },
1918
+ };
1919
+ }),
1920
+ ].sort((left, right) => left.order - right.order);
1921
+ return deepFreeze(
1922
+ snapshotJsonValue(
1923
+ ordered.map(({ value }) => value),
1924
+ 'DECIDE unresolved effect envelope identities',
1925
+ ) as unknown as (
1926
+ | { readonly kind: 'boundary'; readonly boundaryId: string }
1927
+ | { readonly kind: 'logical-operation'; readonly operationId: string }
1928
+ )[],
1929
+ );
1930
+ };
1931
+ const stateIdentity = (state: PlaybookState): { stateId?: string } => {
1932
+ return state.stateId === undefined ? {} : { stateId: state.stateId };
1933
+ };
1934
+ const enqueueTracedEmission = (
1935
+ type: PlaybookTraceType,
1936
+ payload: unknown,
1937
+ meta: { turnId?: number; callId?: string } = {},
1938
+ describedEmission?: (runtimePorts: PlaybookPorts) => Promise<void>,
1939
+ aborts?: AbortReasonClassifier,
1940
+ ): Promise<void> => {
1941
+ const trace = createTraceEvent(type, payload, meta);
1942
+ return enqueue(
1943
+ async () => {
1944
+ const runtimePorts = requirePorts();
1945
+ await runtimePorts.emitTelemetry({ topic: TRACE_TOPIC, payload: trace });
1946
+ await describedEmission?.(runtimePorts);
1947
+ },
1948
+ aborts,
1949
+ );
1950
+ };
1951
+ const createTraceEvent = (
1952
+ type: PlaybookTraceType,
1953
+ payload: unknown,
1954
+ meta: { turnId?: number; callId?: string } = {},
1955
+ ): PlaybookTraceEvent => {
1956
+ const identity = requireSessionIdentity();
1957
+ const jsonPayload = snapshotJsonValue(payload, `trace ${type} payload`);
1958
+ return Object.freeze({
1959
+ schemaVersion: 4,
1960
+ sessionId: identity.sessionId,
1961
+ playbookId: identity.playbookId,
1962
+ rootSessionId: identity.rootSessionId,
1963
+ ...(identity.parentSessionId !== undefined
1964
+ ? { parentSessionId: identity.parentSessionId }
1965
+ : {}),
1966
+ ...(identity.parentCallId !== undefined
1967
+ ? { parentCallId: identity.parentCallId }
1968
+ : {}),
1969
+ depth: identity.depth,
1970
+ sequence: ++traceSequence,
1971
+ timestamp: Date.now(),
1972
+ type,
1973
+ ...(meta.turnId !== undefined ? { turnId: meta.turnId } : {}),
1974
+ ...(meta.callId !== undefined ? { callId: meta.callId } : {}),
1975
+ payload: jsonPayload,
1976
+ });
1977
+ };
1978
+ const emitTrace = (
1979
+ type: PlaybookTraceType,
1980
+ payload: unknown,
1981
+ meta: { turnId?: number; callId?: string } = {},
1982
+ aborts?: AbortReasonClassifier,
1983
+ ): Promise<void> => enqueueTracedEmission(type, payload, meta, undefined, aborts);
1984
+ const enqueueAcceptedOutcomeEmission = (
1985
+ acceptedOutcome: AcceptedOutcomeReceipt,
1986
+ state: PlaybookState,
1987
+ aborts?: AbortReasonClassifier,
1988
+ ): Promise<void> => {
1989
+ const message = `→ ${acceptedOutcome.acceptedOutcome}`;
1990
+ const acceptedTrace = createTraceEvent(
1991
+ 'outcome.accepted',
1992
+ acceptedOutcome,
1993
+ { turnId: currentTurnId },
1994
+ );
1995
+ const statusTrace = createTraceEvent(
1996
+ 'status.emitted',
1997
+ { stateId: acceptedOutcome.target, message, state },
1998
+ { turnId: currentTurnId },
1999
+ );
2000
+ return enqueue(
2001
+ async () => {
2002
+ const runtimePorts = requirePorts();
2003
+ await runtimePorts.emitTelemetry({
2004
+ topic: TRACE_TOPIC,
2005
+ payload: acceptedTrace,
2006
+ });
2007
+ await runtimePorts.emitTelemetry({
2008
+ topic: TRACE_TOPIC,
2009
+ payload: statusTrace,
2010
+ });
2011
+ await runtimePorts.emitStatus(message);
2012
+ },
2013
+ aborts,
2014
+ );
2015
+ };
2016
+ const emitBoundaryStatus = async (
2017
+ message: string,
2018
+ state: PlaybookState,
2019
+ ): Promise<void> => {
2020
+ const bossRelevantStateIds = state.activeStateIds.filter((stateId) =>
2021
+ STATUS_STATE_IDS.has(stateId),
2022
+ );
2023
+ await enqueueTracedEmission(
2024
+ 'status.emitted',
2025
+ {
2026
+ ...(bossRelevantStateIds.length === 1
2027
+ ? { stateId: bossRelevantStateIds[0] }
2028
+ : {}),
2029
+ message,
2030
+ state,
2031
+ },
2032
+ { turnId: currentTurnId },
2033
+ (runtimePorts) => runtimePorts.emitStatus(message),
2034
+ );
2035
+ };
2036
+
1069
2037
  const emitCallStarted = async (
1070
2038
  startedType: 'player.call.started' | 'judge.call.started',
1071
2039
  finishedType: 'player.call.finished' | 'judge.call.finished',
@@ -1073,8 +2041,9 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1073
2041
  meta: { turnId?: number; callId?: string },
1074
2042
  signal: AbortSignal,
1075
2043
  ): Promise<void> => {
2044
+ const aborts = abortReasonClassifier(signal);
1076
2045
  try {
1077
- await emitTrace(startedType, identity, meta);
2046
+ await emitTrace(startedType, identity, meta, aborts);
1078
2047
  } catch (error) {
1079
2048
  latchControlPlaneError(error, signal);
1080
2049
  try {
@@ -1082,13 +2051,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1082
2051
  finishedType,
1083
2052
  {
1084
2053
  ...identity,
1085
- status: 'error',
2054
+ // A started-trace sink rejection causally identical to the
2055
+ // boundary reason is the abort's own evidence: the pair
2056
+ // finishes 'aborted', not 'error' (DR-036 §4).
2057
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1086
2058
  error: normalizeErrorFull(error) ?? {
1087
2059
  name: 'Error',
1088
2060
  message: String(error),
1089
2061
  },
1090
2062
  },
1091
2063
  meta,
2064
+ aborts,
1092
2065
  );
1093
2066
  } catch {
1094
2067
  // Preserve the start failure after one best-effort finish attempt.
@@ -1103,6 +2076,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1103
2076
  purpose: 'boss-input-classification' | 'player-output-adjudication',
1104
2077
  callStateId: string | undefined,
1105
2078
  ): Promise<string> => {
2079
+ const aborts = abortReasonClassifier(signal);
1106
2080
  const identity = {
1107
2081
  purpose,
1108
2082
  ...(callStateId !== undefined ? { stateId: callStateId } : {}),
@@ -1138,13 +2112,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1138
2112
  'judge.call.finished',
1139
2113
  {
1140
2114
  ...identity,
1141
- status: signal.aborted ? 'aborted' : 'error',
2115
+ // Only the exact abort reason is cancellation; a distinct
2116
+ // failure under an aborted signal stays an error
2117
+ // (slc/link.md §Abort).
2118
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1142
2119
  error: normalizeErrorFull(error) ?? {
1143
2120
  name: 'Error',
1144
2121
  message: String(error),
1145
2122
  },
1146
2123
  },
1147
2124
  { turnId: currentTurnId, callId },
2125
+ aborts,
1148
2126
  );
1149
2127
  throw error;
1150
2128
  }
@@ -1153,6 +2131,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1153
2131
  'judge.call.finished',
1154
2132
  { ...identity, status: 'ok', reply: finalText },
1155
2133
  { turnId: currentTurnId, callId },
2134
+ aborts,
1156
2135
  );
1157
2136
  return finalText;
1158
2137
  });
@@ -1173,11 +2152,12 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1173
2152
  const runPlayerCall = async (
1174
2153
  input: PlayerInput,
1175
2154
  signal: AbortSignal,
1176
- ): Promise<{
1177
- roleId: RoleId;
1178
- playerId?: string;
1179
- result: PlayerResult;
1180
- }> => {
2155
+ continuation?: {
2156
+ readonly resume?: string | false;
2157
+ readonly callId: string;
2158
+ },
2159
+ ): Promise<DeferredContinuationEnvelope> => {
2160
+ const aborts = abortReasonClassifier(signal);
1181
2161
  if (!ROLE_ID_SET.has(input.role)) {
1182
2162
  throw new TypeError(
1183
2163
  `DECIDE player input role must name a declared local role`,
@@ -1190,12 +2170,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1190
2170
  let resume: PlayerCallOptions['resume'];
1191
2171
  try {
1192
2172
  signal.throwIfAborted();
1193
- resume = selectPlayerResume(roleId, playerId);
2173
+ resume =
2174
+ continuation !== undefined &&
2175
+ Object.prototype.hasOwnProperty.call(continuation, 'resume')
2176
+ ? continuation.resume!
2177
+ : selectPlayerResume(roleId, playerId);
1194
2178
  } catch (error) {
1195
2179
  latchControlPlaneError(error, signal);
1196
2180
  throw error;
1197
2181
  }
1198
- const callId = `player-${++playerCallSequence}`;
2182
+ const callId =
2183
+ continuation?.callId ?? `player-${++playerCallSequence}`;
1199
2184
  const identity = {
1200
2185
  stateId: input.stateId,
1201
2186
  sourceItem: input.sourceItem,
@@ -1208,13 +2193,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1208
2193
  'player.call.finished',
1209
2194
  {
1210
2195
  ...identity,
1211
- status: signal.aborted ? 'aborted' : 'error',
2196
+ // Only the exact abort reason is cancellation; a distinct
2197
+ // failure under an aborted signal stays an error
2198
+ // (slc/link.md §Abort).
2199
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
1212
2200
  error: normalizeErrorFull(error) ?? {
1213
2201
  name: 'Error',
1214
2202
  message: String(error),
1215
2203
  },
1216
2204
  },
1217
2205
  { turnId: currentTurnId, callId },
2206
+ aborts,
1218
2207
  );
1219
2208
  if (inFlightPlayerKeys.has(playerKey)) {
1220
2209
  const error = new Error(
@@ -1310,10 +2299,12 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1310
2299
  : {}),
1311
2300
  },
1312
2301
  { turnId: currentTurnId, callId },
2302
+ aborts,
1313
2303
  );
1314
2304
  return {
1315
2305
  roleId,
1316
2306
  ...(playerId === undefined ? {} : { playerId }),
2307
+ callId,
1317
2308
  result,
1318
2309
  };
1319
2310
  } finally {
@@ -1321,153 +2312,1060 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1321
2312
  }
1322
2313
  };
1323
2314
 
1324
- const callPlayer = (
2315
+ const governedBoundarySeed = (
1325
2316
  input: PlayerInput,
1326
- signal: AbortSignal,
1327
- ): Promise<{
1328
- roleId: RoleId;
1329
- playerId?: string;
1330
- result: PlayerResult;
1331
- }> => {
1332
- return trackBoundaryCall(runPlayerCall(input, signal));
2317
+ callId: string,
2318
+ ): DecideEffectBoundarySeed => {
2319
+ const outcomes = governedOutcomesFor(input);
2320
+ if (
2321
+ currentTurnId === undefined ||
2322
+ !Number.isSafeInteger(currentTurnId) ||
2323
+ currentTurnId <= 0
2324
+ ) {
2325
+ throw new Error(
2326
+ 'DECIDE governed player call requires an active positive turn id',
2327
+ );
2328
+ }
2329
+ return {
2330
+ boundaryId: randomUUID(),
2331
+ runtimeSessionId: requireSessionIdentity().sessionId,
2332
+ turnId: currentTurnId,
2333
+ callId,
2334
+ roleId: input.role,
2335
+ sourceStateId: input.stateId,
2336
+ sourceOutcomeSchema: snapshotJsonValue(
2337
+ input.result,
2338
+ 'DECIDE governed player source outcome schema',
2339
+ ),
2340
+ dispositions: [
2341
+ ...new Set(
2342
+ Object.values(outcomes).map(
2343
+ ({ repositoryDisposition }) => repositoryDisposition,
2344
+ ),
2345
+ ),
2346
+ ],
2347
+ correctionBudget: { limit: 1, spent: false },
2348
+ };
1333
2349
  };
1334
2350
 
1335
- const player = fromPromise<PlayerOutput, PlayerInput>(
1336
- async ({ input, signal }) => {
1337
- const combined = combineSignals(signal, currentSignal);
1338
- // XState starts invoked actors while publishing the entering snapshot.
1339
- // Yield through the runtime emission queue before crossing the player
1340
- // boundary so state trace/status always precede its call-start trace.
1341
- try {
1342
- await flush();
1343
- } catch (error) {
1344
- latchControlPlaneError(error, combined);
1345
- throw error;
1346
- }
1347
- combined.throwIfAborted();
1348
-
1349
- let { roleId, playerId, result } = await callPlayer(input, combined);
1350
- if (result.status === 'ok' && isEmptyFinalText(result.finalText)) {
1351
- // DR-028: an `ok` result whose finalText is missing, empty, or
1352
- // whitespace-only earns exactly one corrective re-ask — the same
1353
- // composed call repeated, traced by runPlayerCall as its own
1354
- // player-call pair, with the resume selection re-read from the
1355
- // token map the first result left (PBRT-38). An abort that lands
1356
- // between the two calls ends the turn without the re-ask (aborts
1357
- // are never retried), and a rejecting finish emission rejects
1358
- // `callPlayer` itself, so it never reaches this branch (PBRT-47).
1359
- combined.throwIfAborted();
1360
- ({ roleId, playerId, result } = await callPlayer(input, combined));
1361
- }
1362
- if (result.status !== 'ok') {
1363
- throw new Error(
1364
- `${roleLabel(roleId)}${
1365
- playerId === undefined ? '' : ` (${playerId})`
1366
- } returned status "${result.status}"${
1367
- result.error ? `: ${result.error}` : ''
1368
- }`,
1369
- );
1370
- }
1371
- const finalText = result.finalText ?? '';
1372
- if (isEmptyFinalText(finalText)) {
1373
- throw new Error(
1374
- `${roleLabel(roleId)}${
1375
- playerId === undefined ? '' : ` (${playerId})`
1376
- } returned status "ok" with no finalText`,
1377
- );
1378
- }
1379
- combined.throwIfAborted();
1380
-
1381
- try {
1382
- const prompt = buildAdjudicatorPrompt(input, finalText);
1383
- return parseAdjudication(
1384
- await callJudge(
1385
- prompt,
1386
- combined,
1387
- 'player-output-adjudication',
1388
- input.stateId,
1389
- ),
1390
- input,
1391
- finalText,
1392
- );
1393
- } catch (error) {
1394
- latchControlPlaneError(error, combined);
1395
- throw error;
1396
- }
1397
- },
1398
- );
2351
+ const unresolvedGovernedEvidence = (
2352
+ boundaryId: string,
2353
+ reason: string,
2354
+ error?: unknown,
2355
+ ): void => {
2356
+ governedFailuresByBoundaryId.set(
2357
+ boundaryId,
2358
+ error instanceof Error
2359
+ ? error
2360
+ : new Error(`DECIDE governed outcome remains unresolved: ${reason}`),
2361
+ );
2362
+ };
1399
2363
 
1400
- nestedBridge = createNestedPlaybookBridge<PlaybookInput>({
1401
- nextCallId: () => `playbook-${++playbookCallSequence}`,
1402
- getBoundarySignal: () => currentSignal,
1403
- callPlaybook: (request, signal) =>
1404
- trackBoundaryCall(
1405
- Promise.resolve(requirePorts().callPlaybook(request, signal)),
1406
- ),
1407
- emitStarted: async (event) => {
1408
- playbookCallTurnIds.set(event.callId, currentTurnId);
1409
- await emitTrace(
1410
- 'playbook.call.started',
1411
- {
1412
- stateId: event.stateId,
1413
- playbookId: event.playbookId,
1414
- text: event.text,
1415
- },
2364
+ const spendSemanticCorrectionBudget = async (
2365
+ boundary: PlaybookEffectBoundary,
2366
+ receipt: PlaybookRepositoryReceipt,
2367
+ finalText: string,
2368
+ semanticCandidate: JsonValue | undefined,
2369
+ ): Promise<boolean> => {
2370
+ const ledger = readEffectLedger();
2371
+ const current = ledger.boundaries.find(
2372
+ ({ boundaryId }) => boundaryId === boundary.boundaryId,
2373
+ );
2374
+ if (
2375
+ current === undefined ||
2376
+ current.correctionBudget.limit !== 1 ||
2377
+ current.correctionBudget.spent
2378
+ ) {
2379
+ return false;
2380
+ }
2381
+ if (current.finalText !== undefined && current.finalText !== finalText) {
2382
+ throw new TypeError(
2383
+ 'DECIDE correction budget conflicts with retained finalText',
2384
+ );
2385
+ }
2386
+ if (
2387
+ current.physicalReceipt !== undefined &&
2388
+ stableJson(current.physicalReceipt, 'DECIDE retained receipt') !==
2389
+ stableJson(receipt, 'DECIDE correction receipt')
2390
+ ) {
2391
+ throw new TypeError(
2392
+ 'DECIDE correction budget conflicts with retained repository receipt',
2393
+ );
2394
+ }
2395
+ if (
2396
+ semanticCandidate !== undefined &&
2397
+ current.semanticCandidate !== undefined &&
2398
+ stableJson(current.semanticCandidate, 'DECIDE retained candidate') !==
2399
+ stableJson(semanticCandidate, 'DECIDE correction candidate')
2400
+ ) {
2401
+ throw new TypeError(
2402
+ 'DECIDE correction budget conflicts with retained semantic candidate',
2403
+ );
2404
+ }
2405
+ const next: PlaybookEffectBoundary = {
2406
+ ...current,
2407
+ ...(receipt.after === undefined ? {} : { after: receipt.after }),
2408
+ physicalReceipt: receipt,
2409
+ finalText,
2410
+ ...(semanticCandidate === undefined ? {} : { semanticCandidate }),
2411
+ correctionBudget: { limit: 1, spent: true },
2412
+ };
2413
+ const cohortMembers =
2414
+ current.cohortId === undefined
2415
+ ? [current]
2416
+ : ledger.boundaries.filter(
2417
+ ({ cohortId }) => cohortId === current.cohortId,
2418
+ );
2419
+ if (cohortMembers.length === 0) {
2420
+ throw new TypeError(
2421
+ 'DECIDE correction boundary lost its repository cohort',
2422
+ );
2423
+ }
2424
+ const replacements = cohortMembers.map((member) => ({
2425
+ expected: member,
2426
+ next:
2427
+ member.boundaryId === current.boundaryId
2428
+ ? next
2429
+ : {
2430
+ ...member,
2431
+ ...(receipt.after === undefined
2432
+ ? {}
2433
+ : { after: receipt.after }),
2434
+ physicalReceipt: receipt,
2435
+ },
2436
+ })) as [
2437
+ {
2438
+ readonly expected: PlaybookEffectBoundary;
2439
+ readonly next: PlaybookEffectBoundary;
2440
+ },
2441
+ ...{
2442
+ readonly expected: PlaybookEffectBoundary;
2443
+ readonly next: PlaybookEffectBoundary;
2444
+ }[],
2445
+ ];
2446
+ const acknowledged = assertPlaybookEffectLedger(
2447
+ await deferredEffects.effectLedger.writeAhead([
1416
2448
  {
1417
- ...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
1418
- callId: event.callId,
2449
+ kind: 'replace-boundaries',
2450
+ // A cohort's shared receipt must become visible for every member in
2451
+ // one ledger revision. Publishing only this member's correction
2452
+ // spend would transiently create an invalid half-complete cohort.
2453
+ replacements,
1419
2454
  },
2455
+ ]),
2456
+ 'DECIDE semantic correction budget acknowledgement',
2457
+ );
2458
+ synchronizeDeferredProjection(acknowledged);
2459
+ const spent = acknowledged.boundaries.find(
2460
+ ({ boundaryId }) => boundaryId === boundary.boundaryId,
2461
+ );
2462
+ if (
2463
+ spent === undefined ||
2464
+ stableJson(spent, 'DECIDE acknowledged correction boundary') !==
2465
+ stableJson(next, 'DECIDE expected correction boundary')
2466
+ ) {
2467
+ throw new TypeError(
2468
+ 'DECIDE semantic correction budget spend was not acknowledged exactly',
1420
2469
  );
1421
- },
1422
- emitFinished: async (event) => {
1423
- const turnId = playbookCallTurnIds.get(event.callId);
1424
- try {
1425
- await emitTrace(
1426
- 'playbook.call.finished',
1427
- {
1428
- stateId: event.stateId,
1429
- playbookId: event.playbookId,
1430
- text: event.text,
1431
- result: event.result,
1432
- },
1433
- {
1434
- ...(turnId === undefined ? {} : { turnId }),
1435
- callId: event.callId,
1436
- },
1437
- );
1438
- } finally {
1439
- playbookCallTurnIds.delete(event.callId);
1440
- }
1441
- },
1442
- drain: flush,
1443
- bindResumeSignal: (signal) => {
1444
- currentSignal = signal;
1445
- },
1446
- onControlPlaneError: (error) => {
1447
- const signal = currentSignal;
1448
- if (!signal || !isAbortFailure(error, signal)) {
1449
- controlPlaneError ??= error;
1450
- }
1451
- },
1452
- onBackgroundError: (error) => {
1453
- collectFailure(emissionFailures, error);
1454
- },
1455
- });
2470
+ }
2471
+ return true;
2472
+ };
1456
2473
 
1457
- const providedMachine = decideMachine.provide({
1458
- actors: { player, playbook: nestedBridge.actorLogic },
2474
+ const completionEvidenceFor = (
2475
+ input: PlayerInput,
2476
+ roleId: RoleId,
2477
+ playerId: string | undefined,
2478
+ signal: AbortSignal,
2479
+ operationId?: string,
2480
+ ) =>
2481
+ async (
2482
+ completion: DecideRepositoryCompletion<PlayerResult>,
2483
+ ): Promise<DecideRepositoryCompletionEvidence> => {
2484
+ const queued = await semanticCompletionQueue.add(async () => {
2485
+ const { boundary, operation } = completion;
2486
+ const outcomes = governedOutcomesFor(input);
2487
+ const expectedDispositions = [
2488
+ ...new Set(
2489
+ Object.values(outcomes).map(
2490
+ ({ repositoryDisposition }) => repositoryDisposition,
2491
+ ),
2492
+ ),
2493
+ ];
2494
+ const session = requireSessionIdentity();
2495
+ if (
2496
+ boundary.playbookId !== session.playbookId ||
2497
+ boundary.runtimeSessionId !== session.sessionId ||
2498
+ boundary.turnId !== currentTurnId ||
2499
+ boundary.roleId !== roleId ||
2500
+ (completion.roleId !== undefined && completion.roleId !== roleId) ||
2501
+ boundary.sourceStateId !== input.stateId ||
2502
+ stableJson(
2503
+ boundary.dispositions,
2504
+ 'DECIDE boundary dispositions',
2505
+ ) !==
2506
+ stableJson(
2507
+ expectedDispositions,
2508
+ 'DECIDE authority dispositions',
2509
+ ) ||
2510
+ stableJson(
2511
+ boundary.sourceOutcomeSchema,
2512
+ 'DECIDE boundary source schema',
2513
+ ) !==
2514
+ stableJson(input.result, 'DECIDE authored source schema')
2515
+ ) {
2516
+ throw new TypeError(
2517
+ 'DECIDE governed semantic reconciliation source schema changed',
2518
+ );
2519
+ }
2520
+ governedReceiptsByBoundaryId.set(boundary.boundaryId, {
2521
+ physicalReceipt: completion.receipt,
2522
+ outcomeReceipt: completion.outcomeReceipt,
2523
+ });
2524
+ if (
2525
+ operation.status !== 'fulfilled' ||
2526
+ operation.value.status !== 'ok' ||
2527
+ isEmptyFinalText(operation.value.finalText)
2528
+ ) {
2529
+ const incomplete =
2530
+ operation.status === 'fulfilled' &&
2531
+ operation.value.status === 'ok' &&
2532
+ operation.value.finalText !== undefined
2533
+ ? { finalText: operation.value.finalText }
2534
+ : {};
2535
+ if (
2536
+ operation.status === 'fulfilled' &&
2537
+ operation.value.status === 'ok' &&
2538
+ operation.value.finalText !== undefined
2539
+ ) {
2540
+ governedEvidenceByBoundaryId.set(boundary.boundaryId, {
2541
+ finalText: operation.value.finalText,
2542
+ });
2543
+ }
2544
+ return operationId === undefined
2545
+ ? incomplete
2546
+ : { ...incomplete, unresolved: true as const };
2547
+ }
2548
+ const finalText = operation.value.finalText!;
2549
+ let raw: string;
2550
+ try {
2551
+ raw = await callJudge(
2552
+ buildAdjudicatorPrompt(input, finalText),
2553
+ signal,
2554
+ 'player-output-adjudication',
2555
+ input.stateId,
2556
+ );
2557
+ } catch (error) {
2558
+ unresolvedGovernedEvidence(
2559
+ boundary.boundaryId,
2560
+ 'judge transport failed',
2561
+ error,
2562
+ );
2563
+ governedEvidenceByBoundaryId.set(boundary.boundaryId, { finalText });
2564
+ return { finalText, unresolved: true as const };
2565
+ }
2566
+
2567
+ let candidate: unknown;
2568
+ let retainedCandidate: JsonValue | undefined;
2569
+ let reconciliation:
2570
+ | ReturnType<typeof reconcilePlaybookSemanticEvidence>
2571
+ | undefined;
2572
+ let structuralError:
2573
+ | PlaybookSemanticCandidateStructureError
2574
+ | undefined;
2575
+ const retainCandidate = (value: unknown): void => {
2576
+ try {
2577
+ retainedCandidate = snapshotJsonValue(
2578
+ value,
2579
+ 'DECIDE recoverable governed semantic candidate',
2580
+ );
2581
+ } catch {
2582
+ // A non-detachable reply still retains presentation and receipt.
2583
+ }
2584
+ };
2585
+ try {
2586
+ candidate = parseGovernedSemanticCandidate(raw);
2587
+ retainCandidate(candidate);
2588
+ reconciliation = reconcilePlaybookSemanticEvidence({
2589
+ outcomes,
2590
+ semanticCandidate: candidate,
2591
+ finalText,
2592
+ receipt: completion.outcomeReceipt,
2593
+ });
2594
+ } catch (error) {
2595
+ if (!(error instanceof PlaybookSemanticCandidateStructureError)) {
2596
+ throw error;
2597
+ }
2598
+ structuralError = error;
2599
+ }
2600
+
2601
+ if (structuralError !== undefined) {
2602
+ const spent = await spendSemanticCorrectionBudget(
2603
+ boundary,
2604
+ completion.receipt,
2605
+ finalText,
2606
+ retainedCandidate,
2607
+ );
2608
+ if (!spent || signal.aborted) {
2609
+ unresolvedGovernedEvidence(
2610
+ boundary.boundaryId,
2611
+ 'semantic correction budget is unavailable',
2612
+ signal.aborted ? signal.reason : undefined,
2613
+ );
2614
+ governedEvidenceByBoundaryId.set(boundary.boundaryId, {
2615
+ finalText,
2616
+ ...(retainedCandidate === undefined
2617
+ ? {}
2618
+ : { semanticCandidate: retainedCandidate }),
2619
+ });
2620
+ return {
2621
+ finalText,
2622
+ ...(retainedCandidate === undefined
2623
+ ? {}
2624
+ : { semanticCandidate: retainedCandidate }),
2625
+ unresolved: true as const,
2626
+ };
2627
+ }
2628
+ let correctiveRaw: string;
2629
+ try {
2630
+ correctiveRaw = await callJudge(
2631
+ buildAdjudicatorPrompt(input, finalText, {
2632
+ reply: raw,
2633
+ error: structuralError.message,
2634
+ }),
2635
+ signal,
2636
+ 'player-output-adjudication',
2637
+ input.stateId,
2638
+ );
2639
+ } catch (error) {
2640
+ unresolvedGovernedEvidence(
2641
+ boundary.boundaryId,
2642
+ 'corrective judge failed',
2643
+ error,
2644
+ );
2645
+ governedEvidenceByBoundaryId.set(boundary.boundaryId, {
2646
+ finalText,
2647
+ ...(retainedCandidate === undefined
2648
+ ? {}
2649
+ : { semanticCandidate: retainedCandidate }),
2650
+ });
2651
+ return {
2652
+ finalText,
2653
+ ...(retainedCandidate === undefined
2654
+ ? {}
2655
+ : { semanticCandidate: retainedCandidate }),
2656
+ unresolved: true as const,
2657
+ };
2658
+ }
2659
+ try {
2660
+ candidate = parseGovernedSemanticCandidate(correctiveRaw);
2661
+ retainCandidate(candidate);
2662
+ reconciliation = reconcilePlaybookSemanticEvidence({
2663
+ outcomes,
2664
+ semanticCandidate: candidate,
2665
+ finalText,
2666
+ receipt: completion.outcomeReceipt,
2667
+ });
2668
+ } catch (error) {
2669
+ if (!(error instanceof PlaybookSemanticCandidateStructureError)) {
2670
+ throw error;
2671
+ }
2672
+ unresolvedGovernedEvidence(
2673
+ boundary.boundaryId,
2674
+ 'corrective semantic candidate is invalid',
2675
+ );
2676
+ governedEvidenceByBoundaryId.set(boundary.boundaryId, {
2677
+ finalText,
2678
+ ...(retainedCandidate === undefined
2679
+ ? {}
2680
+ : { semanticCandidate: retainedCandidate }),
2681
+ });
2682
+ return {
2683
+ finalText,
2684
+ ...(retainedCandidate === undefined
2685
+ ? {}
2686
+ : { semanticCandidate: retainedCandidate }),
2687
+ unresolved: true as const,
2688
+ };
2689
+ }
2690
+ }
2691
+
2692
+ if (reconciliation === undefined) {
2693
+ throw new Error(
2694
+ 'DECIDE semantic reconciliation produced no decision',
2695
+ );
2696
+ }
2697
+ const semanticCandidate = snapshotJsonValue(
2698
+ reconciliation.evidence.semanticCandidate,
2699
+ 'DECIDE governed semantic candidate',
2700
+ );
2701
+ governedEvidenceByBoundaryId.set(boundary.boundaryId, {
2702
+ finalText,
2703
+ semanticCandidate,
2704
+ });
2705
+ if (reconciliation.status === 'unresolved') {
2706
+ unresolvedGovernedEvidence(
2707
+ boundary.boundaryId,
2708
+ reconciliation.reason,
2709
+ );
2710
+ return { finalText, semanticCandidate, unresolved: true as const };
2711
+ }
2712
+
2713
+ const output = reconciliation.output as unknown as PlayerOutput;
2714
+ governedOutputsByBoundaryId.set(boundary.boundaryId, output);
2715
+ if (reconciliation.status !== 'deferred') {
2716
+ return { finalText, semanticCandidate };
2717
+ }
2718
+ if (
2719
+ output.guard !== 'needsBossReply' ||
2720
+ typeof output.question !== 'string' ||
2721
+ output.question.trim() === ''
2722
+ ) {
2723
+ throw new TypeError(
2724
+ 'DECIDE deferred outcome must carry one exact Boss question',
2725
+ );
2726
+ }
2727
+ const pendingQuestion: PlaybookPendingBossQuestion = {
2728
+ questionId: input.stateId,
2729
+ asker: { kind: 'role', roleId },
2730
+ question: output.question,
2731
+ sourceItem: input.sourceItem,
2732
+ };
2733
+ return {
2734
+ finalText,
2735
+ semanticCandidate,
2736
+ deferred: {
2737
+ operationId: operationId ?? randomUUID(),
2738
+ pendingQuestion,
2739
+ playerContinuation: snapshotJsonValue(
2740
+ selectPlayerResume(roleId, playerId),
2741
+ 'DECIDE deferred player continuation',
2742
+ ),
2743
+ },
2744
+ };
2745
+ });
2746
+ if (queued === undefined) {
2747
+ throw new Error('DECIDE semantic completion produced no evidence');
2748
+ }
2749
+ return queued;
2750
+ };
2751
+
2752
+ const acknowledgeGovernedPlayerResult = (
2753
+ value: DecideRepositoryExclusiveResult<PlayerResult>,
2754
+ boundaryId: string,
2755
+ ): PlayerResult => {
2756
+ const ledger = assertPlaybookEffectLedger(
2757
+ value.effectLedger,
2758
+ 'DECIDE repository settlement effect ledger',
2759
+ );
2760
+ const completed = ledger.boundaries.find(
2761
+ (candidate) => candidate.boundaryId === boundaryId,
2762
+ );
2763
+ if (
2764
+ completed === undefined ||
2765
+ completed.physicalReceipt === undefined ||
2766
+ stableJson(completed.physicalReceipt, 'DECIDE completed receipt') !==
2767
+ stableJson(value.receipt, 'DECIDE acknowledged receipt')
2768
+ ) {
2769
+ throw new TypeError(
2770
+ 'DECIDE repository settlement did not acknowledge its completed boundary',
2771
+ );
2772
+ }
2773
+ const expectedEvidence = governedEvidenceByBoundaryId.get(boundaryId);
2774
+ const expectedReceipts = governedReceiptsByBoundaryId.get(boundaryId);
2775
+ if (
2776
+ expectedReceipts !== undefined &&
2777
+ stableJson(
2778
+ completed.physicalReceipt,
2779
+ 'DECIDE completed physical receipt',
2780
+ ) !==
2781
+ stableJson(
2782
+ expectedReceipts.physicalReceipt,
2783
+ 'DECIDE reconciled physical receipt',
2784
+ )
2785
+ ) {
2786
+ throw new TypeError(
2787
+ 'DECIDE repository settlement changed the physical receipt used during reconciliation',
2788
+ );
2789
+ }
2790
+ if (
2791
+ expectedEvidence !== undefined &&
2792
+ (completed.finalText !== expectedEvidence.finalText ||
2793
+ (expectedEvidence.semanticCandidate === undefined
2794
+ ? completed.semanticCandidate !== undefined
2795
+ : completed.semanticCandidate === undefined ||
2796
+ stableJson(
2797
+ completed.semanticCandidate,
2798
+ 'DECIDE completed semantic candidate',
2799
+ ) !==
2800
+ stableJson(
2801
+ expectedEvidence.semanticCandidate,
2802
+ 'DECIDE expected semantic candidate',
2803
+ )))
2804
+ ) {
2805
+ throw new TypeError(
2806
+ 'DECIDE repository settlement did not acknowledge its exact governed evidence',
2807
+ );
2808
+ }
2809
+ governedEvidenceByBoundaryId.delete(boundaryId);
2810
+ governedReceiptsByBoundaryId.delete(boundaryId);
2811
+ synchronizeDeferredProjection(ledger);
2812
+ if (value.operation.status === 'rejected') {
2813
+ throw value.operation.reason;
2814
+ }
2815
+ const result = validatePlayerResult(value.operation.value);
2816
+ const output = governedOutputsByBoundaryId.get(boundaryId);
2817
+ const failure = governedFailuresByBoundaryId.get(boundaryId);
2818
+ if (expectedReceipts !== undefined) {
2819
+ const continued =
2820
+ 'status' in value && value.status === 'continued'
2821
+ ? (value as DecideRepositoryDeferredContinuationResult<PlayerResult>)
2822
+ : undefined;
2823
+ const acknowledgedOutcomeReceipt =
2824
+ continued === undefined ? value.receipt : continued.logicalReceipt;
2825
+ if (
2826
+ acknowledgedOutcomeReceipt !== undefined &&
2827
+ stableJson(
2828
+ acknowledgedOutcomeReceipt,
2829
+ 'DECIDE acknowledged outcome receipt',
2830
+ ) !==
2831
+ stableJson(
2832
+ expectedReceipts.outcomeReceipt,
2833
+ 'DECIDE reconciled outcome receipt',
2834
+ )
2835
+ ) {
2836
+ throw new TypeError(
2837
+ 'DECIDE repository settlement changed the outcome receipt used during reconciliation',
2838
+ );
2839
+ }
2840
+ if (
2841
+ continued !== undefined &&
2842
+ output !== undefined &&
2843
+ output.guard !== 'needsBossReply' &&
2844
+ acknowledgedOutcomeReceipt === undefined
2845
+ ) {
2846
+ throw new TypeError(
2847
+ 'DECIDE completed deferred continuation omitted its reconciled logical receipt',
2848
+ );
2849
+ }
2850
+ }
2851
+ governedOutputsByBoundaryId.delete(boundaryId);
2852
+ governedFailuresByBoundaryId.delete(boundaryId);
2853
+ const linkedOperationId = completed.logicalOperationId;
2854
+ if (value.deferredStatus === 'unresolved') {
2855
+ if (linkedOperationId === undefined) {
2856
+ throw new TypeError(
2857
+ 'DECIDE unresolved deferred settlement omitted its logical operation',
2858
+ );
2859
+ }
2860
+ deferredOperationId = linkedOperationId;
2861
+ hiddenDeferredOperationId = linkedOperationId;
2862
+ } else if (value.deferredStatus === 'bound') {
2863
+ if (linkedOperationId === undefined) {
2864
+ throw new TypeError(
2865
+ 'DECIDE bound deferred settlement omitted its logical operation',
2866
+ );
2867
+ }
2868
+ deferredOperationId = linkedOperationId;
2869
+ hiddenDeferredOperationId = undefined;
2870
+ } else if (
2871
+ 'status' in value &&
2872
+ value.status === 'continued' &&
2873
+ (value as { readonly logicalReceipt?: PlaybookRepositoryReceipt })
2874
+ .logicalReceipt === undefined &&
2875
+ linkedOperationId !== undefined
2876
+ ) {
2877
+ deferredOperationId = linkedOperationId;
2878
+ hiddenDeferredOperationId = linkedOperationId;
2879
+ } else if (linkedOperationId !== undefined) {
2880
+ deferredOperationId = undefined;
2881
+ hiddenDeferredOperationId = undefined;
2882
+ }
2883
+ if (failure !== undefined) throw failure;
2884
+ if (value.deferredStatus === 'unresolved') {
2885
+ throw new Error(
2886
+ 'DECIDE deferred repository settlement remains unresolved',
2887
+ );
2888
+ }
2889
+ if (output !== undefined) {
2890
+ governedPlayerOutputs.set(result, output);
2891
+ } else if (
2892
+ result.status === 'ok' &&
2893
+ !isEmptyFinalText(result.finalText)
2894
+ ) {
2895
+ throw new Error(
2896
+ 'DECIDE governed player result has no reconciled semantic output',
2897
+ );
2898
+ }
2899
+ return result;
2900
+ };
2901
+
2902
+ const shouldRunProposalCohort = (input: PlayerInput): boolean => {
2903
+ if (
2904
+ input.stateId !== PROPOSAL_STATE_BY_ROLE[input.role] ||
2905
+ completedProposalCohortTurnId === currentTurnId
2906
+ ) {
2907
+ return false;
2908
+ }
2909
+ const state = currentState();
2910
+ return Object.values(PROPOSAL_STATE_BY_ROLE).every((stateId) =>
2911
+ state.activeStateIds.includes(stateId),
2912
+ );
2913
+ };
2914
+
2915
+ const settleProposalCohort = async (): Promise<void> => {
2916
+ const members = Object.fromEntries(
2917
+ ROLE_IDS.map((roleId) => [roleId, pendingProposalCohort.get(roleId)]),
2918
+ ) as Record<RoleId, PendingProposalCohortMember | undefined>;
2919
+ const turnId = currentTurnId;
2920
+ const cohortSignal = currentSignal;
2921
+ const cohortOperations = new AbortController();
2922
+ const completionOrder: RoleId[] = [];
2923
+ try {
2924
+ if (
2925
+ turnId === undefined ||
2926
+ cohortSignal === undefined ||
2927
+ members.coder === undefined ||
2928
+ members.reviewer === undefined
2929
+ ) {
2930
+ throw new Error(
2931
+ 'DECIDE proposal cohort started without both governed members',
2932
+ );
2933
+ }
2934
+ for (const roleId of ROLE_IDS) members[roleId]!.signal.throwIfAborted();
2935
+ const operations = Object.fromEntries(
2936
+ ROLE_IDS.map((roleId) => [
2937
+ roleId,
2938
+ async () => {
2939
+ const member = members[roleId]!;
2940
+ try {
2941
+ const operationSignal = combineSignals(
2942
+ member.signal,
2943
+ cohortOperations.signal,
2944
+ );
2945
+ const envelope = await runPlayerCall(
2946
+ member.input,
2947
+ operationSignal,
2948
+ { callId: member.callId },
2949
+ );
2950
+ member.envelope = envelope;
2951
+ if (
2952
+ envelope.result.status !== 'ok' &&
2953
+ !cohortOperations.signal.aborted
2954
+ ) {
2955
+ cohortOperations.abort(
2956
+ new Error(
2957
+ `DECIDE proposal cohort ${roleId} returned status ${JSON.stringify(envelope.result.status)}`,
2958
+ ),
2959
+ );
2960
+ }
2961
+ return envelope.result;
2962
+ } catch (error) {
2963
+ if (!cohortOperations.signal.aborted) {
2964
+ cohortOperations.abort(error);
2965
+ }
2966
+ throw error;
2967
+ } finally {
2968
+ completionOrder.push(roleId);
2969
+ }
2970
+ },
2971
+ ]),
2972
+ ) as unknown as Readonly<
2973
+ Record<RoleId, () => Promise<PlayerResult>>
2974
+ >;
2975
+ const invocationId = randomUUID();
2976
+ const settled = await deferredEffects.repository.runCohort({
2977
+ signal: cohortSignal,
2978
+ invocationId,
2979
+ roleIds: ROLE_IDS,
2980
+ dispositionsByRole: {
2981
+ coder: ['unchanged'],
2982
+ reviewer: ['unchanged'],
2983
+ },
2984
+ effectBoundaries: {
2985
+ coder: members.coder.effectBoundary,
2986
+ reviewer: members.reviewer.effectBoundary,
2987
+ },
2988
+ operations,
2989
+ completeEffectBoundary: (completion) => {
2990
+ const member = members[completion.roleId];
2991
+ if (member === undefined) {
2992
+ throw new TypeError(
2993
+ `DECIDE proposal cohort completed unknown role ${String(completion.roleId)}`,
2994
+ );
2995
+ }
2996
+ return completionEvidenceFor(
2997
+ member.input,
2998
+ completion.roleId,
2999
+ resolvedPlayerId(completion.roleId),
3000
+ member.signal,
3001
+ )(completion);
3002
+ },
3003
+ });
3004
+ if (settled.invocationId !== invocationId) {
3005
+ throw new TypeError(
3006
+ 'DECIDE repository cohort changed its invocation identity',
3007
+ );
3008
+ }
3009
+ const releaseOrder = [
3010
+ ...completionOrder,
3011
+ ...ROLE_IDS.filter((roleId) => !completionOrder.includes(roleId)),
3012
+ ];
3013
+ const acknowledgedResults = new Map<RoleId, PlayerResult>();
3014
+ const acknowledgementFailures: unknown[] = [];
3015
+ for (const roleId of releaseOrder) {
3016
+ const member = members[roleId]!;
3017
+ try {
3018
+ const result = acknowledgeGovernedPlayerResult(
3019
+ {
3020
+ operation: settled.operations[roleId],
3021
+ receipt: settled.receipts[roleId],
3022
+ effectLedger: settled.effectLedger,
3023
+ },
3024
+ member.effectBoundary.boundaryId,
3025
+ );
3026
+ if (member.envelope === undefined) {
3027
+ throw new TypeError(
3028
+ `DECIDE repository cohort omitted its ${roleId} invocation`,
3029
+ );
3030
+ }
3031
+ acknowledgedResults.set(roleId, result);
3032
+ } catch (error) {
3033
+ acknowledgementFailures.push(error);
3034
+ }
3035
+ }
3036
+ if (acknowledgementFailures.length > 0) {
3037
+ for (const result of acknowledgedResults.values()) {
3038
+ governedPlayerOutputs.delete(result);
3039
+ }
3040
+ throw acknowledgementFailures.length === 1
3041
+ ? acknowledgementFailures[0]
3042
+ : new AggregateError(
3043
+ acknowledgementFailures,
3044
+ 'DECIDE proposal cohort reconciliation failed',
3045
+ );
3046
+ }
3047
+ for (const roleId of releaseOrder) {
3048
+ const member = members[roleId]!;
3049
+ const result = acknowledgedResults.get(roleId);
3050
+ if (member.envelope === undefined || result === undefined) {
3051
+ throw new Error(
3052
+ `DECIDE proposal cohort lost its ${roleId} acknowledgement`,
3053
+ );
3054
+ }
3055
+ member.result.resolve({ ...member.envelope, result });
3056
+ }
3057
+ } catch (error) {
3058
+ for (const member of Object.values(members)) member?.result.reject(error);
3059
+ } finally {
3060
+ completedProposalCohortTurnId = turnId;
3061
+ pendingProposalCohort.clear();
3062
+ activeProposalCohort = undefined;
3063
+ }
3064
+ };
3065
+
3066
+ const queueProposalCohortMember = (
3067
+ input: PlayerInput,
3068
+ signal: AbortSignal,
3069
+ ): Promise<DeferredContinuationEnvelope> => {
3070
+ if (pendingProposalCohort.has(input.role)) {
3071
+ throw new Error(
3072
+ `DECIDE proposal cohort already registered role ${input.role}`,
3073
+ );
3074
+ }
3075
+ const callId = `player-${++playerCallSequence}`;
3076
+ const member: PendingProposalCohortMember = {
3077
+ input,
3078
+ signal,
3079
+ callId,
3080
+ effectBoundary: governedBoundarySeed(input, callId),
3081
+ result: deferredValue<DeferredContinuationEnvelope>(),
3082
+ };
3083
+ pendingProposalCohort.set(input.role, member);
3084
+ if (pendingProposalCohort.size === ROLE_IDS.length) {
3085
+ if (activeProposalCohort !== undefined) {
3086
+ throw new Error('DECIDE proposal cohort was started more than once');
3087
+ }
3088
+ activeProposalCohort = settleProposalCohort();
3089
+ }
3090
+ return member.result.promise;
3091
+ };
3092
+
3093
+ const callPlayer = (
3094
+ input: PlayerInput,
3095
+ signal: AbortSignal,
3096
+ ): Promise<DeferredContinuationEnvelope> => {
3097
+ const invocation = async (): Promise<DeferredContinuationEnvelope> => {
3098
+ const active = activeDeferredContinuation;
3099
+ if (active === undefined && hasUnresolvedReconciliation()) {
3100
+ throw new Error(
3101
+ 'DECIDE governed semantic reconciliation remains unresolved',
3102
+ );
3103
+ }
3104
+ if (active !== undefined) {
3105
+ if (
3106
+ active.effectBoundary.runtimeSessionId !==
3107
+ requireSessionIdentity().sessionId ||
3108
+ active.effectBoundary.turnId !== currentTurnId ||
3109
+ active.effectBoundary.roleId !== input.role ||
3110
+ active.effectBoundary.sourceStateId !== input.stateId ||
3111
+ active.playerContinuation === undefined
3112
+ ) {
3113
+ throw new TypeError(
3114
+ 'DECIDE deferred continuation did not invoke its bound player boundary',
3115
+ );
3116
+ }
3117
+ active.input = input;
3118
+ active.playerId = resolvedPlayerId(input.role);
3119
+ try {
3120
+ const envelope = await runPlayerCall(input, signal, {
3121
+ callId: active.effectBoundary.callId,
3122
+ resume: active.playerContinuation,
3123
+ });
3124
+ active.result.resolve(envelope.result);
3125
+ const acknowledgedResult = await active.acknowledged.promise;
3126
+ return { ...envelope, result: acknowledgedResult };
3127
+ } catch (error) {
3128
+ active.result.reject(error);
3129
+ try {
3130
+ await active.acknowledged.promise;
3131
+ } catch (acknowledgementError) {
3132
+ throw acknowledgementError;
3133
+ }
3134
+ throw error;
3135
+ }
3136
+ }
3137
+
3138
+ if (shouldRunProposalCohort(input)) {
3139
+ return queueProposalCohortMember(input, signal);
3140
+ }
3141
+
3142
+ const callId = `player-${++playerCallSequence}`;
3143
+ const effectBoundary = governedBoundarySeed(input, callId);
3144
+ let envelope: DeferredContinuationEnvelope | undefined;
3145
+ const settled = await deferredEffects.repository.runExclusive({
3146
+ signal,
3147
+ effectBoundary,
3148
+ operation: async () => {
3149
+ envelope = await runPlayerCall(input, signal, { callId });
3150
+ return envelope.result;
3151
+ },
3152
+ completeEffectBoundary: completionEvidenceFor(
3153
+ input,
3154
+ input.role,
3155
+ resolvedPlayerId(input.role),
3156
+ signal,
3157
+ ),
3158
+ });
3159
+ const result = acknowledgeGovernedPlayerResult(
3160
+ settled,
3161
+ effectBoundary.boundaryId,
3162
+ );
3163
+ if (envelope === undefined) {
3164
+ throw new TypeError(
3165
+ 'DECIDE repository settlement omitted its player invocation',
3166
+ );
3167
+ }
3168
+ return { ...envelope, result };
3169
+ };
3170
+ return trackBoundaryCall(invocation());
3171
+ };
3172
+
3173
+ const player = fromPromise<PlayerOutput, PlayerInput>(
3174
+ async ({ input, signal }) => {
3175
+ const combined = combineSignals(signal, currentSignal);
3176
+ const settlementAborts = abortReasonClassifier(combined);
3177
+ try {
3178
+ // XState starts invoked actors while publishing the entering snapshot.
3179
+ // Yield through the runtime emission queue before crossing the player
3180
+ // boundary so state trace/status always precede its call-start trace.
3181
+ combined.throwIfAborted();
3182
+ try {
3183
+ await flush(settlementAborts);
3184
+ } catch (error) {
3185
+ latchControlPlaneError(error, combined);
3186
+ throw error;
3187
+ }
3188
+ combined.throwIfAborted();
3189
+
3190
+ let { roleId, playerId, callId, result } = await callPlayer(
3191
+ input,
3192
+ combined,
3193
+ );
3194
+ if (
3195
+ result.status === 'ok' &&
3196
+ isEmptyFinalText(result.finalText) &&
3197
+ automaticReplayPolicy.allowsEmptyOkCorrection(
3198
+ requireSessionIdentity().sessionId,
3199
+ callId,
3200
+ )
3201
+ ) {
3202
+ // DR-028: an `ok` result whose finalText is missing, empty, or
3203
+ // whitespace-only earns exactly one corrective re-ask — the same
3204
+ // composed call repeated, traced by runPlayerCall as its own
3205
+ // player-call pair, with the resume selection re-read from the
3206
+ // token map the first result left (PBRT-38). An abort that lands
3207
+ // between the two calls ends the turn without the re-ask (aborts
3208
+ // are never retried), and a rejecting finish emission rejects
3209
+ // `callPlayer` itself, so it never reaches this branch (PBRT-47).
3210
+ combined.throwIfAborted();
3211
+ ({ roleId, playerId, callId, result } = await callPlayer(
3212
+ input,
3213
+ combined,
3214
+ ));
3215
+ }
3216
+ if (result.status !== 'ok') {
3217
+ throw new Error(
3218
+ `${roleLabel(roleId)}${
3219
+ playerId === undefined ? '' : ` (${playerId})`
3220
+ } returned status "${result.status}"${
3221
+ result.error ? `: ${result.error}` : ''
3222
+ }`,
3223
+ );
3224
+ }
3225
+ const finalText = result.finalText ?? '';
3226
+ if (isEmptyFinalText(finalText)) {
3227
+ throw new Error(
3228
+ `${roleLabel(roleId)}${
3229
+ playerId === undefined ? '' : ` (${playerId})`
3230
+ } returned status "ok" with no finalText`,
3231
+ );
3232
+ }
3233
+ combined.throwIfAborted();
3234
+
3235
+ const governedOutput = governedPlayerOutputs.get(result);
3236
+ if (governedOutput !== undefined) {
3237
+ governedPlayerOutputs.delete(result);
3238
+ return governedOutput;
3239
+ }
3240
+ throw new Error(
3241
+ 'DECIDE governed player result was not reconciled against repository evidence',
3242
+ );
3243
+ } finally {
3244
+ actorSettlementAborts.push(settlementAborts);
3245
+ }
3246
+ },
3247
+ );
3248
+
3249
+ nestedBridge = createNestedPlaybookBridge<PlaybookInput>({
3250
+ nextCallId: () => `playbook-${++playbookCallSequence}`,
3251
+ getBoundarySignal: () => currentSignal,
3252
+ callPlaybook: (request, signal) =>
3253
+ trackBoundaryCall(
3254
+ Promise.resolve(requirePorts().callPlaybook(request, signal)),
3255
+ ),
3256
+ emitStarted: async (event, aborts) => {
3257
+ playbookCallTurnIds.set(event.callId, currentTurnId);
3258
+ await emitTrace(
3259
+ 'playbook.call.started',
3260
+ {
3261
+ stateId: event.stateId,
3262
+ playbookId: event.playbookId,
3263
+ text: event.text,
3264
+ },
3265
+ {
3266
+ ...(currentTurnId === undefined ? {} : { turnId: currentTurnId }),
3267
+ callId: event.callId,
3268
+ },
3269
+ aborts,
3270
+ );
3271
+ },
3272
+ emitFinished: async (event, aborts) => {
3273
+ const turnId = playbookCallTurnIds.get(event.callId);
3274
+ try {
3275
+ await emitTrace(
3276
+ 'playbook.call.finished',
3277
+ {
3278
+ stateId: event.stateId,
3279
+ playbookId: event.playbookId,
3280
+ text: event.text,
3281
+ result: event.result,
3282
+ },
3283
+ {
3284
+ ...(turnId === undefined ? {} : { turnId }),
3285
+ callId: event.callId,
3286
+ },
3287
+ aborts,
3288
+ );
3289
+ } finally {
3290
+ playbookCallTurnIds.delete(event.callId);
3291
+ }
3292
+ },
3293
+ drain: flush,
3294
+ bindResumeSignal: (signal, aborts) => {
3295
+ currentSignal = signal;
3296
+ currentAborts = aborts ?? abortReasonClassifier(signal);
3297
+ },
3298
+ bindActorSettlement: (aborts) => {
3299
+ actorSettlementAborts.push(aborts);
3300
+ },
3301
+ onControlPlaneError: (error, aborts) => {
3302
+ if (
3303
+ !aborts?.isAbortReason(error) &&
3304
+ !currentAborts?.isAbortReason(error)
3305
+ ) {
3306
+ controlPlaneError ??= error;
3307
+ }
3308
+ },
3309
+ onBackgroundError: (error, aborts) => {
3310
+ if (!aborts?.isAbortReason(error)) {
3311
+ collectFailure(emissionFailures, error);
3312
+ }
3313
+ },
1459
3314
  });
1460
3315
 
3316
+ const providedMachine = decideMachine.provide({
3317
+ actors: { player, playbook: nestedBridge.actorLogic },
3318
+ });
3319
+
3320
+ const consumeActorSettlementAborts = (
3321
+ forSnapshot = false,
3322
+ ): AbortReasonClassifier | undefined => {
3323
+ const aborts = actorSettlementAborts.shift() ?? actorSettlementErrorAborts;
3324
+ actorSettlementErrorAborts = undefined;
3325
+ if (forSnapshot && aborts !== undefined) {
3326
+ actorSettlementErrorAborts = aborts;
3327
+ queueMicrotask(() => {
3328
+ if (actorSettlementErrorAborts === aborts) {
3329
+ actorSettlementErrorAborts = undefined;
3330
+ }
3331
+ });
3332
+ }
3333
+ return aborts;
3334
+ };
3335
+
1461
3336
  const inspect = (event: InspectionEvent): void => {
1462
- if (event.type !== '@xstate.snapshot') return;
1463
3337
  if (actor === undefined || event.actorRef !== actor) return;
1464
3338
  if (suppressInspectionEmissions) return;
3339
+ if (event.type === '@xstate.action') {
3340
+ try {
3341
+ acceptedOutcomeConsumer.capture(event.action);
3342
+ } catch (error) {
3343
+ latchInspectionError(error);
3344
+ }
3345
+ return;
3346
+ }
3347
+ if (event.type !== '@xstate.snapshot') return;
3348
+ const settlementAborts = consumeActorSettlementAborts(true);
1465
3349
  try {
1466
3350
  const snapshot = event.snapshot as SnapshotFrom<typeof decideMachine>;
1467
3351
  const state = normalizePlaybookSnapshot(snapshot);
1468
3352
  const prior = previousState ?? state;
3353
+ let acceptedOutcomes: readonly AcceptedOutcomeReceipt[] = [];
3354
+ try {
3355
+ acceptedOutcomes = acceptedOutcomeConsumer.confirm(previousState, state);
3356
+ } catch (error) {
3357
+ latchInspectionError(error);
3358
+ }
1469
3359
  const context = snapshot.context as unknown as Record<string, unknown>;
1470
- const fsmPayload = telemetryPayload(prior, state, event.event, context);
3360
+ const fsmPayload = telemetryPayload(
3361
+ prior,
3362
+ state,
3363
+ event.event,
3364
+ context,
3365
+ hiddenDeferredOperationId === undefined
3366
+ ? undefined
3367
+ : 'commitCoderProposal',
3368
+ );
1471
3369
  const describedFsmPayload = snapshotJsonValue(
1472
3370
  fsmPayload,
1473
3371
  'described FSM telemetry',
@@ -1482,11 +3380,12 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1482
3380
  topic: TELEMETRY_TOPIC,
1483
3381
  payload: describedFsmPayload,
1484
3382
  }),
3383
+ settlementAborts,
1485
3384
  ).catch(() => undefined);
1486
3385
 
1487
3386
  const priorIds = new Set(previousState?.activeStateIds ?? []);
1488
3387
  previousState = state;
1489
- const pendingQuestions = pendingQuestionsFromContext(context);
3388
+ const pendingQuestions = visiblePendingQuestionsForState(state, context);
1490
3389
  const bossRelevantStateIds = state.activeStateIds.filter((stateId) =>
1491
3390
  STATUS_STATE_IDS.has(stateId),
1492
3391
  );
@@ -1507,9 +3406,18 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1507
3406
  tracePayload,
1508
3407
  { turnId: currentTurnId },
1509
3408
  (emissionPorts) => emissionPorts.emitStatus(message, data),
3409
+ settlementAborts,
1510
3410
  ).catch(() => undefined);
1511
3411
  };
1512
3412
 
3413
+ for (const acceptedOutcome of acceptedOutcomes) {
3414
+ void enqueueAcceptedOutcomeEmission(
3415
+ acceptedOutcome,
3416
+ state,
3417
+ settlementAborts,
3418
+ ).catch(() => undefined);
3419
+ }
3420
+
1513
3421
  for (const activeStateId of state.activeStateIds) {
1514
3422
  if (
1515
3423
  priorIds.has(activeStateId) ||
@@ -1552,89 +3460,740 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1552
3460
  );
1553
3461
  }
1554
3462
  } catch (error) {
1555
- latchInspectionError(error);
3463
+ acceptedOutcomeConsumer.reset();
3464
+ latchInspectionError(error, settlementAborts);
3465
+ }
3466
+ };
3467
+
3468
+ const createRuntimeActor = (machineSnapshot?: JsonValue): void => {
3469
+ previousState = undefined;
3470
+ acceptedOutcomeConsumer.reset();
3471
+ // DR-014 §1: a restore rehydrates the persisted machine snapshot;
3472
+ // XState derives context/value from it and ignores `input` then.
3473
+ actor = createActor(providedMachine, {
3474
+ input: fsmInput,
3475
+ ...(machineSnapshot === undefined
3476
+ ? {}
3477
+ : {
3478
+ snapshot: machineSnapshot as unknown as SnapshotFrom<
3479
+ typeof decideMachine
3480
+ >,
3481
+ }),
3482
+ inspect,
3483
+ });
3484
+ // A synchronous FSM action throw errors the actor without any pending
3485
+ // boundary await to observe it; unobserved, XState would surface it via
3486
+ // reportUnhandledError as an uncaughtException. Observe it here: latch
3487
+ // it as a control error while a turn signal is active (unless it is
3488
+ // the abort reason itself), otherwise collect it with the emission
3489
+ // failures (slc/link.md §Abort).
3490
+ actor.subscribe({
3491
+ error: (error) =>
3492
+ latchInspectionError(error, consumeActorSettlementAborts()),
3493
+ });
3494
+ };
3495
+
3496
+ // PBRT-6: the single seam that stops this runtime's actor. Stopping a
3497
+ // still-running actor fires one more `@xstate.snapshot` for the *unchanged*
3498
+ // state value with `status: 'stopped'`; `inspect` cannot tell that disposal
3499
+ // artifact from a state entry, so unsuppressed it re-emits the parked
3500
+ // state's telemetry and a phantom self-loop transition. Suppression is a
3501
+ // property of stopping, not a rule each caller must remember — every stop
3502
+ // goes through here so no later site can reintroduce the omission.
3503
+ const stopActor = (): void => {
3504
+ if (!actor) return;
3505
+ suppressInspectionEmissions = true;
3506
+ acceptedOutcomeConsumer.reset();
3507
+ actor.stop();
3508
+ };
3509
+
3510
+ const startActor = (): void => {
3511
+ createRuntimeActor();
3512
+ // A fresh actor's emissions are real state entries again.
3513
+ suppressInspectionEmissions = false;
3514
+ actor?.start();
3515
+ };
3516
+
3517
+ const driveToQuiescence = async (): Promise<void> => {
3518
+ const live = actor;
3519
+ if (!live) throw new Error('decide runtime: actor is not initialized');
3520
+ await waitForPlaybookQuiescence(live, { pendingCalls: nestedBridge });
3521
+ };
3522
+
3523
+ const classify = async (
3524
+ text: string,
3525
+ signal: AbortSignal,
3526
+ ): Promise<DecideEvent | { type: 'NO_ACTION' } | null> => {
3527
+ const live = actor;
3528
+ if (!live) throw new Error('decide runtime: actor is not initialized');
3529
+ refreshReconciliationProjection();
3530
+ if (hasUnresolvedReconciliation()) return null;
3531
+ const snapshot = live.getSnapshot() as SnapshotFrom<typeof decideMachine>;
3532
+ const context = snapshot.context as unknown as Record<string, unknown>;
3533
+ const state = normalizePlaybookSnapshot(snapshot, {
3534
+ pendingCall: nestedBridge.getPendingCall(),
3535
+ });
3536
+ const pendingQuestions = visiblePendingQuestionsForState(state, context);
3537
+ const failed = state.activeStateIds.includes('failed');
3538
+ if (
3539
+ pendingQuestions.length === 0 &&
3540
+ (snapshot.status === 'done' ||
3541
+ state.activeStateIds.includes('ready') ||
3542
+ (failed &&
3543
+ automaticReplayPolicy.allowsFailureStateRetry()))
3544
+ ) {
3545
+ return { type: 'START_DECIDE', callerTopic: text };
3546
+ }
3547
+ if (pendingQuestions.length === 0) return null;
3548
+ const prompt = buildClassifierPrompt(text, {
3549
+ state,
3550
+ pendingQuestions,
3551
+ });
3552
+ const raw = await callJudge(
3553
+ prompt,
3554
+ signal,
3555
+ 'boss-input-classification',
3556
+ state.stateId,
3557
+ );
3558
+ return parseClassification(
3559
+ raw,
3560
+ text,
3561
+ pendingQuestions.map(({ questionId }) => questionId),
3562
+ );
3563
+ };
3564
+
3565
+ const deferredBoundarySeed = (
3566
+ operationId: string,
3567
+ callId: string,
3568
+ ): DecideEffectBoundarySeed => {
3569
+ const operation = effectLedgerMirror.logicalOperations.find(
3570
+ (candidate) => candidate.operationId === operationId,
3571
+ );
3572
+ const latestBoundaryId = operation?.boundaryIds.at(-1);
3573
+ const priorBoundary = effectLedgerMirror.boundaries.find(
3574
+ (candidate) => candidate.boundaryId === latestBoundaryId,
3575
+ );
3576
+ if (operation === undefined || priorBoundary === undefined) {
3577
+ throw new TypeError(
3578
+ 'DECIDE deferred logical operation has no linked physical boundary',
3579
+ );
3580
+ }
3581
+ if (
3582
+ currentTurnId === undefined ||
3583
+ !Number.isSafeInteger(currentTurnId) ||
3584
+ currentTurnId <= 0
3585
+ ) {
3586
+ throw new Error(
3587
+ 'DECIDE deferred continuation requires an active positive turn id',
3588
+ );
3589
+ }
3590
+ return {
3591
+ boundaryId: randomUUID(),
3592
+ runtimeSessionId: requireSessionIdentity().sessionId,
3593
+ turnId: currentTurnId,
3594
+ callId,
3595
+ roleId: priorBoundary.roleId,
3596
+ sourceStateId: priorBoundary.sourceStateId,
3597
+ sourceOutcomeSchema: snapshotJsonValue(
3598
+ priorBoundary.sourceOutcomeSchema,
3599
+ 'DECIDE deferred source outcome schema',
3600
+ ),
3601
+ dispositions: [...priorBoundary.dispositions],
3602
+ correctionBudget: { limit: 1, spent: false },
3603
+ };
3604
+ };
3605
+
3606
+ type PreparedDeferredContinuation =
3607
+ | { readonly proceed: false }
3608
+ | { readonly proceed: true; readonly acknowledgement: Promise<void> };
3609
+
3610
+ const prepareDeferredContinuation = async (
3611
+ signal: AbortSignal,
3612
+ resumeEvent: Extract<DecideEvent, { readonly type: 'BOSS_REPLY' }>,
3613
+ ): Promise<PreparedDeferredContinuation> => {
3614
+ if (deferredEffects === undefined || deferredOperationId === undefined) {
3615
+ throw new TypeError(
3616
+ 'DECIDE deferred Boss reply has no host-bound logical operation',
3617
+ );
3618
+ }
3619
+ const operationId = deferredOperationId;
3620
+ const callId = `player-${++playerCallSequence}`;
3621
+ const effectBoundary = deferredBoundarySeed(operationId, callId);
3622
+ const active: ActiveDeferredContinuation = {
3623
+ operationId,
3624
+ effectBoundary,
3625
+ result: deferredValue<PlayerResult>(),
3626
+ acknowledged: deferredValue<PlayerResult>(),
3627
+ };
3628
+ type Readiness =
3629
+ | { readonly status: 'ready' }
3630
+ | {
3631
+ readonly status: 'settled';
3632
+ readonly value:
3633
+ | DecideRepositoryDeferredContinuationResult<PlayerResult>
3634
+ | DecideRepositoryDeferredCheckpointMismatch;
3635
+ }
3636
+ | { readonly status: 'rejected'; readonly reason: unknown };
3637
+ const readiness = deferredValue<Readiness>();
3638
+ const repositoryCall = deferredEffects.repository.runDeferred({
3639
+ mode: 'continue',
3640
+ signal,
3641
+ operationId,
3642
+ effectBoundary,
3643
+ operation: async ({ playerContinuation }) => {
3644
+ if (
3645
+ playerContinuation !== false &&
3646
+ (typeof playerContinuation !== 'string' ||
3647
+ playerContinuation.trim() === '')
3648
+ ) {
3649
+ throw new TypeError(
3650
+ 'DECIDE bound deferred player continuation is invalid',
3651
+ );
3652
+ }
3653
+ active.playerContinuation = playerContinuation;
3654
+ readiness.resolve({ status: 'ready' });
3655
+ return active.result.promise;
3656
+ },
3657
+ completeEffectBoundary: async (completion) => {
3658
+ if (active.input === undefined) {
3659
+ throw new TypeError(
3660
+ 'DECIDE deferred continuation omitted its authored player input',
3661
+ );
3662
+ }
3663
+ return completionEvidenceFor(
3664
+ active.input,
3665
+ active.input.role,
3666
+ active.playerId,
3667
+ signal,
3668
+ operationId,
3669
+ )(completion);
3670
+ },
3671
+ });
3672
+ void repositoryCall.then(
3673
+ (value) => readiness.resolve({ status: 'settled', value }),
3674
+ (reason: unknown) => readiness.resolve({ status: 'rejected', reason }),
3675
+ );
3676
+ const prepared = await readiness.promise;
3677
+ if (prepared.status === 'rejected') throw prepared.reason;
3678
+ if (prepared.status === 'settled') {
3679
+ synchronizeDeferredProjection(
3680
+ assertPlaybookEffectLedger(
3681
+ prepared.value.effectLedger,
3682
+ 'DECIDE deferred checkpoint-mismatch effect ledger',
3683
+ ),
3684
+ );
3685
+ hiddenDeferredOperationId = operationId;
3686
+ return { proceed: false };
3687
+ }
3688
+
3689
+ activeDeferredContinuation = active;
3690
+ const acknowledgement = repositoryCall.then(
3691
+ (value) => {
3692
+ if (value.status !== 'continued') {
3693
+ throw new TypeError(
3694
+ 'DECIDE deferred repository changed status after starting its operation',
3695
+ );
3696
+ }
3697
+ const result = acknowledgeGovernedPlayerResult(
3698
+ value,
3699
+ effectBoundary.boundaryId,
3700
+ );
3701
+ if (hiddenDeferredOperationId === undefined) {
3702
+ suppressInspectionEmissions = false;
3703
+ const live = actor;
3704
+ if (live === undefined) {
3705
+ throw new Error(
3706
+ 'DECIDE deferred acknowledgement lost its runtime actor',
3707
+ );
3708
+ }
3709
+ // Entry into the governed continuation was hidden until its durable
3710
+ // effect acknowledgement. Publish that exact current root snapshot
3711
+ // before releasing the actor output, so an accepted-outcome marker
3712
+ // is confirmed against commitCoderProposal rather than the earlier
3713
+ // awaitBossReply snapshot.
3714
+ inspect({
3715
+ type: '@xstate.snapshot',
3716
+ actorRef: live,
3717
+ event: resumeEvent,
3718
+ snapshot: live.getSnapshot(),
3719
+ } as unknown as InspectionEvent);
3720
+ }
3721
+ active.acknowledged.resolve(result);
3722
+ },
3723
+ (error: unknown) => {
3724
+ active.acknowledged.reject(error);
3725
+ throw error;
3726
+ },
3727
+ ).catch((error: unknown) => {
3728
+ active.acknowledged.reject(error);
3729
+ throw error;
3730
+ });
3731
+ return { proceed: true, acknowledgement };
3732
+ };
3733
+
3734
+ const parkDeferredContinuation = async (
3735
+ signal: AbortSignal,
3736
+ ): Promise<void> => {
3737
+ if (deferredEffects === undefined || deferredOperationId === undefined) {
3738
+ return;
3739
+ }
3740
+ const operationId = deferredOperationId;
3741
+ const parked = await deferredEffects.repository.runDeferred({
3742
+ mode: 'park',
3743
+ signal,
3744
+ operationId,
3745
+ });
3746
+ synchronizeDeferredProjection(
3747
+ assertPlaybookEffectLedger(
3748
+ parked.effectLedger,
3749
+ 'DECIDE deferred park effect ledger',
3750
+ ),
3751
+ );
3752
+ hiddenDeferredOperationId = operationId;
3753
+ };
3754
+
3755
+ const restoreDeferredReconciliation = async (
3756
+ operationId: string,
3757
+ signal: AbortSignal,
3758
+ publishQuestion: boolean,
3759
+ ): Promise<'restored' | 'checkpoint-mismatch' | 'ineligible'> => {
3760
+ const restored = await deferredEffects.repository.runDeferred({
3761
+ mode: 'restore',
3762
+ signal,
3763
+ operationId,
3764
+ });
3765
+ synchronizeDeferredProjection(
3766
+ assertPlaybookEffectLedger(
3767
+ restored.effectLedger,
3768
+ 'DECIDE deferred restoration effect ledger',
3769
+ ),
3770
+ );
3771
+ if (restored.status === 'parked') {
3772
+ throw new TypeError(
3773
+ 'DECIDE deferred restoration returned an invalid parked status',
3774
+ );
3775
+ }
3776
+ if (restored.status !== 'restored') {
3777
+ if (hiddenDeferredOperationId !== operationId) {
3778
+ throw new TypeError(
3779
+ 'DECIDE unresolved deferred restoration lost its operation identity',
3780
+ );
3781
+ }
3782
+ return restored.status;
3783
+ }
3784
+ if (hiddenDeferredOperationId !== undefined) {
3785
+ throw new TypeError(
3786
+ 'DECIDE restored deferred operation remained unresolved',
3787
+ );
3788
+ }
3789
+ if (openDeferredOperation()?.operationId !== operationId) {
3790
+ throw new TypeError(
3791
+ 'DECIDE restored deferred operation is not the current bound wait',
3792
+ );
3793
+ }
3794
+ const live = actor;
3795
+ if (live === undefined) {
3796
+ throw new Error('decide runtime: init(session) must be called first');
3797
+ }
3798
+ const state = currentState();
3799
+ const context = (
3800
+ live.getSnapshot() as SnapshotFrom<typeof decideMachine>
3801
+ ).context as unknown as Record<string, unknown>;
3802
+ const pending = questionForWaitState(
3803
+ 'awaitBossReply',
3804
+ visiblePendingQuestionsForState(state, context),
3805
+ );
3806
+ const operation = openDeferredOperation();
3807
+ const projectedPending =
3808
+ pending === undefined
3809
+ ? undefined
3810
+ : {
3811
+ questionId: pending.questionId,
3812
+ asker: pending.asker,
3813
+ question: pending.question,
3814
+ sourceItem: pending.sourceItem,
3815
+ };
3816
+ if (
3817
+ pending === undefined ||
3818
+ operation?.pendingQuestion === undefined ||
3819
+ stableJson(
3820
+ projectedPending,
3821
+ 'DECIDE restored FSM pending question',
3822
+ ) !==
3823
+ stableJson(
3824
+ operation.pendingQuestion,
3825
+ 'DECIDE restored deferred pending question',
3826
+ )
3827
+ ) {
3828
+ throw new TypeError(
3829
+ 'DECIDE restored deferred wait does not equal its FSM question',
3830
+ );
3831
+ }
3832
+ if (publishQuestion) {
3833
+ await emitBoundaryStatus(
3834
+ `${pending.asker.roleId} asks: ${pending.question}`,
3835
+ state,
3836
+ );
3837
+ await emitBoundaryStatus(
3838
+ `◆ awaiting Boss reply · ${pending.resumeStateId} · ${pending.asker.roleId} · ${pending.sourceItem}`,
3839
+ state,
3840
+ );
3841
+ await flush();
1556
3842
  }
3843
+ return 'restored';
1557
3844
  };
1558
3845
 
1559
- const createRuntimeActor = (machineSnapshot?: JsonValue): void => {
1560
- previousState = undefined;
1561
- // DR-014 §1: a restore rehydrates the persisted machine snapshot;
1562
- // XState derives context/value from it and ignores `input` then.
1563
- actor = createActor(providedMachine, {
1564
- input: fsmInput,
1565
- ...(machineSnapshot === undefined
1566
- ? {}
1567
- : {
1568
- snapshot: machineSnapshot as unknown as SnapshotFrom<
1569
- typeof decideMachine
1570
- >,
1571
- }),
1572
- inspect,
1573
- });
3846
+ type UnresolvedControlCandidate = {
3847
+ readonly action: PlaybookControlAction;
3848
+ readonly kind: 'reconcile' | 'abandon';
3849
+ readonly deferredRestoreOperationId?: string;
1574
3850
  };
3851
+ type UnresolvedControlCandidates = readonly UnresolvedControlCandidate[];
1575
3852
 
1576
- // PBRT-6: the single seam that stops this runtime's actor. Stopping a
1577
- // still-running actor fires one more `@xstate.snapshot` for the *unchanged*
1578
- // state value with `status: 'stopped'`; `inspect` cannot tell that disposal
1579
- // artifact from a state entry, so unsuppressed it re-emits the parked
1580
- // state's telemetry and a phantom self-loop transition. Suppression is a
1581
- // property of stopping, not a rule each caller must remember — every stop
1582
- // goes through here so no later site can reintroduce the omission.
1583
- const stopActor = (): void => {
1584
- if (!actor) return;
1585
- suppressInspectionEmissions = true;
1586
- actor.stop();
3853
+ const unresolvedControlCandidates = (): UnresolvedControlCandidates => {
3854
+ if (!hasUnresolvedReconciliation()) return [];
3855
+ const operation =
3856
+ hiddenDeferredOperationId === undefined
3857
+ ? undefined
3858
+ : effectLedgerMirror.logicalOperations.find(
3859
+ ({ operationId }) =>
3860
+ operationId === hiddenDeferredOperationId,
3861
+ );
3862
+ return [
3863
+ {
3864
+ action: {
3865
+ id: UNRESOLVED_EFFECT_RECONCILIATION_ACTION_ID,
3866
+ label: 'Retry unresolved effect reconciliation',
3867
+ },
3868
+ kind: 'reconcile',
3869
+ ...(operation?.checkpointRestorationEligible === true
3870
+ ? { deferredRestoreOperationId: operation.operationId }
3871
+ : {}),
3872
+ },
3873
+ {
3874
+ action: {
3875
+ id: UNRESOLVED_EFFECT_ABANDONMENT_ACTION_ID,
3876
+ label: 'Abandon unresolved workflow attempt',
3877
+ },
3878
+ kind: 'abandon',
3879
+ },
3880
+ ];
1587
3881
  };
1588
3882
 
1589
- const startActor = (): void => {
1590
- createRuntimeActor();
1591
- // A fresh actor's emissions are real state entries again.
1592
- suppressInspectionEmissions = false;
1593
- actor?.start();
3883
+ const deriveControlCandidates = (): UnresolvedControlCandidates => {
3884
+ const state = currentState();
3885
+ if (
3886
+ state.status !== 'active' ||
3887
+ !state.quiescent ||
3888
+ nestedBridge.getPendingCall() !== undefined
3889
+ ) {
3890
+ return [];
3891
+ }
3892
+ return unresolvedControlCandidates();
1594
3893
  };
1595
3894
 
1596
- const driveToQuiescence = async (): Promise<void> => {
3895
+ const describeControlView = (): PlaybookControlView => {
3896
+ if (disposed || disposalPromise !== undefined) {
3897
+ throw new Error('decide runtime: runtime is disposing or disposed');
3898
+ }
1597
3899
  const live = actor;
1598
- if (!live) throw new Error('decide runtime: actor is not initialized');
1599
- await waitForPlaybookQuiescence(live, { pendingCalls: nestedBridge });
3900
+ if (live === undefined || sessionIdentity === undefined) {
3901
+ throw new Error(
3902
+ 'decide runtime: init(session) must be called before describe',
3903
+ );
3904
+ }
3905
+ if (currentSignal !== undefined || currentTurnId !== undefined) {
3906
+ throw new Error('decide runtime: another runtime turn is active');
3907
+ }
3908
+ refreshReconciliationProjection();
3909
+ const state = currentState();
3910
+ const context = (
3911
+ live.getSnapshot() as SnapshotFrom<typeof decideMachine>
3912
+ ).context as unknown as Record<string, unknown>;
3913
+ const unresolved = hasUnresolvedReconciliation();
3914
+ const pendingQuestions = unresolved
3915
+ ? []
3916
+ : visiblePendingQuestionsForState(state, context).map((pending) =>
3917
+ Object.freeze({
3918
+ questionId: pending.questionId,
3919
+ asker: pending.asker,
3920
+ question: pending.question,
3921
+ sourceItem: pending.sourceItem,
3922
+ }),
3923
+ );
3924
+ const actions = deriveControlCandidates().map(({ action }) => ({
3925
+ ...action,
3926
+ }));
3927
+ const lastError = normalizeErrorFull(context.lastError);
3928
+ const stateDescription =
3929
+ unresolved || state.stateId === undefined
3930
+ ? undefined
3931
+ : STATE_DESCRIPTIONS[state.stateId];
3932
+ return deepFreeze(
3933
+ snapshotJsonValue(
3934
+ {
3935
+ state,
3936
+ ...(stateDescription === undefined ? {} : { stateDescription }),
3937
+ pendingQuestions,
3938
+ ...(lastError === undefined ? {} : { lastError }),
3939
+ actions,
3940
+ },
3941
+ 'DECIDE control view',
3942
+ ) as unknown as PlaybookControlView,
3943
+ );
1600
3944
  };
1601
3945
 
1602
- const classify = async (
1603
- text: string,
1604
- signal: AbortSignal,
1605
- ): Promise<DecideEvent | { type: 'NO_ACTION' } | null> => {
1606
- const live = actor;
1607
- if (!live) throw new Error('decide runtime: actor is not initialized');
1608
- const snapshot = live.getSnapshot() as SnapshotFrom<typeof decideMachine>;
1609
- const context = snapshot.context as unknown as Record<string, unknown>;
1610
- const state = normalizePlaybookSnapshot(snapshot, {
1611
- pendingCall: nestedBridge.getPendingCall(),
1612
- });
1613
- const pendingQuestions = pendingQuestionsFromContext(context);
1614
- if (
1615
- pendingQuestions.length === 0 &&
1616
- (snapshot.status === 'done' ||
1617
- state.activeStateIds.includes('ready') ||
1618
- state.activeStateIds.includes('failed'))
1619
- ) {
1620
- return { type: 'START_DECIDE', callerTopic: text };
3946
+ const normalizedControlError = (error: unknown): NormalizedError =>
3947
+ normalizeErrorFull(error) ?? {
3948
+ name: error instanceof Error ? error.name : 'Error',
3949
+ message: error instanceof Error ? error.message : String(error),
3950
+ };
3951
+
3952
+ const frozenControlReceipt = (
3953
+ receipt: PlaybookControlReceipt,
3954
+ ): PlaybookControlReceipt =>
3955
+ deepFreeze(
3956
+ snapshotJsonValue(
3957
+ receipt,
3958
+ 'DECIDE apply receipt',
3959
+ ) as unknown as PlaybookControlReceipt,
3960
+ );
3961
+
3962
+ const applyControlAction = async (input: {
3963
+ actionId: string;
3964
+ key: string;
3965
+ signal: AbortSignal;
3966
+ }): Promise<PlaybookControlReceipt> => {
3967
+ if (input === null || typeof input !== 'object') {
3968
+ throw new TypeError('decide runtime: apply input must be an object');
1621
3969
  }
1622
- if (pendingQuestions.length === 0) return null;
1623
- const prompt = buildClassifierPrompt(text, {
1624
- state,
1625
- pendingQuestions,
3970
+ const { actionId, key, signal } = input;
3971
+ if (typeof actionId !== 'string' || actionId.length === 0) {
3972
+ throw new TypeError(
3973
+ 'decide runtime: apply actionId must be a non-empty string',
3974
+ );
3975
+ }
3976
+ if (typeof key !== 'string' || key.length === 0) {
3977
+ throw new TypeError(
3978
+ 'decide runtime: apply key must be a non-empty string',
3979
+ );
3980
+ }
3981
+ if (!(signal instanceof AbortSignal)) {
3982
+ throw new TypeError(
3983
+ 'decide runtime: apply signal must be an AbortSignal',
3984
+ );
3985
+ }
3986
+ if (disposed || disposalPromise !== undefined) {
3987
+ throw new Error('decide runtime: runtime is disposing or disposed');
3988
+ }
3989
+ if (actor === undefined || sessionIdentity === undefined) {
3990
+ throw new Error(
3991
+ 'decide runtime: init(session) must be called before apply',
3992
+ );
3993
+ }
3994
+ if (currentSignal !== undefined || currentTurnId !== undefined) {
3995
+ throw new Error('decide runtime: another runtime turn is active');
3996
+ }
3997
+ const recorded = appliedControlReceipts.get(key);
3998
+ if (recorded !== undefined) return recorded;
3999
+ signal.throwIfAborted();
4000
+ refreshReconciliationProjection();
4001
+
4002
+ const turnId = ++turnSequence;
4003
+ const callId = `apply-${++applyCallSequence}`;
4004
+ currentTurnId = turnId;
4005
+ currentSignal = signal;
4006
+ currentAborts = abortReasonClassifier(signal);
4007
+ controlPlaneError = undefined;
4008
+ let accepted = false;
4009
+ let receipt: PlaybookControlReceipt | undefined;
4010
+ let operationError: unknown;
4011
+ let settlementError: unknown;
4012
+ let lateDeliveryError: unknown;
4013
+ const position = { turnId, callId };
4014
+ const preAcceptanceFinish = (
4015
+ reason: string,
4016
+ ): Record<string, unknown> => ({
4017
+ actionId,
4018
+ key,
4019
+ disposition: 'rejected',
4020
+ reason,
1626
4021
  });
1627
- const raw = await callJudge(
1628
- prompt,
1629
- signal,
1630
- 'boss-input-classification',
1631
- state.stateId,
1632
- );
1633
- return parseClassification(
1634
- raw,
1635
- text,
1636
- pendingQuestions.map(({ questionId }) => questionId),
1637
- );
4022
+ try {
4023
+ try {
4024
+ try {
4025
+ await emitTrace(
4026
+ 'apply.started',
4027
+ {
4028
+ actionId,
4029
+ key,
4030
+ ...stateIdentity(currentState()),
4031
+ },
4032
+ position,
4033
+ currentAborts,
4034
+ );
4035
+ } catch (error) {
4036
+ latchControlPlaneError(error, signal);
4037
+ try {
4038
+ await emitTrace(
4039
+ 'apply.finished',
4040
+ {
4041
+ ...preAcceptanceFinish('apply.started trace sink rejected'),
4042
+ status: isAbortFailure(error, signal) ? 'aborted' : 'error',
4043
+ error: normalizedControlError(error),
4044
+ },
4045
+ position,
4046
+ currentAborts,
4047
+ );
4048
+ } catch {
4049
+ // Preserve the start failure after one best-effort finish attempt.
4050
+ }
4051
+ throw error;
4052
+ }
4053
+ await flush();
4054
+ if (signal.aborted) {
4055
+ try {
4056
+ await emitTrace(
4057
+ 'apply.finished',
4058
+ {
4059
+ ...preAcceptanceFinish('aborted before acceptance'),
4060
+ status: 'aborted',
4061
+ error: normalizedControlError(signal.reason),
4062
+ },
4063
+ position,
4064
+ currentAborts,
4065
+ );
4066
+ } catch (error) {
4067
+ // A rejecting abort-finish sink outranks the abort at settlement.
4068
+ settlementError ??= error;
4069
+ }
4070
+ }
4071
+ signal.throwIfAborted();
4072
+ const candidate = deriveControlCandidates().find(
4073
+ ({ action }) => action.id === actionId,
4074
+ );
4075
+ if (candidate === undefined) {
4076
+ receipt = frozenControlReceipt({
4077
+ disposition: 'rejected',
4078
+ reason: `action ${JSON.stringify(actionId)} is not currently advertised`,
4079
+ });
4080
+ } else {
4081
+ // Acceptance is the line after which this boundary always records
4082
+ // and returns a receipt: the requested effect may now exist.
4083
+ accepted = true;
4084
+ try {
4085
+ let run: PlaybookRunResult;
4086
+ if (candidate.kind === 'abandon') {
4087
+ signal.throwIfAborted();
4088
+ run = {
4089
+ outcome: 'unresolved-effect',
4090
+ state: currentState(),
4091
+ };
4092
+ } else {
4093
+ if (candidate.deferredRestoreOperationId !== undefined) {
4094
+ await restoreDeferredReconciliation(
4095
+ candidate.deferredRestoreOperationId,
4096
+ signal,
4097
+ true,
4098
+ );
4099
+ } else {
4100
+ // The host owns receipt reconstruction. Reconciliation only
4101
+ // re-reads its authoritative ledger; it never calls a player
4102
+ // or judge to manufacture missing semantic evidence.
4103
+ refreshReconciliationProjection();
4104
+ }
4105
+ signal.throwIfAborted();
4106
+ run = {
4107
+ outcome: hasUnresolvedReconciliation()
4108
+ ? 'no-action'
4109
+ : 'quiescent',
4110
+ state: currentState(),
4111
+ };
4112
+ }
4113
+ if (controlPlaneError !== undefined) throw controlPlaneError;
4114
+ receipt = frozenControlReceipt({
4115
+ disposition: 'executed',
4116
+ run,
4117
+ });
4118
+ } catch (error) {
4119
+ receipt = frozenControlReceipt({
4120
+ disposition: 'failed',
4121
+ error: normalizedControlError(error),
4122
+ });
4123
+ }
4124
+ appliedControlReceipts.set(key, receipt);
4125
+ }
4126
+ } catch (error) {
4127
+ operationError = error;
4128
+ }
4129
+
4130
+ try {
4131
+ await flush();
4132
+ } catch (error) {
4133
+ settlementError = error;
4134
+ }
4135
+ settlementError ??= controlPlaneError;
4136
+ if (accepted && settlementError !== undefined) {
4137
+ receipt = frozenControlReceipt({
4138
+ disposition: 'failed',
4139
+ error: normalizedControlError(settlementError),
4140
+ });
4141
+ appliedControlReceipts.set(key, receipt);
4142
+ settlementError = undefined;
4143
+ }
4144
+
4145
+ if (receipt !== undefined) {
4146
+ const finishFailures: unknown[] = [];
4147
+ try {
4148
+ await emitTrace(
4149
+ 'apply.finished',
4150
+ { actionId, key, ...receipt },
4151
+ position,
4152
+ currentAborts,
4153
+ );
4154
+ } catch (error) {
4155
+ if (!accepted || !isAbortFailure(error, signal)) {
4156
+ collectFailure(finishFailures, error);
4157
+ }
4158
+ }
4159
+ try {
4160
+ await flush();
4161
+ } catch (error) {
4162
+ if (!accepted || !isAbortFailure(error, signal)) {
4163
+ collectFailure(finishFailures, error);
4164
+ }
4165
+ }
4166
+ if (finishFailures.length > 0) {
4167
+ const failure =
4168
+ finishFailures.length === 1
4169
+ ? finishFailures[0]
4170
+ : new AggregateError(
4171
+ finishFailures,
4172
+ 'DECIDE apply settlement emissions failed',
4173
+ );
4174
+ if (accepted) lateDeliveryError = failure;
4175
+ else settlementError = failure;
4176
+ }
4177
+ }
4178
+ } finally {
4179
+ currentSignal = undefined;
4180
+ currentAborts = undefined;
4181
+ currentTurnId = undefined;
4182
+ controlPlaneError = undefined;
4183
+ if (accepted && receipt !== undefined) {
4184
+ appliedControlReceipts.set(key, receipt);
4185
+ }
4186
+ }
4187
+ if (lateDeliveryError !== undefined) {
4188
+ collectFailure(emissionFailures, lateDeliveryError);
4189
+ }
4190
+ if (accepted && receipt !== undefined) return receipt;
4191
+ const failure = settlementError ?? operationError;
4192
+ if (failure !== undefined) throw failure;
4193
+ if (receipt === undefined) {
4194
+ throw new Error('decide runtime: apply produced no receipt');
4195
+ }
4196
+ return receipt;
1638
4197
  };
1639
4198
 
1640
4199
  const resultForSnapshot = (signal?: AbortSignal): PlaybookRunResult => {
@@ -1644,34 +4203,58 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1644
4203
  const pendingCall = nestedBridge.getPendingCall();
1645
4204
  const state = normalizePlaybookSnapshot(snapshot, { pendingCall });
1646
4205
  const context = snapshot.context as unknown as Record<string, unknown>;
1647
- if (signal?.aborted) {
1648
- return {
1649
- outcome: 'aborted',
1650
- state,
1651
- ...(signal.reason === undefined
1652
- ? {}
1653
- : {
1654
- error: normalizeErrorFull(signal.reason) ?? {
1655
- name: 'AbortError',
1656
- message: String(signal.reason),
1657
- },
1658
- }),
1659
- };
4206
+ const abortedResult = (abortSignal: AbortSignal): PlaybookRunResult => ({
4207
+ outcome: 'aborted',
4208
+ state,
4209
+ ...(abortSignal.reason === undefined
4210
+ ? {}
4211
+ : {
4212
+ error: normalizeErrorFull(abortSignal.reason) ?? {
4213
+ name: 'AbortError',
4214
+ message: String(abortSignal.reason),
4215
+ },
4216
+ }),
4217
+ });
4218
+ if (snapshot.status === 'error') {
4219
+ // An errored actor outranks a coincident abort unless the actor's
4220
+ // error is the abort reason itself (slc/link.md §Abort).
4221
+ const actorError = (snapshot as { error?: unknown }).error;
4222
+ if (
4223
+ actorError !== undefined &&
4224
+ signal !== undefined &&
4225
+ isAbortFailure(actorError, signal)
4226
+ ) {
4227
+ return abortedResult(signal);
4228
+ }
4229
+ throw (
4230
+ actorError ?? new Error('decide runtime actor entered error status')
4231
+ );
1660
4232
  }
4233
+ // Terminal completion outranks a coincident abort (DR-036 §3): reporting
4234
+ // 'aborted' over a completed machine would hide a terminal state that the
4235
+ // next turn silently restarts, duplicating the workflow's side effects.
1661
4236
  if (snapshot.status === 'done') {
1662
4237
  const output = (snapshot as { output?: unknown }).output;
1663
4238
  if (output !== undefined) assertJsonSafe(output, 'terminal output');
4239
+ const stateDescription = state.activeStateIds.includes('done')
4240
+ ? STATE_DESCRIPTIONS.done
4241
+ : state.activeStateIds.includes('reportedReviewFailure')
4242
+ ? STATE_DESCRIPTIONS.reportedReviewFailure
4243
+ : undefined;
4244
+ if (stateDescription === undefined) {
4245
+ throw new Error(
4246
+ 'decide runtime: completed actor has no authored final-state description',
4247
+ );
4248
+ }
1664
4249
  return {
1665
4250
  outcome: 'terminal',
1666
4251
  state,
4252
+ stateDescription,
1667
4253
  ...(output === undefined ? {} : { output }),
1668
4254
  };
1669
4255
  }
1670
- if (snapshot.status === 'error') {
1671
- throw (
1672
- (snapshot as { error?: unknown }).error ??
1673
- new Error('decide runtime actor entered error status')
1674
- );
4256
+ if (signal?.aborted) {
4257
+ return abortedResult(signal);
1675
4258
  }
1676
4259
  if (state.activeStateIds.includes('failed')) {
1677
4260
  const error = normalizeErrorFull(context.lastError);
@@ -1739,8 +4322,12 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1739
4322
  activeEmissionCalls.clear();
1740
4323
  emissionQueue.clear();
1741
4324
  judgeQueue.clear();
4325
+ semanticCompletionQueue.clear();
1742
4326
  actor = undefined;
1743
4327
  currentSignal = undefined;
4328
+ currentAborts = undefined;
4329
+ actorSettlementAborts.length = 0;
4330
+ actorSettlementErrorAborts = undefined;
1744
4331
  currentTurnId = undefined;
1745
4332
  ports = undefined;
1746
4333
  sessionIdentity = undefined;
@@ -1753,7 +4340,20 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1753
4340
  judgeCallSequence = 0;
1754
4341
  playerCallSequence = 0;
1755
4342
  playbookCallSequence = 0;
4343
+ applyCallSequence = 0;
1756
4344
  playbookCallTurnIds.clear();
4345
+ governedOutputsByBoundaryId.clear();
4346
+ governedFailuresByBoundaryId.clear();
4347
+ governedEvidenceByBoundaryId.clear();
4348
+ governedReceiptsByBoundaryId.clear();
4349
+ pendingProposalCohort.clear();
4350
+ activeProposalCohort = undefined;
4351
+ completedProposalCohortTurnId = undefined;
4352
+ deferredOperationId = undefined;
4353
+ hiddenDeferredOperationId = undefined;
4354
+ unresolvedSemanticBoundaryIds.clear();
4355
+ appliedControlReceipts.clear();
4356
+ activeDeferredContinuation = undefined;
1757
4357
  lifecycleStarted = false;
1758
4358
  };
1759
4359
 
@@ -1779,6 +4379,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1779
4379
  ports = identity.ports;
1780
4380
  sessionIdentity = identity;
1781
4381
  try {
4382
+ if (deferredEffects !== undefined) {
4383
+ effectLedgerMirror = readEffectLedger();
4384
+ synchronizeDeferredProjection(effectLedgerMirror);
4385
+ }
1782
4386
  suppressInspectionEmissions = false;
1783
4387
  createRuntimeActor();
1784
4388
  const state = currentState();
@@ -1797,6 +4401,10 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1797
4401
  }
1798
4402
  },
1799
4403
 
4404
+ describe: describeControlView,
4405
+ unresolvedEffectEnvelopes: unresolvedEffectEnvelopeIdentities,
4406
+ apply: applyControlAction,
4407
+
1800
4408
  // DR-014 §1 / DR-031 §5 / PBRT-45: JSON-safe capture of a parked
1801
4409
  // session, including one already-started suspended REVIEW call.
1802
4410
  // Defined only at a safe capture point — initialized, not disposing
@@ -1853,8 +4461,12 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1853
4461
  const context = (
1854
4462
  actor.getSnapshot() as SnapshotFrom<typeof decideMachine>
1855
4463
  ).context as unknown as Record<string, unknown>;
4464
+ if (deferredEffects !== undefined) {
4465
+ synchronizeDeferredProjection(readEffectLedger());
4466
+ }
4467
+ const unresolved = hasUnresolvedReconciliation();
1856
4468
  return {
1857
- schemaVersion: 3,
4469
+ schemaVersion: 4,
1858
4470
  playbookId: sessionIdentity.playbookId,
1859
4471
  machine,
1860
4472
  roleResumeTokens: snapshotRoleResumeTokens(),
@@ -1866,14 +4478,15 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1866
4478
  playbookCall: playbookCallSequence,
1867
4479
  },
1868
4480
  state,
1869
- pendingBossQuestions: pendingQuestionsFromContext(context).map(
1870
- (pending) => ({
1871
- questionId: pending.questionId,
1872
- asker: pending.asker,
1873
- question: pending.question,
1874
- sourceItem: pending.sourceItem,
1875
- }),
1876
- ),
4481
+ pendingBossQuestions: unresolved
4482
+ ? []
4483
+ : visiblePendingQuestionsForState(state, context).map((pending) => ({
4484
+ questionId: pending.questionId,
4485
+ asker: pending.asker,
4486
+ question: pending.question,
4487
+ sourceItem: pending.sourceItem,
4488
+ })),
4489
+ effectLedger: effectLedgerMirror,
1877
4490
  ...(suspendedCall === undefined ? {} : { suspendedCall }),
1878
4491
  };
1879
4492
  },
@@ -1903,6 +4516,28 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1903
4516
  identity.playbookId,
1904
4517
  { allowSuspendedCall: true },
1905
4518
  );
4519
+ if (deferredEffects === undefined) {
4520
+ if (
4521
+ boundSnapshot.effectLedger.revision !== 0 ||
4522
+ boundSnapshot.effectLedger.boundaries.length !== 0 ||
4523
+ boundSnapshot.effectLedger.logicalOperations.length !== 0
4524
+ ) {
4525
+ throw new TypeError(
4526
+ 'decide runtime snapshot effectLedger must be the canonical empty ledger',
4527
+ );
4528
+ }
4529
+ } else {
4530
+ const current = readEffectLedger();
4531
+ if (
4532
+ stableJson(current, 'DECIDE current effect ledger') !==
4533
+ stableJson(boundSnapshot.effectLedger, 'DECIDE snapshot effect ledger')
4534
+ ) {
4535
+ throw new TypeError(
4536
+ 'decide runtime snapshot effectLedger must equal the current host mirror',
4537
+ );
4538
+ }
4539
+ effectLedgerMirror = current;
4540
+ }
1906
4541
  const suspendedCall = boundSnapshot.suspendedCall;
1907
4542
  let finishInitialization!: () => void;
1908
4543
  const initialization = new Promise<void>((resolve) => {
@@ -1921,6 +4556,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1921
4556
  judgeCallSequence = boundSnapshot.sequences.judgeCall;
1922
4557
  playerCallSequence = boundSnapshot.sequences.playerCall;
1923
4558
  playbookCallSequence = boundSnapshot.sequences.playbookCall;
4559
+ applyCallSequence = boundSnapshot.sequences.trace;
1924
4560
  if (identity.playerSessions) {
1925
4561
  priorExternalRoleTokens = snapshotRoleResumeTokens();
1926
4562
  }
@@ -1929,6 +4565,7 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1929
4565
  if (suspendedCall !== undefined) {
1930
4566
  playbookCallTurnIds.set(suspendedCall.callId, suspendedCall.turnId);
1931
4567
  }
4568
+ synchronizeDeferredProjection(effectLedgerMirror);
1932
4569
  suppressInspectionEmissions = true;
1933
4570
  createRuntimeActor(boundSnapshot.machine);
1934
4571
  actor?.start();
@@ -1993,12 +4630,17 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
1993
4630
  const turnId = ++turnSequence;
1994
4631
  currentTurnId = turnId;
1995
4632
  currentSignal = turn.signal;
4633
+ currentAborts = abortReasonClassifier(turn.signal);
1996
4634
  controlPlaneError = undefined;
1997
4635
  let result: PlaybookRunResult = resultForSnapshot(turn.signal);
1998
4636
  let settlement: unknown = result;
1999
4637
  const failures: unknown[] = [];
2000
4638
  try {
2001
4639
  await emitTrace('boss.input.received', { text: turn.text }, { turnId });
4640
+ // A boundary entered aborted records the attempted input, then refuses
4641
+ // delivery before deterministic mapping or the classifier can perform
4642
+ // any host-visible work (DR-036 §5).
4643
+ turn.signal.throwIfAborted();
2002
4644
  if (turn.text.trim().length === 0) {
2003
4645
  const state = currentState();
2004
4646
  result = { outcome: 'no-action', state };
@@ -2006,23 +4648,91 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2006
4648
  const event = await classify(turn.text, turn.signal);
2007
4649
  if (!event) {
2008
4650
  const state = currentState();
2009
- await emitBoundaryStatus('No playbook action classified.', state);
4651
+ if (!hasUnresolvedReconciliation()) {
4652
+ await emitBoundaryStatus('No playbook action classified.', state);
4653
+ }
2010
4654
  result = { outcome: 'no-action', state };
2011
4655
  } else if (event.type === 'NO_ACTION') {
2012
4656
  result = { outcome: 'no-action', state: currentState() };
2013
4657
  } else {
2014
- await emitBoundaryStatus(event.type, currentState());
2015
- if (actor.getSnapshot().status === 'done') {
2016
- stopActor();
2017
- startActor();
2018
- }
4658
+ const before = currentState();
4659
+ const boundCommitWait =
4660
+ deferredEffects !== undefined &&
4661
+ deferredOperationId !== undefined &&
4662
+ before.activeStateIds.includes('awaitBossReply');
4663
+ if (boundCommitWait && event.type === 'BOSS_INTERRUPT') {
4664
+ await parkDeferredContinuation(turn.signal);
4665
+ result = { outcome: 'no-action', state: currentState() };
4666
+ } else {
4667
+ const prepared =
4668
+ boundCommitWait &&
4669
+ event.type === 'BOSS_REPLY' &&
4670
+ event.questionId === 'commitCoderProposal'
4671
+ ? await prepareDeferredContinuation(turn.signal, event)
4672
+ : undefined;
4673
+ if (prepared?.proceed === false) {
4674
+ result = { outcome: 'no-action', state: currentState() };
4675
+ } else {
4676
+ await emitBoundaryStatus(event.type, before);
4677
+ if (actor.getSnapshot().status === 'done') {
4678
+ stopActor();
4679
+ startActor();
4680
+ }
2019
4681
 
2020
- actor.send(event);
2021
- await driveToQuiescence();
2022
- await drainBoundaryCallsAndEmissions();
4682
+ const deferredMachineCheckpoint =
4683
+ prepared?.proceed === true
4684
+ ? detachPersistedMachineSnapshot(
4685
+ actor.getPersistedSnapshot(),
4686
+ )
4687
+ : undefined;
4688
+ if (deferredMachineCheckpoint !== undefined) {
4689
+ suppressInspectionEmissions = true;
4690
+ }
2023
4691
 
2024
- if (controlPlaneError !== undefined) throw controlPlaneError;
2025
- result = resultForSnapshot(turn.signal);
4692
+ try {
4693
+ actor.send(event);
4694
+ await driveToQuiescence();
4695
+ await drainBoundaryCallsAndEmissions();
4696
+ await prepared?.acknowledgement;
4697
+ if (
4698
+ deferredMachineCheckpoint !== undefined &&
4699
+ hiddenDeferredOperationId !== undefined
4700
+ ) {
4701
+ stopActor();
4702
+ createRuntimeActor(deferredMachineCheckpoint);
4703
+ actor.start();
4704
+ previousState = before;
4705
+ suppressInspectionEmissions = false;
4706
+ }
4707
+ } catch (error) {
4708
+ activeDeferredContinuation?.result.reject(error);
4709
+ if (deferredEffects !== undefined) {
4710
+ try {
4711
+ synchronizeDeferredProjection(readEffectLedger());
4712
+ hiddenDeferredOperationId ??= deferredOperationId;
4713
+ } catch {
4714
+ hiddenDeferredOperationId ??= deferredOperationId;
4715
+ }
4716
+ }
4717
+ if (deferredMachineCheckpoint !== undefined) {
4718
+ try {
4719
+ stopActor();
4720
+ createRuntimeActor(deferredMachineCheckpoint);
4721
+ actor.start();
4722
+ previousState = before;
4723
+ } finally {
4724
+ suppressInspectionEmissions = false;
4725
+ }
4726
+ }
4727
+ throw error;
4728
+ } finally {
4729
+ activeDeferredContinuation = undefined;
4730
+ }
4731
+
4732
+ if (controlPlaneError !== undefined) throw controlPlaneError;
4733
+ result = resultForSnapshot(turn.signal);
4734
+ }
4735
+ }
2026
4736
  }
2027
4737
  }
2028
4738
  settlement = {
@@ -2031,15 +4741,18 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2031
4741
  };
2032
4742
  } catch (error) {
2033
4743
  const primaryError = controlPlaneError;
4744
+ // Only a rejection that is the exact abort reason settles as the
4745
+ // cancellation; a distinct failure observed while the signal is
4746
+ // aborted remains a control error (slc/link.md §Abort).
2034
4747
  if (primaryError !== undefined) {
2035
4748
  collectFailure(failures, primaryError);
2036
- } else if (!turn.signal.aborted) {
4749
+ } else if (!isAbortFailure(error, turn.signal)) {
2037
4750
  collectFailure(failures, error);
2038
4751
  }
2039
4752
  const state = currentState();
2040
4753
  const effectiveError = primaryError ?? error;
2041
4754
  result =
2042
- turn.signal.aborted && primaryError === undefined
4755
+ isAbortFailure(error, turn.signal) && primaryError === undefined
2043
4756
  ? resultForSnapshot(turn.signal)
2044
4757
  : {
2045
4758
  outcome: 'failed',
@@ -2060,13 +4773,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2060
4773
  } catch (error) {
2061
4774
  const primaryError = controlPlaneError;
2062
4775
  const effectiveError = primaryError ?? error;
2063
- collectFailure(failures, effectiveError);
4776
+ // A drain rejection that is the exact abort reason evidences the
4777
+ // cancellation, not a control-plane failure (slc/link.md §Abort).
4778
+ const drainAborted = isAbortFailure(effectiveError, turn.signal);
4779
+ if (!drainAborted) collectFailure(failures, effectiveError);
2064
4780
  const state = currentState();
2065
4781
  result = {
2066
- outcome:
2067
- turn.signal.aborted && primaryError === undefined
2068
- ? 'aborted'
2069
- : 'failed',
4782
+ outcome: drainAborted ? 'aborted' : 'failed',
2070
4783
  state,
2071
4784
  error: normalizeErrorFull(effectiveError) ?? {
2072
4785
  name: 'Error',
@@ -2075,18 +4788,28 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2075
4788
  };
2076
4789
  settlement = { ...result, ...stateIdentity(state) };
2077
4790
  }
2078
- currentSignal = undefined;
2079
4791
  try {
2080
4792
  await emitTrace('boss.input.settled', settlement, { turnId });
2081
4793
  } catch (error) {
2082
- collectFailure(failures, error);
4794
+ // A settlement-trace rejection that is the exact abort reason also
4795
+ // evidences the cancellation (slc/link.md §Abort).
4796
+ if (!isAbortFailure(error, turn.signal)) {
4797
+ collectFailure(failures, error);
4798
+ }
2083
4799
  }
2084
4800
  try {
2085
4801
  await flush();
2086
4802
  } catch (error) {
2087
- collectFailure(failures, error);
4803
+ // A late flush rejection that is the exact abort reason likewise
4804
+ // evidences the cancellation; the settled result already labels
4805
+ // the turn aborted then (slc/link.md §Abort).
4806
+ if (!isAbortFailure(error, turn.signal)) {
4807
+ collectFailure(failures, error);
4808
+ }
2088
4809
  } finally {
2089
4810
  const primaryError = controlPlaneError;
4811
+ currentSignal = undefined;
4812
+ currentAborts = undefined;
2090
4813
  currentTurnId = undefined;
2091
4814
  controlPlaneError = undefined;
2092
4815
  if (primaryError !== undefined) throw primaryError;
@@ -2120,8 +4843,13 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2120
4843
  if (currentSignal !== undefined) {
2121
4844
  throw new Error('decide runtime: another runtime turn is active');
2122
4845
  }
4846
+ refreshReconciliationProjection();
4847
+ if (hasUnresolvedReconciliation()) {
4848
+ return { outcome: 'no-action', state: currentState() };
4849
+ }
2123
4850
  currentTurnId = playbookCallTurnIds.get(callId);
2124
4851
  currentSignal = signal;
4852
+ currentAborts = abortReasonClassifier(signal);
2125
4853
  controlPlaneError = undefined;
2126
4854
  let runResult: PlaybookRunResult | undefined;
2127
4855
  let operationError: unknown;
@@ -2148,13 +4876,56 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2148
4876
  } catch (error) {
2149
4877
  drainError = error;
2150
4878
  }
2151
- const failure = controlPlaneError ?? drainError ?? operationError;
4879
+ const aborts = currentAborts ?? abortReasonClassifier(signal);
4880
+ // The control latch has already classified its failure as distinct
4881
+ // under the operation that owned it. Only still-unclassified drain and
4882
+ // operation candidates may be cancellation evidence for this resume.
4883
+ const controlFailure = controlPlaneError;
4884
+ const drainAbort =
4885
+ controlFailure === undefined &&
4886
+ drainError !== undefined &&
4887
+ aborts.isAbortReason(drainError);
4888
+ const operationAbort =
4889
+ controlFailure === undefined &&
4890
+ operationError !== undefined &&
4891
+ aborts.isAbortReason(operationError);
4892
+ const abortEvidence =
4893
+ (drainAbort ? drainError : undefined) ??
4894
+ (operationAbort ? operationError : undefined);
4895
+ const failure =
4896
+ controlFailure ??
4897
+ (drainAbort ? undefined : drainError) ??
4898
+ (operationAbort ? undefined : operationError);
2152
4899
  currentSignal = undefined;
4900
+ currentAborts = undefined;
2153
4901
  currentTurnId = undefined;
2154
4902
  controlPlaneError = undefined;
2155
4903
  if (failure !== undefined) throw failure;
4904
+ if (
4905
+ abortEvidence !== undefined &&
4906
+ runResult?.outcome !== 'terminal' &&
4907
+ runResult?.outcome !== 'suspended'
4908
+ ) {
4909
+ const state = currentState();
4910
+ runResult = {
4911
+ outcome: 'aborted',
4912
+ state,
4913
+ error: normalizeErrorFull(abortEvidence) ?? {
4914
+ name: 'AbortError',
4915
+ message: String(abortEvidence),
4916
+ },
4917
+ };
4918
+ }
2156
4919
  if (runResult === undefined) {
2157
- throw new Error('decide runtime: playbook resume produced no result');
4920
+ if (signal.aborted) {
4921
+ // Every candidate was the abort's own evidence: settle on the
4922
+ // machine's state under the aborted boundary signal (DR-036 §4).
4923
+ runResult = resultForSnapshot(signal);
4924
+ } else {
4925
+ throw new Error(
4926
+ 'decide runtime: playbook resume produced no result',
4927
+ );
4928
+ }
2158
4929
  }
2159
4930
  return runResult;
2160
4931
  },
@@ -2208,13 +4979,30 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2208
4979
  activeEmissionCalls.clear();
2209
4980
  emissionQueue.clear();
2210
4981
  judgeQueue.clear();
4982
+ semanticCompletionQueue.clear();
2211
4983
  actor = undefined;
2212
4984
  currentSignal = undefined;
4985
+ currentAborts = undefined;
4986
+ actorSettlementAborts.length = 0;
4987
+ actorSettlementErrorAborts = undefined;
2213
4988
  currentTurnId = undefined;
2214
4989
  ports = undefined;
2215
4990
  sessionIdentity = undefined;
2216
4991
  previousState = undefined;
2217
4992
  controlPlaneError = undefined;
4993
+ governedOutputsByBoundaryId.clear();
4994
+ governedFailuresByBoundaryId.clear();
4995
+ governedEvidenceByBoundaryId.clear();
4996
+ governedReceiptsByBoundaryId.clear();
4997
+ pendingProposalCohort.clear();
4998
+ activeProposalCohort = undefined;
4999
+ completedProposalCohortTurnId = undefined;
5000
+ deferredOperationId = undefined;
5001
+ hiddenDeferredOperationId = undefined;
5002
+ unresolvedSemanticBoundaryIds.clear();
5003
+ appliedControlReceipts.clear();
5004
+ applyCallSequence = 0;
5005
+ activeDeferredContinuation = undefined;
2218
5006
  disposed = true;
2219
5007
  }
2220
5008
  if (failures.length === 1) throw failures[0];
@@ -2224,7 +5012,23 @@ export const createPlaybookRuntime: PlaybookRuntimeFactory<
2224
5012
  })();
2225
5013
  return disposalPromise;
2226
5014
  },
5015
+
5016
+ // @internal — test-only parity with the shared factory's bridge escape
5017
+ // hatch. This is hidden by the PlaybookRuntime return type.
5018
+ _getNestedBridge() {
5019
+ return nestedBridge;
5020
+ },
2227
5021
  };
5022
+ }
5023
+
5024
+ export const createPlaybookRuntime: PlaybookRuntimeFactory<
5025
+ DecidePlaybookRuntimeConstruction
5026
+ > = (factoryInput) => {
5027
+ const construction = schema3Construction(factoryInput);
5028
+ return createDecidePlaybookRuntime(
5029
+ construction.configuredOptions,
5030
+ construction.hostCapabilities,
5031
+ );
2228
5032
  };
2229
5033
 
2230
5034
  export const _internal = {
@@ -2234,9 +5038,9 @@ export const _internal = {
2234
5038
  buildClassifierPrompt,
2235
5039
  parseClassification,
2236
5040
  buildAdjudicatorPrompt,
2237
- parseAdjudication,
2238
5041
  combineSignals,
2239
5042
  pendingQuestionsFromContext,
5043
+ pendingQuestionsForState,
2240
5044
  normalizeErrorCompact,
2241
5045
  normalizeErrorFull,
2242
5046
  STATE_DESCRIPTIONS,
@@ -2244,6 +5048,7 @@ export const _internal = {
2244
5048
  ROLE_STATE_IDS,
2245
5049
  VERBATIM_PAYLOAD_FIELDS,
2246
5050
  BOSS_INTERRUPT_TARGETS,
5051
+ UNFINISHED_FINAL_STATE_IDS,
2247
5052
  CONTINUATION_PREAMBLE,
2248
5053
  TELEMETRY_TOPIC,
2249
5054
  };