@ikon85/agent-workflow-kit 0.44.1 → 0.45.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 (67) hide show
  1. package/.agents/skills/audit-skills/SKILL.md +7 -4
  2. package/.agents/skills/code-review/SKILL.md +7 -4
  3. package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
  4. package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
  5. package/.agents/skills/improve-codebase-architecture/SKILL.md +7 -4
  6. package/.agents/skills/orchestrate-wave/SKILL.md +1 -1
  7. package/.agents/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
  8. package/.agents/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
  9. package/.agents/skills/research/SKILL.md +7 -4
  10. package/.agents/skills/to-issues/SKILL.md +25 -4
  11. package/.claude/skills/audit-skills/SKILL.md +7 -4
  12. package/.claude/skills/code-review/SKILL.md +7 -4
  13. package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +7 -4
  14. package/.claude/skills/codex-build/SKILL.md +13 -0
  15. package/.claude/skills/codex-review/SKILL.md +13 -0
  16. package/.claude/skills/grill-me-codex/SKILL.md +14 -0
  17. package/.claude/skills/grill-with-docs-codex/SKILL.md +14 -0
  18. package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +7 -4
  19. package/.claude/skills/improve-codebase-architecture/SKILL.md +7 -4
  20. package/.claude/skills/orchestrate-wave/SKILL.md +1 -1
  21. package/.claude/skills/orchestrate-wave/references/dispatch-subagents.md +9 -0
  22. package/.claude/skills/orchestrate-wave/references/dispatch-workflow.md +9 -0
  23. package/.claude/skills/research/SKILL.md +7 -4
  24. package/.claude/skills/skill-manifest.json +34 -23
  25. package/.claude/skills/to-issues/SKILL.md +25 -4
  26. package/README.md +71 -0
  27. package/agent-workflow-kit.package.json +149 -45
  28. package/package.json +1 -1
  29. package/scripts/doctrine-migration/index.mjs +296 -0
  30. package/scripts/kit-release.mjs +41 -9
  31. package/src/cli.mjs +521 -80
  32. package/src/commands/routing-status.mjs +288 -0
  33. package/src/commands/update.mjs +9 -1
  34. package/src/consumer-migrations.json +23 -1
  35. package/src/lib/bundle.mjs +158 -2
  36. package/src/lib/consumerMigrations.mjs +55 -0
  37. package/src/lib/dispatchJournal.mjs +300 -0
  38. package/src/lib/dispatchPlan.mjs +286 -0
  39. package/src/lib/dispatchReceipt.mjs +226 -89
  40. package/src/lib/frontendWorkloads.mjs +35 -33
  41. package/src/lib/routeDispatcher.mjs +367 -70
  42. package/src/lib/routingAccessGraph.mjs +265 -20
  43. package/src/lib/routingAccessGraphStore.mjs +300 -0
  44. package/src/lib/routingAdapters/claude.mjs +104 -4
  45. package/src/lib/routingAdapters/codex.mjs +132 -7
  46. package/src/lib/routingAdapters/hostBridge.mjs +291 -0
  47. package/src/lib/routingCatalog.mjs +201 -24
  48. package/src/lib/routingDispatchLease.mjs +253 -0
  49. package/src/lib/routingEvidenceCache.mjs +78 -0
  50. package/src/lib/routingIntent.mjs +176 -10
  51. package/src/lib/routingIntentClassifier.mjs +137 -0
  52. package/src/lib/routingInventory/snapshots/claude.json +49 -0
  53. package/src/lib/routingInventory/snapshots/codex.json +109 -0
  54. package/src/lib/routingInventory.mjs +182 -0
  55. package/src/lib/routingPolicy.mjs +193 -6
  56. package/src/lib/routingProfile.mjs +1251 -123
  57. package/src/lib/routingProfilePolicy.mjs +156 -0
  58. package/src/lib/routingProfileStorage.mjs +299 -0
  59. package/src/lib/routingResolver.mjs +369 -86
  60. package/src/lib/routingSources/artificialAnalysis.mjs +19 -7
  61. package/src/lib/routingSources/benchlm.mjs +5 -0
  62. package/src/lib/routingSources/codeArena.mjs +13 -3
  63. package/src/lib/routingSources/deepswe.mjs +19 -5
  64. package/src/lib/routingSources/openhands.mjs +17 -4
  65. package/src/lib/routingSources/openhandsFrontend.mjs +13 -3
  66. package/src/lib/safeText.mjs +26 -0
  67. package/src/lib/updateCandidate.mjs +36 -3
@@ -1,5 +1,42 @@
1
+ /**
2
+ * Dispatching a resolved Route decision.
3
+ *
4
+ * The dispatch holds a revision-bound dispatch lease across the spawn handoff
5
+ * whenever one is configured: the store is re-read and the Routing policy
6
+ * recomputed before the adapter prepares, and again after it, so an
7
+ * authorization revoked in that window fails the dispatch instead of racing it.
8
+ * Without a lease the only remaining guard is the in-process comparison of the
9
+ * caller's own resolver input, which cannot see another process at all.
10
+ *
11
+ * A lease survives no crash, so a configured dispatch journal carries the other
12
+ * half: the `prepared` entry is written before the spawn and its terminal entry
13
+ * after, and a dispatch that finds an indeterminate entry from an earlier run
14
+ * blocks pending reconciliation instead of spawning a second agent — unless the
15
+ * surface pre-assigned a spawn id that makes the retry provably safe.
16
+ *
17
+ * A dispatch that runs under a Dispatch plan references its authorization record
18
+ * before anything else happens: the plan is re-checked against the record and the
19
+ * unit's authorized route, and a mismatch blocks pending a newly attributed
20
+ * authorization instead of quietly running something the user never saw.
21
+ *
22
+ * A Route decision that is not `ready` takes one of three paths rather than one
23
+ * blocked receipt: `handoff` hands the work back with the resolved intent,
24
+ * `inherit` is the constrained non-AFK path that runs the session default only
25
+ * when that pair is attested by a readback channel *and* inside the effective
26
+ * roster, and everything else — including any inherit under AFK — fails closed.
27
+ */
1
28
  import { createDispatchReceipt } from './dispatchReceipt.mjs';
29
+ import { authorizeDispatchUnit } from './dispatchPlan.mjs';
30
+ import { normalizeRosterModelId } from './routingProfile.mjs';
31
+ import {
32
+ appendDispatchJournalEntry,
33
+ dispatchIdempotencyKey,
34
+ journalEntryForReceipt,
35
+ planDispatchRecovery,
36
+ readDispatchJournal,
37
+ } from './dispatchJournal.mjs';
2
38
  import { resolveRoute } from './routingResolver.mjs';
39
+ import { openDispatchLease } from './routingDispatchLease.mjs';
3
40
  import {
4
41
  assertRoutingProfileUnchanged,
5
42
  captureRoutingProfileSnapshot,
@@ -26,6 +63,18 @@ function receiptRevisions(decision) {
26
63
  );
27
64
  }
28
65
 
66
+ /**
67
+ * The plan-authorization a dispatch runs under: the record that bound the
68
+ * Dispatch plan, what the caller named, or the approval the Route decision
69
+ * already recorded.
70
+ */
71
+ function authorizationId(input, decision) {
72
+ return input.planAuthorization?.id
73
+ ?? input.authorizationId
74
+ ?? decision.approval?.authorizationId
75
+ ?? null;
76
+ }
77
+
29
78
  function blockedReceipt(input, decision, reason, details = {}) {
30
79
  return createDispatchReceipt({
31
80
  executionId: input.executionId,
@@ -36,6 +85,8 @@ function blockedReceipt(input, decision, reason, details = {}) {
36
85
  enforcement: details.enforcement ?? null,
37
86
  precedence: details.precedence ?? null,
38
87
  revisions: receiptRevisions(decision),
88
+ resultingAccessRevision: input.resultingAccessRevision ?? null,
89
+ authorizationId: authorizationId(input, decision),
39
90
  dispatchedAt: input.dispatchedAt,
40
91
  reason,
41
92
  });
@@ -46,33 +97,62 @@ function decisionReason(decision) {
46
97
  return `${decision.status}:${decision.reason}${blockers}`;
47
98
  }
48
99
 
100
+ /**
101
+ * The closed set of failure reasons a receipt may name. A message is reduced to
102
+ * the reason it starts with, so neither a revision value nor an adapter
103
+ * diagnostic can travel into a receipt.
104
+ */
105
+ const SAFE_FAILURE_REASONS = Object.freeze([
106
+ 'Claude route capability is not attested',
107
+ 'route identity is incomplete',
108
+ 'transport is not detected',
109
+ 'transport is not callable',
110
+ 'transport is not permitted',
111
+ 'model control is not enforced',
112
+ 'effort control is not enforced',
113
+ 'model environment precedence is unverified',
114
+ 'effort environment precedence is unverified',
115
+ 'model applied value is unverified',
116
+ 'effort applied value is unverified',
117
+ 'transport has no approved dispatcher',
118
+ 'environment precedence mismatch: model',
119
+ 'environment precedence mismatch: effort',
120
+ 'applied route mismatch',
121
+ 'spawn guard received no callable dispatcher',
122
+ 'applied route differs from requested route',
123
+ 'applied enforcement differs from attested access path',
124
+ 'AFK dispatch requires enforced model and effort selection',
125
+ 'AFK dispatch requires verified environment precedence',
126
+ 'concurrent routing profile mutation',
127
+ 'concurrent evidence catalog mutation',
128
+ 'routing profile store carries no committed authorization',
129
+ 'dispatch lease is reserved for a writer',
130
+ 'dispatch lease is held',
131
+ 'dispatch lease superseded',
132
+ 'dispatch lease expired',
133
+ 'dispatch is indeterminate pending reconciliation',
134
+ 'dispatch is already recorded',
135
+ 'dispatch plan authorization does not cover this dispatch',
136
+ 'dispatch plan authorization names no such unit',
137
+ 'dispatch route differs from the authorized dispatch plan',
138
+ 'inherit requires an attested session-default pair',
139
+ 'session-default pair is not in the effective roster',
140
+ 'AFK dispatch cannot inherit a session default',
141
+ ]);
142
+
143
+ /**
144
+ * The two reasons the journal itself raised. Recording a terminal entry for
145
+ * them would settle the very indeterminacy that has to stay open until it is
146
+ * reconciled, so a dispatch blocked by the guard writes nothing.
147
+ */
148
+ const JOURNAL_GUARD_REASONS = Object.freeze([
149
+ 'dispatch is indeterminate pending reconciliation',
150
+ 'dispatch is already recorded',
151
+ ]);
152
+
49
153
  function safeFailureReason(error) {
50
154
  const message = error instanceof Error ? error.message : '';
51
- const safeReasons = [
52
- ['Claude route capability is not attested', 'Claude route capability is not attested'],
53
- ['route identity is incomplete', 'route identity is incomplete'],
54
- ['transport is not detected', 'transport is not detected'],
55
- ['transport is not callable', 'transport is not callable'],
56
- ['transport is not permitted', 'transport is not permitted'],
57
- ['model control is not enforced', 'model control is not enforced'],
58
- ['effort control is not enforced', 'effort control is not enforced'],
59
- ['model environment precedence is unverified', 'model environment precedence is unverified'],
60
- ['effort environment precedence is unverified', 'effort environment precedence is unverified'],
61
- ['model applied value is unverified', 'model applied value is unverified'],
62
- ['effort applied value is unverified', 'effort applied value is unverified'],
63
- ['transport has no approved dispatcher', 'transport has no approved dispatcher'],
64
- ['environment precedence mismatch: model', 'environment precedence mismatch: model'],
65
- ['environment precedence mismatch: effort', 'environment precedence mismatch: effort'],
66
- ['applied route mismatch', 'applied route mismatch'],
67
- ['spawn guard received no callable dispatcher', 'spawn guard received no callable dispatcher'],
68
- ['applied route differs from requested route', 'applied route differs from requested route'],
69
- ['applied enforcement differs from attested access path', 'applied enforcement differs from attested access path'],
70
- ['AFK dispatch requires enforced model and effort selection', 'AFK dispatch requires enforced model and effort selection'],
71
- ['AFK dispatch requires verified environment precedence', 'AFK dispatch requires verified environment precedence'],
72
- ['concurrent routing profile mutation', 'concurrent routing profile mutation'],
73
- ['concurrent evidence catalog mutation', 'concurrent evidence catalog mutation'],
74
- ];
75
- return safeReasons.find(([prefix]) => message.startsWith(prefix))?.[1]
155
+ return SAFE_FAILURE_REASONS.find((reason) => message.startsWith(reason))
76
156
  ?? 'dispatch adapter rejected route';
77
157
  }
78
158
 
@@ -92,67 +172,284 @@ function assertAppliedEnforcement(decision, applied) {
92
172
  }
93
173
  }
94
174
 
95
- export async function dispatchResolvedRoute(input) {
96
- if (!input || typeof input !== 'object' || Array.isArray(input)) {
97
- throw new TypeError('dispatch input must be an object');
175
+ /**
176
+ * The in-process comparison over the caller's own resolver input. It catches only
177
+ * a mutation this process made to that object; a store-backed lease is what sees
178
+ * an external writer.
179
+ */
180
+ function inProcessGuard(resolverInput) {
181
+ const snapshot = captureRoutingProfileSnapshot(resolverInput);
182
+ const catalogRevision = resolverInput.catalog.revision;
183
+ return () => {
184
+ assertRoutingProfileUnchanged(snapshot, resolverInput);
185
+ if (catalogRevision !== resolverInput.catalog?.revision) {
186
+ throw new Error('concurrent evidence catalog mutation');
187
+ }
188
+ };
189
+ }
190
+
191
+ /** The lease is bound to the revisions the Route decision itself named. */
192
+ function leaseOptions(input, decision) {
193
+ if (input.lease == null) return null;
194
+ if (typeof input.lease !== 'object' || Array.isArray(input.lease)) {
195
+ throw new TypeError('dispatch lease options must be an object');
98
196
  }
99
- if (!input.adapter || typeof input.adapter.prepare !== 'function') {
100
- throw new TypeError('dispatch adapter must expose prepare');
197
+ return {
198
+ ...input.lease,
199
+ holder: input.lease.holder ?? input.executionId,
200
+ expected: decision.revisions,
201
+ };
202
+ }
203
+
204
+ function preparedReceipt(input, decision, requestedRoute, prepared) {
205
+ const applied = {
206
+ requestedRoute,
207
+ appliedRoute: receiptRoute(prepared.appliedRoute),
208
+ enforcement: prepared.enforcement,
209
+ precedence: prepared.precedence,
210
+ };
211
+ if (prepared.mismatchReason) {
212
+ return blockedReceipt(
213
+ input, decision, safeFailureReason(new Error(prepared.mismatchReason)), applied,
214
+ );
101
215
  }
102
- const decision = resolveRoute(input.resolverInput);
103
- if (decision.status !== 'ready') {
104
- return Object.freeze({
105
- decision,
106
- receipt: blockedReceipt(input, decision, decisionReason(decision)),
107
- dispatchResult: null,
108
- });
216
+ return createDispatchReceipt({
217
+ executionId: input.executionId,
218
+ kind: 'routed-dispatch',
219
+ afk: input.afk,
220
+ ...applied,
221
+ revisions: receiptRevisions(decision),
222
+ authorizationId: authorizationId(input, decision),
223
+ dispatchedAt: input.dispatchedAt,
224
+ });
225
+ }
226
+
227
+ /**
228
+ * The journal a dispatch records into. The idempotency key is derived from the
229
+ * surface actually being dispatched, so a caller cannot claim a Claude spawn id
230
+ * for a Codex run.
231
+ */
232
+ function journalOptions(input, decision) {
233
+ if (input.journal == null) return null;
234
+ if (typeof input.journal !== 'object' || Array.isArray(input.journal)) {
235
+ throw new TypeError('dispatch journal options must be an object');
236
+ }
237
+ if (typeof input.journal.file !== 'string' || input.journal.file.trim() === '') {
238
+ throw new TypeError('dispatch journal options need a journal file');
109
239
  }
110
- const profileSnapshot = captureRoutingProfileSnapshot(input.resolverInput);
111
- const catalogRevision = input.resolverInput.catalog.revision;
240
+ const { surfaceId, transportId } = decision.bestExecutable;
241
+ const cwd = input.journal.cwd;
242
+ return Object.freeze({
243
+ file: input.journal.file,
244
+ cwd,
245
+ surfaceId,
246
+ transportId,
247
+ idempotencyKey: dispatchIdempotencyKey({
248
+ surfaceId, cwd, sessionId: input.journal.sessionId ?? null,
249
+ }),
250
+ });
251
+ }
112
252
 
113
- const requestedRoute = receiptRoute(decision.bestExecutable);
253
+ /** What an earlier run left behind decides whether this one may spawn at all. */
254
+ async function assertJournalPermitsDispatch(journal, executionId) {
255
+ const { entries, damaged } = await readDispatchJournal(journal.file);
256
+ const recovery = planDispatchRecovery({
257
+ entries,
258
+ damaged,
259
+ executionId,
260
+ surfaceId: journal.surfaceId,
261
+ idempotencyKey: journal.idempotencyKey,
262
+ });
263
+ if (recovery.state === 'settled') throw new Error('dispatch is already recorded');
264
+ if (recovery.state === 'blocked-pending-reconciliation') {
265
+ throw new Error('dispatch is indeterminate pending reconciliation');
266
+ }
267
+ return recovery;
268
+ }
269
+
270
+ function recordJournalEntry(journal, receipt, taskId = null) {
271
+ return appendDispatchJournalEntry(journal.file, journalEntryForReceipt(receipt, {
272
+ cwd: journal.cwd,
273
+ surfaceId: journal.surfaceId,
274
+ transportId: journal.transportId,
275
+ idempotencyKey: journal.idempotencyKey,
276
+ taskId,
277
+ }));
278
+ }
279
+
280
+ function recordPreparedEntry(journal, receipt) {
281
+ return appendDispatchJournalEntry(journal.file, {
282
+ phase: 'prepared',
283
+ executionId: receipt.executionId,
284
+ surfaceId: journal.surfaceId,
285
+ transportId: journal.transportId,
286
+ cwd: journal.cwd,
287
+ idempotencyKey: journal.idempotencyKey,
288
+ authorizationId: receipt.authorizationId,
289
+ recordedAt: receipt.dispatchedAt,
290
+ });
291
+ }
292
+
293
+ async function performDispatch(input, decision, { journal, requestedRoute }) {
294
+ const assertInProcessUnchanged = inProcessGuard(input.resolverInput);
295
+ const lease = leaseOptions(input, decision);
296
+ let held = null;
114
297
  try {
298
+ if (journal) await assertJournalPermitsDispatch(journal, input.executionId);
299
+ if (lease) held = await openDispatchLease(lease);
115
300
  const prepared = await input.adapter.prepare(requestedRoute);
116
- assertRoutingProfileUnchanged(profileSnapshot, input.resolverInput);
117
- if (catalogRevision !== input.resolverInput.catalog?.revision) {
118
- throw new Error('concurrent evidence catalog mutation');
119
- }
301
+ assertInProcessUnchanged();
302
+ if (held) await held.revalidate();
120
303
  assertAppliedEnforcement(decision, prepared.enforcement);
121
- if (prepared.mismatchReason) {
122
- return Object.freeze({
123
- decision,
124
- receipt: blockedReceipt(input, decision, safeFailureReason(
125
- new Error(prepared.mismatchReason),
126
- ), {
127
- requestedRoute,
128
- appliedRoute: receiptRoute(prepared.appliedRoute),
129
- enforcement: prepared.enforcement,
130
- precedence: prepared.precedence,
131
- }),
132
- dispatchResult: null,
133
- });
304
+ const receipt = preparedReceipt(input, decision, requestedRoute, prepared);
305
+ if (receipt.status !== 'dispatched') {
306
+ if (journal) await recordJournalEntry(journal, receipt);
307
+ return Object.freeze({ decision, receipt, dispatchResult: null });
134
308
  }
135
- const receipt = createDispatchReceipt({
136
- executionId: input.executionId,
137
- status: 'dispatched',
138
- afk: input.afk,
139
- requestedRoute,
140
- appliedRoute: receiptRoute(prepared.appliedRoute),
141
- enforcement: prepared.enforcement,
142
- precedence: prepared.precedence,
143
- revisions: receiptRevisions(decision),
144
- dispatchedAt: input.dispatchedAt,
145
- });
146
309
  if (typeof prepared.dispatch !== 'function') {
147
310
  throw new Error('spawn guard received no callable dispatcher');
148
311
  }
312
+ if (journal) await recordPreparedEntry(journal, receipt);
149
313
  const dispatchResult = safeDispatchResult(await prepared.dispatch());
314
+ if (journal) await recordJournalEntry(journal, receipt, dispatchResult?.taskId ?? null);
150
315
  return Object.freeze({ decision, receipt, dispatchResult });
151
- } catch (error) {
316
+ } finally {
317
+ held?.release();
318
+ }
319
+ }
320
+
321
+ /**
322
+ * A failed dispatch is journaled too — a terminal entry is what keeps the next
323
+ * run from reading this one as an unresolved crash. A journal write that fails
324
+ * here leaves the entry indeterminate, which blocks rather than spawns.
325
+ */
326
+ async function blockedOutcome(input, decision, { journal, requestedRoute, error }) {
327
+ const reason = safeFailureReason(error);
328
+ const receipt = blockedReceipt(input, decision, reason, { requestedRoute });
329
+ if (journal && !JOURNAL_GUARD_REASONS.includes(reason)) {
330
+ await recordJournalEntry(journal, receipt).catch(() => {});
331
+ }
332
+ return Object.freeze({ decision, receipt, dispatchResult: null });
333
+ }
334
+
335
+ const blockedOnly = (input, decision, reason) => Object.freeze({
336
+ decision, receipt: blockedReceipt(input, decision, reason), dispatchResult: null,
337
+ });
338
+
339
+ /**
340
+ * The authorization gate. It runs before the dispatch paths so a mismatch can
341
+ * never present itself as ordinary policy behaviour, and the blocked receipt
342
+ * still names the record it was checked against.
343
+ */
344
+ function planAuthorizationFailure(input, decision) {
345
+ if (input.planAuthorization == null) return null;
346
+ if (input.plan == null || typeof input.unitId !== 'string') {
347
+ throw new TypeError('a plan-authorized dispatch needs its plan and unit id');
348
+ }
349
+ return authorizeDispatchUnit({
350
+ authorization: input.planAuthorization,
351
+ plan: input.plan,
352
+ unitId: input.unitId,
353
+ route: decision.bestExecutable ? receiptRoute(decision.bestExecutable) : null,
354
+ }).reason;
355
+ }
356
+
357
+ /** Handing back is not a dispatch: it proves nothing ran, and carries the intent on. */
358
+ function handoffOutcome(input, decision) {
359
+ const receipt = createDispatchReceipt({
360
+ executionId: input.executionId,
361
+ kind: 'handoff',
362
+ afk: input.afk,
363
+ handoff: { to: input.handoffTo ?? input.resolverInput.activeSurface },
364
+ reason: decisionReason(decision),
365
+ revisions: receiptRevisions(decision),
366
+ authorizationId: authorizationId(input, decision),
367
+ dispatchedAt: input.dispatchedAt,
368
+ });
369
+ return Object.freeze({
370
+ decision,
371
+ receipt,
372
+ dispatchResult: null,
373
+ handoff: Object.freeze({ to: receipt.handoff.to, intent: decision.intent ?? null }),
374
+ });
375
+ }
376
+
377
+ /**
378
+ * Whether the attested session default is a pair the policy authorized. The
379
+ * roster is matched under its own normalization rule, because one session
380
+ * reports its model in several forms while the roster spells out only one.
381
+ */
382
+ function rosterAuthorizesPair(roster, attested) {
383
+ if (!Array.isArray(roster)) return false;
384
+ const model = normalizeRosterModelId(attested.model);
385
+ return roster.some((pair) => pair && typeof pair.model === 'string'
386
+ && pair.model.trim() !== ''
387
+ && normalizeRosterModelId(pair.model) === model
388
+ && (pair.effort ?? null) === attested.effort);
389
+ }
390
+
391
+ function inheritFailure(input) {
392
+ if (input.afk === true) return 'AFK dispatch cannot inherit a session default';
393
+ const attested = input.sessionDefault?.attestation;
394
+ if (input.sessionDefault?.appliedRoute == null
395
+ || typeof attested?.model !== 'string' || typeof attested?.effort !== 'string') {
396
+ return 'inherit requires an attested session-default pair';
397
+ }
398
+ return rosterAuthorizesPair(input.resolverInput?.policy?.roster, attested)
399
+ ? null
400
+ : 'session-default pair is not in the effective roster';
401
+ }
402
+
403
+ function inheritOutcome(input, decision) {
404
+ const failure = inheritFailure(input);
405
+ if (failure) return blockedOnly(input, decision, failure);
406
+ try {
152
407
  return Object.freeze({
153
408
  decision,
154
- receipt: blockedReceipt(input, decision, safeFailureReason(error), { requestedRoute }),
409
+ receipt: createDispatchReceipt({
410
+ executionId: input.executionId,
411
+ kind: 'inherited-dispatch',
412
+ afk: input.afk,
413
+ appliedRoute: receiptRoute(input.sessionDefault.appliedRoute),
414
+ enforcement: { model: 'session-default', effort: 'session-default' },
415
+ precedence: { model: 'session-default', effort: 'session-default' },
416
+ attestation: input.sessionDefault.attestation,
417
+ revisions: receiptRevisions(decision),
418
+ authorizationId: authorizationId(input, decision),
419
+ dispatchedAt: input.dispatchedAt,
420
+ }),
155
421
  dispatchResult: null,
156
422
  });
423
+ } catch {
424
+ // The readback the caller offered is no attestation; its own diagnostic
425
+ // never travels into a receipt.
426
+ return blockedOnly(input, decision, 'inherit requires an attested session-default pair');
427
+ }
428
+ }
429
+
430
+ /** The three paths a Route decision that cannot dispatch takes. */
431
+ function unreadyOutcome(input, decision) {
432
+ if (decision.status === 'handoff') return handoffOutcome(input, decision);
433
+ if (decision.status === 'inherit') return inheritOutcome(input, decision);
434
+ return blockedOnly(input, decision, decisionReason(decision));
435
+ }
436
+
437
+ export async function dispatchResolvedRoute(input) {
438
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
439
+ throw new TypeError('dispatch input must be an object');
440
+ }
441
+ if (!input.adapter || typeof input.adapter.prepare !== 'function') {
442
+ throw new TypeError('dispatch adapter must expose prepare');
443
+ }
444
+ const decision = resolveRoute(input.resolverInput);
445
+ const unauthorized = planAuthorizationFailure(input, decision);
446
+ if (unauthorized) return blockedOnly(input, decision, unauthorized);
447
+ if (decision.status !== 'ready') return unreadyOutcome(input, decision);
448
+ const journal = journalOptions(input, decision);
449
+ const requestedRoute = receiptRoute(decision.bestExecutable);
450
+ try {
451
+ return await performDispatch(input, decision, { journal, requestedRoute });
452
+ } catch (error) {
453
+ return blockedOutcome(input, decision, { journal, requestedRoute, error });
157
454
  }
158
455
  }