@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
|
@@ -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;
|