@staix/agent-mem 0.13.0 → 0.14.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.
@@ -0,0 +1,139 @@
1
+ /** Root selector commitment only; this module never writes or activates a profile. */
2
+ import { constants, closeSync, fstatSync, lstatSync, openSync, readSync, type Stats } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { canonicalJSON, hashJSON, PROFILE_LOCK_TIMEOUT_MS } from "./generation.ts";
5
+ import { realPath } from "./platform.ts";
6
+
7
+ export const PUBLIC_PRUNE_INTENT = "public-prune-view.intent.json";
8
+ const MAX_BYTES = 65_536;
9
+ export interface PublicPruneViewV1 {
10
+ schema: "agent-mem.public-prune-view/v1";
11
+ receivingGeneration: string;
12
+ realms: Record<string, string>;
13
+ }
14
+ const unavailable = (): never => { throw new Error("public prune view unavailable"); };
15
+ function guard(ok: unknown): asserts ok { if (!ok) unavailable(); }
16
+ function protectedCall<T>(work: () => T): T { try { return work(); } catch { return unavailable(); } }
17
+ const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value)
18
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
19
+ const hex = (value: unknown, size: number): value is string => typeof value === "string" && value.length === size && /^[a-f0-9]+$/.test(value);
20
+ function realmKey(value: string): boolean {
21
+ if (!value.isWellFormed()) return false;
22
+ if (value === "user") return true;
23
+ if (!value.startsWith("project:")) return false;
24
+ const key = value.slice("project:".length);
25
+ return key.length > 0 && key === key.trim() && !/\p{Cc}/u.test(key);
26
+ }
27
+ export function validatePublicPruneView(input: unknown): PublicPruneViewV1 {
28
+ return protectedCall(() => {
29
+ guard(object(input) && Reflect.ownKeys(input).length === 3 && ["schema", "receivingGeneration", "realms"].every(key => Object.hasOwn(input, key)));
30
+ guard(input.schema === "agent-mem.public-prune-view/v1" && hex(input.receivingGeneration, 32) && object(input.realms));
31
+ const keys = Object.keys(input.realms).sort();
32
+ guard(Reflect.ownKeys(input.realms).length === keys.length && keys.length <= Math.floor(MAX_BYTES / 64));
33
+ const realms: Record<string, string> = {}; let minimumBytes = 0;
34
+ for (const key of keys) {
35
+ minimumBytes += Buffer.byteLength(key, "utf8") + 64; guard(minimumBytes <= MAX_BYTES);
36
+ const hash = input.realms[key]; guard(realmKey(key) && hex(hash, 64));
37
+ Object.defineProperty(realms, key, { value: hash, enumerable: true, writable: true, configurable: true });
38
+ }
39
+ const value: PublicPruneViewV1 = { schema: input.schema, receivingGeneration: input.receivingGeneration, realms };
40
+ guard(Buffer.byteLength(canonicalJSON(value), "utf8") <= MAX_BYTES);
41
+ return value;
42
+ });
43
+ }
44
+ export function publicPruneViewHash(view: PublicPruneViewV1): string { return hashJSON(validatePublicPruneView(view)); }
45
+ /** A v2 reader may wait for a short cooperating writer, never authorize or repair one.
46
+ * No liveness helper, network call, PID exemption or mutation occurs on this path. */
47
+ export function waitForPublicPruneWriter(home: string, absoluteDeadline?: number): void {
48
+ protectedCall(() => {
49
+ const deadline = Math.min(absoluteDeadline ?? Infinity, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
50
+ const intentFile = join(home, PUBLIC_PRUNE_INTENT), lockFile = join(home, "profile.lock");
51
+ if (!lstatSync(intentFile, { throwIfNoEntry: false }) && !lstatSync(lockFile, { throwIfNoEntry: false })) return;
52
+ const small = (file: string, limit: number): Record<string, unknown> => {
53
+ const before = lstatSync(file); privateStat(before); guard(before.size > 0 && before.size <= limit);
54
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
55
+ try {
56
+ const opened = fstatSync(fd); privateStat(opened); guard(opened.size > 0 && opened.size <= limit);
57
+ const bytes = Buffer.alloc(opened.size + 1); let count = 0;
58
+ while (count < bytes.length) { const read = readSync(fd, bytes, count, bytes.length - count, count); if (!read) break; count += read; }
59
+ const after = fstatSync(fd); privateStat(after); guard(count === opened.size && same(opened, after));
60
+ const parsed: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count))); guard(object(parsed)); return parsed;
61
+ } finally { closeSync(fd); }
62
+ };
63
+ const profileFile = join(home, "profile.json");
64
+ if (!lstatSync(profileFile, { throwIfNoEntry: false })) return; // Implicit shared-v1 has no selector marker.
65
+ const profile = small(profileFile, MAX_BYTES);
66
+ if (profile.schema !== "agent-mem.profile/v2") return; // v1 admission/intent behavior is unchanged.
67
+ for (;;) {
68
+ try {
69
+ const intent = lstatSync(intentFile, { throwIfNoEntry: false }), lock = lstatSync(lockFile, { throwIfNoEntry: false });
70
+ if (!intent && !lock) return;
71
+ if (lock) {
72
+ privateStat(lock);
73
+ if (lock.size) {
74
+ const owner = small(lockFile, 4096);
75
+ guard(Reflect.ownKeys(owner).length === 3 && Number.isSafeInteger(owner.pid) && Number(owner.pid) > 0 && hex(owner.signature, 64) && hex(owner.token, 32));
76
+ }
77
+ }
78
+ if (intent?.nlink === 2) {
79
+ // Create-once publication briefly links the complete staging inode to its final
80
+ // name before fsync/unlink. Wait under an owned complete writer lock, without
81
+ // reading or trusting that not-yet-independent intent as published evidence.
82
+ guard(lock && lock.size > 0 && intent.isFile() && !intent.isSymbolicLink()
83
+ && intent.uid === process.getuid?.() && (intent.mode & 0o077) === 0
84
+ && intent.size > 0 && intent.size <= 1024 * 1024);
85
+ } else if (intent) {
86
+ const pending = small(intentFile, 1024 * 1024);
87
+ guard(pending.schema === "agent-mem.public-prune-advance/v1" && pending.home === realPath(home) && pending.generation === profile.generation);
88
+ }
89
+ } catch (error) {
90
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; // Publication disappeared normally; retry the bounded observation.
91
+ }
92
+ const remaining = deadline - Date.now(); guard(remaining > 0); Bun.sleepSync(Math.min(5, remaining));
93
+ }
94
+ });
95
+ }
96
+ /** Any unresolved intent blocks reads. Never remove it, including malformed bytes. */
97
+ export function assertNoPublicPruneIntent(home: string, absoluteDeadline?: number): void {
98
+ protectedCall(() => { waitForPublicPruneWriter(home, absoluteDeadline); guard(!lstatSync(join(home, PUBLIC_PRUNE_INTENT), { throwIfNoEntry: false })); });
99
+ }
100
+ function privateStat(stat: Stats, directory = false): void {
101
+ guard(!stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile() && stat.nlink === 1));
102
+ guard(stat.uid === process.getuid?.() && (stat.mode & 0o077) === 0);
103
+ }
104
+ function same(left: Stats, right: Stats): boolean {
105
+ return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
106
+ }
107
+ /** Trusted activation bootstrap reads the artifact while intent fences ordinary readers. */
108
+ export function readPublicPruneViewForActivation(home: string, hash: string, generation: string): PublicPruneViewV1 {
109
+ return protectedCall(() => {
110
+ guard(hex(hash, 64) && hex(generation, 32));
111
+ const original = lstatSync(home); privateStat(original, true);
112
+ const root = realPath(home), rootStat = lstatSync(root); privateStat(rootStat, true); guard(rootStat.dev === original.dev && rootStat.ino === original.ino);
113
+ const directory = join(root, "public-prune-input-sets"), dirStat = lstatSync(directory); privateStat(dirStat, true);
114
+ const file = join(directory, `${hash}.json`), before = lstatSync(file); privateStat(before); guard(before.size > 0 && before.size <= MAX_BYTES);
115
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
116
+ try {
117
+ const opened = fstatSync(fd); privateStat(opened); guard(same(before, opened));
118
+ const bytes = Buffer.alloc(before.size + 1); let count = 0;
119
+ while (count < bytes.length) { const read = readSync(fd, bytes, count, bytes.length - count, count); if (read === 0) break; count += read; }
120
+ guard(count === before.size);
121
+ const after = fstatSync(fd), named = lstatSync(file); privateStat(after); privateStat(named); guard(same(before, after) && same(after, named));
122
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, count));
123
+ const view = validatePublicPruneView(JSON.parse(text)), canonical = canonicalJSON(view);
124
+ guard(text === canonical || text === canonical + "\n");
125
+ guard(view.receivingGeneration === generation && hashJSON(view) === hash);
126
+ const currentRoot = lstatSync(root), currentDir = lstatSync(directory), currentHome = lstatSync(home);
127
+ privateStat(currentRoot, true); privateStat(currentDir, true); privateStat(currentHome, true);
128
+ guard(currentRoot.dev === rootStat.dev && currentRoot.ino === rootStat.ino && currentHome.dev === original.dev && currentHome.ino === original.ino
129
+ && currentDir.dev === dirStat.dev && currentDir.ino === dirStat.ino);
130
+ return view;
131
+ } finally { closeSync(fd); }
132
+ });
133
+ }
134
+
135
+ export function readPublicPruneView(home: string, hash: string, generation: string, absoluteDeadline?: number): PublicPruneViewV1 {
136
+ assertNoPublicPruneIntent(home, absoluteDeadline);
137
+ const result = readPublicPruneViewForActivation(home, hash, generation);
138
+ assertNoPublicPruneIntent(home, absoluteDeadline); return result;
139
+ }
@@ -0,0 +1,168 @@
1
+ /** Layer 1 only: pure complete public-pack proof, never publication or an active mask. */
2
+ import { createHash } from "node:crypto";
3
+ import { types } from "node:util";
4
+ import { canonicalJSON, hashJSON } from "./generation.ts";
5
+ import { reducePruneRows, validatePruneRow, pruneRowHash, type PruneRow, type PruneSourceRow } from "./prune.ts";
6
+ import { decodeShardPack, SHARD_PACK_MAX_BYTES, type ShardSyncRealm } from "./shard-sync-format.ts";
7
+
8
+ export const PUBLIC_PRUNE_MANIFEST_MAX_BYTES = 65_536;
9
+ export const PUBLIC_PRUNE_MAX_WITNESSES = 64, PUBLIC_PRUNE_MAX_PACKS = 65;
10
+ export type PublicPruneLineage = { kind: "native-generation"; seal: string } | { kind: "post-seal"; seal: string; end: number };
11
+ interface Occurrence { pack: string; blob: string }
12
+ export interface PublicPruneWitnessV1 {
13
+ schema: "agent-mem.public-prune-witness/v1";
14
+ realm: ShardSyncRealm;
15
+ prune: PruneRow;
16
+ lineage: PublicPruneLineage;
17
+ pruneOccurrence: Occurrence;
18
+ witnesses: (Occurrence & { id: string; rowHash: string })[];
19
+ }
20
+ /** Supplied only from the owned, audited selected-realm source path (Layer 4).
21
+ * Raw bytes prove content, not private-remote consent or authenticity. A flag,
22
+ * outgoing row selection or old hold receipt cannot substitute for these packs.
23
+ */
24
+ export interface PublicPruneEvidence {
25
+ realm: ShardSyncRealm;
26
+ packs: readonly (Occurrence & { bytes: Uint8Array })[];
27
+ }
28
+ export interface VerifiedPublicPruneWitness {
29
+ status: "verified"; manifest: PublicPruneWitnessV1; hash: string;
30
+ }
31
+ export type PublicPruneVerification = VerifiedPublicPruneWitness | { status: "invalid" }
32
+ | { status: "unsupported_lineage"; reason: "lineage_missing" | "lineage_unsupported" };
33
+ type Candidate = Omit<PublicPruneWitnessV1, "lineage"> & { lineage?: unknown };
34
+ const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
35
+ const exact = (v: Record<string, unknown>, keys: string[]): boolean => Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v, k));
36
+ const hex = (v: unknown, n: number): v is string => typeof v === "string" && v.length === n && new RegExp(`^[a-f0-9]{${n}}$`).test(v);
37
+ const bad = (): never => { throw new Error("public prune witness invalid or unavailable"); };
38
+ function guard(ok: unknown): asserts ok { if (!ok) bad(); }
39
+ function realm(input: unknown): ShardSyncRealm {
40
+ guard(object(input));
41
+ if (input.scope === "user") { guard(exact(input, ["scope"])); return { scope: "user" }; }
42
+ guard(input.scope === "project" && exact(input, ["scope", "key"]) && typeof input.key === "string" && input.key.length > 0);
43
+ guard(Buffer.byteLength(input.key, "utf8") <= PUBLIC_PRUNE_MANIFEST_MAX_BYTES);
44
+ return { scope: "project", key: input.key };
45
+ }
46
+ function occurrence(input: unknown, witness = false): Occurrence {
47
+ guard(object(input) && exact(input, witness ? ["pack", "blob", "id", "rowHash"] : ["pack", "blob"]));
48
+ guard(typeof input.pack === "string" && input.pack.length === 140 && /^rows\/[a-f0-9]{64}\/[a-f0-9]{64}\.jsonl$/.test(input.pack) && hex(input.blob, 40));
49
+ return { pack: input.pack, blob: input.blob };
50
+ }
51
+ /** Check the portable JSON shape and exact encoded size before cloning unknown lineage.
52
+ * Iterative traversal bounds cycles, giant scalars and shared subtrees without a new depth ceiling. */
53
+ function boundLineage(input: unknown, budget: number): void {
54
+ guard(budget >= 0);
55
+ const active = new WeakSet<object>(), stack: { value: unknown; exit?: boolean }[] = [{ value: input }];
56
+ const spend = (bytes: number) => { budget -= bytes; guard(budget >= 0); };
57
+ const stringBytes = (value: string) => { guard(value.length <= budget); return Buffer.byteLength(JSON.stringify(value), "utf8"); };
58
+ while (stack.length) {
59
+ const frame = stack.pop()!, value = frame.value;
60
+ if (frame.exit) { active.delete(value as object); continue; }
61
+ if (typeof value === "string") { spend(stringBytes(value)); continue; }
62
+ if (value === null || typeof value === "boolean") { spend(value === null || value === true ? 4 : 5); continue; }
63
+ if (typeof value === "number") { guard(Number.isFinite(value)); spend(JSON.stringify(value).length); continue; }
64
+ guard(value && typeof value === "object" && !types.isProxy(value) && !active.has(value));
65
+ const array = Array.isArray(value), prototype = Object.getPrototypeOf(value);
66
+ guard(array ? prototype === Array.prototype : prototype === Object.prototype || prototype === null);
67
+ active.add(value); stack.push({ value, exit: true }); spend(2);
68
+ let count = 0;
69
+ if (array) { guard(value.length <= budget); if (value.length) spend(value.length - 1); }
70
+ for (const key in value) {
71
+ if (!Object.hasOwn(value, key)) continue;
72
+ const field = Object.getOwnPropertyDescriptor(value, key)!;
73
+ guard("value" in field && field.enumerable);
74
+ if (array) { const index = Number(key); guard(Number.isInteger(index) && index >= 0 && index < value.length && String(index) === key); }
75
+ else spend(stringBytes(key) + 1 + (count ? 1 : 0));
76
+ count++; stack.push({ value: field.value });
77
+ }
78
+ if (array) guard(count === value.length); // JSON arrays have no holes or extra enumerable fields.
79
+ }
80
+ }
81
+ function candidate(input: unknown): Candidate {
82
+ guard(object(input));
83
+ guard(exact(input, ["schema", "realm", "prune", "pruneOccurrence", "witnesses", ...(Object.hasOwn(input, "lineage") ? ["lineage"] : [])]));
84
+ guard(input.schema === "agent-mem.public-prune-witness/v1" && Array.isArray(input.witnesses) && input.witnesses.length <= PUBLIC_PRUNE_MAX_WITNESSES);
85
+ guard(object(input.prune) && Array.isArray(input.prune.targets) && input.prune.targets.length <= 32);
86
+ const prune = validatePruneRow(input.prune, undefined, "synced"), selected = realm(input.realm);
87
+ guard(hex(prune.generation, 32) && hex(prune.machine, 16));
88
+ const needed = new Map<string, string>();
89
+ for (const target of prune.targets) for (const ref of [target, target.basis]) {
90
+ guard(!needed.has(ref.id) || needed.get(ref.id) === ref.rowHash); needed.set(ref.id, ref.rowHash);
91
+ }
92
+ let previous = "";
93
+ const witnesses = input.witnesses.map(value => {
94
+ const where = occurrence(value, true); guard(object(value) && hex(value.id, 32) && hex(value.rowHash, 64));
95
+ guard(value.id > previous && needed.get(value.id) === value.rowHash); previous = value.id;
96
+ return { ...where, id: value.id, rowHash: value.rowHash };
97
+ });
98
+ guard(witnesses.length === needed.size);
99
+ const result: Candidate = { schema: input.schema, realm: selected, prune, pruneOccurrence: occurrence(input.pruneOccurrence), witnesses };
100
+ const baseBytes = Buffer.byteLength(canonicalJSON(result), "utf8");
101
+ guard(baseBytes <= PUBLIC_PRUNE_MANIFEST_MAX_BYTES);
102
+ const origin = Object.getOwnPropertyDescriptor(input, "lineage");
103
+ if (origin) {
104
+ guard("value" in origin);
105
+ const value: unknown = origin.value;
106
+ if (value !== undefined) {
107
+ boundLineage(value, PUBLIC_PRUNE_MANIFEST_MAX_BYTES - baseBytes - Buffer.byteLength(',"lineage":'));
108
+ result.lineage = structuredClone(value);
109
+ }
110
+ }
111
+ guard(Buffer.byteLength(canonicalJSON(result), "utf8") <= PUBLIC_PRUNE_MANIFEST_MAX_BYTES);
112
+ return result;
113
+ }
114
+ function lineage(input: unknown): PublicPruneLineage | undefined {
115
+ if (!object(input) || !["native-generation", "post-seal"].includes(String(input.kind))) return undefined;
116
+ guard(exact(input, input.kind === "native-generation" ? ["kind", "seal"] : ["kind", "seal", "end"]) && hex(input.seal, 64));
117
+ if (input.kind === "native-generation") return { kind: input.kind, seal: input.seal };
118
+ guard(Number.isSafeInteger(input.end) && (input.end as number) >= 0);
119
+ return { kind: "post-seal", seal: input.seal, end: input.end as number };
120
+ }
121
+ export function validatePublicPruneWitness(input: unknown): PublicPruneWitnessV1 {
122
+ try { const value = candidate(input), origin = lineage(value.lineage); guard(origin); return { ...value, lineage: origin }; }
123
+ catch { return bad(); }
124
+ }
125
+ export function publicPruneWitnessHash(input: PublicPruneWitnessV1): string { return hashJSON(validatePublicPruneWitness(input)); }
126
+
127
+ export function verifyPublicPruneWitness(input: unknown, evidence: PublicPruneEvidence): PublicPruneVerification {
128
+ try {
129
+ const value = candidate(input);
130
+ guard(object(evidence) && exact(evidence, ["realm", "packs"]) && canonicalJSON(realm(evidence.realm)) === canonicalJSON(value.realm));
131
+ guard(Array.isArray(evidence.packs) && evidence.packs.length <= PUBLIC_PRUNE_MAX_PACKS);
132
+ const required = new Map<string, string>();
133
+ for (const ref of [value.pruneOccurrence, ...value.witnesses]) {
134
+ guard(!required.has(ref.pack) || required.get(ref.pack) === ref.blob); required.set(ref.pack, ref.blob);
135
+ }
136
+ guard(evidence.packs.length === required.size);
137
+ const packs = new Map<string, Map<string, PruneRow | PruneSourceRow["row"]>>(), seen = new Map<string, string>();
138
+ for (const pack of evidence.packs) {
139
+ guard(object(pack) && exact(pack, ["pack", "blob", "bytes"]) && typeof pack.pack === "string" && typeof pack.blob === "string");
140
+ guard(required.get(pack.pack) === pack.blob && !packs.has(pack.pack));
141
+ guard(pack.bytes instanceof Uint8Array && pack.bytes.byteLength > 0 && pack.bytes.byteLength <= SHARD_PACK_MAX_BYTES);
142
+ const bytes = Buffer.from(pack.bytes);
143
+ const blob = createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex");
144
+ guard(blob === pack.blob);
145
+ const rows = decodeShardPack(pack.pack, bytes, value.realm, value.prune.generation);
146
+ const contents = new Map<string, PruneRow | PruneSourceRow["row"]>();
147
+ for (let i = 0; i < rows.length; i++) {
148
+ const row = rows[i]!, canonical = canonicalJSON(row);
149
+ guard(!seen.has(row.id) || seen.get(row.id) === canonical);
150
+ seen.set(row.id, canonical); contents.set(row.id, row);
151
+ }
152
+ packs.set(pack.pack, contents);
153
+ }
154
+ guard(canonicalJSON(packs.get(value.pruneOccurrence.pack)?.get(value.prune.id)) === canonicalJSON(value.prune));
155
+ const sources: PruneSourceRow[] = [];
156
+ for (const witness of value.witnesses) {
157
+ const row = packs.get(witness.pack)?.get(witness.id);
158
+ guard(row && row.op !== "prune" && pruneRowHash(row) === witness.rowHash);
159
+ sources.push({ row, placement: "synced", publicWitness: true }); // only after complete public pack proof
160
+ }
161
+ const proof = reducePruneRows([{ row: value.prune, placement: "synced" }], { rows: sources, expiries: [] }, value.prune.generation);
162
+ guard(proof.invalid === 0 && proof.pending.length === 0 && proof.targets.size === value.prune.targets.length);
163
+ const origin = lineage(value.lineage);
164
+ if (!origin) return { status: "unsupported_lineage", reason: value.lineage === undefined ? "lineage_missing" : "lineage_unsupported" };
165
+ const manifest: PublicPruneWitnessV1 = { ...value, lineage: origin };
166
+ return { status: "verified", manifest, hash: hashJSON(manifest) };
167
+ } catch { return { status: "invalid" }; }
168
+ }
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { readProfileJSON, writeProfileJSON } from "./generation.ts";
4
4
  import { readerAdmissionDatabase } from "./mutation.ts";
5
5
  import { processLiveness, processSignature } from "./platform.ts";
6
+ import { waitForPublicPruneWriter } from "./public-prune-view.ts";
6
7
 
7
8
  const READER_LEASE_SCHEMA = "agent-mem.reader-lease/v1";
8
9
  const RENEW_MS = 25_000;
@@ -12,7 +13,9 @@ interface OpenLease { refs: number; timer: ReturnType<typeof setInterval>; busyT
12
13
  const open = new Map<string, OpenLease>();
13
14
  let activeIdentity: { pid: number; signature: string } | undefined;
14
15
 
15
- const fileFor = (memoryDir: string, pid: number, signature: string) => join(memoryDir, "readers", `${pid}-${signature}.json`);
16
+ type LeaseScope = "legacy" | "shard";
17
+ const leaseFolder = (scope: LeaseScope) => scope === "shard" ? "shard-readers" : "readers";
18
+ const fileFor = (memoryDir: string, pid: number, signature: string, scope: LeaseScope) => join(memoryDir, leaseFolder(scope), `${pid}-${signature}.json`);
16
19
  const record = (pid: number, signature: string): Lease => ({ schema: READER_LEASE_SCHEMA, pid, signature, renewedAt: new Date().toISOString() });
17
20
 
18
21
  function withFence<T>(memoryDir: string, work: () => T, busyTimeoutMs = 3000): T {
@@ -20,27 +23,27 @@ function withFence<T>(memoryDir: string, work: () => T, busyTimeoutMs = 3000): T
20
23
  try { return db.transaction(work).immediate(); } finally { db.close(); }
21
24
  }
22
25
 
23
- function checkReadersDir(memoryDir: string): string {
24
- const dir = join(memoryDir, "readers");
26
+ function checkReadersDir(memoryDir: string, scope: LeaseScope): string {
27
+ const dir = join(memoryDir, leaseFolder(scope));
25
28
  const stat = lstatSync(dir, { throwIfNoEntry: false });
26
29
  if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) throw new Error("reader lease directory is not a regular directory");
27
30
  return dir;
28
31
  }
29
32
 
30
- function readLease(memoryDir: string, file: string): Lease | undefined {
33
+ function readLease(memoryDir: string, file: string, scope: LeaseScope): Lease | undefined {
31
34
  try {
32
35
  const value = readProfileJSON(file) as Partial<Lease> | undefined;
33
36
  if (value === undefined) return undefined;
34
37
  if (!value || value.schema !== READER_LEASE_SCHEMA || !Number.isSafeInteger(value.pid) || value.pid! <= 0
35
38
  || typeof value.signature !== "string" || !/^[0-9a-f]{64}$/.test(value.signature)
36
- || fileFor(memoryDir, value.pid!, value.signature) !== file || typeof value.renewedAt !== "string" || Number.isNaN(Date.parse(value.renewedAt))) throw new Error();
39
+ || fileFor(memoryDir, value.pid!, value.signature, scope) !== file || typeof value.renewedAt !== "string" || Number.isNaN(Date.parse(value.renewedAt))) throw new Error();
37
40
  return value as Lease;
38
41
  } catch { throw new Error("reader lease is unreadable or unverifiable"); }
39
42
  }
40
43
 
41
- function ownedLease(memoryDir: string, file: string, signature: string): void {
42
- checkReadersDir(memoryDir);
43
- const lease = readLease(memoryDir, file);
44
+ function ownedLease(memoryDir: string, file: string, signature: string, scope: LeaseScope): void {
45
+ checkReadersDir(memoryDir, scope);
46
+ const lease = readLease(memoryDir, file, scope);
44
47
  if (!lease || lease.pid !== process.pid || lease.signature !== signature) throw new Error("reader lease ownership is unavailable");
45
48
  }
46
49
 
@@ -48,12 +51,17 @@ function admitReader(memoryDir: string): void {
48
51
  // The profile lock is created under the same reader admission fence, then held across migration awaits.
49
52
  // Even a malformed or stale marker refuses admission; only the profile-lock owner may reclaim it.
50
53
  try {
54
+ waitForPublicPruneWriter(memoryDir);
51
55
  if (lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume");
52
56
  } catch { throw new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume"); }
53
57
  }
54
58
 
55
- /** One lease per process, reference-counted across handles; an unref'd timer does not keep the process resident. */
56
- export function registerReaderLease(memoryDir: string, options: { busyTimeoutMs?: number; signature?: string } = {}): ReaderLease {
59
+ /** One lease per process/home/namespace, reference-counted across handles.
60
+ * Shard scope shares root admission and native identity; it grants no authority.
61
+ * An unref'd timer does not keep the process resident. */
62
+ export function registerReaderLease(memoryDir: string, options: { busyTimeoutMs?: number; signature?: string; scope?: "shard" } = {}): ReaderLease {
63
+ if (options.scope !== undefined && options.scope !== "shard") throw new Error("invalid reader lease scope");
64
+ const scope: LeaseScope = options.scope === "shard" ? "shard" : "legacy";
57
65
  const requestedTimeout = options.busyTimeoutMs ?? 3000;
58
66
  if (!Number.isSafeInteger(requestedTimeout) || requestedTimeout < 0 || requestedTimeout > 3000) throw new Error("invalid reader admission busy timeout");
59
67
  if (options.signature !== undefined && (typeof options.signature !== "string" || !/^[a-f0-9]{64}$/.test(options.signature))) throw new Error("invalid reader lease identity");
@@ -65,28 +73,28 @@ export function registerReaderLease(memoryDir: string, options: { busyTimeoutMs?
65
73
  // Host-only: the supplied hash must already describe this process's pinned native ps identity.
66
74
  const signature = inherited ?? options.signature ?? processSignature(pid);
67
75
  if (!signature) throw new Error("cannot establish reader lease identity");
68
- const file = fileFor(memoryDir, process.pid, signature);
76
+ const file = fileFor(memoryDir, process.pid, signature, scope);
69
77
  const busyTimeoutMs = open.get(file)?.busyTimeoutMs ?? requestedTimeout;
70
78
  mkdirSync(memoryDir, { recursive: true, mode: 0o700 });
71
79
  withFence(memoryDir, () => {
72
80
  admitReader(memoryDir);
73
81
  const current = open.get(file);
74
82
  if (current) {
75
- ownedLease(memoryDir, file, signature);
83
+ ownedLease(memoryDir, file, signature, scope);
76
84
  current.refs++;
77
85
  return;
78
86
  }
79
- const dir = checkReadersDir(memoryDir);
87
+ const dir = checkReadersDir(memoryDir, scope);
80
88
  mkdirSync(dir, { recursive: true, mode: 0o700 });
81
89
  // A failed earlier close may have retained evidence. Never overwrite an unverifiable replacement.
82
- const existing = readLease(memoryDir, file);
90
+ const existing = readLease(memoryDir, file, scope);
83
91
  if (existing && (existing.pid !== process.pid || existing.signature !== signature)) throw new Error("reader lease ownership is unavailable");
84
92
  writeProfileJSON(file, record(process.pid, signature));
85
93
  const timer = setInterval(() => {
86
94
  if (!open.has(file)) return;
87
95
  try {
88
96
  withFence(memoryDir, () => {
89
- ownedLease(memoryDir, file, signature);
97
+ ownedLease(memoryDir, file, signature, scope);
90
98
  writeProfileJSON(file, record(process.pid, signature));
91
99
  }, busyTimeoutMs);
92
100
  } catch { /* keep unknown evidence; migration admission remains fail-closed */ }
@@ -107,7 +115,7 @@ export function registerReaderLease(memoryDir: string, options: { busyTimeoutMs?
107
115
  open.delete(file);
108
116
  try {
109
117
  withFence(memoryDir, () => {
110
- ownedLease(memoryDir, file, signature);
118
+ ownedLease(memoryDir, file, signature, scope);
111
119
  rmSync(file);
112
120
  }, held.busyTimeoutMs);
113
121
  } catch { /* Contention or replacement keeps evidence; never remove another actor's lease. */ }
@@ -118,30 +126,34 @@ export function registerReaderLease(memoryDir: string, options: { busyTimeoutMs?
118
126
  // The held lifetime lease blocks legitimate migration. Verification reads only: no ps, DB fence or refcount mutation.
119
127
  // ponytail: no atomic same-user concurrent path-swap claim; descriptor-relative ownership checks are the upgrade path.
120
128
  admitReader(memoryDir);
121
- ownedLease(memoryDir, file, signature);
129
+ ownedLease(memoryDir, file, signature, scope);
122
130
  },
123
131
  });
124
132
  }
125
133
 
126
- /** Fail closed on a live or unverifiable lease; remove only a pid confirmed gone, never by TTL. */
127
- export function assertNoLiveReaders(memoryDir: string, options: { onDeadLeaseRemoved?: (event: { pid: number; lease: string }) => void } = {}): { removed: number } {
134
+ /** Default migration checks legacy readers only; v2 activation opts into both
135
+ * namespaces under the same root admission fence. Scope is cooperation, not authentication. */
136
+ export function assertNoLiveReaders(memoryDir: string, options: { includeShards?: boolean; onDeadLeaseRemoved?: (event: { pid: number; lease: string }) => void } = {}): { removed: number } {
128
137
  return withFence(memoryDir, () => {
129
- const dir = checkReadersDir(memoryDir);
130
- let names: string[] = [];
131
- try { names = readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); }
132
- catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { removed: 0 }; throw new Error("reader lease directory is unreadable"); }
133
138
  let removed = 0;
134
- for (const name of names) {
135
- if (!/^[1-9][0-9]*-[0-9a-f]{64}\.json$/.test(name)) throw new Error("unverifiable reader lease filename");
136
- const file = join(dir, name);
137
- const lease = readLease(memoryDir, file);
138
- if (!lease) throw new Error("reader lease is unreadable or unverifiable");
139
- const liveness = processLiveness(lease.pid, lease.signature);
140
- if (liveness === "live") throw new Error(`legacy reader is live (pid ${lease.pid}); close it before migration`);
141
- if (liveness === "unknown") throw new Error(`legacy reader liveness is unknown (pid ${lease.pid})`);
142
- rmSync(file);
143
- removed++;
144
- options.onDeadLeaseRemoved?.({ pid: lease.pid, lease: name });
139
+ const scopes: LeaseScope[] = options.includeShards === true ? ["legacy", "shard"] : ["legacy"];
140
+ for (const scope of scopes) {
141
+ const dir = checkReadersDir(memoryDir, scope);
142
+ let names: string[] = [];
143
+ try { names = readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); }
144
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw new Error("reader lease directory is unreadable"); }
145
+ for (const name of names) {
146
+ if (!/^[1-9][0-9]*-[0-9a-f]{64}\.json$/.test(name)) throw new Error("unverifiable reader lease filename");
147
+ const file = join(dir, name);
148
+ const lease = readLease(memoryDir, file, scope);
149
+ if (!lease) throw new Error("reader lease is unreadable or unverifiable");
150
+ const liveness = processLiveness(lease.pid, lease.signature);
151
+ if (liveness === "live") throw new Error(`legacy reader is live (pid ${lease.pid}); close it before migration`);
152
+ if (liveness === "unknown") throw new Error(`legacy reader liveness is unknown (pid ${lease.pid})`);
153
+ rmSync(file);
154
+ removed++;
155
+ options.onDeadLeaseRemoved?.({ pid: lease.pid, lease: name });
156
+ }
145
157
  }
146
158
  return { removed };
147
159
  });
@@ -8,7 +8,7 @@ import { pruneId, prunePayloadHash, reducePruneRows, validatePruneRow, type Prun
8
8
  import { acquireMutation } from "./mutation.ts";
9
9
  import { canonicalPath, realPath } from "./platform.ts";
10
10
  import { assertNoLiveReaders } from "./reader-lease.ts";
11
- import { assertCuratedShardPaths, ensureShardDescriptor, shardDir, SHARDED_SCHEMA, userShardDir, SHARD_SCHEMA } from "./shard.ts";
11
+ import { assertCuratedShardPaths, ensureShardDescriptor, shardDir, SHARDED_SCHEMA, SHARDED_V2_SCHEMA, userShardDir, SHARD_SCHEMA } from "./shard.ts";
12
12
  import { buildGenerationIndex, type Entry } from "./store.ts";
13
13
  import { readMigrationSources, type SourcePrefix } from "./migrate.ts";
14
14
  import { label, PLAIN, singleLine, type Style } from "./style.ts";
@@ -19,7 +19,7 @@ const JOURNAL = "shards.json";
19
19
  const CONSEQUENCE = "Legacy bytes are never rewritten; unannounced older readers cannot be detected and remain safe only because of that rule. Before splitting a live home, ensure every installed host uses openShardStore; ordinary new MemoryStore opens are refused once the shard marker exists. No host capability registry is available to prove an absent host safe.";
20
20
 
21
21
  interface ShardJournal {
22
- schema: typeof SHARDED_SCHEMA;
22
+ schema: typeof SHARDED_SCHEMA | typeof SHARDED_V2_SCHEMA;
23
23
  generation: string;
24
24
  sourceGeneration?: string;
25
25
  phase: "captured" | "active";
@@ -67,7 +67,7 @@ function readJournal(memoryDir: string, optional = false): ShardJournal | undefi
67
67
  if (optional) return undefined;
68
68
  throw new Error("no resumable shard migration journal");
69
69
  }
70
- if (value.schema !== SHARDED_SCHEMA || !validGeneration(value.generation) || !["captured", "active"].includes(value.phase ?? "")
70
+ if ((value.schema !== SHARDED_SCHEMA && value.schema !== SHARDED_V2_SCHEMA) || !validGeneration(value.generation) || !["captured", "active"].includes(value.phase ?? "")
71
71
  || value.sourceGeneration !== undefined && (!validGeneration(value.sourceGeneration) || value.sourceGeneration !== value.generation)
72
72
  || typeof value.selectorRevision !== "string" || !Array.isArray(value.sources) || !value.machine
73
73
  || typeof value.machine.id !== "string" || typeof value.machine.label !== "string") throw new Error("invalid shard migration journal");
@@ -503,6 +503,7 @@ function preflight(memoryDir: string, options: ShardMigrationOptions, chosen?: s
503
503
  }
504
504
 
505
505
  export async function migrateShards(options: ShardMigrationOptions): Promise<ShardMigrationResult> {
506
+ if (readSelector(options.memoryDir).selector?.schema === "agent-mem.profile/v2") throw new Error("public prune view is forward-only; use migrate --public-prune-view --resume");
506
507
  if (options.authorizedPerson !== true || options.tty !== true) throw new Error("shard migration requires an authorized person in a terminal");
507
508
  if (!["dry-run", "apply", "resume"].includes(options.mode) || options.generation !== undefined && !validGeneration(options.generation)) throw new Error("invalid shard migration request");
508
509
  const memoryDir = existsSync(options.memoryDir) ? realPath(options.memoryDir) : canonicalPath(options.memoryDir);
@@ -3,11 +3,15 @@ import { legacyEntries } from "./bridge.ts";
3
3
  import { canonicalJSON, validGeneration } from "./generation.ts";
4
4
  import { validatePruneRow, type PruneRow } from "./prune.ts";
5
5
  import { shardKeyHash } from "./shard.ts";
6
+ import { validatePublicPruneWitness, type PublicPruneWitnessV1 } from "./public-prune-witness.ts";
6
7
  import type { Entry } from "./store.ts";
7
8
 
8
9
  export type ShardSyncRealm = { scope: "project"; key: string } | { scope: "user" };
9
10
  export type ShardSyncRow = Entry | PruneRow;
10
11
  export const SHARD_SYNC_SCHEMA = "agent-mem.shard-sync/v1";
12
+ export const SHARD_SYNC_V2_SCHEMA = "agent-mem.shard-sync/v2";
13
+ const METADATA_MAX_BYTES = 65_536;
14
+ const MANIFEST = /^prune-witnesses\/([a-f0-9]{64})\.json$/;
11
15
  export const SHARD_PACK_MAX_BYTES = 1024 * 1024;
12
16
  const PACK = /^rows\/([a-f0-9]{64})\/([a-f0-9]{64})\.jsonl$/;
13
17
  const hash = (value: string | Uint8Array): string => createHash("sha256").update(value).digest("hex");
@@ -28,9 +32,43 @@ export function shardSyncRef(realm: ShardSyncRealm): string {
28
32
  checkRealm(realm);
29
33
  return realm.scope === "user" ? "refs/heads/amem/u" : `refs/heads/amem/p/${shardKeyHash(realm.key)}`;
30
34
  }
31
- export function shardSyncDescriptor(realm: ShardSyncRealm): string {
35
+ export function shardSyncDescriptor(realm: ShardSyncRealm, version: "v1" | "v2" = "v1"): string {
32
36
  checkRealm(realm);
33
- return canonicalJSON(realm.scope === "user" ? { schema: SHARD_SYNC_SCHEMA, scope: "user" } : { schema: SHARD_SYNC_SCHEMA, scope: "project", key: realm.key }) + "\n";
37
+ if (version !== "v1" && version !== "v2") throw new ShardSyncFormatError("shard", "unsupported descriptor version");
38
+ const schema = version === "v1" ? SHARD_SYNC_SCHEMA : SHARD_SYNC_V2_SCHEMA;
39
+ return canonicalJSON(realm.scope === "user" ? { schema, scope: "user" } : { schema, scope: "project", key: realm.key }) + "\n";
40
+ }
41
+
42
+ /** Closed canonical metadata, including its single complete newline terminator. */
43
+ function decodeMetadata(bytes: Uint8Array, label: string): Record<string, unknown> {
44
+ try {
45
+ if (!bytes.byteLength || bytes.byteLength > METADATA_MAX_BYTES) throw new Error("bound");
46
+ const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes), value: unknown = JSON.parse(text);
47
+ if (!value || typeof value !== "object" || Array.isArray(value) || canonicalJSON(value) + "\n" !== text) throw new Error("canonical");
48
+ return value as Record<string, unknown>;
49
+ } catch { throw new ShardSyncFormatError(label, "invalid canonical metadata or bound"); }
50
+ }
51
+ export function decodeShardDescriptor(bytes: Uint8Array, realm: ShardSyncRealm): "v1" | "v2" {
52
+ const label = shardSyncLabel(realm), value = decodeMetadata(bytes, label);
53
+ const version = value.schema === SHARD_SYNC_SCHEMA ? "v1" : value.schema === SHARD_SYNC_V2_SCHEMA ? "v2" : undefined;
54
+ if (!version || canonicalJSON(value) + "\n" !== shardSyncDescriptor(realm, version)) throw new ShardSyncFormatError(label, "descriptor realm or schema mismatch");
55
+ return version;
56
+ }
57
+ export function isShardManifestPath(path: string): boolean { return MANIFEST.exec(path)?.[0] === path; }
58
+ /** Shape and lineage classification belong to Layer 1 after complete pack acquisition. */
59
+ export function decodeShardManifest(path: string, bytes: Uint8Array): unknown {
60
+ const match = MANIFEST.exec(path), label = "shard manifest";
61
+ if (!match || match[0] !== path) throw new ShardSyncFormatError(label, "invalid manifest path");
62
+ const value = decodeMetadata(bytes, label);
63
+ if (value.schema !== "agent-mem.public-prune-witness/v1" || hash(canonicalJSON(value)) !== match[1]) throw new ShardSyncFormatError(label, "invalid manifest schema or hash");
64
+ return value;
65
+ }
66
+ export function encodeShardManifest(manifest: PublicPruneWitnessV1): { path: string; bytes: Buffer } {
67
+ try {
68
+ const value = validatePublicPruneWitness(manifest), body = canonicalJSON(value), bytes = Buffer.from(body + "\n");
69
+ if (bytes.byteLength > METADATA_MAX_BYTES) throw new Error("bound");
70
+ return { path: `prune-witnesses/${hash(body)}.json`, bytes };
71
+ } catch { throw new ShardSyncFormatError("shard manifest", "invalid supported manifest or bound"); }
34
72
  }
35
73
 
36
74
  /** Only row shape/public placement here. The sync engine must prove target/basis realm ownership across the full source set. */