@sema-agent/core 5.39.0 → 5.41.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 +150 -11
- package/dist/core/auto-mode-prompt.js +1 -1
- package/dist/core/checkpoint-store.d.ts +12 -0
- package/dist/core/governance-codes.js +2 -0
- package/dist/core/hooks.js +9 -1
- 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 +648 -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 +99 -1
- package/dist/core/memory-engine/layout.js +143 -7
- 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.js +14 -2
- package/dist/core/runner/prepare-config-doors.js +20 -0
- package/dist/core/runner/prepare-task.js +13 -0
- package/dist/core/runner/runtask.js +14 -1
- package/dist/core/runner/synthetic-tools.js +3 -1
- package/dist/core/runner/tool-disclosure.js +2 -1
- package/dist/core/types.d.ts +7 -0
- package/dist/core/write-protect.d.ts +0 -20
- package/dist/core/write-protect.js +4 -3
- package/dist/engine/harness/agent-harness.d.ts +17 -0
- package/dist/engine/harness/agent-harness.js +19 -1
- package/dist/engine/harness/types.d.ts +5 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/tools/fs/safety.d.ts +1 -1
- package/dist/tools/fs/safety.js +1 -1
- 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 +9 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { type TransferEvidence } from "./file-backend.js";
|
|
2
|
+
import type { MemoryEntry } from "./types.js";
|
|
3
|
+
/** One exported challenge event (unresolved generations only). Generations are DESTINATION-minted:
|
|
4
|
+
* the bundle deliberately carries no generation number — the importing store re-mints events under
|
|
5
|
+
* its own idempotent namespace and allocates generations there. */
|
|
6
|
+
export interface BundleChallengeRow {
|
|
7
|
+
eventId: string;
|
|
8
|
+
entryId: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
at: number;
|
|
11
|
+
challengedRev?: string;
|
|
12
|
+
}
|
|
13
|
+
/** One exported durable pollution marker (verbatim source record). */
|
|
14
|
+
export interface BundlePollutedSession {
|
|
15
|
+
sessionId: string;
|
|
16
|
+
at: number;
|
|
17
|
+
reason: string;
|
|
18
|
+
}
|
|
19
|
+
/** One exported committed lineage contribution (verbatim `lastRev`/`lastAt`). */
|
|
20
|
+
export interface BundleLineageRow {
|
|
21
|
+
entryId: string;
|
|
22
|
+
sessionId: string;
|
|
23
|
+
lastRev: string;
|
|
24
|
+
lastAt: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* design/178 v2 §4.3 + v2-c §6 — the governance-complete export bundle. The governance section is
|
|
28
|
+
* STRUCTURALLY REQUIRED (a bundle without it is a bad package — there is no "entries-only import"
|
|
29
|
+
* escape hatch: an optional hatch is a laundering channel, and the fixed section-hash key set makes
|
|
30
|
+
* a stripped section fail integrity outright). `doc` is human-readable disclosure text OUTSIDE the
|
|
31
|
+
* hashed sections (documentation, not governance data); unknown root keys are read-tolerated and
|
|
32
|
+
* never re-export-preserved (a re-export always mints a fresh bundle from store state).
|
|
33
|
+
*/
|
|
34
|
+
export interface MemoryExportBundle {
|
|
35
|
+
/** Envelope version — a reader meeting `bundle > 1` must refuse, never reinterpret. */
|
|
36
|
+
bundle: 1;
|
|
37
|
+
at: number;
|
|
38
|
+
scopes: string[];
|
|
39
|
+
/** The source store's durable identity (control-plane `store-identity.json`, minted at the first
|
|
40
|
+
* export) — the import side's `origin` stamp for custody rows that carry none. */
|
|
41
|
+
storeId: string;
|
|
42
|
+
/** Full-fidelity entries (provenance/trust/extra verbatim). A duplicate id is a bad package. */
|
|
43
|
+
entries: MemoryEntry[];
|
|
44
|
+
governance: {
|
|
45
|
+
challenges: BundleChallengeRow[];
|
|
46
|
+
pollutedSessions: BundlePollutedSession[];
|
|
47
|
+
lineage: BundleLineageRow[];
|
|
48
|
+
/** Evidence-chain rows sliced to the exported scopes (v2-c §2 table): open form — a consumer
|
|
49
|
+
* tolerates unknown keys; unknown CHANNELS never leave the exporter (they refuse the export). */
|
|
50
|
+
custody: TransferEvidence[];
|
|
51
|
+
};
|
|
52
|
+
/** Honest enumeration of what the bundle does NOT carry. The `unbound`/`pendingLatch` name lists
|
|
53
|
+
* are populated on a FULL-STORE export only (one trust domain); a subset export ships empty
|
|
54
|
+
* lists and the counts (an id list would leak another tenant's identifiers). The counts are
|
|
55
|
+
* always present and always equal the honest total, so a consumer reads ONE seat either way. */
|
|
56
|
+
residuals: {
|
|
57
|
+
quarantined: string[];
|
|
58
|
+
unbound: string[];
|
|
59
|
+
pendingLatch: string[];
|
|
60
|
+
contentUnavailable: Array<{
|
|
61
|
+
id: string;
|
|
62
|
+
scope: string;
|
|
63
|
+
slug: string;
|
|
64
|
+
rev: string;
|
|
65
|
+
reason: string;
|
|
66
|
+
}>;
|
|
67
|
+
quarantineOpaque: number;
|
|
68
|
+
/** Custody rows a SUBSET export could not slice (no scope to slice by — unbound-deletion
|
|
69
|
+
* history among them): the anti-resurrection guarantee for those ids does not travel. */
|
|
70
|
+
unsliceableCustody: number;
|
|
71
|
+
unboundCount: number;
|
|
72
|
+
pendingLatchCount: number;
|
|
73
|
+
};
|
|
74
|
+
/** Accident detection, not forgery-proofing (see module note). `sectionHashes` carries EXACTLY
|
|
75
|
+
* the four fixed keys (meta/entries/governance/residuals); `revs` and `entries` are mutually
|
|
76
|
+
* pointing (same id set, verbatim revs, `entryCount` ≡ both cardinalities). */
|
|
77
|
+
integrity: {
|
|
78
|
+
entryCount: number;
|
|
79
|
+
revs: Record<string, string>;
|
|
80
|
+
sectionHashes: Record<string, string>;
|
|
81
|
+
};
|
|
82
|
+
/** Human-readable disclosure sentences (free-string carriage, subset residuals). Outside the
|
|
83
|
+
* hashed sections by design — documentation, not governance data. */
|
|
84
|
+
doc: string[];
|
|
85
|
+
}
|
|
86
|
+
/** design/178 v2-c §7 — the import answer (the parent-design draft fields plus the v2-c report
|
|
87
|
+
* seats). Every negative disposition is REPORTED, never silent. */
|
|
88
|
+
export interface MemoryImportReport {
|
|
89
|
+
v: 1;
|
|
90
|
+
/** The bundle identity (sha256 over the canonical section-hash map) — the latch/remint salt. */
|
|
91
|
+
bundleHash: string;
|
|
92
|
+
sourceStoreId: string;
|
|
93
|
+
landed: Array<{
|
|
94
|
+
id: string;
|
|
95
|
+
scope: string;
|
|
96
|
+
slug: string;
|
|
97
|
+
}>;
|
|
98
|
+
/** id+rev+scope three-way idempotent skips (slug tolerates the deterministic `-n` suffix). */
|
|
99
|
+
alreadyPresent: string[];
|
|
100
|
+
/** The destination chain carries a delete row for the id and no live row survives — resurrection
|
|
101
|
+
* refused (no override; a legitimately re-created id has a live row and takes the ordinary
|
|
102
|
+
* CAS/conflict path instead). */
|
|
103
|
+
refusedErased: string[];
|
|
104
|
+
/** Fresh add of a `repo_file`-provenance entry with no `trust` marker — whitewash refused. */
|
|
105
|
+
refusedUntrusted: string[];
|
|
106
|
+
conflicts: Array<{
|
|
107
|
+
id: string;
|
|
108
|
+
reason: string;
|
|
109
|
+
}>;
|
|
110
|
+
/** Entry-attached governance (challenges/lineage) withheld because its entry neither landed nor
|
|
111
|
+
* matched already-present-at-the-same-rev — misattribution is worse than omission. */
|
|
112
|
+
governanceWithheld: string[];
|
|
113
|
+
/** Source chain carries a delete row while the destination holds a LIVE entry for the id: named
|
|
114
|
+
* for deployment adjudication, never deleted on the import's behalf. */
|
|
115
|
+
erasedAtSource: string[];
|
|
116
|
+
pollutionDivergence: Array<{
|
|
117
|
+
sessionId: string;
|
|
118
|
+
kept: "destination";
|
|
119
|
+
}>;
|
|
120
|
+
/** Governance rows pointing at ids outside entries ∪ the destination account (custody rows are
|
|
121
|
+
* exempt — an absent-id delete row is exactly the anti-resurrection payload). */
|
|
122
|
+
referentialOrphans: string[];
|
|
123
|
+
lineageDivergence: Array<{
|
|
124
|
+
entryId: string;
|
|
125
|
+
sessionId: string;
|
|
126
|
+
kept: "destination";
|
|
127
|
+
}>;
|
|
128
|
+
/** Custody rows withheld unappended: unknown channel, or a known-channel row failing the store's
|
|
129
|
+
* own validation — reported per row, never a whole-package refusal (a newer exporter must not
|
|
130
|
+
* brick an older importer) and never a verbatim append (an unvalidatable row would poison the
|
|
131
|
+
* fail-closed chain). `srcEv` is the row's source identity (its own `srcEv` when it carries one,
|
|
132
|
+
* else its `ev`). */
|
|
133
|
+
custodyWithheld: Array<{
|
|
134
|
+
srcEv: string;
|
|
135
|
+
channel: string;
|
|
136
|
+
}>;
|
|
137
|
+
/** Custody rows submitted to the destination chain under this bundle's namespace (idempotent
|
|
138
|
+
* re-imports answer the same number — the reconciliation handle). */
|
|
139
|
+
custodyAppended: number;
|
|
140
|
+
}
|
|
141
|
+
/** What the engine hands the File backend's `importBundleCommit` face after the pure validation
|
|
142
|
+
* pass: the bundle's governed content plus its computed identity. Everything here is inert plain
|
|
143
|
+
* data (the engine clones the caller's bundle through a JSON round-trip before validating). */
|
|
144
|
+
export interface MemoryBundleImportPlan {
|
|
145
|
+
bundleHash: string;
|
|
146
|
+
sourceStoreId: string;
|
|
147
|
+
entries: MemoryEntry[];
|
|
148
|
+
challenges: BundleChallengeRow[];
|
|
149
|
+
pollutedSessions: BundlePollutedSession[];
|
|
150
|
+
lineage: BundleLineageRow[];
|
|
151
|
+
/** Open custody rows (byte-equal duplicates already collapsed by validation). */
|
|
152
|
+
custody: Array<Record<string, unknown>>;
|
|
153
|
+
}
|
|
154
|
+
/** The four fixed integrity section keys (§6.3) — missing OR extra keys are a bad package. */
|
|
155
|
+
export declare const BUNDLE_SECTION_KEYS: readonly ["meta", "entries", "governance", "residuals"];
|
|
156
|
+
/** The canonical per-section hashes of a bundle's four sections (meta = the bundle/at/scopes/
|
|
157
|
+
* storeId scalars). Canonical JSON (recursively sorted keys) + LF, sha256 hex. */
|
|
158
|
+
export declare function computeBundleSectionHashes(bundle: Pick<MemoryExportBundle, "bundle" | "at" | "scopes" | "storeId" | "entries" | "governance" | "residuals">): Record<string, string>;
|
|
159
|
+
/**
|
|
160
|
+
* The bundle IDENTITY: sha256 over the canonical JSON of `integrity.sectionHashes` (full 64-hex —
|
|
161
|
+
* the import latch id, the challenge/custody remint salt, and the report echo all share it). The
|
|
162
|
+
* section hashes already seal the section contents, so their sorted map IS the package identity;
|
|
163
|
+
* a tampered package fails integrity before its hash ever matters.
|
|
164
|
+
*/
|
|
165
|
+
export declare function computeMemoryBundleHash(sectionHashes: Record<string, string>): string;
|
|
166
|
+
/**
|
|
167
|
+
* design/178 v2-c §1-2① — the PURE bundle validation (integrity/structure/self-consistency; zero
|
|
168
|
+
* store reads, zero side effects). Returns the FIRST violation, or undefined (valid). Every
|
|
169
|
+
* violation is a whole-package refusal at the caller — nothing lands, no latch is set.
|
|
170
|
+
*/
|
|
171
|
+
export declare function memoryBundleInvalid(raw: unknown, opts?: {
|
|
172
|
+
expectedScopes?: readonly string[];
|
|
173
|
+
}): string | undefined;
|
|
174
|
+
/** What the exporter feeds {@link assembleMemoryExportBundle} — the store-consistent snapshot plus
|
|
175
|
+
* the already-sliced governance pieces (pure data; the snapshot fence lives in the backend). */
|
|
176
|
+
export interface MemoryExportBundleInput {
|
|
177
|
+
at: number;
|
|
178
|
+
scopes: string[];
|
|
179
|
+
storeId: string;
|
|
180
|
+
entries: MemoryEntry[];
|
|
181
|
+
challenges: BundleChallengeRow[];
|
|
182
|
+
pollutedSessions: BundlePollutedSession[];
|
|
183
|
+
lineage: BundleLineageRow[];
|
|
184
|
+
custody: TransferEvidence[];
|
|
185
|
+
residuals: MemoryExportBundle["residuals"];
|
|
186
|
+
}
|
|
187
|
+
/** The C-1 disclosure sentence (recommended-arm ruling posture: free strings ride verbatim and the
|
|
188
|
+
* bundle SAYS so — coding them away would destroy the identity/attribution keys idempotency and
|
|
189
|
+
* the remint namespaces depend on). */
|
|
190
|
+
export declare 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.";
|
|
191
|
+
/** Assemble the bundle: deterministic ordering, section hashes, disclosure doc. Pure. */
|
|
192
|
+
export declare function assembleMemoryExportBundle(input: MemoryExportBundleInput): MemoryExportBundle;
|
|
@@ -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
|
+
}
|