@sema-agent/core 7.26.0 → 7.26.2

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 (50) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/agents/verify.d.ts +16 -6
  3. package/dist/agents/verify.js +12 -5
  4. package/dist/core/checkpoint-store.d.ts +13 -10
  5. package/dist/core/gate-lanes.js +2 -1
  6. package/dist/core/gate-outcome.d.ts +54 -5
  7. package/dist/core/gate-outcome.js +17 -0
  8. package/dist/core/permission-rule-provider.js +6 -1
  9. package/dist/core/permission-rule-store.d.ts +36 -0
  10. package/dist/core/permission-rule-store.js +119 -19
  11. package/dist/core/permission-rule-sync.js +14 -5
  12. package/dist/core/runner/contracts.d.ts +48 -0
  13. package/dist/core/runner/decide-continuation.js +3 -1
  14. package/dist/core/runner/denial-limit-arms.d.ts +4 -3
  15. package/dist/core/runner/denial-limit-arms.js +2 -1
  16. package/dist/core/runner/prepare-inherited-gate.d.ts +1 -1
  17. package/dist/core/runner/prepare-inherited-gate.js +2 -1
  18. package/dist/core/runner/resume-apply.js +3 -1
  19. package/dist/core/runner/resume-policy-outcome.js +6 -6
  20. package/dist/core/runner/run-harness-handlers.js +2 -5
  21. package/dist/core/runner/run-identity-wiring.d.ts +6 -0
  22. package/dist/core/runner/run-identity-wiring.js +8 -3
  23. package/dist/core/runner/run-leg.js +9 -3
  24. package/dist/core/runner/runtask.js +2 -2
  25. package/dist/core/runner/session-rule-policy.d.ts +1 -1
  26. package/dist/core/runner/session-rule-policy.js +6 -7
  27. package/dist/core/session-policy-store.d.ts +18 -0
  28. package/dist/core/session-policy-store.js +3 -0
  29. package/dist/core/store-contracts/permission-rule-write-contract.js +16 -3
  30. package/dist/core/store-contracts/read-back-shape-contract.d.ts +56 -0
  31. package/dist/core/store-contracts/read-back-shape-contract.js +78 -0
  32. package/dist/core/tool-policy.d.ts +36 -12
  33. package/dist/core/tool-policy.js +22 -9
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.js +4 -0
  36. package/dist/stores/file/adoption/adopt.js +4 -10
  37. package/dist/stores/file/permission-rule-store.d.ts +5 -1
  38. package/dist/stores/file/permission-rule-store.js +5 -3
  39. package/dist/stores/file/session-policy-store.d.ts +32 -0
  40. package/dist/stores/file/session-policy-store.js +72 -50
  41. package/dist/tools/fs/shell-read-check.d.ts +58 -1
  42. package/dist/tools/fs/shell-read-check.js +47 -33
  43. package/dist/tools/fs/shell-read-establish.js +19 -8
  44. package/dist/tools/fs/shell-read-judge.d.ts +3 -0
  45. package/dist/tools/fs/shell-read-judge.js +3 -1
  46. package/dist/tools/fs/shell-read-plan.js +16 -17
  47. package/package.json +1 -1
  48. package/test/export-surface.snapshot.json +52 -2
  49. package/wire-changes/core@7.26.1.json +605 -0
  50. package/wire-changes/core@7.26.2.json +106 -0
@@ -5,6 +5,71 @@ import { thrownText } from "./thrown-value.js";
5
5
  export function sameRuleOwner(a, b) {
6
6
  return a.kind === "principal" ? b.kind === "principal" && a.principal === b.principal : b.kind === "local-owner";
7
7
  }
8
+ export function isRuleRevision(rev) {
9
+ return typeof rev === "number" && Number.isSafeInteger(rev) && rev >= 0;
10
+ }
11
+ function outcomeWord(value) {
12
+ if (value === undefined)
13
+ return "no revision at all";
14
+ if (value === null)
15
+ return "a null revision";
16
+ if (typeof value === "number")
17
+ return `the number ${String(value)}, which is not a revision`;
18
+ return `a ${typeof value} where a revision belongs`;
19
+ }
20
+ function isReadableDrop(drop) {
21
+ if (drop === null || typeof drop !== "object")
22
+ return false;
23
+ const dot = drop.dot;
24
+ if (dot === null || typeof dot !== "object")
25
+ return false;
26
+ return typeof dot.actor === "string" && typeof dot.counter === "number" && typeof drop.reason === "string";
27
+ }
28
+ function readSyncReport(sync) {
29
+ if (sync === undefined)
30
+ return {};
31
+ if (sync === null || typeof sync !== "object")
32
+ return { unreadable: `the landing carried a ${sync === null ? "null" : typeof sync} where its sync report belongs` };
33
+ for (const member of ["droppedInbound", "quarantined"]) {
34
+ const value = sync[member];
35
+ if (value === undefined)
36
+ continue;
37
+ if (!Array.isArray(value))
38
+ return { unreadable: `the landing's sync report carried a ${typeof value} as \`${member}\`, which a reader cannot walk` };
39
+ const bad = value.findIndex((row) => !isReadableDrop(row));
40
+ if (bad !== -1)
41
+ return { unreadable: `the landing's sync report carried a \`${member}\` entry (#${String(bad)}) that is not a drop record, so what was dropped cannot be read` };
42
+ }
43
+ return { report: sync };
44
+ }
45
+ export function readRuleWriteOutcome(res) {
46
+ if (res === null || typeof res !== "object") {
47
+ return { kind: "unreadable", why: `the store answered ${res === null ? "null" : `a ${typeof res}`} where a write outcome belongs` };
48
+ }
49
+ const tagged = res;
50
+ const refusal = tagged.refused;
51
+ const conflict = tagged.conflict;
52
+ if (refusal !== undefined && conflict !== undefined) {
53
+ return { kind: "unreadable", why: "the store answered a refusal and a decline at once, and an answer that is two arms is not an answer about either" };
54
+ }
55
+ if (refusal !== undefined) {
56
+ return typeof refusal === "string" && refusal !== ""
57
+ ? { kind: "refused", refused: refusal }
58
+ : { kind: "unreadable", why: `the store tagged its answer as a refusal and named it with a ${refusal === null ? "null" : typeof refusal}, so which refusal it is cannot be read` };
59
+ }
60
+ if (conflict !== undefined) {
61
+ if (conflict !== true)
62
+ return { kind: "unreadable", why: `the store tagged its answer as a decline with \`${String(conflict)}\`, which does not say a compare-and-set lost` };
63
+ return isRuleRevision(tagged.rev)
64
+ ? { kind: "conflict", rev: tagged.rev }
65
+ : { kind: "unreadable", why: `the store declined the write with ${outcomeWord(tagged.rev)}, so the revision to re-anchor the next attempt to is unknown` };
66
+ }
67
+ if (!isRuleRevision(tagged.rev)) {
68
+ return { kind: "unreadable", why: `the store answered neither a conflict nor a refusal, and carried ${outcomeWord(tagged.rev)} — a landing reports the revision it committed at, so this answer cannot say whether the write landed` };
69
+ }
70
+ const { report, unreadable } = readSyncReport(tagged.sync);
71
+ return { kind: "landed", rev: tagged.rev, ...(report !== undefined ? { sync: report } : {}), ...(unreadable !== undefined ? { reportUnreadable: unreadable } : {}) };
72
+ }
8
73
  export function unknownRuleWriteDeltaError(delta) {
9
74
  const kind = delta?.kind;
10
75
  return new Error(`the permission-rule store cannot apply a write delta of kind ${escapeForDisclosure(typeof kind === "string" ? kind : String(kind))} — the write face folds exactly four named arms (redemption-add, tighten-add, tighten-delete, sync-join), and an unnamed one is refused rather than read as any of them`);
@@ -405,13 +470,32 @@ function resolveRuleWriter(store) {
405
470
  w = writerOf(store);
406
471
  }
407
472
  catch (err) {
408
- return { unavailable: `the permission-rule store's write face could not be acquired (${errText(err)})` };
473
+ return { unavailable: `the permission-rule store's write face could not be acquired (${errText(err)})`, committed: "unknown" };
409
474
  }
410
- return w === undefined ? { unavailable: "the resolved permission-rule store has no write face" } : { writer: w };
475
+ return w === undefined ? { unavailable: "the resolved permission-rule store has no write face", committed: "no" } : { writer: w };
411
476
  }
412
477
  function ruleIdentityStands(rules, identity) {
413
478
  return rules.some((r) => sameRuleIdentity(r, identity) && isRuleLive(r));
414
479
  }
480
+ async function witnessRuleAdd(store, writer, delta) {
481
+ const identity = { behavior: delta.behavior, rule: delta.rule, scope: delta.scope };
482
+ const dot = delta.add.dot;
483
+ try {
484
+ if (delta.scope.kind === "session") {
485
+ const view = await store.effective({ sessionId: delta.scope.sessionId });
486
+ if (view.unreadable !== undefined)
487
+ return { unreadable: view.unreadable };
488
+ return { seen: view.rules.some((r) => sameRuleIdentity(r, identity) && r.adds.some((a) => sameDot(a.dot, dot))), rev: view.rev };
489
+ }
490
+ const raw = await writer.readRaw();
491
+ const inRows = raw.rules.some((r) => sameRuleIdentity(r, identity) && r.adds.some((a) => sameDot(a.dot, dot)));
492
+ const inQuarantine = (raw.quarantined ?? []).some((q) => sameDot(q.add.dot, dot));
493
+ return { seen: inRows || inQuarantine, rev: raw.rev };
494
+ }
495
+ catch (err) {
496
+ return { unreadable: errText(err) };
497
+ }
498
+ }
415
499
  async function readRuleLiveness(store, identity) {
416
500
  try {
417
501
  const after = await store.effective();
@@ -448,7 +532,7 @@ export async function removePersistedRule(opts) {
448
532
  const store = owner.kind === "local-owner" ? provider.forLocalOwner() : provider.forPrincipal(owner.principal);
449
533
  const face = resolveRuleWriter(store);
450
534
  if ("unavailable" in face) {
451
- return { status: "failed", error: `${face.unavailable} — rules cannot be removed through it`, committed: "no" };
535
+ return { status: "failed", error: `${face.unavailable} — rules cannot be removed through it`, committed: face.committed };
452
536
  }
453
537
  const writer = face.writer;
454
538
  let snapshot;
@@ -474,30 +558,35 @@ export async function removePersistedRule(opts) {
474
558
  const tombstone = { behavior, rule, scope, removedDots, deletedBy };
475
559
  let expectedRev = snapshot.rev;
476
560
  let lastError = "";
477
- let applyThrew = false;
561
+ let settlement = "no";
478
562
  for (let attempt = 0; attempt < REMOVE_MAX_ATTEMPTS; attempt++) {
479
563
  try {
480
- const res = await writer.apply({ kind: "tighten-delete", tombstone }, { expectedRev });
481
- if ("refused" in res) {
482
- return { status: "failed", error: `the store refused the tombstone (${res.refused}) — nothing was removed`, committed: "no" };
564
+ const reading = readRuleWriteOutcome(await writer.apply({ kind: "tighten-delete", tombstone }, { expectedRev }));
565
+ if (reading.kind === "refused") {
566
+ return { status: "failed", error: `the store refused the tombstone (${reading.refused}) — nothing was removed`, committed: "no" };
567
+ }
568
+ if (reading.kind === "unreadable") {
569
+ settlement = "unknown";
570
+ lastError = `the permission-rule store answered the write unreadably: ${reading.why}`;
571
+ break;
483
572
  }
484
- if (!("conflict" in res)) {
485
- return { status: "removed", rev: res.rev, stillLive: await readRuleLiveness(store, identity) };
573
+ if (reading.kind === "landed") {
574
+ return { status: "removed", rev: reading.rev, stillLive: await readRuleLiveness(store, identity) };
486
575
  }
487
- expectedRev = res.rev;
576
+ expectedRev = reading.rev;
488
577
  }
489
578
  catch (err) {
490
- applyThrew = true;
579
+ settlement = "unknown";
491
580
  lastError = errText(err);
492
581
  break;
493
582
  }
494
583
  }
495
- const reason = applyThrew
584
+ const reason = settlement === "unknown"
496
585
  ? lastError !== ""
497
586
  ? lastError
498
587
  : "the permission-rule store threw while applying the tombstone, with no message"
499
588
  : `optimistic-concurrency retries exhausted after ${REMOVE_MAX_ATTEMPTS} attempts`;
500
- const committed = applyThrew ? "unknown" : "no";
589
+ const committed = settlement;
501
590
  try {
502
591
  const after = await writer.readRaw();
503
592
  const landed = after.tombstones.some((t) => t.deletedBy.actor === tombstone.deletedBy.actor && t.deletedBy.counter === tombstone.deletedBy.counter);
@@ -506,7 +595,7 @@ export async function removePersistedRule(opts) {
506
595
  }
507
596
  return {
508
597
  status: "failed",
509
- error: applyThrew
598
+ error: settlement === "unknown"
510
599
  ? `${reason}; the store retains no tombstone for this removal, which cannot tell a write that never landed from one whose evidence was collected — the outcome is indeterminate, reconcile with list()`
511
600
  : `${reason} (read-back confirms no tombstone was written)`,
512
601
  committed,
@@ -515,7 +604,7 @@ export async function removePersistedRule(opts) {
515
604
  catch (err) {
516
605
  return {
517
606
  status: "failed",
518
- error: applyThrew
607
+ error: settlement === "unknown"
519
608
  ? `${reason}; the read-back also failed (${errText(err)}) so this outcome is indeterminate — reconcile with list()`
520
609
  : `${reason}; the read-back also failed (${errText(err)}), but every attempt was answered by the store, so nothing was written`,
521
610
  committed,
@@ -555,10 +644,21 @@ export async function landRuleAdd(args) {
555
644
  catch (err) {
556
645
  return { landed: false, reason: `the permission-rule store refused the write: ${errText(err)}`, committed: "unknown" };
557
646
  }
558
- if ("refused" in res)
647
+ const reading = readRuleWriteOutcome(res);
648
+ if (reading.kind === "refused")
559
649
  return { landed: false, reason: SESSION_ENDED_REFUSAL, committed: "no" };
560
- if (!("conflict" in res))
561
- return { landed: true, rev: res.rev };
650
+ if (reading.kind === "unreadable")
651
+ return { landed: false, reason: `the permission-rule store answered the write unreadably: ${reading.why}`, committed: "unknown" };
652
+ if (reading.kind === "landed") {
653
+ const witness = await witnessRuleAdd(args.store, args.writer, args.delta);
654
+ if ("unreadable" in witness) {
655
+ return { landed: false, reason: `the permission-rule store reported a landing at revision ${String(reading.rev)}, and the read-back that would have witnessed it could not be taken (${witness.unreadable})`, committed: "unknown" };
656
+ }
657
+ if (!witness.seen) {
658
+ return { landed: false, reason: `the permission-rule store reported a landing at revision ${String(reading.rev)}, and a read-back of this add's own dot finds no trace of it — a revision says the ANSWER was well formed, not that a row exists, so nothing here can say the write landed`, committed: "unknown" };
659
+ }
660
+ return { landed: true, rev: reading.rev };
661
+ }
562
662
  }
563
663
  return { landed: false, reason: `optimistic-concurrency retries exhausted after ${RULE_ADD_MAX_ATTEMPTS} attempts`, committed: "no" };
564
664
  }
@@ -592,7 +692,7 @@ export async function addPersistedRule(opts) {
592
692
  const store = owner.kind === "local-owner" ? provider.forLocalOwner() : provider.forPrincipal(owner.principal);
593
693
  const face = resolveRuleWriter(store);
594
694
  if ("unavailable" in face) {
595
- return { status: "failed", error: `${face.unavailable} — rules cannot be added through it`, committed: "no" };
695
+ return { status: "failed", error: `${face.unavailable} — rules cannot be added through it`, committed: face.committed };
596
696
  }
597
697
  const writer = face.writer;
598
698
  const query = scope.kind === "session" ? { sessionId: scope.sessionId } : undefined;
@@ -1,5 +1,5 @@
1
1
  import { escapeForDisclosure, isRuleBehavior, parseRuleText } from "./permission-rule-model.js";
2
- import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, ruleSyncVector, sameRuleIdentity, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
2
+ import { applyTombstones, errText, joinFrontiers, joinRuleStates, normalizePersistedRule, readRuleWriteOutcome, ruleSyncVector, sameRuleIdentity, screenRuleSyncState, writerOf, } from "./permission-rule-store.js";
3
3
  import { emitTrace } from "./trace.js";
4
4
  export const PERMISSION_RULE_SYNC_PATH = "/v1/permission-rules/sync";
5
5
  export const LOCAL_OWNER_UNSYNCABLE_CODE = "permission_rules.local_owner_unsyncable";
@@ -174,11 +174,12 @@ export async function syncPermissionRules(opts) {
174
174
  warnings: [...warnings, `the store refused the sync landing: ${escapeForDisclosure(errText(err))} — nothing landed, the local state is unchanged`],
175
175
  });
176
176
  }
177
- if ("conflict" in res) {
177
+ const reading = readRuleWriteOutcome(res);
178
+ if (reading.kind === "conflict") {
178
179
  current = await writer.readRaw();
179
180
  continue;
180
181
  }
181
- if ("refused" in res) {
182
+ if (reading.kind === "refused" || reading.kind === "unreadable") {
182
183
  return disclose({
183
184
  ok: false,
184
185
  pushed: { addDots: raw.rules.reduce((n, r) => n + r.adds.length, 0), tombstones: raw.tombstones.length },
@@ -186,7 +187,12 @@ export async function syncPermissionRules(opts) {
186
187
  resurrected: [],
187
188
  dropped,
188
189
  rev: current.rev,
189
- warnings: [...warnings, `the store refused the sync landing (${res.refused}) — nothing landed, the local state is unchanged`],
190
+ warnings: [
191
+ ...warnings,
192
+ reading.kind === "refused"
193
+ ? `the store refused the sync landing (${reading.refused}) — nothing landed, the local state is unchanged`
194
+ : `the store answered the sync landing unreadably: ${reading.why} — this round is not reported as landed; re-read before trusting the local state`,
195
+ ],
190
196
  });
191
197
  }
192
198
  const landedRaw = await writer.readRaw();
@@ -211,7 +217,10 @@ export async function syncPermissionRules(opts) {
211
217
  const tombKeyOf = (t) => JSON.stringify([t.behavior, t.rule, t.scope.kind, t.scope.kind === "project" ? t.scope.root : t.scope.kind === "session" ? t.scope.sessionId : null, dotKeyOf(t.deletedBy)]);
212
218
  const preTombs = new Set(current.tombstones.map(tombKeyOf));
213
219
  const newTombstones = landedRaw.tombstones.filter((t) => !preTombs.has(tombKeyOf(t))).length;
214
- const landedReport = res.sync;
220
+ const landedReport = reading.sync;
221
+ if (reading.reportUnreadable !== undefined) {
222
+ warnings.push(`${reading.reportUnreadable} — the join landed, but what it dropped or quarantined cannot be reported from this round`);
223
+ }
215
224
  for (const d of landedReport?.droppedInbound ?? []) {
216
225
  if (!dropped.some((x) => x.dot.actor === d.dot.actor && x.dot.counter === d.dot.counter && x.reason === d.reason))
217
226
  dropped.push(d);
@@ -36,6 +36,7 @@ import type { MemoryEngine } from "../memory-engine/engine.js";
36
36
  import type { ResolvedSkillListingBudget } from "../skill-listing-budget.js";
37
37
  import type { HarvestReport, MemorySessionHandle } from "../memory-engine/types.js";
38
38
  import type { SessionPermissionRules } from "../session-policy-store.js";
39
+ import type { SafeNotifier } from "../safe-notify.js";
39
40
  import type { RecoveredOrphan } from "../session-reconcile.js";
40
41
  import type { SessionStore, StoredSession } from "../session.js";
41
42
  import type { TaskOutcome } from "../task-outcome.js";
@@ -2063,6 +2064,49 @@ export interface RunInternals {
2063
2064
  * threads it too) — a resumed leg's restore re-fires the observation with the settled root.
2064
2065
  */
2065
2066
  onWorkspaceResolved?: (workspace: ResolvedWorkspace) => void;
2067
+ /**
2068
+ * A TRUSTED run-scoped WIRING-MANIFEST sink, filled ONLY by a core caller and NEVER a {@link
2069
+ * TaskSpec} field — the posture of {@link onActivity} and {@link onWorkspaceResolved} beside it.
2070
+ * Handed the very object this leg's `wiring_manifest` frame carries, at the moment that frame is
2071
+ * pushed: ONCE per prepared leg, after prepare and before the leg's first model or tool
2072
+ * interaction. Rides `internals` on FRESH and RESUMED legs alike, because the mint sits in the
2073
+ * run body both entries share.
2074
+ *
2075
+ * WHY a seat, when the frame already rides the stream: a COMPOSITION entry (the verify gate, the
2076
+ * cascade) drains its inner legs' queues ITSELF, so the host holds no TaskStream for any of them
2077
+ * and an inner leg's manifest dies inside the gate — the hole {@link onStatusEvent} was opened
2078
+ * for, one frame family over. A direct `runTask` caller needs neither seat: its own stream
2079
+ * carries both frames.
2080
+ *
2081
+ * `leg` is the LEG IDENTITY, and the engine mints NONE of it. It is present only where a
2082
+ * composition labelled the leg it dispatched: the verify entries hand each leg a per-dispatch
2083
+ * copy of this object whose sink closes over that leg's identity, so a consumer can tell the
2084
+ * implementation leg from round 2's verifier without inferring it from arrival order (the
2085
+ * ordering is all the sibling sinks have — see the attribution paragraph on `verifyCompleted`).
2086
+ * ABSENT therefore means "nobody labelled this leg", never "implementation": a plain `runTask`,
2087
+ * a workflow agent call and a cascade rung all reach this sink with no identity, and reading one
2088
+ * in would be the engine fabricating a fact it does not hold. NOT the same axis as the manifest's
2089
+ * OWN `leg` field ({@link WiringManifest.leg}, `root` / `child` / `resume`), which is a fact about
2090
+ * how the ENGINE reached this run; this one is a fact about the composition that dispatched it, and
2091
+ * a resumed fix leg carries both at once — `manifest.leg.kind === "resume"` and `leg.kind === "fix"`
2092
+ * are both true of it. `round` is 1-based over the gate's verification rounds: a `verifier` leg
2093
+ * carries the one it verifies, a `fix` leg the one whose findings it answers, and the entry
2094
+ * implementation leg carries 0 — before the first. The axis is relative to the gate INVOCATION,
2095
+ * not to the session's history: a resume entry's work leg is THAT invocation's implementation leg
2096
+ * (`impl`, round 0) even when the checkpoint it resumes was minted inside a previous invocation's
2097
+ * fix round, because the invocation's own round counter (`VerificationResult.rounds`) restarts with
2098
+ * it — one axis for both fields, rather than a leg that reads "fix round 1" inside a result whose
2099
+ * rounds begin at 1 again.
2100
+ *
2101
+ * Observe-only and failure-isolated through the SAME per-run notifier as {@link onActivity} (one
2102
+ * isolation scope for every sink on this object, so a broken sink is counted and disclosed once,
2103
+ * not once per site family): a throw — or an `async` sink returning a rejecting promise — is
2104
+ * contained and never faults the leg whose manifest it was handed.
2105
+ */
2106
+ onWiringManifest?: (manifest: WiringManifest, leg?: {
2107
+ readonly kind: "impl" | "verifier" | "fix";
2108
+ readonly round: number;
2109
+ }) => void;
2066
2110
  }
2067
2111
  /**
2068
2112
  * The payload of {@link RunInternals.onWorkspaceResolved}: the working root this task actually
@@ -2541,6 +2585,10 @@ export interface HarnessHandlersDeps {
2541
2585
  runnerHooks: {
2542
2586
  onError: RunnerDeps["onError"];
2543
2587
  };
2588
+ /** The run's ONE isolation scope for the {@link RunInternals} observation sinks, minted by the
2589
+ * identity-wiring lane (which calls the first of them) and shared with these handlers, so the
2590
+ * "first failure per site" disclosure is one account for the whole run rather than one per lane. */
2591
+ internalsNotifier: SafeNotifier;
2544
2592
  }
2545
2593
  /**
2546
2594
  * The Runner as the modules it drives and the compositions over it see it — the delegation tools
@@ -119,7 +119,9 @@ export function resumeContinuation(resume, mark) {
119
119
  }
120
120
  const decided = resume.outcome.decision === "allow"
121
121
  ? "was APPROVED and has now been executed — its result is in the tool results above"
122
- : `was DENIED${resume.outcome.reason ? `: ${delimitUntrusted("reviewer note", resume.outcome.reason, REVIEWER_NOTE_MAX_BODY)}` : ""}`;
122
+ : resume.outcome.hostDecision.decidedBy === "policy"
123
+ ? "was DENIED by this deployment's own policy, not by a person — the refusal and its reason are in the tool results above"
124
+ : `was DENIED${resume.outcome.reason ? `: ${delimitUntrusted("reviewer note", resume.outcome.reason, REVIEWER_NOTE_MAX_BODY)}` : ""}`;
123
125
  return formatHookFeedback(`The pending tool call "${pendingAction.toolName}" ${decided}. ` +
124
126
  `This is a RESUMED task — every step before this point is already done and its results are in the ` +
125
127
  `conversation above. Do NOT restart the task or re-run any tool you already ran; continue from this ` +
@@ -168,9 +168,10 @@ export declare function judgeInheritedClassifier<A extends InheritedAsk>(opts: {
168
168
  }): Promise<InheritedClassifierJudgment<A>>;
169
169
  /** The resolver's answer as the settlement reads it. */
170
170
  export type FallbackResolution = Pick<ResolvedAsk, "action" | "settlement" | "approverUnavailable">;
171
- /** A deny nobody made, at a wrapper arm: no approver wired, or a blanket `onAsk:"allow"` refused as
172
- * no approver. `approver_unavailable` is NOT headless here — it floats to the main gate, which owns
173
- * that arm (a park, or the fail-closed refusal). */
171
+ /** A deny nobody JUDGED, at a wrapper arm — read off the one table over the settlement words
172
+ * ({@link SETTLEMENT_NOBODY_JUDGED}) rather than listed here, so a word added to the set answers this
173
+ * question by getting a row and not by falling out of a disjunction. `approver_unavailable` is NOT
174
+ * headless here — it floats to the main gate, which owns that arm (a park, or the fail-closed refusal). */
174
175
  export declare function headlessDenyAtFold(r: FallbackResolution): boolean;
175
176
  /** The re-check's reading: the approved-edit helper has NO park route (an approved edit is
176
177
  * re-adjudicated in place), so an approver that reports unavailable is as headless as none at all. */
@@ -1,6 +1,7 @@
1
1
  import { classifierParseFailureDenyMessage, classifierUnavailableDenyMessage, createAutoModeDenialTracker, denialLimitFallbackMessage, denialLimitSentence, unarmedWindow } from "../auto-mode.js";
2
2
  import { sanitizeAutoModeArmingRecipe, tightenDenialLimit } from "../auto-mode-arming.js";
3
3
  import { attestClassifierDenyCause, attestClassifierRound } from "../tool-policy.js";
4
+ import { SETTLEMENT_NOBODY_JUDGED } from "../gate-outcome.js";
4
5
  import { deliverEngineNotice } from "../types.js";
5
6
  import { inlineUntrusted } from "../untrusted-text.js";
6
7
  import { askOriginOf, classifierMayAnswer, probeMandatedAsk } from "../ask-origin.js";
@@ -109,7 +110,7 @@ export async function judgeInheritedClassifier(opts) {
109
110
  return { kind: "resolve", ask: minted, fallback, mintedHere: true, origin: askOriginOf(minted, INHERITED_STATION_FACTS) };
110
111
  }
111
112
  export function headlessDenyAtFold(r) {
112
- return r.settlement?.kind === "no_approver" || r.settlement?.kind === "blanket_allow_refused";
113
+ return r.settlement !== undefined && SETTLEMENT_NOBODY_JUDGED[r.settlement.kind];
113
114
  }
114
115
  export function headlessDenyAtRecheck(r) {
115
116
  return headlessDenyAtFold(r) || r.settlement?.kind === "approver_unavailable" || r.approverUnavailable === true;
@@ -41,7 +41,7 @@ import { type HookEnvCapabilities, type Hooks } from "../hooks.js";
41
41
  import type { LockedPreflight } from "../locked-config.js";
42
42
  import type { OwnOrgAdmissionVerdict } from "../memory-admission.js";
43
43
  import type { DelegationContentSafety } from "../memory-engine/delegation-provenance.js";
44
- import type { SessionPermissionRules } from "../session-policy-store.js";
44
+ import { type SessionPermissionRules } from "../session-policy-store.js";
45
45
  import type { SessionStore } from "../session.js";
46
46
  import { type OnAsk } from "../tool-policy.js";
47
47
  import type { ApproverPosture, RunnerDeps, RuntimeCaps, ShellGateDoctrine, TaskEvent, TaskSpec, ToolExecuteContext } from "../types.js";
@@ -2,6 +2,7 @@ import { forkGovernanceDenial, resolveDelegationEntryCaps } from "../../agents/s
2
2
  import { isLiveQuestionFace } from "../ask-question.js";
3
3
  import { parentSpecSeatOf } from "../parent-spec-seat.js";
4
4
  import { createPreToolUseConstraintPolicy } from "../hooks.js";
5
+ import { continuesLineage } from "../session-policy-store.js";
5
6
  import { StubExecutionEnv } from "../stub-env.js";
6
7
  import { askApproverIdentity, durableMandateSourceOf } from "../tool-policy.js";
7
8
  import { attachRebuiltDenialTrackers, createDenialLimitStop } from "./denial-limit-arms.js";
@@ -21,7 +22,7 @@ export function prepareInheritedGate(input) {
21
22
  return [...seedRules];
22
23
  const merged = seedRules.map((s) => {
23
24
  const live = liveRules.find((l) => l.sessionId === s.sessionId && l.principal === s.principal);
24
- const sameLineage = live === undefined || live.gen === undefined || s.gen === undefined || live.gen === s.gen;
25
+ const sameLineage = live === undefined || continuesLineage(live, s);
25
26
  return live !== undefined && sameLineage && live.rev >= s.rev ? live : s;
26
27
  });
27
28
  const extras = liveRules.filter((l) => !seedRules.some((s) => s.sessionId === l.sessionId && s.principal === l.principal));
@@ -176,7 +176,9 @@ export async function resumeApply(input) {
176
176
  const facts = plainPolicyOutcome.hostDecision;
177
177
  const settlement = facts.decidedBy === "sla_timeout"
178
178
  ? { kind: "park_sla_expired", who: { party: "host", ...(facts.approver !== undefined ? { approver: facts.approver } : {}) }, when: Date.now() }
179
- : settledByPerson(plainPolicyOutcome.decision === "allow" ? "human_allowed" : "human_refused", facts.approver, plainPolicyOutcome.decision === "deny" ? plainPolicyOutcome.reason : undefined);
179
+ : facts.decidedBy === "policy"
180
+ ? { kind: "policy_refused", who: { party: "none" }, when: Date.now() }
181
+ : settledByPerson(plainPolicyOutcome.decision === "allow" ? "human_allowed" : "human_refused", facts.approver, plainPolicyOutcome.decision === "deny" ? plainPolicyOutcome.reason : undefined);
180
182
  plainPolicyOutcome = { ...plainPolicyOutcome, gateOutcome: mintGateOutcome({ ...(plainPolicyOutcome.decision === "deny" ? { deniedBy: "ask_resolution" } : {}), settled: { settlement, origin: parkedOrigin } }) };
181
183
  }
182
184
  const outcomeForStore = plainPolicyOutcome ?? plainReviewOutcome ?? plainParkOutcome;
@@ -5,7 +5,7 @@ import { sanitizeUntrustedText } from "../untrusted-text.js";
5
5
  import { assertOutcomeText, describeSuppliedValue, pendingContentAskCallId, sameWinner } from "./decide-continuation.js";
6
6
  const REFUSED_DECIDE_KEYS = [
7
7
  ["gateOutcome", "the settlement record is minted by core from the host's decision facts and the row's origin, never supplied"],
8
- ["settledBy", 'a retired key — the host supplies decision FACTS as hostDecision{decidedBy: "person" | "sla_timeout", approver?} and core mints the settlement word from them'],
8
+ ["settledBy", 'a retired key — the host supplies decision FACTS as hostDecision{decidedBy: "person" | "sla_timeout" | "policy", approver?} and core mints the settlement word from them'],
9
9
  ["approver", "a retired key — the attribution rides the facts it belongs to, as hostDecision.approver"],
10
10
  ];
11
11
  export function resumePolicyOutcome(input) {
@@ -49,14 +49,14 @@ export function resumePolicyOutcome(input) {
49
49
  }
50
50
  const hostDecisionRaw = decide.hostDecision;
51
51
  if (typeof hostDecisionRaw !== "object" || hostDecisionRaw === null) {
52
- throw new CheckpointError("checkpoint.invalid_outcome", `resume carries no hostDecision — a policy_ask decide names who decided it ({ decidedBy: "person" | "sla_timeout", approver? }); refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
52
+ throw new CheckpointError("checkpoint.invalid_outcome", `resume carries no hostDecision — a policy_ask decide names who decided it ({ decidedBy: "person" | "sla_timeout" | "policy", approver? }); refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
53
53
  }
54
54
  const decidedBy = hostDecisionRaw.decidedBy;
55
- if (decidedBy !== "person" && decidedBy !== "sla_timeout") {
56
- throw new CheckpointError("checkpoint.invalid_outcome", `resume hostDecision.decidedBy "${describeSuppliedValue(decidedBy)}" is outside the closed set — it is exactly "person" or "sla_timeout"; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
55
+ if (decidedBy !== "person" && decidedBy !== "sla_timeout" && decidedBy !== "policy") {
56
+ throw new CheckpointError("checkpoint.invalid_outcome", `resume hostDecision.decidedBy "${describeSuppliedValue(decidedBy)}" is outside the closed set — it is exactly "person", "sla_timeout" or "policy"; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
57
57
  }
58
- if (decidedBy === "sla_timeout" && decision === "allow") {
59
- throw new CheckpointError("checkpoint.invalid_outcome", `resume carries decision "allow" decided by "sla_timeout" — an elapsed SLA window cannot be what approved an action that EXECUTES; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
58
+ if (decidedBy !== "person" && decision === "allow") {
59
+ throw new CheckpointError("checkpoint.invalid_outcome", `resume carries decision "allow" decided by "${decidedBy}" — only a person approves an action that EXECUTES, and that word names a decision nobody made; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" });
60
60
  }
61
61
  const attribution = screenApproverAttribution(hostDecisionRaw.approver);
62
62
  if (attribution.defect !== undefined) {
@@ -1,4 +1,4 @@
1
- import { createSafeNotifier, observeThenableRejection } from "../safe-notify.js";
1
+ import { observeThenableRejection } from "../safe-notify.js";
2
2
  import { forwardSafely } from "./forward-sink.js";
3
3
  import { emitTrace } from "../trace.js";
4
4
  import { primaryActivityArg } from "../arg-summary.js";
@@ -12,10 +12,7 @@ import { hasVerifiableStructureSignal } from "./grounding-signal.js";
12
12
  import { batchResponseDigest, toolEndBodyFrom } from "./tool-end-body.js";
13
13
  export function createHarnessHandlers(input) {
14
14
  const { prepared, stats, rs, deps } = input;
15
- const { spec, queue, internals, ident, parentToolCallId, subagentName, pushContent, emitCommitted, startedToolCallIds, toolStartAt, toolProgress, writeFamilyOf, toolLabels, postToolBatchHook, batchArgs } = deps;
16
- const internalsNotifier = createSafeNotifier({
17
- onError: (f) => console.warn(`[sema-core] ${f.site}: run-internals activity sink threw (contained; further failures counted, not re-disclosed): ${f.error.message}`),
18
- });
15
+ const { spec, queue, internals, ident, parentToolCallId, subagentName, pushContent, emitCommitted, startedToolCallIds, toolStartAt, toolProgress, writeFamilyOf, toolLabels, postToolBatchHook, batchArgs, internalsNotifier } = deps;
19
16
  let lastWorkspaceCwd = prepared.cwdRef?.current;
20
17
  const announceWorkspaceMove = () => {
21
18
  const cwdNow = prepared.cwdRef?.current;
@@ -1,4 +1,5 @@
1
1
  import type { PeerInboundChainRef } from "../../agents/peer-admission.js";
2
+ import { type SafeNotifier } from "../safe-notify.js";
2
3
  import { type NotificationInjectionOutcome, type PendingSessionNotifications, type SystemInjectionPriority, type TaskNotificationPayload } from "../task-notification.js";
3
4
  import type { PushQueue } from "../push-queue.js";
4
5
  import type { TaskEvent, TaskSpec } from "../types.js";
@@ -75,6 +76,11 @@ export interface RunIdentityWiringResult {
75
76
  followUp: number;
76
77
  } | undefined;
77
78
  };
79
+ /** The run's ONE isolation scope for the {@link RunInternals} observation sinks: minted here because
80
+ * the manifest sink below is the run's first call into one, and handed on to the harness-handlers lane
81
+ * so its activity beats are contained by the SAME account — bounded disclosure is per notifier, so a
82
+ * second scope would report one broken sink twice and count it in two places. */
83
+ internalsNotifier: SafeNotifier;
78
84
  /** Did the run END with a person's steer / follow-up still pending (queued, or drained at the final boundary and
79
85
  * never served)? A READ FACE over this lane's own `let`, written by the same undrained-inputs sink on BOTH its arms
80
86
  * (park or not): the terminal-adoption lane folds it into the answer-settled verdict — a "stop" answer the person had
@@ -1,5 +1,5 @@
1
1
  import { uuidv7 } from "../../internal/harness.js";
2
- import { createSafeNotifier } from "../safe-notify.js";
2
+ import { createSafeNotifier, observeThenableRejection } from "../safe-notify.js";
3
3
  import { discloseDroppedPending, renderTaskNotificationXml, taskNotificationDedupKey } from "../task-notification.js";
4
4
  import { deliverDelegationLifecycle, deliverEngineNotice, undrainedUserInputNotices } from "../types.js";
5
5
  export function runIdentityWiring(input) {
@@ -14,7 +14,12 @@ export function runIdentityWiring(input) {
14
14
  const parentToolCallId = internals?.parentToolCallId;
15
15
  const ident = () => parentToolCallId !== undefined ? { eventId: uuidv7(), parentToolCallId, sourceTaskId: runSourceTaskId } : { eventId: uuidv7() };
16
16
  notificationLane.ident = ident;
17
- queue.push({ type: "wiring_manifest", manifest: prepared.wiringManifest, ...ident() });
17
+ const internalsNotifier = createSafeNotifier({
18
+ onError: (f) => console.warn(`[sema-core] ${f.site}: run-internals sink threw (contained; further failures counted, not re-disclosed): ${f.error.message}`),
19
+ });
20
+ const wiringManifest = prepared.wiringManifest;
21
+ queue.push({ type: "wiring_manifest", manifest: wiringManifest, ...ident() });
22
+ internalsNotifier.notify(() => observeThenableRejection(internals?.onWiringManifest?.(wiringManifest), internalsNotifier, "runtask.onWiringManifest"), "runtask.onWiringManifest");
18
23
  prepared.toolRosterDeltas.subscribe((delta) => queue.push({ type: "tool_roster_delta", delta, ...ident() }));
19
24
  prepared.toolDisclosure?.subscribe((snapshot) => queue.push({ type: "tool_disclosure", ...snapshot, ...ident() }));
20
25
  const delegationLifecycleNotifier = createSafeNotifier({
@@ -90,5 +95,5 @@ export function runIdentityWiring(input) {
90
95
  const loopLatch = { ended: false, userInterrupted: false, userHalted: false };
91
96
  onReady({ harness: prepared.harness, abortController: prepared.abortController, loop: loopLatch, reminderMark: prepared.reminderMark, sessionId: prepared.sessionId, runId: prepared.runId, hookTimeoutMs: prepared.hookTimeoutMs, hookIdentity: prepared.hookIdentity });
92
97
  const stats = { turns: 0, tokens: 0, toolCalls: 0, promptTokens: 0, totalInputTokens: 0, cachedTokens: 0, cacheWriteTokens: 0, cacheWriteTokensLong: 0, outputTokens: 0, costMicroUsd: 0 };
93
- return { runSourceTaskId, parentToolCallId, ident, emitDelegationLifecycle, loopLatch, stats, undrainedUserAtEnd: { get current() { return undrainedUserAtEnd; } }, userInputLostAtEnd: { get current() { return userInputLostAtEnd; } } };
98
+ return { runSourceTaskId, parentToolCallId, ident, emitDelegationLifecycle, loopLatch, stats, internalsNotifier, undrainedUserAtEnd: { get current() { return undrainedUserAtEnd; } }, userInputLostAtEnd: { get current() { return userInputLostAtEnd; } } };
94
99
  }
@@ -3,6 +3,7 @@ import { redactThenCut } from "../../agents/subagent-steps.js";
3
3
  import { ASK_USER_QUESTION_TOOL_NAME } from "../ask-question.js";
4
4
  import { CheckpointError, readPendingSteerQueue, realApprovalOrgFact, remainingBudgetMicroUsd } from "../checkpoint-store.js";
5
5
  import { normalizePersistedRuleHit } from "../gate-lanes.js";
6
+ import { decidersNote } from "../gate-outcome.js";
6
7
  import { formatHookFeedback, hookSeatExpiredError, runHookSeat } from "../hooks.js";
7
8
  import { buildHumanInputEvent, projectHumanInput } from "../human-input-projection.js";
8
9
  import { ORG_ADJUDICATION_TIMEOUT_MS, settleOrgVerdictWithin } from "../permission-rule-org.js";
@@ -11,7 +12,7 @@ import { computeCostMicroUsd } from "../pricing.js";
11
12
  import { composeApprovalPostureDirective } from "../auto-mode-directive.js";
12
13
  import { mintSystemReminder, statedReminderBodiesOf } from "../reminder-mint.js";
13
14
  import { hasDestroy } from "../remote-env.js";
14
- import { advertisedWritableRootsSeat, refuseOutOfContractDecision } from "../tool-policy.js";
15
+ import { advertisedWritableRootsSeat, policyRefusalMessage, refuseOutOfContractDecision } from "../tool-policy.js";
15
16
  import { callFaceSeat, judgeParkedToolIdentity, rosterEntryNamed, toolCallFaceOf } from "../tool-roster.js";
16
17
  import { deliverEngineNotice } from "../types.js";
17
18
  import { delimitUntrusted, inlineUntrusted, REVIEWER_NOTE_MAX_BODY, sanitizeUntrustedText } from "../untrusted-text.js";
@@ -566,10 +567,15 @@ async function resolvePendingCall(runner, prepared, resume, emit, emitCommitted,
566
567
  ...(outcome.gateOutcome?.settlement !== undefined && outcome.gateOutcome.origin !== undefined ? { settled: { settlement: outcome.gateOutcome.settlement, origin: outcome.gateOutcome.origin } } : {}),
567
568
  });
568
569
  if (outcome.decision === "deny") {
569
- const defaultDenial = outcome.gateOutcome?.settlement?.kind === "park_sla_expired"
570
+ const settlementKind = outcome.gateOutcome?.settlement?.kind;
571
+ const defaultDenial = settlementKind === "park_sla_expired"
570
572
  ? `No one answered the approval request for the pending tool call "${pendingAction.toolName}" — the approval window elapsed with no answer, so it was not executed.`
571
573
  : `The pending tool call "${pendingAction.toolName}" was denied by an approver.`;
572
- const reason = outcome.reason ? delimitUntrusted("reviewer note", outcome.reason, REVIEWER_NOTE_MAX_BODY) : defaultDenial;
574
+ const reason = settlementKind === "policy_refused"
575
+ ? policyRefusalMessage(pendingAction.toolName, undefined, decidersNote(outcome.reason))
576
+ : outcome.reason
577
+ ? delimitUntrusted("reviewer note", outcome.reason, REVIEWER_NOTE_MAX_BODY)
578
+ : defaultDenial;
573
579
  await settleEnd(true, { content: formatHookFeedback(reason, prepared.reminderMark) });
574
580
  const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, formatHookFeedback(reason, prepared.reminderMark), true));
575
581
  emitCommitted(eid, "toolResult", pendingAction.toolCallId);
@@ -414,7 +414,7 @@ export class Runner {
414
414
  runner: this.depsSeat, self: this, sessions: this.sessions, sessionReadStates: this.sessionReadStates, pendingSessionNotifications: this.pendingSessionNotifications,
415
415
  prepareTask,
416
416
  next: ({ prepared, injectTaskNotification, deliveredAtTurnOpen, peerInboundChainRef, modelCatalog, notificationLane, unsubscribeTaskNotifications }) => {
417
- const { runSourceTaskId, parentToolCallId, ident, emitDelegationLifecycle, loopLatch, stats, undrainedUserAtEnd, userInputLostAtEnd } = runIdentityWiring({
417
+ const { runSourceTaskId, parentToolCallId, ident, emitDelegationLifecycle, loopLatch, stats, internalsNotifier, undrainedUserAtEnd, userInputLostAtEnd } = runIdentityWiring({
418
418
  spec, queue, prepared, internals, taskIdRef, onReady, manualCompactRef, notifyRef, captureOptOutRef, mcpRedialRef, injectTaskNotification, deliveredAtTurnOpen, peerInboundChainRef,
419
419
  notificationLane, pendingSessionNotifications: this.pendingSessionNotifications, runner: this.depsSeat,
420
420
  });
@@ -442,7 +442,7 @@ export class Runner {
442
442
  deps: {
443
443
  spec, queue, internals, ident, parentToolCallId, subagentName,
444
444
  pushContent, emitCommitted, startedToolCallIds, toolStartAt, toolProgress, writeFamilyOf,
445
- toolLabels, postToolBatchHook, batchArgs,
445
+ toolLabels, postToolBatchHook, batchArgs, internalsNotifier,
446
446
  runnerHooks: { onError: (e, info) => this.deps.onError?.(e, info) },
447
447
  },
448
448
  });
@@ -1,7 +1,7 @@
1
1
  import type { ExecutionEnv } from "../../internal/harness-types.js";
2
2
  import type { ToolEffect } from "../types.js";
3
3
  import { type NamedToolPolicy } from "../tool-policy.js";
4
- import type { SessionPermissionRules, SessionPolicyStore, StoredSessionRules } from "../session-policy-store.js";
4
+ import { type SessionPermissionRules, type SessionPolicyStore, type StoredSessionRules } from "../session-policy-store.js";
5
5
  /** Tool names whose write target is the `file_path`/`path` arg ({@link fileArgPath}).
6
6
  *
7
7
  * REF-B3 — the ONE definition of that narrower set. Production consumer today:
@@ -1,5 +1,6 @@
1
1
  import { canonicalizeTarget, isWinFormPath, pathFamilyOf, writeTargetPath } from "../../tools/fs/safety.js";
2
2
  import { createCoarseCommandNamePolicy, namespacedCoveringEntries, namespacedCoveringHit } from "../tool-policy.js";
3
+ import { continuesLineage } from "../session-policy-store.js";
3
4
  import { thrownText } from "../thrown-value.js";
4
5
  import { ruleDenialSentence } from "../rule-denial-sentence.js";
5
6
  import { DEFAULT_GATE_AWAIT_TIMEOUT_MS, gateWaitFailureOf, withGateTimeout } from "../gate-bounded-await.js";
@@ -61,13 +62,11 @@ export function createLiveSessionRules(store, floor, hooks) {
61
62
  return enforced;
62
63
  }
63
64
  if (enforced.rev > 0) {
64
- if (live.gen !== undefined && enforced.gen !== undefined) {
65
- if (live.gen !== enforced.gen) {
66
- announce(`session ${JSON.stringify(floor.sessionId)} has a rules row of a new lineage (generation ${live.gen}, rev ${live.rev}), which does not continue the row this leg enforces (generation ${enforced.gen})`);
67
- return enforced;
68
- }
65
+ if (!continuesLineage(live, enforced)) {
66
+ announce(`session ${JSON.stringify(floor.sessionId)} has a rules row of a new lineage (generation ${live.gen}, rev ${live.rev}), which does not continue the row this leg enforces (generation ${enforced.gen})`);
67
+ return enforced;
69
68
  }
70
- else if (vanished) {
69
+ if (vanished && live.gen === undefined) {
71
70
  announce(`session ${JSON.stringify(floor.sessionId)} has a new rules row at rev ${live.rev} after the row this leg enforced was deleted, and its backend carries no lineage to continue it by`);
72
71
  return enforced;
73
72
  }
@@ -89,7 +88,7 @@ export function createLiveSessionRules(store, floor, hooks) {
89
88
  if (handedRead !== undefined) {
90
89
  handed.delete(toolCallId);
91
90
  const claimed = await handedRead;
92
- if (claimed.rev >= enforced.rev)
91
+ if (continuesLineage(claimed, enforced) && claimed.rev >= enforced.rev)
93
92
  return claimed;
94
93
  }
95
94
  const p = read(signal);