@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 +57 -0
- package/dist/agents/subagent.d.ts +10 -0
- package/dist/core/governance-codes.js +4 -0
- package/dist/core/memory-engine/engine.d.ts +142 -0
- package/dist/core/memory-engine/engine.js +264 -2
- package/dist/core/memory-engine/file-backend.d.ts +490 -16
- package/dist/core/memory-engine/file-backend.js +1099 -36
- package/dist/core/memory-engine/index.d.ts +2 -2
- package/dist/core/memory-engine/index.js +1 -1
- package/dist/core/memory-engine/layout.d.ts +42 -2
- package/dist/core/memory-engine/layout.js +76 -12
- package/dist/core/memory-engine/memory-backend-contract.d.ts +13 -0
- package/dist/core/memory-engine/memory-backend-contract.js +89 -0
- package/dist/core/protocol-table.d.ts +4 -4
- package/dist/core/runner/assemble-result.d.ts +5 -0
- package/dist/core/runner/assemble-result.js +1 -1
- package/dist/core/runner/prepare-memory.d.ts +11 -1
- package/dist/core/runner/prepare-memory.js +48 -2
- package/dist/core/runner/prepare-task.d.ts +5 -0
- package/dist/core/runner/prepare-task.js +2 -2
- package/dist/core/runner/runtask.js +4 -0
- package/dist/core/types.d.ts +95 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +13 -1
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)");
|