@staix/agent-mem 0.20.2 → 0.21.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 +4 -3
- package/package.json +2 -2
- package/src/cli.ts +8 -1
- package/src/commands-table.ts +2 -1
- package/src/hook-spool.ts +45 -3
- package/src/index.ts +25 -2
- package/src/memory/bridge.ts +66 -18
- package/src/memory/candidates.ts +71 -9
- 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-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 +67 -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-placement-active.ts +75 -0
- package/src/memory/sync-placement-cache.ts +32 -0
- package/src/memory/sync-placement-capture.ts +292 -0
- package/src/memory/sync-placement-dependencies.ts +255 -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 +98 -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 +236 -0
- package/src/memory/sync-placement-journal-format.ts +114 -0
- package/src/memory/sync-placement-journal-storage.ts +513 -0
- package/src/memory/sync-placement-journal.ts +153 -0
- package/src/memory/sync-placement-materialize.ts +149 -0
- package/src/memory/sync-placement-plan.ts +171 -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 +489 -0
- package/src/memory/sync-policy-inspection.ts +126 -0
- package/src/memory/sync-screen.ts +128 -33
- package/src/memory/sync.ts +20 -64
- package/src/setup.ts +69 -14
- 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,236 @@
|
|
|
1
|
+
/** IDs-only current placement decisions and separately verified historical exposure. No effects or network. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { lstatSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { types } from "node:util";
|
|
6
|
+
import { SegmentReadBudgetError } from "./bridge.ts";
|
|
7
|
+
import { canonicalJSON, hashJSON, selectorRevision } from "./generation.ts";
|
|
8
|
+
import { assertFormatFloor, FormatFloorError } from "./formats.ts";
|
|
9
|
+
import { readPlacementRuntimeMarkers, SyncPlacementRuntimeError } from "./sync-placement-guard.ts";
|
|
10
|
+
import { readActivePlacementBinding } from "./sync-placement-active.ts";
|
|
11
|
+
import { PLACEMENT_FORMAT } from "./sync-placement-format.ts";
|
|
12
|
+
import { captureSyncPlacementContext, type PlacementCapturePort } from "./sync-placement-capture.ts";
|
|
13
|
+
import { placementKeyId, SyncPlacementJournalError, type PlacementKey, type PlacementHoldReason } from "./sync-placement-journal-format.ts";
|
|
14
|
+
import { materializePlacementRows, verifiedPlacementPrunes } from "./sync-placement-materialize.ts";
|
|
15
|
+
import { placementCacheRootStamp } from "./sync-placement-cache.ts";
|
|
16
|
+
import { captureInspectionJournal, inspectRetainedPublicationHistories, PlacementInspectionObjects, type InspectionExposureProof } from "./sync-placement-inspection-evidence.ts";
|
|
17
|
+
import { outboundPolicyEvaluator, type RowScreen, type SyncPolicyCategory } from "./sync-screen.ts";
|
|
18
|
+
import { explicitExpirySnapshot } from "./generation-index.ts";
|
|
19
|
+
import { reducePruneRows, type PruneRow } from "./prune.ts";
|
|
20
|
+
import { readPublicPruneViewForActivation } from "./public-prune-view.ts";
|
|
21
|
+
import { reducePublicPruneInputs } from "./public-prune-inputs.ts";
|
|
22
|
+
import { inspectPublisherPruneLineages } from "./prune-publisher-lineage.ts";
|
|
23
|
+
import type { PublicPruneLineage } from "./public-prune-witness.ts";
|
|
24
|
+
import type { PlacementPublicationDecisionReason } from "./sync-placement-publication-plan.ts";
|
|
25
|
+
import type { ShardSyncRealm, ShardSyncRow } from "./shard-sync-format.ts";
|
|
26
|
+
import type { MemoryStore } from "./store.ts";
|
|
27
|
+
import { captureInspectionPendingPrunes } from "./sync-placement-inspection-pending.ts";
|
|
28
|
+
|
|
29
|
+
export interface SyncPlacementInspectionOptions {
|
|
30
|
+
screen?: RowScreen;
|
|
31
|
+
policyVersion?: () => string;
|
|
32
|
+
/** Same fresh writer-pin binding as v3 sync; this read-only port grants no effect or activation authority. */
|
|
33
|
+
placementAttestation?: PlacementCapturePort["attest"];
|
|
34
|
+
}
|
|
35
|
+
export type SyncPlacementDisposition = "eligible" | "policy-local" | "person-local" | "held";
|
|
36
|
+
export type SyncPlacementExposureEvidence = InspectionExposureProof;
|
|
37
|
+
export type SyncPlacementExposure = { certainty: "verified-published"; evidence: SyncPlacementExposureEvidence[] }
|
|
38
|
+
| { certainty: "not-observed" | "unknown" };
|
|
39
|
+
export interface SyncPlacementInspectionRow extends PlacementKey {
|
|
40
|
+
sourcePlacement: "local" | "synced";
|
|
41
|
+
disposition: SyncPlacementDisposition;
|
|
42
|
+
reason: PlacementPublicationDecisionReason;
|
|
43
|
+
categories: SyncPolicyCategory[];
|
|
44
|
+
holdReasons: PlacementHoldReason[];
|
|
45
|
+
exposure: SyncPlacementExposure;
|
|
46
|
+
}
|
|
47
|
+
export interface SyncPlacementIntrinsicLocalRow extends PlacementKey {
|
|
48
|
+
sourcePlacement: "local";
|
|
49
|
+
reason: "intrinsic-local";
|
|
50
|
+
categories: SyncPolicyCategory[];
|
|
51
|
+
holdReasons: PlacementHoldReason[];
|
|
52
|
+
exposure: SyncPlacementExposure;
|
|
53
|
+
}
|
|
54
|
+
export type SyncPlacementInspectionIncompleteReason = "placement-not-active" | "binding-changed" | "input-unavailable" | "input-bound" | "input-changed"
|
|
55
|
+
| "journal-unavailable" | "policy-unavailable" | "attestation-unavailable" | "evidence-unavailable" | "reader-upgrade-required";
|
|
56
|
+
export type SyncPlacementInspection = {
|
|
57
|
+
complete: true; rows: SyncPlacementInspectionRow[]; intrinsicLocal: SyncPlacementIntrinsicLocalRow[]; fingerprint: string;
|
|
58
|
+
counts: { selected: number; rows: number; intrinsicLocal: number; eligible: number; policyLocal: number; personLocal: number; held: number; verifiedPublished: number; notObserved: number; unknown: number };
|
|
59
|
+
} | { complete: false; reason: SyncPlacementInspectionIncompleteReason; nextStep: string };
|
|
60
|
+
|
|
61
|
+
const categories: readonly SyncPolicyCategory[] = ["secret", "personal-data", "deny-path", "private-or-invalid", "host"];
|
|
62
|
+
const remedies: Record<SyncPlacementInspectionIncompleteReason, string> = {
|
|
63
|
+
"placement-not-active": "Placement is not active; complete the person-reviewed shard/public-prune migrations and separately authorized placement activation before inspecting placement.",
|
|
64
|
+
"binding-changed": "Selected placement bindings changed; inspect the retained activation to restore matching markers when required, then reopen the selected project/user handles and inspect again.",
|
|
65
|
+
"input-unavailable": "Selected source or placement-context inputs are unreadable, incomplete or unsupported; inspect retained source metadata and any recorded partial request, verify compatible writer pins, then inspect again.",
|
|
66
|
+
"input-bound": "Placement inspection exceeds its supported 8 MiB/20000-row or retained-evidence bounds; request a person-reviewed bounded epoch transition before inspecting again.",
|
|
67
|
+
"input-changed": "The selected placement inspection context changed; wait for stable source, journal and policy bindings, then inspect again.",
|
|
68
|
+
"journal-unavailable": "The retained placement journal is incomplete or inconsistent; preserve it and resume its exact recorded request with a compatible runtime before inspecting again.",
|
|
69
|
+
"policy-unavailable": "The policy callback/version or configured/default-deny epoch is unavailable or changed; restore a stable policy and policyVersion before inspecting again.",
|
|
70
|
+
"attestation-unavailable": "Fresh placement writer-pin/quiescence attestation is missing or invalid; supply the matching placementAttestation callback and inspect again.",
|
|
71
|
+
"evidence-unavailable": "Retained publication evidence is unreadable or inconsistent; check the private cache's ownership, permissions, links and exact objects, restore compatible retained evidence, then use a separately authorized sync to reverify before inspecting again.",
|
|
72
|
+
"reader-upgrade-required": "The configured sync format floor refuses the loaded reader; install a compatible newer runtime, reopen the selected handles and inspect again.",
|
|
73
|
+
};
|
|
74
|
+
class Incomplete extends Error {
|
|
75
|
+
constructor(readonly reason: SyncPlacementInspectionIncompleteReason) { super(remedies[reason]); }
|
|
76
|
+
}
|
|
77
|
+
function fail(reason: SyncPlacementInspectionIncompleteReason): never { throw new Incomplete(reason); }
|
|
78
|
+
const digest = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
79
|
+
function physical(realm: ShardSyncRealm): PlacementKey["realm"] { return realm.scope === "user" ? { scope: "user" } : { scope: "project", keyHash: digest(realm.key) }; }
|
|
80
|
+
function keyId(key: PlacementKey): string { return placementKeyId({ realm: key.realm, id: key.id, rowHash: key.rowHash }); }
|
|
81
|
+
function callbacks(input: SyncPlacementInspectionOptions): SyncPlacementInspectionOptions {
|
|
82
|
+
if (!input || typeof input !== "object" || types.isProxy(input) || ![Object.prototype, null].includes(Object.getPrototypeOf(input))) fail("policy-unavailable");
|
|
83
|
+
const fields = Object.getOwnPropertyDescriptors(input);
|
|
84
|
+
const read = <K extends keyof SyncPlacementInspectionOptions>(key: K): SyncPlacementInspectionOptions[K] => {
|
|
85
|
+
const field = fields[key]; if (!field) return undefined;
|
|
86
|
+
if (!("value" in field) || !field.enumerable || field.value !== undefined && typeof field.value !== "function") fail("policy-unavailable");
|
|
87
|
+
return field.value as SyncPlacementInspectionOptions[K];
|
|
88
|
+
};
|
|
89
|
+
const result = { screen: read("screen"), policyVersion: read("policyVersion"), placementAttestation: read("placementAttestation") };
|
|
90
|
+
if (result.screen && !result.policyVersion) fail("policy-unavailable"); return result;
|
|
91
|
+
}
|
|
92
|
+
function inactive(store: MemoryStore): Extract<SyncPlacementInspection, { complete: false }> {
|
|
93
|
+
const steps: string[] = [];
|
|
94
|
+
if (!store.profile.sharded) steps.push("Have the person run amem migrate --shards --dry-run, review the plan, then amem migrate --shards --apply.");
|
|
95
|
+
if (!store.profile.foreignPruneFormat) steps.push("Have the person run amem migrate --public-prune-view --dry-run, review the plan, then amem migrate --public-prune-view --apply.");
|
|
96
|
+
steps.push("Complete separately person-authorized placement activation with compatible exact writer pins; this stage provides no placement activation command or marker writer.");
|
|
97
|
+
return { complete: false, reason: "placement-not-active", nextStep: "Placement is not active. " + steps.join(" ") };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function inspectSyncPlacement(store: MemoryStore, input: SyncPlacementInspectionOptions = {}): Promise<SyncPlacementInspection> {
|
|
101
|
+
let phase: SyncPlacementInspectionIncompleteReason = "input-unavailable";
|
|
102
|
+
let captured = false;
|
|
103
|
+
try {
|
|
104
|
+
const options = callbacks(input), home = store.profile.home;
|
|
105
|
+
assertFormatFloor(home, "sync");
|
|
106
|
+
const opened = [...store.openedSyncTargets()];
|
|
107
|
+
const binding = () => hashJSON(store.openedSyncTargets().map(target => ({ profile: target.profile, machine: target.machine,
|
|
108
|
+
selector: selectorRevision(target.profile.home) })));
|
|
109
|
+
if (opened.some(target => selectorRevision(target.profile.home) !== target.profile.selectorRevision)) fail("binding-changed");
|
|
110
|
+
const beforeBinding = binding();
|
|
111
|
+
if (store.profile.publicationFormat !== PLACEMENT_FORMAT) return inactive(store);
|
|
112
|
+
if (!options.placementAttestation) fail("attestation-unavailable");
|
|
113
|
+
if (!opened.length || opened.length > 2 || !opened.includes(store) || opened.some(target => target.profile.home !== home || !target.profile.sharded
|
|
114
|
+
|| !target.profile.generation || !target.profile.shardUser && target.profile.shardKey !== store.profile.shardKey)) fail("binding-changed");
|
|
115
|
+
const markers = readPlacementRuntimeMarkers(home), active = readActivePlacementBinding(home, markers.profile, markers.shards);
|
|
116
|
+
if (!active || active.profile.placementHead !== store.profile.placementHead) fail("binding-changed");
|
|
117
|
+
const targets = opened.map(target => ({ store: target, realm: target.profile.shardUser ? { scope: "user" as const } : { scope: "project" as const, key: target.profile.shardKey! }, generation: target.profile.generation! }));
|
|
118
|
+
const pendingChecks = targets.map(target => captureInspectionPendingPrunes(target.store.dir));
|
|
119
|
+
const port: PlacementCapturePort = { attest: options.placementAttestation, ...(options.policyVersion ? { policyVersion: options.policyVersion } : {}) };
|
|
120
|
+
const capture = captureSyncPlacementContext(home, port, targets.map(target => physical(target.realm)));
|
|
121
|
+
captured = true;
|
|
122
|
+
if (capture.activeHead !== active.profile.placementHead || canonicalJSON(capture.context.writers) !== canonicalJSON(active.activation.writers)) fail("attestation-unavailable");
|
|
123
|
+
phase = "journal-unavailable"; const retained = captureInspectionJournal(home, active.profile.placementHead);
|
|
124
|
+
const check = () => {
|
|
125
|
+
if (binding() !== beforeBinding) fail("binding-changed"); retained.assertCurrent(); capture.assertUnchanged();
|
|
126
|
+
if (binding() !== beforeBinding) fail("binding-changed");
|
|
127
|
+
};
|
|
128
|
+
phase = "evidence-unavailable";
|
|
129
|
+
const cacheRoot = join(home, "shard-sync-placement"), rootStamp = lstatSync(cacheRoot, { throwIfNoEntry: false }) ? placementCacheRootStamp(home) : undefined;
|
|
130
|
+
const cache = join(cacheRoot, "objects.git"), cacheAbsent = !lstatSync(cache, { throwIfNoEntry: false });
|
|
131
|
+
const objects = cacheAbsent ? undefined : new PlacementInspectionObjects(home, check);
|
|
132
|
+
const histories = await inspectRetainedPublicationHistories(retained, targets, objects);
|
|
133
|
+
const cached = targets.map(target => objects?.ref(target.realm));
|
|
134
|
+
const view = readPublicPruneViewForActivation(home, active.profile.pruneInputSetHash, active.profile.generation);
|
|
135
|
+
const foreignChecks: (() => void)[] = [];
|
|
136
|
+
phase = "input-unavailable";
|
|
137
|
+
const plannedTargets = targets.map((target, index) => {
|
|
138
|
+
const realm = physical(target.realm), id = canonicalJSON(realm), sources = capture.sourceRowsByRealm.get(id);
|
|
139
|
+
if (!sources) fail("binding-changed");
|
|
140
|
+
const prunes = [...capture.specialRows.values()].filter(source => canonicalJSON(source.key.realm) === id && source.row.op === "prune");
|
|
141
|
+
const reduced = reducePruneRows(prunes.map(source => ({ row: source.row, placement: source.sourcePlacement })), { rows: sources, expiries: explicitExpirySnapshot(sources) }, target.generation);
|
|
142
|
+
if (reduced.invalid || reduced.pending.length) fail("input-unavailable");
|
|
143
|
+
const inputSetHash = view.realms[target.realm.scope === "user" ? "user" : `project:${target.realm.key}`];
|
|
144
|
+
let pruned = reduced.targets;
|
|
145
|
+
if (inputSetHash) {
|
|
146
|
+
const replay = () => reducePublicPruneInputs({ dir: target.store.dir, realm: target.realm, receivingGeneration: target.generation, inputSetHash,
|
|
147
|
+
publicationFormat: PLACEMENT_FORMAT, nativeTargets: [...reduced.targets], ordinary: sources.map(source => source.row) });
|
|
148
|
+
const before = replay(); pruned = before.targets;
|
|
149
|
+
const identity = (value: ReturnType<typeof replay>) => hashJSON({ targets: [...value.targets].sort(), rows: [...value.rowHashes].sort(), inputs: value.inputs, commitments: value.commitments });
|
|
150
|
+
const fingerprint = identity(before); foreignChecks.push(() => { if (identity(replay()) !== fingerprint) fail("input-changed"); });
|
|
151
|
+
}
|
|
152
|
+
const localPrunes = new Map(prunes.filter(source => source.sourcePlacement === "synced").map(source => [source.key.id, source.row as PruneRow]));
|
|
153
|
+
return { realm: target.realm, generation: target.generation, remote: histories.tips[index], local: cached[index], fetched: new Set(histories.old[index]?.rows.keys()), localPrunes, pruned };
|
|
154
|
+
});
|
|
155
|
+
const lineages = targets.map((target, index) => {
|
|
156
|
+
const rows = new Map<string, ShardSyncRow>(histories.old[index]?.rows);
|
|
157
|
+
for (const source of [...capture.rows.values(), ...capture.specialRows.values()]) if (canonicalJSON(source.key.realm) === canonicalJSON(physical(target.realm)) && source.row.op !== "vault_link") {
|
|
158
|
+
const prior = rows.get(source.key.id); if (prior && hashJSON(prior) !== source.key.rowHash) fail("input-unavailable"); rows.set(source.key.id, source.row);
|
|
159
|
+
}
|
|
160
|
+
const candidates = [...rows.values()].filter((row): row is PruneRow => row.op === "prune" && row.generation === target.generation);
|
|
161
|
+
const boundary = join(target.store.dir, "local", `publisher-boundary-${target.store.machine.id}.json`);
|
|
162
|
+
const proof = lstatSync(boundary, { throwIfNoEntry: false }) ? inspectPublisherPruneLineages(target.store.profile, candidates) : new Map<string, PublicPruneLineage>();
|
|
163
|
+
return { rows, proof };
|
|
164
|
+
});
|
|
165
|
+
const verified = verifiedPlacementPrunes(targets.map((target, index) => ({ realm: target.realm, generation: target.generation, rows: lineages[index]!.rows,
|
|
166
|
+
fetched: plannedTargets[index]!.fetched, lineageIds: new Set<string>(lineages[index]!.proof.keys()) })));
|
|
167
|
+
const rowCategories = new Map<string, Set<SyncPolicyCategory>>();
|
|
168
|
+
phase = "policy-unavailable"; const evaluate = outboundPolicyEvaluator(home, options.screen);
|
|
169
|
+
phase = "input-unavailable";
|
|
170
|
+
const guardedEvaluate = (value: unknown): ReturnType<typeof evaluate> => {
|
|
171
|
+
check(); let verdict: ReturnType<typeof evaluate>;
|
|
172
|
+
try { verdict = evaluate(structuredClone(value) as Record<string, unknown>); } catch { return fail("policy-unavailable"); }
|
|
173
|
+
check();
|
|
174
|
+
if (!verdict.ok && value && typeof value === "object" && "id" in value && typeof value.id === "string") {
|
|
175
|
+
let known = rowCategories.get(value.id); if (!known) rowCategories.set(value.id, known = new Set()); known.add(verdict.category);
|
|
176
|
+
}
|
|
177
|
+
return verdict;
|
|
178
|
+
};
|
|
179
|
+
const materialized = await materializePlacementRows({ capture, projection: retained.projection, generation: active.profile.generation, targets: plannedTargets,
|
|
180
|
+
old: histories.old, verifiedPrunes: verified, createdUnixSeconds: Math.floor(Date.now() / 1000), evaluate: guardedEvaluate,
|
|
181
|
+
readTree: async (_index, head) => { if (!objects) fail("evidence-unavailable"); return objects.tree(head); },
|
|
182
|
+
publisherLineages: (index, rows) => inspectPublisherPruneLineages(targets[index]!.store.profile, rows) });
|
|
183
|
+
const exposureFor = (key: PlacementKey): SyncPlacementExposure => {
|
|
184
|
+
const known = histories.published.get(placementKeyId(key)), attempts = retained.projection.get(key)?.attempts;
|
|
185
|
+
return known?.length ? { certainty: "verified-published", evidence: known } : { certainty: attempts?.length ? "unknown" : "not-observed" };
|
|
186
|
+
};
|
|
187
|
+
const rows: SyncPlacementInspectionRow[] = materialized.decisions.map(decision => {
|
|
188
|
+
const state = retained.projection.get(decision.key), holdReasons = state?.reasons ?? [], own = new Set<SyncPolicyCategory>(rowCategories.get(decision.key.id));
|
|
189
|
+
for (const value of [decision.reason, ...holdReasons]) if (categories.includes(value as SyncPolicyCategory)) own.add(value as SyncPolicyCategory);
|
|
190
|
+
const disposition: SyncPlacementDisposition = decision.eligible ? "eligible" : holdReasons.includes("person-local") ? "person-local"
|
|
191
|
+
: decision.reason !== "source-local" && own.size ? "policy-local" : "held";
|
|
192
|
+
return { ...decision.key, sourcePlacement: decision.sourcePlacement, disposition, reason: decision.reason,
|
|
193
|
+
categories: [...own].sort(), holdReasons: [...holdReasons].sort(), exposure: exposureFor(decision.key) };
|
|
194
|
+
});
|
|
195
|
+
const outboundKeys = new Set(rows.map(keyId));
|
|
196
|
+
const intrinsicLocal: SyncPlacementIntrinsicLocalRow[] = [];
|
|
197
|
+
for (const source of capture.specialRows.values()) if (!outboundKeys.has(placementKeyId(source.key))) {
|
|
198
|
+
if (source.sourcePlacement !== "local") fail("input-unavailable");
|
|
199
|
+
guardedEvaluate(source.row);
|
|
200
|
+
const holdReasons = retained.projection.get(source.key)?.reasons ?? [], own = new Set<SyncPolicyCategory>(rowCategories.get(source.key.id));
|
|
201
|
+
for (const reason of holdReasons) if (categories.includes(reason as SyncPolicyCategory)) own.add(reason as SyncPolicyCategory);
|
|
202
|
+
intrinsicLocal.push({ ...source.key, sourcePlacement: "local", reason: "intrinsic-local", categories: [...own].sort(), holdReasons: [...holdReasons].sort(), exposure: exposureFor(source.key) });
|
|
203
|
+
}
|
|
204
|
+
intrinsicLocal.sort((a, b) => keyId(a).localeCompare(keyId(b)));
|
|
205
|
+
// The final recapture invokes host callbacks first; every remaining proof is passive filesystem/data validation.
|
|
206
|
+
phase = "input-changed"; capture.assertCurrent(); retained.assertCurrent(); for (const checkPending of pendingChecks) checkPending(); for (const checkForeign of foreignChecks) checkForeign(); objects?.assertRetained();
|
|
207
|
+
if (cacheAbsent && lstatSync(cache, { throwIfNoEntry: false })) fail("input-changed");
|
|
208
|
+
if (rootStamp !== undefined ? placementCacheRootStamp(home) !== rootStamp : lstatSync(cacheRoot, { throwIfNoEntry: false }) !== undefined) fail("input-changed");
|
|
209
|
+
if (binding() !== beforeBinding) fail("binding-changed");
|
|
210
|
+
assertFormatFloor(home, "sync");
|
|
211
|
+
const counts = { selected: rows.length + intrinsicLocal.length, rows: rows.length, intrinsicLocal: intrinsicLocal.length, eligible: 0, policyLocal: 0, personLocal: 0, held: 0, verifiedPublished: 0, notObserved: 0, unknown: 0 };
|
|
212
|
+
for (const row of rows) {
|
|
213
|
+
counts[row.disposition === "policy-local" ? "policyLocal" : row.disposition === "person-local" ? "personLocal" : row.disposition]++;
|
|
214
|
+
}
|
|
215
|
+
for (const row of [...rows, ...intrinsicLocal]) {
|
|
216
|
+
counts[row.exposure.certainty === "verified-published" ? "verifiedPublished" : row.exposure.certainty === "not-observed" ? "notObserved" : "unknown"]++;
|
|
217
|
+
}
|
|
218
|
+
return { complete: true, rows, intrinsicLocal, counts, fingerprint: hashJSON({ rows, intrinsicLocal }) };
|
|
219
|
+
} catch (error) {
|
|
220
|
+
let reason: SyncPlacementInspectionIncompleteReason = phase;
|
|
221
|
+
if (error && typeof error === "object" && !types.isProxy(error)) {
|
|
222
|
+
if (error instanceof Incomplete) reason = error.reason;
|
|
223
|
+
else if (error instanceof FormatFloorError) {
|
|
224
|
+
if (error.code === "format_floor") reason = "reader-upgrade-required";
|
|
225
|
+
else return { complete: false, reason: "input-unavailable", nextStep: "The sync reader-floor declaration is unreadable or unsupported; inspect and restore the person-approved declaration with a compatible runtime, then inspect again." };
|
|
226
|
+
}
|
|
227
|
+
else if (error instanceof SyncPlacementRuntimeError) reason = "binding-changed";
|
|
228
|
+
else if (error instanceof SegmentReadBudgetError) reason = "input-bound";
|
|
229
|
+
else if (error instanceof SyncPlacementJournalError) {
|
|
230
|
+
if (error.code === "bound") reason = "input-bound";
|
|
231
|
+
else if (error.code === "changed") reason = captured ? "input-changed" : "input-unavailable";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { complete: false, reason, nextStep: remedies[reason] };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { canonicalJSON, hashJSON } from "./generation.ts";
|
|
2
|
+
import { memoryPolicySnapshot } from "./policy.ts";
|
|
3
|
+
import { PLACEMENT_FORMAT } from "./sync-placement-format.ts";
|
|
4
|
+
import type { SyncPolicyCategory } from "./sync-screen.ts";
|
|
5
|
+
|
|
6
|
+
export const PLACEMENT_EPOCH_SCHEMA = "agent-mem.sync-placement-epoch/v1";
|
|
7
|
+
export const PLACEMENT_RECORD_SCHEMA = "agent-mem.sync-placement-record/v1";
|
|
8
|
+
export const PLACEMENT_ACTIVE_FENCE_SCHEMA = "agent-mem.sync-placement-fence/v1";
|
|
9
|
+
export const PLACEMENT_JOURNAL_MAX_RECORDS = 20_000, PLACEMENT_JOURNAL_MAX_OBJECTS = 256;
|
|
10
|
+
export const PLACEMENT_JOURNAL_CHUNK_MAX_BYTES = 64 * 1024, PLACEMENT_JOURNAL_ROOT_MAX_BYTES = 256 * 1024;
|
|
11
|
+
export type SyncPlacementJournalErrorCode = "invalid" | "corrupt" | "bound" | "conflict" | "pending" | "changed" | "authorization" | "policy" | "local" | "busy";
|
|
12
|
+
const messages: Record<SyncPlacementJournalErrorCode, string> = {
|
|
13
|
+
invalid: "Sync placement journal data is invalid or unsupported; inspect the retained state and use a compatible runtime.",
|
|
14
|
+
corrupt: "Sync placement journal evidence is incomplete or inconsistent; restore the exact retained evidence before continuing.",
|
|
15
|
+
bound: "Sync placement journal exceeds its supported bounds; a person-reviewed bounded epoch transition is required.",
|
|
16
|
+
conflict: "Sync placement journal identity or request conflicts with retained evidence; inspect the exact request before retrying.",
|
|
17
|
+
pending: "Sync placement journal has an unfinished transaction; resume its exact recorded request before continuing.",
|
|
18
|
+
changed: "Sync placement journal or source bindings changed; capture and review a fresh request before continuing.",
|
|
19
|
+
authorization: "Sync placement change requires explicit person authorization of the exact reviewed request.",
|
|
20
|
+
policy: "Current mandatory policy refuses this sync placement change; inspect its safe policy classification.",
|
|
21
|
+
local: "An intrinsically local source cannot be released for publication; preserve its source placement.",
|
|
22
|
+
busy: "Sync placement journal is busy or its owner is unavailable; wait for the existing transaction before retrying.",
|
|
23
|
+
};
|
|
24
|
+
export class SyncPlacementJournalError extends Error {
|
|
25
|
+
readonly code: SyncPlacementJournalErrorCode;
|
|
26
|
+
constructor(code: SyncPlacementJournalErrorCode = "invalid") {
|
|
27
|
+
const safe = typeof code === "string" && Object.hasOwn(messages, code) ? code : "invalid";
|
|
28
|
+
super(messages[safe]); this.code = safe; this.name = "SyncPlacementJournalError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export interface PlacementEpoch {
|
|
32
|
+
schema: typeof PLACEMENT_EPOCH_SCHEMA; publicationFormat: typeof PLACEMENT_FORMAT;
|
|
33
|
+
generation: string; homeHash: string; contextHash: string; nonce: string;
|
|
34
|
+
}
|
|
35
|
+
export interface PlacementActiveFence { schema: typeof PLACEMENT_ACTIVE_FENCE_SCHEMA; epochHash: string; homeHash: string; generation: string }
|
|
36
|
+
export type PlacementRealm = { scope: "user" } | { scope: "project"; keyHash: string };
|
|
37
|
+
export interface PlacementKey { realm: PlacementRealm; id: string; rowHash: string }
|
|
38
|
+
export type PlacementHoldReason = SyncPolicyCategory | "person-local" | "related-local";
|
|
39
|
+
export interface PlacementAmbiguousOutcome { attemptHash: string; remoteHash: string; refHash: string }
|
|
40
|
+
export interface PlacementCommittedOutcome extends PlacementAmbiguousOutcome { commit: string; proofHash: string }
|
|
41
|
+
interface EventFields { requestId: string; key: PlacementKey; sourcePlacement: "local" | "synced"; effectHash: string }
|
|
42
|
+
export type PlacementEvent = EventFields & (
|
|
43
|
+
{ kind: "hold"; reason: PlacementHoldReason } | { kind: "release" }
|
|
44
|
+
| { kind: "publication-committed"; outcome: PlacementCommittedOutcome }
|
|
45
|
+
| { kind: "publication-ambiguous"; outcome: PlacementAmbiguousOutcome }
|
|
46
|
+
);
|
|
47
|
+
export type PlacementRecord = PlacementEvent & { schema: typeof PLACEMENT_RECORD_SCHEMA; epochHash: string; sequence: number; previousHash: string; seal: string };
|
|
48
|
+
const eventFields = ["requestId", "key", "sourcePlacement", "effectHash", "kind"];
|
|
49
|
+
const reasons: readonly PlacementHoldReason[] = ["secret", "personal-data", "deny-path", "private-or-invalid", "host", "person-local", "related-local"];
|
|
50
|
+
const fail = (code: SyncPlacementJournalErrorCode = "invalid"): never => { throw new SyncPlacementJournalError(code); };
|
|
51
|
+
const hex = (value: unknown, length: number): value is string => typeof value === "string" && value.length === length && /^[a-f0-9]+$/.test(value);
|
|
52
|
+
function object(value: unknown, required: readonly string[], optional: readonly string[] = []): Record<string, unknown> {
|
|
53
|
+
// Shared descriptor-only snapshot rejects proxies, getters, functions, private flags and unbounded structure without running caller code.
|
|
54
|
+
const copy = memoryPolicySnapshot(value);
|
|
55
|
+
if (!copy || typeof copy !== "object" || Array.isArray(copy)) return fail();
|
|
56
|
+
const keys = Object.keys(copy), allowed = new Set([...required, ...optional]);
|
|
57
|
+
if (keys.length < required.length || keys.length > allowed.size || required.some(key => !Object.hasOwn(copy, key)) || keys.some(key => !allowed.has(key))) return fail();
|
|
58
|
+
return copy as Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
function bounded<T>(value: T): T { if (Buffer.byteLength(canonicalJSON(value) + "\n") > PLACEMENT_JOURNAL_CHUNK_MAX_BYTES) return fail("bound"); return value; }
|
|
61
|
+
export function validatePlacementEpoch(value: unknown): PlacementEpoch {
|
|
62
|
+
const row = object(value, ["schema", "publicationFormat", "generation", "homeHash", "contextHash", "nonce"]);
|
|
63
|
+
if (row.schema !== PLACEMENT_EPOCH_SCHEMA || row.publicationFormat !== PLACEMENT_FORMAT || !hex(row.generation, 32) || !hex(row.homeHash, 64) || !hex(row.contextHash, 64) || !hex(row.nonce, 32)) return fail();
|
|
64
|
+
return bounded<PlacementEpoch>({ schema: PLACEMENT_EPOCH_SCHEMA, publicationFormat: PLACEMENT_FORMAT, generation: row.generation, homeHash: row.homeHash, contextHash: row.contextHash, nonce: row.nonce });
|
|
65
|
+
}
|
|
66
|
+
export function validatePlacementActiveFence(value: unknown): PlacementActiveFence {
|
|
67
|
+
const row = object(value, ["schema", "epochHash", "homeHash", "generation"]);
|
|
68
|
+
if (row.schema !== PLACEMENT_ACTIVE_FENCE_SCHEMA || !hex(row.epochHash, 64) || !hex(row.homeHash, 64) || !hex(row.generation, 32)) return fail();
|
|
69
|
+
return bounded<PlacementActiveFence>({ schema: PLACEMENT_ACTIVE_FENCE_SCHEMA, epochHash: row.epochHash, homeHash: row.homeHash, generation: row.generation });
|
|
70
|
+
}
|
|
71
|
+
export function validatePlacementKey(value: unknown): PlacementKey {
|
|
72
|
+
const row = object(value, ["realm", "id", "rowHash"]), realm = object(row.realm, ["scope"], ["keyHash"]);
|
|
73
|
+
if (!hex(row.id, 32) || !hex(row.rowHash, 64)) return fail();
|
|
74
|
+
if (realm.scope === "user" && !Object.hasOwn(realm, "keyHash")) return { realm: { scope: "user" }, id: row.id, rowHash: row.rowHash };
|
|
75
|
+
if (realm.scope === "project" && hex(realm.keyHash, 64)) return { realm: { scope: "project", keyHash: realm.keyHash }, id: row.id, rowHash: row.rowHash };
|
|
76
|
+
return fail();
|
|
77
|
+
}
|
|
78
|
+
function outcome(value: unknown, committed: boolean): PlacementAmbiguousOutcome | PlacementCommittedOutcome {
|
|
79
|
+
const row = object(value, ["attemptHash", "remoteHash", "refHash", ...(committed ? ["commit", "proofHash"] : [])]);
|
|
80
|
+
if (!hex(row.attemptHash, 64) || !hex(row.remoteHash, 64) || !hex(row.refHash, 64)) return fail();
|
|
81
|
+
const base = { attemptHash: row.attemptHash, remoteHash: row.remoteHash, refHash: row.refHash };
|
|
82
|
+
if (!committed) return base;
|
|
83
|
+
if (!hex(row.commit, 40) || !hex(row.proofHash, 64)) return fail();
|
|
84
|
+
return { ...base, commit: row.commit, proofHash: row.proofHash };
|
|
85
|
+
}
|
|
86
|
+
export function validatePlacementEvent(value: unknown): PlacementEvent {
|
|
87
|
+
const row = object(value, eventFields, ["reason", "outcome"]);
|
|
88
|
+
if (!hex(row.requestId, 64) || !hex(row.effectHash, 64) || !["local", "synced"].includes(row.sourcePlacement as string)) return fail();
|
|
89
|
+
const base: EventFields = { requestId: row.requestId, key: validatePlacementKey(row.key), sourcePlacement: row.sourcePlacement as "local" | "synced", effectHash: row.effectHash };
|
|
90
|
+
if (row.kind === "hold" && !Object.hasOwn(row, "outcome") && typeof row.reason === "string" && reasons.includes(row.reason as PlacementHoldReason)) return bounded<PlacementEvent>({ ...base, kind: "hold", reason: row.reason as PlacementHoldReason });
|
|
91
|
+
if (Object.hasOwn(row, "reason")) return fail();
|
|
92
|
+
if (row.kind === "release" && !Object.hasOwn(row, "outcome")) return bounded<PlacementEvent>({ ...base, kind: "release" });
|
|
93
|
+
if (row.kind === "publication-committed") return bounded<PlacementEvent>({ ...base, kind: row.kind, outcome: outcome(row.outcome, true) as PlacementCommittedOutcome });
|
|
94
|
+
if (row.kind === "publication-ambiguous") return bounded<PlacementEvent>({ ...base, kind: row.kind, outcome: outcome(row.outcome, false) });
|
|
95
|
+
return fail();
|
|
96
|
+
}
|
|
97
|
+
export function validatePlacementRecord(value: unknown): PlacementRecord {
|
|
98
|
+
const row = object(value, ["schema", "epochHash", "sequence", "previousHash", "seal", ...eventFields], ["reason", "outcome"]);
|
|
99
|
+
if (row.schema !== PLACEMENT_RECORD_SCHEMA || !hex(row.epochHash, 64) || !hex(row.previousHash, 64) || !hex(row.seal, 64)
|
|
100
|
+
|| typeof row.sequence !== "number" || !Number.isSafeInteger(row.sequence) || row.sequence < 1) return fail();
|
|
101
|
+
if (row.sequence > PLACEMENT_JOURNAL_MAX_RECORDS) return fail("bound");
|
|
102
|
+
const rawEvent: Record<string, unknown> = {}; for (const field of [...eventFields, "reason", "outcome"]) if (Object.hasOwn(row, field)) rawEvent[field] = row[field];
|
|
103
|
+
const event = validatePlacementEvent(rawEvent), body = { ...event, schema: PLACEMENT_RECORD_SCHEMA as typeof PLACEMENT_RECORD_SCHEMA, epochHash: row.epochHash, sequence: row.sequence, previousHash: row.previousHash };
|
|
104
|
+
if (row.sequence === 1 && row.previousHash !== row.epochHash || hashJSON(body) !== row.seal) return fail("corrupt");
|
|
105
|
+
return bounded<PlacementRecord>({ ...body, seal: row.seal });
|
|
106
|
+
}
|
|
107
|
+
export function createPlacementRecord(epochHash: string, sequence: number, previousHash: string, value: PlacementEvent): PlacementRecord {
|
|
108
|
+
if (!hex(epochHash, 64) || !hex(previousHash, 64) || typeof sequence !== "number" || !Number.isSafeInteger(sequence) || sequence < 1) return fail();
|
|
109
|
+
if (sequence > PLACEMENT_JOURNAL_MAX_RECORDS) return fail("bound");
|
|
110
|
+
const event = validatePlacementEvent(value), body = { ...event, schema: PLACEMENT_RECORD_SCHEMA, epochHash, sequence, previousHash };
|
|
111
|
+
return validatePlacementRecord({ ...body, seal: hashJSON(body) });
|
|
112
|
+
}
|
|
113
|
+
export const placementEpochHash = (epoch: PlacementEpoch): string => hashJSON(validatePlacementEpoch(epoch));
|
|
114
|
+
export const placementKeyId = (key: PlacementKey): string => hashJSON(validatePlacementKey(key));
|