@sema-agent/core 5.34.0 → 5.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.35.0 — 2026-08-15
4
+
5
+ No BREAKING changes. One deliberate fail-closed tighten and one loosening-direction fix are called
6
+ out under Changed; everything else is additive.
7
+
8
+ ### Added
9
+
10
+ - `TaskStream.steer` takes an optional `inputId` dedup key (additive; absent ⇒ byte-identical
11
+ behavior). The live steer leg becomes idempotent the way the parked leg already was: an identical
12
+ payload under a seen key is a no-op (nothing injected, no second ledger frame), a DIFFERENT payload
13
+ under a seen key is refused loudly (`steering.duplicate_input_id`, the parked leg's code), and the
14
+ caller's key rides the `human_input` frame verbatim (previously a fresh uuid was always minted, so
15
+ a replayed injection was indistinguishable from an idempotent hit in the receipt). Validation runs
16
+ before liveness (empty/overlong/reserved-sentinel values are refused, never silently treated as
17
+ "no key"). Dedup scope is the live stream's lifetime — narrower than the parked queue's persisted
18
+ window; the contract states the asymmetry rather than pretending parity.
19
+ - `AskRequest.ruleEvidence` (design/252 G-2): an ask carries the rule-provenance evidence behind it —
20
+ the org snapshot `revision`, the org rule text, and the matched personal rule's add dots — each
21
+ member present as a value or as a NAMED absence (`not_wired` / `not_adjudicated` / `unavailable` /
22
+ `no_match` / `not_reported`), so "nothing governed this", "the source was unreadable" and "the lane
23
+ was skipped" stop sharing one wire shape.
24
+ - Settlement approver attribution (design/252 G-7): both settlement lanes accept an optional
25
+ `approver` identity, screened (length/control-characters/lone-surrogates, refused not truncated)
26
+ and TRANSCRIBED — never adjudicated — onto the settled call's `tool_end` frame beside `settledBy`.
27
+ - `probeParkRoundTrip` + `durableParkGapOf` (design/252 G-6): a park-lane liveness self-check (file a
28
+ synthetic checkpoint, read it back, enumerate, fence) plus the static two-halves reading that turns
29
+ a missing-session resume symptom into a named absent seat. `ParkSelfCheckResult.casLossObserved`
30
+ (additive) marks the one green arm a CAS-mis-reporting backend would pass through.
31
+
32
+ ### Changed
33
+
34
+ - Fail-closed tighten: a `persistedRules.admits()` answer that is neither a string nor an object no
35
+ longer clears an ask. Previously any non-`undefined` answer cleared it — "`null` admits this call"
36
+ was an allow built on an unreadable answer.
37
+ - The org-verdict normalizer reads ANY non-array carrier (class instances and callables included —
38
+ the structural contract admits them) with own-data field reads; refusing the carrier outright
39
+ could fold a structurally valid org DENY into `unavailable`, i.e. from un-approvable into a
40
+ real-approval ask a person could clear. Inherited members still never authorize; revoked proxies
41
+ are contained; foreign array methods (an overridden or species-substituted `.map`/`.filter`) no
42
+ longer run anywhere on the evidence path.
43
+ - The park probe sweeps behind a late-committing timed-out `put` on either settle, and the
44
+ approver screen refuses oversized values before materializing a code-point array.
45
+ - Dev-surface: the live-gate recipe and examples switch the reference live model to
46
+ `deepseek-v4-flash` (operator directive; prior cache/behavior figures quoted against
47
+ `deepseek-v4-pro` re-anchor on next measurement).
48
+
3
49
  ## 5.34.0 — 2026-08-15
4
50
 
5
51
  No BREAKING changes. Two behavior-surface additions (both additive seats), one packaging addition,
@@ -577,6 +577,31 @@ export type ResumeOutcome = {
577
577
  * with the backends and belongs to its own change, not to this one.
578
578
  */
579
579
  settledBy?: import("./tool-policy.js").ApprovalSettledBy;
580
+ /**
581
+ * design/252 G-7 — WHOSE settlement: the identifier the approval channel reports for the party
582
+ * that ended this wait, transcribed onto the resolved call's `tool_end` frame beside
583
+ * {@link settledBy}.
584
+ *
585
+ * Core AUTHENTICATES NOTHING here. On the durable lane the identity work is entirely the
586
+ * deployment's — its approval card, its signature check, its operator console decide who this
587
+ * was; a library holding no identity surface has nothing to check the claim against and does not
588
+ * pretend otherwise. What core owns is that the claim is well-formed: a plain string, bounded,
589
+ * free of control characters, refused pre-CAS (`checkpoint.invalid_outcome`) otherwise, like
590
+ * every other out-of-domain field on this arm. Refused rather than dropped, because a settlement
591
+ * that silently lost its attributed party reads as unattributed — the misreport this seat exists
592
+ * to end.
593
+ *
594
+ * SCOPE — like {@link settledBy}, this rides the SETTLEMENT and not the persisted winner:
595
+ * `winnerFromOutcome` records what an `env_failed` replay must reproduce exactly, and who
596
+ * reported a settlement is not part of that authorization. A replay states its own attribution
597
+ * and is not held to the first one; binding it into the durable row would also make a re-supply
598
+ * that merely omits the field fail closed on the recovery path. Carrying it into the row is a
599
+ * store-shape change owned jointly with the backends and belongs to its own change.
600
+ *
601
+ * OPTIONAL and additive: absent means the caller named nobody, which is not the same as "nobody
602
+ * approved this" and must not be read as one.
603
+ */
604
+ approver?: string;
580
605
  }
581
606
  /** design/74: continue a resource-suspended run with the next slice's allowance. NO decision payload and
582
607
  * NO budget figure — the allowance is computed from {@link Checkpoint.resourceLedger}, so money never
@@ -1631,12 +1656,14 @@ export declare class CheckpointError extends Error {
1631
1656
  * action: re-review), or `"answer"` (the content-ask answer is missing, or was attached to a
1632
1657
  * decision that cannot carry one: re-decide with a well-formed answer — ruled 2026-08-04), or
1633
1658
  * `"settledBy"` (the settlement source is outside the three-word vocabulary: re-decide naming one
1634
- * of them, or omit the field). The throw
1659
+ * of them, or omit the field), or — design/252 G-7 — `"approver"` (the attribution is not a
1660
+ * well-formed identifier: re-decide with a plain, bounded, control-character-free string, or omit
1661
+ * it). The throw
1635
1662
  * sites always knew which arm fired; this carries that fact instead of dropping it (ruled
1636
1663
  * 2026-08-03, cross-repo HITL discussion). Additive and optional — `code` remains the only REQUIRED
1637
1664
  * discriminant. */
1638
1665
  readonly detail?: {
1639
- field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy";
1666
+ field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy" | "approver";
1640
1667
  /** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
1641
1668
  * deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
1642
1669
  * damaged/caller-declared and no worker ever will" — blanket-retriable and gate-shape heuristics were
@@ -1752,12 +1779,14 @@ export declare class CheckpointError extends Error {
1752
1779
  * action: re-review), or `"answer"` (the content-ask answer is missing, or was attached to a
1753
1780
  * decision that cannot carry one: re-decide with a well-formed answer — ruled 2026-08-04), or
1754
1781
  * `"settledBy"` (the settlement source is outside the three-word vocabulary: re-decide naming one
1755
- * of them, or omit the field). The throw
1782
+ * of them, or omit the field), or — design/252 G-7 — `"approver"` (the attribution is not a
1783
+ * well-formed identifier: re-decide with a plain, bounded, control-character-free string, or omit
1784
+ * it). The throw
1756
1785
  * sites always knew which arm fired; this carries that fact instead of dropping it (ruled
1757
1786
  * 2026-08-03, cross-repo HITL discussion). Additive and optional — `code` remains the only REQUIRED
1758
1787
  * discriminant. */
1759
1788
  detail?: {
1760
- field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy";
1789
+ field?: "boundCallId" | "boundInputHash" | "answer" | "settledBy" | "approver";
1761
1790
  /** WHICH pre-CAS refusal arm fired, where one `code` covers several (requested 2026-08-10: a
1762
1791
  * deployment retry policy needs to tell "a newer worker can redeem this row" from "this row is
1763
1792
  * damaged/caller-declared and no worker ever will" — blanket-retriable and gate-shape heuristics were
@@ -481,6 +481,14 @@ export interface ToolGateResult {
481
481
  * and no post-tool hook can write.
482
482
  */
483
483
  settledBy?: import("./tool-policy.js").ApprovalSettledBy;
484
+ /**
485
+ * design/252 G-7 — WHOSE settlement that was: the identifier the approval channel reported, carried
486
+ * out verbatim beside {@link settledBy}. This layer authenticates nothing and compares nothing; the
487
+ * value was screened for shape at the seam that received it (`screenApproverAttribution`) and is a
488
+ * transcription from there on. Present only where the caller settled something AND reported an
489
+ * identity — absent everywhere else, and absence never means "nobody": it means nobody said.
490
+ */
491
+ approver?: string;
484
492
  /** Rewritten args to execute with (re-validated by the loop); omitted when nothing rewrote. */
485
493
  updatedInput?: unknown;
486
494
  /**
@@ -569,11 +577,90 @@ export type OrgGateVerdict = {
569
577
  behavior: "deny" | "ask";
570
578
  rule: string;
571
579
  };
580
+ /** design/252 G-2 (additive): the REVISION of the snapshot this answer was read off — the
581
+ * published-policy version an auditor reconciles the resulting decision against. Present on the
582
+ * available arm whether or not a rule spoke: "revision 41 published nothing about this call" is
583
+ * itself the audit answer, and it is a different fact from "we never looked". ABSENT ⇒ the lane
584
+ * did not report one (a foreign overlay, or a revision that failed its own domain screen — the
585
+ * evidence records that as `"not_reported"` rather than inventing a number). Never adjudication
586
+ * input: the verdict above is the whole decision, and dropping this field would leave every
587
+ * outcome byte-identical. */
588
+ revision?: number;
572
589
  } | {
573
590
  status: "unavailable";
574
591
  /** The overlay's own disclosure lines (engine/deployment-authored), for the operator channel. */
575
592
  disclosures: readonly string[];
576
593
  };
594
+ /**
595
+ * design/252 G-2 — the richer answer a persisted-rule lane may give instead of the bare rule text (see
596
+ * `ToolGateInput.persistedRules.admits`): the same canonical text, plus the matched rule's add dots.
597
+ *
598
+ * Why the dots and not an id: a persisted allow rule HAS no scalar id — it is a (text, scope) group
599
+ * whose identity is the set of add dots that redeemed it, and two concurrently approved records for one
600
+ * text each carry their own. The set is the identity; a joined string would not be one.
601
+ */
602
+ export interface PersistedRuleHit {
603
+ /** The canonical text of the admitting rule — the same value the bare-string shape returns. */
604
+ readonly rule: string;
605
+ /** The matched rule's LIVE add dots (tombstoned adds already filtered by the store's read). Omitted
606
+ * by a lane that has the text but not the dots; the evidence then reports `"not_reported"`. */
607
+ readonly dots?: readonly import("./permission-rule-model.js").RuleDot[];
608
+ }
609
+ /**
610
+ * design/252 G-2 — the answer a lane gives when it could not READ its store.
611
+ *
612
+ * Its DECISION meaning is identical to "no rule" — a loosening seam that cannot read its source fails
613
+ * toward asking, and this shape changes no verdict anywhere. What it changes is the RECORD: without it,
614
+ * an unreadable rule store and a store that genuinely holds no matching rule are the same answer
615
+ * (`undefined`), and the ask's evidence has to report one of them as the other. The absence vocabulary
616
+ * distinguishes "we looked and found nothing" (`"no_match"`) from "we could not look" (`"unavailable"`),
617
+ * and this is how a lane says which one it means.
618
+ */
619
+ export interface PersistedRuleUnreadable {
620
+ readonly unreadable: true;
621
+ }
622
+ /** Every shape a lane may answer with. A bare string stays valid and unchanged. */
623
+ export type PersistedRuleAnswer = string | PersistedRuleHit | PersistedRuleUnreadable | undefined;
624
+ /**
625
+ * design/252 review r3 — read a foreign {@link OrgGateVerdict} the way the personal-rule answer is read:
626
+ * OWN DATA properties only, never the prototype chain, never an accessor.
627
+ *
628
+ * Unlike the personal-rule normalizer below, this one accepts ANY non-array object as the carrier —
629
+ * not only a plain record. The two seams tighten in opposite directions when a shape is refused: the
630
+ * personal lane is a LOOSENING seam, so refusing a class instance degrades toward asking; this answer
631
+ * carries the org's DENY, and folding a structurally valid `{status:"available", verdict:{behavior:
632
+ * "deny"}}` class instance into `unavailable` would LOOSEN it — from a deny nobody can approve into a
633
+ * real-approval ask a person can clear. A class instance's fields are its own data properties, so the
634
+ * own-data read below already gives the full pollution guarantee (inherited members never authorize);
635
+ * the prototype test added nothing here but the downgrade.
636
+ *
637
+ * The two seams are the same class of trust boundary and were not being read the same way. What that
638
+ * cost here is worse than on the personal lane, because this answer is GOVERNANCE:
639
+ * · an inherited `revision` (a polluted `Object.prototype`) stamped a fabricated snapshot version onto
640
+ * a human's approval request — a WRONG record, which is worse than an absent one;
641
+ * · an inherited `verdict` invented an org rule, and with it an org ask or deny the lane never gave;
642
+ * · an answer that is not a record at all read as "available, nothing to say", i.e. the governance
643
+ * fail-open the availability contract exists to prevent.
644
+ * Anything this function cannot read as a well-formed answer becomes `unavailable` — the fail-CLOSED
645
+ * word, never the empty one.
646
+ */
647
+ export declare function normalizeOrgGateVerdict(answer: unknown, unreadable: string): OrgGateVerdict;
648
+ /**
649
+ * Normalize the accepted {@link ToolGateInput.persistedRules} answers into one reading.
650
+ *
651
+ * `{}` = a clean negative (no rule admits this call). `{ unreadable: true }` = the lane could not read
652
+ * its source. `{ hit }` = a match. An answer outside every accepted shape — a number, `null`, an object
653
+ * with no `rule` — is read as a clean negative rather than a match: this is a LOOSENING seam, so an
654
+ * answer nobody can name degrades toward asking, never toward an allow built on it.
655
+ *
656
+ * The hit's dots are COPIED, not aliased. The array travels onto an ask that may sit in front of a
657
+ * person for a long time; a lane that retains and mutates its own array would otherwise change what the
658
+ * approver is looking at, and what an audit later reads, after the evidence was stamped.
659
+ */
660
+ export declare function normalizePersistedRuleHit(hit: PersistedRuleAnswer): {
661
+ hit?: PersistedRuleHit;
662
+ unreadable?: true;
663
+ };
577
664
  /** Inputs to the two-phase tool gate. `adjudicate`/`resolveAsk` are pre-bound to the task abort signal. */
578
665
  export interface ToolGateInput {
579
666
  /** RB-473 batch 2b — bounded disclosure for a HOST callback that throws inside this gate call's
@@ -794,9 +881,17 @@ export interface ToolGateInput {
794
881
  * bullets above are what keep integrity/hook/mandated asks out of that set.
795
882
  */
796
883
  persistedRules?: {
797
- /** The canonical text of the rule that admits this call, or `undefined`. Must not throw: a store that
798
- * cannot be read answers "no rule" and discloses on its own channel (fail-closed = more asks). */
799
- admits: (req: ToolCallRequest) => Promise<string | undefined>;
884
+ /** The rule that admits this call, or `undefined`. Must not throw: a store that cannot be read
885
+ * answers "no rule" and discloses on its own channel (fail-closed = more asks).
886
+ *
887
+ * TWO ACCEPTED SHAPES (design/252 G-2, additive — a lane returning the bare string is unchanged
888
+ * and stays supported): the canonical rule TEXT, or a {@link PersistedRuleHit} carrying that same
889
+ * text plus the matched rule's add DOTS. The dots are the rule's durable identity, and the text
890
+ * is not one (it reaches a card sanitized and capped); a lane that can name them lets the
891
+ * shadowed-ask evidence say WHICH stored rule matched rather than merely what it looked like.
892
+ * Widening the RETURN is why this is additive for implementers: every existing lane satisfies
893
+ * the union unchanged, and the gate reads both through one normalizer. */
894
+ admits: (req: ToolCallRequest) => Promise<PersistedRuleAnswer>;
800
895
  /** Observation sink for an ask a rule resolved — the allow-side disclosure. Never affects the outcome. */
801
896
  onResolved?: (info: {
802
897
  toolName: string;
@@ -1,4 +1,4 @@
1
- import { decisionText, refuseOutOfContractDecision } from "./tool-policy.js";
1
+ import { decisionText, describeThrown, refuseOutOfContractDecision } from "./tool-policy.js";
2
2
  import { brandPolicyAskClass } from "./ask-class.js";
3
3
  import { inlineUntrusted } from "./untrusted-text.js";
4
4
  import { PROBE_REASON_MAX, normalizeProbeCause } from "./checkpoint-store.js";
@@ -89,6 +89,99 @@ function withParkFailureCause(reason, parkFailed) {
89
89
  `(${inlineUntrusted(parkFailed, PARK_FAILURE_CAUSE_MAX)}), so the refusal above is what the fallback had ` +
90
90
  `left to say, not the reason the call stopped.`);
91
91
  }
92
+ function isPlainOwnRecord(x) {
93
+ try {
94
+ if (x === null || typeof x !== "object" || Array.isArray(x))
95
+ return false;
96
+ const p = Object.getPrototypeOf(x);
97
+ return p === Object.prototype || p === null;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ function ownDataValue(obj, key) {
104
+ try {
105
+ const d = Object.getOwnPropertyDescriptor(obj, key);
106
+ return d && "value" in d ? d.value : undefined;
107
+ }
108
+ catch {
109
+ return undefined;
110
+ }
111
+ }
112
+ export function normalizeOrgGateVerdict(answer, unreadable) {
113
+ const clean = (v) => Object.assign(Object.create(null), v);
114
+ const readable = (x) => {
115
+ try {
116
+ return x !== null && (typeof x === "object" || typeof x === "function") && !Array.isArray(x);
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ };
122
+ if (!readable(answer))
123
+ return clean({ status: "unavailable", disclosures: [unreadable] });
124
+ const status = ownDataValue(answer, "status");
125
+ if (status === "unavailable") {
126
+ const disclosures = ownDataValue(answer, "disclosures");
127
+ let lines = [];
128
+ if (Array.isArray(disclosures)) {
129
+ try {
130
+ for (let i = 0; i < disclosures.length; i++) {
131
+ const d = disclosures[i];
132
+ if (typeof d === "string")
133
+ lines.push(d);
134
+ }
135
+ }
136
+ catch {
137
+ lines = [];
138
+ }
139
+ }
140
+ return clean({ status: "unavailable", disclosures: lines.length > 0 ? lines : [unreadable] });
141
+ }
142
+ if (status !== "available")
143
+ return clean({ status: "unavailable", disclosures: [unreadable] });
144
+ const revision = ownDataValue(answer, "revision");
145
+ const revisionCell = typeof revision === "number" && Number.isFinite(revision) ? { revision } : {};
146
+ const verdict = ownDataValue(answer, "verdict");
147
+ if (verdict === undefined)
148
+ return clean({ status: "available", ...revisionCell });
149
+ if (!readable(verdict))
150
+ return clean({ status: "unavailable", disclosures: [unreadable] });
151
+ const behavior = ownDataValue(verdict, "behavior");
152
+ const rule = ownDataValue(verdict, "rule");
153
+ if ((behavior !== "deny" && behavior !== "ask") || typeof rule !== "string")
154
+ return clean({ status: "unavailable", disclosures: [unreadable] });
155
+ return clean({ status: "available", verdict: clean({ behavior: behavior, rule }), ...revisionCell });
156
+ }
157
+ export function normalizePersistedRuleHit(hit) {
158
+ if (typeof hit === "string")
159
+ return { hit: { rule: hit } };
160
+ if (!isPlainOwnRecord(hit))
161
+ return {};
162
+ if (ownDataValue(hit, "unreadable") === true)
163
+ return { unreadable: true };
164
+ const rule = ownDataValue(hit, "rule");
165
+ if (typeof rule !== "string")
166
+ return {};
167
+ const dots = ownDataValue(hit, "dots");
168
+ let copied;
169
+ try {
170
+ if (Array.isArray(dots)) {
171
+ const walked = [];
172
+ for (let i = 0; i < dots.length; i++) {
173
+ const d = dots[i];
174
+ walked.push(isPlainOwnRecord(d) ? { actor: ownDataValue(d, "actor"), counter: ownDataValue(d, "counter") } : { actor: undefined, counter: undefined });
175
+ }
176
+ copied = walked;
177
+ }
178
+ }
179
+ catch {
180
+ copied = undefined;
181
+ }
182
+ const usable = copied?.every((d) => typeof d.actor === "string" && typeof d.counter === "number" && Number.isFinite(d.counter)) === true && copied.length > 0;
183
+ return { hit: { rule, ...(usable ? { dots: copied } : {}) } };
184
+ }
92
185
  function preToolUseCrashReason(subject, err) {
93
186
  const raw = err instanceof Error ? err.message.trim() || err.name : String(err);
94
187
  const cause = inlineUntrusted(raw, 200);
@@ -291,22 +384,39 @@ export async function runToolGate(input) {
291
384
  let orgRealApprovalRequired = false;
292
385
  let orgAskOrigin;
293
386
  let orgTightenCount = 0;
387
+ let resolvedApprover;
388
+ let orgEvidence = (() => {
389
+ const w = input.orgRules === undefined ? "not_wired" : "not_adjudicated";
390
+ return { revisionAbsent: w, ruleAbsent: w };
391
+ })();
392
+ const mintRuleEvidence = (personal) => ({
393
+ ...(orgEvidence.revision !== undefined ? { orgRevision: orgEvidence.revision } : { orgRevisionAbsent: orgEvidence.revisionAbsent ?? "not_reported" }),
394
+ ...(orgEvidence.rule !== undefined ? { orgRule: orgEvidence.rule } : { orgRuleAbsent: orgEvidence.ruleAbsent ?? "not_reported" }),
395
+ ...(personal.dots !== undefined ? { personalRuleDots: personal.dots } : { personalRuleDotsAbsent: personal.dotsAbsent ?? "not_reported" }),
396
+ });
294
397
  const applyOrgLayer = async (current, args) => {
295
398
  if (input.orgRules === undefined)
296
399
  return current;
297
400
  if (toolName === ASK_USER_QUESTION_TOOL_NAME && input.orgRules.contentAskToolMounted === true)
298
401
  return current;
299
402
  let decided = current;
300
- const answer = await settleOrgVerdictWithin(input.orgRules
301
- .adjudicate({ toolName, args, toolCallId })
302
- .catch((err) => ({
403
+ let pending;
404
+ try {
405
+ pending = Promise.resolve(input.orgRules.adjudicate({ toolName, args, toolCallId }));
406
+ }
407
+ catch (err) {
408
+ pending = Promise.resolve({ status: "unavailable", disclosures: [`the org adjudication face threw before answering: ${describeThrown(err)}`] });
409
+ }
410
+ const raw = await settleOrgVerdictWithin(pending.catch((err) => ({
303
411
  status: "unavailable",
304
- disclosures: [`the org adjudication face threw: ${err instanceof Error ? err.message : String(err)}`],
412
+ disclosures: [`the org adjudication face threw: ${describeThrown(err)}`],
305
413
  })), {
306
414
  status: "unavailable",
307
415
  disclosures: [`the org adjudication face did not answer within ${ORG_ADJUDICATION_TIMEOUT_MS}ms (or the task ended first)`],
308
416
  }, { ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}), timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
417
+ const answer = normalizeOrgGateVerdict(raw, "the org adjudication face answered something this gate cannot read as a verdict — treated as unreadable governance, never as an empty one");
309
418
  if (answer.status === "unavailable") {
419
+ orgEvidence = { revisionAbsent: "unavailable", ruleAbsent: "unavailable" };
310
420
  orgRealApprovalRequired = true;
311
421
  orgAskOrigin = "unavailable";
312
422
  await notifier.notifyAsync(() => input.orgRules?.onUnavailable?.({ toolName, toolCallId, message: answer.disclosures.join("; ") }), "toolGate.orgSnapshotUnavailable");
@@ -327,9 +437,13 @@ export async function runToolGate(input) {
327
437
  }
328
438
  return decided;
329
439
  }
330
- if (answer.verdict === undefined)
440
+ const revisionCell = answer.revision !== undefined ? { revision: answer.revision } : { revisionAbsent: "not_reported" };
441
+ if (answer.verdict === undefined) {
442
+ orgEvidence = { ...revisionCell, ruleAbsent: "no_match" };
331
443
  return decided;
444
+ }
332
445
  const { behavior, rule } = answer.verdict;
446
+ orgEvidence = { ...revisionCell, rule };
333
447
  if (behavior === "deny") {
334
448
  orgTightenCount += 1;
335
449
  denySource = "org";
@@ -357,6 +471,7 @@ export async function runToolGate(input) {
357
471
  req.args = policyRewrite;
358
472
  }
359
473
  const persistedRuleMandate = persistedRuleMandateOf(input);
474
+ let personalEvidence = { dotsAbsent: input.persistedRules === undefined ? "not_wired" : "not_adjudicated" };
360
475
  if (input.persistedRules &&
361
476
  !orgRealApprovalRequired &&
362
477
  decision.action === "ask" &&
@@ -364,7 +479,19 @@ export async function runToolGate(input) {
364
479
  decision.decisionReason !== "hook" &&
365
480
  req.toolName !== ASK_USER_QUESTION_TOOL_NAME &&
366
481
  input.isMarkedUnresolvable?.(input.event.toolCallId) !== true) {
367
- const hit = await input.persistedRules.admits(req).catch(() => undefined);
482
+ const answer = await (async () => {
483
+ let pendingHit;
484
+ try {
485
+ pendingHit = Promise.resolve(input.persistedRules?.admits(req));
486
+ }
487
+ catch {
488
+ return { unreadable: true };
489
+ }
490
+ return await pendingHit.then(normalizePersistedRuleHit).catch(() => ({ unreadable: true }));
491
+ })();
492
+ const hitEntry = answer.hit;
493
+ const hit = hitEntry?.rule;
494
+ personalEvidence = { dotsAbsent: answer.unreadable === true ? "unavailable" : "no_match" };
368
495
  if (hit !== undefined && persistedRuleMandate === undefined && decision.matchedAskRule === undefined) {
369
496
  decision = {
370
497
  action: "allow",
@@ -375,6 +502,7 @@ export async function runToolGate(input) {
375
502
  await notifier.notifyAsync(() => input.persistedRules?.onResolved?.({ toolName: req.toolName, toolCallId, rule: hit }), "toolGate.persistedRuleResolved");
376
503
  }
377
504
  else if (hit !== undefined) {
505
+ personalEvidence = hitEntry?.dots !== undefined && hitEntry.dots.length > 0 ? { dots: hitEntry.dots } : { dotsAbsent: "not_reported" };
378
506
  const shownRule = inlineUntrusted(hit, 200);
379
507
  const mandateNoun = persistedRuleMandate === "operator_always"
380
508
  ? "this deployment mandates per-call confirmation for shell commands (shellGate: always)"
@@ -388,6 +516,9 @@ export async function runToolGate(input) {
388
516
  };
389
517
  }
390
518
  }
519
+ if (decision.action === "ask") {
520
+ decision = { ...decision, ruleEvidence: mintRuleEvidence(personalEvidence) };
521
+ }
391
522
  if (input.autoMode &&
392
523
  !orgRealApprovalRequired &&
393
524
  decision.action === "ask" &&
@@ -492,6 +623,8 @@ export async function runToolGate(input) {
492
623
  if (decision.action === "ask") {
493
624
  const askBeforeResolve = decision;
494
625
  const resolved = await resolveAsk(decision, req);
626
+ if (resolved.action !== "ask" && resolved.approver !== undefined)
627
+ resolvedApprover = resolved.approver;
495
628
  decision = resolved;
496
629
  if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
497
630
  const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve), askBeforeResolve.action === "ask" ? askBeforeResolve.persistedRuleShadowed : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.decisionReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeReason : undefined, askBeforeResolve.action === "ask" ? askBeforeResolve.probeCause : undefined);
@@ -580,7 +713,8 @@ export async function runToolGate(input) {
580
713
  }
581
714
  if (recheck.updatedInput !== undefined)
582
715
  editArgs = recheck.updatedInput;
583
- const rr = await resolveAsk(recheck, { toolName, args: editArgs, toolCallId });
716
+ const rr = await resolveAsk({ ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) }, { toolName, args: editArgs, toolCallId });
717
+ resolvedApprover = rr.action !== "ask" ? rr.approver : undefined;
584
718
  if (rr.action !== "allow") {
585
719
  editDenied = rr;
586
720
  if (!orgRaisedThisRound)
@@ -610,10 +744,12 @@ export async function runToolGate(input) {
610
744
  await notifier.notifyAsync(() => input.permissionDenied?.({ toolName, input: cloneObserverInput(currentInput), toolCallId, reason: denyReason, source: denySource }), "toolGate.permissionDenied");
611
745
  }
612
746
  const denySettledBy = decision.settledBy;
747
+ const denyApprover = denySettledBy !== undefined ? resolvedApprover : undefined;
613
748
  return {
614
749
  block: true,
615
750
  reason: formatHookFeedback(denyReason),
616
751
  ...(denySettledBy !== undefined ? { settledBy: denySettledBy } : {}),
752
+ ...(denyApprover !== undefined ? { approver: denyApprover } : {}),
617
753
  preToolContext,
618
754
  };
619
755
  }
@@ -623,9 +759,11 @@ export async function runToolGate(input) {
623
759
  currentInput = rw;
624
760
  }
625
761
  const allowSettledBy = decision.action === "allow" ? decision.settledBy : undefined;
762
+ const allowApprover = decision.action === "allow" && allowSettledBy !== undefined ? resolvedApprover : undefined;
626
763
  return {
627
764
  updatedInput: currentInput === event.input ? undefined : currentInput,
628
765
  ...(allowSettledBy !== undefined ? { settledBy: allowSettledBy } : {}),
766
+ ...(allowApprover !== undefined ? { approver: allowApprover } : {}),
629
767
  preToolContext,
630
768
  };
631
769
  }