@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.
- package/CHANGELOG.md +182 -10
- package/dist/agents/send-message-tool.d.ts +8 -0
- package/dist/agents/send-message-tool.js +8 -0
- package/dist/agents/teacher.js +9 -3
- package/dist/agents/verify.js +9 -3
- package/dist/core/checkpoint-store.d.ts +12 -0
- package/dist/core/governance-codes.js +2 -0
- package/dist/core/hooks.d.ts +23 -0
- package/dist/core/hooks.js +53 -4
- package/dist/core/mailbox-store.d.ts +39 -0
- package/dist/core/mailbox-store.js +9 -0
- package/dist/core/memory-engine/engine.d.ts +27 -0
- package/dist/core/memory-engine/engine.js +103 -1
- package/dist/core/memory-engine/export-bundle.d.ts +192 -0
- package/dist/core/memory-engine/export-bundle.js +306 -0
- package/dist/core/memory-engine/file-backend.d.ts +178 -1
- package/dist/core/memory-engine/file-backend.js +637 -6
- package/dist/core/memory-engine/index.d.ts +2 -1
- package/dist/core/memory-engine/index.js +1 -0
- package/dist/core/memory-engine/layout.d.ts +89 -1
- package/dist/core/memory-engine/layout.js +131 -1
- package/dist/core/memory-engine/memory-backend-contract.d.ts +1 -1
- package/dist/core/memory-engine/memory-backend-contract.js +52 -0
- package/dist/core/memory-engine/tools.js +8 -1
- package/dist/core/permission-rule-consent.d.ts +27 -4
- package/dist/core/permission-rule-consent.js +41 -4
- package/dist/core/permission-rule-model.d.ts +7 -1
- package/dist/core/runner/prepare-task.js +24 -3
- package/dist/core/runner/runtask.js +5 -0
- package/dist/core/runner/synthetic-tools.js +3 -1
- package/dist/core/runner/tool-disclosure.js +2 -1
- package/dist/core/sensitive-path-policy.js +3 -3
- package/dist/core/store-contracts/mailbox-store-contract.d.ts +29 -1
- package/dist/core/store-contracts/mailbox-store-contract.js +78 -0
- package/dist/core/types.d.ts +21 -0
- package/dist/core/write-protect.d.ts +73 -0
- package/dist/core/write-protect.js +195 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/orchestration/governance-baseline-validity.d.ts +44 -0
- package/dist/orchestration/governance-baseline-validity.js +55 -0
- package/dist/orchestration/run-workflow-tool.js +33 -8
- package/dist/orchestration/workflow-script-runner.js +9 -4
- package/dist/tools/fs/read-deny.d.ts +15 -5
- package/dist/tools/fs/read-deny.js +33 -12
- package/dist/tools/fs/safety.d.ts +5 -2
- package/dist/tools/fs/safety.js +5 -3
- package/dist/tools/fs/search.d.ts +33 -0
- package/dist/tools/fs/search.js +72 -0
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +21 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync } from "node:fs";
|
|
1
|
+
import { existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, openSync, fsyncSync, closeSync } from "node:fs";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { dirname, join, relative } from "node:path";
|
|
4
4
|
import { jaccardDistance, termSet } from "../memory-vector.js";
|
|
5
5
|
import { MAX_MEMORY_BYTES } from "../memory.js";
|
|
6
6
|
import { inlineUntrusted } from "../untrusted-text.js";
|
|
7
7
|
import { computeEntryRev, entryFromFile, isValidEntryId, parseEntryFile, serializeEntryFile } from "./frontmatter.js";
|
|
8
|
-
import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, claimRootScope, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, isContainedIn, lineageContributionsOfSession, readLineageRecord, registerScope, registeredScopes, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, writeAllSync, } from "./layout.js";
|
|
8
|
+
import { ControlPlaneCorruptError, CURSORS_FILE, QUARANTINE_DIR, CHALLENGES_FILE, IMPORT_RECEIPTS_DIR, LINEAGE_FILE, SESSION_POLLUTION_DIR, appendChallengeEvents, atomicWriteFileSync, quarantineAndTombstone, captureAndClearLineageForEntries, claimRootScope, adoptCanonicalKeyedControlDir, deriveControlPlaneDir, ensureDirExists, enqueueMemoryAnnouncement, importLatchTxnId, importLineageCommitted, importSessionPollution, isContainedIn, lineageContributionsOfSession, listSessionPollution, readChallengeEvents, readLineageRecord, registerScope, registeredScopes, releaseImportLatch, resolveMemoryEngineRoot, scopeDirFor, scopeDirName, stageImportLatch, writeAllSync, } from "./layout.js";
|
|
9
|
+
import { screenInboundEntries } from "./data-plane.js";
|
|
9
10
|
import { scanMemoryFileName, scanMemoryWrite } from "./scan.js";
|
|
10
11
|
export const MEMORY_INDEX_FILENAME = "MEMORY.md";
|
|
11
12
|
export const DEFAULT_MAX_ENTRY_DEPTH = 3;
|
|
@@ -159,6 +160,7 @@ const TXN_LOCK_STALE_MS = 30_000;
|
|
|
159
160
|
const TXN_LOCK_WAIT_MS = 15_000;
|
|
160
161
|
const TXN_LOCK_STEAL_GRACE_MS = 250;
|
|
161
162
|
const SHADOW_DIR = "shadow";
|
|
163
|
+
const STORE_IDENTITY_FILE = "store-identity.json";
|
|
162
164
|
function rowsRev(rows, id) {
|
|
163
165
|
return rows[id]?.rev;
|
|
164
166
|
}
|
|
@@ -331,9 +333,18 @@ function transferEventInvalid(raw) {
|
|
|
331
333
|
return "non-string/empty 'origin'";
|
|
332
334
|
if (e.srcEv !== undefined && (typeof e.srcEv !== "string" || e.srcEv.length === 0))
|
|
333
335
|
return "non-string/empty 'srcEv'";
|
|
336
|
+
const redactedKeys = new Set();
|
|
334
337
|
if (e.redacted !== undefined) {
|
|
335
|
-
|
|
336
|
-
|
|
338
|
+
const list = Array.isArray(e.redacted) ? e.redacted : undefined;
|
|
339
|
+
if (list === undefined || list.length === 0)
|
|
340
|
+
return "'redacted', when present, must be a non-empty array naming the removed keys";
|
|
341
|
+
for (const k of list) {
|
|
342
|
+
if (k !== "from" && k !== "to")
|
|
343
|
+
return "'redacted' may only name 'from'/'to'";
|
|
344
|
+
if (redactedKeys.has(k))
|
|
345
|
+
return "'redacted' names a key twice";
|
|
346
|
+
redactedKeys.add(k);
|
|
347
|
+
}
|
|
337
348
|
if (e.origin === undefined)
|
|
338
349
|
return "'redacted' requires the import pair (origin/srcEv) — a local row is never redacted";
|
|
339
350
|
}
|
|
@@ -349,12 +360,25 @@ function transferEventInvalid(raw) {
|
|
|
349
360
|
if (e.channel === "adopted-move" || e.channel === "applyPatches-move" || e.channel === "migration-bind") {
|
|
350
361
|
if (typeof e.id !== "string" || e.id.length === 0)
|
|
351
362
|
return "row transfer missing 'id'";
|
|
352
|
-
if (
|
|
363
|
+
if (e.to !== undefined && redactedKeys.has("to"))
|
|
364
|
+
return "'to' is present but named in 'redacted'";
|
|
365
|
+
if (e.from !== undefined && redactedKeys.has("from"))
|
|
366
|
+
return "'from' is present but named in 'redacted'";
|
|
367
|
+
if (e.to === undefined) {
|
|
368
|
+
if (!redactedKeys.has("to"))
|
|
369
|
+
return "row transfer missing/malformed 'to'";
|
|
370
|
+
}
|
|
371
|
+
else if (bindingInvalid(e.to)) {
|
|
353
372
|
return "row transfer missing/malformed 'to'";
|
|
373
|
+
}
|
|
354
374
|
if (e.channel === "migration-bind") {
|
|
355
375
|
if (e.from !== undefined && bindingInvalid(e.from))
|
|
356
376
|
return "malformed 'from'";
|
|
357
377
|
}
|
|
378
|
+
else if (e.from === undefined) {
|
|
379
|
+
if (!redactedKeys.has("from"))
|
|
380
|
+
return `'${e.channel}' requires a well-formed 'from'`;
|
|
381
|
+
}
|
|
358
382
|
else if (bindingInvalid(e.from)) {
|
|
359
383
|
return `'${e.channel}' requires a well-formed 'from'`;
|
|
360
384
|
}
|
|
@@ -516,6 +540,7 @@ export class FileMemoryEngineBackend {
|
|
|
516
540
|
unboundRowsKnown = false;
|
|
517
541
|
adoptionNoticeKeys = new Set();
|
|
518
542
|
transfersAppendFault;
|
|
543
|
+
exportFaceProbe;
|
|
519
544
|
deletedIdsDigest;
|
|
520
545
|
inboundFindings = [];
|
|
521
546
|
batchScan;
|
|
@@ -2704,7 +2729,7 @@ export class FileMemoryEngineBackend {
|
|
|
2704
2729
|
`(or the ledger kept advancing) across every try — the committed account cannot be snapshotted without tearing. ` +
|
|
2705
2730
|
`A journal that never clears is a crashed transaction: the next backend construction or locked write recovers it.`);
|
|
2706
2731
|
}
|
|
2707
|
-
resolveCommittedContent(id, rev, binding, final) {
|
|
2732
|
+
resolveCommittedContent(id, rev, binding, final, lockHeld = false) {
|
|
2708
2733
|
if (binding === undefined)
|
|
2709
2734
|
return { state: "unavailable", reason: "carrier-missing" };
|
|
2710
2735
|
if (!isValidEntryId(id))
|
|
@@ -2726,6 +2751,8 @@ export class FileMemoryEngineBackend {
|
|
|
2726
2751
|
if (projEntry.rev === rev)
|
|
2727
2752
|
return { state: "present", entry: projEntry };
|
|
2728
2753
|
}
|
|
2754
|
+
if (lockHeld)
|
|
2755
|
+
return { state: "unavailable", reason: "carrier-missing" };
|
|
2729
2756
|
if (!final)
|
|
2730
2757
|
throw new AuditObservationUnstableError(`shadow bytes for ${id} do not carry the committed rev — possible adoption in flight`);
|
|
2731
2758
|
if (this.txnInFlight()) {
|
|
@@ -2884,6 +2911,610 @@ export class FileMemoryEngineBackend {
|
|
|
2884
2911
|
}
|
|
2885
2912
|
return out;
|
|
2886
2913
|
}
|
|
2914
|
+
static EXPORT_INCOMPLETE = "memory.export_incomplete";
|
|
2915
|
+
storeIdentityLocked() {
|
|
2916
|
+
const path = join(this.controlPlaneRoot, STORE_IDENTITY_FILE);
|
|
2917
|
+
const parse = (raw) => {
|
|
2918
|
+
let parsed;
|
|
2919
|
+
try {
|
|
2920
|
+
parsed = JSON.parse(raw);
|
|
2921
|
+
}
|
|
2922
|
+
catch {
|
|
2923
|
+
throw new ControlPlaneCorruptError(`store identity is unparseable: ${path}`);
|
|
2924
|
+
}
|
|
2925
|
+
const rec = parsed;
|
|
2926
|
+
if (!rec || typeof rec !== "object" || rec.v !== 1 || typeof rec.storeId !== "string" || rec.storeId.length === 0) {
|
|
2927
|
+
throw new ControlPlaneCorruptError(`store identity has the wrong shape: ${path}`);
|
|
2928
|
+
}
|
|
2929
|
+
return rec.storeId;
|
|
2930
|
+
};
|
|
2931
|
+
const raw = readControlFileOrAbsent(path, "store identity");
|
|
2932
|
+
if (raw !== undefined)
|
|
2933
|
+
return parse(raw);
|
|
2934
|
+
const storeId = randomUUID();
|
|
2935
|
+
let fd;
|
|
2936
|
+
try {
|
|
2937
|
+
fd = openSync(path, "wx", 0o600);
|
|
2938
|
+
}
|
|
2939
|
+
catch (err) {
|
|
2940
|
+
if (err.code === "EEXIST") {
|
|
2941
|
+
const again = readControlFileOrAbsent(path, "store identity");
|
|
2942
|
+
if (again === undefined)
|
|
2943
|
+
throw new ControlPlaneCorruptError(`store identity vanished between its EEXIST and the re-read: ${path}`);
|
|
2944
|
+
return parse(again);
|
|
2945
|
+
}
|
|
2946
|
+
throw err;
|
|
2947
|
+
}
|
|
2948
|
+
try {
|
|
2949
|
+
writeAllSync(fd, `${JSON.stringify({ v: 1, storeId }, null, 2)}\n`);
|
|
2950
|
+
fsyncSync(fd);
|
|
2951
|
+
}
|
|
2952
|
+
finally {
|
|
2953
|
+
closeSync(fd);
|
|
2954
|
+
}
|
|
2955
|
+
return storeId;
|
|
2956
|
+
}
|
|
2957
|
+
exportGovernanceFingerprint() {
|
|
2958
|
+
const h = createHash("sha256");
|
|
2959
|
+
const feed = (label, text) => {
|
|
2960
|
+
h.update(JSON.stringify([label, text]));
|
|
2961
|
+
};
|
|
2962
|
+
for (const file of [LINEAGE_FILE, CHALLENGES_FILE]) {
|
|
2963
|
+
feed(file, readControlFileOrAbsent(join(this.controlPlaneRoot, file), `${file} (export fence)`) ?? "<absent>");
|
|
2964
|
+
let journalRaw;
|
|
2965
|
+
try {
|
|
2966
|
+
journalRaw = readControlFileOrAbsent(join(this.controlPlaneRoot, `${file}.journal`), `${file} journal (export fence)`);
|
|
2967
|
+
}
|
|
2968
|
+
catch (err) {
|
|
2969
|
+
throw erasureCodedError(`memory export refused: the ${file} journal could not be read (${err instanceof Error ? err.message : String(err)}) — a journal read failure is not absence, and exporting over it could seal stale governance under an honest hash`, FileMemoryEngineBackend.EXPORT_INCOMPLETE);
|
|
2970
|
+
}
|
|
2971
|
+
feed(`${file}.journal`, journalRaw ?? "<absent>");
|
|
2972
|
+
}
|
|
2973
|
+
const dir = join(this.controlPlaneRoot, SESSION_POLLUTION_DIR);
|
|
2974
|
+
let names = [];
|
|
2975
|
+
try {
|
|
2976
|
+
names = readdirSync(dir).sort();
|
|
2977
|
+
}
|
|
2978
|
+
catch (err) {
|
|
2979
|
+
if (err.code !== "ENOENT") {
|
|
2980
|
+
throw new ControlPlaneCorruptError(`session pollution markers could not be enumerated for export (${err.code ?? "io error"}) at ${dir} — fail-closed`, { cause: err });
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
for (const name of names) {
|
|
2984
|
+
let text;
|
|
2985
|
+
try {
|
|
2986
|
+
text = readFileSync(join(dir, name), "utf8");
|
|
2987
|
+
}
|
|
2988
|
+
catch (err) {
|
|
2989
|
+
text = `<unreadable:${err.code ?? "io error"}>`;
|
|
2990
|
+
}
|
|
2991
|
+
feed(`pollution/${name}`, text);
|
|
2992
|
+
}
|
|
2993
|
+
return h.digest("hex");
|
|
2994
|
+
}
|
|
2995
|
+
readExportChainRowsPure() {
|
|
2996
|
+
const path = join(this.controlPlaneRoot, TRANSFERS_FILE);
|
|
2997
|
+
const raw = readControlFileOrAbsent(path, "transfer evidence log");
|
|
2998
|
+
const out = [];
|
|
2999
|
+
if (raw === undefined)
|
|
3000
|
+
return out;
|
|
3001
|
+
const byEv = new Map();
|
|
3002
|
+
const lines = raw.split("\n");
|
|
3003
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3004
|
+
const line = lines[i];
|
|
3005
|
+
if (line === undefined || line === "")
|
|
3006
|
+
continue;
|
|
3007
|
+
let parsed;
|
|
3008
|
+
try {
|
|
3009
|
+
parsed = JSON.parse(line);
|
|
3010
|
+
}
|
|
3011
|
+
catch {
|
|
3012
|
+
if (lines.slice(i + 1).every((l) => l === "")) {
|
|
3013
|
+
throw erasureCodedError(`memory export refused: the transfer evidence log has a torn tail (line ${i + 1}) — the export reads it purely (the file is untouched); run any locked mutation entry (a write, an adopting read) to heal the chain, then export again`, FileMemoryEngineBackend.EXPORT_INCOMPLETE);
|
|
3014
|
+
}
|
|
3015
|
+
throw new ControlPlaneCorruptError(`transfer evidence log line ${i + 1} is unparseable (not a torn tail — fail-closed): ${path}`);
|
|
3016
|
+
}
|
|
3017
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
3018
|
+
throw new ControlPlaneCorruptError(`transfer evidence log line ${i + 1} is not an object (fail-closed): ${path}`);
|
|
3019
|
+
}
|
|
3020
|
+
const e = parsed;
|
|
3021
|
+
if (typeof e.ev !== "string" || e.ev.length === 0 || typeof e.channel !== "string" || e.channel.length === 0 || typeof e.at !== "number" || !Number.isInteger(e.at)) {
|
|
3022
|
+
throw new ControlPlaneCorruptError(`transfer evidence log line ${i + 1} is missing its base keys (ev/channel/at) — fail-closed: ${path}`);
|
|
3023
|
+
}
|
|
3024
|
+
if (!KNOWN_TRANSFER_CHANNELS.has(e.channel)) {
|
|
3025
|
+
throw erasureCodedError(`memory export refused: the evidence chain carries channel ${JSON.stringify(e.channel)} this exporter does not know (line ${i + 1}) — an unknown row can neither be tenant-sliced nor silently dropped; export with the binary that wrote it (or newer)`, FileMemoryEngineBackend.EXPORT_INCOMPLETE);
|
|
3026
|
+
}
|
|
3027
|
+
const invalid = transferEventInvalid(parsed);
|
|
3028
|
+
if (invalid !== undefined)
|
|
3029
|
+
throw new ControlPlaneCorruptError(`transfer evidence log line ${i + 1} is invalid (${invalid}): ${path}`);
|
|
3030
|
+
const canonical = canonicalJsonStringify(e);
|
|
3031
|
+
const prior = byEv.get(e.ev);
|
|
3032
|
+
if (prior !== undefined) {
|
|
3033
|
+
if (prior !== canonical) {
|
|
3034
|
+
throw erasureCodedError(`memory export refused: the evidence chain carries event id ${JSON.stringify(e.ev)} twice with DIFFERENT payloads (line ${i + 1}) — a spliced chain cannot be exported`, FileMemoryEngineBackend.EXPORT_INCOMPLETE);
|
|
3035
|
+
}
|
|
3036
|
+
continue;
|
|
3037
|
+
}
|
|
3038
|
+
byEv.set(e.ev, canonical);
|
|
3039
|
+
out.push(e);
|
|
3040
|
+
}
|
|
3041
|
+
return out;
|
|
3042
|
+
}
|
|
3043
|
+
sliceCustodyForScopes(rows, scopeSet, fullStore, storeId) {
|
|
3044
|
+
const custody = [];
|
|
3045
|
+
let unsliceable = 0;
|
|
3046
|
+
for (const e of rows) {
|
|
3047
|
+
const channel = e.channel;
|
|
3048
|
+
if (channel === "migration" || channel === "erasure-anchor")
|
|
3049
|
+
continue;
|
|
3050
|
+
if (channel === "erasure-request") {
|
|
3051
|
+
if (scopeSet.has(e.scope))
|
|
3052
|
+
custody.push(e);
|
|
3053
|
+
continue;
|
|
3054
|
+
}
|
|
3055
|
+
if (channel === "delete") {
|
|
3056
|
+
const from = e.from;
|
|
3057
|
+
if (from !== undefined && typeof from.scope === "string") {
|
|
3058
|
+
if (scopeSet.has(from.scope))
|
|
3059
|
+
custody.push(e);
|
|
3060
|
+
}
|
|
3061
|
+
else if (fullStore) {
|
|
3062
|
+
custody.push(e);
|
|
3063
|
+
}
|
|
3064
|
+
else {
|
|
3065
|
+
unsliceable++;
|
|
3066
|
+
}
|
|
3067
|
+
continue;
|
|
3068
|
+
}
|
|
3069
|
+
const from = e.from;
|
|
3070
|
+
const to = e.to;
|
|
3071
|
+
const fromIn = from !== undefined && typeof from.scope === "string" && scopeSet.has(from.scope);
|
|
3072
|
+
const toIn = to !== undefined && typeof to.scope === "string" && scopeSet.has(to.scope);
|
|
3073
|
+
if (!fromIn && !toIn)
|
|
3074
|
+
continue;
|
|
3075
|
+
if ((from === undefined || fromIn) && (to === undefined || toIn)) {
|
|
3076
|
+
custody.push(e);
|
|
3077
|
+
continue;
|
|
3078
|
+
}
|
|
3079
|
+
const redacted = { ...e };
|
|
3080
|
+
const named = new Set(Array.isArray(e.redacted) ? e.redacted : []);
|
|
3081
|
+
if (from !== undefined && !fromIn) {
|
|
3082
|
+
delete redacted.from;
|
|
3083
|
+
named.add("from");
|
|
3084
|
+
}
|
|
3085
|
+
if (to !== undefined && !toIn) {
|
|
3086
|
+
delete redacted.to;
|
|
3087
|
+
named.add("to");
|
|
3088
|
+
}
|
|
3089
|
+
redacted.redacted = [...named].sort();
|
|
3090
|
+
if (redacted.origin === undefined) {
|
|
3091
|
+
redacted.origin = storeId();
|
|
3092
|
+
redacted.srcEv = e.ev;
|
|
3093
|
+
}
|
|
3094
|
+
custody.push(redacted);
|
|
3095
|
+
}
|
|
3096
|
+
return { custody, unsliceable };
|
|
3097
|
+
}
|
|
3098
|
+
enumerateQuarantineFor(ids) {
|
|
3099
|
+
const qDir = join(this.controlPlaneRoot, QUARANTINE_DIR);
|
|
3100
|
+
let names = [];
|
|
3101
|
+
try {
|
|
3102
|
+
names = readdirSync(qDir);
|
|
3103
|
+
}
|
|
3104
|
+
catch (err) {
|
|
3105
|
+
if (err.code !== "ENOENT") {
|
|
3106
|
+
throw new ControlPlaneCorruptError(`export residual enumeration failed: the quarantine dir could not be read (${err.code ?? "io error"}) at ${qDir} — a bundle must not silently claim an empty residual set`);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
const quarantined = [];
|
|
3110
|
+
let quarantineOpaque = 0;
|
|
3111
|
+
for (const name of names.sort()) {
|
|
3112
|
+
const text = readSafe(join(qDir, name));
|
|
3113
|
+
if (text === undefined) {
|
|
3114
|
+
quarantineOpaque++;
|
|
3115
|
+
continue;
|
|
3116
|
+
}
|
|
3117
|
+
const qid = parseEntryFile(text).id;
|
|
3118
|
+
if (qid === undefined) {
|
|
3119
|
+
quarantineOpaque++;
|
|
3120
|
+
continue;
|
|
3121
|
+
}
|
|
3122
|
+
if (ids.has(qid))
|
|
3123
|
+
quarantined.push(name);
|
|
3124
|
+
}
|
|
3125
|
+
return { quarantined, quarantineOpaque };
|
|
3126
|
+
}
|
|
3127
|
+
async exportSnapshotOf(scopes, timings) {
|
|
3128
|
+
const scopeSet = new Set(scopes);
|
|
3129
|
+
const lock = await this.acquireTxnLock(timings);
|
|
3130
|
+
try {
|
|
3131
|
+
this.assertTxnLockOwnership(lock.token);
|
|
3132
|
+
this.ledger = undefined;
|
|
3133
|
+
this.recoverJournal();
|
|
3134
|
+
const rows = this.loadLedger();
|
|
3135
|
+
if (this.persistedSchemaVersion !== "v2") {
|
|
3136
|
+
throw erasureCodedError("memory export refused: the committed-rev ledger is still schema v1 (pre-binding) — the store migrates itself at its next write/read entry or checkControlPlane; an export never triggers the migration", FileMemoryEngineBackend.EXPORT_INCOMPLETE);
|
|
3137
|
+
}
|
|
3138
|
+
if (this.chainDegradedMarkerPresent()) {
|
|
3139
|
+
throw erasureCodedError("memory export refused: the custody chain carries the durable degradation marker (transfers.chain-degraded.json) — evidence rows were lost to an out-of-band tear and a bundle cannot testify to a complete deletion history; resolve the marker deliberately before exporting", FileMemoryEngineBackend.EXPORT_INCOMPLETE);
|
|
3140
|
+
}
|
|
3141
|
+
const storeId = this.storeIdentityLocked();
|
|
3142
|
+
const registered = registeredScopes(this.controlPlaneRoot);
|
|
3143
|
+
const fullStore = Object.keys(registered).every((s) => scopeSet.has(s));
|
|
3144
|
+
for (let round = 0; round < 3; round++) {
|
|
3145
|
+
const before = this.exportGovernanceFingerprint();
|
|
3146
|
+
const lineageRec = readLineageRecord(this.controlPlaneRoot);
|
|
3147
|
+
const challenges = readChallengeEvents(this.controlPlaneRoot);
|
|
3148
|
+
const polluted = listSessionPollution(this.controlPlaneRoot);
|
|
3149
|
+
const chainRows = this.readExportChainRowsPure();
|
|
3150
|
+
this.exportFaceProbe?.(round);
|
|
3151
|
+
const after = this.exportGovernanceFingerprint();
|
|
3152
|
+
if (before !== after)
|
|
3153
|
+
continue;
|
|
3154
|
+
const latched = new Set();
|
|
3155
|
+
for (const txn of Object.values(lineageRec.pending))
|
|
3156
|
+
for (const row of txn.rows)
|
|
3157
|
+
latched.add(row.entryId);
|
|
3158
|
+
const entries = [];
|
|
3159
|
+
const contentUnavailable = [];
|
|
3160
|
+
const boundIdsInScopes = new Set();
|
|
3161
|
+
for (const [id, row] of rowsEntries(rows)) {
|
|
3162
|
+
const binding = rowsBinding(rows, id);
|
|
3163
|
+
if (binding === undefined || !scopeSet.has(binding.scope))
|
|
3164
|
+
continue;
|
|
3165
|
+
boundIdsInScopes.add(id);
|
|
3166
|
+
if (latched.has(id))
|
|
3167
|
+
continue;
|
|
3168
|
+
const content = this.resolveCommittedContent(id, row.rev, binding, true, true);
|
|
3169
|
+
if (content.state === "present")
|
|
3170
|
+
entries.push(content.entry);
|
|
3171
|
+
else
|
|
3172
|
+
contentUnavailable.push({ id, scope: binding.scope, slug: binding.slug, rev: row.rev, reason: content.reason });
|
|
3173
|
+
}
|
|
3174
|
+
const sliced = this.sliceCustodyForScopes(chainRows, scopeSet, fullStore, () => storeId);
|
|
3175
|
+
const residual = this.enumerateQuarantineFor(boundIdsInScopes);
|
|
3176
|
+
const lineage = [];
|
|
3177
|
+
for (const [entryId, sessions] of Object.entries(lineageRec.committed)) {
|
|
3178
|
+
for (const [sessionId, c] of Object.entries(sessions))
|
|
3179
|
+
lineage.push({ entryId, sessionId, lastRev: c.lastRev, lastAt: c.lastAt });
|
|
3180
|
+
}
|
|
3181
|
+
const pollutedSessions = Object.entries(polluted).map(([sessionId, r]) => ({ sessionId, at: r.at, reason: r.reason }));
|
|
3182
|
+
try {
|
|
3183
|
+
this.assertTxnLockOwnership(lock.token);
|
|
3184
|
+
}
|
|
3185
|
+
catch (err) {
|
|
3186
|
+
throw erasureCodedError(`memory export refused: the transaction lock was lost during assembly (a stalled hold past the stale line was stolen from) — the read set can no longer be proven consistent; retry the export (${err instanceof Error ? err.message : String(err)})`, FileMemoryEngineBackend.EXPORT_INCOMPLETE);
|
|
3187
|
+
}
|
|
3188
|
+
return {
|
|
3189
|
+
storeId,
|
|
3190
|
+
fullStore,
|
|
3191
|
+
entries,
|
|
3192
|
+
contentUnavailable,
|
|
3193
|
+
unbound: unboundRowIds(rows).sort(),
|
|
3194
|
+
pendingLatch: [...latched].sort(),
|
|
3195
|
+
custody: sliced.custody,
|
|
3196
|
+
unsliceableCustody: sliced.unsliceable,
|
|
3197
|
+
challenges,
|
|
3198
|
+
lineage,
|
|
3199
|
+
pollutedSessions,
|
|
3200
|
+
quarantined: residual.quarantined,
|
|
3201
|
+
quarantineOpaque: residual.quarantineOpaque,
|
|
3202
|
+
};
|
|
3203
|
+
}
|
|
3204
|
+
throw erasureCodedError("memory export refused: the governance faces (challenge/lineage/pollution) kept changing across three consistency rounds — the store is too hot to snapshot without tearing; retry when its writers settle", FileMemoryEngineBackend.EXPORT_INCOMPLETE);
|
|
3205
|
+
}
|
|
3206
|
+
finally {
|
|
3207
|
+
lock.release();
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
async governanceExport(scopes, timings) {
|
|
3211
|
+
const scopeSet = new Set(scopes);
|
|
3212
|
+
const lock = await this.acquireTxnLock(timings);
|
|
3213
|
+
try {
|
|
3214
|
+
this.assertTxnLockOwnership(lock.token);
|
|
3215
|
+
this.ledger = undefined;
|
|
3216
|
+
this.recoverJournal();
|
|
3217
|
+
const rows = this.loadLedger();
|
|
3218
|
+
if (this.persistedSchemaVersion !== "v2")
|
|
3219
|
+
return { custody: [], unbound: [], complete: false, unsliceableCustody: 0, reason: "the committed-rev ledger is still schema v1 (migrate first)" };
|
|
3220
|
+
if (this.chainDegradedMarkerPresent()) {
|
|
3221
|
+
return { custody: [], unbound: [], complete: false, unsliceableCustody: 0, reason: "the custody chain carries the durable degradation marker (transfers.chain-degraded.json) — evidence rows were lost to an out-of-band tear" };
|
|
3222
|
+
}
|
|
3223
|
+
const registered = registeredScopes(this.controlPlaneRoot);
|
|
3224
|
+
const fullStore = Object.keys(registered).every((s) => scopeSet.has(s));
|
|
3225
|
+
try {
|
|
3226
|
+
const chainRows = this.readExportChainRowsPure();
|
|
3227
|
+
const sliced = this.sliceCustodyForScopes(chainRows, scopeSet, fullStore, () => this.storeIdentityLocked());
|
|
3228
|
+
this.assertTxnLockOwnership(lock.token);
|
|
3229
|
+
return { custody: sliced.custody, unbound: unboundRowIds(rows).sort(), complete: true, unsliceableCustody: sliced.unsliceable };
|
|
3230
|
+
}
|
|
3231
|
+
catch (err) {
|
|
3232
|
+
if (err.code === FileMemoryEngineBackend.EXPORT_INCOMPLETE || err instanceof ControlPlaneCorruptError) {
|
|
3233
|
+
if (err instanceof ControlPlaneCorruptError && !err.message.includes("ownership"))
|
|
3234
|
+
throw err;
|
|
3235
|
+
return { custody: [], unbound: [], complete: false, unsliceableCustody: 0, reason: err instanceof Error ? err.message : String(err) };
|
|
3236
|
+
}
|
|
3237
|
+
throw err;
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
finally {
|
|
3241
|
+
lock.release();
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
async custodyImport(rows, opts, timings) {
|
|
3245
|
+
if (typeof opts?.bundleHash !== "string" || opts.bundleHash.length === 0 || typeof opts.sourceStoreId !== "string" || opts.sourceStoreId.length === 0) {
|
|
3246
|
+
throw erasureCodedError("custodyImport: bundleHash and sourceStoreId are required (the remint namespace and the origin stamp)", "config.memory_import_request");
|
|
3247
|
+
}
|
|
3248
|
+
const lock = await this.acquireTxnLock(timings);
|
|
3249
|
+
try {
|
|
3250
|
+
this.assertTxnLockOwnership(lock.token);
|
|
3251
|
+
this.recoverJournal();
|
|
3252
|
+
return this.custodyImportLocked(rows, opts);
|
|
3253
|
+
}
|
|
3254
|
+
finally {
|
|
3255
|
+
lock.release();
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
custodyImportLocked(rows, opts) {
|
|
3259
|
+
const withheld = [];
|
|
3260
|
+
const toAppend = [];
|
|
3261
|
+
for (const row of rows) {
|
|
3262
|
+
const channel = typeof row.channel === "string" ? row.channel : "<malformed>";
|
|
3263
|
+
const srcEv = typeof row.srcEv === "string" ? row.srcEv : typeof row.ev === "string" ? row.ev : "<malformed>";
|
|
3264
|
+
if (typeof row.ev !== "string" || row.ev.length === 0 || typeof row.channel !== "string" || row.channel.length === 0 || typeof row.at !== "number" || !Number.isInteger(row.at)) {
|
|
3265
|
+
withheld.push({ srcEv, channel });
|
|
3266
|
+
continue;
|
|
3267
|
+
}
|
|
3268
|
+
if (!KNOWN_TRANSFER_CHANNELS.has(row.channel)) {
|
|
3269
|
+
withheld.push({ srcEv, channel });
|
|
3270
|
+
continue;
|
|
3271
|
+
}
|
|
3272
|
+
if (transferEventInvalid(row) !== undefined) {
|
|
3273
|
+
withheld.push({ srcEv, channel });
|
|
3274
|
+
continue;
|
|
3275
|
+
}
|
|
3276
|
+
const reminted = {
|
|
3277
|
+
...row,
|
|
3278
|
+
ev: `imp:${opts.bundleHash}:${row.ev}`,
|
|
3279
|
+
origin: row.origin ?? opts.sourceStoreId,
|
|
3280
|
+
srcEv: row.srcEv ?? row.ev,
|
|
3281
|
+
};
|
|
3282
|
+
if (transferEventInvalid(reminted) !== undefined) {
|
|
3283
|
+
withheld.push({ srcEv, channel });
|
|
3284
|
+
continue;
|
|
3285
|
+
}
|
|
3286
|
+
toAppend.push(reminted);
|
|
3287
|
+
}
|
|
3288
|
+
this.appendTransfers(toAppend);
|
|
3289
|
+
return { appended: toAppend.length, withheld };
|
|
3290
|
+
}
|
|
3291
|
+
appendImportChallengesVerified(events) {
|
|
3292
|
+
if (events.length === 0)
|
|
3293
|
+
return;
|
|
3294
|
+
const { assignments } = appendChallengeEvents(this.controlPlaneRoot, events, this.now);
|
|
3295
|
+
const replayed = assignments.filter((a) => a.replayed);
|
|
3296
|
+
if (replayed.length === 0)
|
|
3297
|
+
return;
|
|
3298
|
+
const byEventId = new Map();
|
|
3299
|
+
for (const e of readChallengeEvents(this.controlPlaneRoot))
|
|
3300
|
+
if (!byEventId.has(e.eventId))
|
|
3301
|
+
byEventId.set(e.eventId, e);
|
|
3302
|
+
for (const a of replayed) {
|
|
3303
|
+
const want = events.find((e) => e.eventId === a.eventId);
|
|
3304
|
+
const got = byEventId.get(a.eventId);
|
|
3305
|
+
if (want === undefined || got === undefined || got.kind !== "challenge" || got.entryId !== want.entryId || got.reason !== want.reason || got.challengedRev !== want.challengedRev) {
|
|
3306
|
+
throw new ControlPlaneCorruptError(`bundle import aborted: challenge event ${JSON.stringify(a.eventId)} replayed onto an existing ledger event with a DIFFERENT payload (kind/entryId/reason/challengedRev) — key idempotency would silently swallow the imported challenge; the import latch stays until a clean re-import converges`);
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
async importBundleCommit(plan, timings) {
|
|
3311
|
+
const bundleHash = typeof plan?.bundleHash === "string" ? plan.bundleHash : "";
|
|
3312
|
+
const sourceStoreId = typeof plan?.sourceStoreId === "string" ? plan.sourceStoreId : "";
|
|
3313
|
+
if (!/^[0-9a-f]{64}$/.test(bundleHash) || sourceStoreId.length === 0) {
|
|
3314
|
+
throw erasureCodedError("importBundleCommit: plan.bundleHash must be the 64-hex bundle digest and sourceStoreId a non-empty string", "config.memory_import_request");
|
|
3315
|
+
}
|
|
3316
|
+
const lock = await this.acquireTxnLock(timings);
|
|
3317
|
+
try {
|
|
3318
|
+
this.assertTxnLockOwnership(lock.token);
|
|
3319
|
+
this.ledger = undefined;
|
|
3320
|
+
this.recoverJournal();
|
|
3321
|
+
this.migrateLockedIfNeeded(lock.token);
|
|
3322
|
+
const ctl = this.controlPlaneRoot;
|
|
3323
|
+
const txnId = importLatchTxnId(bundleHash);
|
|
3324
|
+
const receiptPath = join(ctl, IMPORT_RECEIPTS_DIR, `${bundleHash}.json`);
|
|
3325
|
+
const receiptRaw = readControlFileOrAbsent(receiptPath, "bundle import completion receipt");
|
|
3326
|
+
if (receiptRaw !== undefined) {
|
|
3327
|
+
let report;
|
|
3328
|
+
try {
|
|
3329
|
+
const parsed = JSON.parse(receiptRaw);
|
|
3330
|
+
if (parsed === null ||
|
|
3331
|
+
typeof parsed !== "object" ||
|
|
3332
|
+
!("v" in parsed) ||
|
|
3333
|
+
parsed.v !== 1 ||
|
|
3334
|
+
!("bundleHash" in parsed) ||
|
|
3335
|
+
parsed.bundleHash !== bundleHash ||
|
|
3336
|
+
!("report" in parsed)) {
|
|
3337
|
+
throw new Error("wrong envelope");
|
|
3338
|
+
}
|
|
3339
|
+
const rep = parsed.report;
|
|
3340
|
+
if (!rep ||
|
|
3341
|
+
typeof rep !== "object" ||
|
|
3342
|
+
rep.v !== 1 ||
|
|
3343
|
+
rep.bundleHash !== bundleHash ||
|
|
3344
|
+
typeof rep.sourceStoreId !== "string" ||
|
|
3345
|
+
!Array.isArray(rep.landed) ||
|
|
3346
|
+
!Array.isArray(rep.alreadyPresent) ||
|
|
3347
|
+
!Array.isArray(rep.refusedErased) ||
|
|
3348
|
+
!Array.isArray(rep.refusedUntrusted) ||
|
|
3349
|
+
!Array.isArray(rep.conflicts) ||
|
|
3350
|
+
!Array.isArray(rep.governanceWithheld) ||
|
|
3351
|
+
!Array.isArray(rep.erasedAtSource) ||
|
|
3352
|
+
!Array.isArray(rep.pollutionDivergence) ||
|
|
3353
|
+
!Array.isArray(rep.referentialOrphans) ||
|
|
3354
|
+
!Array.isArray(rep.lineageDivergence) ||
|
|
3355
|
+
!Array.isArray(rep.custodyWithheld) ||
|
|
3356
|
+
typeof rep.custodyAppended !== "number") {
|
|
3357
|
+
throw new Error("wrong report shape");
|
|
3358
|
+
}
|
|
3359
|
+
report = rep;
|
|
3360
|
+
}
|
|
3361
|
+
catch {
|
|
3362
|
+
throw new ControlPlaneCorruptError(`bundle import completion receipt is unreadable/malformed at ${receiptPath} — it cannot prove completion and cannot be re-minted; repair or remove it deliberately (fail-closed)`);
|
|
3363
|
+
}
|
|
3364
|
+
releaseImportLatch(ctl, txnId);
|
|
3365
|
+
return report;
|
|
3366
|
+
}
|
|
3367
|
+
const digest = this.deletedIdsDigestLocked();
|
|
3368
|
+
if (digest.state === "damaged" || this.chainDegradedMarkerPresent()) {
|
|
3369
|
+
throw erasureCodedError("memory bundle import refused: the destination evidence chain is damaged or durably marked degraded — deletion history cannot be proven, so the anti-resurrection judgment cannot run; heal/rebuild the chain (locked mutation entry / explicit rebuild), then import again (nothing was landed, no latch was set)", "memory.import_rejected");
|
|
3370
|
+
}
|
|
3371
|
+
stageImportLatch(ctl, bundleHash, plan.entries.map((e) => ({ entryId: e.id, rev: e.rev })), this.now);
|
|
3372
|
+
const rows = this.loadLedger();
|
|
3373
|
+
const rowPresent = (id) => Object.prototype.hasOwnProperty.call(rows, id);
|
|
3374
|
+
const report = {
|
|
3375
|
+
v: 1,
|
|
3376
|
+
bundleHash,
|
|
3377
|
+
sourceStoreId,
|
|
3378
|
+
landed: [],
|
|
3379
|
+
alreadyPresent: [],
|
|
3380
|
+
refusedErased: [],
|
|
3381
|
+
refusedUntrusted: [],
|
|
3382
|
+
conflicts: [],
|
|
3383
|
+
governanceWithheld: [],
|
|
3384
|
+
erasedAtSource: [],
|
|
3385
|
+
pollutionDivergence: [],
|
|
3386
|
+
referentialOrphans: [],
|
|
3387
|
+
lineageDivergence: [],
|
|
3388
|
+
custodyWithheld: [],
|
|
3389
|
+
custodyAppended: 0,
|
|
3390
|
+
};
|
|
3391
|
+
const entryById = new Map(plan.entries.map((e) => [e.id, e]));
|
|
3392
|
+
const toLand = [];
|
|
3393
|
+
for (const e of plan.entries) {
|
|
3394
|
+
const present = rowPresent(e.id);
|
|
3395
|
+
if (!present && digest.ids.has(e.id)) {
|
|
3396
|
+
report.refusedErased.push(e.id);
|
|
3397
|
+
continue;
|
|
3398
|
+
}
|
|
3399
|
+
if (present && rowsRev(rows, e.id) === e.rev && rowsBinding(rows, e.id)?.scope === e.scope) {
|
|
3400
|
+
report.alreadyPresent.push(e.id);
|
|
3401
|
+
continue;
|
|
3402
|
+
}
|
|
3403
|
+
if (!present && e.frontmatter.provenance?.kind === "repo_file" && e.frontmatter.trust === undefined) {
|
|
3404
|
+
report.refusedUntrusted.push(e.id);
|
|
3405
|
+
continue;
|
|
3406
|
+
}
|
|
3407
|
+
toLand.push(e);
|
|
3408
|
+
}
|
|
3409
|
+
const erasedAtSource = new Set();
|
|
3410
|
+
for (const c of plan.custody) {
|
|
3411
|
+
if (c.channel === "delete" && typeof c.id === "string" && rowPresent(c.id))
|
|
3412
|
+
erasedAtSource.add(c.id);
|
|
3413
|
+
}
|
|
3414
|
+
report.erasedAtSource = [...erasedAtSource].sort();
|
|
3415
|
+
const screened = new Set();
|
|
3416
|
+
for (const finding of screenInboundEntries(toLand, { perFileBytes: MAX_MEMORY_BYTES })) {
|
|
3417
|
+
screened.add(finding.id);
|
|
3418
|
+
report.conflicts.push({ id: finding.id, reason: `inbound screen: ${finding.findings.map((f) => `${f.code} (${f.reason})`).join("; ")}` });
|
|
3419
|
+
}
|
|
3420
|
+
const patches = toLand.filter((e) => !screened.has(e.id)).map((e) => ({ op: "add", id: e.id, entry: e, guard: "absent" }));
|
|
3421
|
+
const patchReport = patches.length > 0 ? await this.applyPatchesLocked(patches, lock.token) : { applied: [], conflicts: [] };
|
|
3422
|
+
for (const c of patchReport.conflicts)
|
|
3423
|
+
report.conflicts.push({ id: c.id, reason: c.reason });
|
|
3424
|
+
const landedIds = new Set();
|
|
3425
|
+
for (const a of patchReport.applied) {
|
|
3426
|
+
if (a.op !== "add")
|
|
3427
|
+
continue;
|
|
3428
|
+
landedIds.add(a.id);
|
|
3429
|
+
const src = entryById.get(a.id);
|
|
3430
|
+
report.landed.push({ id: a.id, scope: src?.scope ?? "", slug: a.slug ?? src?.slug ?? "" });
|
|
3431
|
+
}
|
|
3432
|
+
const custodyOutcome = this.custodyImportLocked(plan.custody, { bundleHash, sourceStoreId });
|
|
3433
|
+
report.custodyAppended = custodyOutcome.appended;
|
|
3434
|
+
report.custodyWithheld = custodyOutcome.withheld;
|
|
3435
|
+
const eligible = new Set([...landedIds, ...report.alreadyPresent]);
|
|
3436
|
+
const destRows = this.loadLedger();
|
|
3437
|
+
const destKnown = (id) => Object.prototype.hasOwnProperty.call(destRows, id);
|
|
3438
|
+
const withheldIds = new Set();
|
|
3439
|
+
const orphanIds = new Set();
|
|
3440
|
+
const lineageToApply = [];
|
|
3441
|
+
for (const row of plan.lineage) {
|
|
3442
|
+
if (eligible.has(row.entryId))
|
|
3443
|
+
lineageToApply.push(row);
|
|
3444
|
+
else if (destKnown(row.entryId) || entryById.has(row.entryId))
|
|
3445
|
+
withheldIds.add(row.entryId);
|
|
3446
|
+
else
|
|
3447
|
+
orphanIds.add(row.entryId);
|
|
3448
|
+
}
|
|
3449
|
+
const challengesToApply = [];
|
|
3450
|
+
for (const c of plan.challenges) {
|
|
3451
|
+
if (eligible.has(c.entryId))
|
|
3452
|
+
challengesToApply.push(c);
|
|
3453
|
+
else if (destKnown(c.entryId) || entryById.has(c.entryId))
|
|
3454
|
+
withheldIds.add(c.entryId);
|
|
3455
|
+
else
|
|
3456
|
+
orphanIds.add(c.entryId);
|
|
3457
|
+
}
|
|
3458
|
+
report.governanceWithheld = [...withheldIds].sort();
|
|
3459
|
+
report.referentialOrphans = [...orphanIds].sort();
|
|
3460
|
+
report.lineageDivergence = importLineageCommitted(ctl, lineageToApply);
|
|
3461
|
+
this.appendImportChallengesVerified(challengesToApply.map((c) => ({
|
|
3462
|
+
eventId: `imp:${bundleHash}:${c.eventId}`,
|
|
3463
|
+
entryId: c.entryId,
|
|
3464
|
+
reason: c.reason,
|
|
3465
|
+
...(c.challengedRev !== undefined ? { challengedRev: c.challengedRev } : {}),
|
|
3466
|
+
})));
|
|
3467
|
+
for (const p of [...plan.pollutedSessions].sort((a, b) => (a.sessionId < b.sessionId ? -1 : 1))) {
|
|
3468
|
+
const contributions = lineageContributionsOfSession(ctl, p.sessionId);
|
|
3469
|
+
this.appendImportChallengesVerified(contributions.map((c) => ({
|
|
3470
|
+
eventId: `imp-sweep:${bundleHash}:pollution:${p.sessionId}:${p.at}:${c.entryId}`,
|
|
3471
|
+
entryId: c.entryId,
|
|
3472
|
+
reason: `challenged: the contributing session was marked polluted`,
|
|
3473
|
+
challengedRev: c.lastRev,
|
|
3474
|
+
})));
|
|
3475
|
+
const outcome = importSessionPollution(ctl, p.sessionId, { at: p.at, reason: p.reason });
|
|
3476
|
+
if (outcome === "divergent")
|
|
3477
|
+
report.pollutionDivergence.push({ sessionId: p.sessionId, kept: "destination" });
|
|
3478
|
+
}
|
|
3479
|
+
ensureDirExists(join(ctl, IMPORT_RECEIPTS_DIR));
|
|
3480
|
+
if (readControlFileOrAbsent(receiptPath, "bundle import completion receipt") !== undefined) {
|
|
3481
|
+
throw new ControlPlaneCorruptError(`bundle import completion receipt appeared mid-import at ${receiptPath} — refusing to overwrite it (fail-closed)`);
|
|
3482
|
+
}
|
|
3483
|
+
const staged = join(ctl, IMPORT_RECEIPTS_DIR, `.stage-${bundleHash}-${randomUUID().slice(0, 8)}`);
|
|
3484
|
+
const fd = openSync(staged, "wx", 0o600);
|
|
3485
|
+
try {
|
|
3486
|
+
writeAllSync(fd, `${JSON.stringify({ v: 1, bundleHash, at: this.now(), report }, null, 2)}\n`);
|
|
3487
|
+
fsyncSync(fd);
|
|
3488
|
+
}
|
|
3489
|
+
finally {
|
|
3490
|
+
closeSync(fd);
|
|
3491
|
+
}
|
|
3492
|
+
try {
|
|
3493
|
+
linkSync(staged, receiptPath);
|
|
3494
|
+
rmSync(staged, { force: true });
|
|
3495
|
+
}
|
|
3496
|
+
catch (err) {
|
|
3497
|
+
try {
|
|
3498
|
+
rmSync(staged, { force: true });
|
|
3499
|
+
}
|
|
3500
|
+
catch {
|
|
3501
|
+
}
|
|
3502
|
+
if (err.code === "EEXIST") {
|
|
3503
|
+
throw new ControlPlaneCorruptError(`bundle import completion receipt appeared mid-import at ${receiptPath} — refusing to overwrite it (fail-closed)`);
|
|
3504
|
+
}
|
|
3505
|
+
throw err;
|
|
3506
|
+
}
|
|
3507
|
+
releaseImportLatch(ctl, txnId);
|
|
3508
|
+
return report;
|
|
3509
|
+
}
|
|
3510
|
+
catch (err) {
|
|
3511
|
+
this.ledger = undefined;
|
|
3512
|
+
throw err;
|
|
3513
|
+
}
|
|
3514
|
+
finally {
|
|
3515
|
+
lock.release();
|
|
3516
|
+
}
|
|
3517
|
+
}
|
|
2887
3518
|
async getConsolidationCursor(scope) {
|
|
2888
3519
|
const cursors = this.readCursors();
|
|
2889
3520
|
return cursors[scope];
|