@sema-agent/core 7.36.0 → 7.37.1

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 (56) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/dist/brain/anthropic.js +7 -14
  3. package/dist/brain/open-responses.js +6 -13
  4. package/dist/brain/openai.js +7 -14
  5. package/dist/brain/stream-shared.d.ts +21 -0
  6. package/dist/brain/stream-shared.js +13 -0
  7. package/dist/core/ask-origin.d.ts +35 -5
  8. package/dist/core/ask-origin.js +23 -4
  9. package/dist/core/checkpoint-store.d.ts +13 -3
  10. package/dist/core/decision-receipt.d.ts +21 -0
  11. package/dist/core/decision-receipt.js +63 -0
  12. package/dist/core/engine-notice.d.ts +17 -5
  13. package/dist/core/gate-fold.js +2 -1
  14. package/dist/core/gate-lanes.js +8 -13
  15. package/dist/core/gate-outcome.d.ts +7 -5
  16. package/dist/core/gate-outcome.js +5 -1
  17. package/dist/core/governance-codes.d.ts +1 -1
  18. package/dist/core/governance-codes.js +2 -0
  19. package/dist/core/hooks.d.ts +11 -2
  20. package/dist/core/hooks.js +3 -1
  21. package/dist/core/park-selfcheck.js +2 -0
  22. package/dist/core/runner/denial-limit-arms.d.ts +28 -18
  23. package/dist/core/runner/denial-limit-arms.js +11 -7
  24. package/dist/core/runner/inherited-ask-grants.d.ts +32 -31
  25. package/dist/core/runner/inherited-ask-grants.js +5 -0
  26. package/dist/core/runner/prepare-inherited-gate.js +1 -1
  27. package/dist/core/runner/prepare-park-ask.d.ts +2 -2
  28. package/dist/core/runner/prepare-park-ask.js +1 -5
  29. package/dist/core/runner/prepare-policy-chain.d.ts +3 -8
  30. package/dist/core/runner/prepare-policy-chain.js +43 -25
  31. package/dist/core/runner/run-identity-wiring.d.ts +1 -1
  32. package/dist/core/runner/run-identity-wiring.js +2 -0
  33. package/dist/core/runner/tool-turn-repair-seat.d.ts +8 -0
  34. package/dist/core/runner/tool-turn-repair-seat.js +28 -0
  35. package/dist/core/session-reconcile.d.ts +35 -0
  36. package/dist/core/session-reconcile.js +31 -20
  37. package/dist/core/side-query.js +4 -2
  38. package/dist/core/store-contracts/checkpoint-store-contract.js +18 -0
  39. package/dist/core/tool-name-readings.d.ts +4 -0
  40. package/dist/core/tool-name-readings.js +1 -0
  41. package/dist/core/tool-policy.d.ts +74 -84
  42. package/dist/core/tool-policy.js +71 -36
  43. package/dist/engine/harness/agent-harness.d.ts +7 -0
  44. package/dist/engine/harness/agent-harness.js +14 -2
  45. package/dist/engine/llm/index.d.ts +1 -0
  46. package/dist/engine/llm/index.js +1 -0
  47. package/dist/engine/llm/tool-turns.d.ts +20 -0
  48. package/dist/engine/llm/tool-turns.js +91 -0
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/internal/llm.d.ts +2 -2
  52. package/dist/internal/llm.js +1 -1
  53. package/package.json +1 -1
  54. package/test/export-surface.snapshot.json +7 -1
  55. package/wire-changes/core@7.37.0.json +405 -0
  56. package/wire-changes/core@7.37.1.json +122 -0
@@ -0,0 +1,21 @@
1
+ import type { PermissionResult } from "./tool-policy.js";
2
+ /** The screen's one refusal — every arm's deny, so an out-of-contract decision is one code whichever arm caught it. */
3
+ export declare const refusedOutOfContract: (message: string) => PermissionResult;
4
+ /** One level of the receipt, the same for the decision and each record it nests. The carrier is an ORDINARY object — it and
5
+ * every object on its prototype chain are looked at for being a Proxy before any descriptor is asked for (a Proxy's traps are
6
+ * the caller's code; the look invokes none of them) — so the receipt runs none of the carrier's code: a contract member is
7
+ * absent or an own enumerable DATA property, its value taken off the descriptor (no read at all). Anything else refuses the
8
+ * carrier, the member named by `path`: an accessor (never invoked), and a member the prototype or a non-enumerable property
9
+ * supplies, unless the nearest descriptor holding it spells absence (an `undefined` data property). Descriptors are looked up
10
+ * as OWN entries of the descriptor map, so a polluted `Object.prototype` names no member. Members outside the contract are
11
+ * not looked at. */
12
+ /** The receipt's own answers carry a `kind` discriminant as an own property: narrowing never asks the prototype chain (`in`) for a
13
+ * word, so a polluted `Object.prototype` cannot answer for the receipt either. */
14
+ export type Received = {
15
+ kind: "held";
16
+ held: Record<string, unknown>;
17
+ } | {
18
+ kind: "refused";
19
+ refused: PermissionResult;
20
+ };
21
+ export declare function receiveContract(carrier: object, members: readonly string[], path: string): Received;
@@ -0,0 +1,63 @@
1
+ import { types } from "node:util";
2
+ import { thrownText } from "./thrown-value.js";
3
+ export const refusedOutOfContract = (message) => ({ action: "deny", message, decisionReason: "rule" });
4
+ const spellsAbsence = (d) => Object.hasOwn(d, "value") && d.value === undefined;
5
+ function ordinaryChain(carrier) {
6
+ const chain = [];
7
+ const seen = new Set();
8
+ try {
9
+ for (let o = carrier; o !== null; o = Object.getPrototypeOf(o)) {
10
+ if (types.isProxy(o))
11
+ return { kind: "not", why: o === carrier ? "is a Proxy" : "has a Proxy on its prototype chain" };
12
+ if (seen.has(o))
13
+ return { kind: "not", why: "has a cyclic prototype chain" };
14
+ seen.add(o);
15
+ chain.push(o);
16
+ }
17
+ }
18
+ catch (e) {
19
+ return { kind: "not", why: `threw while its prototype chain was looked at (${thrownText(e)})` };
20
+ }
21
+ return { kind: "chain", chain };
22
+ }
23
+ export function receiveContract(carrier, members, path) {
24
+ const what = path === "" ? "a permission decision" : `a permission decision's "${path.slice(0, -1)}" record`;
25
+ const ordinary = ordinaryChain(carrier);
26
+ if (ordinary.kind === "not") {
27
+ return { kind: "refused", refused: refusedOutOfContract(`${what} ${ordinary.why} — the engine reads a decision off an ordinary object's own property descriptors and runs none of the carrier's code; denied fail-closed`) };
28
+ }
29
+ let own;
30
+ try {
31
+ own = Object.getOwnPropertyDescriptors(carrier);
32
+ }
33
+ catch (e) {
34
+ return { kind: "refused", refused: refusedOutOfContract(`${what} threw while its property descriptors were looked at (${thrownText(e)}) — not an ordinary object; denied fail-closed`) };
35
+ }
36
+ const held = Object.create(null);
37
+ for (const m of members) {
38
+ const d = Object.hasOwn(own, m) ? own[m] : undefined;
39
+ if (d !== undefined && !Object.hasOwn(d, "value")) {
40
+ return { kind: "refused", refused: refusedOutOfContract(`${what}'s contract member "${path}${m}" is held by an accessor — contract members must be own data properties, not values supplied through an accessor; denied fail-closed`) };
41
+ }
42
+ if (d !== undefined && d.enumerable === true) {
43
+ held[m] = d.value;
44
+ continue;
45
+ }
46
+ let reached = d;
47
+ try {
48
+ for (let i = 1; reached === undefined && i < ordinary.chain.length; i++)
49
+ reached = Object.getOwnPropertyDescriptor(ordinary.chain[i], m);
50
+ }
51
+ catch (e) {
52
+ return { kind: "refused", refused: refusedOutOfContract(`${what} threw while "${path}${m}" was looked for on its prototype chain (${thrownText(e)}) — not an ordinary object; denied fail-closed`) };
53
+ }
54
+ if (reached !== undefined && !spellsAbsence(reached)) {
55
+ return {
56
+ kind: "refused",
57
+ refused: refusedOutOfContract(`${what} supplies "${path}${m}" other than as its own enumerable property (its prototype or a non-enumerable property) — ` +
58
+ `contract fields must be own properties, so every reader sees the one value the engine read; denied fail-closed`),
59
+ };
60
+ }
61
+ }
62
+ return { kind: "held", held };
63
+ }
@@ -258,6 +258,15 @@ export interface EngineNotice {
258
258
  * call fact. Audience `operator`; `detail: { toolCallId, rawLength, reason, sessionId, runId }` —
259
259
  * `toolCallId` the engine id, `reason` `"charset"` (a control byte, a space, a non-ASCII unit or `~`) or
260
260
  * `"length"` (admitted characters, over 128); never the raw bytes. Absent ⇒ no call of the run was folded.
261
+ * - `"context.tool_turn_repaired"` (#1224) — a provider request was built from a transcript that breaks the
262
+ * tool-turn law, and the request view restored it (`engine/llm/tool-turns.ts`; the transcript is never
263
+ * rewritten): a block after an assistant's tool calls moved ahead of them (`trailing_text`), a result recorded
264
+ * later moved up behind its call (`late_result`), a call with no result closed with the reconcile family's
265
+ * interrupted result (`missing_result`), a second result recorded for one call folded into the first
266
+ * (`duplicate_result`; an interrupted close beside a recorded result is left out, no recorded result is). Advisory:
267
+ * the run proceeds on the repaired request. Audience `operator`; `detail: { repairs: [{ form, toolCallIds }],
268
+ * sessionId, runId }`. ONCE PER RUN PER (form, calls) — the same broken turn is repaired on every request of
269
+ * the run and announced on the first. Absent ⇒ every request of the run already had the structure.
261
270
  * - `"config.read_face_deployment_clamped"` (#237) — a deployment-wide `readFace: "open"` is not
262
271
  * in force beside a read-only (verifier) mount: it clamps to "roots" without throwing
263
272
  * (stricter-wins; the clamp verdict stands, only its occurrence was undisclosed). Announced
@@ -337,7 +346,8 @@ export interface EngineNotice {
337
346
  * `permissionMode` (`autoModeRequested: true` ⇒ `auto`; a posture is dropped — it never authorized anything;
338
347
  * `shellGate` `"off"` / `"classify"` are dropped, `"always"` kept) and the next suspend writes the new member.
339
348
  * `detail: { migrated: { permissionMode?, droppedKeys }, sessionId, runId }`. Audience `operator`. A row the
340
- * migration cannot read refuses the resume instead (`resume.legacy_mode_seed`, the error-code row above).
349
+ * migration cannot read refuses the resume instead (`resume.legacy_mode_seed`, the error-code row above). The
350
+ * migration is kept through 7.37.x and removed in 7.38.0, which refuses a row still carrying these keys.
341
351
  * - `"config.durable_gate_unavailable"` — a durable-approval MANDATE is in force on a leg with NO
342
352
  * checkpoint store: the per-principal `forceDurableGate` entitlement, or the task's own
343
353
  * `durableApproval` opt-in with no live approver seat (`durableMandateSourceOf`, the predicate the
@@ -712,10 +722,12 @@ export interface EngineNotice {
712
722
  * `detail.settlement` names how: `"delivered"` (a re-judgment or an approval released it into
713
723
  * this session's model context), `"refused"` (the setting moved to refuse, or an approval met a
714
724
  * refusing policy), `"expired"` (the `dialogExpiry` deadline, an eviction when the 100-entry queue
715
- * was full, a cancelled review, or a graceful shutdown), `"denied"` (the review face), `"dropped"`
716
- * (the injection lane refused the released frame). `detail.reason` is the trigger
717
- * (`deadline | evicted | shutdown | rejudge | approved | denied | cancelled | inject_failed`); the
718
- * sender was sent the matching receipt whenever the record carried a reply address.
725
+ * was full, a cancelled review, a graceful shutdown, or a record another lease holder already settled),
726
+ * `"denied"` (the review face), `"dropped"` (the injection lane refused the released frame).
727
+ * `detail.reason` is the trigger (`PeerHeldSettleReason`: `deadline | evicted | shutdown | rejudge |
728
+ * approved | denied | cancelled | inject_failed | superseded`); the sender was sent the matching receipt
729
+ * whenever the record carried a reply address — except on `superseded`, where the lease holder that
730
+ * settled the record sent it.
719
731
  * `detail: { settlement, reason, seq, box, fromSession?, heldCount, sessionId, runId }`. Audience
720
732
  * `"user"`: it is this session's user who held (or was holding) the message.
721
733
  * - `"peer.idle_subscription"` (design/385 §5.2, slice 4) — a peer session asked to be told when
@@ -6,11 +6,12 @@ import { traceHookCrash } from "./gate-crash-trace.js";
6
6
  import { writeProtectionMandated, writeProtectionTightenDecision, writeProtectionUnresolved } from "./write-protect.js";
7
7
  import { createGateDeadline, awaitWriteProtectionJudgment, gateWaitFailureOf, withGateTimeout } from "./gate-bounded-await.js";
8
8
  import { thrownText } from "./thrown-value.js";
9
+ import { refuseOutOfContractDecision } from "./tool-policy.js";
9
10
  export async function runGateFold(pass) {
10
11
  const { input, toolName, toolCallId, callSignal, ledger, notifier, adjudicate } = pass;
11
12
  const boundedAwaitDeadlineMs = createGateDeadline(input, notifier);
12
13
  pass.req = { toolName, args: pass.currentInput, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
13
- pass.decision = adjudicate ? await (callSignal !== undefined ? adjudicate(pass.req, callSignal) : adjudicate(pass.req)) : { action: "allow" };
14
+ pass.decision = adjudicate ? refuseOutOfContractDecision(await (callSignal !== undefined ? adjudicate(pass.req, callSignal) : adjudicate(pass.req))) : { action: "allow" };
14
15
  {
15
16
  const refusedBy = ledger.fromDecision(pass.decision);
16
17
  if (refusedBy !== undefined)
@@ -1,4 +1,4 @@
1
- import { attestClassifierDenyCause, classifierDenyCauseOf, decisionText, describeThrown } from "./tool-policy.js";
1
+ import { attestClassifierDenyCause, classifierDenyCauseOf, decisionText, describeThrown, refuseOutOfContractDecision } from "./tool-policy.js";
2
2
  import { arbitrateByMode, classifyAsk, fileCommandOptionsSentence, mandateDisclosureOf, persistedRuleMandateOf, ruleClearanceOf, safetyAxisOf, stampAskOrigin } from "./ask-origin.js";
3
3
  import { classifierParseFailureDenyMessage, denialLimitFallbackMessage, unarmedWindow } from "./auto-mode.js";
4
4
  import { classifierUnavailableArm } from "./classifier-unavailable-arm.js";
@@ -181,9 +181,7 @@ export async function runGateLanes(pass) {
181
181
  ...(input.toolMarkSources !== undefined ? { toolMarkSources: input.toolMarkSources } : {}),
182
182
  ...(input.a2aIsolatePeers === true ? { a2aIsolatePeers: true } : {}),
183
183
  });
184
- const classifyStanding = (unstamped, facts) => {
185
- pass.askClassification = classifyAsk(unstamped, facts);
186
- };
184
+ const classifyStanding = (unstamped, facts) => (pass.askClassification = classifyAsk(unstamped, facts));
187
185
  let orgTightenCount = 0;
188
186
  let orgEvidence = (() => {
189
187
  const w = input.orgRules === undefined ? "not_wired" : "not_adjudicated";
@@ -355,8 +353,7 @@ export async function runGateLanes(pass) {
355
353
  const originFacts = askOriginFacts(orgAnswer.org, ruleCallUnreadableOf(laneAnswer));
356
354
  if (pass.decision.action === "ask") {
357
355
  const stamped = { ...pass.decision, ruleEvidence: mintRuleEvidence(personalEvidence), ...(laneCoverage !== undefined ? { segmentCoverage: laneCoverage } : {}) };
358
- classifyStanding(stamped, originFacts);
359
- pass.decision = stampAskOrigin(stamped, originFacts);
356
+ pass.decision = stampAskOrigin(stamped, originFacts, classifyStanding(stamped, originFacts));
360
357
  }
361
358
  if (allowLayerMayClear() && pass.decision.action === "ask" && ruleMayClear() && pass.probeBoundaryDeclined !== true && wireOf("lanes.readOnlyShellAllow", pass.req) === COMMAND_RULE_TOOL) {
362
359
  const commandParam = catalogRuleFaceOf(COMMAND_RULE_TOOL)?.primaryParams[0] ?? "command";
@@ -399,8 +396,7 @@ export async function runGateLanes(pass) {
399
396
  else if (verdict?.kind === "unavailable") {
400
397
  const arm = classifierUnavailableArm({ ask: pass.decision, req: pass.req, cause: verdict.cause, attribution: verdict.classifier, seat: input.autoMode.unavailable, aborted: classifySignal?.aborted === true });
401
398
  if (arm.kind === "asked") {
402
- classifyStanding(arm.ask, originFacts);
403
- pass.decision = stampAskOrigin(arm.ask, originFacts);
399
+ pass.decision = stampAskOrigin(arm.ask, originFacts, classifyStanding(arm.ask, originFacts));
404
400
  pass.classifierRound = undefined;
405
401
  }
406
402
  else
@@ -418,8 +414,7 @@ export async function runGateLanes(pass) {
418
414
  requiresRealApproval: true,
419
415
  denialLimitFallback: tracked.fallback,
420
416
  };
421
- classifyStanding(fallbackAsk, originFacts);
422
- pass.decision = stampAskOrigin(fallbackAsk, originFacts);
417
+ pass.decision = stampAskOrigin(fallbackAsk, originFacts, classifyStanding(fallbackAsk, originFacts));
423
418
  }
424
419
  else {
425
420
  const denied = {
@@ -517,6 +512,7 @@ export async function runGateLanes(pass) {
517
512
  ...(callSignal !== undefined ? { signal: callSignal } : {}),
518
513
  ...(d.action === "ask" && d.denialLimitFallback !== undefined ? { denialLimitFallback: unarmedWindow(d.denialLimitFallback) } : {}),
519
514
  ...(d.action === "ask" && d.origin !== undefined ? { origin: d.origin } : {}),
515
+ ...(d.action === "ask" && d.askClass !== undefined && d.classes !== undefined ? { askClass: d.askClass, classes: d.classes } : {}),
520
516
  ...(d.action === "ask" && d.mandated === true ? { mandated: true, ...(d.mandate !== undefined ? { mandate: d.mandate } : {}) } : {}),
521
517
  ...(d.action === "ask" && d.authoredBy === "deployment" ? { authoredBy: "deployment" } : {}),
522
518
  });
@@ -662,7 +658,7 @@ export async function runGateLanes(pass) {
662
658
  if (adjudicate) {
663
659
  try {
664
660
  const editReq = { toolName, args: editArgs, toolCallId, ...(pass.req.face !== undefined ? { face: pass.req.face } : {}) };
665
- recheck = await (callSignal !== undefined ? adjudicate(editReq, callSignal) : adjudicate(editReq));
661
+ recheck = refuseOutOfContractDecision(await (callSignal !== undefined ? adjudicate(editReq, callSignal) : adjudicate(editReq)));
666
662
  }
667
663
  catch (err) {
668
664
  recheck = {
@@ -720,8 +716,7 @@ export async function runGateLanes(pass) {
720
716
  const editAskSnapshot = { ...recheck, ruleEvidence: mintRuleEvidence({ dotsAbsent: "not_adjudicated" }) };
721
717
  markedThisRound = undefined;
722
718
  const editFacts = askOriginFacts(editOrg.org, editRuleCallUnreadable);
723
- classifyStanding(editAskSnapshot, editFacts);
724
- const editAskDecision = stampAskOrigin(editAskSnapshot, editFacts);
719
+ const editAskDecision = stampAskOrigin(editAskSnapshot, editFacts, classifyStanding(editAskSnapshot, editFacts));
725
720
  if (input.permissionMode !== undefined && pass.askClassification !== undefined) {
726
721
  const editVerdict = arbitrateByMode(input.permissionMode(), pass.askClassification, { ...(editAskDecision.origin !== undefined ? { origin: editAskDecision.origin } : {}), ...(editAskDecision.mandate !== undefined ? { mandate: editAskDecision.mandate } : {}) });
727
722
  if (editVerdict.kind === "released") {
@@ -7,7 +7,7 @@
7
7
  * policy composes: a policy says allow / ask / deny; this file says how the pass that consumed that
8
8
  * verdict ended.
9
9
  */
10
- import { type AskClass, type AskOrigin, type AssertAllKeysHandled, type PermissionMode, type PersistedRuleMandate } from "./ask-origin.js";
10
+ import { type AskClass, type AskOrigin, type AssertAllKeysHandled, type ModeReleaseReason, type PermissionMode, type PersistedRuleMandate } from "./ask-origin.js";
11
11
  import { type AutoModeClassifierRound } from "./auto-mode.js";
12
12
  /**
13
13
  * How the wait an ask was in ENDED — the closed set. One word per way a wait can end; the `who` on the
@@ -303,7 +303,8 @@ export type GateDisposition = {
303
303
  * A permission MODE released a surviving ask — the allowed disposition's account of the question it did not put
304
304
  * to anyone: the mode, the disclosed class word (`askClass`, the first member of the folded set in `ASK_CLASSES`
305
305
  * order — one of {@link RELEASABLE_ASK_CLASSES}), the whole set, and the origin / mandate words the card would
306
- * have shown. `reason: "rule_unreadable"` marks a release over a rule the call could not be read against.
306
+ * have shown. `reason` is a member of {@link import("./ask-origin.js").MODE_RELEASE_REASONS} (`isModeReleaseReason`):
307
+ * `"rule_unreadable"` marks a release over a rule the call could not be read against; absent, the release names none.
307
308
  */
308
309
  export interface ModeRelease {
309
310
  mode: PermissionMode;
@@ -311,7 +312,7 @@ export interface ModeRelease {
311
312
  classes: readonly AskClass[];
312
313
  origin?: AskOrigin;
313
314
  mandate?: PersistedRuleMandate;
314
- reason?: "rule_unreadable";
315
+ reason?: ModeReleaseReason;
315
316
  }
316
317
  /** The two modes that refuse on their own account (`deniedBy: "mode"`): `dontAsk` every non-authority ask,
317
318
  * `bypassPermissions` a write whose protection could not be judged. `plan` refuses nothing here — its questions ask. */
@@ -341,8 +342,9 @@ export interface ModeRefusal {
341
342
  * {@link import("./auto-mode.js").AUTO_MODE_FALLBACK_CAUSES} — a record whose `to` and `modelUsed`
342
343
  * disagree would let a reader answer "which model decided this call?" two ways;
343
344
  * I7 `disposition.released` present ⇒ `kind === "allowed"`, no settlement, `mode` is `bypassPermissions`,
344
- * `askClass` is a releasable class and every member of `classes` is an ask class — a mode releases nothing
345
- * a person answered and nothing the table calls immune;
345
+ * `askClass` is a releasable class, every member of `classes` is an ask class, `origin` / `reason`, when
346
+ * present, are members of their closed sets, and `reason` names a member of `classes` (the mint takes it from
347
+ * that set) — a mode releases nothing a person answered and nothing the table calls immune;
346
348
  * I8 `deniedBy === "mode"` ⇔ `mode` present (`bypassPermissions` | `dontAsk`) with `askClass` and `classes`.
347
349
  * Nothing here is read out of an ABSENCE: an ordinary allow with no ask is `{ disposition: { kind: "allowed" } }`,
348
350
  * a direct policy deny is `{ disposition: { kind: "denied", deniedBy: "policy" } }`, and neither carries a
@@ -1,4 +1,4 @@
1
- import { isAskClass, isAskOrigin, isPermissionMode, RELEASABLE_ASK_CLASSES } from "./ask-origin.js";
1
+ import { isAskClass, isAskOrigin, isModeReleaseReason, isPermissionMode, RELEASABLE_ASK_CLASSES } from "./ask-origin.js";
2
2
  import { isAutoModeFallbackCause } from "./auto-mode.js";
3
3
  export const SETTLEMENT_KINDS = [
4
4
  "human_allowed",
@@ -128,6 +128,10 @@ export function screenGateOutcome(g) {
128
128
  defects.push("I7: released.classes must be a non-empty list of ask classes");
129
129
  if (r.origin !== undefined && !isAskOrigin(r.origin))
130
130
  defects.push(`I7: released.origin "${String(r.origin)}" is outside the closed set`);
131
+ if (r.reason !== undefined && !isModeReleaseReason(r.reason))
132
+ defects.push(`I7: released.reason "${String(r.reason)}" is outside the closed set`);
133
+ else if (r.reason !== undefined && Array.isArray(r.classes) && !r.classes.includes(r.reason))
134
+ defects.push(`I7: released.reason "${r.reason}" is not a member of released.classes`);
131
135
  }
132
136
  if (d.kind === "denied") {
133
137
  const modeRefusal = d.deniedBy === "mode";
@@ -135,7 +135,7 @@ export type NoticeAudience = "user" | "operator";
135
135
  * the union's catalog ({@link EngineNotice.code}). The groups below say only which family a row joins,
136
136
  * so the catalog is not written a second time here and the two cannot drift.
137
137
  */
138
- export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.context_settings_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "config.secret_env_scrubbed", "config.permission_mode_unavailable", "permission.ask_released_by_mode", "permission.read_deny_unapplied", "permission.rule_store_unavailable", "resume.legacy_mode_seed_migrated", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "classifier.unavailable", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "route.response_model_mismatch", "route.tool_call_id_folded", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "approval.read_root_granted", "approval.read_root_grant_rejected", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
138
+ export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.context_settings_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "config.secret_env_scrubbed", "config.permission_mode_unavailable", "permission.ask_released_by_mode", "permission.read_deny_unapplied", "permission.rule_store_unavailable", "resume.legacy_mode_seed_migrated", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "classifier.unavailable", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "route.response_model_mismatch", "route.tool_call_id_folded", "context.tool_turn_repaired", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "approval.read_root_granted", "approval.read_root_grant_rejected", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
139
139
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
140
140
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
141
141
  * sink is a supported shape, and narrowing that field would break it. */
@@ -179,6 +179,7 @@ export const ENGINE_NOTICE_CODES = [
179
179
  "route.base_url_changed_key_unchanged",
180
180
  "route.response_model_mismatch",
181
181
  "route.tool_call_id_folded",
182
+ "context.tool_turn_repaired",
182
183
  "task.user_steer_undrained",
183
184
  "task.user_followup_undrained",
184
185
  "steering.parked_input_blocked",
@@ -271,6 +272,7 @@ const NOTICE_AUDIENCE_TABLE = {
271
272
  "route.base_url_changed_key_unchanged": "operator",
272
273
  "route.response_model_mismatch": "operator",
273
274
  "route.tool_call_id_folded": "operator",
275
+ "context.tool_turn_repaired": "operator",
274
276
  "skills.listing_truncated": "operator",
275
277
  "agents.listing_truncated": "operator",
276
278
  "tool_result.offload_put_failed": "operator",
@@ -1737,6 +1737,12 @@ export interface AskCarry {
1737
1737
  /** The surviving ask's engine-stamped origin word ({@link import("./ask-origin.js").AskOrigin}), persisted
1738
1738
  * on the row so a durable card is rendered by the same word the synchronous card is. */
1739
1739
  readonly origin?: import("./ask-origin.js").AskOrigin;
1740
+ /** The surviving ask's engine-stamped class ({@link import("./tool-policy.js").AskRequest.askClass}), persisted on the
1741
+ * row so a durable approval seat answers by the class the synchronous one reads. Present exactly when
1742
+ * {@link classes} is. */
1743
+ readonly askClass?: import("./ask-origin.js").AskClass;
1744
+ /** The whole class set beside it ({@link import("./tool-policy.js").AskRequest.classes}), in `ASK_CLASSES` order. */
1745
+ readonly classes?: readonly import("./ask-origin.js").AskClass[];
1740
1746
  /** The stamped mandate ({@link import("./tool-policy.js").AskRequest.mandated}) — the park site's
1741
1747
  * rule-offer facts read it, so a parked mandated ask offers no rule, like the live card. */
1742
1748
  readonly mandated?: true;
@@ -1747,11 +1753,14 @@ export interface AskCarry {
1747
1753
  }
1748
1754
  /** What a durable row persists of an {@link AskCarry}: the fallback member COPIED (the row never aliases
1749
1755
  * the gate's object; its window is already `0` — the gate's carry mint is the one writer of that
1750
- * number) and the origin word only when it is a member of the closed set (the carry is typed, but the
1751
- * row is the boundary a host reads back through — the same screen the summary applies). */
1756
+ * number), the origin word only when it is a member of the closed set, and the class pair only when it is
1757
+ * well-formed, as a fresh copy (the carry is typed, but the row is the boundary a host reads back through —
1758
+ * the same screen the summary applies to the word). */
1752
1759
  export declare function askCarryRowMembers(carry: AskCarry | undefined): {
1753
1760
  denialLimitFallback?: import("./auto-mode.js").DenialLimitFallback;
1754
1761
  origin?: import("./ask-origin.js").AskOrigin;
1762
+ askClass?: import("./ask-origin.js").AskClass;
1763
+ classes?: readonly import("./ask-origin.js").AskClass[];
1755
1764
  };
1756
1765
  /**
1757
1766
  * The design/37 **two-phase tool gate** — the single chokepoint that makes the load-bearing invariant
@@ -7,7 +7,7 @@ import { runGateLanes } from "./gate-lanes.js";
7
7
  export { normalizeOrgGateVerdict, normalizePersistedRuleHit } from "./gate-lanes.js";
8
8
  export { cloneObserverInput } from "./runner/gate-exit.js";
9
9
  import { brandPolicyAskClass } from "./ask-class.js";
10
- import { isAskOrigin } from "./ask-origin.js";
10
+ import { isAskOrigin, screenAskClassMembers } from "./ask-origin.js";
11
11
  import { inlineUntrusted } from "./untrusted-text.js";
12
12
  export { formatHookFeedback } from "./reminder-mint.js";
13
13
  import { createSafeNotifier } from "./safe-notify.js";
@@ -239,9 +239,11 @@ export function askCarryRowMembers(carry) {
239
239
  if (carry === undefined)
240
240
  return {};
241
241
  const origin = carry.origin;
242
+ const classed = screenAskClassMembers(carry.askClass, carry.classes);
242
243
  return {
243
244
  ...(carry.denialLimitFallback !== undefined ? { denialLimitFallback: { ...carry.denialLimitFallback } } : {}),
244
245
  ...(isAskOrigin(origin) ? { origin } : {}),
246
+ ...(classed !== undefined ? classed : {}),
245
247
  };
246
248
  }
247
249
  export async function runToolGate(input) {
@@ -77,6 +77,8 @@ function syntheticCheckpoint(scope) {
77
77
  mandated: true,
78
78
  denialLimitFallback: { consecutive: 3, total: 3, limit: "consecutive", autoDenyAfterMs: 0 },
79
79
  origin: "denial_limit_fallback",
80
+ askClass: "denial_limit_fallback",
81
+ classes: ["denial_limit_fallback"],
80
82
  batchToolCallIds: [`${scope}:call`],
81
83
  completedCallIds: [],
82
84
  },
@@ -3,7 +3,7 @@ import { type AutoModeArmingRecipe } from "../auto-mode-arming.js";
3
3
  import { type ClassifierUnavailableSeat } from "../classifier-unavailable-arm.js";
4
4
  import { type PermissionResult, type ResolvedAsk, type ToolCallRequest } from "../tool-policy.js";
5
5
  import { type EngineNotice } from "../types.js";
6
- import { type AskOrigin, type AskOriginFacts } from "../ask-origin.js";
6
+ import { type AskClass, type AskClassFacts, type AskClassification, type AskOrigin, type AskOriginFacts } from "../ask-origin.js";
7
7
  import type { Prepared, StopForDenialLimit } from "./contracts.js";
8
8
  /**
9
9
  * The classifier DENIAL-LIMIT arms of the tool gate's inherited (delegation) lane, extracted
@@ -98,34 +98,38 @@ export type InheritedAsk = Extract<PermissionResult, {
98
98
  * classifier runs inside the policy fold, BEFORE the live gate's org layer has spoken and before its
99
99
  * question-tool and marked-call lanes run, so they read as absent here and those asks meet their
100
100
  * exclusions downstream, where the facts exist. Excluded asks fall through to the frozen approver
101
- * chain, the delegation lane's original chain. The word is derived inside {@link judgeInheritedClassifier}
102
- * — once off the incoming ask for the exclusion and the unchanged-ask branches, once off the re-spoken
103
- * ask this station mints at the bound — and threaded to the request mint, never re-read off the
104
- * caller-owned object there.
105
- * The MANDATE marks are empty for the same reason — no tool seat of this task, no tighten of its own;
106
- * what the ancestor's ask carries on ITSELF still answers, read off the decision by the one predicate.
101
+ * chain, the delegation lane's original chain. The word and the class set are derived inside
102
+ * {@link judgeInheritedClassifier} and threaded to every reader ({@link inheritedAskCarry}).
103
+ * The MANDATE marks here are empty (the base names no seat): the policy-chain phase builds each call's facts over this
104
+ * base with THIS task's tool seat — the seat the request's risk axes are read from — so a marked tool's ask is
105
+ * classified by the seat at the station as at the gate. What the ancestor's ask carries on ITSELF still answers.
107
106
  */
108
107
  export declare const INHERITED_STATION_FACTS: AskOriginFacts;
109
108
  /** The members of a surviving ask that ride onto the approval request with a COMPUTED value — what the
110
109
  * four request mint stations (the gate's own and the three inherited-lane ones) spread after the seats
111
- * they spell themselves: the ask's origin word and its denial-limit fallback. */
110
+ * they spell themselves: the ask's origin word, its class (the word and the set, carried together) and its
111
+ * denial-limit fallback. */
112
112
  export interface AskRequestCarry {
113
113
  origin?: AskOrigin;
114
+ askClass?: AskClass;
115
+ classes?: readonly AskClass[];
114
116
  denialLimitFallback?: DenialLimitFallback;
115
117
  }
116
- /** The GATE's own mint station: both members are read off the surviving decision, which is the gate's
117
- * OWN snapshot (its stamp point spreads the policy's object and stamps the word onto the copy), so the
118
- * origin is the engine-derived one and the member is the one the gate's classifier step minted. */
118
+ /** The GATE's own mint station: every member is read off the surviving decision, which is the gate's
119
+ * OWN snapshot (its stamp point spreads the policy's object and stamps the word and the class onto the copy),
120
+ * so the origin and the class are the engine-derived ones and the fallback member is the one the gate's
121
+ * classifier step minted. */
119
122
  export declare function gateAskCarry(decision: InheritedAsk, liveApprover: boolean, tracker: AutoModeDenialTracker | undefined): AskRequestCarry;
120
123
  /** An INHERITED-lane mint station (the two wrapper arms and the approved-edit re-check): the ask is an
121
- * ancestor policy's own decision, never engine-stamped, so BOTH members come from the judgment
122
- * {@link judgeInheritedClassifier} handed back — the word it derived under the station facts and the
123
- * fallback member it captured — each read ONCE there and threaded here, never re-read off the
124
+ * ancestor policy's own decision, never engine-stamped, so EVERY member comes from the judgment
125
+ * {@link judgeInheritedClassifier} handed back — the word and the classification it derived under the station
126
+ * facts and the fallback member it captured — each read ONCE there and threaded here, never re-read off the
124
127
  * caller-owned object (a stateful accessor could otherwise answer the card with one value and the
125
- * settlement with another; a self-declared `origin` on the ancestor's decision is never read). */
128
+ * settlement with another; a self-declared `origin` / `askClass` / `classes` on the ancestor's decision is never
129
+ * read). */
126
130
  export declare function inheritedAskCarry(judged: Pick<Extract<InheritedClassifierJudgment<InheritedAsk>, {
127
131
  kind: "resolve";
128
- }>, "origin" | "fallback" | "ask">, liveApprover: boolean, tracker: AutoModeDenialTracker | undefined): AskRequestCarry;
132
+ }>, "origin" | "classification" | "fallback" | "ask">, liveApprover: boolean, tracker: AutoModeDenialTracker | undefined): AskRequestCarry;
129
133
  /** What {@link judgeInheritedClassifier} hands back to the arm that called it. */
130
134
  export type InheritedClassifierJudgment<A extends InheritedAsk> =
131
135
  /** The frozen classifier allowed. `result` is the allow the arm RETURNS — minted at the judging station so
@@ -146,8 +150,11 @@ export type InheritedClassifierJudgment<A extends InheritedAsk> =
146
150
  * `origin` is the ask's word under the station facts, threaded to the request mint: on the two
147
151
  * unchanged-ask branches it is the SAME read the exclusion judged by (a caller-owned decision with a
148
152
  * stateful accessor cannot answer the exclusion with one word and the card with another); on the
149
- * bound branch it is derived from the re-spoken ask this station minted (its own object). */
150
- /** `ask` is the incoming ask unchanged (the caller's own object), or — an UNAVAILABLE classification with a live frozen
153
+ * bound branch it is derived from the re-spoken ask this station minted (its own object).
154
+ * `classification` is the class set of the ask handed on, under the same station facts: on the two unchanged-ask
155
+ * branches the one read the exclusion judged by, on the unavailable and bound branches a read of the object this
156
+ * station minted (each adds a real-approval bit, which the set must reflect). */
157
+ /** `ask` is the incoming ask unchanged (the arm's receipt snapshot of the ancestor's decision), or — an UNAVAILABLE classification with a live frozen
151
158
  * approver — that ask with the classifier sentence appended; without one it is a `deny` whose attested cause reads `unavailable`. */
152
159
  | {
153
160
  kind: "resolve";
@@ -155,6 +162,7 @@ export type InheritedClassifierJudgment<A extends InheritedAsk> =
155
162
  fallback: UnarmedDenialLimitFallback | undefined;
156
163
  mintedHere: boolean;
157
164
  origin: AskOrigin;
165
+ classification: AskClassification;
158
166
  };
159
167
  /**
160
168
  * The ancestor's frozen classifier, run BEFORE its frozen approver in the ancestor's own gate order,
@@ -195,6 +203,8 @@ export declare function judgeInheritedClassifier<A extends InheritedAsk>(opts: {
195
203
  allowRewrite?: unknown;
196
204
  /** An `unavailable` verdict's fork facts (`classifierUnavailableArm`): is the frozen approver live; who hears it. */
197
205
  unavailableSeat?: ClassifierUnavailableSeat;
206
+ /** The station's facts for this call (the base with this task's tool seat); absent ⇒ {@link INHERITED_STATION_FACTS}. */
207
+ facts?: AskClassFacts;
198
208
  }): Promise<InheritedClassifierJudgment<A>>;
199
209
  /** The resolver's answer as the settlement reads it. */
200
210
  export type FallbackResolution = Pick<ResolvedAsk, "action" | "settlement" | "approverUnavailable">;
@@ -5,7 +5,7 @@ import { attestClassifierDenyCause, attestClassifierRound } from "../tool-policy
5
5
  import { SETTLEMENT_NOBODY_JUDGED } from "../gate-outcome.js";
6
6
  import { deliverEngineNotice } from "../types.js";
7
7
  import { inlineUntrusted } from "../untrusted-text.js";
8
- import { askOriginOf, classifyAsk } from "../ask-origin.js";
8
+ import { askClassMembersOf, askOriginOf, classifyAsk } from "../ask-origin.js";
9
9
  export function attachRebuiltDenialTrackers(entries, denialLimit) {
10
10
  if (entries === undefined)
11
11
  return undefined;
@@ -68,30 +68,34 @@ function fallbackCarry(fallback, liveApprover, tracker) {
68
68
  export function gateAskCarry(decision, liveApprover, tracker) {
69
69
  return {
70
70
  ...(decision.origin !== undefined ? { origin: decision.origin } : {}),
71
+ ...(decision.askClass !== undefined && decision.classes !== undefined ? { askClass: decision.askClass, classes: decision.classes } : {}),
71
72
  ...fallbackCarry(decision.denialLimitFallback, liveApprover, tracker),
72
73
  };
73
74
  }
74
75
  export function inheritedAskCarry(judged, liveApprover, tracker) {
75
76
  return {
76
77
  origin: judged.origin,
78
+ ...askClassMembersOf(judged.classification),
77
79
  ...fallbackCarry(judged.fallback, liveApprover, tracker),
78
80
  };
79
81
  }
80
82
  export async function judgeInheritedClassifier(opts) {
81
83
  const { autoMode, ask, req } = opts;
82
- const origin = askOriginOf(ask, INHERITED_STATION_FACTS);
84
+ const facts = opts.facts ?? INHERITED_STATION_FACTS;
85
+ const origin = askOriginOf(ask, facts);
86
+ const classification = classifyAsk(ask, facts);
83
87
  const classifierStandsDown = () => {
84
88
  const live = opts.liveMode?.();
85
89
  return live === "dontAsk" || live === "bypassPermissions" || live === "plan" || (autoMode?.liveMode !== undefined && autoMode.liveMode.current !== "auto");
86
90
  };
87
- if (autoMode === undefined || classifierStandsDown() || !classifyAsk(ask, INHERITED_STATION_FACTS).row.classifierEligible) {
88
- return { kind: "resolve", ask, fallback: ask.denialLimitFallback, mintedHere: false, origin };
91
+ if (autoMode === undefined || classifierStandsDown() || !classification.row.classifierEligible) {
92
+ return { kind: "resolve", ask, fallback: ask.denialLimitFallback, mintedHere: false, origin, classification };
89
93
  }
90
94
  const verdict = await autoMode.decider
91
95
  .decide({ req, ...(ask.message !== undefined ? { askMessage: ask.message } : {}) }, opts.signal)
92
96
  .catch(() => ({ kind: "unavailable", cause: "error" }));
93
97
  if (verdict.kind === "allow" && classifierStandsDown())
94
- return { kind: "resolve", ask, fallback: ask.denialLimitFallback, mintedHere: false, origin };
98
+ return { kind: "resolve", ask, fallback: ask.denialLimitFallback, mintedHere: false, origin, classification };
95
99
  const attributed = (d) => (verdict.classifier !== undefined ? attestClassifierRound(d, verdict.classifier, req) : d);
96
100
  if (verdict.kind === "allow") {
97
101
  autoMode.denialTracking?.recordAllow();
@@ -100,7 +104,7 @@ export async function judgeInheritedClassifier(opts) {
100
104
  if (verdict.kind === "unavailable") {
101
105
  const arm = classifierUnavailableArm({ ask, req, cause: verdict.cause, seat: opts.unavailableSeat, aborted: opts.signal.aborted });
102
106
  if (arm.kind === "asked")
103
- return { kind: "resolve", ask: arm.ask, fallback: ask.denialLimitFallback, mintedHere: false, origin };
107
+ return { kind: "resolve", ask: arm.ask, fallback: ask.denialLimitFallback, mintedHere: false, origin, classification: classifyAsk(arm.ask, facts) };
104
108
  return { kind: "deny", result: attributed(arm.deny) };
105
109
  }
106
110
  const reason = verdict.kind === "block" ? (verdict.reason ? inlineUntrusted(verdict.reason) : "") : classifierParseFailureDenyMessage(verdict.classifier);
@@ -116,7 +120,7 @@ export async function judgeInheritedClassifier(opts) {
116
120
  }
117
121
  const fallback = tracked.fallback;
118
122
  const minted = { ...ask, message: denialLimitFallbackMessage(fallback, reason || category || req.toolName), decisionReason: "classifier", requiresRealApproval: true, denialLimitFallback: fallback };
119
- return { kind: "resolve", ask: minted, fallback, mintedHere: true, origin: askOriginOf(minted, INHERITED_STATION_FACTS) };
123
+ return { kind: "resolve", ask: minted, fallback, mintedHere: true, origin: askOriginOf(minted, facts), classification: classifyAsk(minted, facts) };
120
124
  }
121
125
  export function headlessDenyAtFold(r) {
122
126
  return r.settlement !== undefined && SETTLEMENT_NOBODY_JUDGED[r.settlement.kind];