@sema-agent/core 5.38.0 → 5.40.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +182 -10
  2. package/dist/agents/send-message-tool.d.ts +8 -0
  3. package/dist/agents/send-message-tool.js +8 -0
  4. package/dist/agents/teacher.js +9 -3
  5. package/dist/agents/verify.js +9 -3
  6. package/dist/core/checkpoint-store.d.ts +12 -0
  7. package/dist/core/governance-codes.js +2 -0
  8. package/dist/core/hooks.d.ts +23 -0
  9. package/dist/core/hooks.js +53 -4
  10. package/dist/core/mailbox-store.d.ts +39 -0
  11. package/dist/core/mailbox-store.js +9 -0
  12. package/dist/core/memory-engine/engine.d.ts +27 -0
  13. package/dist/core/memory-engine/engine.js +103 -1
  14. package/dist/core/memory-engine/export-bundle.d.ts +192 -0
  15. package/dist/core/memory-engine/export-bundle.js +306 -0
  16. package/dist/core/memory-engine/file-backend.d.ts +178 -1
  17. package/dist/core/memory-engine/file-backend.js +637 -6
  18. package/dist/core/memory-engine/index.d.ts +2 -1
  19. package/dist/core/memory-engine/index.js +1 -0
  20. package/dist/core/memory-engine/layout.d.ts +89 -1
  21. package/dist/core/memory-engine/layout.js +131 -1
  22. package/dist/core/memory-engine/memory-backend-contract.d.ts +1 -1
  23. package/dist/core/memory-engine/memory-backend-contract.js +52 -0
  24. package/dist/core/memory-engine/tools.js +8 -1
  25. package/dist/core/permission-rule-consent.d.ts +27 -4
  26. package/dist/core/permission-rule-consent.js +41 -4
  27. package/dist/core/permission-rule-model.d.ts +7 -1
  28. package/dist/core/runner/prepare-task.js +24 -3
  29. package/dist/core/runner/runtask.js +5 -0
  30. package/dist/core/runner/synthetic-tools.js +3 -1
  31. package/dist/core/runner/tool-disclosure.js +2 -1
  32. package/dist/core/sensitive-path-policy.js +3 -3
  33. package/dist/core/store-contracts/mailbox-store-contract.d.ts +29 -1
  34. package/dist/core/store-contracts/mailbox-store-contract.js +78 -0
  35. package/dist/core/types.d.ts +21 -0
  36. package/dist/core/write-protect.d.ts +73 -0
  37. package/dist/core/write-protect.js +195 -0
  38. package/dist/index.d.ts +4 -3
  39. package/dist/index.js +4 -3
  40. package/dist/orchestration/governance-baseline-validity.d.ts +44 -0
  41. package/dist/orchestration/governance-baseline-validity.js +55 -0
  42. package/dist/orchestration/run-workflow-tool.js +33 -8
  43. package/dist/orchestration/workflow-script-runner.js +9 -4
  44. package/dist/tools/fs/read-deny.d.ts +15 -5
  45. package/dist/tools/fs/read-deny.js +33 -12
  46. package/dist/tools/fs/safety.d.ts +5 -2
  47. package/dist/tools/fs/safety.js +5 -3
  48. package/dist/tools/fs/search.d.ts +33 -0
  49. package/dist/tools/fs/search.js +72 -0
  50. package/package.json +1 -1
  51. package/test/export-surface.snapshot.json +21 -1
@@ -0,0 +1,306 @@
1
+ import { createHash } from "node:crypto";
2
+ import { canonicalJsonStringify } from "./file-backend.js";
3
+ import { computeEntryRev } from "./frontmatter.js";
4
+ export const BUNDLE_SECTION_KEYS = ["meta", "entries", "governance", "residuals"];
5
+ function sha256Line(text) {
6
+ return createHash("sha256").update(`${text}\n`, "utf8").digest("hex");
7
+ }
8
+ export function computeBundleSectionHashes(bundle) {
9
+ return {
10
+ meta: sha256Line(canonicalJsonStringify({ bundle: bundle.bundle, at: bundle.at, scopes: bundle.scopes, storeId: bundle.storeId })),
11
+ entries: sha256Line(canonicalJsonStringify(bundle.entries)),
12
+ governance: sha256Line(canonicalJsonStringify(bundle.governance)),
13
+ residuals: sha256Line(canonicalJsonStringify(bundle.residuals)),
14
+ };
15
+ }
16
+ export function computeMemoryBundleHash(sectionHashes) {
17
+ return sha256Line(canonicalJsonStringify(sectionHashes));
18
+ }
19
+ const HEX64_RE = /^[0-9a-f]{64}$/;
20
+ function isRecord(v) {
21
+ return typeof v === "object" && v !== null && !Array.isArray(v);
22
+ }
23
+ function stringArrayInvalid(v, what) {
24
+ if (!Array.isArray(v))
25
+ return `${what} is not an array`;
26
+ for (const s of v)
27
+ if (typeof s !== "string")
28
+ return `${what} carries a non-string member`;
29
+ return undefined;
30
+ }
31
+ function countInvalid(v, what) {
32
+ return typeof v === "number" && Number.isInteger(v) && v >= 0 ? undefined : `${what} is not a non-negative integer`;
33
+ }
34
+ function entryInvalid(raw, scopes) {
35
+ if (!isRecord(raw))
36
+ return "entry is not an object";
37
+ const e = raw;
38
+ if (typeof e.id !== "string" || e.id.length === 0)
39
+ return "entry has no id";
40
+ if (typeof e.slug !== "string" || e.slug.length === 0)
41
+ return `entry ${JSON.stringify(e.id)} has no slug`;
42
+ if (typeof e.scope !== "string" || e.scope.length === 0)
43
+ return `entry ${JSON.stringify(e.id)} has no scope`;
44
+ if (!scopes.has(e.scope))
45
+ return `entry ${JSON.stringify(e.id)} carries scope ${JSON.stringify(e.scope)} outside bundle.scopes (self-consistency)`;
46
+ if (typeof e.body !== "string")
47
+ return `entry ${JSON.stringify(e.id)} has no string body`;
48
+ if (typeof e.rev !== "string" || e.rev.length === 0)
49
+ return `entry ${JSON.stringify(e.id)} has no rev`;
50
+ const fm = e.frontmatter;
51
+ if (!isRecord(fm))
52
+ return `entry ${JSON.stringify(e.id)} has no frontmatter object`;
53
+ for (const key of ["name", "description", "type"]) {
54
+ if (fm[key] !== undefined && typeof fm[key] !== "string")
55
+ return `entry ${JSON.stringify(e.id)} frontmatter.${key} is not a string`;
56
+ }
57
+ if (fm.deleted !== undefined && typeof fm.deleted !== "boolean")
58
+ return `entry ${JSON.stringify(e.id)} frontmatter.deleted is not a boolean`;
59
+ if (fm.extra !== undefined) {
60
+ const bad = stringArrayInvalid(fm.extra, `entry ${JSON.stringify(e.id)} frontmatter.extra`);
61
+ if (bad !== undefined)
62
+ return bad;
63
+ }
64
+ if (fm.trust !== undefined && fm.trust !== "untrusted")
65
+ return `entry ${JSON.stringify(e.id)} frontmatter.trust is not "untrusted"`;
66
+ if (fm.provenance !== undefined) {
67
+ const p = fm.provenance;
68
+ if (!isRecord(p) || p.kind !== "repo_file" || typeof p.path !== "string" || typeof p.contentHash !== "string" || typeof p.ingestedAt !== "number") {
69
+ return `entry ${JSON.stringify(e.id)} frontmatter.provenance is malformed`;
70
+ }
71
+ }
72
+ const recomputed = computeEntryRev({ id: e.id, frontmatter: fm, body: e.body });
73
+ if (recomputed !== e.rev)
74
+ return `entry ${JSON.stringify(e.id)} rev ${JSON.stringify(e.rev)} does not equal its recomputed content rev ${JSON.stringify(recomputed)} (forged or corrupted package)`;
75
+ return undefined;
76
+ }
77
+ export function memoryBundleInvalid(raw, opts = {}) {
78
+ if (!isRecord(raw))
79
+ return "bundle is not an object";
80
+ const b = raw;
81
+ if (typeof b.bundle !== "number")
82
+ return "bundle envelope version is missing";
83
+ if (b.bundle > 1)
84
+ return `bundle envelope version ${String(b.bundle)} is newer than this engine supports — refusing to reinterpret it`;
85
+ if (b.bundle !== 1)
86
+ return `bundle envelope version ${String(b.bundle)} is not recognized`;
87
+ if (typeof b.at !== "number" || !Number.isInteger(b.at))
88
+ return "bundle.at is not an integer";
89
+ const scopesBad = stringArrayInvalid(b.scopes, "bundle.scopes");
90
+ if (scopesBad !== undefined)
91
+ return scopesBad;
92
+ const scopes = b.scopes;
93
+ if (scopes.length === 0)
94
+ return "bundle.scopes is empty";
95
+ if (scopes.some((s) => s.length === 0))
96
+ return "bundle.scopes carries an empty scope";
97
+ if (new Set(scopes).size !== scopes.length)
98
+ return "bundle.scopes carries a duplicate scope";
99
+ if (typeof b.storeId !== "string" || b.storeId.length === 0)
100
+ return "bundle.storeId is missing";
101
+ if (opts.expectedScopes !== undefined) {
102
+ const allowed = new Set(opts.expectedScopes);
103
+ const outside = scopes.find((s) => !allowed.has(s));
104
+ if (outside !== undefined)
105
+ return `bundle scope ${JSON.stringify(outside)} is outside the expected scope set — whole package refused`;
106
+ }
107
+ const entriesArr = Array.isArray(b.entries) && b.entries;
108
+ if (entriesArr === false)
109
+ return "bundle.entries is not an array";
110
+ const scopeSet = new Set(scopes);
111
+ const entryIds = new Set();
112
+ for (const raw2 of entriesArr) {
113
+ const bad = entryInvalid(raw2, scopeSet);
114
+ if (bad !== undefined)
115
+ return bad;
116
+ const id = raw2.id;
117
+ if (entryIds.has(id))
118
+ return `bundle.entries carries id ${JSON.stringify(id)} twice (bad package)`;
119
+ entryIds.add(id);
120
+ }
121
+ const gov = b.governance;
122
+ if (!isRecord(gov))
123
+ return "bundle.governance is missing (the governance section is a structural requirement, not an attachment)";
124
+ const challengesArr = Array.isArray(gov.challenges) && gov.challenges;
125
+ if (challengesArr === false)
126
+ return "bundle.governance.challenges is not an array";
127
+ const challengeEventIds = new Set();
128
+ for (const raw2 of challengesArr) {
129
+ if (!isRecord(raw2))
130
+ return "challenge row is not an object";
131
+ const c = raw2;
132
+ if (typeof c.eventId !== "string" || c.eventId.length === 0)
133
+ return "challenge row has no eventId";
134
+ if (typeof c.entryId !== "string" || c.entryId.length === 0)
135
+ return "challenge row has no entryId";
136
+ if (typeof c.reason !== "string")
137
+ return "challenge row has no reason";
138
+ if (typeof c.at !== "number" || !Number.isInteger(c.at))
139
+ return "challenge row has no integer at";
140
+ if (c.challengedRev !== undefined && typeof c.challengedRev !== "string")
141
+ return "challenge row challengedRev is not a string";
142
+ if (challengeEventIds.has(c.eventId))
143
+ return `bundle.governance.challenges carries eventId ${JSON.stringify(c.eventId)} twice (bad package)`;
144
+ challengeEventIds.add(c.eventId);
145
+ }
146
+ const pollutedSessionsArr = Array.isArray(gov.pollutedSessions) && gov.pollutedSessions;
147
+ if (pollutedSessionsArr === false)
148
+ return "bundle.governance.pollutedSessions is not an array";
149
+ const pollutedIds = new Set();
150
+ for (const raw2 of pollutedSessionsArr) {
151
+ if (!isRecord(raw2))
152
+ return "polluted-session row is not an object";
153
+ if (typeof raw2.sessionId !== "string" || raw2.sessionId.length === 0)
154
+ return "polluted-session row has no sessionId";
155
+ if (typeof raw2.at !== "number")
156
+ return "polluted-session row has no at";
157
+ if (typeof raw2.reason !== "string")
158
+ return "polluted-session row has no reason";
159
+ if (pollutedIds.has(raw2.sessionId))
160
+ return `bundle.governance.pollutedSessions carries sessionId ${JSON.stringify(raw2.sessionId)} twice (bad package)`;
161
+ pollutedIds.add(raw2.sessionId);
162
+ }
163
+ const lineageArr = Array.isArray(gov.lineage) && gov.lineage;
164
+ if (lineageArr === false)
165
+ return "bundle.governance.lineage is not an array";
166
+ const lineagePairs = new Set();
167
+ for (const raw2 of lineageArr) {
168
+ if (!isRecord(raw2))
169
+ return "lineage row is not an object";
170
+ if (typeof raw2.entryId !== "string" || raw2.entryId.length === 0)
171
+ return "lineage row has no entryId";
172
+ if (typeof raw2.sessionId !== "string" || raw2.sessionId.length === 0)
173
+ return "lineage row has no sessionId";
174
+ if (typeof raw2.lastRev !== "string" || raw2.lastRev.length === 0)
175
+ return "lineage row has no lastRev";
176
+ if (typeof raw2.lastAt !== "number")
177
+ return "lineage row has no lastAt";
178
+ const key = `${raw2.entryId}\u0000${raw2.sessionId}`;
179
+ if (lineagePairs.has(key))
180
+ return `bundle.governance.lineage carries (${raw2.entryId}, ${raw2.sessionId}) twice (bad package)`;
181
+ lineagePairs.add(key);
182
+ }
183
+ const custodyArr = Array.isArray(gov.custody) && gov.custody;
184
+ if (custodyArr === false)
185
+ return "bundle.governance.custody is not an array";
186
+ const custodyByEv = new Map();
187
+ const scopeLiteralOutside = (row) => {
188
+ const sideScope = (v) => {
189
+ if (!isRecord(v))
190
+ return undefined;
191
+ return typeof v.scope === "string" ? v.scope : undefined;
192
+ };
193
+ const literals = [
194
+ sideScope(row.from),
195
+ sideScope(row.to),
196
+ typeof row.scope === "string" ? row.scope : undefined,
197
+ sideScope(row.select),
198
+ ];
199
+ return literals.find((s) => s !== undefined && !scopeSet.has(s));
200
+ };
201
+ for (const raw2 of custodyArr) {
202
+ if (!isRecord(raw2))
203
+ return "custody row is not an object";
204
+ if (typeof raw2.ev !== "string" || raw2.ev.length === 0)
205
+ return "custody row has no ev";
206
+ if (typeof raw2.channel !== "string" || raw2.channel.length === 0)
207
+ return "custody row has no channel";
208
+ if (typeof raw2.at !== "number" || !Number.isInteger(raw2.at))
209
+ return "custody row has no integer at";
210
+ const outside = scopeLiteralOutside(raw2);
211
+ if (outside !== undefined)
212
+ return `custody row ${JSON.stringify(raw2.ev)} names scope ${JSON.stringify(outside)} outside bundle.scopes (self-consistency: every in-package scope literal ⊆ bundle.scopes)`;
213
+ const canonical = canonicalJsonStringify(raw2);
214
+ const prior = custodyByEv.get(raw2.ev);
215
+ if (prior !== undefined && prior !== canonical)
216
+ return `bundle.governance.custody carries ev ${JSON.stringify(raw2.ev)} twice with DIFFERENT payloads (bad package)`;
217
+ custodyByEv.set(raw2.ev, canonical);
218
+ }
219
+ const res = b.residuals;
220
+ if (!isRecord(res))
221
+ return "bundle.residuals is missing";
222
+ for (const key of ["quarantined", "unbound", "pendingLatch"]) {
223
+ const bad = stringArrayInvalid(res[key], `bundle.residuals.${key}`);
224
+ if (bad !== undefined)
225
+ return bad;
226
+ }
227
+ const contentUnavailableArr = Array.isArray(res.contentUnavailable) && res.contentUnavailable;
228
+ if (contentUnavailableArr === false)
229
+ return "bundle.residuals.contentUnavailable is not an array";
230
+ for (const raw2 of contentUnavailableArr) {
231
+ if (!isRecord(raw2) || typeof raw2.id !== "string" || typeof raw2.scope !== "string" || typeof raw2.slug !== "string" || typeof raw2.rev !== "string" || typeof raw2.reason !== "string") {
232
+ return "bundle.residuals.contentUnavailable carries a malformed row";
233
+ }
234
+ if (!scopeSet.has(raw2.scope))
235
+ return `bundle.residuals.contentUnavailable row ${JSON.stringify(raw2.id)} names scope ${JSON.stringify(raw2.scope)} outside bundle.scopes (self-consistency)`;
236
+ }
237
+ for (const key of ["quarantineOpaque", "unsliceableCustody", "unboundCount", "pendingLatchCount"]) {
238
+ const bad = countInvalid(res[key], `bundle.residuals.${key}`);
239
+ if (bad !== undefined)
240
+ return bad;
241
+ }
242
+ const integrity = b.integrity;
243
+ if (!isRecord(integrity))
244
+ return "bundle.integrity is missing";
245
+ const sh = integrity.sectionHashes;
246
+ if (!isRecord(sh))
247
+ return "bundle.integrity.sectionHashes is missing";
248
+ const shKeys = Object.keys(sh).sort();
249
+ if (shKeys.length !== BUNDLE_SECTION_KEYS.length || BUNDLE_SECTION_KEYS.some((k) => !Object.prototype.hasOwnProperty.call(sh, k))) {
250
+ return `bundle.integrity.sectionHashes must carry exactly the keys ${BUNDLE_SECTION_KEYS.join("/")} (a stripped or padded section set is a bad package)`;
251
+ }
252
+ for (const k of BUNDLE_SECTION_KEYS) {
253
+ if (typeof sh[k] !== "string" || !HEX64_RE.test(sh[k]))
254
+ return `bundle.integrity.sectionHashes.${k} is not a sha256 hex`;
255
+ }
256
+ const recomputed = computeBundleSectionHashes(b);
257
+ for (const k of BUNDLE_SECTION_KEYS) {
258
+ if (recomputed[k] !== sh[k])
259
+ return `bundle.integrity.sectionHashes.${k} does not match the section's canonical content (damaged or tampered package)`;
260
+ }
261
+ const entryCount = integrity.entryCount;
262
+ if (typeof entryCount !== "number" || entryCount !== entriesArr.length)
263
+ return "bundle.integrity.entryCount does not equal entries.length";
264
+ const revs = integrity.revs;
265
+ if (!isRecord(revs))
266
+ return "bundle.integrity.revs is missing";
267
+ const revKeys = Object.keys(revs);
268
+ if (revKeys.length !== entryIds.size)
269
+ return "bundle.integrity.revs cardinality does not equal the entry id set";
270
+ for (const e of b.entries) {
271
+ if (revs[e.id] !== e.rev)
272
+ return `bundle.integrity.revs[${JSON.stringify(e.id)}] does not equal the entry's rev`;
273
+ }
274
+ return undefined;
275
+ }
276
+ export const BUNDLE_FREE_TEXT_DISCLOSURE = "Free-string fields in this bundle (pollution/challenge reasons, challenge event ids, custody request ids, session ids) are carried verbatim and may contain text the exporting deployment put there; subset-export tenant isolation does not extend to those literals.";
277
+ export function assembleMemoryExportBundle(input) {
278
+ const sections = {
279
+ bundle: 1,
280
+ at: input.at,
281
+ scopes: [...input.scopes].sort(),
282
+ storeId: input.storeId,
283
+ entries: [...input.entries].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
284
+ governance: {
285
+ challenges: input.challenges,
286
+ pollutedSessions: [...input.pollutedSessions].sort((a, b) => (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0)),
287
+ lineage: [...input.lineage].sort((a, b) => (a.entryId < b.entryId ? -1 : a.entryId > b.entryId ? 1 : 0) || (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0)),
288
+ custody: input.custody,
289
+ },
290
+ residuals: input.residuals,
291
+ };
292
+ const sectionHashes = computeBundleSectionHashes(sections);
293
+ const doc = [BUNDLE_FREE_TEXT_DISCLOSURE];
294
+ if (input.residuals.unsliceableCustody > 0) {
295
+ doc.push(`This subset export could not carry ${input.residuals.unsliceableCustody} custody row(s) that have no scope to slice by (unbound-deletion history among them): the anti-resurrection guarantee for those ids does not travel with this bundle — a full-store export carries them.`);
296
+ }
297
+ return {
298
+ ...sections,
299
+ integrity: {
300
+ entryCount: sections.entries.length,
301
+ revs: Object.fromEntries(sections.entries.map((e) => [e.id, e.rev])),
302
+ sectionHashes,
303
+ },
304
+ doc,
305
+ };
306
+ }
@@ -1,4 +1,5 @@
1
- import { scopeDirName, type MemoryPartitionIncidentSink } from "./layout.js";
1
+ import { scopeDirName, type ChallengeEvent, type MemoryPartitionIncidentSink } from "./layout.js";
2
+ import type { MemoryBundleImportPlan, MemoryImportReport } from "./export-bundle.js";
2
3
  import type { HarvestRejection, MemoryBackend, MemoryEntry, MemoryEntryHeader, NotePatch, PatchReport, ScoredMemoryEntry } from "./types.js";
3
4
  /** Files/dirs the entry scan never treats as entries: the derived index, dotfiles (`.hydrate`,
4
5
  * transaction staging files), and anything not `.md`. */
@@ -288,6 +289,55 @@ export interface EntryCustodyReport {
288
289
  events: TransferEvidence[];
289
290
  reason?: string;
290
291
  }
292
+ /**
293
+ * design/178 v2-c §3 — what the ONE-LOCK export composite ({@link FileMemoryEngineBackend.exportSnapshotOf})
294
+ * answers: every face of the store the bundle needs, read inside a single txn-mutex hold (see the
295
+ * method doc for the fence law). Entries carry committed content only; a bound row whose committed
296
+ * carrier cannot be served is enumerated under `contentUnavailable` instead (never silently
297
+ * dropped, never dressed as content). `unbound`/`pendingLatch` are STORE-LEVEL residual sets (no
298
+ * tenant to belong to); the bundle assembly decides whether their id lists travel (full-store
299
+ * export) or only their counts (subset export). `challenges`/`lineage`/`pollutedSessions` are the
300
+ * RAW governance faces — the (pure) bundle assembly slices them to the exported entry/session
301
+ * sets; `custody` is already sliced (the slicing consumes row-level scope fields this module owns).
302
+ */
303
+ export interface MemoryExportSnapshot {
304
+ storeId: string;
305
+ /** True ⇔ the requested scopes cover every registered scope (one trust domain — the residual id
306
+ * lists and unbound-deletion custody rows may travel). */
307
+ fullStore: boolean;
308
+ entries: MemoryEntry[];
309
+ contentUnavailable: Array<{
310
+ id: string;
311
+ scope: string;
312
+ slug: string;
313
+ rev: string;
314
+ reason: string;
315
+ }>;
316
+ unbound: string[];
317
+ pendingLatch: string[];
318
+ custody: TransferEvidence[];
319
+ unsliceableCustody: number;
320
+ challenges: ChallengeEvent[];
321
+ lineage: Array<{
322
+ entryId: string;
323
+ sessionId: string;
324
+ lastRev: string;
325
+ lastAt: number;
326
+ }>;
327
+ pollutedSessions: Array<{
328
+ sessionId: string;
329
+ at: number;
330
+ reason: string;
331
+ }>;
332
+ quarantined: string[];
333
+ quarantineOpaque: number;
334
+ }
335
+ /** Test hook (证伪式复审 L3): small constants so the crash-window leg does not really wait 15-30s. */
336
+ interface TxnLockTimings {
337
+ staleMs?: number;
338
+ waitMs?: number;
339
+ stealGraceMs?: number;
340
+ }
291
341
  /** Construction options for {@link FileMemoryEngineBackend}. */
292
342
  export interface FileMemoryEngineBackendOptions {
293
343
  /** ⚠️ 分家坑(service 报告 2026-07-09):不显式传时,控制平面按无参 `resolveMemoryEngineRoot()`
@@ -369,6 +419,10 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
369
419
  private adoptionNoticeKeys;
370
420
  /** Test seam (§7.4 r4-④ arm): force a post-commit-point transfer-append failure. */
371
421
  private transfersAppendFault?;
422
+ /** Test seam (v2-c §3-2 fence pins): runs INSIDE the export composite's mutex hold, after the
423
+ * data reads and before the second fingerprint — a test injects a governance-face write here to
424
+ * exercise the drift-retry and the three-round refusal. Production leaves it unset. */
425
+ private exportFaceProbe?;
372
426
  /** v2-b §3-4 — the resurrection backstop's chain digest, keyed by the evidence log's stat
373
427
  * fingerprint (size + mtimeMs — append-only growth and a torn-tail shrink both move `size`).
374
428
  * NEVER a mount-lifetime cache: the fingerprint is re-verified under the txn mutex on every
@@ -972,6 +1026,129 @@ export declare class FileMemoryEngineBackend implements MemoryBackend {
972
1026
  /** The pure open-form evidence read behind {@link custodyOf} (no healing, no writes). `raw` is
973
1027
  * the caller's already-fenced read of the log (undefined = absent). */
974
1028
  private custodyReadPure;
1029
+ /** design/178 v2-c §4 — the export/import refusal code (engine coded-error family, deliberately
1030
+ * outside the governance registry: a store-state refusal, not a per-principal verdict). */
1031
+ private static readonly EXPORT_INCOMPLETE;
1032
+ /** The durable store identity (§6.2): read it, or mint it (`wx`) on first need — the txn mutex is
1033
+ * held by every caller, so the exclusive create cannot race in-process; a cross-process EEXIST
1034
+ * loser adopts the winner's identity. Corrupt/unreadable identity is fail-closed. */
1035
+ private storeIdentityLocked;
1036
+ /** One consistency fingerprint over the three ENGINE governance faces (challenge ledger, lineage
1037
+ * ledger, pollution markers) — raw bytes, journal included. The two strict sidecars' JOURNAL
1038
+ * reads are wrapped FAIL-CLOSED here (§4-6): the shared strict reader folds a journal read
1039
+ * error into absence, and an export that read a stale main file over an unreadable journal
1040
+ * would seal stale governance under an honest hash. Marker files that exist but cannot be read
1041
+ * fingerprint their error code (a stable fault stays stable; a flapping one drifts the fence). */
1042
+ private exportGovernanceFingerprint;
1043
+ /**
1044
+ * The export chain read: PURE INSPECTION (never heals, never quarantines, never truncates — C-8
1045
+ * pins the file byte-identical across a refused export). A torn tail refuses the export
1046
+ * (`memory.export_incomplete`; the deployment heals it by running any locked mutation entry,
1047
+ * then re-exports); an unknown channel refuses too (T-14: a row whose slicing rules this build
1048
+ * does not know can neither be carried — possible cross-tenant leak — nor silently dropped —
1049
+ * lost mandatory governance); one ev under two different payloads refuses (spliced chain);
1050
+ * non-tail corruption stays the fail-closed {@link ControlPlaneCorruptError}. Byte-equal
1051
+ * duplicates collapse (append-idempotency residue).
1052
+ */
1053
+ private readExportChainRowsPure;
1054
+ /**
1055
+ * v2-c §2 — the tenant-slicing table over validated chain rows (T-6..T-11; T-9/T-10 refusals,
1056
+ * T-14 already refused by the reader). `storeId` is a thunk so the identity mints only when a
1057
+ * redaction actually needs the provenance stamp.
1058
+ */
1059
+ private sliceCustodyForScopes;
1060
+ /** T-12 — quarantine residual enumeration for an id set (names for attributable hits, an opaque
1061
+ * count for the rest — same honest bound the erasure attestation uses). */
1062
+ private enumerateQuarantineFor;
1063
+ /**
1064
+ * design/178 v2-c §3 — the ONE-LOCK export composite (capability face; probe with
1065
+ * `typeof backend.exportSnapshotOf === "function"`). All five reads — entries (ledger-driven,
1066
+ * committed content), custody chain, challenge ledger, lineage ledger, pollution markers — happen
1067
+ * inside a single txn-mutex hold: the two backend faces are writer-serialized by the mutex
1068
+ * itself; the three engine faces (written under their own sidecar locks, not the mutex) are
1069
+ * fenced by a byte-level double fingerprint around the reads (≤3 rounds, then a loud refusal —
1070
+ * a store too hot to snapshot answers `memory.export_incomplete`, never a mixed-epoch bundle).
1071
+ * The terminal ownership assertion turns a stolen lock (stale-steal past the 30s line during a
1072
+ * long assembly) into a refusal: an unstolen lock means no writer entered, which IS the
1073
+ * consistency proof — no lease machinery needed. The chain read is pure inspection (a torn tail
1074
+ * refuses with the file byte-untouched); `complete:false`-class scenes all refuse loudly — there
1075
+ * is no degraded bundle shape, by design (§4).
1076
+ */
1077
+ exportSnapshotOf(scopes: readonly string[], timings?: TxnLockTimings): Promise<MemoryExportSnapshot>;
1078
+ /**
1079
+ * design/178 v2 §4.3 — the single-item governance slice face (parent-design form, kept for
1080
+ * NON-export consumers: erasure resolution, per-id inspection tooling). Answers
1081
+ * `complete: false` + reason instead of a bundle-shaped refusal (its callers fail closed on the
1082
+ * flag); definite corruption still throws. The export path deliberately does NOT use this face —
1083
+ * two independently-locked single faces are exactly the cross-face tear the composite closes.
1084
+ */
1085
+ governanceExport(scopes: readonly string[], timings?: TxnLockTimings): Promise<{
1086
+ custody: TransferEvidence[];
1087
+ unbound: string[];
1088
+ complete: boolean;
1089
+ unsliceableCustody: number;
1090
+ reason?: string;
1091
+ }>;
1092
+ /**
1093
+ * design/178 v2-c §5.4 — custody carriage onto THIS chain (capability face). Rows land under the
1094
+ * import namespace — `ev = imp:<bundleHash>:<source ev>` (full-hash salt: deterministic per
1095
+ * bundle, chain-deterministic across hops — a re-exported imported row salts its CURRENT ev
1096
+ * next hop), `origin` kept when present (multi-hop provenance) else stamped with the source
1097
+ * store's identity, `srcEv` kept else stamped with the source ev — so an imported row is ALWAYS
1098
+ * origin-bearing and the local replay/anchor judgments structurally never read it. Unknown
1099
+ * channels and rows failing this store's own validation are WITHHELD and reported (never
1100
+ * appended verbatim — an unvalidatable row would poison the fail-closed chain — and never a
1101
+ * whole-package refusal — a newer exporter must not brick an older importer). Appends are
1102
+ * idempotent by ev with canonical-payload comparison (same-bundle re-import converges; a
1103
+ * different payload under an existing ev is fail-closed corruption). Direct chain append, no
1104
+ * journal: custody carriage is not a ledger mutation, and origin-bearing rows are forbidden in
1105
+ * journals by the recovery leg's own validation.
1106
+ */
1107
+ custodyImport(rows: ReadonlyArray<Record<string, unknown>>, opts: {
1108
+ bundleHash: string;
1109
+ sourceStoreId: string;
1110
+ }, timings?: TxnLockTimings): Promise<{
1111
+ appended: number;
1112
+ withheld: Array<{
1113
+ srcEv: string;
1114
+ channel: string;
1115
+ }>;
1116
+ }>;
1117
+ /** The in-lock custody carriage body ({@link custodyImport}'s and the import composite's shared
1118
+ * leg — the composite already holds the mutex and must not re-enter the acquisition). */
1119
+ private custodyImportLocked;
1120
+ /** Append import-namespace challenge events and VERIFY every key-idempotent replay by reading
1121
+ * the full payload back (§5.2 defense ③): `appendChallengeEvents` is key-idempotent, not
1122
+ * payload-idempotent — a pre-planted same-key event (a resolve, or a same-entry different-
1123
+ * reason challenge) would silently swallow the imported challenge while the return echoes a
1124
+ * plausible assignment. A mismatch ABORTS the import (the latch stays; the account is not
1125
+ * silently short). */
1126
+ private appendImportChallengesVerified;
1127
+ /**
1128
+ * design/178 v2-c §1 — the IMPORT COMPOSITE (capability face; the engine's `importMemoryBundle`
1129
+ * is its validating membrane). One txn-mutex hold end to end:
1130
+ * ①b destination-chain completeness precheck (torn/degraded ⇒ whole package refused with ZERO
1131
+ * side effects — before the latch, "reject before landing" made literal);
1132
+ * ② the synthetic latch (durable pending lineage txn over every bundle entry id — from here
1133
+ * to release, those ids are withheld on every model-visible read face; crash-safe);
1134
+ * ③ authoritative in-lock judgment (the five import rules — a pre-lock classification would
1135
+ * only be advisory, so there is none: the lock-held pass IS the classification);
1136
+ * ④ entry landing through {@link applyPatchesLocked} (same skeleton, same lock, same recovery
1137
+ * leg — never a second transaction protocol; `guard:"absent"` adjudicates the residual
1138
+ * same-id arms);
1139
+ * ⑤ governance legs, custody FIRST unconditionally (chain facts are exempt from the
1140
+ * entry-eligibility rule — an absent-id delete row is exactly the anti-resurrection
1141
+ * payload), then entry-attached lineage/challenges for landed ∪ already-present ids only,
1142
+ * then pollution (retroactive sweep DURABLE BEFORE the marker — the crash between them
1143
+ * leaves contributions challenged and the marker converging on re-import, never the
1144
+ * reverse);
1145
+ * ⑥ the durable completion receipt (`wx`, carrying the report) and the latch release —
1146
+ * receipt-gated, the ONLY release channel.
1147
+ * Any failure past ② leaves the latch standing: re-importing the SAME bundle converges every
1148
+ * crash window (idempotent latch, idempotent judgments, ev/key-idempotent governance legs,
1149
+ * receipt short-circuit).
1150
+ */
1151
+ importBundleCommit(plan: MemoryBundleImportPlan, timings?: TxnLockTimings): Promise<MemoryImportReport>;
975
1152
  getConsolidationCursor(scope: string): Promise<string | undefined>;
976
1153
  setConsolidationCursor(scope: string, cursor: string): Promise<void>;
977
1154
  private readCursors;