@sema-agent/core 5.23.0 → 5.24.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,105 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.24.0 — 2026-08-10
4
+
5
+ No API-BREAKING changes (exports grow only; `suspendAsk` gains an optional fifth parameter;
6
+ `removePersistedRule` widens its `principal` input to a union). Behavior-surface narrowings and two
7
+ deliberate loosenings are called out below; checkpoint **v8** is a compatibility disclosure, not a
8
+ breaking change (rows without the new gate bit keep their historic stamps).
9
+
10
+ ### Narrowed (behavior)
11
+
12
+ - **The real-approval bit survives the durability boundary (#130/#120).** An ask carrying
13
+ `requiresRealApproval` (an org ASK rule, the org-unavailable tighten, or a policy that minted the
14
+ bit) now parks as the NON-BUDGETABLE `irreversible_ask` gate kind — it used to park as plain
15
+ `human`, the one kind a network budget resolver may auto-approve. The gate carries
16
+ `RealApprovalGateBit` (`origin: "org_rule" | "org_unavailable" | "policy"`), the row stamps
17
+ `REAL_APPROVAL_CHECKPOINT_VERSION = 8` (`MAX_SUPPORTED` raised to 8 — a pre-5.24 worker rejects a
18
+ v8 row pre-CAS as `unsupported_version`, the row stays pending). Resume adds three guards: a
19
+ governed row (org origin) is refused PRE-CAS on a worker with no `permissionRuleOrg` wiring (the
20
+ human decision stays unspent, redeemable on an org-wired worker; a post-CAS belt remains as
21
+ defense in depth); a v8 row whose bit was stripped in storage, and a sub-v8 row carrying a bit no
22
+ release minted, are both refused pre-CAS (corruption/forgery guards).
23
+ - **`run_in_background: true` is judged by the backgrounding doctrine (#125, P0).** On a
24
+ `shellGate:"classify"` deployment, `bashReversibilityProbe` used to read only the command text, so
25
+ `ls` + the parameter form auto-admitted exactly what `ls &` asks for. The parameter spelling now
26
+ gets the textual spelling's verdict (not reversible ⇒ ask).
27
+ - **Oversized strings inside `details` are offloaded (#116).** `withToolResultOffload` used to
28
+ replace only `res.content`; a Bash call's 140k-char `details.stdout` sailed into session
29
+ persistence and every wire projection. Every oversized string inside `details` (deep walk, plain
30
+ objects/arrays, cycle-cut with a completed-transform memo, identity-preserving when untouched) is
31
+ now offloaded to the same store and replaced by a bounded head naming its ref; judged
32
+ independently of content size. Sequenced after the server tool-results read face (7.11.0) so full
33
+ payloads always have a host-side egress. `Write` joins `Read`'s structural offload exemption: its
34
+ `details.content` is the session-continuation replay's authorization source, and the replay
35
+ additionally refuses to seed a content string carrying the offload-replacement notice
36
+ (`isOffloadedDetailReplacement`, exported). An own `__proto__` key in `details` survives the
37
+ rebuild as data (never installed as the rebuilt object's prototype), and below head+notice size a
38
+ string rides untouched (no replacement that grows).
39
+ - **A local-owner adoption rebinds the anonymous session-policy estate (#132).** The
40
+ session-policy leg now runs for BOTH adoption shapes: the local-owner shape rewrites `[sid, null]`
41
+ rows to the adopted principal (a foreign principal's row stays), and the terminal report says
42
+ `row-rewrite` with the real count. On such a root the anonymous lane ALIASES to the adopted
43
+ principal (reads and writes), so the window between terminal adoption and the operator landing the
44
+ principal wiring cannot drop tighten-only deny rules; unadopted and principal-adopted roots are
45
+ untouched. Narrow disclosed window: a pre-5.24 in-flight local-owner marker (a stage-3 claim with
46
+ no session-policy evidence) reads as corrupt — recovery is an operator action (remove the root's
47
+ adoption.json and adopt afresh).
48
+
49
+ ### Loosened (deliberate, ruled)
50
+
51
+ - **A park minted under org UNAVAILABILITY honors the approval it solicited (#131).** The gate's
52
+ own message promises "every allow tightens to a real approval until [the org] can [be read]" — but
53
+ the resume belt refused that very approval and burned it, livelocking a park-only deployment
54
+ (ask → park → approved → still unavailable → refused, forever). A row with
55
+ `origin:"org_unavailable"` now EXECUTES on an approved resume even while the org stays unreadable
56
+ (the durable row — origin plus the human outcome — is the audit record); `org_rule` rows and
57
+ pre-v8 rows keep the strict fail-closed posture, and a published org deny blocks regardless of
58
+ origin.
59
+ - **The adoption terminal record's read validator checks shape, not this build's constants
60
+ (#133).** The stored report snapshots the by-design-not-migrated set and the config table AS RULED
61
+ AT ADOPTION TIME; both may grow by ruling, and the old exact-equality read marked every
62
+ already-adopted root corrupt on the first release after any growth (and again on rollback), across
63
+ 13 store constructors at once. Read time now enforces non-empty/well-formed/no-duplicate-identity;
64
+ the writer-emits-the-constant drift guard moves to the test grid, where a change is a deliberate,
65
+ reviewable edit. Empty, duplicate-carrying and mangled reports are refused exactly as before.
66
+
67
+ ### Added
68
+
69
+ - **The permission-rule BACKEND CONTRACT is exported** (ruled 2026-08-10): `PERMISSION_RULE_WRITER`,
70
+ `writerOf`, `foldDelta`, `addDotsOf`, the delta/writer/store types and the two consent-boundary
71
+ assertions — an out-of-repo store twin builds against the same definitions the file backend
72
+ implements instead of mirroring them. The consent boundary is unchanged and now PINNED: the engine
73
+ reaches `writer.apply` only on the consent redemption, tighten-delete and sync-join lanes
74
+ (registered-caller scan + a second net over every file naming the writer handle).
75
+ - **`removePersistedRule` accepts a structural `RuleOwner`** — the string shorthand is unchanged;
76
+ `{ kind: "local-owner" }` resolves the local-owner bucket (previously unrevokable through this
77
+ entry), and a provider without `forLocalOwner` fails loudly instead of no-oping against the
78
+ zero-rule store.
79
+ - `RealApprovalGateBit` exported (the supervisor-inbox / SQL-twin read of the new gate member).
80
+
81
+ ### Fixed
82
+
83
+ - **The background time wall is stated as a budget, never a kill threat (#121).** Under deadline
84
+ pressure a model read "auto-terminates if still running after 600s" as "better TaskStop it myself
85
+ first" (a measured 6.3s self-stop where waiting was optimal). All three Bash minting arms and both
86
+ Monitor siblings now say "may run up to Ns"; the kill explanation stays with the terminal
87
+ timed-out wording, which fires only when a timeout actually happens. Retired-dialect tripwires on
88
+ both faces.
89
+ - **`createAllowDenyPolicy`'s invalid-name refusal teaches each lesson once.** A CC-migrated
90
+ settings file with 39 content-form entries repeated the same three-line lesson 39 times; the
91
+ thrown string now groups by lesson (entries listed under it). The structured `issues` face stays
92
+ per-entry and gains a `lesson` member (additive).
93
+ - Three public comment surfaces stopped recommending prefix recomposition over `buildToolResultRef`
94
+ (#134 — the encoding is not injective; ownership is exact-equality against a recomposed ref), the
95
+ `RunnerDeps.permissionRuleStore` docstring states the ruled backend-contract boundary instead of
96
+ "the write face is core-private", and dated errata landed for two past releases (5.22.0: the
97
+ design/182 rule-sync core half shipped unlisted; 5.13.0: safety asks going sync-first under a live
98
+ approver shipped unlisted and superseded a written park promise).
99
+ - Docs: `docs/sdk/09` gains the sandbox-admission section (every exclusion conjunct, from source)
100
+ and an org-rule-layer account stated in terms of the mechanism that actually holds the line
101
+ (`requiresRealApproval` excluded at every loosening seam, now including the durability boundary).
102
+
3
103
  ## 5.23.0 — 2026-08-10
4
104
 
5
105
  No BREAKING changes.
@@ -92,7 +192,10 @@ No BREAKING changes.
92
192
  delegation-provenance aggregate rides the durable checkpoint; a resumed leg missing the state
93
193
  reads as `unknown` (static floor), never as `clean`.
94
194
  - **`buildToolResultRef` exported from the package root** — a host wiring an HTTP tool-result read
95
- face binds an incoming ref to its owning task by prefix recomputation.
195
+ face composes refs through the single source instead of reimplementing the segment escaping.
196
+ (Erratum 2026-08-10: this entry originally said a read face binds a ref to its owning task "by
197
+ prefix recomputation". It must not — the encoding is not injective over the two segments, so
198
+ ownership is an exact-equality test against a recomposed ref, never a prefix match.)
96
199
  - **Content mandate split from the approval mandate (#94).** A delegated child's question routes on
97
200
  the QUESTION seat, not the approver seat. Behavior widening, called out explicitly: under
98
201
  `durableApproval` with a live `onQuestion`, an absent or string approver seat used to leave child
@@ -103,6 +206,13 @@ No BREAKING changes.
103
206
  `TaskRegistry.reviveBackgroundAgent`'s refusal union gains the `recycling` member — exhaustive
104
207
  consumers add one arm.
105
208
 
209
+ _Addendum (2026-08-10, #135 erratum): the design/182 rule-sync **core half also shipped in this
210
+ release** and was not listed. It added the sync client and its wire contract to the public surface:
211
+ `syncPermissionRules` (full-state join, screened in both directions), `PERMISSION_RULE_SYNC_PATH`,
212
+ `RuleSyncRequestBody` / `RuleSyncResponseBody` / `PermissionRuleSyncResult`, `parseRuleSyncResponse`,
213
+ and `LOCAL_OWNER_UNSYNCABLE_CODE` (a local-owner bucket refuses to sync, loudly) — the definitions a
214
+ server-side rule store builds against. Recorded as a dated erratum rather than a silent rewrite._
215
+
106
216
  ## 5.21.1 — 2026-08-09
107
217
 
108
218
  - **Fix: every `hands: none` deployment failed at the door on 5.21.0** (P0). `HAND_TOOL_EFFECTS`
@@ -1105,6 +1215,17 @@ mysterious runtime.
1105
1215
 
1106
1216
  - **`finalVerification` mechanism hardening (four of the seven field failure modes).** ① The injection headroom guard read only the turn axis — it now also reads the tightest caller-armed budget axis (tokens/cost/walltime, same axes as the limit-approach frames); ≥90% full skips the injection, so the gate can no longer convert an externally-passing run into `limits.*_exceeded`. ② Both injections emit the standard `steering_injected` echo (**new closed-set member: `source: "final_verification"`** — switches over the source union add an arm), closing the stats-vs-stream observability gap. ③ The nudge licenses cleaning up residue the model's own testing created (the state-harmless clause no longer reads as "leave your test residue in place"). ④ The nudge forbids laundering pre-existing uncertainty into "confirmed". The remaining modes are design-bounded: the engine never judges check semantics, and self-grading is the deployment's verifier-role wiring.
1107
1217
 
1218
+ _Addendum to 5.13.0 (2026-08-10, erratum — the entry below was missing at release): **SAFETY asks go
1219
+ sync-first when a live approver is wired.** With a FUNCTION-valued `onAsk` present, an
1220
+ egress/irreversibility-tightened ask now resolves in-stream through that approver (same turn, no
1221
+ checkpoint) instead of always parking as `irreversible_ask`; the durable park remains the headless /
1222
+ no-live-approver / `forceDurableGate` / live-face-answered-unavailable path, where the gate kind,
1223
+ `safetyAxis` and risk descriptor are unchanged. This superseded the earlier written promise that
1224
+ "safety asks … still park" with a live approver (the 1.37x sync-approval entry). The change shipped
1225
+ in 5.13.0 with no changelog entry — recorded here as a dated erratum rather than a silent rewrite;
1226
+ consumer flips: a probe pinning "an `irreversibility:'always'` tool always suspends" reds whenever a
1227
+ live `onAsk` is wired — drop the approver (or arm `forceDurableGate`) to test the park leg._
1228
+
1108
1229
  ## 5.12.0 — 2026-08-05
1109
1230
 
1110
1231
  ### BREAKING
@@ -267,6 +267,28 @@ export declare function buildRiskDescriptor(input: {
267
267
  /** The resolved doctrine to persist when `shellGated` (see {@link RiskDescriptor.shellGateDoctrine}). */
268
268
  shellGateDoctrine?: "classify" | "always";
269
269
  }): RiskDescriptor;
270
+ /**
271
+ * #130/#131/#120 (2026-08-10) — the durable record of an ask's `requiresRealApproval` bit, which used
272
+ * to DIE at the park: the mint keyed the gate kind on the static tool marks only, so an org-governed
273
+ * ask on an unmarked tool minted a plain `{kind:"human"}` — the one kind a network budget resolver may
274
+ * auto-approve — and the org's "only judgment clears this" demand was silently budgetable after the
275
+ * durability boundary. Present ⇒ the mint escalates to `irreversible_ask` (non-budgetable) and stamps
276
+ * {@link REAL_APPROVAL_CHECKPOINT_VERSION}. `origin` additionally records WHY, for the resume belts:
277
+ * · `"org_rule"` — an org ASK rule fired (governance was READABLE at mint). Resume keeps the strict
278
+ * posture: org unavailable at resume still refuses (newly-blind ⇒ fail-closed).
279
+ * · `"org_unavailable"` — governance could NOT be read at mint and the gate's own message promised
280
+ * "every allow tightens to a real approval until it can". A person approving THIS park IS that real
281
+ * approval — so a resume that finds org still unavailable executes instead of refusing and burning
282
+ * the approval (the park-only livelock #131 closed). The durable row itself — this origin plus the
283
+ * recorded human outcome — is the audit record of that passage; the resume emits no extra notice.
284
+ * · `"policy"` — a policy/hook minted the bit (e.g. the always-on classifier-parity rule); no org
285
+ * semantics, the resume belts treat it as a plain non-budgetable approval.
286
+ * Both org origins double as the #120 governed mark: a resuming worker with NO org adjudication wiring
287
+ * refuses to redeem such a row (the governed boundary must not vanish with a deployment's wiring).
288
+ */
289
+ export interface RealApprovalGateBit {
290
+ origin: "org_rule" | "org_unavailable" | "policy";
291
+ }
270
292
  export type CheckpointGate =
271
293
  /** F4: a human (or any external authority) must allow/deny a pending tool call. design/80 §D-E:
272
294
  * carries an OPTIONAL display-only {@link RiskDescriptor} (severity/axes/summary) for the supervisor
@@ -292,6 +314,7 @@ export type CheckpointGate =
292
314
  reason: string;
293
315
  toolName: string;
294
316
  safetyAxis?: SafetyAxis;
317
+ realApproval?: RealApprovalGateBit;
295
318
  riskDescriptor?: RiskDescriptor;
296
319
  }
297
320
  /** design/74: a resource slice limit (budget/walltime/turns) was reached — suspend (resumable) instead of
@@ -909,13 +932,25 @@ export declare const ORG_ADMISSION_CHECKPOINT_VERSION = 6;
909
932
  * state keeps the historic stamps — deployments not delegating see zero version movement.
910
933
  */
911
934
  export declare const F012_CHECKPOINT_VERSION = 7;
935
+ /**
936
+ * #130/#120 (2026-08-10 — the F012_CHECKPOINT_VERSION precedent replayed a sixth time): the schema
937
+ * version a suspend stamps when its gate carries {@link RealApprovalGateBit}. The enforcement lives
938
+ * ENTIRELY in the resuming worker (the no-org-wiring refusal, the org_unavailable resume-belt
939
+ * distinction): a pre-131 worker (MAX_SUPPORTED=7) would accept the row, ignore `realApproval`, and
940
+ * either redeem a governed row with no org wiring at all (#120's exact hole, replayed through version
941
+ * skew) or burn the approval on the unavailable belt the bit exists to soften. Stamping v8 forces it
942
+ * to reject PRE-CAS (`unsupported_version`, stays `pending`, retried on an enforcing worker). A gate
943
+ * with NO `realApproval` keeps the historic stamps — ungoverned deployments see zero version movement.
944
+ */
945
+ export declare const REAL_APPROVAL_CHECKPOINT_VERSION = 8;
912
946
  /** The highest {@link Checkpoint.version} `runner.resume` will act on; a higher one is rejected pre-CAS with
913
947
  * {@link CheckpointError} `unsupported_version` (the checkpoint stays `pending`, retryable on a newer worker).
914
- * Raised to 7 for F-012-bearing checkpoints — this worker reads v1 (legacy human), v2 (resource), v3
948
+ * Raised to 8 for realApproval-bearing gates — this worker reads v1 (legacy human), v2 (resource), v3
915
949
  * (binding human/irreversible_ask), v4 (a pre-164 row, refused only when it carries the retired
916
950
  * wall-clock allocation), v5 (token-allocation-bearing resource/approval), v6 (org-admission freeze
917
- * state) and v7 (F-012 constraint-chain / delegation-provenance enforcement state). */
918
- export declare const MAX_SUPPORTED_CHECKPOINT_VERSION = 7;
951
+ * state), v7 (F-012 constraint-chain / delegation-provenance enforcement state) and v8 (a
952
+ * non-budgetable `realApproval` gate bit with its org-origin resume semantics). */
953
+ export declare const MAX_SUPPORTED_CHECKPOINT_VERSION = 8;
919
954
  /**
920
955
  * Read a checkpoint's schema version, defaulting an absent field to **legacy `0`** (a 1.67-era checkpoint
921
956
  * written before the field existed — it carries no `workspaceHandle`, so resuming it the v1 way is safe).
@@ -134,7 +134,8 @@ export const BINDING_CHECKPOINT_VERSION = 3;
134
134
  export const TOKEN_CHECKPOINT_VERSION = 5;
135
135
  export const ORG_ADMISSION_CHECKPOINT_VERSION = 6;
136
136
  export const F012_CHECKPOINT_VERSION = 7;
137
- export const MAX_SUPPORTED_CHECKPOINT_VERSION = 7;
137
+ export const REAL_APPROVAL_CHECKPOINT_VERSION = 8;
138
+ export const MAX_SUPPORTED_CHECKPOINT_VERSION = 8;
138
139
  export function checkpointVersionOf(cp) {
139
140
  return cp.version ?? 0;
140
141
  }
@@ -602,7 +602,13 @@ export interface ToolGateInput {
602
602
  * prefer, so the closure must NOT take its sync-first decline and should park durably. All other
603
603
  * decline/pre-commit-failure paths keep their existing `undefined` fallbacks (the gate then keeps the
604
604
  * fail-closed deny / typed refusal the caller carries). */
605
- liveFaceUnavailable?: boolean) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
605
+ liveFaceUnavailable?: boolean,
606
+ /** #130: present ⇒ the surviving ask carries `requiresRealApproval` and the mint must escalate to
607
+ * the non-budgetable `irreversible_ask` kind carrying this bit (a budget resolver must never
608
+ * auto-approve what only judgment may clear). `origin` records whether the bit came from an org
609
+ * ASK rule, from the org-unavailable tighten (whose resume semantics differ — see
610
+ * {@link import("./checkpoint-store.js").RealApprovalGateBit}), or from a policy/hook. */
611
+ realApproval?: import("./checkpoint-store.js").RealApprovalGateBit) => Promise<ToolGateResult["suspend"] | ParkAttemptFailed | undefined>;
606
612
  /**
607
613
  * design/174 — route a policy `ask` on the reserved question tool to this run's CONTENT-ask channel
608
614
  * before it can become a park or a refusal. Called in the `ask` branch with the FINAL post-hook,
@@ -248,6 +248,7 @@ export async function runToolGate(input) {
248
248
  }
249
249
  }
250
250
  let orgRealApprovalRequired = false;
251
+ let orgAskOrigin;
251
252
  let orgTightenCount = 0;
252
253
  const applyOrgLayer = async (current, args) => {
253
254
  if (input.orgRules === undefined)
@@ -266,6 +267,7 @@ export async function runToolGate(input) {
266
267
  }, { ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}), timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
267
268
  if (answer.status === "unavailable") {
268
269
  orgRealApprovalRequired = true;
270
+ orgAskOrigin = "unavailable";
269
271
  await notifier.notifyAsync(() => input.orgRules?.onUnavailable?.({ toolName, toolCallId, message: answer.disclosures.join("; ") }), "toolGate.orgSnapshotUnavailable");
270
272
  if (decided.action === "allow") {
271
273
  decided = {
@@ -297,6 +299,7 @@ export async function runToolGate(input) {
297
299
  orgTightenCount += 1;
298
300
  denySource = "org";
299
301
  orgRealApprovalRequired = true;
302
+ orgAskOrigin = "rule";
300
303
  return decided.action === "ask"
301
304
  ? { ...decided, requiresRealApproval: true }
302
305
  : {
@@ -383,8 +386,11 @@ export async function runToolGate(input) {
383
386
  const egressTool = input.egress === true;
384
387
  const irreversibleTool = input.irreversibility === "always" || input.irreversibility === "maybe";
385
388
  const safety = egressTool || irreversibleTool ? { egress: egressTool, irreversible: irreversibleTool } : undefined;
389
+ const realApprovalOf = (d) => d.action === "ask" && d.requiresRealApproval === true
390
+ ? { origin: orgAskOrigin !== undefined ? `org_${orgAskOrigin}` : "policy" }
391
+ : undefined;
386
392
  if (suspendAsk && decision.action === "ask") {
387
- const suspended = await suspendAsk(req, currentInput, safety);
393
+ const suspended = await suspendAsk(req, currentInput, safety, undefined, realApprovalOf(decision));
388
394
  if (suspended) {
389
395
  if ("parkFailed" in suspended)
390
396
  parkFailed = suspended.parkFailed;
@@ -403,7 +409,7 @@ export async function runToolGate(input) {
403
409
  req.args = outcome.presentedInput;
404
410
  }
405
411
  if (suspendAsk && outcome.parkDeclined && parkFailed === undefined) {
406
- const suspended = await suspendAsk(req, currentInput, safety, true);
412
+ const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(decision));
407
413
  if (suspended) {
408
414
  if ("parkFailed" in suspended)
409
415
  parkFailed = suspended.parkFailed;
@@ -427,10 +433,11 @@ export async function runToolGate(input) {
427
433
  }
428
434
  }
429
435
  if (decision.action === "ask") {
436
+ const askBeforeResolve = decision;
430
437
  const resolved = await resolveAsk(decision, req);
431
438
  decision = resolved;
432
439
  if (resolved.action === "deny" && resolved.approverUnavailable === true && suspendAsk && parkFailed === undefined) {
433
- const suspended = await suspendAsk(req, currentInput, safety, true);
440
+ const suspended = await suspendAsk(req, currentInput, safety, true, realApprovalOf(askBeforeResolve));
434
441
  if (suspended) {
435
442
  if ("parkFailed" in suspended)
436
443
  parkFailed = suspended.parkFailed;
@@ -1,11 +1,14 @@
1
1
  /**
2
- * design/179 §8 — the persisted allow-rule store seam, its core-private write face, and the removal entry.
2
+ * design/179 §8 — the persisted allow-rule store seam, its backend write face, and the removal entry.
3
3
  *
4
4
  * ## Two faces, deliberately unequal
5
5
  *
6
- * A deployment sees a READ face: `list()`, scoped to one verified principal by a factory. There is no
7
- * exported write API at all the only way a rule enters the store through the engine is the core-private
8
- * redemption path (`permission-rule-consent.ts`), which requires an approved durable approval record.
6
+ * A deployment sees a READ face: `list()`, scoped to one verified principal by a factory. The write face
7
+ * is a BACKEND CONTRACT (ruled 2026-08-10: exported so an out-of-repo store twin builds against the same
8
+ * definitions instead of mirroring them), not a host write API the only way a rule enters the store
9
+ * through the ENGINE is the redemption path (`permission-rule-consent.ts`), which requires an approved
10
+ * durable approval record; that invariant lives in the engine's wiring and is pinned by the
11
+ * writer-caller registry test, not in type visibility.
9
12
  * `expectedRev` is concurrency control, not authorization, so "hold a put API and skip the ticket" is not
10
13
  * a shape that exists here rather than a rule someone must remember.
11
14
  *
@@ -156,15 +159,19 @@ export interface RuleSyncJoinDelta {
156
159
  * What a write may say. Authorization discriminates on the DELTA SHAPE, not on a full snapshot: only the
157
160
  * add arm can introduce a dot, and the delete arm carries a tombstone and no adds. A backend additionally
158
161
  * REFUSES at runtime any delete that would introduce a new add dot — structure and runtime check together,
159
- * so "pick the delete arm and smuggle an add" is neither expressible nor accepted. The `sync-join` arm is
160
- * constructible only by core (the writer is never exported), and every inbound record inside it passes the
161
- * single validator again AT THE BACKEND the fifth door of design/179 §4's validator list closes here,
162
- * not at the calling layer.
162
+ * so "pick the delete arm and smuggle an add" is neither expressible nor accepted. The `sync-join` arm's
163
+ * safety does not rest on hiding the type (ruled 2026-08-10: the backend contract IS exported for
164
+ * out-of-repo store twins) it rests on every inbound record inside it passing the single validator
165
+ * again AT THE BACKEND: the fifth door of design/179 §4's validator list closes here, not at the
166
+ * calling layer, and closes identically for every caller.
163
167
  */
164
168
  export type RuleWriteDelta = RuleAddDelta | RuleDeleteDelta | RuleSyncJoinDelta;
165
169
  /**
166
- * The core-private write face. Deliberately absent from the package's public exports: a host cannot hold
167
- * one, so no API-level path to the store bypasses the consent protocol.
170
+ * The backend write face. Exported as part of the BACKEND CONTRACT (ruled 2026-08-10) so an
171
+ * out-of-repo store implementation hangs the same face the file backend does, instead of mirroring the
172
+ * types. The consent boundary is unchanged by the export: the ENGINE reaches a writer only through the
173
+ * consent protocol's redemption (and the sync client's join) — a property of the engine's wiring —
174
+ * and a deployment always owned its own storage bytes, so type visibility grants nothing new.
168
175
  */
169
176
  export interface PermissionRuleWriter {
170
177
  /** Mint the next dot for this replica. Counters need only be unique and monotonic, so a dot minted for
@@ -198,11 +205,11 @@ export interface RawRuleSyncState {
198
205
  quarantined?: QuarantinedRuleAdd[];
199
206
  }
200
207
  /**
201
- * The internal handle a writable backend hangs its write face on. Not exported from the package index
202
- * that omission IS the boundary described in the module doc.
208
+ * The handle a writable backend hangs its write face on (exported with the backend contract, ruled 2026-08-10;
209
+ * the consent boundary lives in the engine's wiring, not in this key's visibility).
203
210
  */
204
211
  export declare const PERMISSION_RULE_WRITER = "__semaPermissionRuleWriter";
205
- /** A store that also carries the core-private write face. */
212
+ /** A store that also carries the backend write face. */
206
213
  export interface WritablePermissionRuleStore extends PermissionRuleStore {
207
214
  readonly [PERMISSION_RULE_WRITER]: PermissionRuleWriter;
208
215
  }
@@ -419,7 +426,11 @@ export type RemoveResult =
419
426
  export declare function removePersistedRule(opts: {
420
427
  rule: string;
421
428
  scope: RuleScope;
422
- principal: string;
429
+ /** Whose bucket. A bare string stays the principal shorthand (unchanged callers); the structural
430
+ * {@link RuleOwner} form adds the local-owner bucket (downstream request, 2026-08-10 — design/182 §4.5's
431
+ * `forLocalOwner()` face existed, but removal could not name it, so a local-owner rule was
432
+ * unrevokable through this entry). Same observed-remove/add-wins/stillLive semantics either way. */
433
+ principal: string | RuleOwner;
423
434
  provider: PermissionRuleStoreProvider;
424
435
  }): Promise<RemoveResult>;
425
436
  export declare function errText(err: unknown): string;
@@ -327,7 +327,11 @@ export async function ruleStoreChecksum(payload) {
327
327
  }
328
328
  const REMOVE_MAX_ATTEMPTS = 8;
329
329
  export async function removePersistedRule(opts) {
330
- const store = opts.provider.forPrincipal(opts.principal);
330
+ const owner = typeof opts.principal === "string" ? { kind: "principal", principal: opts.principal } : opts.principal;
331
+ if (owner.kind === "local-owner" && opts.provider.forLocalOwner === undefined) {
332
+ return { status: "failed", error: "this provider has no local-owner bucket (forLocalOwner is not implemented) — a local-owner rule cannot be removed through it" };
333
+ }
334
+ const store = owner.kind === "local-owner" ? opts.provider.forLocalOwner() : opts.provider.forPrincipal(owner.principal);
331
335
  const writer = writerOf(store);
332
336
  if (writer === undefined) {
333
337
  return { status: "failed", error: "the resolved permission-rule store has no write face — rules cannot be removed through it" };
@@ -85,7 +85,7 @@ import { resolveWorkflowSizeGuideline } from "../../orchestration/workflow-size-
85
85
  import { createLspTool, gitCheckIgnoreFilter, resolveLspPath } from "../lsp.js";
86
86
  import { resolveKey } from "../../tools/fs/safety.js";
87
87
  import { wholeFileRecordsFromTranscript } from "./session-file-state-replay.js";
88
- import { BINDING_CHECKPOINT_VERSION, mintCheckpointToken, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, buildRiskDescriptor, debitLedger, encodeAtFidelity, remainingBudgetMicroUsd, resolveCheckpointStore, resolveDeclaredFidelity, samePlainValue, } from "../checkpoint-store.js";
88
+ import { BINDING_CHECKPOINT_VERSION, mintCheckpointToken, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, buildRiskDescriptor, debitLedger, encodeAtFidelity, remainingBudgetMicroUsd, resolveCheckpointStore, resolveDeclaredFidelity, samePlainValue, } from "../checkpoint-store.js";
89
89
  import { boundInputHashOf } from "../canonical-json.js";
90
90
  import { countElicitOptIns, deriveAskEffective, deriveWiringManifest, resolveAskSeamForm, resolveDeclaredDurability, resolveElicitSeam, resolveQuestionSeam } from "../wiring-manifest.js";
91
91
  import { GLOBAL_USAGE_KEY, resolveUsageWindows, usageRetryAfterMs } from "../usage-window-store.js";
@@ -4236,7 +4236,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4236
4236
  }
4237
4237
  };
4238
4238
  const suspendAsk = parkLaneArmed && checkpointStore !== undefined
4239
- ? async (req, postHookArgs, safety, liveFaceUnavailable) => {
4239
+ ? async (req, postHookArgs, safety, liveFaceUnavailable, realApproval) => {
4240
4240
  const syncFirstEligible = req.toolName === ASK_USER_QUESTION_TOOL_NAME ? contentAskRoutable(req.toolCallId) : isLiveApproverSeat(onAsk);
4241
4241
  if (syncFirstEligible &&
4242
4242
  runtimeCaps?.forceDurableGate !== true &&
@@ -4345,12 +4345,15 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4345
4345
  ...(effectiveShellGate !== "off" ? { shellGateDoctrine: effectiveShellGate } : {}),
4346
4346
  });
4347
4347
  gate =
4348
- safety !== undefined
4348
+ safety !== undefined || realApproval !== undefined
4349
4349
  ? {
4350
4350
  kind: "irreversible_ask",
4351
- reason: `human approval required before safety-tightened tool "${req.toolName}"`,
4351
+ reason: safety !== undefined
4352
+ ? `human approval required before safety-tightened tool "${req.toolName}"`
4353
+ : `real human approval required for tool "${req.toolName}" (non-budgetable: ${realApproval.origin})`,
4352
4354
  toolName: req.toolName,
4353
- safetyAxis: safety,
4355
+ ...(safety !== undefined ? { safetyAxis: safety } : {}),
4356
+ ...(realApproval !== undefined ? { realApproval } : {}),
4354
4357
  riskDescriptor,
4355
4358
  }
4356
4359
  : durableApproval
@@ -4373,7 +4376,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
4373
4376
  const mintedAt = Date.now();
4374
4377
  cp = {
4375
4378
  token,
4376
- version: f012CheckpointState() ? F012_CHECKPOINT_VERSION : orgAdmissionCheckpointState() ? ORG_ADMISSION_CHECKPOINT_VERSION : approvalLedger.totalTokens !== undefined ? TOKEN_CHECKPOINT_VERSION : BINDING_CHECKPOINT_VERSION,
4379
+ version: realApproval !== undefined ? REAL_APPROVAL_CHECKPOINT_VERSION : f012CheckpointState() ? F012_CHECKPOINT_VERSION : orgAdmissionCheckpointState() ? ORG_ADMISSION_CHECKPOINT_VERSION : approvalLedger.totalTokens !== undefined ? TOKEN_CHECKPOINT_VERSION : BINDING_CHECKPOINT_VERSION,
4377
4380
  scope,
4378
4381
  sessionId,
4379
4382
  leafId,
@@ -1,6 +1,6 @@
1
1
  import { AgentHarness, DEFAULT_COMPACTION_SETTINGS, uuidv7 } from "../../internal/harness.js";
2
2
  import { snapshotActorAssertion } from "../../internal/llm.js";
3
- import { CheckpointError, BINDING_CHECKPOINT_VERSION, checkpointVersionOf, MAX_SUPPORTED_CHECKPOINT_VERSION, remainingBudgetMicroUsd, readPendingSteerQueue, remainingTokens, validatePendingSteer, winnerFromOutcome, } from "../checkpoint-store.js";
3
+ import { CheckpointError, BINDING_CHECKPOINT_VERSION, checkpointVersionOf, MAX_SUPPORTED_CHECKPOINT_VERSION, REAL_APPROVAL_CHECKPOINT_VERSION, remainingBudgetMicroUsd, readPendingSteerQueue, remainingTokens, validatePendingSteer, winnerFromOutcome, } from "../checkpoint-store.js";
4
4
  import { engineVersion } from "../version.js";
5
5
  import { CONFIG_CATALOG_VERSION, declarationReasons, resolveEffectiveConfig } from "../../config/catalog.js";
6
6
  import { eventDefaultOn } from "../../prompt-assembly/event-registry.js";
@@ -3704,6 +3704,21 @@ export class Runner {
3704
3704
  if (checkpointVersionOf(cp) > MAX_SUPPORTED_CHECKPOINT_VERSION) {
3705
3705
  throw new CheckpointError("checkpoint.unsupported_version", `checkpoint version ${checkpointVersionOf(cp)} is newer than this worker supports (max ${MAX_SUPPORTED_CHECKPOINT_VERSION})`);
3706
3706
  }
3707
+ const preCasGateBit = cp.gate.kind === "irreversible_ask" ? cp.gate.realApproval : undefined;
3708
+ const preCasBitWellFormed = preCasGateBit !== undefined &&
3709
+ typeof preCasGateBit === "object" &&
3710
+ (preCasGateBit.origin === "org_rule" ||
3711
+ preCasGateBit.origin === "org_unavailable" ||
3712
+ preCasGateBit.origin === "policy");
3713
+ if (checkpointVersionOf(cp) >= REAL_APPROVAL_CHECKPOINT_VERSION ? !preCasBitWellFormed : preCasGateBit !== undefined) {
3714
+ throw new CheckpointError("checkpoint.invalid_outcome", checkpointVersionOf(cp) >= REAL_APPROVAL_CHECKPOINT_VERSION
3715
+ ? `a v${checkpointVersionOf(cp)} checkpoint must carry a well-formed non-budgetable realApproval gate bit (origin org_rule/org_unavailable/policy) on an irreversible_ask gate — this row does not; refusing to resume a damaged real-approval row (corruption / downgrade guard), the checkpoint stays pending`
3716
+ : `a v${checkpointVersionOf(cp)} checkpoint carries a realApproval gate bit no release of that version ever minted — refusing to honor a fabricated origin (corruption / forgery guard), the checkpoint stays pending`);
3717
+ }
3718
+ if ((preCasGateBit?.origin === "org_rule" || preCasGateBit?.origin === "org_unavailable") &&
3719
+ this.deps.permissionRuleOrg === undefined) {
3720
+ throw new CheckpointError("checkpoint.unsupported_version", `this checkpoint's approval was minted under organization governance (${preCasGateBit.origin}) and this worker has no org adjudication wiring (permissionRuleOrg) — a governed approval may only be redeemed where governance can be enforced; the checkpoint stays pending, resume it on an org-wired worker`);
3721
+ }
3707
3722
  const retiredWalltimeTotal = cp.resourceLedger?.totalWalltimeSec;
3708
3723
  if (retiredWalltimeTotal !== undefined) {
3709
3724
  throw new CheckpointError("checkpoint.walltime_axis_retired", "this checkpoint's ledger carries a cross-slice WALL-CLOCK allocation (resourceLedger.totalWalltimeSec), an axis this engine version retired — " +
@@ -4009,13 +4024,24 @@ export class Runner {
4009
4024
  return;
4010
4025
  }
4011
4026
  }
4027
+ const gateRealApproval = resume.cp.gate.kind === "irreversible_ask" ? resume.cp.gate.realApproval : undefined;
4028
+ const gateOrgGoverned = gateRealApproval?.origin === "org_rule" || gateRealApproval?.origin === "org_unavailable";
4029
+ if (gateOrgGoverned && prepared.permissionRuleOrg === undefined) {
4030
+ const unwiredDenial = formatHookFeedback(`The approved tool call "${pendingAction.toolName}" was not executed: its approval was minted under organization governance (${gateRealApproval.origin}), and this worker has no org adjudication wiring — a governed approval may only be redeemed where governance can be enforced. This approval is spent; re-issue the call on an org-wired worker.`);
4031
+ emitEnd(true, { content: unwiredDenial });
4032
+ const eid = await prepared.session.appendMessage(toolResultMsg(pendingAction.toolCallId, pendingAction.toolName, unwiredDenial, true));
4033
+ emitCommitted(eid, "toolResult", pendingAction.toolCallId);
4034
+ return;
4035
+ }
4012
4036
  if (prepared.permissionRuleOrg !== undefined) {
4013
4037
  const orgVerdict = prepared.permissionRuleOrg
4014
4038
  .adjudicate({ toolName: pendingAction.toolName, args: resolvedArgs, toolCallId: pendingAction.toolCallId })
4015
4039
  .catch(() => ({ status: "unavailable", disclosures: ["the org adjudication face threw on resume"] }));
4016
4040
  const org = await settleOrgVerdictWithin(orgVerdict, { status: "unavailable", disclosures: [`the org adjudication face did not answer within ${ORG_ADJUDICATION_TIMEOUT_MS}ms (or the task ended first)`] }, { signal: prepared.abortController.signal, timeoutMs: ORG_ADJUDICATION_TIMEOUT_MS });
4017
4041
  const blocked = org.status === "unavailable"
4018
- ? "this deployment is org-governed and cannot currently adjudicate against an organization policy snapshot"
4042
+ ? gateRealApproval?.origin === "org_unavailable"
4043
+ ? undefined
4044
+ : "this deployment is org-governed and cannot currently adjudicate against an organization policy snapshot"
4019
4045
  : org.verdict?.behavior === "deny"
4020
4046
  ? `an organization policy rule (${org.verdict.rule}) denies it`
4021
4047
  : undefined;
@@ -1,4 +1,5 @@
1
1
  import { isAbsolutePathForm } from "../../tools/fs/safety.js";
2
+ import { isOffloadedDetailReplacement } from "../tool-result-store.js";
2
3
  const READ_TOOL = "Read";
3
4
  const WRITE_TOOL = "Write";
4
5
  const RETRACTING_RESULTS = [
@@ -50,6 +51,8 @@ export function wholeFileRecordsFromTranscript(messages) {
50
51
  const content = isRead ? wholeFileFromReadCard(rest) : typeof rest.content === "string" ? rest.content : undefined;
51
52
  if (content === undefined)
52
53
  continue;
54
+ if (isOffloadedDetailReplacement(content))
55
+ continue;
53
56
  byPath.set(filePath, { path: filePath, content, at: m.timestamp });
54
57
  }
55
58
  return [...byPath.values()];
@@ -122,12 +122,9 @@ export function createAllowDenyPolicy(opts) {
122
122
  if (entry.startsWith("mcp__")) {
123
123
  const segments = entry.slice("mcp__".length).split("__");
124
124
  if (segments.some((seg) => seg.length === 0)) {
125
- invalid.push({
126
- entry,
127
- list,
128
- message: `"${entry}" is a malformed MCP tool name (empty segment) — it can never match any mounted tool. ` +
129
- `Use \`mcp__<server>\` for every tool of a server, or \`mcp__<server>__<tool>\` for one tool.`,
130
- });
125
+ const lesson = `a malformed MCP tool name (empty segment) — it can never match any mounted tool. ` +
126
+ `Use \`mcp__<server>\` for every tool of a server, or \`mcp__<server>__<tool>\` for one tool.`;
127
+ invalid.push({ entry, list, message: `"${entry}" is ${lesson}`, lesson });
131
128
  continue;
132
129
  }
133
130
  }
@@ -135,14 +132,11 @@ export function createAllowDenyPolicy(opts) {
135
132
  kept.push(entry);
136
133
  continue;
137
134
  }
138
- invalid.push({
139
- entry,
140
- list,
141
- message: `"${entry}" is a rule CONTENT form, not a tool name a name set matches raw tool names, so this entry ` +
142
- `can never match any mounted tool (in an allow list it removes the tool entirely). Use the tool NAME here, ` +
143
- `and route the narrowing through the lane that speaks this grammar: parameter rules via ` +
144
- `createPermissionRulePolicy, Bash command prefixes via the persisted allow-rule lane.`,
145
- });
135
+ const lesson = `a rule CONTENT form, not a tool name — a name set matches raw tool names, so this entry ` +
136
+ `can never match any mounted tool (in an allow list it removes the tool entirely). Use the tool NAME here, ` +
137
+ `and route the narrowing through the lane that speaks this grammar: parameter rules via ` +
138
+ `createPermissionRulePolicy, Bash command prefixes via the persisted allow-rule lane.`;
139
+ invalid.push({ entry, list, message: `"${entry}" is ${lesson}`, lesson });
146
140
  }
147
141
  return kept;
148
142
  };
@@ -150,8 +144,16 @@ export function createAllowDenyPolicy(opts) {
150
144
  const screenedDeny = screen(opts.deny, "deny");
151
145
  if (invalid.length > 0) {
152
146
  if ((opts.onInvalidName ?? "throw") === "throw") {
147
+ const byLesson = new Map();
148
+ for (const i of invalid) {
149
+ const group = byLesson.get(i.lesson) ?? [];
150
+ group.push({ entry: i.entry, list: i.list });
151
+ byLesson.set(i.lesson, group);
152
+ }
153
153
  const e = new Error(`createAllowDenyPolicy: ${invalid.length} entr${invalid.length === 1 ? "y is" : "ies are"} not tool name(s):\n` +
154
- invalid.map((i) => ` [${i.list}] ${i.message}`).join("\n"));
154
+ [...byLesson.entries()]
155
+ .map(([lesson, group]) => ` Each of the following is ${lesson}\n` + group.map((g) => ` [${g.list}] "${g.entry}"`).join("\n"))
156
+ .join("\n"));
155
157
  e.code = "config.invalid_tool_name_set";
156
158
  e.issues = invalid;
157
159
  throw e;
@@ -240,5 +240,13 @@ export declare function withToolResultOffload(tool: AgentTool, store: ToolResult
240
240
  * ⇒ byte-identical historic preview. An accessor (not a snapshot) because the wrap happens at
241
241
  * prepare time, before the deferred classification exists and before any activation can. */
242
242
  reachableTools?: () => ReadonlySet<string> | undefined): AgentTool;
243
+ /** The machine-recognizable start of an offloaded-detail replacement notice. Exported for consumers
244
+ * that treat a `details` string as SEMANTIC INPUT (not display) — e.g. the session-continuation
245
+ * read-state replay — so they can tell "this member was offloaded, the bytes here are a preview"
246
+ * and degrade honestly instead of consuming preview bytes as the real value. */
247
+ export declare const OFFLOADED_DETAIL_NOTICE_PREFIX = "\u2026[offloaded \u2014 ";
248
+ /** True when `s` carries an offloaded-detail replacement notice (the newline-anchored prefix form the
249
+ * walk emits). A semantic consumer treats such a string as NOT the member's real bytes. */
250
+ export declare function isOffloadedDetailReplacement(s: string): boolean;
243
251
  /** The injected `read_tool_result` tool: pages through an offloaded result. `effect:"read"` (verifier/reconcile-safe). */
244
252
  export declare function createReadToolResultTool(store: ToolResultStore): AgentTool;
@@ -121,6 +121,8 @@ export function firstPartyOffloadPolicy(toolName) {
121
121
  switch (toolName) {
122
122
  case "Read":
123
123
  return { offload: false };
124
+ case "Write":
125
+ return { offload: false };
124
126
  case "Bash":
125
127
  return { offloadThresholdChars: 30_000 };
126
128
  case "Grep":
@@ -164,21 +166,93 @@ export function withToolResultOffload(tool, store, thresholdChars, sessionId, re
164
166
  return tool;
165
167
  const wrappedExecute = async (toolCallId, params, signal, onUpdate) => {
166
168
  const res = await tool.execute(toolCallId, params, signal, onUpdate);
169
+ const offloadedDetails = res.details === undefined ? undefined : await offloadOversizedDetailStrings(res.details, store, thresholdChars, sessionId, toolCallId);
170
+ const withDetails = (r) => offloadedDetails === undefined || offloadedDetails.value === res.details ? r : { ...r, details: offloadedDetails.value };
167
171
  if (totalTextChars(res.content) <= thresholdChars)
168
- return res;
172
+ return withDetails(res);
169
173
  const full = res.content
170
174
  .filter((b) => b.type === "text")
171
175
  .map((b) => b.text)
172
176
  .join("\n");
173
177
  if (full.length <= PREVIEW_HEAD_CHARS + PREVIEW_TAIL_CHARS)
174
- return res;
178
+ return withDetails(res);
175
179
  const ref = buildToolResultRef(sessionId, toolCallId);
176
180
  await store.put(ref, full);
177
181
  const images = res.content.filter((b) => b.type !== "text");
178
- return { ...res, content: [{ type: "text", text: buildPreview(full, ref, undefined, reachableTools?.()) }, ...images] };
182
+ return withDetails({ ...res, content: [{ type: "text", text: buildPreview(full, ref, undefined, reachableTools?.()) }, ...images] });
179
183
  };
180
184
  return { ...tool, execute: wrappedExecute };
181
185
  }
186
+ const OFFLOADED_DETAIL_HEAD_CHARS = 2_000;
187
+ const OFFLOADED_DETAIL_NOTICE_ALLOWANCE_CHARS = 400;
188
+ export const OFFLOADED_DETAIL_NOTICE_PREFIX = "…[offloaded — ";
189
+ export function isOffloadedDetailReplacement(s) {
190
+ return s.includes(`\n${OFFLOADED_DETAIL_NOTICE_PREFIX}`);
191
+ }
192
+ async function offloadOversizedDetailStrings(details, store, thresholdChars, sessionId, toolCallId) {
193
+ const puts = [];
194
+ const onStack = new Set();
195
+ const isPlainObject = (v) => {
196
+ if (typeof v !== "object" || v === null)
197
+ return false;
198
+ const p = Object.getPrototypeOf(v);
199
+ return p === Object.prototype || p === null;
200
+ };
201
+ const replace = (full, path) => {
202
+ const detailRef = buildToolResultRef(sessionId, toolCallId + " " + path);
203
+ puts.push(Promise.resolve(store.put(detailRef, full)));
204
+ return (`${full.slice(0, OFFLOADED_DETAIL_HEAD_CHARS)}\n` +
205
+ `${OFFLOADED_DETAIL_NOTICE_PREFIX}${full.length} chars total; ref "${detailRef}"; the remainder is retained in this run's tool-result store and reads back through the deployment's tool-results face]`);
206
+ };
207
+ const memo = new Map();
208
+ const walk = (v, segs) => {
209
+ if (typeof v === "string")
210
+ return v.length > thresholdChars && v.length > OFFLOADED_DETAIL_HEAD_CHARS + OFFLOADED_DETAIL_NOTICE_ALLOWANCE_CHARS ? replace(v, JSON.stringify(segs)) : v;
211
+ if (Array.isArray(v)) {
212
+ if (onStack.has(v))
213
+ return v;
214
+ const done = memo.get(v);
215
+ if (done !== undefined)
216
+ return done;
217
+ onStack.add(v);
218
+ let changed = false;
219
+ const next = v.map((item, i) => {
220
+ const w = walk(item, [...segs, i]);
221
+ if (w !== item)
222
+ changed = true;
223
+ return w;
224
+ });
225
+ onStack.delete(v);
226
+ const result = changed ? next : v;
227
+ memo.set(v, result);
228
+ return result;
229
+ }
230
+ if (isPlainObject(v)) {
231
+ if (onStack.has(v))
232
+ return v;
233
+ const done = memo.get(v);
234
+ if (done !== undefined)
235
+ return done;
236
+ onStack.add(v);
237
+ let changed = false;
238
+ const next = {};
239
+ for (const [k, item] of Object.entries(v)) {
240
+ const w = walk(item, [...segs, k]);
241
+ if (w !== item)
242
+ changed = true;
243
+ Object.defineProperty(next, k, { value: w, enumerable: true, writable: true, configurable: true });
244
+ }
245
+ onStack.delete(v);
246
+ const result = changed ? next : v;
247
+ memo.set(v, result);
248
+ return result;
249
+ }
250
+ return v;
251
+ };
252
+ const value = walk(details, []);
253
+ await Promise.all(puts);
254
+ return { value };
255
+ }
182
256
  export function createReadToolResultTool(store) {
183
257
  return defineTool({
184
258
  name: OFFLOAD_TOOL_NAME,
@@ -4188,11 +4188,13 @@ export interface RunnerDeps {
4188
4188
  * they confirmed once is not asked about again.
4189
4189
  *
4190
4190
  * This is the one seam in this file that LOOSENS, and it is shaped so it can only do so within limits
4191
- * the engine holds. The provider hands out a READ face anchored to one verified principal; the write
4192
- * face is core-private, so a deployment has no API path to put a rule in — that goes through the
4193
- * approval-record protocol, which requires a confirmed human decision. Rules are consumed post-fold, in
4194
- * the gate's ask branch, and never resolve an ask carrying `requiresRealApproval` or one a PreToolUse
4195
- * hook raised.
4191
+ * the engine holds. The provider hands out a READ face anchored to one verified principal. The write
4192
+ * face is an exported BACKEND CONTRACT (ruled 2026-08-10: an out-of-repo store twin builds against the
4193
+ * same definitions instead of mirroring them), but the ENGINE reaches it only on the consent lanes
4194
+ * redemption of a confirmed human decision, the tighten-delete, and the sync join a wiring invariant
4195
+ * pinned by a registered-caller scan (test/permission-rule-writer-callers); no exported convenience
4196
+ * mints a rule around consent. Rules are consumed post-fold, in the gate's ask branch, and never
4197
+ * resolve an ask carrying `requiresRealApproval` or one a PreToolUse hook raised.
4196
4198
  *
4197
4199
  * Omitted ⇒ the lane does not exist: no rules are read, no field is added to any ask, and the decision
4198
4200
  * path is byte-identical to a build without it. An unauthenticated task (no `principal`) resolves to
package/dist/index.d.ts CHANGED
@@ -88,7 +88,7 @@ export { HAND_TOOL_EFFECTS, bashReversibilityProbe, BASH_READONLY_DEFAULT_ALLOW,
88
88
  export { classifyCompoundReadonlyDetailed, formatOutOfRootReadApprovalOption, type BashReadonlyRootBoundary, type CompoundReadonlyVerdict, } from "./tools/fs/index.js";
89
89
  export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
90
90
  export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, assertSafeToolResultRef, buildToolResultRef, type ToolResultStore, type ToolResultSlice, } from "./core/tool-result-store.js";
91
- export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, LEGACY_PENDING_STEER_INPUT_ID, type ActorAssertion, type PendingSteerEntry, type PendingSteerInput, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, type RiskDescriptor, type CheckpointStore, type CheckpointSummary, type Checkpoint, type CheckpointToken, type CheckpointGate, type CheckpointState, type SerializedCheckpointState, type CheckpointFaultMode, type PendingAction, type ResumeOutcome, type ResolvedOutcome, type ReopenReason, type ResolveExpectation, type SafetyAxis, type ResourceLedger, type ResourceLimitReason, type PlatformLimitReason, } from "./core/checkpoint-store.js";
91
+ export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, ORG_ADMISSION_CHECKPOINT_VERSION, F012_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, TOKEN_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingTokens, winnerFromOutcome, validatePendingSteer, readPendingSteerQueue, appendPendingSteer, MAX_PENDING_STEER_CHARS, MAX_PENDING_STEER_ENTRIES, PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES, PENDING_STEER_FROZEN_FIELDS, ACTOR_ASSERTION_FROZEN_FIELDS, MAX_ACTOR_FIELD_CHARS, MAX_STEER_INPUT_ID_CHARS, LEGACY_PENDING_STEER_INPUT_ID, type ActorAssertion, type PendingSteerEntry, type PendingSteerInput, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, type RiskDescriptor, type CheckpointStore, type CheckpointSummary, type Checkpoint, type CheckpointToken, type CheckpointGate, type CheckpointState, type SerializedCheckpointState, type CheckpointFaultMode, type PendingAction, type ResumeOutcome, type ResolvedOutcome, type ReopenReason, type ResolveExpectation, type SafetyAxis, type RealApprovalGateBit, type ResourceLedger, type ResourceLimitReason, type PlatformLimitReason, } from "./core/checkpoint-store.js";
92
92
  export { InMemoryUsageWindowStore, GLOBAL_USAGE_KEY, EMPTY_USAGE_WINDOW_RECORD, chargeUsageRecord, readUsageRecord, usageRetryAfterMs, resolveUsageWindows, type UsageWindow, type UsageWindowStore, type UsageWindowReading, type UsageWindowRecord, type UsageSlot, type UsageBucketRow, } from "./core/usage-window-store.js";
93
93
  export { FileUsageWindowStore } from "./stores/file/usage-window-store.js";
94
94
  export { ENV_LIFETIME_SUSPEND_MARGIN_MS, USAGE_WINDOW_REAP_MARGIN_MS } from "./core/runner/prepare-task.js";
@@ -136,14 +136,20 @@ export { createPermissionRulePolicy, validatePermissionRules, parsePermissionRul
136
136
  /**
137
137
  * design/179 — persisted ALLOW rules: the standing form of approvals a person already gave.
138
138
  *
139
- * Note what is NOT here. `PermissionRuleWriter` is core-private and never exported, so a deployment has
140
- * no API path that puts a rule in the store; every rule that enters through the engine comes out of the
141
- * consent protocol below (a durable approval record, an authenticated confirmation, a principal-bound
142
- * redemption). Removal is exported without that ceremony, because narrowing on a user's behalf is allowed
143
- * and widening is not.
139
+ * The consent boundary, restated precisely (ruled 2026-08-10): the ENGINE's only write
140
+ * path into a rule store is the consent protocol below (a durable approval record, an authenticated
141
+ * confirmation, a principal-bound redemption). That fact is a property of the engine's wiring, not of
142
+ * what this file exports a deployment always owned its own storage bytes. The BACKEND CONTRACT
143
+ * (writer types, the writer handle key, and the fold pure functions) is therefore exported further
144
+ * down, so an out-of-repo store implementation (the server's SQL twin) is built against the same
145
+ * definitions as the file backend instead of mirroring them — mirrored types are how comment/contract
146
+ * drift starts. What stays deliberately absent is any convenience that mints rules AROUND consent:
147
+ * exporting the contract gives a store implementer the same powers it already had over its own bytes,
148
+ * and nothing more. Removal is exported without ceremony, because narrowing on a user's behalf is
149
+ * allowed and widening is not.
144
150
  */
145
151
  export { parseAllowRuleText, formatAllowRuleText, ruleAdmitsCommand, findAdmittingRule, suggestRulesForCommand, scopeCoversCwd, pathWithinRoot, isRuleLive, BARE_INTERPRETER_NAMES, MAX_RULE_TEXT_CHARS, type PersistedAllowRule, type RuleTombstone, type RuleScope, type RuleDot, type RuleAdd, type RuleAddOrigin, type RuleSuggestion, type RuleReject, type RuleRejectCode, type ParsedAllowRule, type PersistedRuleTool, type PersistedRuleMatch, } from "./core/permission-rule-model.js";
146
- export { removePersistedRule, applyTombstones, sameScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, type PermissionRuleStore, type PermissionRuleStoreProvider, type StoredAllowRules, type RemoveResult, type PutResult, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, type RuleSyncState, type RuleSyncFrontier, type RuleSyncDrop, type RuleSyncLandingReport, type RuleOwner, type QuarantinedRuleAdd, } from "./core/permission-rule-store.js";
152
+ export { removePersistedRule, applyTombstones, sameScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, type PermissionRuleStore, type PermissionRuleStoreProvider, type StoredAllowRules, type RemoveResult, type PutResult, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, type RuleSyncState, type RuleSyncFrontier, type RuleSyncDrop, type RuleSyncLandingReport, type RuleOwner, type QuarantinedRuleAdd, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, type PermissionRuleWriter, type WritablePermissionRuleStore, type RuleWriteDelta, type RuleAddDelta, type RuleDeleteDelta, type RuleSyncJoinDelta, type RawRuleSyncState, type RedemptionAuthorization, } from "./core/permission-rule-store.js";
147
153
  export { syncPermissionRules, parseRuleSyncResponse, PERMISSION_RULE_SYNC_PATH, LOCAL_OWNER_UNSYNCABLE_CODE, type PermissionRuleSyncTransport, type PermissionRuleSyncResult, type RuleSyncRequestBody, type RuleSyncResponseBody, } from "./core/permission-rule-sync.js";
148
154
  export { createOrgRuleOverlay, orgRuleVerdictFor, effectivePermissionRules, orgRuleStatePersistenceOf, ORG_UNAVAILABLE_DECISION_REASON, ORG_RULE_DECISION_REASON, ORG_ADJUDICATION_TIMEOUT_MS, type OrgPermissionRule, type OrgRuleSnapshot, type OrgRuleSnapshotProvider, type OrgRuleStatePersistence, type PersistedOrgRuleState, type OrgRuleOverlay, type OrgOverlayResolution, type OrgOverlayStatus, type EffectivePermissionRule, } from "./core/permission-rule-org.js";
149
155
  export { RULE_SYNC_DROP_CODES, type RuleSyncDropReason, type RuleQuarantineReason } from "./core/governance-codes.js";
package/dist/index.js CHANGED
@@ -112,7 +112,7 @@ export { buildAutoModePrompt, renderAutoModeWindow, renderAutoModeAction, AUTO_M
112
112
  export { AUTO_MODE_BASE_PROMPT, AUTO_MODE_PERMISSIONS_EXTERNAL } from "./core/auto-mode-prompt-assets.js";
113
113
  export { createPermissionRulePolicy, validatePermissionRules, parsePermissionRule, wildcardMatch, } from "./core/permission-rules.js";
114
114
  export { parseAllowRuleText, formatAllowRuleText, ruleAdmitsCommand, findAdmittingRule, suggestRulesForCommand, scopeCoversCwd, pathWithinRoot, isRuleLive, BARE_INTERPRETER_NAMES, MAX_RULE_TEXT_CHARS, } from "./core/permission-rule-model.js";
115
- export { removePersistedRule, applyTombstones, sameScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, } from "./core/permission-rule-store.js";
115
+ export { removePersistedRule, applyTombstones, sameScope, InMemoryPermissionRuleStore, EMPTY_RULE_STORE, joinRuleStates, screenRuleSyncState, collectBelowFrontier, ruleSyncVector, joinFrontiers, dotAtOrBelowFrontier, sameRuleOwner, PERMISSION_RULE_WRITER, writerOf, foldDelta, addDotsOf, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, } from "./core/permission-rule-store.js";
116
116
  export { syncPermissionRules, parseRuleSyncResponse, PERMISSION_RULE_SYNC_PATH, LOCAL_OWNER_UNSYNCABLE_CODE, } from "./core/permission-rule-sync.js";
117
117
  export { createOrgRuleOverlay, orgRuleVerdictFor, effectivePermissionRules, orgRuleStatePersistenceOf, ORG_UNAVAILABLE_DECISION_REASON, ORG_RULE_DECISION_REASON, ORG_ADJUDICATION_TIMEOUT_MS, } from "./core/permission-rule-org.js";
118
118
  export { RULE_SYNC_DROP_CODES } from "./core/governance-codes.js";
@@ -500,8 +500,8 @@ export async function adoptLocalDataRoot(opts) {
500
500
  };
501
501
  }
502
502
  }
503
- if (opts.from.kind === "principal" && legBits[SESSION_POLICY_LEG]?.done !== true) {
504
- const rows = rewriteSessionPolicyRows(opts.root, opts.from.principal, opts.toPrincipal);
503
+ if (legBits[SESSION_POLICY_LEG]?.done !== true) {
504
+ const rows = rewriteSessionPolicyRows(opts.root, opts.from.kind === "principal" ? opts.from.principal : null, opts.toPrincipal);
505
505
  legBits = { ...legBits, [SESSION_POLICY_LEG]: { done: true, rows } };
506
506
  publishMarker(opts.root, adoptionId, opts.from, opts.toPrincipal, 2, plan, legBits);
507
507
  }
@@ -557,12 +557,7 @@ export async function adoptLocalDataRoot(opts) {
557
557
  }
558
558
  if (plan.rulesDir !== undefined)
559
559
  legs.push({ store: "permission-rule", action: "bucket-rebind" });
560
- if (opts.from.kind === "principal") {
561
- legs.push({ store: SESSION_POLICY_LEG, action: "row-rewrite", rows: legBits[SESSION_POLICY_LEG]?.rows ?? 0 });
562
- }
563
- else {
564
- legs.push({ store: SESSION_POLICY_LEG, action: "none" });
565
- }
560
+ legs.push({ store: SESSION_POLICY_LEG, action: "row-rewrite", rows: legBits[SESSION_POLICY_LEG]?.rows ?? 0 });
566
561
  const carried = Object.entries(legBits)
567
562
  .filter(([k]) => k.startsWith(CARRIAGE_BIT_PREFIX))
568
563
  .map(([k, bit]) => ({
@@ -153,18 +153,26 @@ export type RootAdoptionFile = {
153
153
  export declare const ROOT_ADOPTION_FILE = "adoption.json";
154
154
  /**
155
155
  * design/183 §6 (r4) — the CLOSED SET of by-design-not-migrated assets, verbatim from the design's
156
- * written list. Lives HERE (beside the reader) because the terminal validator enforces EXACT
157
- * equality: the report is the account's recovery source, and a reduced-or-grown list at read time
158
- * is either damage or drift — both refused (§10.5 ①).
156
+ * written list. The WRITER emits exactly this list and the drift pin (§10.5 ① — the design-vs-code
157
+ * two-cell guard) lives in the TEST GRID, where a ruled growth of the set is a deliberate edit.
158
+ *
159
+ * #133: the terminal READ validator deliberately does NOT compare a stored report against this
160
+ * constant. A persisted report is a snapshot of the set AS RULED AT ADOPTION TIME; the set is
161
+ * allowed to grow by ruling (§10.5 ① names that road), so an exact-equality read check would mark
162
+ * every already-adopted root corrupt on the release after any growth (and again on rollback) — 13
163
+ * construction sites all refusing to serve. The read validator checks SHAPE and integrity
164
+ * (non-empty, well-formed entries, no duplicate assets — what an empty/mangled account rebuild
165
+ * actually needs), never version-crossed content equality.
159
166
  */
160
167
  export declare const NOT_MIGRATED_BY_DESIGN: ReadonlyArray<{
161
168
  asset: string;
162
169
  ruling: string;
163
170
  }>;
164
- /** The five baseline obligation IDENTITIES (design/183 §6 minimum face). The terminal validator
165
- * requires them all: a "reduced but shaped" report would otherwise rebuild a thinner account.
166
- * The writer's `baselineConfigs` emits exactly these any drift breaks the writer's own
167
- * read-back immediately (self-checking pair). */
171
+ /** The five baseline obligation IDENTITIES (design/183 §6 minimum face). Since #133 the terminal READ
172
+ * validator deliberately does NOT compare a stored report against this table (the stored report is a
173
+ * snapshot of the set as ruled at adoption time; see the validator's own note) — the enforcement that
174
+ * the WRITER emits exactly this set lives in the test grid (test/file-adoption-root.test.ts pins the
175
+ * fresh report's identities against this constant), where a drift is a deliberate, reviewable edit. */
168
176
  export declare const BASELINE_CONFIG_IDENTITIES: ReadonlyArray<{
169
177
  deployment: string;
170
178
  key: string;
@@ -74,7 +74,7 @@ function isRootAdoptionFileShape(v) {
74
74
  }
75
75
  }
76
76
  const legs = (m.legs ?? {});
77
- if (m.phase >= 3 && m.from.kind === "principal" && legs["session-policy"]?.done !== true)
77
+ if (m.phase >= 3 && legs["session-policy"]?.done !== true)
78
78
  return false;
79
79
  if (m.phase === 5) {
80
80
  for (const store of p.carriage) {
@@ -123,14 +123,21 @@ function isRootAdoptionFileShape(v) {
123
123
  return false;
124
124
  configIds.add(id);
125
125
  return true;
126
- }) &&
127
- BASELINE_CONFIG_IDENTITIES.every((b) => configIds.has(JSON.stringify([b.deployment, b.key])));
126
+ });
128
127
  const nmd = r.notMigratedByDesign;
128
+ const nmdAssets = new Set();
129
129
  const closedSetOk = Array.isArray(nmd) &&
130
- nmd.length === NOT_MIGRATED_BY_DESIGN.length &&
131
- NOT_MIGRATED_BY_DESIGN.every((m, i) => {
132
- const e = nmd[i];
133
- return (e !== null && typeof e === "object" && e.asset === m.asset && e.ruling === m.ruling);
130
+ nmd.length > 0 &&
131
+ nmd.every((e) => {
132
+ if (e === null || typeof e !== "object")
133
+ return false;
134
+ const m = e;
135
+ if (typeof m.asset !== "string" || m.asset === "" || typeof m.ruling !== "string" || m.ruling === "")
136
+ return false;
137
+ if (nmdAssets.has(m.asset))
138
+ return false;
139
+ nmdAssets.add(m.asset);
140
+ return true;
134
141
  });
135
142
  const legsOk = Array.isArray(r.legs) &&
136
143
  r.legs.every((l) => {
@@ -31,6 +31,15 @@ export interface FileSessionPolicyStoreOptions {
31
31
  export declare class FileSessionPolicyStore implements SessionPolicyStore {
32
32
  private readonly dir;
33
33
  private readonly onCorruptRead;
34
+ /** #132 — the ADOPTED identity of a local-owner root's anonymous lane, or undefined on an
35
+ * unadopted (or principal-adopted) root. A local-owner adoption rebinds the anonymous
36
+ * `[sid,null]` estate rows to `toPrincipal`; until the deployment's principal wiring lands
37
+ * (`REQUIRE_PRINCIPAL` et al. — recorded `migrated:false` in the config account, an operator
38
+ * action), the runtime still queries anonymously, and ENOENT there would silently drop the very
39
+ * tighten-only deny rules the rebind moved. Adoption is a transfer of the whole anonymous
40
+ * identity, so on such a root the anonymous key IS the adopted principal — reads and writes
41
+ * alike (a window-era anonymous write must not mint a fresh orphan row). */
42
+ private readonly anonymousAlias;
34
43
  constructor(root: string, opts?: FileSessionPolicyStoreOptions);
35
44
  /** The one delivery point for {@link onCorruptRead}. Swallow-guarded here so no caller has to remember. */
36
45
  private disclose;
@@ -41,7 +50,8 @@ export declare class FileSessionPolicyStore implements SessionPolicyStore {
41
50
  * (swallow-guarded; never on a plain ENOENT, which really is absence). */
42
51
  private discloseCorrupt;
43
52
  /** `(sessionId, principal)` → a safe, INJECTIVE filename (sanitizeScope appends the full sha256 of the raw
44
- * composite key, so distinct keys never collide on disk). */
53
+ * composite key, so distinct keys never collide on disk). On a local-owner-adopted root the
54
+ * anonymous lane resolves to the adopted principal's key (#132 — see {@link anonymousAlias}). */
45
55
  private pathFor;
46
56
  private read;
47
57
  getRules(sessionId: string, principal?: string): Promise<StoredSessionRules | null>;
@@ -2,15 +2,19 @@ import { readFileSync, readdirSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { loosenReasons, normalizeRules, stripRev, SessionPolicyError, } from "../../core/session-policy-store.js";
4
4
  import { atomicWriteFile, ensureDir, sanitizeScope } from "./fs-atomic.js";
5
- import { assertAdoptionBootGate } from "./adoption/marker.js";
5
+ import { assertAdoptionBootGate, readRootAdoptionFile } from "./adoption/marker.js";
6
6
  export class FileSessionPolicyStore {
7
7
  dir;
8
8
  onCorruptRead;
9
+ anonymousAlias;
9
10
  constructor(root, opts) {
10
11
  assertAdoptionBootGate(root, "FileSessionPolicyStore");
11
12
  this.dir = join(root, "session-policy");
12
13
  ensureDir(this.dir);
13
14
  this.onCorruptRead = opts?.onCorruptRead;
15
+ const adoption = readRootAdoptionFile(root);
16
+ this.anonymousAlias =
17
+ adoption !== undefined && "adopted" in adoption && adoption.adopted.from.kind === "local-owner" ? adoption.adopted.toPrincipal : undefined;
14
18
  }
15
19
  disclose(info) {
16
20
  try {
@@ -23,7 +27,8 @@ export class FileSessionPolicyStore {
23
27
  this.disclose({ sessionId, ...(principal !== undefined ? { principal } : {}), path: this.pathFor(sessionId, principal), reason });
24
28
  }
25
29
  pathFor(sessionId, principal) {
26
- const composite = JSON.stringify([sessionId, principal ?? null]);
30
+ const effective = principal ?? this.anonymousAlias;
31
+ const composite = JSON.stringify([sessionId, effective ?? null]);
27
32
  return join(this.dir, `${sanitizeScope(composite)}.json`);
28
33
  }
29
34
  read(sessionId, principal) {
@@ -78,7 +83,7 @@ export class FileSessionPolicyStore {
78
83
  }
79
84
  }
80
85
  const next = { ...clean, rev: (prior?.rev ?? 0) + 1 };
81
- atomicWriteFile(join(this.dir, "tmp"), this.pathFor(sessionId, principal), JSON.stringify({ ...next, __sid: sessionId, __principal: principal ?? null }));
86
+ atomicWriteFile(join(this.dir, "tmp"), this.pathFor(sessionId, principal), JSON.stringify({ ...next, __sid: sessionId, __principal: (principal ?? this.anonymousAlias) ?? null }));
82
87
  return next;
83
88
  }
84
89
  async listBySession(sessionId) {
@@ -14,9 +14,12 @@ import { BASH_READONLY_DEFAULT_ALLOW, coarseReadonlyCheck, classifyBoundedReadon
14
14
  export function bashReversibilityProbe(allow, boundary) {
15
15
  const allowSet = new Set(allow ?? BASH_READONLY_DEFAULT_ALLOW);
16
16
  return (args) => {
17
- const command = args?.command;
17
+ const a = args;
18
+ const command = a?.command;
18
19
  if (typeof command !== "string")
19
20
  return { reversible: false };
21
+ if (a?.run_in_background === true)
22
+ return { reversible: false };
20
23
  const resolved = typeof boundary === "function" ? boundary() : boundary;
21
24
  if (classifyCompoundReadonly(command, allowSet, resolved) === undefined)
22
25
  return { reversible: true };
@@ -555,8 +558,8 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
555
558
  const bgCappedByMax = appliedTimeoutSec !== undefined && requestedTimeoutSec !== undefined && typeof bgCaps.maxBgTimeoutSec === "number" && requestedTimeoutSec > bgCaps.maxBgTimeoutSec;
556
559
  const budgetNote = appliedTimeoutSec !== undefined
557
560
  ? bgCappedByMax
558
- ? ` Time budget: requested ${requestedTimeoutSec}s, capped at ${bgCaps.maxBgTimeoutSec}s (env ceiling: requests above ${bgCaps.maxBgTimeoutSec}s are reduced to it) — auto-terminates if still running after ${appliedTimeoutSec}s.`
559
- : ` Time budget: auto-terminates if still running after ${appliedTimeoutSec}s (hard cap ${bgCaps.maxBgTimeoutSec}s).`
561
+ ? ` Time budget: requested ${requestedTimeoutSec}s, capped at ${bgCaps.maxBgTimeoutSec}s (env ceiling: requests above ${bgCaps.maxBgTimeoutSec}s are reduced to it) — the process may run up to ${appliedTimeoutSec}s.`
562
+ : ` Time budget: the process may run up to ${appliedTimeoutSec}s (hard cap ${bgCaps.maxBgTimeoutSec}s).`
560
563
  : "";
561
564
  const lifetimeNote = backgroundLifetimeNote(bgCaps, bgSessionScoped, bgEnvIsolatedOwned);
562
565
  const registry = taskOpts.taskRegistry;
@@ -655,7 +658,7 @@ export function createBashTool(env, rootCanonical, coAuthor = false, cwdRef = {
655
658
  const tail = stdoutSoFar.slice(-1_000);
656
659
  const adoptCaps = detachEnv.backgroundCapabilities;
657
660
  const adoptBudgetNote = typeof adoptCaps.defaultBgTimeoutSec === "number" && typeof adoptCaps.maxBgTimeoutSec === "number"
658
- ? ` Time budget: auto-terminates if still running after ${Math.min(adoptCaps.defaultBgTimeoutSec, adoptCaps.maxBgTimeoutSec)}s (hard cap ${adoptCaps.maxBgTimeoutSec}s).`
661
+ ? ` Time budget: the process may run up to ${Math.min(adoptCaps.defaultBgTimeoutSec, adoptCaps.maxBgTimeoutSec)}s (hard cap ${adoptCaps.maxBgTimeoutSec}s).`
659
662
  : "";
660
663
  return {
661
664
  content: (cause === "timeout"
@@ -121,7 +121,7 @@ export function createMonitorTool(env, opts) {
121
121
  const envWallMs = bgTimeout !== undefined ? bgTimeout * 1000 : undefined;
122
122
  const envWallWins = !isPersistent && envWallMs !== undefined && envWallMs < timeoutMs;
123
123
  const persistentBudget = bgTimeout !== undefined
124
- ? `the execution environment kills background processes after ${bgTimeout}s`
124
+ ? `the execution environment's background time budget ends the watch after ${bgTimeout}s`
125
125
  : `the execution environment's background time budget still applies`;
126
126
  const retainedByEnv = env.backgroundCapabilities.retainBackgroundProcesses === true;
127
127
  const retainedBySpec = opts.retainBackgroundProcesses === true;
@@ -142,8 +142,8 @@ export function createMonitorTool(env, opts) {
142
142
  const lifetime = isPersistent
143
143
  ? `It is persistent: no watch timeout of its own${timeoutIgnoredNote}, but ${persistentBudget}${persistentAnchor}`
144
144
  : envWallWins
145
- ? `It will be killed after ${envWallMs}ms if still running — the execution environment's background time budget (${bgTimeout}s) is shorter than the ${timeoutMs}ms watch timeout${clampNote}, and the shorter of the two wins.`
146
- : `It will be killed after ${timeoutMs}ms${clampNote} if still running.`;
145
+ ? `The watch may run up to ${envWallMs}ms — the execution environment's background time budget (${bgTimeout}s) is shorter than the ${timeoutMs}ms watch timeout${clampNote}, and the shorter of the two wins.`
146
+ : `The watch may run up to ${timeoutMs}ms${clampNote}.`;
147
147
  const content = onNotify !== undefined
148
148
  ? `Monitoring in background; task_id=${taskId}. Each stdout line becomes a notification event (lines within ` +
149
149
  `~200ms are batched); you'll get a final notification with the exit code when it ends — do not poll. ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.23.0",
3
+ "version": "5.24.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",