@agenticprimitives/orchestration 0.0.0-alpha.26 → 0.0.0-alpha.28

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 (59) hide show
  1. package/dist/admission.d.ts +11 -2
  2. package/dist/admission.d.ts.map +1 -1
  3. package/dist/admission.js.map +1 -1
  4. package/dist/artifacts.d.ts +9 -1
  5. package/dist/artifacts.d.ts.map +1 -1
  6. package/dist/artifacts.js +119 -2
  7. package/dist/artifacts.js.map +1 -1
  8. package/dist/conformance.d.ts +14 -0
  9. package/dist/conformance.d.ts.map +1 -1
  10. package/dist/conformance.js +154 -30
  11. package/dist/conformance.js.map +1 -1
  12. package/dist/derivation.d.ts +23 -4
  13. package/dist/derivation.d.ts.map +1 -1
  14. package/dist/derivation.js +105 -6
  15. package/dist/derivation.js.map +1 -1
  16. package/dist/digest.d.ts +10 -0
  17. package/dist/digest.d.ts.map +1 -0
  18. package/dist/digest.js +45 -0
  19. package/dist/digest.js.map +1 -0
  20. package/dist/index.d.ts +7 -6
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -5
  23. package/dist/index.js.map +1 -1
  24. package/dist/loop.d.ts +4 -1
  25. package/dist/loop.d.ts.map +1 -1
  26. package/dist/loop.js +275 -75
  27. package/dist/loop.js.map +1 -1
  28. package/dist/outcome.d.ts.map +1 -1
  29. package/dist/outcome.js +6 -5
  30. package/dist/outcome.js.map +1 -1
  31. package/dist/plan-digest.d.ts.map +1 -1
  32. package/dist/plan-digest.js +5 -13
  33. package/dist/plan-digest.js.map +1 -1
  34. package/dist/planner.d.ts +25 -0
  35. package/dist/planner.d.ts.map +1 -1
  36. package/dist/planner.js +57 -0
  37. package/dist/planner.js.map +1 -1
  38. package/dist/record.d.ts +14 -1
  39. package/dist/record.d.ts.map +1 -1
  40. package/dist/record.js +6 -0
  41. package/dist/record.js.map +1 -1
  42. package/dist/regression.d.ts +23 -2
  43. package/dist/regression.d.ts.map +1 -1
  44. package/dist/regression.js +22 -6
  45. package/dist/regression.js.map +1 -1
  46. package/dist/selection.js +1 -1
  47. package/dist/selection.js.map +1 -1
  48. package/dist/spans.d.ts +15 -2
  49. package/dist/spans.d.ts.map +1 -1
  50. package/dist/spans.js +20 -14
  51. package/dist/spans.js.map +1 -1
  52. package/dist/tool-invoker.d.ts +4 -0
  53. package/dist/tool-invoker.d.ts.map +1 -1
  54. package/dist/tool-invoker.js.map +1 -1
  55. package/dist/types.d.ts +54 -0
  56. package/dist/types.d.ts.map +1 -1
  57. package/dist/types.js +4 -0
  58. package/dist/types.js.map +1 -1
  59. package/package.json +2 -2
package/dist/loop.js CHANGED
@@ -14,9 +14,15 @@
14
14
  //
15
15
  // A step with no capability skips the path and still emits a receipt: the trace is the product, and
16
16
  // "nothing was verified" is itself a fact worth recording.
17
- import { compactObservations } from './artifacts.js';
17
+ import { canonicalJson, compactObservations } from './artifacts.js';
18
+ import { VALUE_DIGEST_SCHEME, isValueDigest, sameValue, valueDigest } from './digest.js';
18
19
  import { planDigest } from './plan-digest.js';
19
- import { CONTINUE_STEP_ID, OrchestrationError } from './types.js';
20
+ import { CONTINUE_STEP_ID, OrchestrationError, isEvaluationIntent } from './types.js';
21
+ import { frameUntrusted } from './planner.js';
22
+ import { statedOutcomeOf } from './conformance.js';
23
+ /** The stated-outcome rule's codes — the only violations the point-of-use re-judgment acts on (other admission rules
24
+ * read the plan as the planner WORDED it and are not re-run against resolved values). */
25
+ const POINT_OF_USE_CODES = new Set(['OUTCOME_NOT_ENTAILED', 'OUTCOME_BOUND_EXCEEDED', 'OUTCOME_BOUND_UNREAD', 'OUTCOME_BOUND_PENDING', 'OUTCOME_DISCLOSURE']);
20
26
  import { isToolInvocationResult } from './tool-invoker.js';
21
27
  import { inputsFor, isInputRequired, receiptPending } from './input.js';
22
28
  import { deriveArgs } from './derivation.js';
@@ -24,7 +30,8 @@ import { deriveArgs } from './derivation.js';
24
30
  * dots is a plain top-level lookup. A missing segment short-circuits to `undefined` (the tool then validates
25
31
  * its own args — fail-closed, no path traversal / eval). */
26
32
  function getPath(root, path) {
27
- return path.split('.').reduce((acc, seg) => (acc == null ? undefined : acc[seg]), root);
33
+ // OWN properties only (07 §6 lead 5 / RUNC-14): `x.constructor`, `x.__proto__`, `x.toString` are not results a step returned.
34
+ return path.split('.').reduce((acc, seg) => (acc == null || !Object.prototype.hasOwnProperty.call(Object(acc), seg) ? undefined : acc[seg]), root);
28
35
  }
29
36
  /** Recursively resolve `{ $ref: 'name.path' }` placeholders (anywhere — nested objects/arrays included)
30
37
  * against prior step results. Non-placeholder values pass through unchanged. */
@@ -115,8 +122,6 @@ function resolveStep(runRef, index, step, tool, args) {
115
122
  /** Spec 413 — the stepRef of the declared pre-plan retrieval step. */
116
123
  export const RETRIEVAL_STEP_REF = 'retrieve';
117
124
  const randomRef = () => `run-${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
118
- /** A stable digest is the sink's job (it has the hashing deps); here we record a cheap structural fingerprint
119
- * so two receipts for the same input compare equal. Never treated as a cryptographic commitment. */
120
125
  /**
121
126
  * Spec 410 §2 — READ WHAT THE ADAPTER SAW, and bound it by what the tool can establish. A result contract is
122
127
  * taken at its word within that bound; a raw value is `attempted` for an act (the adapter showed nothing but
@@ -144,20 +149,12 @@ function observeInvocation(resolved, tool, now) {
144
149
  }
145
150
  return { output: resolved.output, observation: { ...o, outcome, evidence } };
146
151
  }
147
- function fingerprint(v) {
148
- try {
149
- return `json:${JSON.stringify(v, (_k, x) => (typeof x === 'bigint' ? x.toString() : x))}`.slice(0, 2048);
150
- }
151
- catch {
152
- return 'json:<unserializable>';
153
- }
154
- }
155
152
  /** Does a declared branch hold against the results so far? Absent/empty is false for `exists`; `equals`
156
- * compares by fingerprint (the same JSON is the same value). */
153
+ * compares the FULL canonical JSON (the same JSON is the same value) — never a prefix (RUN-8). */
157
154
  function holds(when, refs) {
158
155
  const v = getPath(refs, when.ref);
159
156
  if (when.equals !== undefined)
160
- return fingerprint(v) === fingerprint(when.equals);
157
+ return sameValue(v, when.equals);
161
158
  if (when.exists === false)
162
159
  return v === undefined || v === null || (Array.isArray(v) && v.length === 0);
163
160
  return !(v === undefined || v === null || (Array.isArray(v) && v.length === 0));
@@ -233,6 +230,11 @@ function independentBatch(plan, i, toolsById, completed) {
233
230
  */
234
231
  /** The planner's own words for a step's arguments (before resolution turned "nathan.me" into an address). */
235
232
  const rawArgsOf = (rs) => rs.step.args ?? rs.args;
233
+ /** Spec 445 decision 8 — the digest an outbound confirmation is bound to: the tool and EXACTLY the arguments it would
234
+ * send (`sha256-jcs-v1`). A `yes` to one disclosure is not a `yes` to another. */
235
+ export async function outboundDigest(toolId, sends) {
236
+ return valueDigest({ toolId, sends });
237
+ }
236
238
  export async function runIntent(intent, opts) {
237
239
  // THE STEP BUDGET bounds what a PLANNER may keep proposing (a re-plan loop that never ends), not what a
238
240
  // declared fan-out enumerates: that is capped by the ontology's binding (spec 358 W4 `maxItems`) at the
@@ -251,6 +253,8 @@ export async function runIntent(intent, opts) {
251
253
  const plannerTools = opts.continuation && opts.continuation.max > 0 ? [...opts.tools, CONTINUE_TOOL] : opts.tools;
252
254
  const toolsById = new Map(plannerTools.map((t) => [t.id, t]));
253
255
  const toolIds = new Set(toolsById.keys());
256
+ /** The tools the AGENT exposes (never the reserved continuation step) — what a compensation may name (RUN-7). */
257
+ const exposedById = new Map(opts.tools.map((t) => [t.id, t]));
254
258
  const allObservations = [];
255
259
  const receipts = [];
256
260
  /** Executed steps whose tool declares a compensation, newest first — unwound on failure. */
@@ -282,18 +286,19 @@ export async function runIntent(intent, opts) {
282
286
  if (ports.receiptSink)
283
287
  await ports.receiptSink.record(r);
284
288
  };
285
- const base = (rs, startedAt) => ({
289
+ // RUN-8 (spec 445) — the digests are SHA-256 over the full canonical value (`digest.ts`), so `base` awaits.
290
+ const base = async (rs, startedAt) => ({
286
291
  runRef, stepRef: rs.stepRef, index: rs.index, toolId: rs.tool.id, risk: rs.risk, startedAt,
287
292
  ...(rs.risk !== 'informational' ? { capability: rs.capability } : {}),
288
293
  ...(rs.idempotencyKey ? { idempotencyKey: rs.idempotencyKey } : {}),
289
- inputDigest: fingerprint(rs.args),
294
+ inputDigest: await valueDigest(rs.args),
290
295
  // What was PLANNED for this step (resolved against earlier results only) — what a resume's replay can reproduce.
291
- ...(() => { try {
292
- return { planArgsDigest: fingerprint(resolveArgs(rs.step.args, refs)) };
296
+ ...(await (async () => { try {
297
+ return { planArgsDigest: await valueDigest(resolveArgs(rs.step.args, refs)) };
293
298
  }
294
299
  catch {
295
300
  return {};
296
- } })(),
301
+ } })()),
297
302
  // Spec 421 W2 — whose words each argument of an ACT came from (raw planner values against her goal and what was read).
298
303
  ...(rs.risk !== 'informational' ? (() => { const d = deriveArgs(rawArgsOf(rs), intent.goal, allObservations); return Object.keys(d).length ? { derivation: d } : {}; })() : {}),
299
304
  // Spec 367 §5 — the binding the step was admitted under, pinned by the harness that knows the
@@ -325,7 +330,7 @@ export async function runIntent(intent, opts) {
325
330
  // 401, not 403. Nothing was verified and nothing ran; the receipt records that the plan reached a
326
331
  // step it had no authority for, and the caller is told WHICH step so it can go and get one.
327
332
  emit({ type: 'RunSuspended', runRef, stepRef: rs.stepRef, resumeToken: rs.stepRef });
328
- await record({ ...base(rs, startedAt), status: 'authority-required', completedAt: now() });
333
+ await record({ ...(await base(rs, startedAt)), status: 'authority-required', completedAt: now() });
329
334
  return {
330
335
  outcome: 'authority-required', plan, steps: allObservations, receipts, runRef,
331
336
  required: { stepRef: rs.stepRef, toolId: rs.tool.id, capability: rs.capability, risk: rs.risk, args: rs.args, ...(() => { const d = deriveArgs(rawArgsOf(rs), intent.goal, allObservations); return Object.keys(d).length ? { derivation: d } : {}; })(), ...(rs.tool.carries ? { carries: rs.tool.carries } : {}) },
@@ -336,7 +341,7 @@ export async function runIntent(intent, opts) {
336
341
  emit({ type: 'MandateChecked', runRef, stepRef: rs.stepRef, decision: decision.decision, afterApproval: false });
337
342
  if (decision.decision === 'deny') {
338
343
  emit({ type: 'MandateDenied', runRef, stepRef: rs.stepRef, reasons: decision.reasons });
339
- await record({ ...base(rs, startedAt), status: 'denied', authority: { presentedRef: presented.ref, decision, afterApproval: false }, completedAt: now(), error: decision.reasons.map((r) => `${r.code}: ${r.message}`).join('; ') });
344
+ await record({ ...(await base(rs, startedAt)), status: 'denied', authority: { presentedRef: presented.ref, decision, afterApproval: false }, completedAt: now(), error: decision.reasons.map((r) => `${r.code}: ${r.message}`).join('; ') });
340
345
  return { outcome: 'denied', plan, steps: allObservations, receipts, runRef, error: decision.reasons.map((r) => `${r.code}: ${r.message}`).join('; ') };
341
346
  }
342
347
  // Obligations: the verifier's, plus whatever policy adds for this risk class. Never fewer.
@@ -355,7 +360,7 @@ export async function runIntent(intent, opts) {
355
360
  emit({ type: 'RunSuspended', runRef, stepRef: rs.stepRef, resumeToken: outcome.resumeToken });
356
361
  const prompt = outcome.prompt ? { ...outcome.prompt, stepRef: rs.stepRef, toolId: rs.tool.id } : undefined;
357
362
  await record({
358
- ...base(rs, startedAt), status: 'suspended',
363
+ ...(await base(rs, startedAt)), status: 'suspended',
359
364
  authority: { presentedRef: presented.ref, decision, afterApproval: false },
360
365
  ...(prompt ? { pendingInput: receiptPending(prompt) } : {}),
361
366
  completedAt: now(),
@@ -365,7 +370,7 @@ export async function runIntent(intent, opts) {
365
370
  if (outcome.status === 'refused') {
366
371
  emit({ type: 'ApprovalRefused', runRef, stepRef: rs.stepRef, ...(outcome.reason ? { reason: outcome.reason } : {}) });
367
372
  const error = `approval refused${outcome.by ? ` by ${outcome.by}` : ''}${outcome.reason ? `: ${outcome.reason}` : ''}`;
368
- await record({ ...base(rs, startedAt), status: 'denied', authority: { presentedRef: presented.ref, decision, afterApproval: false }, completedAt: now(), error });
373
+ await record({ ...(await base(rs, startedAt)), status: 'denied', authority: { presentedRef: presented.ref, decision, afterApproval: false }, completedAt: now(), error });
369
374
  return { outcome: 'denied', plan, steps: allObservations, receipts, runRef, error };
370
375
  }
371
376
  approvalRecords = outcome.records;
@@ -378,44 +383,91 @@ export async function runIntent(intent, opts) {
378
383
  const reasons = decision.decision === 'deny' ? decision.reasons : [{ code: 'still-obligated', message: 'obligations remain after approval' }];
379
384
  emit({ type: 'MandateDenied', runRef, stepRef: rs.stepRef, reasons });
380
385
  const error = reasons.map((r) => `${r.code}: ${r.message}`).join('; ');
381
- await record({ ...base(rs, startedAt), status: 'denied', authority: { presentedRef: presented.ref, decision, afterApproval: true }, approvalRecords, completedAt: now(), error });
386
+ await record({ ...(await base(rs, startedAt)), status: 'denied', authority: { presentedRef: presented.ref, decision, afterApproval: true }, approvalRecords, completedAt: now(), error });
382
387
  return { outcome: 'denied', plan, steps: allObservations, receipts, runRef, error };
383
388
  }
384
389
  }
385
390
  return { decision, approvalRecords, used: presented };
386
391
  }
387
- /** Unwind executed steps that declared compensation, newest first. Best effort: a compensation that
388
- * fails is recorded, not retried — the receipt is the honest record. Compensations are AUTHORIZED
389
- * like any other capability step; a denied compensation is recorded as such. */
392
+ /**
393
+ * Unwind executed steps that declared compensation, newest first. Best effort: a compensation that fails is
394
+ * recorded, not retried — the receipt is the honest record.
395
+ *
396
+ * RUN-7 (spec 445 W0) — A COMPENSATION IS A STEP. It runs only if it is a tool this run EXPOSES, and, when that tool
397
+ * exercises a capability, only under a verdict: the verifier judged it `allow` against a presented mandate that fits
398
+ * it, with no obligation outstanding (or it is one of the loop's declared non-mandate paths — self-acting, or judged
399
+ * at the subject's agent — exactly as in `attempt`). Its arguments come from the original tool's RESULT, so they are
400
+ * the last thing that may run unjudged. Anything else is NOT RUN: it is recorded on its own receipt and returned as
401
+ * `compensationsOwed`, naming the step, so the caller can obtain the authority and perform it — owed, never done
402
+ * unverified, never dropped.
403
+ */
404
+ const compensationsOwed = [];
405
+ const withOwed = (r) => (compensationsOwed.length ? { ...r, compensationsOwed: [...compensationsOwed] } : r);
390
406
  async function compensate() {
391
407
  for (const { resolved, result } of compensable.reverse()) {
392
408
  const comp = resolved.tool.compensation;
393
- const compTool = toolsById.get(comp.toolId);
409
+ const compTool = exposedById.get(comp.toolId);
394
410
  const startedAt = now();
395
- const args = resolveValue(comp.args, { ...refs, $result: result });
411
+ const args = (resolveValue(comp.args, { ...refs, $result: result }) ?? {});
396
412
  const crs = compTool
397
413
  ? resolveStep(runRef, resolved.index, { toolId: comp.toolId, args, id: `${resolved.stepRef}:compensate` }, compTool, args)
398
- : { ...resolved, stepRef: `${resolved.stepRef}:compensate`, tool: { id: comp.toolId, description: '' }, args, risk: 'informational', capability: { id: '', action: '' } };
399
- let ok = false;
400
- let error;
414
+ : { ...resolved, stepRef: `${resolved.stepRef}:compensate`, step: { toolId: comp.toolId, args, id: `${resolved.stepRef}:compensate` }, tool: { id: comp.toolId, description: '' }, args, risk: 'informational', capability: { id: '', action: '' } };
415
+ let authority;
416
+ const notRun = async (reason, status, detail) => {
417
+ compensationsOwed.push({ stepRef: crs.stepRef, forStepRef: resolved.stepRef, toolId: comp.toolId, ...(crs.risk !== 'informational' ? { capability: crs.capability } : {}), risk: crs.risk, args, reason, detail });
418
+ emit({ type: 'StepCompensated', runRef, stepRef: resolved.stepRef, by: comp.toolId, ok: false });
419
+ await record({ ...(await base(crs, startedAt)), status, ...(authority ? { authority } : {}), completedAt: now(), error: `compensation owed (${reason}): ${detail}` });
420
+ };
421
+ if (!compTool) {
422
+ await notRun('tool-not-exposed', 'failed', `${resolved.tool.id} declares ${comp.toolId} as its compensation, which this run does not expose — refused, not run`);
423
+ continue;
424
+ }
401
425
  try {
402
- const compPresented = presentedFor(crs);
403
- if (crs.risk !== 'informational' && ports.mandateVerifier && compPresented) {
404
- const d = await ports.mandateVerifier.verify({ intent, presented: compPresented, step: crs, afterApproval: false, planDigest: currentPlanDigest });
405
- if (d.decision !== 'allow') {
406
- error = `compensation not authorized: ${d.decision}`;
426
+ if (crs.risk !== 'informational') {
427
+ const judgedAt = opts.authorityJudgedElsewhere ? opts.authorityJudgedElsewhere(crs) : null;
428
+ if (judgedAt) {
429
+ authority = { presentedRef: 'routed', decision: { decision: 'allow', obligations: [], evidence: { mandateRef: `routed:${judgedAt}`, intentDigest: await valueDigest(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'routed', allowed: true, reason: `the compensation's subject is ${judgedAt}; its authority is judged at that agent` }] } }, afterApproval: false };
430
+ }
431
+ else if (compTool.selfAuthorized) {
432
+ if (holdSelfActs) {
433
+ await record({ ...(await base(crs, startedAt)), status: 'skipped', completedAt: now(), error: holdReason });
434
+ continue;
435
+ }
436
+ authority = { presentedRef: 'self', decision: { decision: 'allow', obligations: [], evidence: { mandateRef: 'self', intentDigest: await valueDigest(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'self', allowed: true, reason: 'the run\'s own principal owns what this touches; the session is the authority' }] } }, afterApproval: false };
437
+ }
438
+ else {
439
+ if (!ports.mandateVerifier) {
440
+ await notRun('no-mandate', 'authority-required', `${comp.toolId} exercises ${crs.capability.id} and no MandateVerifier is configured — nothing could judge it`);
441
+ continue;
442
+ }
443
+ const compPresented = presentedFor(crs);
444
+ if (!compPresented) {
445
+ await notRun('no-mandate', 'authority-required', `${comp.toolId} exercises ${crs.capability.id} and no presented mandate covers it`);
446
+ continue;
447
+ }
448
+ const req = { intent, presented: compPresented, step: crs, afterApproval: false, planDigest: currentPlanDigest };
449
+ const d = await ports.mandateVerifier.verify(req);
450
+ emit({ type: 'MandateChecked', runRef, stepRef: crs.stepRef, decision: d.decision, afterApproval: false });
451
+ authority = { presentedRef: compPresented.ref, decision: d, afterApproval: false };
452
+ if (d.decision === 'deny') {
453
+ await notRun('not-authorized', 'denied', d.reasons.map((r) => `${r.code}: ${r.message}`).join('; '));
454
+ continue;
455
+ }
456
+ const extra = ports.policyEvaluator ? (await ports.policyEvaluator.evaluate({ ...req, decision: d })).obligations : [];
457
+ if (d.decision !== 'allow' || d.obligations.length + extra.length > 0) {
458
+ await notRun('approval-required', 'authority-required', `${comp.toolId} carries obligation(s) that an unwinding run does not discharge — it needs its own approval`);
459
+ continue;
460
+ }
407
461
  }
408
462
  }
409
- if (!error) {
410
- await opts.invoke(comp.toolId, args, { intent, step: crs.step, index: crs.index, ...(crs.idempotencyKey ? { idempotencyKey: crs.idempotencyKey } : {}), operationId: `${crs.idempotencyKey ?? `${runRef}:${crs.stepRef}`}:compensate`, planDigest: currentPlanDigest });
411
- ok = true;
412
- }
463
+ await opts.invoke(comp.toolId, args, { intent, step: crs.step, index: crs.index, ...(crs.idempotencyKey ? { idempotencyKey: crs.idempotencyKey } : {}), operationId: `${crs.idempotencyKey ?? `${runRef}:${crs.stepRef}`}:compensate`, planDigest: currentPlanDigest, untrustedSeen });
413
464
  }
414
465
  catch (e) {
415
- error = e instanceof Error ? e.message : String(e);
466
+ await notRun('failed', 'failed', e instanceof Error ? e.message : String(e));
467
+ continue;
416
468
  }
417
- emit({ type: 'StepCompensated', runRef, stepRef: resolved.stepRef, by: comp.toolId, ok });
418
- await record({ ...base(crs, startedAt), status: ok ? 'compensated' : 'failed', completedAt: now(), ...(error ? { error } : {}) });
469
+ emit({ type: 'StepCompensated', runRef, stepRef: resolved.stepRef, by: comp.toolId, ok: true });
470
+ await record({ ...(await base(crs, startedAt)), status: 'compensated', ...(authority ? { authority } : {}), completedAt: now() });
419
471
  }
420
472
  compensable.length = 0;
421
473
  }
@@ -428,10 +480,20 @@ export async function runIntent(intent, opts) {
428
480
  // placeholder drop, a re-plan): what the verifier compares a plan-bound mandate against. A re-planned run
429
481
  // is a different plan and a plan-bound mandate refuses it, which is the point.
430
482
  let currentPlanDigest = '';
483
+ // Spec 445 decision 8 (EVAL-13) — an EVALUATION's intent holds self-acting writes exactly as a comparison's dry run does.
484
+ const holdSelfActs = opts.dryRunSelfActs === true || isEvaluationIntent(intent);
485
+ const holdReason = isEvaluationIntent(intent) ? 'held: an evaluation run does not commit a self-acting write' : 'dry run: a comparison run does not commit a self-acting write';
431
486
  // Spec 409 §4 — whether any observation so far carried untrusted content (see `InvokeContext.untrustedSeen`).
432
487
  let untrustedSeen = false;
433
488
  const isUntrusted = (result) => !!result && typeof result === 'object' && (result.untrusted === true || result['x-ap-untrusted'] === true);
434
489
  const completedByRef = new Map((opts.resume?.completed ?? []).map((c) => [c.stepRef, c]));
490
+ // Spec 445 decision 8 — A REPLAYED UNTRUSTED OBSERVATION STILL MARKS THE RUN. The checkpoint carries what this run
491
+ // read before it parked; a resume is the same run, so what it read then counts from the first step now — not only
492
+ // once its replay is reached (and whether or not a host relays it on `ctx.untrustedSeen`).
493
+ if ((opts.resume?.completed ?? []).some((c) => isUntrusted(c.result)))
494
+ untrustedSeen = true;
495
+ /** Spec 445 decision 8 — the `ref`s whose results are untrusted, or were threaded from one (transitively). */
496
+ const taintedRefs = new Set();
435
497
  // Spec 413 — the declared retrieval step (see `RunIntentOpts.retrieval`), before the plan and outside its budget —
436
498
  // or, with `onlyIf`, after the plan and only when the plan needs it (416 §4g).
437
499
  const runRetrieval = async () => {
@@ -458,26 +520,42 @@ export async function runIntent(intent, opts) {
458
520
  obs = { step, ok: false, error: e instanceof Error ? e.message : String(e), stepRef: rs.stepRef };
459
521
  }
460
522
  emit({ type: 'ToolInvoked', runRef, stepRef: rs.stepRef, toolId: tool.id, ok: obs.ok });
461
- await record({ ...base(rs, startedAt), status: obs.ok ? 'executed' : 'failed', ...(obs.ok ? { outputDigest: fingerprint(obs.result) } : { error: obs.error }), ...(observation ? { observation, attests: observation.outcome } : {}), completedAt: now() });
523
+ await record({ ...(await base(rs, startedAt)), status: obs.ok ? 'executed' : 'failed', ...(obs.ok ? { outputDigest: await valueDigest(obs.result) } : { error: obs.error }), ...(observation ? { observation, attests: observation.outcome } : {}), completedAt: now() });
462
524
  allObservations.push(obs);
463
525
  opts.onObserve?.(obs);
464
526
  };
465
527
  if (opts.retrieval && !opts.resume && !opts.retrieval.onlyIf)
466
528
  await runRetrieval();
467
- const forPlanner = (list) => list.filter((o) => o.stepRef !== RETRIEVAL_STEP_REF);
529
+ // RUN-3 — what the planner is shown of a FAILED step is its error framed as untrusted data (`frameUntrusted`: the
530
+ // same `<data>` delimiter a continuation's results use, the delimiter neutralised inside, bounded): an error string
531
+ // can be text a remote service chose. The run's own observations and receipts keep the error as it was.
532
+ const forPlanner = (list) => list.filter((o) => o.stepRef !== RETRIEVAL_STEP_REF)
533
+ .map((o) => (!o.ok && typeof o.error === 'string' ? { ...o, error: frameUntrusted(o.error) } : o));
468
534
  // ── PLAN ADMISSION (spec 367 W1), as ONE function — the first plan, a failure re-plan and a continuation plan
469
535
  // (spec 421) all pass the same gate. Returns the admitted plan, or the run's terminal result.
470
536
  const admitPlan = async (p) => {
471
537
  if (!ports.planAdmission)
472
538
  return { plan: p };
473
- let verdict = await ports.planAdmission.admit({ intent, plan: p, tools: plannerTools });
539
+ // Spec 445 W0 — A DEFERRED JUDGMENT IS NOT A PASS, AND THIS LOOP IS WHAT MAKES IT SAFE TO PROCEED. A rule that cannot
540
+ // judge a value that does not exist yet (an amount to be asked for, or threaded by `$ref`) says `defer`; to any other
541
+ // caller of `admit` that is a refusal. Here it is set aside, because every step that exercises a capability under a
542
+ // stated outcome is judged AGAIN on its concrete arguments at the point of use (`judgeAtPointOfUse`, below) before
543
+ // the verifier, a signature request or the tool sees it.
544
+ const admit = async (candidate) => {
545
+ const v = await ports.planAdmission.admit({ intent, plan: candidate, tools: plannerTools });
546
+ if (v.admitted)
547
+ return v;
548
+ const rest = v.violations.filter((x) => x.recovery !== 'defer');
549
+ return rest.length ? { admitted: false, violations: rest } : { admitted: true };
550
+ };
551
+ let verdict = await admit(p);
474
552
  // Spec 421 W1 — A PLAN THAT READ WHAT ITS ACT NEEDS AND STOPPED continues: the loop appends `plan.continue` and admits
475
553
  // again. Only when every violation says so; the continuation plan that follows is admitted by these same rules.
476
554
  if (!verdict.admitted && verdict.violations.every((v) => v.recovery === 'continue')) {
477
555
  emit({ type: 'PlanRefused', runRef, violations: verdict.violations, replanning: false });
478
556
  owed = verdict.violations.map((v) => v.message);
479
557
  p = { ...p, steps: [...p.steps, { toolId: CONTINUE_STEP_ID, args: {} }] };
480
- verdict = await ports.planAdmission.admit({ intent, plan: p, tools: plannerTools });
558
+ verdict = await admit(p);
481
559
  if (verdict.admitted)
482
560
  emit({ type: 'PlanCreated', runRef, steps: p.steps.length, rationale: 'the plan read what the act needs — continuing from what it returns' });
483
561
  }
@@ -498,7 +576,7 @@ export async function runIntent(intent, opts) {
498
576
  return { ...st, args };
499
577
  }) };
500
578
  emit({ type: 'PlanRefused', runRef, violations: verdict.violations, replanning: false });
501
- verdict = await ports.planAdmission.admit({ intent, plan: p, tools: plannerTools });
579
+ verdict = await admit(p);
502
580
  if (verdict.admitted)
503
581
  emit({ type: 'PlanCreated', runRef, steps: p.steps.length, rationale: `placeholders dropped to be asked for: ${asked.join(', ')}` });
504
582
  }
@@ -514,7 +592,7 @@ export async function runIntent(intent, opts) {
514
592
  emit({ type: 'PlanRefused', runRef, violations: verdict.violations, replanning: true });
515
593
  const told = { ...intent, context: { ...(intent.context ?? {}), admission: verdict.violations.map((v) => v.message) } };
516
594
  p = await callPlanner(opts.planner, { intent: told, tools: plannerTools });
517
- verdict = await ports.planAdmission.admit({ intent, plan: p, tools: plannerTools });
595
+ verdict = await admit(p);
518
596
  if (!verdict.admitted) {
519
597
  emit({ type: 'PlanRefused', runRef, violations: verdict.violations, replanning: false });
520
598
  const error = `plan_refused: ${verdict.violations.map((v) => v.message).join(' | ')}`;
@@ -543,6 +621,16 @@ export async function runIntent(intent, opts) {
543
621
  // 416 §4g — a conditional retrieval runs now, once the plan is known, and only if the plan needs it.
544
622
  if (opts.retrieval?.onlyIf && !opts.resume && opts.retrieval.onlyIf(plan))
545
623
  await runRetrieval();
624
+ // The plan loop: walk the current plan; on a step failure, re-plan (bounded) and restart with a fresh plan.
625
+ //
626
+ // ── ONE ATTEMPT OF ONE STEP (spec 370 P3) ────────────────────────────────────────────────────────
627
+ // Everything a step goes through — replay, a declared branch, the answers already given, normalisation,
628
+ // authority, execution, the receipt, declared effects — in one function, so the scheduler below can run
629
+ // it for one step or for a batch of independent ones. It reports what happened; the scheduler decides
630
+ // what it means for the plan.
631
+ /** Spec 445 W0 — consequential steps that have run in this run, with the arguments they ran with (see the point of use). */
632
+ const spent = [];
633
+ const concreteArgs = new Map();
546
634
  const attempt = async (i, step) => {
547
635
  const tool = toolsById.get(step.toolId);
548
636
  // ── ALREADY RAN (spec 370 P1) — replayed as evidence, never as permission. ────────────────
@@ -568,9 +656,18 @@ export async function runIntent(intent, opts) {
568
656
  refuse(`the receipt records ${r.toolId}, the plan says ${step.toolId}`);
569
657
  if (r.status !== 'executed')
570
658
  refuse(`the receipt records '${r.status}', not an execution`);
571
- if (r.outputDigest !== fingerprint(done.result))
659
+ // RUN-8 (spec 445 W0) — THE DIGESTS BIND THE WHOLE VALUE, AND ONLY UNDER THE CURRENT SCHEME. They were a
660
+ // 2,048-character JSON prefix: a result altered past it replayed as recorded. A receipt written under that
661
+ // retired form (or under no form) cannot vouch for anything, and there is exactly one comparison — it is
662
+ // never compared by prefix "for compatibility", and the step is never quietly re-executed either (an act
663
+ // that already happened would happen twice). The run is refused, by name; start it again.
664
+ const retired = (d) => !isValueDigest(d);
665
+ if (retired(r.outputDigest) || retired(r.inputDigest) || (r.planArgsDigest !== undefined && retired(r.planArgsDigest))) {
666
+ refuse(`its receipt carries no ${VALUE_DIGEST_SCHEME} digest of its arguments and result — it was recorded under the retired prefix scheme (or was not written by this loop), which cannot vouch for a replayed value; this run cannot be resumed with integrity and must be started again`);
667
+ }
668
+ if (r.outputDigest !== await valueDigest(done.result))
572
669
  refuse('the supplied result is not the one the receipt recorded');
573
- if (r.inputDigest !== undefined) {
670
+ {
574
671
  let resolvedArgs = {};
575
672
  try {
576
673
  resolvedArgs = resolveArgs(step.args, refs);
@@ -583,7 +680,7 @@ export async function runIntent(intent, opts) {
583
680
  // resume past goal regression's inserted roster read of "missio nexus" was refused). As strict as before:
584
681
  // any change to the plan's arguments or to an earlier result still refuses.
585
682
  const recorded = r.planArgsDigest ?? r.inputDigest;
586
- if (recorded !== fingerprint(resolvedArgs))
683
+ if (recorded !== await valueDigest(resolvedArgs))
587
684
  refuse('the receipt recorded different arguments than this plan and the results before it produce');
588
685
  }
589
686
  const obs = { step, ok: true, result: done.result, stepRef, replayed: true };
@@ -597,9 +694,9 @@ export async function runIntent(intent, opts) {
597
694
  if (step.when && !holds(step.when, refs)) {
598
695
  const stepRef = step.id ?? `s${i}`;
599
696
  const rs = resolveStep(runRef, i, step, tool, resolveArgs(step.args, refs));
600
- const because = `${step.when.ref} ${step.when.equals !== undefined ? `≠ ${fingerprint(step.when.equals)}` : 'is absent'}`;
697
+ const because = `${step.when.ref} ${step.when.equals !== undefined ? `≠ ${canonicalJson(step.when.equals).slice(0, 120)}` : 'is absent'}`;
601
698
  emit({ type: 'StepSkipped', runRef, stepRef, toolId: tool.id, because });
602
- await record({ ...base(rs, now()), status: 'skipped', completedAt: now(), error: `not taken: ${because}` });
699
+ await record({ ...(await base(rs, now())), status: 'skipped', completedAt: now(), error: `not taken: ${because}` });
603
700
  return { kind: 'done', obs: { step, ok: true, stepRef, skipped: true } };
604
701
  }
605
702
  let args = resolveArgs(step.args, refs);
@@ -613,6 +710,9 @@ export async function runIntent(intent, opts) {
613
710
  // asked is not a licence to set arguments we did not ask about.
614
711
  const declaredArgs = tool.inputSchema?.properties;
615
712
  for (const answer of inputsFor(opts.supplied, step.id ?? `s${i}`)) {
713
+ // A CONFIRMATION answers yes/no; its `data` (an outbound `disclosure` digest) binds the answer, never an argument.
714
+ if (answer.confirmed !== undefined)
715
+ continue;
616
716
  for (const [name, value] of Object.entries(answer.data ?? {})) {
617
717
  if (value === undefined || value === '')
618
718
  continue;
@@ -641,15 +741,43 @@ export async function runIntent(intent, opts) {
641
741
  const pending = resolveStep(runRef, i, step, tool, args);
642
742
  const request = { ...e.request, stepRef: pending.stepRef, toolId: tool.id };
643
743
  emit({ type: 'RunSuspended', runRef, stepRef: pending.stepRef, resumeToken: pending.stepRef });
644
- await record({ ...base(pending, startedAt), status: 'suspended', pendingInput: receiptPending(request), completedAt: now() });
744
+ await record({ ...(await base(pending, startedAt)), status: 'suspended', pendingInput: receiptPending(request), completedAt: now() });
645
745
  return { kind: 'exit', result: { outcome: 'suspended', plan, steps: allObservations, receipts, runRef, resumeToken: pending.stepRef, prompt: request }, compensate: false };
646
746
  }
647
747
  }
648
748
  const rs = resolveStep(runRef, i, step, tool, args);
649
749
  emit({ type: 'StepProposed', runRef, stepRef: rs.stepRef, toolId: tool.id, risk: rs.risk });
750
+ // ── THE STATED OUTCOME, JUDGED ON THE ARGUMENTS THE STEP RUNS WITH (spec 445 W0) ────────────────────────
751
+ // Plan admission judged the plan as written. An amount that was absent then (asked for since) or threaded from an
752
+ // earlier result (resolved since) is a figure only now — and the bound is over the run, so it is summed with the
753
+ // amounts that have already moved. Judged here, before the verifier, before any signature is asked for and
754
+ // before the tool: over the bound ⇒ denied and unwound; still not stated ⇒ the PERSON is asked, by the loop, so
755
+ // no tool is left to choose an amount nobody compared with what she said.
756
+ if (ports.planAdmission && tool.capability && statedOutcomeOf(intent)) {
757
+ const at = spent.length;
758
+ const verdict = await ports.planAdmission.admit({ intent, plan: { steps: [...spent, { toolId: step.toolId, args }] }, tools: plannerTools, concrete: true });
759
+ const mine = verdict.admitted ? [] : verdict.violations.filter((v) => v.stepIndex === at && POINT_OF_USE_CODES.has(v.code));
760
+ const refused = mine.filter((v) => v.recovery !== 'ask');
761
+ if (refused.length) {
762
+ const error = `plan_refused: ${refused.map((v) => v.message).join(' | ')}`;
763
+ emit({ type: 'PlanRefused', runRef, violations: refused.map((v) => ({ ...v, stepIndex: i })), replanning: false });
764
+ await record({ ...(await base(rs, startedAt)), status: 'denied', completedAt: now(), error });
765
+ return { kind: 'exit', result: { outcome: 'denied', plan, steps: allObservations, receipts, runRef, error }, compensate: true };
766
+ }
767
+ const asked = mine.filter((v) => v.field);
768
+ if (asked.length) {
769
+ const request = { kind: 'data', stepRef: rs.stepRef, toolId: tool.id, prompt: asked.map((v) => v.message).join(' '), fields: asked.map((v) => ({ name: v.field, label: v.field, type: 'number', required: true })) };
770
+ emit({ type: 'RunSuspended', runRef, stepRef: rs.stepRef, resumeToken: rs.stepRef });
771
+ await record({ ...(await base(rs, startedAt)), status: 'suspended', pendingInput: receiptPending(request), completedAt: now() });
772
+ return { kind: 'exit', result: { outcome: 'suspended', plan, steps: allObservations, receipts, runRef, resumeToken: rs.stepRef, prompt: request }, compensate: false };
773
+ }
774
+ concreteArgs.set(rs.stepRef, args);
775
+ }
650
776
  // ── authority, for every step that exercises a capability ──
651
777
  let authority;
652
778
  let approvalRecords = [];
779
+ /** Spec 445 decision 8 — this step runs under a VERIFIED mandate (the effectful-step authority path). */
780
+ let mandated = false;
653
781
  // Spec 374 §1 — THE ASK IS ROUTED; THE MANDATE IS NOT. A step whose subject is another agent is
654
782
  // sent there (spec 366 R1), and THAT agent judges its authority under its own playbook, asks its own
655
783
  // steward, and answers. Judging it here would ask the asker for a mandate the subject never
@@ -662,12 +790,12 @@ export async function runIntent(intent, opts) {
662
790
  presentedRef: 'routed',
663
791
  decision: {
664
792
  decision: 'allow', obligations: [],
665
- evidence: { mandateRef: `routed:${judgedAt}`, intentDigest: fingerprint(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'routed', allowed: true, reason: `the step's subject is ${judgedAt}; its authority is judged at that agent, under its own playbook and its own steward — this run holds only what it answers` }] },
793
+ evidence: { mandateRef: `routed:${judgedAt}`, intentDigest: await valueDigest(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'routed', allowed: true, reason: `the step's subject is ${judgedAt}; its authority is judged at that agent, under its own playbook and its own steward — this run holds only what it answers` }] },
666
794
  },
667
795
  afterApproval: false,
668
796
  };
669
797
  }
670
- else if (rs.risk !== 'informational' && tool.selfAuthorized && opts.dryRunSelfActs && tool.dryRun === 'invoke') {
798
+ else if (rs.risk !== 'informational' && tool.selfAuthorized && holdSelfActs && tool.dryRun === 'invoke') {
671
799
  // A tool that honours a dry run is INVOKED with it: its preconditions run, nothing is written. A refusal is recorded as
672
800
  // executed (it ran, and declined); anything else as skipped — the write it describes did not happen.
673
801
  let result;
@@ -681,19 +809,19 @@ export async function runIntent(intent, opts) {
681
809
  if (isInputRequired(e)) {
682
810
  const request = { ...e.request, stepRef: rs.stepRef, toolId: tool.id };
683
811
  emit({ type: 'RunSuspended', runRef, stepRef: rs.stepRef, resumeToken: rs.stepRef });
684
- await record({ ...base(rs, startedAt), status: 'suspended', pendingInput: receiptPending(request), completedAt: now() });
812
+ await record({ ...(await base(rs, startedAt)), status: 'suspended', pendingInput: receiptPending(request), completedAt: now() });
685
813
  return { kind: 'exit', result: { outcome: 'suspended', plan, steps: allObservations, receipts, runRef, resumeToken: rs.stepRef, prompt: request }, compensate: false };
686
814
  }
687
- await record({ ...base(rs, startedAt), status: 'failed', completedAt: now(), error: e instanceof Error ? e.message : String(e) });
815
+ await record({ ...(await base(rs, startedAt)), status: 'failed', completedAt: now(), error: e instanceof Error ? e.message : String(e) });
688
816
  return { kind: 'done', obs: { step, ok: false, stepRef: rs.stepRef, error: e instanceof Error ? e.message : String(e) } };
689
817
  }
690
818
  const declined = !!result && typeof result === 'object' && typeof result.refused === 'string';
691
- await record({ ...base(rs, startedAt), status: declined ? 'executed' : 'skipped', completedAt: now(), ...(declined ? {} : { error: 'dry run: the invoker checked and wrote nothing' }),
692
- authority: { presentedRef: 'self', decision: { decision: 'allow', obligations: [], evidence: { mandateRef: 'self', intentDigest: fingerprint(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'self', allowed: true, reason: 'dry run' }] } }, afterApproval: false } });
819
+ await record({ ...(await base(rs, startedAt)), status: declined ? 'executed' : 'skipped', completedAt: now(), ...(declined ? {} : { error: 'dry run: the invoker checked and wrote nothing' }),
820
+ authority: { presentedRef: 'self', decision: { decision: 'allow', obligations: [], evidence: { mandateRef: 'self', intentDigest: await valueDigest(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'self', allowed: true, reason: 'dry run' }] } }, afterApproval: false } });
693
821
  return { kind: 'done', obs: { step, ok: true, stepRef: rs.stepRef, ...(declined ? {} : { skipped: true }), result } };
694
822
  }
695
- else if (rs.risk !== 'informational' && tool.selfAuthorized && opts.dryRunSelfActs) {
696
- await record({ ...base(rs, startedAt), status: 'skipped', completedAt: now(), error: 'dry run: a comparison run does not commit a self-acting write' });
823
+ else if (rs.risk !== 'informational' && tool.selfAuthorized && holdSelfActs) {
824
+ await record({ ...(await base(rs, startedAt)), status: 'skipped', completedAt: now(), error: holdReason });
697
825
  return { kind: 'done', obs: { step, ok: true, stepRef: rs.stepRef, skipped: true, result: { dryRun: true, toolId: tool.id } } };
698
826
  }
699
827
  else if (rs.risk !== 'informational' && tool.selfAuthorized) {
@@ -708,7 +836,7 @@ export async function runIntent(intent, opts) {
708
836
  decision: 'allow', obligations: [],
709
837
  // The evidence names the basis honestly — there is no mandate to reference, and inventing a
710
838
  // mandateRef would make a receipt claim a signature that never happened.
711
- evidence: { mandateRef: 'self', intentDigest: fingerprint(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'self', allowed: true, reason: 'the run\'s own principal owns what this touches; the session is the authority' }] },
839
+ evidence: { mandateRef: 'self', intentDigest: await valueDigest(intent), evaluatedAt: now(), verdicts: [{ enforcer: 'self', allowed: true, reason: 'the run\'s own principal owns what this touches; the session is the authority' }] },
712
840
  },
713
841
  afterApproval: false,
714
842
  };
@@ -719,12 +847,43 @@ export async function runIntent(intent, opts) {
719
847
  return { kind: 'exit', result: a, compensate: true };
720
848
  authority = { presentedRef: a.used.ref, decision: a.decision, afterApproval: a.approvalRecords.length > 0 };
721
849
  approvalRecords = a.approvalRecords;
850
+ mandated = true;
851
+ }
852
+ // ── OUTBOUND (spec 445 decision 8) — a tool that sends data to another party is not run silently once this run
853
+ // has read untrusted content, nor when an argument is threaded from an untrusted result. A verified mandate is
854
+ // the authority path; without one the step PARKS for the asker's confirmation, bound to `outboundDigest` over
855
+ // exactly what would leave. Declined ⇒ denied, nothing sent. Batched steps cannot `$ref` their own batch's
856
+ // results (`independentBatch`), so a batched outbound step sends the plan's literal arguments only.
857
+ let outboundConfirmed;
858
+ if (tool.outbound && !mandated) {
859
+ const threaded = [...refsUsed(step.args)].some((r) => taintedRefs.has(r));
860
+ if (untrustedSeen || threaded) {
861
+ const disclosure = await outboundDigest(tool.id, args);
862
+ const answers = inputsFor(opts.supplied, rs.stepRef).filter((s) => s.confirmed !== undefined);
863
+ const party = typeof tool.outbound === 'object' ? String(args[tool.outbound.partyArg] ?? 'another party') : 'another party';
864
+ if (answers.some((s) => s.confirmed === false)) {
865
+ const error = `outbound_declined: nothing was sent to ${party} — the person declined`;
866
+ await record({ ...(await base(rs, startedAt)), status: 'denied', completedAt: now(), error });
867
+ return { kind: 'exit', result: { outcome: 'denied', plan, steps: allObservations, receipts, runRef, error }, compensate: true };
868
+ }
869
+ if (!answers.some((s) => s.confirmed === true && s.data?.disclosure === disclosure)) {
870
+ const request = {
871
+ kind: 'confirmation', stepRef: rs.stepRef, toolId: tool.id,
872
+ prompt: `This run has read content written by others. Send this to ${party}? Nothing leaves until you confirm exactly this.`,
873
+ summary: { party, sends: args, disclosure, why: threaded ? 'threaded-from-untrusted' : 'untrusted-content-seen', confirmWith: { confirmed: true, data: { disclosure } } },
874
+ };
875
+ emit({ type: 'RunSuspended', runRef, stepRef: rs.stepRef, resumeToken: rs.stepRef });
876
+ await record({ ...(await base(rs, startedAt)), status: 'suspended', ...(authority ? { authority } : {}), pendingInput: receiptPending(request), completedAt: now() });
877
+ return { kind: 'exit', result: { outcome: 'suspended', plan, steps: allObservations, receipts, runRef, resumeToken: rs.stepRef, prompt: request }, compensate: false };
878
+ }
879
+ outboundConfirmed = disclosure;
880
+ }
722
881
  }
723
882
  // ── execute ──
724
883
  const suppliedHere = inputsFor(opts.supplied, rs.stepRef);
725
884
  const binding = opts.bindingFor?.(rs);
726
885
  const operationId = binding?.operationId ?? rs.idempotencyKey ?? `${runRef}:${rs.stepRef}`;
727
- const ctx = { intent, step, index: i, ...(rs.idempotencyKey ? { idempotencyKey: rs.idempotencyKey } : {}), operationId, ...(suppliedHere.length ? { supplied: suppliedHere } : {}), planDigest: currentPlanDigest, untrustedSeen };
886
+ const ctx = { intent, step, index: i, ...(rs.idempotencyKey ? { idempotencyKey: rs.idempotencyKey } : {}), operationId, ...(suppliedHere.length ? { supplied: suppliedHere } : {}), planDigest: currentPlanDigest, untrustedSeen, ...(outboundConfirmed ? { outboundConfirmed } : {}) };
728
887
  executed++;
729
888
  let obs;
730
889
  let observation;
@@ -764,7 +923,7 @@ export async function runIntent(intent, opts) {
764
923
  const request = { ...e.request, stepRef: rs.stepRef, toolId: tool.id };
765
924
  emit({ type: 'RunSuspended', runRef, stepRef: rs.stepRef, resumeToken: rs.stepRef });
766
925
  await record({
767
- ...base(rs, startedAt),
926
+ ...(await base(rs, startedAt)),
768
927
  status: 'suspended',
769
928
  ...(authority ? { authority } : {}),
770
929
  ...(approvalRecords.length ? { approvalRecords } : {}),
@@ -778,13 +937,13 @@ export async function runIntent(intent, opts) {
778
937
  emit({ type: 'ToolInvoked', runRef, stepRef: rs.stepRef, toolId: tool.id, ok: obs.ok });
779
938
  const decisions = opts.decisionsFor?.(tool.id, args) ?? [];
780
939
  const stepReceipt = {
781
- ...base(rs, startedAt),
940
+ ...(await base(rs, startedAt)),
782
941
  status: obs.ok ? 'executed' : 'failed',
783
942
  // What was decided FOR the person on the way here, and on what basis (spec 363 W6).
784
943
  ...(decisions.length ? { decisions: [...decisions] } : {}),
785
944
  ...(authority ? { authority } : {}),
786
945
  ...(approvalRecords.length ? { approvalRecords } : {}),
787
- ...(obs.ok ? { outputDigest: fingerprint(obs.result) } : { error: obs.error }),
946
+ ...(obs.ok ? { outputDigest: await valueDigest(obs.result) } : { error: obs.error }),
788
947
  // Spec 410 §2 — what the adapter saw, and the one word the receipt attests.
789
948
  ...(observation ? { observation, attests: observation.outcome } : {}),
790
949
  ...(reconciled ? { reconciled: true } : {}),
@@ -916,6 +1075,20 @@ export async function runIntent(intent, opts) {
916
1075
  emit({ type: 'PlanCreated', runRef, steps: expanded.length, rationale: `fan-out of ${baseId} over <${relation}> into ${expanded.length} steps` });
917
1076
  maxSteps += Math.max(0, expanded.length - 1);
918
1077
  plan = { ...plan, steps: [...plan.steps.slice(0, i), ...expanded, ...plan.steps.slice(i + 1)] };
1078
+ // RUNC-4 (spec 445 W0) — A BOUND STATED OVER THE PLAN IS JUDGED OVER THE EXPANDED PLAN. Admission saw ONE step
1079
+ // where there are now N; "up to 20 in total" over three payees is only decidable here. The expanded plan goes
1080
+ // back through the same admission port, and a violation no re-plan may cure (`refuse`) ends the run before
1081
+ // any item runs. Other recoveries are not acted on here: the plan as written was already admitted, and an
1082
+ // expansion is not a new plan to re-word.
1083
+ if (ports.planAdmission && expanded.length > 0) {
1084
+ const verdict = await ports.planAdmission.admit({ intent, plan, tools: plannerTools });
1085
+ const refused = verdict.admitted ? [] : verdict.violations.filter((v) => v.recovery === 'refuse');
1086
+ if (refused.length) {
1087
+ emit({ type: 'PlanRefused', runRef, violations: refused, replanning: false });
1088
+ await compensate();
1089
+ return withOwed({ outcome: 'denied', plan, steps: allObservations, receipts, runRef, error: `plan_refused: ${refused.map((v) => v.message).join(' | ')}` });
1090
+ }
1091
+ }
919
1092
  if (expanded.length === 0)
920
1093
  continue; // an empty set fans out to nothing, which is a fine answer (the step is gone; `i` now names the next)
921
1094
  step = plan.steps[i];
@@ -940,8 +1113,24 @@ export async function runIntent(intent, opts) {
940
1113
  for (const o of outcomes) {
941
1114
  if (o.kind === 'done') {
942
1115
  allObservations.push(o.obs);
1116
+ // What has MOVED in this run, with the arguments it moved with — the running total a bound over the run is
1117
+ // judged against at the next step's point of use (a replayed step counts: it ran, in the run this resumes).
1118
+ if (o.obs.ok && !o.obs.skipped && toolsById.get(o.obs.step.toolId)?.capability) {
1119
+ let ran = o.obs.stepRef ? concreteArgs.get(o.obs.stepRef) : undefined;
1120
+ if (!ran) {
1121
+ try {
1122
+ ran = resolveArgs(o.obs.step.args, refs);
1123
+ }
1124
+ catch {
1125
+ ran = o.obs.step.args;
1126
+ }
1127
+ }
1128
+ spent.push({ toolId: o.obs.step.toolId, args: ran });
1129
+ }
943
1130
  if (o.obs.ok && isUntrusted(o.obs.result))
944
1131
  untrustedSeen = true;
1132
+ if (o.obs.ok && o.obs.step.ref && (isUntrusted(o.obs.result) || [...refsUsed(o.obs.step.args)].some((r) => taintedRefs.has(r))))
1133
+ taintedRefs.add(o.obs.step.ref);
945
1134
  opts.onObserve?.(o.obs);
946
1135
  if (o.obs.ok && !o.obs.skipped)
947
1136
  lastResult = o.obs.result;
@@ -954,7 +1143,7 @@ export async function runIntent(intent, opts) {
954
1143
  if (exit) {
955
1144
  if (exit.compensate)
956
1145
  await compensate();
957
- return exit.result;
1146
+ return withOwed(exit.result);
958
1147
  }
959
1148
  if (failedMidPlan)
960
1149
  break; // stop this plan; decide whether to re-plan below
@@ -969,12 +1158,23 @@ export async function runIntent(intent, opts) {
969
1158
  await compensate();
970
1159
  const error = last?.error ?? 'step failed';
971
1160
  emit({ type: 'RunFailed', runRef, error });
972
- return { outcome: 'failed', plan, steps: allObservations, receipts, runRef, error };
1161
+ return withOwed({ outcome: 'failed', plan, steps: allObservations, receipts, runRef, error });
973
1162
  }
974
1163
  replans++;
975
1164
  // Spec 391 W2 — the planner sees what each prior step WAS and how big, not a 40 KB body in its prompt.
976
- plan = await callPlanner(opts.planner, { intent, tools: plannerTools, observations: compactObservations(forPlanner(allObservations)) });
977
- assertKnownTools(plan, toolIds);
1165
+ // RUN-3 (spec 445 W0) — and what a failed step SAID is framed as data, bounded (`forPlanner`): it can be a remote
1166
+ // service's text. The plan that comes back is a plan like any other: it passes `admitPlan` — the one gate the
1167
+ // first plan and a continuation pass — BEFORE a step of it runs. It used to go straight to the known-tools
1168
+ // guard, so every admission rule (a `refuse` rule included) was skipped on exactly the planner turn that had
1169
+ // just read an error string. Refused ⇒ the run ends there, and what already ran is unwound.
1170
+ const replanned = await callPlanner(opts.planner, { intent, tools: plannerTools, observations: compactObservations(forPlanner(allObservations)) });
1171
+ const readmitted = await admitPlan(replanned);
1172
+ if ('result' in readmitted) {
1173
+ await compensate();
1174
+ return withOwed(readmitted.result);
1175
+ }
1176
+ plan = readmitted.plan;
1177
+ assertKnownTools(plan, toolIds); // fail-closed after admission (ADR-0013).
978
1178
  currentPlanDigest = await planDigest(plan);
979
1179
  emit({ type: 'PlanCreated', runRef, steps: plan.steps.length, ...(plan.rationale ? { rationale: plan.rationale } : {}) });
980
1180
  }
@@ -983,7 +1183,7 @@ export async function runIntent(intent, opts) {
983
1183
  const error = e instanceof OrchestrationError ? `${e.code}: ${e.message}` : e instanceof Error ? e.message : String(e);
984
1184
  await compensate();
985
1185
  emit({ type: 'RunFailed', runRef, error });
986
- return { outcome: 'failed', plan, steps: allObservations, receipts, runRef, error };
1186
+ return withOwed({ outcome: 'failed', plan, steps: allObservations, receipts, runRef, error });
987
1187
  }
988
1188
  }
989
1189
  //# sourceMappingURL=loop.js.map