@sema-agent/core 7.30.1 → 7.31.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 (80) hide show
  1. package/CHANGELOG.md +284 -0
  2. package/dist/agents/cross-session-judge.d.ts +4 -12
  3. package/dist/agents/cross-session-judge.js +4 -11
  4. package/dist/agents/peer-session-drain.js +0 -5
  5. package/dist/agents/send-message-tool.js +1 -0
  6. package/dist/agents/subagent.d.ts +1 -0
  7. package/dist/agents/subagent.js +1 -0
  8. package/dist/core/ask-origin.d.ts +6 -0
  9. package/dist/core/ask-origin.js +10 -0
  10. package/dist/core/checkpoint-store.d.ts +14 -0
  11. package/dist/core/checkpoint-store.js +1 -0
  12. package/dist/core/engine-notice.d.ts +4 -0
  13. package/dist/core/governance-codes.d.ts +1 -1
  14. package/dist/core/governance-codes.js +4 -0
  15. package/dist/core/memory-engine/data-plane.d.ts +3 -1
  16. package/dist/core/memory-engine/data-plane.js +1 -1
  17. package/dist/core/memory-engine/delegation-settlement.d.ts +44 -3
  18. package/dist/core/memory-engine/delegation-settlement.js +38 -3
  19. package/dist/core/memory-engine/engine.d.ts +133 -27
  20. package/dist/core/memory-engine/engine.js +452 -151
  21. package/dist/core/memory-engine/entry-scan.d.ts +23 -0
  22. package/dist/core/memory-engine/entry-scan.js +55 -0
  23. package/dist/core/memory-engine/file-backend.d.ts +31 -23
  24. package/dist/core/memory-engine/file-backend.js +174 -100
  25. package/dist/core/memory-engine/layer-observations.d.ts +109 -0
  26. package/dist/core/memory-engine/layer-observations.js +148 -0
  27. package/dist/core/memory-engine/layout.d.ts +40 -4
  28. package/dist/core/memory-engine/layout.js +43 -7
  29. package/dist/core/memory-engine/types.d.ts +26 -2
  30. package/dist/core/parent-spec-seat.d.ts +4 -1
  31. package/dist/core/parent-spec-seat.js +2 -0
  32. package/dist/core/park-selfcheck.js +1 -0
  33. package/dist/core/runner/announce-once-ledger.d.ts +26 -0
  34. package/dist/core/runner/announce-once-ledger.js +37 -0
  35. package/dist/core/runner/contracts.d.ts +22 -5
  36. package/dist/core/runner/denial-limit-arms.d.ts +2 -2
  37. package/dist/core/runner/prepare-acquire-reconcile.d.ts +0 -4
  38. package/dist/core/runner/prepare-acquire-reconcile.js +1 -3
  39. package/dist/core/runner/prepare-file-history.d.ts +13 -15
  40. package/dist/core/runner/prepare-file-history.js +57 -43
  41. package/dist/core/runner/prepare-hands-readface.d.ts +2 -2
  42. package/dist/core/runner/prepare-listings.d.ts +3 -1
  43. package/dist/core/runner/prepare-listings.js +2 -2
  44. package/dist/core/runner/prepare-memory.js +16 -5
  45. package/dist/core/runner/prepare-park-ask.js +27 -16
  46. package/dist/core/runner/prepare-prompt-inputs.d.ts +8 -1
  47. package/dist/core/runner/prepare-prompt-inputs.js +5 -3
  48. package/dist/core/runner/prepare-task.js +7 -5
  49. package/dist/core/runner/prepare-wiring-manifest.d.ts +1 -0
  50. package/dist/core/runner/prepare-wiring-manifest.js +2 -1
  51. package/dist/core/runner/run-attachment-seats.js +1 -1
  52. package/dist/core/runner/run-leg.js +61 -52
  53. package/dist/core/runner/run-settle-and-teardown.js +4 -0
  54. package/dist/core/runner/run-turn-boundary.js +6 -6
  55. package/dist/core/runner/turn-attachments.d.ts +6 -6
  56. package/dist/core/runner-deps.d.ts +3 -1
  57. package/dist/core/secret-env.d.ts +12 -1
  58. package/dist/core/secret-env.js +27 -2
  59. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +2 -0
  60. package/dist/core/store-contracts/checkpoint-store-contract.js +22 -0
  61. package/dist/core/task-result.d.ts +1 -1
  62. package/dist/core/task-spec.d.ts +17 -8
  63. package/dist/core/tool-spec.d.ts +1 -1
  64. package/dist/core/wiring-manifest.d.ts +10 -0
  65. package/dist/core/wiring-manifest.js +7 -1
  66. package/dist/engine/execution-env/node-execution-env.d.ts +6 -0
  67. package/dist/engine/execution-env/node-execution-env.js +15 -17
  68. package/dist/engine/harness/agent-harness.js +7 -5
  69. package/dist/engine/lsp/node-lsp-manager.d.ts +4 -0
  70. package/dist/engine/lsp/node-lsp-manager.js +10 -4
  71. package/dist/index.d.ts +1 -1
  72. package/dist/index.js +1 -1
  73. package/dist/tools/fs/shell-read-plan.d.ts +2 -2
  74. package/dist/tools/fs/shell-read-plan.js +15 -10
  75. package/dist/tools/fs/shell-read-word.d.ts +5 -4
  76. package/dist/tools/fs/shell-read-word.js +3 -2
  77. package/package.json +1 -1
  78. package/test/export-surface.snapshot.json +7 -1
  79. package/wire-changes/core@7.31.0.json +705 -0
  80. package/wire-changes/core@7.31.1.json +189 -0
@@ -277,6 +277,10 @@ export interface EngineNotice {
277
277
  * - `"config.execution_env_capability_invalid"` — a remote env DECLARES a boolean capability key
278
278
  * (`isolation`, `suspendable`, `canonicalPathAuthoritative`) with a non-boolean value; ABSENCE is a
279
279
  * supported reading of each and stays silent. Once per key per run; `detail: { key, valueType }`.
280
+ * - `"config.secret_env_scrubbed"` (#1081) — the D1 fail-closed `scrub` policy withheld one or more
281
+ * secret-shaped keys from a spawned child's environment (a tool's shell, or a language server).
282
+ * Once per PROCESS, shared by every scrub site ({@link import("./secret-env.js").announceSecretEnvScrub});
283
+ * `detail: { names }` — every withheld key's name, never a value.
280
284
  * - `"config.durable_gate_unavailable"` — a durable-approval MANDATE is in force on a leg with NO
281
285
  * checkpoint store: the per-principal `forceDurableGate` entitlement, or the task's own
282
286
  * `durableApproval` opt-in with no live approver seat (`durableMandateSourceOf`, the predicate the
@@ -119,7 +119,7 @@ export type NoticeAudience = "user" | "operator";
119
119
  * the union's catalog ({@link EngineNotice.code}). The groups below say only which family a row joins,
120
120
  * so the catalog is not written a second time here and the two cannot drift.
121
121
  */
122
- 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", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "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", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
122
+ 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", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "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", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
123
123
  /** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
124
124
  * `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
125
125
  * sink is a supported shape, and narrowing that field would break it. */
@@ -37,6 +37,7 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
37
37
  "memory.restore_withheld",
38
38
  "memory.project_scope_absent",
39
39
  "memory.projection_failed",
40
+ "memory.observation_settlement_unavailable",
40
41
  "memory.consolidation_disabled",
41
42
  "memory.consolidation_gated",
42
43
  "memory.consolidation_busy",
@@ -82,6 +83,7 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
82
83
  "memory.capture_optout_unpersisted",
83
84
  "memory.capture_optout_unavailable",
84
85
  "memory.capture_optout_sweep_failed",
86
+ "memory.target_outside_store",
85
87
  ]);
86
88
  export function governanceRetryClass(code) {
87
89
  if (Object.prototype.hasOwnProperty.call(GOVERNANCE_CODES, code)) {
@@ -125,6 +127,7 @@ export const ENGINE_NOTICE_CODES = [
125
127
  "config.durable_gate_unavailable",
126
128
  "config.peer_admission_out_of_range",
127
129
  "config.peer_lane_unmounted",
130
+ "config.secret_env_scrubbed",
128
131
  "peer.inbound_disposition",
129
132
  "peer.held_settled",
130
133
  "peer.idle_subscription",
@@ -217,6 +220,7 @@ const NOTICE_AUDIENCE_TABLE = {
217
220
  "config.tool_face_invalid": "operator",
218
221
  "config.peer_lane_unmounted": "operator",
219
222
  "config.peer_admission_out_of_range": "operator",
223
+ "config.secret_env_scrubbed": "operator",
220
224
  "peer.inbound_disposition": "user",
221
225
  "peer.held_settled": "user",
222
226
  "peer.idle_subscription": "user",
@@ -32,7 +32,9 @@ export interface RemoteMemoryBaseline {
32
32
  readonlyDirs?: string[];
33
33
  }
34
34
  export interface RemoteMaterialization {
35
- /** Ship these into the sandbox memory root (the deployment owns the transport + any chmod). */
35
+ /** Ship these into the sandbox memory root (the deployment owns the transport). `readonly` is the
36
+ * session's policy, enforced by {@link harvestFilesToPatches}' `readonly_layer` rejection; whether
37
+ * to also set permission bits on its own volume is the deployment's call — the engine sets none. */
36
38
  files: Array<RemoteMemoryFile & {
37
39
  readonly: boolean;
38
40
  }>;
@@ -2,7 +2,7 @@ import { uuidv7 } from "../../internal/harness.js";
2
2
  import { firstSentence } from "../memory.js";
3
3
  import { computeEntryRev, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
4
4
  import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
5
- import { MEMORY_INDEX_FILENAME } from "./file-backend.js";
5
+ import { MEMORY_INDEX_FILENAME } from "./entry-scan.js";
6
6
  import { scopeDirName } from "./layout.js";
7
7
  export const REMOTE_HARVEST_PER_FILE_BYTES = 100 * 1024;
8
8
  export const REMOTE_MASS_DELETION_FUSE_RATIO = 0.5;
@@ -153,6 +153,19 @@ export interface UnattributedMember {
153
153
  export interface SessionAccountRow {
154
154
  sessionId: string;
155
155
  openedAt: number;
156
+ /** The scope this session writes (its write home) — the layer it is a live writer of until
157
+ * `terminatedAt` ({@link openLayerWriters}). Absent on rows written before the field. */
158
+ writeScope?: string;
159
+ /** The session's leg ENDED here: its terminal harvest returned (whatever it admitted — a refused,
160
+ * zero-admission or budget-deferred pass ends the leg all the same), or the host valve closed the
161
+ * row. A lifecycle fact, independent of `closedAt` (every full harvest closes the account — a
162
+ * checkpoint harvest too — while the session may keep writing); cleared when a resume re-opens
163
+ * the row. */
164
+ terminatedAt?: number;
165
+ /** The LEG that holds the row now: minted by the materialize that opened (or re-opened) it and
166
+ * carried on that mount's handle. A close or a leg end made for another leg — an earlier leg
167
+ * whose harvest finished after a resume re-opened the row — is not this leg's and is not written. */
168
+ leg?: string;
156
169
  /** The STICKY unattributed set, frozen at materialize (§3.6 归属判据): pre-existing, uncommitted
157
170
  * files seen while a FOREIGN dangling open row stood. Membership never shrinks on later writes
158
171
  * (the sticky law); it clears only when the row is re-opened with a fresh classification (the
@@ -175,6 +188,20 @@ export declare function convertV1SidecarKeys(controlDir: string): void;
175
188
  /** The dangling-trigger read: open rows (and unadjudicated closes) belonging to OTHER sessions.
176
189
  * These are what arm the unattributed residue classification at a session's materialize. */
177
190
  export declare function foreignDanglingSessionAccounts(controlDir: string, selfSessionId: string): SessionAccountRow[];
191
+ /** The live writers of the store's layers, as the account knows them: every row other than the one
192
+ * held by `selfLeg` (the asking mount's own leg — another leg of the same session is another
193
+ * writer) whose session's leg has not ended (no `terminatedAt` — running, or ended without
194
+ * a terminal close, e.g. a crash), by its write scope. A row written before the write scope was
195
+ * recorded (the engine then closed the account at every full harvest, so its close cannot say
196
+ * whether the leg ended) reads as before: live while never closed, counted in `unscoped` — a
197
+ * writer of an unknown layer. Sessions that open no row (no session id, provenance "off", an
198
+ * adoption-restricted mount) are not visible here. */
199
+ export declare function openLayerWriters(controlDir: string, selfLeg: string | undefined): {
200
+ scopes: Set<string>;
201
+ unscoped: number;
202
+ };
203
+ /** The leg that holds `sessionId`'s account row (undefined ⇔ no row, or a row without one). */
204
+ export declare function sessionAccountLeg(controlDir: string, sessionId: string): string | undefined;
178
205
  /** The membership question the harvest asks per file: is `(scope, rel)` in the session's sticky
179
206
  * set? A session carrying members the conversion could not attribute answers yes for every seat. */
180
207
  export interface SessionUnattributedSet {
@@ -189,6 +216,8 @@ export declare function openSessionAccount(controlDir: string, input: {
189
216
  sessionId: string;
190
217
  now: () => number;
191
218
  unattributed: readonly UnattributedMember[];
219
+ writeScope?: string;
220
+ leg?: string;
192
221
  }): void;
193
222
  /** The current session's sticky unattributed set (the harvest's residue-arm input). */
194
223
  export declare function sessionUnattributedSet(controlDir: string, sessionId: string): SessionUnattributedSet;
@@ -203,13 +232,25 @@ export declare function sessionUnattributedSet(controlDir: string, sessionId: st
203
232
  * condition and does not hold the close, but the caller DISCLOSES any standing deferral beside it
204
233
  * (a close over id-less deferred files must not read as a clean full pass).
205
234
  *
206
- * Returns whether a row was actually closed: absent ⇒ `false` (a session that never opened an
235
+ * Returns what happened ({@link SessionAccountWrite}): `"absent"` (a session that never opened an
207
236
  * account — e.g. an adoption-restricted materialize — has no close, and the caller's disclosure
208
- * must not claim one). */
237
+ * must not claim one), or `"stale"` when `leg` names a mount that no longer holds the row. */
209
238
  export declare function closeSessionAccount(controlDir: string, input: {
210
239
  sessionId: string;
211
240
  now: () => number;
212
- }): boolean;
241
+ leg?: string;
242
+ }): SessionAccountWrite;
243
+ /** What a close / leg end did: wrote the row, found none, or found the row held by ANOTHER leg (the
244
+ * caller's leg is over — a resume re-opened the row — so nothing was written). */
245
+ export type SessionAccountWrite = "written" | "absent" | "stale";
246
+ /** Record that the session's leg ended ({@link SessionAccountRow.terminatedAt}). Touches nothing
247
+ * else — `closedAt` stays whatever the harvests made it. `leg` (the caller's mount) must hold the
248
+ * row: a leg that is over does not end the one that re-opened it. */
249
+ export declare function terminateSessionAccount(controlDir: string, input: {
250
+ sessionId: string;
251
+ now: () => number;
252
+ leg?: string;
253
+ }): SessionAccountWrite;
213
254
  /** The host valve for a dangling row (advisory; §3.6 — dangling rows never auto-expire). The
214
255
  * close is `closed-unadjudicated` (r7-3): it stops the row dangling but the residue arm keeps
215
256
  * firing until a full-domain harvest closes normally. `requestId` required (audit, #123). */
@@ -242,6 +242,8 @@ function malformedAccountRow(row) {
242
242
  !Number.isFinite(row.openedAt) ||
243
243
  !Array.isArray(row.unattributed) ||
244
244
  (row.closedAt !== undefined && (typeof row.closedAt !== "number" || !Number.isFinite(row.closedAt))) ||
245
+ (row.terminatedAt !== undefined && (typeof row.terminatedAt !== "number" || !Number.isFinite(row.terminatedAt))) ||
246
+ (row.leg !== undefined && !reqStr(row.leg)) ||
245
247
  (row.unadjudicated !== undefined && row.unadjudicated !== true));
246
248
  }
247
249
  function isUnattributedMember(x) {
@@ -273,6 +275,8 @@ function coerceSessionAccounts(raw) {
273
275
  bad();
274
276
  if (row.unattributable !== undefined && (!Array.isArray(row.unattributable) || row.unattributable.some((x) => typeof x !== "string")))
275
277
  bad();
278
+ if (row.writeScope !== undefined && !reqStr(row.writeScope))
279
+ bad();
276
280
  }
277
281
  }
278
282
  return rec.version === 1 ? { version: 1, rows: rows } : { version: 2, rows: rows };
@@ -320,6 +324,13 @@ export function foreignDanglingSessionAccounts(controlDir, selfSessionId) {
320
324
  const rec = readSessionAccountsConverted(controlDir);
321
325
  return rec.rows.filter((r) => r.sessionId !== selfSessionId && (r.closedAt === undefined || r.unadjudicated === true));
322
326
  }
327
+ export function openLayerWriters(controlDir, selfLeg) {
328
+ const open = readSessionAccountsConverted(controlDir).rows.filter((r) => (selfLeg === undefined || r.leg !== selfLeg) && (r.writeScope !== undefined ? r.terminatedAt === undefined : r.closedAt === undefined));
329
+ return { scopes: new Set(open.flatMap((r) => (r.writeScope !== undefined ? [r.writeScope] : []))), unscoped: open.filter((r) => r.writeScope === undefined).length };
330
+ }
331
+ export function sessionAccountLeg(controlDir, sessionId) {
332
+ return readSessionAccountsConverted(controlDir).rows.find((r) => r.sessionId === sessionId)?.leg;
333
+ }
323
334
  export function emptyUnattributedSet() {
324
335
  return { has: () => false, empty: true };
325
336
  }
@@ -330,8 +341,17 @@ export function openSessionAccount(controlDir, input) {
330
341
  lockedStrictUpdate(controlDir, SESSION_ACCOUNTS_FILE, "memory session-account ledger", coerceSessionAccounts, (read) => {
331
342
  const { rec } = convertSessionAccounts(read, controlDir);
332
343
  const row = rec.rows.find((r) => r.sessionId === input.sessionId);
344
+ if (row !== undefined && input.writeScope !== undefined)
345
+ row.writeScope = input.writeScope;
346
+ if (row !== undefined) {
347
+ delete row.terminatedAt;
348
+ if (input.leg !== undefined)
349
+ row.leg = input.leg;
350
+ else
351
+ delete row.leg;
352
+ }
333
353
  if (row === undefined) {
334
- rec.rows.push({ sessionId: input.sessionId, openedAt: input.now(), unattributed: [...input.unattributed] });
354
+ rec.rows.push({ sessionId: input.sessionId, openedAt: input.now(), ...(input.writeScope !== undefined ? { writeScope: input.writeScope } : {}), ...(input.leg !== undefined ? { leg: input.leg } : {}), unattributed: [...input.unattributed] });
335
355
  }
336
356
  else if (row.closedAt !== undefined && row.unadjudicated === undefined) {
337
357
  row.openedAt = input.now();
@@ -363,12 +383,26 @@ export function closeSessionAccount(controlDir, input) {
363
383
  const { rec } = convertSessionAccounts(read, controlDir);
364
384
  const row = rec.rows.find((r) => r.sessionId === input.sessionId);
365
385
  if (row === undefined)
366
- return { result: false };
386
+ return { result: "absent" };
387
+ if (input.leg !== undefined && row.leg !== input.leg)
388
+ return { result: "stale" };
367
389
  row.closedAt = input.now();
368
390
  delete row.unadjudicated;
369
391
  for (const r of rec.rows)
370
392
  delete r.unadjudicated;
371
- return { next: rec, result: true };
393
+ return { next: rec, result: "written" };
394
+ });
395
+ }
396
+ export function terminateSessionAccount(controlDir, input) {
397
+ return lockedStrictUpdate(controlDir, SESSION_ACCOUNTS_FILE, "memory session-account ledger", coerceSessionAccounts, (read) => {
398
+ const { rec } = convertSessionAccounts(read, controlDir);
399
+ const row = rec.rows.find((r) => r.sessionId === input.sessionId);
400
+ if (row === undefined)
401
+ return { result: "absent" };
402
+ if (input.leg !== undefined && row.leg !== input.leg)
403
+ return { result: "stale" };
404
+ row.terminatedAt = input.now();
405
+ return { next: rec, result: "written" };
372
406
  });
373
407
  }
374
408
  export function resolveSessionAccountRecord(controlDir, input) {
@@ -387,6 +421,7 @@ export function resolveSessionAccountRecord(controlDir, input) {
387
421
  }
388
422
  row.closedAt = input.now();
389
423
  row.unadjudicated = true;
424
+ row.terminatedAt = row.closedAt;
390
425
  return { next: rec, result: undefined };
391
426
  });
392
427
  }
@@ -1,6 +1,6 @@
1
1
  import type { EngineNotice } from "../types.js";
2
2
  import { type ConsolidationGateRead, type ConsolidationGateRow, type ConsolidationPlanFile, type ConsolidationProposal, type ConsolidationRunStopReason, type MemoryConsolidationOptions } from "./consolidation.js";
3
- import { foreignDanglingSessionAccounts, type EffectiveSettlement, type HoldRow } from "./delegation-settlement.js";
3
+ import { foreignDanglingSessionAccounts, type SessionAccountWrite, type EffectiveSettlement, type HoldRow } from "./delegation-settlement.js";
4
4
  import { type OriginClearanceRow } from "./origin-clearance.js";
5
5
  import { type CommittedBinding, type EraseMemoryEntriesInput, type MemoryErasureAttestation, type TransferEvidence } from "./file-backend.js";
6
6
  import { type MemoryExportBundle, type MemoryImportReport } from "./export-bundle.js";
@@ -155,7 +155,8 @@ export declare const MEMORY_ANNOUNCEMENT_CAPTURE_OPTOUT_CODA = "Any guidance in
155
155
  * about the state itself, and a model asked "remember X" under that silence answers with a success
156
156
  * receipt for a save that never happens (the confabulated-receipt shape). This section states the
157
157
  * state instead. Mounted only where "you cannot save" is provably TRUE: by `inject()` for a
158
- * read-only layering (writeScope null ⇒ chmod'd tree), and by the runner for handsReadOnly (the
158
+ * read-only layering (writeScope null ⇒ the harvest admits nothing and the file-tool gate refuses the
159
+ * whole memory domain), and by the runner for handsReadOnly (the
159
160
  * shell rides the read-only band), for a declared-unavailable session (`memoryPersistenceCapable:
160
161
  * false` — where the runner's write gate also refuses the file channel, keeping the engine-refusal
161
162
  * sentence true), and for a write-less roster the persistence inference cannot vouch for. A merely
@@ -310,7 +311,7 @@ export interface MemoryEngineOptions {
310
311
  /** M7 — max files PROCESSED per harvest (default {@link DEFAULT_HARVEST_FILE_BUDGET}). */
311
312
  harvestFileBudget?: number;
312
313
  /**
313
- * Where this engine's advisory incidents go. Two codes ride it:
314
+ * Where this engine's advisory incidents go. Codes that ride it include:
314
315
  * - `memory.partition_split` / `memory.partition_adopt_failed` — the mount-time partition-key probe,
315
316
  * when this engine derives its OWN control dir (no `controlDir`, backend pins none). Same seat and
316
317
  * same reason as the file backend's option: the probe promises "never silent", and on this
@@ -319,6 +320,9 @@ export interface MemoryEngineOptions {
319
320
  * The queue is the DISCLOSURE lane for out-of-session memory events, and its own failures used to
320
321
  * be swallowed at four call sites: a host whose control plane went unwritable kept seeing healthy
321
322
  * sessions while every gate/external notice was dropped on the floor.
323
+ * - `memory.observation_settlement_unavailable` — once per engine, at a zero-copy materialize over a
324
+ * backend without the committed-snapshot face: changes observed in read-only layers are recorded
325
+ * undecided and cannot settle on it.
322
326
  * Absent ⇒ advisory-silent; neither a mount nor a harvest ever fails over this seat.
323
327
  */
324
328
  onIncident?: MemoryPartitionIncidentSink;
@@ -343,8 +347,8 @@ export interface MemoryInjection {
343
347
  /** CC `# Memory` section (system-authority instruction — §0.3 逐字 surface). Empty for a read-only
344
348
  * layering (no write channel to instruct). */
345
349
  instruction: string;
346
- /** Present exactly when this is a read-only LAYERING (writeScope null — the tree is chmod'd
347
- * read-only, so no tool writes into it whatever the roster): the {@link MEMORY_READONLY_NOTICE}
350
+ /** Present exactly when this is a read-only LAYERING (writeScope null — the harvest admits
351
+ * nothing from this session, whatever the roster writes): the {@link MEMORY_READONLY_NOTICE}
348
352
  * section stating that memory cannot be saved to, so the model declines "remember X" instead of
349
353
  * issuing a confabulated success receipt. NOT set for `writeToolMounted:false` over a writable
350
354
  * scope — there only the instruction-named tool is absent, other roster tools can still write the
@@ -829,6 +833,8 @@ export interface SessionMemoryStatus {
829
833
  export declare class MemoryEngine {
830
834
  private readonly backend;
831
835
  private readonly memoryDir;
836
+ /** {@link memoryDir}'s physical form as this engine first mounted it; every later mount compares, never re-resolves. */
837
+ private mountedRoot;
832
838
  private readonly controlDir;
833
839
  private readonly now;
834
840
  private readonly perScopeBudgetBytes;
@@ -843,6 +849,7 @@ export declare class MemoryEngine {
843
849
  private readonly provenance;
844
850
  /** {@link MemoryEngineOptions.onIncident} — the advisory disclosure seat (partition + announce codes). */
845
851
  private readonly onIncident;
852
+ private unsettleableDisclosed;
846
853
  /** A backend-pinned directory (FileBackend zero-copy detection). */
847
854
  private readonly backendPinnedRoot?;
848
855
  constructor(opts: MemoryEngineOptions);
@@ -858,6 +865,94 @@ export declare class MemoryEngine {
858
865
  get provenanceMode(): "off" | "carry";
859
866
  /** {@link MemoryEngineOptions.consolidation}, screened — undefined ⇔ the protocol is OFF. */
860
867
  private readonly consolidation?;
868
+ /**
869
+ * Record that this mount's leg of `sessionId` ended — its terminal harvest returned or threw, or
870
+ * the run that mounted it will never harvest (a prepare that failed after the mount). Writer
871
+ * liveness (`SessionAccountRow.terminatedAt`) is the session's lifecycle, so this is the one mint
872
+ * of it besides the host valve. Only the leg the handle opened is ended (`"stale"`: a resume
873
+ * re-opened the row, and the later leg is still live). A record that cannot be written leaves the
874
+ * row reading live (restores of its layer withheld) and is disclosed, never silent.
875
+ */
876
+ endSessionLeg(handle: MemorySessionHandle, sessionId: string): SessionAccountWrite | undefined;
877
+ private endLeg;
878
+ /** design/336 §3.6 (slice 2) — the session-account incident channel (`memory.session_account_failed`):
879
+ * a degraded residue classification is DISCLOSED, never quiet (the behavioral fail-closed half —
880
+ * harvest refusing on `handle.sessionAccountFailed` — is enforced at the write boundary). */
881
+ private discloseSessionAccountIncident;
882
+ /** Read-only layer integrity, DETECTION half — run on every harvest path, before any exit. A
883
+ * read-only layer is this session's POLICY (the file-tool gate refuses writes into it; nothing on
884
+ * disk enforces it), so a layer file that differs from what this session was served is a
885
+ * `readonly_layer` rejection whatever the arm — unless its bytes EQUAL the committed copy: then a
886
+ * session that may write this layer committed them while this one ran (the same transactional
887
+ * proof the restricted committed view reads), and there is nothing to reject or put back.
888
+ * Returns what the RESTORE half may put back ({@link restoreReadOnlyLayers}): only a session that
889
+ * may commit ever restores, and only once the harvest's fail-closed preflights (journal recovery
890
+ * included) passed — a session that admits nothing (read-only layering, capture opt-out,
891
+ * persistence declared unavailable, an indeterminate capture state) only detects and reports: it
892
+ * never adjudicates a shared layer on disk, and the boundary audit of that arm names the same
893
+ * divergence on the inbound channel. */
894
+ private detectReadOnlyLayerChanges;
895
+ /** The committed copy of `id` read WITHOUT an adopting sync: a File backend's ordinary `getByIds`
896
+ * adopts pending disk edits across every registered scope, which a session that admits nothing
897
+ * must never trigger — its committed view (`restrictedAdoptionView`, audit off) is read instead.
898
+ * Zero-copy reads the committed shadow already, and a backend without that view has no read-side
899
+ * adoption channel (its plain reads serve committed rows). */
900
+ private committedContentUnadopted;
901
+ /** Read-only layer integrity, RESTORE half (zero-copy hazard: the changed file IS the backend's
902
+ * storage, and left in place a later read-side sync would adopt it as the layer's baseline). It
903
+ * acts on the committed account AS IT STANDS NOW — after the preflights recovered any interrupted
904
+ * transaction — never on what detection read before them: the file and its committed copy are
905
+ * read again, and bytes that now equal the committed copy were a commit (the early rejection was
906
+ * judged against a stale account and is withdrawn; nothing is written). A restore that could not
907
+ * land is a warning, never a silent skip. */
908
+ private restoreReadOnlyLayers;
909
+ /** Read-only layer integrity, OBSERVATION half — run on every harvest path with the detection
910
+ * half. Every file of a layer this session holds read-only whose bytes changed in the window
911
+ * (against the materialize-time census) is a candidate; one that no commit backs at its seat is
912
+ * recorded in the session-observation table, which every door into the committed account
913
+ * consults: the bytes are never adopted, whoever reads next. A file this session was not served
914
+ * (a new file, with or without an entry id) is rejected here; a served entry's change is the
915
+ * detection half's rejection. The commit question is answered only by a committed account that
916
+ * can be read whole — a pending transaction journal (a writer mid-commit, or one that died there)
917
+ * makes it undecidable, never "not committed": those candidates are recorded UNDECIDED (never
918
+ * adopted meanwhile) and returned; a writing session settles every undecided row once its
919
+ * preflight has recovered the journal (the restore half's rule). Zero-copy only (the census is taken there): that is where the
920
+ * observed plane IS the store the read side adopts from. A record that cannot be written is a
921
+ * warning — the next session may then adopt the bytes, which must not happen silently. */
922
+ private observeReadOnlyLayers;
923
+ /** Write an observation batch AS JUDGED (a retry writes the same rows — same baseline, same
924
+ * observation time — never a re-judgment against a later account). Only a landed batch claims
925
+ * anything: its rejections now, its undecided rows' disclosure at the conclusion. A failure is
926
+ * kept, and said only once no retry follows ({@link concludeObservations}). */
927
+ private landObservations;
928
+ /** Where the harvest leaves the observation half for good: a batch that never landed is said (once,
929
+ * with its last failure); a landed batch's undecided rows are named. */
930
+ private concludeObservations;
931
+ /** Candidates the committed account could not answer for are named, never silently dropped. */
932
+ private discloseUndecidedObservations;
933
+ /** The settlement of undecided observation rows — run by a session that may write, right after its
934
+ * preflight recovered any pending journal, each row by its own key: removed when the committed
935
+ * account backs its bytes at its seat (they are committed content), otherwise pinned to the
936
+ * committed rev read here — an ordinary row, retired like any other when that rev moves; a row the
937
+ * account still cannot answer for stays undecided.
938
+ * This session's own undecided candidates that settle
939
+ * as unexpected writes and were not served become its rejections; ones still undecidable are
940
+ * named. */
941
+ private settleUndecidedObservations;
942
+ /** Whether the read doors refuse this content of `entryId` now: the observation table as it stands,
943
+ * against the committed rev read now. Undefined when either cannot be read. */
944
+ private refusedNow;
945
+ /** Once per engine, at a zero-copy materialize: a backend without `committedSnapshotOf` (a wrapper
946
+ * that does not pass it through) records every read-only-layer change undecided, and none settles. */
947
+ private discloseUnsettleableObservations;
948
+ /** The committed row of `id`, read side-effect-free from the store's audit face: rev plus binding,
949
+ * undefined when the account has no row, `"undecidable"` when the account cannot be read whole
950
+ * (a pending journal, a fault, no audit face) — never a guess from a copy that carries no seat. */
951
+ private committedRowOf;
952
+ /** A caught memory-write failure as a report / incident line: its text, plus the remedy when the
953
+ * file system refused it on permissions ({@link withPermissionRemedy} — the one spelling every
954
+ * write seat surfaces, so no seat renders a permission refusal as a bare errno). */
955
+ private writeFailureText;
861
956
  /**
862
957
  * The ONE delivery point for a failed announcement-queue operation (`memory.announce_failed`, ruled
863
958
  * 2026-08-03). Every enqueue/drain call site stays fail-OPEN — a broken queue must never fail a
@@ -869,10 +964,6 @@ export declare class MemoryEngine {
869
964
  * failure produces no second-order disclosure — the alternative (announcing the announce failure)
870
965
  * would recurse through the very lane that just failed.
871
966
  */
872
- /** design/336 §3.6 (slice 2) — the session-account incident channel (`memory.session_account_failed`):
873
- * a degraded residue classification is DISCLOSED, never quiet (the behavioral fail-closed half —
874
- * harvest refusing on `handle.sessionAccountFailed` — is enforced at the write boundary). */
875
- private discloseSessionAccountIncident;
876
967
  private discloseAnnounceFailure;
877
968
  /**
878
969
  * Record retrieval hits (entries a memory tool actually RETURNED to the model) into this engine's
@@ -1089,6 +1180,7 @@ export declare class MemoryEngine {
1089
1180
  * `residuals.indexUncleared`, never silently absorbed into a clean residual set.
1090
1181
  */
1091
1182
  eraseMemoryEntries(input: EraseMemoryEntriesInput): Promise<MemoryErasureAttestation>;
1183
+ private eraseMemoryEntriesCore;
1092
1184
  /**
1093
1185
  * design/178 v2-b (rescan hardening) — the erase-time DERIVED-INDEX sweep, both lanes. MEMORY.md
1094
1186
  * is the engine's model-visible distillation of entry frontmatter (name + description — routinely
@@ -1199,6 +1291,7 @@ export declare class MemoryEngine {
1199
1291
  sessionId?: string;
1200
1292
  shadowNames?: ReadonlyMap<string, string>;
1201
1293
  }): Promise<MemorySessionHandle>;
1294
+ private materializeCore;
1202
1295
  inject(handle: MemorySessionHandle, opts?: {
1203
1296
  writeToolMounted?: boolean;
1204
1297
  reminderMark?: string;
@@ -1228,6 +1321,10 @@ export declare class MemoryEngine {
1228
1321
  * S2-B (O-F7/C-F3): the announcement ENQUEUE rides the harvest tail INSIDE the engine — every
1229
1322
  * caller (checkpoint mint / task terminal) is covered without run-loop wiring, and a refused
1230
1323
  * harvest (incident) announces too. The enqueue is fail-open: a broken queue never fails a harvest.
1324
+ * `opts.phase` is the CALLER's word for where the session stands: `"terminal"` ⇔ its leg ends with
1325
+ * this harvest, which records the leg's end on the session account once the harvest returns or
1326
+ * throws — whatever it admitted (`SessionAccountRow.terminatedAt`); anything else, absent
1327
+ * included, leaves the writer live.
1231
1328
  */
1232
1329
  harvest(handle: MemorySessionHandle, opts?: {
1233
1330
  polluted?: {
@@ -1239,6 +1336,7 @@ export declare class MemoryEngine {
1239
1336
  reason: string;
1240
1337
  cause?: "capture_optout_inherited";
1241
1338
  };
1339
+ phase?: "checkpoint" | "terminal";
1242
1340
  }): Promise<HarvestReport>;
1243
1341
  private harvestCore;
1244
1342
  /**
@@ -1272,7 +1370,8 @@ export declare class MemoryEngine {
1272
1370
  resolveHold(holdId: string, action: "release" | "discard", requestId: string): void;
1273
1371
  /** §3.6 — the session-account valve: close a dangling foreign row WITHOUT adjudication (the
1274
1372
  * residue arm keeps firing until the next full-domain harvest closes normally — a valve close
1275
- * must not launder the window it covers). Audited (requestId required). */
1373
+ * must not launder the window it covers). It also ends the session's leg (no live writer of its
1374
+ * layer any more). Audited (requestId required). */
1276
1375
  resolveSessionAccount(sessionId: string, requestId: string): void;
1277
1376
  /** The engine's control-plane directory (the sidecar home: pollution markers, lineage/challenge
1278
1377
  * ledgers, and the design/336 settlement/hold/session accounts). Exposed so a host (and the
@@ -1679,19 +1778,27 @@ export declare class MemoryEngine {
1679
1778
  * @returns the rejection to report, or `undefined` when the index is clean/absent.
1680
1779
  */
1681
1780
  private gateDerivedIndex;
1781
+ /** The file backend's verdict at an act the engine itself performs on a store path ({@link
1782
+ * storeTargetEscape}): throws `memory.target_outside_store` when `path` would land outside `root`;
1783
+ * each caller's existing failure lane reports it. `root` is the mount's canonical root where a
1784
+ * handle exists, so a root re-pointed after the mount is judged against the one it mounted. */
1785
+ private refuseOutsideStore;
1682
1786
  /** Projection write: reads the target to skip a no-op, then writes — the same check-then-use shape
1683
1787
  * as the containment restores, so it takes the same no-follow write. A link at a projection path
1684
1788
  * is never a supported layout (the harvest scan refuses symlinked entry files outright), and this
1685
- * site already reports write failures by throwing, so ELOOP joins the failures it already had. */
1789
+ * site already reports write failures by throwing, so ELOOP joins the failures it already had. A
1790
+ * target whose directory resolves outside `root` (a directory on the path is a link) is refused
1791
+ * the same way, before anything is read or written ({@link storeTargetEscape}). */
1686
1792
  private writeIfChanged;
1687
1793
  /** Persist the canonical projection back to the session file (id minting / frontmatter completion).
1688
1794
  * C-F6 (S2-0): called ONLY after the backend transaction committed — never ahead of the journal
1689
1795
  * commit point. Skips the write when the disk already holds the canonical bytes (zero-copy: the
1690
- * journal's own execute step wrote them, making this an idempotent no-op). Returns whether the
1691
- * disk now holds the canonical bytes — the ordinary completion family stays best-effort on a
1692
- * `false` (the next materialize re-projects), but the id-COMPLETION family's caller reads it to
1693
- * keep the #366 projection-debt row standing (an id-less plane file must re-bind to its
1694
- * committed id at the next harvest, never re-admit as a duplicate). */
1796
+ * journal's own execute step wrote them, making this an idempotent no-op). Returns `undefined`
1797
+ * when the disk now holds the canonical bytes, else the failure's text: the caller reports it
1798
+ * (the ordinary completion family stays recoverable — the backend commit is authoritative and the
1799
+ * next materialize re-projects), and the id-COMPLETION family additionally keeps the #366
1800
+ * projection-debt row standing (an id-less plane file must re-bind to its committed id at the
1801
+ * next harvest, never re-admit as a duplicate). */
1695
1802
  private writeBackProjection;
1696
1803
  /**
1697
1804
  * #366 — repair an id-less-but-committed seat IN PLACE (the failed write-back's retry): write the
@@ -1699,7 +1806,8 @@ export declare class MemoryEngine {
1699
1806
  * already proven the seat carries no unadmitted edit (rev equality against its baseline), so the
1700
1807
  * overwrite is content-preserving — it restores the id line, the completed frontmatter and any
1701
1808
  * carried marker, nothing else. A refused read/write leaves the row standing (retried next pass;
1702
- * the next materialize's projection loop repairs and settles it too).
1809
+ * the next materialize's projection loop repairs and settles it too); a refused WRITE is said on
1810
+ * `warnings`, so a seat that keeps failing is not silently retried forever.
1703
1811
  */
1704
1812
  private repairProjectionSeat;
1705
1813
  /**
@@ -1713,16 +1821,14 @@ export declare class MemoryEngine {
1713
1821
  * `getByIds` there would sync-adopt mid-harvest.
1714
1822
  */
1715
1823
  private debtCommittedProjection;
1716
- /** chmod a scope's entry tree (best-effort). The tree is the scope's own home and nothing else —
1717
- * no other scope's directory can lie under it.
1718
- *
1719
- * `opts.announced` is the CALLER's per-materialize ledger of paths already disclosed (#495 M-1).
1720
- * One materialize sweeps each inherited tree TWICE — restore write perms at the top, re-apply
1721
- * read-only at the bottom — and both passes see the same anomalies, so without it every skipped
1722
- * path was enqueued twice, byte-identically. The chmod WORK still happens twice (it has to: the
1723
- * modes really do change twice); only the disclosure is deduplicated. Absent ⇒ the historic
1724
- * per-call behaviour (each call states everything it skipped). */
1725
- private chmodScopeTree;
1824
+ /** The layer census: one walk of a scope's home per materialize, looking and never changing
1825
+ * anything. The tree is the scope's own home and nothing else — no other scope's directory can
1826
+ * lie under it. What it owes is DISCLOSURE: a link or an unreadable path inside a memory layer is
1827
+ * an anomaly the host should hear about, and a read-only layer has no other channel for it (the
1828
+ * harvest scan, which reports links for the writable scope, does not walk read-only layers at
1829
+ * all). Links are never followed (lstat): a linked directory would carry the walk out of the
1830
+ * tree. */
1831
+ private censusScopeTree;
1726
1832
  }
1727
1833
  /** Render drained announcements as ONE engine-owned system-reminder block (the lead/frame is
1728
1834
  * trusted; item identifiers were neutralized at enqueue — see {@link MemoryAnnouncement.items}). */