@sema-agent/core 5.36.0 → 5.37.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,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.37.0 — 2026-08-16
4
+
5
+ No BREAKING changes. Narrowings disclosed below (fail-closed forms replacing silent tolerance).
6
+
7
+ ### Added
8
+
9
+ - design/178 v2-a — the provenance/audit read family (part 2): `MemoryEngine.provenanceOf(id)`
10
+ returns a versioned envelope joining the entry's custody chain, lineage account and committed
11
+ binding; backend faces `committedSnapshotOf` / `committedSnapshotsOfScopes` / `custodyOf` answer
12
+ the committed store state and per-id custody without touching live planes. `TransferEvidence` and
13
+ the custody/snapshot report types are exported; the contract kit gains optional clauses for the
14
+ new faces.
15
+ - design/178 v2-a — the memory-visibility observation seat (part 1): `TaskResult.effectiveMemoryScopes`
16
+ (exported union `EffectiveMemoryScopes`) reports the leg's EFFECTIVE memory posture — mounted
17
+ (visibility rows in effective service order, with per-scope admission origin), memoryless
18
+ (mount-failed with residue, or no-backend), or none (no-spec / disabled). Minted after the
19
+ materialize outcome on every terminal that completed prepare, including the stream-layer backstop
20
+ terminal; delegated child legs each mint their own.
21
+ - design/178 v2-b — the erasure evidence protocol: `MemoryEngine.eraseMemoryEntries` /
22
+ backend `eraseWithEvidence` perform evidence-bearing erasure — store-level anchor plus per-scope
23
+ request projections in one journaled transaction, delete rows causally bound to the physical
24
+ deletes, a chain-level resurrection backstop (an erased id's replanted bytes are refused on BOTH
25
+ the adoption and retrieval faces), a census-backed scope selector (unledgered-live ghost files are
26
+ members), an erase-time MEMORY.md index sweep (residue is swept or disclosed on
27
+ `residuals.indexUncleared`, never silently left — including on §4.6 replays, where the evidence
28
+ row's recorded binding drives the sweep under an occupancy check so a freed slug taken by a live
29
+ entry is never touched), and an unevidenced degradation lane behind an explicit `allowUnevidenced`
30
+ opt-in. `MemoryErasureAttestation` rows are re-checkable against the evidence chain.
31
+ - `EntryCustodyReport.reason` (optional): a custody chain that carries one event id with two
32
+ different payloads answers `state: "damaged"` with the contradiction named, instead of a clean
33
+ "complete" over a spliced chain; byte-equal duplicate rows collapse instead of double-counting.
34
+ - Erasure host-API codes registered: `memory.erasure_evidence_unavailable`,
35
+ `memory.erasure_selector_mismatch`, `memory.erasure_census_incomplete`,
36
+ `memory.erasure_index_residue` (all non-governance caller/state-shape verdicts).
37
+
38
+ ### Fixed
39
+
40
+ - Erasure selectors resolve prototype-member keys as data, never as phantom sets: a
41
+ `sessionId: "constructor"` selector answers the empty resolution instead of treating every
42
+ lineaged entry as a match (class-swept across the lineage/scope/cursor keyed reads).
43
+ - `provenanceOf` names the face that is actually missing: the unknown-binding reason literal is now
44
+ `audit-snapshot-capability-absent` (was minted as `custody-capability-absent` even while the
45
+ custody face was answering).
46
+ - The retrieval face's delete-evidence guard is loud on an account-read fault: the documented
47
+ conservative-withhold direction is real (ids carrying delete evidence are withheld during the
48
+ fault window, entries without delete evidence keep serving), and the fault is announced once per
49
+ fault code per mount instead of a silent vanish.
50
+
51
+ ### Narrowed (disclosed)
52
+
53
+ - Transfer-evidence journal replay: a journal row whose event id matches an existing chain row with
54
+ a DIFFERENT payload is now fail-closed corruption (loud refusal naming the ev), replacing the
55
+ pre-v2b silent skip/overwrite. One ev is one identity.
56
+ - `{scope}` erasure membership is bound rows ∪ the census's unledgered-live inhabitants — a ghost
57
+ file no longer survives a scope erase under a clean attestation.
58
+
59
+
3
60
  ## 5.36.0 — 2026-08-15
4
61
 
5
62
  No BREAKING changes. Upgrade note: the system-prompt shape change resets provider prompt caches
@@ -492,6 +492,16 @@ export interface SubagentToolOptions {
492
492
  /** β 批 A-2 (BREAKING 1.365.0): REQUIRED — `"default"` is the single-tenant spelling (explicit,
493
493
  * matching the engine chain's `principal ?? "default"`), never implied by omission. */
494
494
  scope: string;
495
+ /**
496
+ * Deployment-level completion-push FALLBACK — not an always-on tap. The settle notification is
497
+ * delivered through `ctx.onTaskNotification ?? background.notify`: inside an engine-driven run
498
+ * the tool-execution context carries the run's own notification injector (the completion lands
499
+ * in the PARENT's live injection queue at its next turn boundary), and this sink is then never
500
+ * called — one completion, one channel, no double-send. It fires only when the tool is executed
501
+ * WITHOUT an engine notification channel (direct `execute()` harnesses, minimal mounts). A
502
+ * deployment that wants an unconditional process-level completion tap should use
503
+ * `RunnerDeps.onBackgroundChildEvent` (the BCE terminal frame) instead.
504
+ */
495
505
  notify?: (n: import("../core/task-notification.js").TaskNotificationPayload, opts?: {
496
506
  priority?: "now" | "next" | "later";
497
507
  }) => void;
@@ -20,6 +20,10 @@ export const NON_GOVERNANCE_MEMORY_CODES = new Set([
20
20
  "memory.challenge_ledger_oversize",
21
21
  "memory.control_plane_rebuilt",
22
22
  "memory.control_plane_not_corrupt",
23
+ "memory.erasure_evidence_unavailable",
24
+ "memory.erasure_selector_mismatch",
25
+ "memory.erasure_census_incomplete",
26
+ "memory.erasure_index_residue",
23
27
  ]);
24
28
  export function governanceRetryClass(code) {
25
29
  if (Object.prototype.hasOwnProperty.call(GOVERNANCE_CODES, code)) {
@@ -1,3 +1,4 @@
1
+ import { type CommittedBinding, type EraseMemoryEntriesInput, type MemoryErasureAttestation, type TransferEvidence } from "./file-backend.js";
1
2
  import { type ChallengeAssignment, type ChallengeEvent, type ControlPlaneRebuildReceipt, type StrictControlPlaneLedger, type ChallengedHistoryRow, type LineagePendingTxn, type LineagePromotion, type MemoryPartitionIncidentSink, type RetrievedAccountRow, type SessionPollutionRecord } from "./layout.js";
2
3
  import type { HarvestReport, MemoryAnnouncement, MemoryBackend, MemorySessionHandle, ScanFinding } from "./types.js";
3
4
  /**
@@ -187,6 +188,72 @@ export interface MemoryInjection {
187
188
  content: string;
188
189
  };
189
190
  }
191
+ /**
192
+ * design/178 v2-a §1.3 — one entry's ASSEMBLED provenance account: the machine-readable answer to
193
+ * "who wrote this in, what state is it in, has it been moved" for an auditor holding an entry id.
194
+ * ASSEMBLY, not a second account: every field is read from an authority that already exists
195
+ * (lineage/challenge/pollution = the engine's control plane; binding/content/custody = the
196
+ * backend's committed account + evidence chain) — a "provenance store" would be a drift surface.
197
+ *
198
+ * Field law:
199
+ * - `v` — version envelope: a consumer reading `v > 1` must refuse, never reinterpret.
200
+ * - `binding` — the committed-account row state, FOUR states without collapse: `bound`/`unbound`
201
+ * ({@link CommittedBinding}, shared tagged form) / `absent` (no row) / `unknown` (the backend
202
+ * has no audit-snapshot capability — "cannot judge" is never folded into "does not exist").
203
+ * - `contributors` — the committed lineage BY-ENTRY projection, each row joined with its
204
+ * session's pollution record where one exists. Pending (unsettled) lineage rows are never
205
+ * contributions; they surface as the `lineage_pending` exclusion instead.
206
+ * - `exclusion` — the model-visible read faces' withholding state for this id, when any.
207
+ * - `contentState` — the content half, LINKED to `binding` by construction: `absent` ⇔ binding
208
+ * `absent`; `capability-absent` ⇔ binding `unknown`; a row answers `present` (committed content
209
+ * servable; only then may `ingest` ride) or `unavailable` (row facts answered, no committed
210
+ * carrier — "cannot read it" stays distinct from "does not exist").
211
+ * - `ingest` — the typed repo-file ingest provenance carried by the committed content's
212
+ * frontmatter (`provenance` + the un-whitewashable `trust` marker), present only with
213
+ * `contentState: "present"`.
214
+ * - `custody` — the per-id evidence-chain read (`transfers.jsonl`), `damaged` = the chain's
215
+ * integrity is impeached while a sound reading still answers (torn tail / evidence loss /
216
+ * ev-identity contradiction — `reason` carries the detail when one is known; see
217
+ * {@link EntryCustodyReport}), `capability-absent` = the backend has no custody face (events
218
+ * then empty — never fabricated).
219
+ */
220
+ export interface EntryProvenanceAccount {
221
+ v: 1;
222
+ id: string;
223
+ binding: CommittedBinding | {
224
+ state: "absent";
225
+ } | {
226
+ state: "unknown";
227
+ reason: "audit-snapshot-capability-absent";
228
+ };
229
+ contributors: Array<{
230
+ sessionId: string;
231
+ lastRev: string;
232
+ lastAt?: number;
233
+ polluted?: {
234
+ at: number;
235
+ reason: string;
236
+ };
237
+ }>;
238
+ exclusion?: {
239
+ code: "challenged" | "lineage_pending";
240
+ generation?: number;
241
+ at?: number;
242
+ };
243
+ contentState: "present" | "unavailable" | "capability-absent" | "absent";
244
+ ingest?: {
245
+ kind: "repo_file";
246
+ path: string;
247
+ contentHash: string;
248
+ ingestedAt: number;
249
+ trust?: "untrusted";
250
+ };
251
+ custody: {
252
+ state: "complete" | "damaged" | "capability-absent";
253
+ events: TransferEvidence[];
254
+ reason?: string;
255
+ };
256
+ }
190
257
  export declare class MemoryEngine {
191
258
  private readonly backend;
192
259
  private readonly memoryDir;
@@ -296,6 +363,81 @@ export declare class MemoryEngine {
296
363
  * so one batched request stays per-entry attributable.
297
364
  */
298
365
  challengeEntries(ids: readonly string[], reason: string, requestId: string): ChallengeAssignment[];
366
+ /**
367
+ * design/178 v2-b §3.3(b) — the HOST erasure API: explicit, named, evidenced, loud. This is a
368
+ * host-plane DECLARED deletion, not a second deletion throat: it executes through the same
369
+ * `op:"delete"` lane every deletion takes, and the model-visible rule is untouched (a missing
370
+ * file is still never a delete; the frontmatter tombstone is still the only model-side channel).
371
+ * The mass-deletion fuse guards the ACCIDENT shape (files silently missing at harvest) and does
372
+ * not apply here — an explicitly authorized erasure self-discloses by enumerating every id in
373
+ * its attestation, and each selector form is bounded (there is no whole-store selector).
374
+ *
375
+ * Thin membrane by design (§7.4 r1): with the backend's evidence capability present
376
+ * (`eraseWithEvidence` — File implements it), validation is the ONLY thing that happens outside
377
+ * the store's transaction lock; resolution, lineage capture, planning, and evidence all run
378
+ * inside it. Capability ABSENT ⇒ loud refusal (`memory.erasure_evidence_unavailable`) unless
379
+ * `allowUnevidenced: true` opts into the DEGRADED lane, whose contract is deliberately weaker
380
+ * and stated (§5): same requestId = a NEW request (re-resolved — replay convergence is not
381
+ * promised), selector reuse is undetectable (no anchor to compare), `erasedPreviously` and
382
+ * `evidenceEv` never appear, every notFound row carries `historyUnknown` ("never existed" vs
383
+ * "deleted without evidence" is not decidable), resolution is the contract read faces'
384
+ * best-effort set (shadow-only rows invisible), execution is per-id CAS'd delete patches
385
+ * (conflicts reported, replay converges), and the attestation carries
386
+ * `evidenceCapability: "none"` + `custodyState: "capability-absent"` throughout — a silent
387
+ * downgrade is the forbidden shape. Residual enumeration is unavailable on the degraded lane
388
+ * (no quarantine face): its `quarantineHits`/`quarantineOpaque` answer empty/zero as
389
+ * "unenumerable", never as a verified absence. Applied deletes close their lineage rows (the
390
+ * same space hygiene the organic tombstone path takes).
391
+ *
392
+ * BOTH lanes finish with the derived-index sweep ({@link sweepErasedIndexLines}): the erased
393
+ * entries' MEMORY.md pointer lines — name + description, the model-visible distillation — are
394
+ * cleared from disk in the same call, and a sweep failure is disclosed on
395
+ * `residuals.indexUncleared`, never silently absorbed into a clean residual set.
396
+ */
397
+ eraseMemoryEntries(input: EraseMemoryEntriesInput): Promise<MemoryErasureAttestation>;
398
+ /**
399
+ * design/178 v2-b (rescan hardening) — the erase-time DERIVED-INDEX sweep, both lanes. MEMORY.md
400
+ * is the engine's model-visible distillation of entry frontmatter (name + description — routinely
401
+ * a restatement of exactly what an erasure request targets), and it is a derived projection the
402
+ * BACKEND knows nothing about: the store lane deletes rows/projections/shadows while the index
403
+ * line survives on disk (git working tree included) and rides the next injection that prefers the
404
+ * live on-disk index. The only other cleaner is the next materialize's rebuild — which a one-off
405
+ * compliance erasure cannot count on — so the erase itself clears the pointer lines.
406
+ *
407
+ * LINE-TARGETED, never a full rebuild (no session handle exists here; prose/headers and every
408
+ * unrelated line stay verbatim): a line is dropped iff its link target resolves to an erased
409
+ * row's BOUND projection path. Failures are DISCLOSED on the attestation's
410
+ * `residuals.indexUncleared` seat, never silent (坏值响亮度): a non-file at the index path, a
411
+ * non-ENOENT read failure, or a refused write leaves the path listed — the caller archiving the
412
+ * attestation knows the model-visible index may still name the erased entry. Honest bounds,
413
+ * stated: an UNBOUND erased row has no projection address to match (its dangling line — if a
414
+ * model ever wrote one — falls to the rebuild's orphan clearing); a session handle minted
415
+ * BEFORE the erase still carries its materialize-time index text (a handle is a snapshot; the
416
+ * next materialize re-derives); a REPO-INGESTED entry's index line targets the repo source file
417
+ * (recorded at materialize), which is not derivable from the deleted row — that line is a
418
+ * pointer to a still-existing user-owned file (L8 keeps it; the repo bytes were never the
419
+ * store's to erase — `propagation: "local-store-only"`); and the write is the same unlocked
420
+ * read-filter-write every writer performs on this MODEL-EDITABLE plane file (a concurrent
421
+ * session's line landing inside the window is last-writer-wins — the plane's standing posture,
422
+ * not a channel this sweep adds).
423
+ */
424
+ private sweepErasedIndexLines;
425
+ /**
426
+ * design/178 v2-a §1.3 — the per-entry provenance ANSWER (host audit API; see
427
+ * {@link EntryProvenanceAccount} for the field law). The read is held to the three audit axes:
428
+ * SIDE-EFFECT-FREE (no adoption, no healing, no migration trigger — the backend half goes through
429
+ * the audit-snapshot capability faces, never the adopting/serving read paths), COMMITTED (the
430
+ * account answers, never uncommitted plane bytes), and ACCOUNT-COMPLETE (the backend half is
431
+ * ledger-driven, so a row whose projection vanished out-of-band still answers).
432
+ *
433
+ * Failure posture is the control plane's existing fail-closed law: a corrupt lineage or challenge
434
+ * ledger THROWS (`ControlPlaneCorruptError` — an answer is never assembled over an account whose
435
+ * integrity is unknown), and the backend's own refusals (v1-compat store, contended snapshot
436
+ * fence) propagate. CAPABILITY ABSENCE is not failure: a backend without the audit faces answers
437
+ * `binding: "unknown"` / `contentState: "capability-absent"` / `custody: "capability-absent"` —
438
+ * reported honestly, never fabricated and never a throw (#196 absence-reports-not-silent-green).
439
+ */
440
+ provenanceOf(entryId: string): Promise<EntryProvenanceAccount>;
299
441
  /** Host API: challenge every entry the lineage ledger attributes to `sessionId` (post-hoc source
300
442
  * falsification — trustedTools misconfigured, a tool re-classified, late delegation evidence).
301
443
  * Same requestId contract as {@link challengeEntries}. */
@@ -5,8 +5,8 @@ import { MAX_MEMORY_BYTES, composeMemoryBlock, firstSentence } from "../memory.j
5
5
  import { inlineUntrusted } from "../untrusted-text.js";
6
6
  import { formatMemoryAge } from "../memory-recall.js";
7
7
  import { computeEntryRev, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
8
- import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, scanEntryFiles } from "./file-backend.js";
9
- import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, readIndexRevs, writeIndexRevs, bumpScanFuse, canonicalize, claimRootScope, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, isContainedIn, markSessionPolluted, readSessionPollution, recordRetrievedAccount, writeFileNoFollow, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, appendChallengeEvents, appendLineageAudit, rebuildStrictControlPlaneLedger, isStrictControlPlaneLedgerCorrupt, CHALLENGE_LEDGER_MAX_EVENTS, adjudicateLineagePending, challengedEntryIds, clearLineageForEntries, discardLineagePending, lineageContributionsOfSession, lineageLatchedIds, promoteLineagePending, readChallengeEvents, readChallengedHistory, readLineageRecord, recordChallengedHistory, recordLineageCredential, reconcileLineage, resolveChallengeEvent, stageLineagePending, } from "./layout.js";
8
+ import { DEFAULT_MAX_ENTRY_DEPTH, MEMORY_INDEX_FILENAME, canonicalJsonStringify, captureErasureInput, erasureRequestInvalid, erasureSelectHash, scanEntryFiles, } from "./file-backend.js";
9
+ import { QUARANTINE_DIR, SCAN_FUSE_THRESHOLD, quarantineAndTombstone, readIndexRevs, writeIndexRevs, bumpScanFuse, canonicalize, claimRootScope, clearScanFuse, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, ensureDirExists, isContainedIn, markSessionPolluted, readSessionPollution, recordRetrievedAccount, writeFileNoFollow, readRetrievedAccount, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, appendChallengeEvents, appendLineageAudit, rebuildStrictControlPlaneLedger, isStrictControlPlaneLedgerCorrupt, CHALLENGE_LEDGER_MAX_EVENTS, adjudicateLineagePending, challengedEntryIds, clearLineageForEntries, discardLineagePending, lineageAccountOfEntry, lineageContributionsOfSession, lineageLatchedIds, promoteLineagePending, readChallengeEvents, readChallengedHistory, readLineageRecord, recordChallengedHistory, recordLineageCredential, reconcileLineage, resolveChallengeEvent, stageLineagePending, } from "./layout.js";
10
10
  import { scanMemoryFileName, scanMemoryWrite, scanRemediation } from "./scan.js";
11
11
  export const MEMORY_INSTRUCTION_TEMPLATE = `# Memory
12
12
 
@@ -259,6 +259,268 @@ export class MemoryEngine {
259
259
  }
260
260
  return this.challengeAndAnnounce(ids.map((entryId) => ({ eventId: `${requestId}:${entryId}`, entryId, reason })));
261
261
  }
262
+ async eraseMemoryEntries(input) {
263
+ const captured = captureErasureInput(input);
264
+ const bad = erasureRequestInvalid(captured);
265
+ if (bad !== undefined) {
266
+ const e = new Error(`eraseMemoryEntries: ${bad}`);
267
+ e.code = "config.memory_erasure_request";
268
+ throw e;
269
+ }
270
+ const requestId = captured.requestId;
271
+ const face = this.backend.eraseWithEvidence;
272
+ if (typeof face === "function") {
273
+ const att = await face.call(this.backend, captured);
274
+ this.sweepErasedIndexLines(att);
275
+ return att;
276
+ }
277
+ if (captured.allowUnevidenced !== true) {
278
+ const e = new Error("eraseMemoryEntries: this backend has no erasure-evidence capability (eraseWithEvidence) — an unevidenced erasure is refused by default. " +
279
+ "Pass allowUnevidenced: true to run the documented degraded lane (no replay convergence, no selector-reuse detection, no erasure history, best-effort resolution).");
280
+ e.code = "memory.erasure_evidence_unavailable";
281
+ throw e;
282
+ }
283
+ const at = this.now();
284
+ const select = captured.select;
285
+ let resolvedIds;
286
+ if ("ids" in select) {
287
+ resolvedIds = [...select.ids];
288
+ }
289
+ else if ("scope" in select) {
290
+ const headers = await this.backend.listHeaders([select.scope]);
291
+ resolvedIds = [...new Set(headers.map((h) => h.id))].sort();
292
+ }
293
+ else {
294
+ resolvedIds = lineageContributionsOfSession(this.controlDir, select.sessionId)
295
+ .map((c) => c.entryId)
296
+ .sort();
297
+ }
298
+ const present = await this.backend.getByIds(resolvedIds);
299
+ const byId = new Map(present.map((e) => [e.id, e]));
300
+ const patches = [...byId.values()].map((e) => ({ op: "delete", id: e.id, baseRev: e.rev }));
301
+ const report = patches.length > 0 ? await this.backend.applyPatches(patches) : { applied: [], conflicts: [] };
302
+ const appliedDeletes = new Set(report.applied.filter((a) => a.op === "delete").map((a) => a.id));
303
+ if (appliedDeletes.size > 0)
304
+ clearLineageForEntries(this.controlDir, [...appliedDeletes]);
305
+ const erased = [...appliedDeletes].map((id) => {
306
+ const e = byId.get(id);
307
+ return {
308
+ id,
309
+ rev: e?.rev ?? "",
310
+ binding: e !== undefined ? { state: "bound", scope: e.scope, slug: e.slug } : { state: "unbound" },
311
+ projectionsRemoved: 1,
312
+ sessions: [],
313
+ };
314
+ });
315
+ const notFound = resolvedIds.filter((id) => !byId.has(id)).map((id) => ({ id, historyUnknown: true }));
316
+ const conflicts = report.conflicts.map((c) => ({ id: c.id, reason: c.reason }));
317
+ const status = conflicts.length === 0 && resolvedIds.every((id) => appliedDeletes.has(id)) ? "complete" : "partial";
318
+ const att = {
319
+ v: 1,
320
+ requestId,
321
+ at,
322
+ status,
323
+ evidenceCapability: "none",
324
+ custodyState: "capability-absent",
325
+ select,
326
+ selectHash: erasureSelectHash(select),
327
+ resolvedIds,
328
+ erased,
329
+ notFound,
330
+ conflicts,
331
+ residuals: { quarantineHits: [], quarantineOpaque: 0, propagation: "local-store-only" },
332
+ };
333
+ this.sweepErasedIndexLines(att);
334
+ return att;
335
+ }
336
+ sweepErasedIndexLines(att) {
337
+ const uncleared = [];
338
+ try {
339
+ const erasedPaths = new Set();
340
+ const boundScopes = new Set();
341
+ for (const row of att.erased) {
342
+ if (row.binding.state !== "bound")
343
+ continue;
344
+ boundScopes.add(row.binding.scope);
345
+ erasedPaths.add(canonicalize(join(scopeDirFor(this.memoryDir, this.controlDir, row.binding.scope), `${row.binding.slug}.md`)));
346
+ }
347
+ for (const row of att.erasedPreviously ?? []) {
348
+ if (row.from === undefined)
349
+ continue;
350
+ boundScopes.add(row.from.scope);
351
+ erasedPaths.add(canonicalize(join(scopeDirFor(this.memoryDir, this.controlDir, row.from.scope), `${row.from.slug}.md`)));
352
+ }
353
+ if (erasedPaths.size === 0)
354
+ return;
355
+ const dirs = new Set([this.memoryDir]);
356
+ for (const scope of Object.keys(registeredScopes(this.controlDir)))
357
+ dirs.add(scopeDirFor(this.memoryDir, this.controlDir, scope));
358
+ for (const scope of boundScopes)
359
+ dirs.add(scopeDirFor(this.memoryDir, this.controlDir, scope));
360
+ const seen = new Set();
361
+ for (const dir of dirs) {
362
+ const indexPath = join(dir, MEMORY_INDEX_FILENAME);
363
+ const canon = canonicalize(indexPath);
364
+ if (seen.has(canon))
365
+ continue;
366
+ seen.add(canon);
367
+ let isFile;
368
+ try {
369
+ isFile = lstatSync(indexPath).isFile();
370
+ }
371
+ catch (err) {
372
+ if (err.code === "ENOENT")
373
+ continue;
374
+ uncleared.push(indexPath);
375
+ continue;
376
+ }
377
+ if (!isFile) {
378
+ uncleared.push(indexPath);
379
+ continue;
380
+ }
381
+ const text = readNoFollowSafe(indexPath);
382
+ if (text === undefined) {
383
+ uncleared.push(indexPath);
384
+ continue;
385
+ }
386
+ const lines = text.split("\n");
387
+ const kept = lines.filter((line) => {
388
+ const target = indexLineTarget(line);
389
+ if (target === undefined)
390
+ return true;
391
+ return !erasedPaths.has(canonicalize(join(dir, target)));
392
+ });
393
+ if (kept.length === lines.length)
394
+ continue;
395
+ try {
396
+ writeFileNoFollow(indexPath, kept.join("\n"));
397
+ }
398
+ catch {
399
+ uncleared.push(indexPath);
400
+ }
401
+ }
402
+ }
403
+ catch (err) {
404
+ uncleared.push(`${join(this.memoryDir, MEMORY_INDEX_FILENAME)} (index sweep failed: ${err instanceof Error ? err.message : String(err)})`);
405
+ }
406
+ if (uncleared.length > 0) {
407
+ try {
408
+ att.residuals.indexUncleared = [...new Set(uncleared)].sort();
409
+ }
410
+ catch (err) {
411
+ try {
412
+ const detail = `memory erasure: the attestation could not carry its index-residue disclosure ` +
413
+ `(${inlineUntrusted(String(err instanceof Error ? err.message : err), 200)}) — MEMORY.md files that may still name erased entries: ` +
414
+ `${uncleared.map((p) => inlineUntrusted(String(p), 300)).join(", ")}`;
415
+ try {
416
+ enqueueMemoryAnnouncement(this.controlDir, { kind: "external", at: this.now(), items: [detail] });
417
+ }
418
+ catch (enqueueErr) {
419
+ this.discloseAnnounceFailure("erasure index-residue enqueue", enqueueErr);
420
+ }
421
+ const sink = this.onIncident;
422
+ if (sink !== undefined) {
423
+ try {
424
+ const e = new Error(detail);
425
+ e.code = "memory.erasure_index_residue";
426
+ sink(e);
427
+ }
428
+ catch {
429
+ }
430
+ }
431
+ }
432
+ catch {
433
+ }
434
+ }
435
+ }
436
+ }
437
+ async provenanceOf(entryId) {
438
+ const snapshotFn = this.backend.committedSnapshotOf;
439
+ const custodyFn = this.backend.custodyOf;
440
+ const readEnginePlane = () => {
441
+ const challenged = challengedEntryIds(this.controlDir).get(entryId);
442
+ const lineage = lineageAccountOfEntry(this.controlDir, entryId);
443
+ return {
444
+ contributors: lineage.contributors.map((c) => {
445
+ const polluted = this.sessionPollution(c.sessionId);
446
+ return {
447
+ sessionId: c.sessionId,
448
+ lastRev: c.lastRev,
449
+ lastAt: c.lastAt,
450
+ ...(polluted !== undefined ? { polluted: { at: polluted.at, reason: polluted.reason } } : {}),
451
+ };
452
+ }),
453
+ exclusion: challenged !== undefined
454
+ ? { code: "challenged", generation: challenged.generation, at: challenged.at }
455
+ : lineage.pendingLatched
456
+ ? { code: "lineage_pending" }
457
+ : undefined,
458
+ };
459
+ };
460
+ {
461
+ let snap;
462
+ let custody;
463
+ if (typeof snapshotFn !== "function") {
464
+ custody = typeof custodyFn !== "function" ? { state: "capability-absent", events: [] } : await custodyFn.call(this.backend, entryId);
465
+ }
466
+ else if (typeof custodyFn !== "function") {
467
+ snap = await snapshotFn.call(this.backend, entryId);
468
+ custody = { state: "capability-absent", events: [] };
469
+ }
470
+ else {
471
+ const accountKey = (s) => JSON.stringify(s.state === "row" ? { state: s.state, rev: s.rev, binding: s.binding } : { state: s.state });
472
+ const chainKey = (c) => canonicalJsonStringify(JSON.parse(JSON.stringify([c.state, c.reason ?? null, c.events])));
473
+ snap = await snapshotFn.call(this.backend, entryId);
474
+ custody = await custodyFn.call(this.backend, entryId);
475
+ let stable = false;
476
+ for (let bracket = 0; bracket < 4; bracket++) {
477
+ const snapAgain = await snapshotFn.call(this.backend, entryId);
478
+ if (accountKey(snapAgain) !== accountKey(snap)) {
479
+ snap = snapAgain;
480
+ custody = await custodyFn.call(this.backend, entryId);
481
+ continue;
482
+ }
483
+ const custodyAgain = await custodyFn.call(this.backend, entryId);
484
+ if (chainKey(custodyAgain) !== chainKey(custody)) {
485
+ custody = custodyAgain;
486
+ snap = await snapshotFn.call(this.backend, entryId);
487
+ continue;
488
+ }
489
+ stable = true;
490
+ break;
491
+ }
492
+ if (!stable) {
493
+ throw new Error(`memory provenance read refused: the committed account for ${JSON.stringify(entryId)} kept moving across ` +
494
+ `every snapshot/custody bracket — the two faces cannot be joined without tearing while the store is this hot; retry later.`);
495
+ }
496
+ }
497
+ const plane = readEnginePlane();
498
+ let binding;
499
+ let contentState;
500
+ let ingest;
501
+ if (snap === undefined) {
502
+ binding = { state: "unknown", reason: "audit-snapshot-capability-absent" };
503
+ contentState = "capability-absent";
504
+ }
505
+ else if (snap.state === "absent") {
506
+ binding = { state: "absent" };
507
+ contentState = "absent";
508
+ }
509
+ else {
510
+ binding = snap.binding;
511
+ if (snap.content.state === "present") {
512
+ contentState = "present";
513
+ const fm = snap.content.entry.frontmatter;
514
+ if (fm.provenance !== undefined)
515
+ ingest = { ...fm.provenance, ...(fm.trust !== undefined ? { trust: fm.trust } : {}) };
516
+ }
517
+ else {
518
+ contentState = "unavailable";
519
+ }
520
+ }
521
+ return { v: 1, id: entryId, binding, contributors: plane.contributors, ...(plane.exclusion !== undefined ? { exclusion: plane.exclusion } : {}), contentState, ...(ingest !== undefined ? { ingest } : {}), custody };
522
+ }
523
+ }
262
524
  challengeSession(sessionId, reason, requestId) {
263
525
  if (typeof requestId !== "string" || requestId === "") {
264
526
  const e = new Error("challengeSession: requestId is required (idempotency identity — retries must reuse it; the engine does not mint one)");