@staix/agent-mem 0.20.3 → 0.22.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/.claude-plugin/plugin.json +1 -1
- package/README.md +30 -3
- package/package.json +2 -2
- package/src/cli.ts +36 -3
- package/src/commands-table.ts +3 -0
- package/src/hook-spool.ts +45 -3
- package/src/index.ts +31 -2
- package/src/memory/bridge.ts +66 -18
- package/src/memory/candidates.ts +71 -9
- package/src/memory/commands.ts +3 -2
- package/src/memory/generation-index.ts +95 -18
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/generation.ts +33 -15
- package/src/memory/platform.ts +38 -3
- package/src/memory/policy.ts +28 -15
- package/src/memory/prune-publisher-lineage.ts +50 -12
- package/src/memory/public-prune-activation.ts +4 -4
- package/src/memory/public-prune-advance.ts +11 -5
- package/src/memory/public-prune-inputs.ts +17 -16
- package/src/memory/shard-sync-applications.ts +153 -0
- package/src/memory/shard-sync-format.ts +15 -0
- package/src/memory/shard-sync-graph.ts +6 -4
- package/src/memory/shard-sync-history.ts +238 -0
- package/src/memory/shard-sync.ts +66 -151
- package/src/memory/shard.ts +32 -6
- package/src/memory/store.ts +14 -1
- package/src/memory/sync-binding.ts +63 -0
- package/src/memory/sync-cadence.ts +2 -1
- package/src/memory/sync-placement-abandon.ts +142 -0
- package/src/memory/sync-placement-activation-locks.ts +28 -0
- package/src/memory/sync-placement-activation-path.ts +2 -0
- package/src/memory/sync-placement-activation-proof.ts +68 -0
- package/src/memory/sync-placement-activation-storage.ts +167 -0
- package/src/memory/sync-placement-activation.ts +185 -0
- package/src/memory/sync-placement-active.ts +75 -0
- package/src/memory/sync-placement-cache.ts +32 -0
- package/src/memory/sync-placement-capture.ts +373 -0
- package/src/memory/sync-placement-dependencies.ts +255 -0
- package/src/memory/sync-placement-effective-writers.ts +84 -0
- package/src/memory/sync-placement-format.ts +182 -0
- package/src/memory/sync-placement-git.ts +95 -0
- package/src/memory/sync-placement-guard.ts +104 -0
- package/src/memory/sync-placement-inspection-evidence.ts +257 -0
- package/src/memory/sync-placement-inspection-pending.ts +66 -0
- package/src/memory/sync-placement-inspection.ts +240 -0
- package/src/memory/sync-placement-journal-format.ts +114 -0
- package/src/memory/sync-placement-journal-storage.ts +557 -0
- package/src/memory/sync-placement-journal.ts +160 -0
- package/src/memory/sync-placement-materialize.ts +149 -0
- package/src/memory/sync-placement-plan.ts +171 -0
- package/src/memory/sync-placement-prerequisites.ts +50 -0
- package/src/memory/sync-placement-projection.ts +79 -0
- package/src/memory/sync-placement-publication-plan.ts +189 -0
- package/src/memory/sync-placement-publication-storage.ts +87 -0
- package/src/memory/sync-placement-transport.ts +500 -0
- package/src/memory/sync-placement-writer-history.ts +278 -0
- package/src/memory/sync-placement-writer-renewal.ts +179 -0
- package/src/memory/sync-placement-writer-revision-format.ts +122 -0
- package/src/memory/sync-placement-writer-roots.ts +167 -0
- package/src/memory/sync-placement-writers.ts +135 -0
- package/src/memory/sync-policy-inspection.ts +126 -0
- package/src/memory/sync-screen.ts +128 -33
- package/src/memory/sync.ts +19 -64
- package/src/setup.ts +101 -14
- package/src/sync-placement-writer-context.ts +136 -0
- package/src/ui/inbox-cache.ts +219 -0
- package/src/ui/inbox-worker-version.ts +14 -0
- package/src/ui/inbox-worker.ts +32 -0
- package/src/ui/inbox.ts +281 -0
- package/src/ui/panel.js +7 -3
- package/src/ui/plugin.ts +26 -53
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import type { PlacementPublicationSource } from "./sync-placement-publication-plan.ts";
|
|
2
|
+
import { validatePlacementKey, type PlacementRealm } from "./sync-placement-journal-format.ts";
|
|
3
|
+
import { types } from "node:util";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readSync, type BigIntStats } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { canonicalJSON, generationDescriptor, hashJSON, validateVaultLinkRow, type BoundProfile, type VaultLinkRow } from "./generation.ts";
|
|
8
|
+
import { validatePlacementBaseV2, validatePlacementProfile, validatePlacementShards, PLACEMENT_PROFILE_SCHEMA } from "./sync-placement-format.ts";
|
|
9
|
+
import { planSyncPlacementActivation, type SyncPlacementActivationContext, type SyncPlacementWriterAttestation } from "./sync-placement-plan.ts";
|
|
10
|
+
import { readPublicPruneViewForActivation, PUBLIC_PRUNE_INTENT } from "./public-prune-view.ts";
|
|
11
|
+
import { realPath, DENY_NAMES, DENY_SEGMENTS } from "./platform.ts";
|
|
12
|
+
import { loadAmemConfig } from "./config.ts";
|
|
13
|
+
import { memoryPolicySnapshot } from "./policy.ts";
|
|
14
|
+
import { SegmentReader, SegmentReadBudgetError, checkedEntry, completeLines } from "./bridge.ts";
|
|
15
|
+
import { readShardLegacySnapshot } from "./shard-bridge.ts";
|
|
16
|
+
import { coalesceGenerationSource } from "./generation-index.ts";
|
|
17
|
+
import { validatePruneRow, type PruneSourceRow, type PruneRow } from "./prune.ts";
|
|
18
|
+
import { shardKeyHash, SHARD_SCHEMA } from "./shard.ts";
|
|
19
|
+
import { SyncPlacementJournalError, placementKeyId, type PlacementKey } from "./sync-placement-journal-format.ts";
|
|
20
|
+
import type { Entry } from "./store.ts";
|
|
21
|
+
import { assertPlacementRuntime } from "./sync-placement-guard.ts";
|
|
22
|
+
import { activationSourceMarkers, assertPlacementActivationOwner, type PlacementActivationOwner, type PlacementActivationCaptureProof } from "./sync-placement-activation-proof.ts";
|
|
23
|
+
import { readEffectiveSyncPlacementWriters, writerBoundPlacementContextHash, writerInventoryEvidence, type EffectiveSyncPlacementWriters } from "./sync-placement-effective-writers.ts";
|
|
24
|
+
import { SharedWriterRevisionError } from "./sync-placement-writer-revision-format.ts";
|
|
25
|
+
|
|
26
|
+
/** Host authentication/compatibility facts are re-read at every effect boundary, never inferred from a session marker. */
|
|
27
|
+
export interface PlacementCapturePort {
|
|
28
|
+
attest(): { writers: readonly SyncPlacementWriterAttestation[]; legacySyncQuiesced: true };
|
|
29
|
+
/** Required when a caller adds a policy callback; must change whenever its policy changes. */
|
|
30
|
+
policyVersion?(): string;
|
|
31
|
+
}
|
|
32
|
+
/** Internal actual-transport proof, admitted only after the native pending plan and fetched original were validated. */
|
|
33
|
+
export interface PlacementPendingSource { realm: PlacementRealm; pathHash: string; offset: number; prefixHash: string; line: string }
|
|
34
|
+
export interface PlacementSourceRow { key: PlacementKey; row: Entry; sourcePlacement: "local" | "synced"; intrinsicLocal: boolean }
|
|
35
|
+
export interface PlacementSpecialSourceRow { key: PlacementKey; row: PruneRow | VaultLinkRow; sourcePlacement: "local" | "synced"; intrinsicLocal: boolean }
|
|
36
|
+
export interface PlacementCapture {
|
|
37
|
+
home: string; context: SyncPlacementActivationContext; activeHead?: string;
|
|
38
|
+
markerHash: string; fingerprint: string; directories: string[]; prefixes: PlacementPublicationSource[]; rows: Map<string, PlacementSourceRow>;
|
|
39
|
+
/** Diagnostic inventory only; these rows never enter the ordinary transport/capture row map implicitly. */
|
|
40
|
+
specialRows: Map<string, PlacementSpecialSourceRow>;
|
|
41
|
+
sourceRowsByRealm: Map<string, readonly PruneSourceRow[]>;
|
|
42
|
+
writerInventory?:EffectiveSyncPlacementWriters;
|
|
43
|
+
assertCurrent(): void;
|
|
44
|
+
/** Under live writer fences, inode/ctime/size checks protect the already-hashed prefixes across callbacks without reparsing rows. */
|
|
45
|
+
assertUnchanged(): void;
|
|
46
|
+
}
|
|
47
|
+
export function placementCaptureContextHash(capture:Pick<PlacementCapture,"context"|"writerInventory">):string{return writerBoundPlacementContextHash(capture.context,capture.writerInventory);}
|
|
48
|
+
export interface PlacementActivationMarkerSnapshot {
|
|
49
|
+
profileText: string; shardsText: string; profileStamp: [string,string,string,string,string]; shardsStamp: [string,string,string,string,string];
|
|
50
|
+
}
|
|
51
|
+
/** Source facts only: no writer attestation, quiescence claim or effect authority. */
|
|
52
|
+
export interface PlacementSourcePreview {
|
|
53
|
+
home: string; base: Omit<SyncPlacementActivationContext, "writers" | "legacySyncQuiesced">;
|
|
54
|
+
activeHead?: string; markerHash: string; markers: PlacementActivationMarkerSnapshot; directories: string[]; prefixes: PlacementPublicationSource[];
|
|
55
|
+
rows: Map<string, PlacementSourceRow>; specialRows: Map<string, PlacementSpecialSourceRow>;
|
|
56
|
+
sourceRowsByRealm: Map<string, readonly PruneSourceRow[]>; assertSourcesUnchanged(): void;
|
|
57
|
+
}
|
|
58
|
+
function fail(code: "changed" | "invalid" | "bound" = "changed"): never { throw new SyncPlacementJournalError(code); }
|
|
59
|
+
const digest = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex");
|
|
60
|
+
const stamp = (s: BigIntStats) => [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs].map(String);
|
|
61
|
+
const same = (a: BigIntStats, b: BigIntStats) => canonicalJSON(stamp(a)) === canonicalJSON(stamp(b));
|
|
62
|
+
function ownedDirectory(dir: string): BigIntStats {
|
|
63
|
+
const s = lstatSync(dir, { bigint: true });
|
|
64
|
+
if (!s.isDirectory() || s.isSymbolicLink() || s.uid !== BigInt(process.getuid!()) || (s.mode & 0o022n) !== 0n) fail();
|
|
65
|
+
return s;
|
|
66
|
+
}
|
|
67
|
+
/** ponytail: capture is capped at 256 metadata objects and 8 MiB/20000 source rows across all realms.
|
|
68
|
+
* Larger homes need a reviewed chunked activation proof; no input is silently omitted at this ceiling. */
|
|
69
|
+
function sourcePreview(homeInput: string, selected?: readonly PlacementRealm[], pending: readonly PlacementPendingSource[] = [], policyVersion?: string, ownerProof?: PlacementActivationCaptureProof, sourceOwner?:PlacementActivationOwner): PlacementSourcePreview {
|
|
70
|
+
let selection: PlacementRealm[] | undefined;
|
|
71
|
+
if (selected !== undefined) {
|
|
72
|
+
if (!Array.isArray(selected) || types.isProxy(selected) || selected.length > 256) fail("bound");
|
|
73
|
+
const copied = memoryPolicySnapshot(selected); if (!copied) fail();
|
|
74
|
+
selection = copied.map(realm => validatePlacementKey({ realm, id: "0".repeat(32), rowHash: "0".repeat(64) }).realm);
|
|
75
|
+
if (!selection.length || new Set(selection.map(realm => canonicalJSON(realm))).size !== selection.length) fail();
|
|
76
|
+
}
|
|
77
|
+
const home = realPath(homeInput), root = ownedDirectory(home);
|
|
78
|
+
if(sourceOwner)assertPlacementActivationOwner(sourceOwner,home);
|
|
79
|
+
let metadataObjects = 0, metadataBytes = 0;
|
|
80
|
+
const facts: unknown[] = [], sourceChecks: (() => void)[] = [];
|
|
81
|
+
const pendingByPath = new Map(pending.map(item => [canonicalJSON(item.realm)+":"+item.pathHash,item]));
|
|
82
|
+
if (pendingByPath.size !== pending.length) fail();
|
|
83
|
+
const directoryFact = (dir: string) => {
|
|
84
|
+
const stat = ownedDirectory(dir); facts.push([dir, "directory", String(stat.dev), String(stat.ino), String(stat.uid)]);
|
|
85
|
+
sourceChecks.push(() => { const now=ownedDirectory(dir); if(now.dev!==stat.dev||now.ino!==stat.ino) fail(); });
|
|
86
|
+
};
|
|
87
|
+
const bytes = (file: string, optional = false): Buffer | undefined => {
|
|
88
|
+
const before = lstatSync(file, { bigint: true, throwIfNoEntry: false });
|
|
89
|
+
if (!before) { if (!optional) fail(); facts.push([file, null]); sourceChecks.push(() => { if(lstatSync(file,{throwIfNoEntry:false})) fail(); }); return undefined; }
|
|
90
|
+
if (++metadataObjects > 256 || before.size > 262144n || metadataBytes + Number(before.size) > 8 * 1024 * 1024) fail("bound");
|
|
91
|
+
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n || before.uid !== root.uid || (before.mode & 0o022n) !== 0n) fail();
|
|
92
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
93
|
+
try {
|
|
94
|
+
const opened = fstatSync(fd, { bigint: true }); if (!same(before, opened)) fail();
|
|
95
|
+
const out = Buffer.alloc(Number(opened.size)); let at = 0;
|
|
96
|
+
while (at < out.length) { const count = readSync(fd, out, at, out.length - at, at); if (!count) fail(); at += count; }
|
|
97
|
+
const after = lstatSync(file, { bigint: true }); if (!same(opened, fstatSync(fd, { bigint: true })) || !same(opened, after)) fail();
|
|
98
|
+
metadataBytes += out.length; facts.push([file, stamp(opened), digest(out)]);
|
|
99
|
+
sourceChecks.push(() => { const now=lstatSync(file,{bigint:true}); if(!now.isFile()||now.isSymbolicLink()||now.nlink!==1n||!same(opened,now)) fail(); }); return out;
|
|
100
|
+
} finally { closeSync(fd); }
|
|
101
|
+
};
|
|
102
|
+
const json = (file: string, optional = false): any => {
|
|
103
|
+
const input = bytes(file, optional); if (!input) return undefined;
|
|
104
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(input));
|
|
105
|
+
};
|
|
106
|
+
const markerStart = facts.length;
|
|
107
|
+
const profileBytes = bytes(join(home, "profile.json"))!, shardsBytes = bytes(join(home, "shards.json"))!;
|
|
108
|
+
const actualMarkers: PlacementActivationMarkerSnapshot = { profileText: profileBytes.toString("utf8"), shardsText: shardsBytes.toString("utf8"),
|
|
109
|
+
profileStamp: [...(facts[markerStart] as [string,PlacementActivationMarkerSnapshot["profileStamp"],string])[1]], shardsStamp: [...(facts[markerStart+1] as [string,PlacementActivationMarkerSnapshot["shardsStamp"],string])[1]] };
|
|
110
|
+
const markers = ownerProof ? activationSourceMarkers(ownerProof,home,actualMarkers) : actualMarkers;
|
|
111
|
+
if (ownerProof) {
|
|
112
|
+
facts[markerStart] = [join(home,"profile.json"),markers.profileStamp,digest(Buffer.from(markers.profileText))];
|
|
113
|
+
facts[markerStart+1] = [join(home,"shards.json"),markers.shardsStamp,digest(Buffer.from(markers.shardsText))];
|
|
114
|
+
}
|
|
115
|
+
const decodeMarker = (text: string): unknown => JSON.parse(new TextDecoder("utf-8",{fatal:true}).decode(Buffer.from(text)));
|
|
116
|
+
const rawProfile = decodeMarker(markers.profileText) as { schema?: string }, rawShards = decodeMarker(markers.shardsText);
|
|
117
|
+
let activeHead: string | undefined;
|
|
118
|
+
let base: ReturnType<typeof validatePlacementBaseV2>;
|
|
119
|
+
if (rawProfile?.schema === PLACEMENT_PROFILE_SCHEMA) {
|
|
120
|
+
const profile = validatePlacementProfile(rawProfile), shards = validatePlacementShards(rawShards, profile);
|
|
121
|
+
activeHead = profile.placementHead;
|
|
122
|
+
const { publicationFormat: _pf, placementHead: _ph, ...p } = profile;
|
|
123
|
+
const profile2 = { ...p, schema: "agent-mem.profile/v2" };
|
|
124
|
+
const { publicationFormat: _sf, placementHead: _sh, ...s } = shards;
|
|
125
|
+
base = validatePlacementBaseV2(profile2, { ...s, schema: "agent-mem.shards/v2", selectorRevision: hashJSON(profile2) });
|
|
126
|
+
} else base = validatePlacementBaseV2(rawProfile, rawShards);
|
|
127
|
+
const markerHash = hashJSON({ profile: rawProfile, shards: rawShards });
|
|
128
|
+
bytes(join(home, "public-prune-input-sets", `${base.profile.pruneInputSetHash}.json`));
|
|
129
|
+
readPublicPruneViewForActivation(home, base.profile.pruneInputSetHash, base.profile.generation);
|
|
130
|
+
const aliases = bytes(join(home, "aliases.jsonl"), true);
|
|
131
|
+
bytes(join(home, "amem.config.json"), true);
|
|
132
|
+
const machine = json(join(home, "machine.json"));
|
|
133
|
+
if (machine?.id !== base.shards.machine.id) fail();
|
|
134
|
+
// An in-flight migration/prune is never accepted as a stable placement source.
|
|
135
|
+
for (const name of ["profile.lock", PUBLIC_PRUNE_INTENT]) {
|
|
136
|
+
if (name === "profile.lock" && ownerProof) continue; // The module-private proof validated this exact live owner and sealed intent.
|
|
137
|
+
if(name==="profile.lock"&&sourceOwner){assertPlacementActivationOwner(sourceOwner,home);continue;}
|
|
138
|
+
if (lstatSync(join(home,name),{throwIfNoEntry:false})) fail();
|
|
139
|
+
}
|
|
140
|
+
const reader = new SegmentReader(16 * 1024 * 1024, { maxSourceBytes: 8 * 1024 * 1024, maxRows: 20000 });
|
|
141
|
+
const rows = new Map<string, PlacementSourceRow>(), specialRows = new Map<string, PlacementSpecialSourceRow>(), sourceRowsByRealm = new Map<string, readonly PruneSourceRow[]>();
|
|
142
|
+
const directories = [home], prefixes: PlacementPublicationSource[] = [];
|
|
143
|
+
const realms: SyncPlacementActivationContext["realms"][number][] = [];
|
|
144
|
+
const listing = (dir: string, optional = false): string[] => {
|
|
145
|
+
const s = lstatSync(dir, { throwIfNoEntry: false });
|
|
146
|
+
if (!s) { if (!optional) fail(); facts.push([dir, null]); sourceChecks.push(() => {if(lstatSync(dir,{throwIfNoEntry:false}))fail();}); return []; }
|
|
147
|
+
directoryFact(dir); const listStamp = lstatSync(dir,{bigint:true});
|
|
148
|
+
sourceChecks.push(() => { if(!same(listStamp,lstatSync(dir,{bigint:true}))) fail(); });
|
|
149
|
+
const iterator = opendirSync(dir), names: string[] = [];
|
|
150
|
+
try { for (;;) { const item = iterator.readSync(); if (!item) break; if (names.length >= 256) fail("bound"); names.push(item.name); } }
|
|
151
|
+
finally { iterator.closeSync(); }
|
|
152
|
+
names.sort(); facts.push([dir, names]); return names;
|
|
153
|
+
};
|
|
154
|
+
const realm = (dir: string, identity: PlacementKey["realm"], project?: string) => {
|
|
155
|
+
directories.push(dir);
|
|
156
|
+
const beforeFacts = facts.length; directoryFact(dir);
|
|
157
|
+
if (project !== undefined) {
|
|
158
|
+
const descriptor = json(join(dir, "project.json"));
|
|
159
|
+
if (descriptor?.schema !== SHARD_SCHEMA || descriptor.key !== project || typeof descriptor.local !== "boolean") fail();
|
|
160
|
+
}
|
|
161
|
+
const descriptor = json(join(dir, "generation.json")), sources = json(join(dir, "migration-sources.json")), seal = json(join(dir, "generation-seal.json"));
|
|
162
|
+
if (canonicalJSON(descriptor) !== canonicalJSON(generationDescriptor(base.profile.generation)) || !Array.isArray(sources?.sources)
|
|
163
|
+
|| sources.schema !== "agent-mem.migration-sources/v1" || seal?.schema !== "agent-mem.generation-seal/v1"
|
|
164
|
+
|| seal.generation !== base.profile.generation || seal.descriptorHash !== hashJSON(descriptor)
|
|
165
|
+
|| seal.sourceDigest !== hashJSON({ sources: sources.sources, legacyCommit: sources.legacyCommit })
|
|
166
|
+
|| json(join(dir, "machine.json"))?.id !== machine.id) fail();
|
|
167
|
+
const hasMain = !!lstatSync(join(dir, "legacy-main"), { throwIfNoEntry: false });
|
|
168
|
+
const profile: BoundProfile = { kind: "generation", home, dir, generation: base.profile.generation, selectorRevision: hashJSON(base.profile),
|
|
169
|
+
sharded: true, legacyHome: join(dir, "legacy"), ...(hasMain ? { legacyMainHome: join(dir, "legacy-main") } : {}) };
|
|
170
|
+
for (const root of [profile.legacyHome!, ...(hasMain ? [profile.legacyMainHome!] : [])]) {
|
|
171
|
+
directoryFact(root); bytes(join(root, "machine.json"), true);
|
|
172
|
+
for(const folder of ["log","local"]) {
|
|
173
|
+
const file=join(root,folder), s=lstatSync(file,{bigint:true,throwIfNoEntry:false});
|
|
174
|
+
sourceChecks.push(() => { const now=lstatSync(file,{bigint:true,throwIfNoEntry:false}); if(s ? !now||!same(s,now) : now!==undefined) fail(); });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const segments = readShardLegacySnapshot(profile, { reader });
|
|
178
|
+
for (const folder of ["log", "local"]) {
|
|
179
|
+
directoryFact(join(dir, folder));
|
|
180
|
+
const folderPath=join(dir,folder), folderStamp=lstatSync(folderPath,{bigint:true});
|
|
181
|
+
sourceChecks.push(() => { if(!same(folderStamp,lstatSync(folderPath,{bigint:true}))) fail(); });
|
|
182
|
+
if(folder === "local") bytes(join(dir,folder,`publisher-boundary-${machine.id}.json`),true);
|
|
183
|
+
for (const name of reader.listFiles(join(dir, folder))) {
|
|
184
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
185
|
+
if ( !/^[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl$/.test(name)) fail();
|
|
186
|
+
segments.push({ ...reader.read(join(dir, folder, name), undefined, true), source: "generation", path: `${folder}/${name}`, local: folder === "local" });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const presence = new Map<string, PruneSourceRow>();
|
|
190
|
+
const special = new Map<string, PlacementSpecialSourceRow>();
|
|
191
|
+
const retainSpecial = (row: PruneRow | VaultLinkRow, local: boolean) => {
|
|
192
|
+
const key = { realm: identity, id: row.id, rowHash: hashJSON(row) }, prior = special.get(row.id);
|
|
193
|
+
if (prior && (prior.key.rowHash !== key.rowHash || prior.sourcePlacement !== (local ? "local" : "synced"))) fail();
|
|
194
|
+
special.set(row.id, { key, row, sourcePlacement: local ? "local" : "synced", intrinsicLocal: row.op === "vault_link" });
|
|
195
|
+
};
|
|
196
|
+
for (const segment of segments) {
|
|
197
|
+
const file = join(segment.source === "generation" ? dir : segment.source === "legacy-main" ? profile.legacyMainHome! : profile.legacyHome!, segment.path);
|
|
198
|
+
const fileStat = lstatSync(file);
|
|
199
|
+
if (!fileStat.isFile() || fileStat.isSymbolicLink() || fileStat.nlink !== 1 || fileStat.uid !== process.getuid?.() || (fileStat.mode & 0o022) !== 0) fail();
|
|
200
|
+
let sourceHash = segment.hash, sourceLength = segment.offset;
|
|
201
|
+
if (segment.tailBytes) {
|
|
202
|
+
const proof = pendingByPath.get(canonicalJSON(identity)+":"+digest(segment.path));
|
|
203
|
+
if (segment.source !== "generation" || !proof || proof.offset !== segment.offset || proof.prefixHash !== segment.hash
|
|
204
|
+
|| segment.tailBytes >= Buffer.byteLength(proof.line) || segment.tailBytes > 16384) fail();
|
|
205
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
206
|
+
try {
|
|
207
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
208
|
+
if (canonicalJSON(stamp(opened)) !== segment.stamp) fail();
|
|
209
|
+
const tail = Buffer.alloc(segment.tailBytes); let at = 0;
|
|
210
|
+
while (at < tail.length) { const n = readSync(fd, tail, at, tail.length-at, segment.offset+at); if (!n) fail(); at+=n; }
|
|
211
|
+
if (!tail.equals(Buffer.from(proof.line).subarray(0,tail.length)) || !same(opened,fstatSync(fd,{bigint:true})) || !same(opened,lstatSync(file,{bigint:true}))) fail();
|
|
212
|
+
sourceHash = createHash("sha256").update(segment.bytes).update(tail).digest("hex"); sourceLength += tail.length;
|
|
213
|
+
} finally { closeSync(fd); }
|
|
214
|
+
}
|
|
215
|
+
facts.push([dir, segment.source, segment.path, sourceLength, sourceHash, segment.stamp]);
|
|
216
|
+
const fileStamp=lstatSync(file,{bigint:true});
|
|
217
|
+
if(canonicalJSON(stamp(fileStamp))!==segment.stamp) fail();
|
|
218
|
+
sourceChecks.push(() => { const now=lstatSync(file,{bigint:true}); if(now.isSymbolicLink()||!now.isFile()||now.nlink!==1n||!same(fileStamp,now)) fail(); });
|
|
219
|
+
if (!segment.stamp || !["generation", "legacy-local", "legacy-main"].includes(segment.source)) fail();
|
|
220
|
+
const physical: unknown = JSON.parse(segment.stamp);
|
|
221
|
+
if (!Array.isArray(physical) || physical.length !== 5 || !physical.every(value => typeof value === "string" && /^[0-9]+$/.test(value))) fail();
|
|
222
|
+
prefixes.push({ realm: identity, source: segment.source as PlacementPublicationSource["source"], pathHash: digest(segment.path),
|
|
223
|
+
dev: physical[0] as string, ino: physical[1] as string, byteLength: sourceLength, hash: sourceHash,
|
|
224
|
+
mtimeNs: physical[3] as string, ctimeNs: physical[4] as string });
|
|
225
|
+
for (const line of completeLines(segment.bytes)) {
|
|
226
|
+
if (!line.length) continue;
|
|
227
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(line));
|
|
228
|
+
if (segment.source === "generation" && parsed?.op === "prune") {
|
|
229
|
+
const row = validatePruneRow(parsed, profile.generation, segment.local ? "local" : "synced");
|
|
230
|
+
if (segment.path !== `${segment.local ? "local" : "log"}/${row.machine}.jsonl`) fail();
|
|
231
|
+
retainSpecial(row, segment.local);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (segment.source === "generation" && parsed?.op === "vault_link") {
|
|
235
|
+
const row = validateVaultLinkRow(parsed, profile.generation, segment.local ? "local" : "synced");
|
|
236
|
+
if (segment.path !== `local/${row.machine}.jsonl`) fail();
|
|
237
|
+
retainSpecial(row, true);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const entry = checkedEntry(parsed);
|
|
241
|
+
presence.set(entry.id, coalesceGenerationSource(presence.get(entry.id), entry, segment));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const source of special.values()) {
|
|
245
|
+
if (presence.has(source.key.id)) fail();
|
|
246
|
+
specialRows.set(placementKeyId(source.key), source);
|
|
247
|
+
}
|
|
248
|
+
sourceRowsByRealm.set(canonicalJSON(identity), [...presence.values()]);
|
|
249
|
+
for (const source of presence.values()) {
|
|
250
|
+
const key = { realm: identity, id: source.row.id, rowHash: hashJSON(source.row) };
|
|
251
|
+
rows.set(placementKeyId(key), { key, row: source.row, sourcePlacement: source.placement,
|
|
252
|
+
intrinsicLocal: source.row.op === "settle" || source.row.op === "note" && source.row.tier === "short" });
|
|
253
|
+
}
|
|
254
|
+
const bindingHash = hashJSON(facts.slice(beforeFacts));
|
|
255
|
+
realms.push({ ...identity, bindingHash });
|
|
256
|
+
};
|
|
257
|
+
if (!selection || selection.some(item => item.scope === "user")) realm(join(home, "user"), { scope: "user" });
|
|
258
|
+
for (const name of listing(join(home, "projects"), true)) {
|
|
259
|
+
if (!/^[a-f0-9]{16}$/.test(name)) fail();
|
|
260
|
+
if (selection && !selection.some(item => item.scope === "project" && item.keyHash.slice(0,16) === name)) continue;
|
|
261
|
+
const dir = join(home, "projects", name), project = json(join(dir, "project.json"));
|
|
262
|
+
if (typeof project?.key !== "string" || shardKeyHash(project.key) !== name) fail();
|
|
263
|
+
// Capture scaffolds are not curated realms, but their identity still binds this inventory.
|
|
264
|
+
if (!lstatSync(join(dir, "generation.json"), { throwIfNoEntry: false })) continue;
|
|
265
|
+
const keyHash = digest(project.key);
|
|
266
|
+
if (selection && !selection.some(item => item.scope === "project" && item.keyHash === keyHash)) fail();
|
|
267
|
+
realm(dir, { scope: "project", keyHash }, project.key);
|
|
268
|
+
}
|
|
269
|
+
if (selection && realms.length !== selection.length) fail();
|
|
270
|
+
const defaults = memoryPolicySnapshot({ names: DENY_NAMES, segments: DENY_SEGMENTS }); if (!defaults) fail();
|
|
271
|
+
const configuration = loadAmemConfig(home).config;
|
|
272
|
+
const contextBase: PlacementSourcePreview["base"] = { ...base,
|
|
273
|
+
homeHash: hashJSON([home, String(root.dev), String(root.ino), String(root.uid)]), sourceHash: hashJSON(facts),
|
|
274
|
+
aliasesHash: digest(aliases ?? Buffer.alloc(0)), policyHash: hashJSON({ config: configuration, defaults, policyVersion }), realms };
|
|
275
|
+
// ponytail: passive checks rely on native inode/size/ctime identity for the hashed prefixes.
|
|
276
|
+
// Immutable descriptor-bound source blocks are the upgrade path for hostile same-user raw writers.
|
|
277
|
+
const assertSourcesUnchanged = () => {
|
|
278
|
+
try {
|
|
279
|
+
const currentDefaults = memoryPolicySnapshot({ names: DENY_NAMES, segments: DENY_SEGMENTS });
|
|
280
|
+
if (!currentDefaults || hashJSON({ config: configuration, defaults: currentDefaults, policyVersion }) !== contextBase.policyHash) fail();
|
|
281
|
+
const now = ownedDirectory(home); if (now.dev !== root.dev || now.ino !== root.ino) fail();
|
|
282
|
+
for (const check of sourceChecks) check();
|
|
283
|
+
if (ownerProof) activationSourceMarkers(ownerProof,home,actualMarkers);
|
|
284
|
+
if(sourceOwner)assertPlacementActivationOwner(sourceOwner,home);
|
|
285
|
+
} catch { fail(); }
|
|
286
|
+
};
|
|
287
|
+
return { home, base: contextBase, assertSourcesUnchanged, ...(activeHead ? { activeHead } : {}), markerHash, markers,
|
|
288
|
+
directories: directories.sort(), prefixes, rows, specialRows, sourceRowsByRealm };
|
|
289
|
+
}
|
|
290
|
+
/** Read-only preview shares the source collector, without fabricating a quiescence attestation. */
|
|
291
|
+
export function previewSyncPlacementSources(home: string): PlacementSourcePreview {
|
|
292
|
+
return previewSources(home);
|
|
293
|
+
}
|
|
294
|
+
/** Internal renewal inventory under the exact live home/profile/mutation owner; no marker virtualization. */
|
|
295
|
+
export function previewOwnedSyncPlacementSources(home:string,owner:PlacementActivationOwner):PlacementSourcePreview {
|
|
296
|
+
assertPlacementActivationOwner(owner,home);return previewSources(home,owner);
|
|
297
|
+
}
|
|
298
|
+
function previewSources(home:string,owner?:PlacementActivationOwner):PlacementSourcePreview {
|
|
299
|
+
try { assertPlacementRuntime(home); const preview = sourcePreview(home,undefined,[],undefined,undefined,owner); preview.assertSourcesUnchanged(); return preview; }
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (error && typeof error === "object" && !types.isProxy(error)) {
|
|
302
|
+
if (error instanceof SyncPlacementJournalError) throw error;
|
|
303
|
+
if (error instanceof SegmentReadBudgetError) return fail("bound");
|
|
304
|
+
}
|
|
305
|
+
return fail();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function capture(home: string, port: PlacementCapturePort, selected?: readonly PlacementRealm[], pending: readonly PlacementPendingSource[] = [], ownerProof?: PlacementActivationCaptureProof): Omit<PlacementCapture, "assertCurrent"> {
|
|
309
|
+
// All host callouts precede filesystem capture, including the final passive proof.
|
|
310
|
+
const attestation = memoryPolicySnapshot(port.attest()), policyVersion = port.policyVersion?.();
|
|
311
|
+
if (!attestation || port.policyVersion && typeof policyVersion !== "string") fail();
|
|
312
|
+
const source = sourcePreview(home, selected, pending, policyVersion, ownerProof);
|
|
313
|
+
const context: SyncPlacementActivationContext = { ...source.base, writers: attestation.writers, legacySyncQuiesced: attestation.legacySyncQuiesced };
|
|
314
|
+
const validated = planSyncPlacementActivation(context, "0".repeat(64));
|
|
315
|
+
context.writers = validated.writers; context.realms = validated.realms;
|
|
316
|
+
const writerInventory=source.activeHead&&writerInventoryEvidence(source.home,source.activeHead)?readEffectiveSyncPlacementWriters(source.home):undefined;
|
|
317
|
+
if(writerInventory&&canonicalJSON(context.writers)!==canonicalJSON(writerInventory.writers))fail();
|
|
318
|
+
const assertUnchanged = () => {
|
|
319
|
+
try {
|
|
320
|
+
const currentAttestation = memoryPolicySnapshot(port.attest()), currentVersion = port.policyVersion?.();
|
|
321
|
+
if (!currentAttestation) fail();
|
|
322
|
+
const checked = planSyncPlacementActivation({ ...context, writers: currentAttestation.writers, legacySyncQuiesced: currentAttestation.legacySyncQuiesced }, "0".repeat(64));
|
|
323
|
+
if (canonicalJSON(checked.writers) !== canonicalJSON(context.writers) || currentVersion !== policyVersion) fail();
|
|
324
|
+
writerInventory?.assertCurrent();
|
|
325
|
+
if(source.activeHead&&!writerInventory&&writerInventoryEvidence(source.home,source.activeHead))fail();
|
|
326
|
+
source.assertSourcesUnchanged();
|
|
327
|
+
} catch { fail(); }
|
|
328
|
+
};
|
|
329
|
+
return { home: source.home, context, assertUnchanged, ...(source.activeHead ? { activeHead: source.activeHead } : {}),
|
|
330
|
+
...(writerInventory?{writerInventory}:{}),markerHash: source.markerHash, fingerprint: hashJSON({ context, markerHash: source.markerHash,...(writerInventory?.revisionSequence?{writerRevisionHash:writerInventory.revisionHash}:{}) }), directories: source.directories,
|
|
331
|
+
prefixes: source.prefixes, rows: source.rows, specialRows: source.specialRows, sourceRowsByRealm: source.sourceRowsByRealm };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function captureSyncPlacementContext(home: string, port: PlacementCapturePort, selected?: readonly PlacementRealm[], pendingInput: readonly PlacementPendingSource[] = []): PlacementCapture {
|
|
335
|
+
try {
|
|
336
|
+
const selection = selected === undefined ? undefined : memoryPolicySnapshot(selected);
|
|
337
|
+
if (selected !== undefined && !selection) fail();
|
|
338
|
+
if (!Array.isArray(pendingInput) || types.isProxy(pendingInput) || pendingInput.length > 20000) fail("bound");
|
|
339
|
+
const pending = pendingInput.map(item => {
|
|
340
|
+
if (!item || typeof item !== "object" || types.isProxy(item)) fail();
|
|
341
|
+
const fields = Object.getOwnPropertyDescriptors(item);
|
|
342
|
+
if (Object.keys(fields).sort().join() !== "line,offset,pathHash,prefixHash,realm" || Object.values(fields).some(field => !("value" in field) || !field.enumerable)) fail();
|
|
343
|
+
const realm = validatePlacementKey({realm:fields.realm!.value,id:"0".repeat(32),rowHash:"0".repeat(64)}).realm;
|
|
344
|
+
const line: unknown = fields.line!.value, offset: unknown = fields.offset!.value, pathHash: unknown = fields.pathHash!.value, prefixHash: unknown = fields.prefixHash!.value;
|
|
345
|
+
if (typeof line !== "string" || Buffer.byteLength(line)>16384 || !line.endsWith("\n") || typeof offset!=="number" || !Number.isSafeInteger(offset) || offset<0
|
|
346
|
+
|| typeof pathHash!=="string" || pathHash.length!==64 || !/^[a-f0-9]+$/.test(pathHash) || typeof prefixHash!=="string" || prefixHash.length!==64 || !/^[a-f0-9]+$/.test(prefixHash)) fail();
|
|
347
|
+
return {realm,line,offset,pathHash,prefixHash};
|
|
348
|
+
});
|
|
349
|
+
const before = capture(home, port, selection, pending);
|
|
350
|
+
return { ...before, assertCurrent: () => { try { if (capture(home, port, selection, pending).fingerprint !== before.fingerprint) fail(); } catch { fail(); } } };
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error && typeof error === "object" && !types.isProxy(error)) {
|
|
353
|
+
if(error instanceof SharedWriterRevisionError){const code=Object.getOwnPropertyDescriptor(error,"code")?.value;if(code==="staging"||code==="pending")throw new SharedWriterRevisionError(code);}
|
|
354
|
+
if (error instanceof SyncPlacementJournalError) throw error;
|
|
355
|
+
if (error instanceof SegmentReadBudgetError) return fail("bound");
|
|
356
|
+
}
|
|
357
|
+
return fail();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Internal initial-activation capture only. Tokens require the actual profile owner, live home mutation and exact durable intent. */
|
|
362
|
+
export function captureSyncPlacementActivationContext(home: string, port: PlacementCapturePort, proof: PlacementActivationCaptureProof): PlacementCapture {
|
|
363
|
+
try {
|
|
364
|
+
const before = capture(home,port,undefined,[],proof);
|
|
365
|
+
return { ...before, assertCurrent: () => { if (capture(home,port,undefined,[],proof).fingerprint !== before.fingerprint) fail(); } };
|
|
366
|
+
} catch (error) {
|
|
367
|
+
if (error && typeof error === "object" && !types.isProxy(error)) {
|
|
368
|
+
if (error instanceof SyncPlacementJournalError) throw error;
|
|
369
|
+
if (error instanceof SegmentReadBudgetError) return fail("bound");
|
|
370
|
+
}
|
|
371
|
+
return fail();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { types } from "node:util";
|
|
2
|
+
import { checkedEntry } from "./bridge.ts";
|
|
3
|
+
import { canonicalJSON, hashJSON, validateVaultLinkRow } from "./generation.ts";
|
|
4
|
+
import { OP_CONTRACTS } from "./ops-contract.ts";
|
|
5
|
+
import { validatePruneRow } from "./prune.ts";
|
|
6
|
+
import { SUGGESTED } from "./store.ts";
|
|
7
|
+
import { placementKeyId, validatePlacementKey, type PlacementKey, type PlacementRealm } from "./sync-placement-journal-format.ts";
|
|
8
|
+
import type { OutboundVerdict, SyncPolicyCategory } from "./sync-screen.ts";
|
|
9
|
+
|
|
10
|
+
export interface DependencySource {
|
|
11
|
+
key: PlacementKey; row: unknown; sourcePlacement: "local" | "synced"; policy: OutboundVerdict; held: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Trusted input only after the caller independently verifies the exact public occurrence.
|
|
14
|
+
* Neither source placement nor a journal exposure flag constitutes this proof. */
|
|
15
|
+
export interface VerifiedDependencyEndpoint { key: PlacementKey; row: unknown }
|
|
16
|
+
export interface PlacementDependencyInput {
|
|
17
|
+
generation: string; selectedRealms: readonly PlacementRealm[]; rows: readonly DependencySource[];
|
|
18
|
+
publicEndpoints?: readonly VerifiedDependencyEndpoint[];
|
|
19
|
+
/** Exact original prune keys whose complete realm/generation/target/basis witness graph the caller verified. */
|
|
20
|
+
verifiedPrunes?: readonly PlacementKey[];
|
|
21
|
+
}
|
|
22
|
+
export type DependencyHoldReason = SyncPolicyCategory | "journal-hold" | "source-local" | "related-local" | "unsupported-reference" | "unverified-prune";
|
|
23
|
+
export interface DependencyDecision { key: PlacementKey; eligible: boolean; reason?: DependencyHoldReason; dependencies: PlacementKey[] }
|
|
24
|
+
export class PlacementDependencyInputError extends Error {
|
|
25
|
+
constructor() { super("Sync placement dependency inputs are invalid, incomplete or exceed supported bounds; capture and verify the selected sources again."); this.name = "PlacementDependencyInputError"; }
|
|
26
|
+
}
|
|
27
|
+
function fail(): never { throw new PlacementDependencyInputError(); }
|
|
28
|
+
const fullId = (v: unknown): v is string => typeof v === "string" && v.length === 32 && /^[a-f0-9]+$/.test(v);
|
|
29
|
+
const realmId = (v: PlacementRealm) => canonicalJSON(v);
|
|
30
|
+
const categories: readonly string[] = ["secret", "personal-data", "deny-path", "private-or-invalid", "host"];
|
|
31
|
+
interface Reference { id: string; op?: "note" | "goal" | "suggest"; hash?: string; publicHeld?: boolean; sameRealm?: boolean }
|
|
32
|
+
interface Node {
|
|
33
|
+
key: PlacementKey; row: Record<string, unknown>; output: boolean; sourcePlacement: "local" | "synced";
|
|
34
|
+
policy: OutboundVerdict; held: boolean; public: boolean; reason?: DependencyHoldReason; syntaxValid: boolean; refs: Reference[];
|
|
35
|
+
}
|
|
36
|
+
/** Descriptor-only bounded JSON copy, including private flags: those are data for the mandatory verdict,
|
|
37
|
+
* never a reason to invoke getters or silently omit a source. */
|
|
38
|
+
// Check physical cardinality and canonical source bytes before constructing any copied source collection.
|
|
39
|
+
function preflight(raw: unknown): void {
|
|
40
|
+
const descriptors = (value: unknown, array = false) => {
|
|
41
|
+
if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value) !== array || Object.getOwnPropertySymbols(value).length) return fail();
|
|
42
|
+
const proto = Object.getPrototypeOf(value);
|
|
43
|
+
if (array ? proto !== Array.prototype : proto !== Object.prototype && proto !== null) return fail();
|
|
44
|
+
return Object.getOwnPropertyDescriptors(value);
|
|
45
|
+
};
|
|
46
|
+
const fields = descriptors(raw);
|
|
47
|
+
const arrayFields = (value: unknown, max: number) => {
|
|
48
|
+
if (!value || typeof value !== "object" || types.isProxy(value) || !Array.isArray(value)) return fail();
|
|
49
|
+
const length = Object.getOwnPropertyDescriptor(value, "length")?.value;
|
|
50
|
+
if (!Number.isSafeInteger(length) || length > max) return fail();
|
|
51
|
+
const own = descriptors(value, true);
|
|
52
|
+
if (Object.keys(own).length !== length + 1) return fail();
|
|
53
|
+
for (let i = 0; i < length; i++) if (!own[String(i)] || !("value" in own[String(i)]!) || !own[String(i)]!.enumerable) fail();
|
|
54
|
+
return { own, length: length as number };
|
|
55
|
+
};
|
|
56
|
+
for (const [key, field] of Object.entries(fields)) if (!("value" in field) || !field.enumerable || !["generation", "selectedRealms", "rows", "publicEndpoints", "verifiedPrunes"].includes(key)) fail();
|
|
57
|
+
arrayFields(fields.selectedRealms?.value, 256);
|
|
58
|
+
const sources = arrayFields(fields.rows?.value, 20000);
|
|
59
|
+
let bytes = 0, nodes = 0;
|
|
60
|
+
const seen = new Set<object>();
|
|
61
|
+
const size = (v: unknown, depth: number): number => {
|
|
62
|
+
if (++nodes > 1_000_000 || depth > 64) return fail();
|
|
63
|
+
if (v === null) return 4;
|
|
64
|
+
if (typeof v === "boolean") return v ? 4 : 5;
|
|
65
|
+
if (typeof v === "number") { if (!Number.isFinite(v)) return fail(); return String(v).length; }
|
|
66
|
+
if (typeof v === "string") { if (Buffer.byteLength(v) > 8 * 1024 * 1024) return fail(); return Buffer.byteLength(JSON.stringify(v)); }
|
|
67
|
+
if (!v || typeof v !== "object" || types.isProxy(v) || seen.has(v)) return fail();
|
|
68
|
+
seen.add(v);
|
|
69
|
+
try {
|
|
70
|
+
if (Array.isArray(v)) {
|
|
71
|
+
const { own, length } = arrayFields(v, 20000); let total = 2 + Math.max(0, length - 1);
|
|
72
|
+
for (let i = 0; i < length; i++) { total += size(own[String(i)]!.value, depth + 1); if (total > 8 * 1024 * 1024) fail(); }
|
|
73
|
+
return total;
|
|
74
|
+
}
|
|
75
|
+
const own = descriptors(v); let total = 2, count = 0;
|
|
76
|
+
for (const [key, field] of Object.entries(own)) {
|
|
77
|
+
if (!("value" in field) || !field.enumerable) fail();
|
|
78
|
+
total += size(key, depth + 1) + 1 + size(field.value, depth + 1) + Number(count++ > 0);
|
|
79
|
+
if (total > 8 * 1024 * 1024) fail();
|
|
80
|
+
}
|
|
81
|
+
return total;
|
|
82
|
+
} finally { seen.delete(v); }
|
|
83
|
+
};
|
|
84
|
+
for (let i = 0; i < sources.length; i++) {
|
|
85
|
+
const source = descriptors(sources.own[String(i)]!.value), row = source.row;
|
|
86
|
+
if (!row || !("value" in row)) return fail();
|
|
87
|
+
bytes += size(row.value, 0) + 1;
|
|
88
|
+
if (bytes > 8 * 1024 * 1024) fail();
|
|
89
|
+
}
|
|
90
|
+
for (const key of ["publicEndpoints", "verifiedPrunes"]) if (fields[key]) arrayFields(fields[key]!.value, 20000);
|
|
91
|
+
}
|
|
92
|
+
/** ponytail: descriptor-only JSON safety stops at depth64, one million nodes and16MiB including supplied evidence.
|
|
93
|
+
* A reviewed chunked endpoint interface is required before larger proofs can enter this compiler. */
|
|
94
|
+
function copyInput(value: unknown): unknown {
|
|
95
|
+
let bytes = 0, nodes = 0;
|
|
96
|
+
const seen = new Set<object>();
|
|
97
|
+
const visit = (v: unknown, depth: number): unknown => {
|
|
98
|
+
if (++nodes > 1_000_000 || depth > 64) fail();
|
|
99
|
+
if (v === null || typeof v === "boolean") { bytes += 5; return v; }
|
|
100
|
+
if (typeof v === "number") { if (!Number.isFinite(v)) fail(); bytes += 24; return v; }
|
|
101
|
+
if (typeof v === "string") { bytes += Buffer.byteLength(v); if (bytes > 16 * 1024 * 1024) fail(); return v; }
|
|
102
|
+
if (!v || typeof v !== "object" || types.isProxy(v) || seen.has(v) || Object.getOwnPropertySymbols(v).length) fail();
|
|
103
|
+
const array = Array.isArray(v), proto = Object.getPrototypeOf(v);
|
|
104
|
+
if (array ? proto !== Array.prototype : proto !== Object.prototype && proto !== null) fail();
|
|
105
|
+
const fields = Object.getOwnPropertyDescriptors(v); seen.add(v);
|
|
106
|
+
try {
|
|
107
|
+
if (array) {
|
|
108
|
+
const length = fields.length?.value;
|
|
109
|
+
if (!Number.isSafeInteger(length) || length > 20000 || Object.keys(fields).length !== length + 1) fail();
|
|
110
|
+
const out: unknown[] = [];
|
|
111
|
+
for (let i = 0; i < length; i++) { const field = fields[String(i)]; if (!field || !("value" in field) || !field.enumerable) fail(); out.push(visit(field.value, depth + 1)); }
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
const out: Record<string, unknown> = Object.create(null);
|
|
115
|
+
for (const [key, field] of Object.entries(fields)) { if (!("value" in field) || !field.enumerable) fail(); bytes += Buffer.byteLength(key); out[key] = visit(field.value, depth + 1); }
|
|
116
|
+
return out;
|
|
117
|
+
} finally { seen.delete(v); }
|
|
118
|
+
};
|
|
119
|
+
return visit(value, 0);
|
|
120
|
+
}
|
|
121
|
+
function object(value: unknown): Record<string, unknown> { if (!value || typeof value !== "object" || Array.isArray(value)) return fail(); return value as Record<string, unknown>; }
|
|
122
|
+
function keys(value: Record<string, unknown>, required: string[], optional: string[] = []): void {
|
|
123
|
+
if (required.some(k => !Object.hasOwn(value, k)) || Object.keys(value).some(k => !required.includes(k) && !optional.includes(k))) fail();
|
|
124
|
+
}
|
|
125
|
+
function realm(value: unknown): PlacementRealm { return validatePlacementKey({ realm: value, id: "0".repeat(32), rowHash: "0".repeat(64) }).realm; }
|
|
126
|
+
function syntax(node: Node, generation: string): boolean {
|
|
127
|
+
const row = node.row;
|
|
128
|
+
try {
|
|
129
|
+
// Existing validators require a plain JSON object, not our safe null-prototype copy.
|
|
130
|
+
const plain = JSON.parse(canonicalJSON(row));
|
|
131
|
+
if (row.op === "prune") validatePruneRow(plain, generation, node.sourcePlacement);
|
|
132
|
+
else if (row.op === "vault_link") validateVaultLinkRow(plain, generation, "local");
|
|
133
|
+
else if (typeof row.op === "string" && Object.hasOwn(OP_CONTRACTS, row.op)) checkedEntry(plain);
|
|
134
|
+
else return false;
|
|
135
|
+
return true;
|
|
136
|
+
} catch { return false; }
|
|
137
|
+
}
|
|
138
|
+
function utc(value: unknown): boolean { return typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value; }
|
|
139
|
+
function suggestion(row: Record<string, unknown>): { from: string; to: string } | undefined {
|
|
140
|
+
if (row.op !== "suggest" || !fullId(row.target) || typeof row.by !== "string") return;
|
|
141
|
+
const match = /^([^:]+):([a-f0-9]{32})$/.exec(row.by);
|
|
142
|
+
if (!match || match[0] !== row.by || !(SUGGESTED as readonly string[]).includes(match[1]!) || row.target === match[2]) return;
|
|
143
|
+
return { from: row.target, to: match[2]! };
|
|
144
|
+
}
|
|
145
|
+
function visible(from: Node, to: Node): boolean {
|
|
146
|
+
// Shared/user facts must not acquire a dependency on a private project endpoint.
|
|
147
|
+
const sharedEndpoint = to.key.realm.scope === "user" && (to.row.scope === undefined || to.row.scope === "shared");
|
|
148
|
+
if (from.key.realm.scope === "user" || from.row.scope === "shared") return sharedEndpoint;
|
|
149
|
+
return sharedEndpoint
|
|
150
|
+
|| from.key.realm.scope === "project" && to.key.realm.scope === "project" && from.key.realm.keyHash === to.key.realm.keyHash;
|
|
151
|
+
}
|
|
152
|
+
/** Pure, body-free dependency decisions. The caller supplies authoritative coalesced physical rows and
|
|
153
|
+
* mandatory policy/journal verdicts; this function performs no screening, hydration, I/O or publication. */
|
|
154
|
+
export function compilePlacementDependencies(raw: PlacementDependencyInput): DependencyDecision[] {
|
|
155
|
+
try { return compile(raw); } catch (error) { if (error instanceof PlacementDependencyInputError) throw error; return fail(); }
|
|
156
|
+
}
|
|
157
|
+
function compile(raw: PlacementDependencyInput): DependencyDecision[] {
|
|
158
|
+
preflight(raw);
|
|
159
|
+
const input = object(copyInput(raw)); keys(input, ["generation", "selectedRealms", "rows"], ["publicEndpoints", "verifiedPrunes"]);
|
|
160
|
+
if (!fullId(input.generation) || !Array.isArray(input.selectedRealms) || input.selectedRealms.length > 256 || !Array.isArray(input.rows)
|
|
161
|
+
|| input.rows.length > 20000 || input.publicEndpoints !== undefined && !Array.isArray(input.publicEndpoints)
|
|
162
|
+
|| input.verifiedPrunes !== undefined && !Array.isArray(input.verifiedPrunes)) return fail();
|
|
163
|
+
const selected = new Set(input.selectedRealms.map(x => realmId(realm(x)))); if (!selected.size || selected.size !== input.selectedRealms.length) fail();
|
|
164
|
+
const nodes: Node[] = [], byKey = new Map<string, Node>(), byId = new Map<string, Node[]>(), hashes = new Map<string, string>();
|
|
165
|
+
let sourceBytes = 0;
|
|
166
|
+
const add = (value: unknown, output: boolean): void => {
|
|
167
|
+
const item = object(value); keys(item, output ? ["key", "row", "sourcePlacement", "policy", "held"] : ["key", "row"]);
|
|
168
|
+
const key = validatePlacementKey(item.key), row = object(item.row), id = placementKeyId(key);
|
|
169
|
+
if (!selected.has(realmId(key.realm)) || row.id !== key.id || hashJSON(row) !== key.rowHash) fail();
|
|
170
|
+
if (hashes.has(key.id) && hashes.get(key.id) !== key.rowHash) fail(); hashes.set(key.id, key.rowHash);
|
|
171
|
+
if (output && (sourceBytes += Buffer.byteLength(canonicalJSON(row) + "\n")) > 8 * 1024 * 1024) fail();
|
|
172
|
+
let policy: OutboundVerdict = { ok: true };
|
|
173
|
+
if (output) {
|
|
174
|
+
const verdict = object(item.policy); keys(verdict, ["ok"], ["category"]);
|
|
175
|
+
if (verdict.ok === false && typeof verdict.category === "string" && categories.includes(verdict.category)) policy = { ok: false, category: verdict.category as SyncPolicyCategory };
|
|
176
|
+
else if (verdict.ok !== true || Object.hasOwn(verdict, "category")) fail();
|
|
177
|
+
if (!["local", "synced"].includes(item.sourcePlacement as string) || typeof item.held !== "boolean") fail();
|
|
178
|
+
}
|
|
179
|
+
const old = byKey.get(id);
|
|
180
|
+
if (old) { if (output || old.public) fail(); old.public = true; return; }
|
|
181
|
+
const node: Node = { key, row, output, sourcePlacement: output ? item.sourcePlacement as "local" | "synced" : "synced", policy,
|
|
182
|
+
held: output ? item.held as boolean : false, public: !output, syntaxValid: false, refs: [] };
|
|
183
|
+
nodes.push(node); byKey.set(id, node); byId.set(key.id, [...(byId.get(key.id) ?? []), node]);
|
|
184
|
+
};
|
|
185
|
+
for (const value of input.rows) add(value, true);
|
|
186
|
+
const endpoints = input.publicEndpoints as unknown[] | undefined;
|
|
187
|
+
if ((endpoints?.length ?? 0) > 20000) fail(); for (const value of endpoints ?? []) add(value, false);
|
|
188
|
+
const proofs = input.verifiedPrunes as unknown[] | undefined;
|
|
189
|
+
if ((proofs?.length ?? 0) > 20000) fail();
|
|
190
|
+
const verified = new Set((proofs ?? []).map(value => { const key = validatePlacementKey(value); if (!selected.has(realmId(key.realm))) fail(); return placementKeyId(key); }));
|
|
191
|
+
const heldIds = new Set(nodes.filter(node => node.held).map(node => node.key.id));
|
|
192
|
+
const policyHolds = new Map<string, SyncPolicyCategory>();
|
|
193
|
+
for (const node of nodes) if (!node.policy.ok) policyHolds.set(node.key.id, node.policy.category);
|
|
194
|
+
const refs = (node: Node, ids: unknown[], op?: Reference["op"], publicHeld = false): boolean => {
|
|
195
|
+
if (ids.some(id => !fullId(id))) return false;
|
|
196
|
+
node.refs.push(...ids.map(id => ({ id: id as string, ...(op ? { op } : {}), ...(publicHeld ? { publicHeld: true } : {}) }))); return true;
|
|
197
|
+
};
|
|
198
|
+
for (const node of nodes) {
|
|
199
|
+
const row = node.row;
|
|
200
|
+
node.syntaxValid = syntax(node, input.generation);
|
|
201
|
+
if (!node.syntaxValid) { node.reason = "unsupported-reference"; continue; }
|
|
202
|
+
if (node.sourcePlacement === "local" || row.op === "settle" || row.op === "vault_link" || row.op === "note" && row.tier === "short") node.reason = "source-local";
|
|
203
|
+
else if (heldIds.has(node.key.id)) node.reason = "journal-hold";
|
|
204
|
+
else if (policyHolds.has(node.key.id)) node.reason = policyHolds.get(node.key.id)!;
|
|
205
|
+
let valid = true;
|
|
206
|
+
switch (row.op) {
|
|
207
|
+
case "note": break; // Both ancestry fields are weak; never fetch or export their originals.
|
|
208
|
+
case "goal": valid = Array.isArray(row.dependsOn) && refs(node, row.dependsOn, "goal"); break;
|
|
209
|
+
case "supersede": valid = row.target !== row.by && refs(node, [row.target, row.by], "note"); break;
|
|
210
|
+
case "goal_state": valid = ["open", "active", "blocked", "done", "dropped"].includes(row.by as string) && refs(node, [row.target], "goal"); break;
|
|
211
|
+
case "goal_link": valid = typeof row.by === "string" && row.by.length > 0 && refs(node, [row.target], "goal"); break;
|
|
212
|
+
case "lease": case "lease_force": valid = utc(row.by) && refs(node, [row.target], "goal"); break;
|
|
213
|
+
case "suggest": { const edge = suggestion(row); valid = !!edge && refs(node, [edge.from, edge.to], "note"); break; }
|
|
214
|
+
case "confirm": case "reject": {
|
|
215
|
+
valid = refs(node, [row.target], "suggest");
|
|
216
|
+
const suggestions = fullId(row.target) ? byId.get(row.target) ?? [] : [];
|
|
217
|
+
const edge = suggestions.map(n => suggestion(n.row)).find(Boolean);
|
|
218
|
+
valid = valid && !!edge && refs(node, [edge.from, edge.to], "note"); break;
|
|
219
|
+
}
|
|
220
|
+
case "forget": valid = refs(node, [row.target], "note", true); break;
|
|
221
|
+
case "demote": valid = utc(row.by) && refs(node, [row.target], "note", true); break;
|
|
222
|
+
case "prune":
|
|
223
|
+
if (!verified.has(placementKeyId(node.key))) node.reason ??= "unverified-prune";
|
|
224
|
+
for (const target of row.targets as { id: string; rowHash: string; basis: { id: string; rowHash: string } }[]) {
|
|
225
|
+
node.refs.push({ id: target.id, hash: target.rowHash, sameRealm: true, publicHeld: true }, { id: target.basis.id, hash: target.basis.rowHash, sameRealm: true, publicHeld: true });
|
|
226
|
+
}
|
|
227
|
+
break;
|
|
228
|
+
case "settle": case "vault_link": break;
|
|
229
|
+
default: valid = false;
|
|
230
|
+
}
|
|
231
|
+
if (!valid) node.reason ??= "unsupported-reference";
|
|
232
|
+
}
|
|
233
|
+
const candidates = (from: Node, ref: Reference): Node[] => (byId.get(ref.id) ?? []).filter(to => visible(from, to)
|
|
234
|
+
&& (!ref.op || to.row.op === ref.op) && (!ref.hash || to.key.rowHash === ref.hash)
|
|
235
|
+
&& (!ref.sameRealm || realmId(from.key.realm) === realmId(to.key.realm)));
|
|
236
|
+
const usable = (from: Node, ref: Reference): Node | undefined => candidates(from, ref).find(to => {
|
|
237
|
+
if (to.public && ref.publicHeld && to.syntaxValid) return true;
|
|
238
|
+
return !heldIds.has(to.key.id) && !policyHolds.has(to.key.id) && !to.reason;
|
|
239
|
+
});
|
|
240
|
+
const reverse = new Map<string, Set<Node>>();
|
|
241
|
+
for (const node of nodes) for (const ref of node.refs) {
|
|
242
|
+
let dependents = reverse.get(ref.id); if (!dependents) reverse.set(ref.id, dependents = new Set()); dependents.add(node);
|
|
243
|
+
}
|
|
244
|
+
const queue = [...nodes];
|
|
245
|
+
for (let at = 0; at < queue.length; at++) {
|
|
246
|
+
const node = queue[at]!; if (node.reason) continue;
|
|
247
|
+
const endpoints = node.refs.map(ref => usable(node, ref));
|
|
248
|
+
let good = endpoints.every(Boolean);
|
|
249
|
+
if (good && node.row.op === "supersede") good = endpoints[0]!.row.scope !== "shared" || endpoints[1]!.row.scope === "shared";
|
|
250
|
+
if (!good) { node.reason = "related-local"; for (const dependent of reverse.get(node.key.id) ?? []) queue.push(dependent); }
|
|
251
|
+
}
|
|
252
|
+
return nodes.filter(node => node.output).map(node => ({ key: validatePlacementKey(node.key), eligible: !node.reason,
|
|
253
|
+
...(node.reason ? { reason: node.reason } : {}), dependencies: [...new Map(node.refs.flatMap(ref => candidates(node, ref).map(to => [placementKeyId(to.key), to.key] as const))).values()].sort((a,b) => placementKeyId(a).localeCompare(placementKeyId(b))).map(validatePlacementKey) }))
|
|
254
|
+
.sort((a,b) => placementKeyId(a.key).localeCompare(placementKeyId(b.key)));
|
|
255
|
+
}
|