@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,153 @@
|
|
|
1
|
+
import { validatePlacementProfile, validatePlacementShards } from "./sync-placement-format.ts";
|
|
2
|
+
import { assertPlacementViewAdvance } from "./sync-placement-active.ts";
|
|
3
|
+
import { readPlacementRuntimeMarkers } from "./sync-placement-guard.ts";
|
|
4
|
+
import { types } from "node:util";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { canonicalJSON, hashJSON } from "./generation.ts";
|
|
7
|
+
import { acquireMutation, type MutationHandle } from "./mutation.ts";
|
|
8
|
+
import { memoryPolicySnapshot } from "./policy.ts";
|
|
9
|
+
import { outboundPolicyEvaluator, type RowScreen } from "./sync-screen.ts";
|
|
10
|
+
import { assertPlacementRuntime } from "./sync-placement-guard.ts";
|
|
11
|
+
import { PLACEMENT_FORMAT } from "./sync-placement-format.ts";
|
|
12
|
+
import { planSyncPlacementActivation, validatePlacementPlan, validateSyncPlacementActivation, type SyncPlacementActivationPlan } from "./sync-placement-plan.ts";
|
|
13
|
+
import { captureSyncPlacementContext, type PlacementCapturePort, type PlacementCapture } from "./sync-placement-capture.ts";
|
|
14
|
+
import { PLACEMENT_EPOCH_SCHEMA, placementEpochHash, placementKeyId, validatePlacementEpoch, validatePlacementKey, validatePlacementEvent,
|
|
15
|
+
SyncPlacementJournalError, type PlacementEpoch, type PlacementKey, type PlacementCommittedOutcome, type PlacementAmbiguousOutcome } from "./sync-placement-journal-format.ts";
|
|
16
|
+
import { rebuildPlacementProjection } from "./sync-placement-projection.ts";
|
|
17
|
+
import { preparePlacementEpoch, readPlacementJournal, appendPlacementEvent } from "./sync-placement-journal-storage.ts";
|
|
18
|
+
|
|
19
|
+
export interface PlacementJournalPreview { epoch: PlacementEpoch; plan: SyncPlacementActivationPlan }
|
|
20
|
+
export interface PlacementAuthorizationBinding {
|
|
21
|
+
action: "prepare" | "hold" | "release"; seal: string;
|
|
22
|
+
epochHash?: string; key?: PlacementKey; expectedHead?: string; policyHash?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface PlacementJournalPort extends PlacementCapturePort {
|
|
25
|
+
/** Person intent is authenticated by the host. Data/seals/session markers grant no authority. */
|
|
26
|
+
authorize(binding: Readonly<PlacementAuthorizationBinding>): boolean;
|
|
27
|
+
screen?: RowScreen;
|
|
28
|
+
}
|
|
29
|
+
function fail(code: "invalid" | "changed" | "authorization" | "policy" | "local" | "conflict" | "pending" | "busy" = "invalid"): never { throw new SyncPlacementJournalError(code); }
|
|
30
|
+
function protectedCall<T>(work: () => T): T {
|
|
31
|
+
try { return work(); } catch (error) { if (error && typeof error === "object" && !types.isProxy(error) && error instanceof SyncPlacementJournalError) throw error; return fail("changed"); }
|
|
32
|
+
}
|
|
33
|
+
function person(port: PlacementJournalPort, action: "prepare" | "hold" | "release", seal: string, details: Omit<PlacementAuthorizationBinding, "action" | "seal"> = {}): void {
|
|
34
|
+
let authorized = false;
|
|
35
|
+
try { authorized = port.authorize(Object.freeze({ action, seal, ...details })) === true; } catch { fail("authorization"); }
|
|
36
|
+
if (!authorized) fail("authorization");
|
|
37
|
+
}
|
|
38
|
+
function fenced<T>(capture: PlacementCapture, work: (homeMutation: MutationHandle) => T): T {
|
|
39
|
+
const handles: MutationHandle[] = [];
|
|
40
|
+
try {
|
|
41
|
+
let homeMutation: MutationHandle | undefined;
|
|
42
|
+
for (const dir of capture.directories) {
|
|
43
|
+
const handle = acquireMutation(dir); if (!handle) fail("busy");
|
|
44
|
+
handles.push(handle); if (dir === capture.home) homeMutation = handle;
|
|
45
|
+
}
|
|
46
|
+
capture.assertCurrent(); if (!homeMutation) fail();
|
|
47
|
+
return work(homeMutation);
|
|
48
|
+
} finally { for (const release of handles.reverse()) release(); }
|
|
49
|
+
}
|
|
50
|
+
/** Read-only concrete preview. Preparing it still requires a fresh capture and explicit person authorization. */
|
|
51
|
+
export function planPlacementJournal(home: string, port: PlacementCapturePort): PlacementJournalPreview {
|
|
52
|
+
return protectedCall(() => {
|
|
53
|
+
assertPlacementRuntime(home);
|
|
54
|
+
const capture = captureSyncPlacementContext(home, port); if (capture.activeHead) fail("conflict");
|
|
55
|
+
const epoch = validatePlacementEpoch({ schema: PLACEMENT_EPOCH_SCHEMA, publicationFormat: PLACEMENT_FORMAT,
|
|
56
|
+
generation: (capture.context.profile as { generation: string }).generation, homeHash: capture.context.homeHash,
|
|
57
|
+
contextHash: hashJSON(capture.context), nonce: randomBytes(16).toString("hex") });
|
|
58
|
+
const plan = planSyncPlacementActivation(capture.context, placementEpochHash(epoch));
|
|
59
|
+
capture.assertCurrent(); return { epoch, plan };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Creates only an empty private epoch on v2; never writes profile/shards/formats markers or enables transport. */
|
|
63
|
+
export function preparePlacementJournal(home: string, preview: PlacementJournalPreview, port: PlacementJournalPort): { activated: false; epochHash: string } {
|
|
64
|
+
return protectedCall(() => {
|
|
65
|
+
const copied = memoryPolicySnapshot(preview); if (!copied || Object.keys(copied).sort().join() !== "epoch,plan") fail();
|
|
66
|
+
assertPlacementRuntime(home);
|
|
67
|
+
const epoch = validatePlacementEpoch(copied.epoch), plan = validatePlacementPlan(copied.plan), epochHash = placementEpochHash(epoch);
|
|
68
|
+
const capture = captureSyncPlacementContext(home, port);
|
|
69
|
+
if (capture.activeHead || epoch.homeHash !== capture.context.homeHash || epoch.contextHash !== hashJSON(capture.context)
|
|
70
|
+
|| epoch.generation !== plan.generation || epochHash !== plan.targetProfile.placementHead) fail("changed");
|
|
71
|
+
validateSyncPlacementActivation(plan, { current: () => { capture.assertCurrent(); return capture.context; },
|
|
72
|
+
authorize: binding => { person(port, "prepare", binding.seal); return true; } });
|
|
73
|
+
fenced(capture, mutation => preparePlacementEpoch(capture.home, epoch, plan, { mutation, assertCurrent: capture.assertCurrent }));
|
|
74
|
+
return { activated: false, epochHash };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
export interface PlacementEffectRequest { epochHash: string; expectedHead: string; requestId: string; key: PlacementKey }
|
|
78
|
+
function request(input: PlacementEffectRequest): PlacementEffectRequest {
|
|
79
|
+
const value = memoryPolicySnapshot(input);
|
|
80
|
+
if (!value || Object.keys(value).sort().join() !== "epochHash,expectedHead,key,requestId"
|
|
81
|
+
|| ![value.epochHash, value.expectedHead, value.requestId].every(x => typeof x === "string" && x.length === 64 && /^[a-f0-9]+$/.test(x))) fail();
|
|
82
|
+
return { epochHash: value.epochHash, expectedHead: value.expectedHead, requestId: value.requestId, key: validatePlacementKey(value.key) };
|
|
83
|
+
}
|
|
84
|
+
function active(home: string, input: PlacementEffectRequest, port: PlacementCapturePort) {
|
|
85
|
+
const capture = captureSyncPlacementContext(home, port), journal = readPlacementJournal(capture.home, input.epochHash);
|
|
86
|
+
if (capture.activeHead !== input.epochHash || journal.epoch.homeHash !== capture.context.homeHash
|
|
87
|
+
|| journal.epoch.generation !== (capture.context.profile as { generation: string }).generation
|
|
88
|
+
|| journal.activation.targetProfile.placementHead !== input.epochHash
|
|
89
|
+
|| canonicalJSON(capture.context.writers) !== canonicalJSON(journal.activation.writers)
|
|
90
|
+
|| canonicalJSON(capture.context.realms.map(({ bindingHash: _hash, ...identity }) => identity))
|
|
91
|
+
!== canonicalJSON(journal.activation.realms.map(({ bindingHash: _hash, ...identity }) => identity))) fail("changed");
|
|
92
|
+
const markers = readPlacementRuntimeMarkers(capture.home), profile = validatePlacementProfile(markers.profile);
|
|
93
|
+
assertPlacementViewAdvance(capture.home, journal.activation, profile, validatePlacementShards(markers.shards, profile));
|
|
94
|
+
const projection = rebuildPlacementProjection(journal.epoch, journal.records);
|
|
95
|
+
const source = capture.rows.get(placementKeyId(input.key)); if (!source) fail("changed");
|
|
96
|
+
return { capture, journal, projection, source };
|
|
97
|
+
}
|
|
98
|
+
type Decision = { kind: "hold"; mode: "policy" | "person" } | { kind: "release" }
|
|
99
|
+
| { kind: "publication-committed"; outcome: PlacementCommittedOutcome; verify(binding: Readonly<{ effectHash: string; outcome: PlacementCommittedOutcome }>): boolean }
|
|
100
|
+
| { kind: "publication-ambiguous"; outcome: PlacementAmbiguousOutcome };
|
|
101
|
+
function effect(home: string, raw: PlacementEffectRequest, port: PlacementJournalPort, decision: Decision) {
|
|
102
|
+
return protectedCall(() => {
|
|
103
|
+
const input = request(raw), { capture, source } = active(home, input, port);
|
|
104
|
+
if (port.screen && !port.policyVersion) fail("policy");
|
|
105
|
+
const fields = { requestId: input.requestId, key: input.key, sourcePlacement: source.sourcePlacement };
|
|
106
|
+
let partial: Record<string, unknown>;
|
|
107
|
+
if (decision.kind === "hold") {
|
|
108
|
+
if (decision.mode === "person") partial = { ...fields, kind: "hold", reason: "person-local" };
|
|
109
|
+
else {
|
|
110
|
+
const verdict = outboundPolicyEvaluator(capture.home, port.screen)(source.row as unknown as Record<string, unknown>);
|
|
111
|
+
if (verdict.ok) fail("policy");
|
|
112
|
+
partial = { ...fields, kind: "hold", reason: verdict.category };
|
|
113
|
+
}
|
|
114
|
+
} else if (decision.kind === "release") {
|
|
115
|
+
if (source.intrinsicLocal) fail("local");
|
|
116
|
+
if (!outboundPolicyEvaluator(capture.home, port.screen)(source.row as unknown as Record<string, unknown>).ok) fail("policy");
|
|
117
|
+
partial = { ...fields, kind: "release" };
|
|
118
|
+
} else partial = { ...fields, kind: decision.kind, outcome: decision.outcome };
|
|
119
|
+
const effectHash = hashJSON({ event: partial, expectedHead: input.expectedHead, markerHash: capture.markerHash, contextHash: hashJSON(capture.context) });
|
|
120
|
+
const event = validatePlacementEvent({ ...partial, effectHash });
|
|
121
|
+
if (decision.kind === "release" || decision.kind === "hold" && decision.mode === "person") person(port, decision.kind, effectHash, { epochHash: input.epochHash, key: Object.freeze({ ...input.key, realm: Object.freeze({ ...input.key.realm }) }),
|
|
122
|
+
expectedHead: input.expectedHead, policyHash: capture.context.policyHash });
|
|
123
|
+
if (decision.kind === "publication-committed") {
|
|
124
|
+
let verified = false;
|
|
125
|
+
try { verified = decision.verify(Object.freeze({ effectHash, outcome: structuredClone(decision.outcome) })) === true; } catch { fail("authorization"); }
|
|
126
|
+
if (!verified) fail("authorization");
|
|
127
|
+
}
|
|
128
|
+
capture.assertCurrent();
|
|
129
|
+
return fenced(capture, mutation => appendPlacementEvent(capture.home, input.epochHash, event, input.expectedHead,
|
|
130
|
+
{ mutation, assertCurrent: capture.assertCurrent }));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/** Sticky local placement. Retrying or loosening a policy never invokes the release path. */
|
|
134
|
+
export const holdPlacementRow = (home: string, input: PlacementEffectRequest, port: PlacementJournalPort, mode: "policy" | "person" = "policy") => effect(home, input, port, { kind: "hold", mode });
|
|
135
|
+
export const releasePlacementHold = (home: string, input: PlacementEffectRequest, port: PlacementJournalPort) => effect(home, input, port, { kind: "release" });
|
|
136
|
+
/** Records verified evidence only. This API performs no publication, fetch, git or network operation. */
|
|
137
|
+
export function recordPlacementPublication(home: string, input: PlacementEffectRequest, port: PlacementJournalPort,
|
|
138
|
+
outcome: PlacementCommittedOutcome, verify: (binding: Readonly<{ effectHash: string; outcome: PlacementCommittedOutcome }>) => boolean) {
|
|
139
|
+
const copied = memoryPolicySnapshot(outcome); if (!copied) return fail();
|
|
140
|
+
return effect(home, input, port, { kind: "publication-committed", outcome: copied, verify });
|
|
141
|
+
}
|
|
142
|
+
export function recordPlacementAmbiguity(home: string, input: PlacementEffectRequest, port: PlacementJournalPort, outcome: PlacementAmbiguousOutcome) {
|
|
143
|
+
const copied = memoryPolicySnapshot(outcome); if (!copied) return fail();
|
|
144
|
+
return effect(home, input, port, { kind: "publication-ambiguous", outcome: copied });
|
|
145
|
+
}
|
|
146
|
+
/** Rebuilds solely from the private journal, not from any index flag. Pending writes never yield a complete empty result. */
|
|
147
|
+
export function readPlacementProjection(home: string, epochHash: string) {
|
|
148
|
+
return protectedCall(() => {
|
|
149
|
+
const journal = readPlacementJournal(home, epochHash); if (journal.pending) fail("pending");
|
|
150
|
+
const projection = rebuildPlacementProjection(journal.epoch, journal.records);
|
|
151
|
+
return { epochHash: projection.epochHash, headHash: projection.headHash, sequence: projection.sequence, states: projection.states() };
|
|
152
|
+
});
|
|
153
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** Shared internal row materialization. All source/transport authority and effect boundaries belong to the caller. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { canonicalJSON, hashJSON } from "./generation.ts";
|
|
4
|
+
import { SYNC_SCREEN_MAX_BYTES, SYNC_SCREEN_MAX_ROWS, type OutboundVerdict } from "./sync-screen.ts";
|
|
5
|
+
import type { PlacementCapture } from "./sync-placement-capture.ts";
|
|
6
|
+
import { compilePlacementDependencies, type DependencySource, type VerifiedDependencyEndpoint } from "./sync-placement-dependencies.ts";
|
|
7
|
+
import { compilePlacementGitObjects, type PlannedGitObject } from "./sync-placement-git.ts";
|
|
8
|
+
import { PLACEMENT_DESCRIPTOR_SCHEMA, PLACEMENT_FORMAT } from "./sync-placement-format.ts";
|
|
9
|
+
import { placementKeyId, SyncPlacementJournalError, type PlacementKey, type PlacementRealm } from "./sync-placement-journal-format.ts";
|
|
10
|
+
import type { PlacementProjection } from "./sync-placement-projection.ts";
|
|
11
|
+
import type { PlacementPublicationBundle, PlacementPublicationDecision, PlacementPublicationObject, PlacementPublicationTarget } from "./sync-placement-publication-plan.ts";
|
|
12
|
+
import type { ShardHistory } from "./shard-sync-history.ts";
|
|
13
|
+
import { decodeShardPack, encodeShardPacks, encodeShardManifest, shardSyncRef, type ShardSyncRow, type ShardSyncRealm } from "./shard-sync-format.ts";
|
|
14
|
+
import { verifyPublicPruneWitness, type PublicPruneWitnessV1, type PublicPruneLineage } from "./public-prune-witness.ts";
|
|
15
|
+
import type { Entry } from "./store.ts";
|
|
16
|
+
import type { PruneRow } from "./prune.ts";
|
|
17
|
+
|
|
18
|
+
export interface PlacementMaterializeTarget {
|
|
19
|
+
realm: ShardSyncRealm; generation: string; remote?: string; local?: string;
|
|
20
|
+
fetched: ReadonlySet<string>; localPrunes: ReadonlyMap<string, PruneRow>; pruned: ReadonlySet<string>;
|
|
21
|
+
}
|
|
22
|
+
export interface PlacementMaterializeInput {
|
|
23
|
+
capture: Pick<PlacementCapture, "rows">; projection: PlacementProjection; generation: string;
|
|
24
|
+
targets: readonly PlacementMaterializeTarget[]; old: readonly (ShardHistory | undefined)[];
|
|
25
|
+
verifiedPrunes: ReadonlySet<string>; evaluate(row: unknown): OutboundVerdict;
|
|
26
|
+
readTree(index: number, head: string): Promise<string>;
|
|
27
|
+
publisherLineages(index: number, rows: readonly PruneRow[]): ReadonlyMap<string, PublicPruneLineage>;
|
|
28
|
+
createdUnixSeconds: number; frozen?: PlacementPublicationBundle;
|
|
29
|
+
}
|
|
30
|
+
export interface PlacementMaterializeTargetResult { candidate: ShardHistory; objects: PlannedGitObject[]; commit?: string }
|
|
31
|
+
export interface PlacementMaterializeResult {
|
|
32
|
+
decisions: PlacementPublicationDecision[]; targets: PlacementMaterializeTargetResult[];
|
|
33
|
+
plannedTargets: PlacementPublicationTarget[]; objects: PlacementPublicationObject[];
|
|
34
|
+
}
|
|
35
|
+
const digest = (value: Uint8Array | string) => createHash("sha256").update(value).digest("hex");
|
|
36
|
+
const sameRealm = (a: PlacementRealm, b: PlacementRealm) => canonicalJSON(a) === canonicalJSON(b);
|
|
37
|
+
const physical = (target: Pick<PlacementMaterializeTarget, "realm">): PlacementRealm => target.realm.scope === "user" ? {scope:"user"} : {scope:"project",keyHash:digest(target.realm.key)};
|
|
38
|
+
const keyFor = (target: Pick<PlacementMaterializeTarget, "realm">, row: ShardSyncRow): PlacementKey => ({realm:physical(target),id:row.id,rowHash:hashJSON(row)});
|
|
39
|
+
function refuse(): never { throw new Error("placement sync unavailable"); }
|
|
40
|
+
export interface PlacementPruneProofTarget {
|
|
41
|
+
realm: ShardSyncRealm; generation: string; rows: ReadonlyMap<string, ShardSyncRow>; fetched: ReadonlySet<string>; lineageIds?: ReadonlySet<string>;
|
|
42
|
+
}
|
|
43
|
+
/** Pure predicate only: fetched/lineage membership must already be independently verified by the caller. */
|
|
44
|
+
export function verifiedPlacementPrunes(targets: readonly PlacementPruneProofTarget[]): Set<string> {
|
|
45
|
+
const verified = new Set<string>();
|
|
46
|
+
for (const target of targets) for (const row of target.rows.values()) {
|
|
47
|
+
if (row.op !== "prune" || row.generation !== target.generation) continue;
|
|
48
|
+
const complete = row.targets.every(item => [item,item.basis].every(ref => target.fetched.has(ref.id) && hashJSON(target.rows.get(ref.id)) === ref.rowHash));
|
|
49
|
+
if (complete && (target.fetched.has(row.id) || target.lineageIds?.has(row.id))) verified.add(placementKeyId(keyFor(target,row)));
|
|
50
|
+
}
|
|
51
|
+
return verified;
|
|
52
|
+
}
|
|
53
|
+
export async function materializePlacementRows(input: PlacementMaterializeInput): Promise<PlacementMaterializeResult> {
|
|
54
|
+
const {capture,projection,old,frozen,createdUnixSeconds}=input;
|
|
55
|
+
const workTargets = input.targets.map(target => ({...target,objects:[] as PlannedGitObject[],commit:undefined as string|undefined,candidate:undefined as ShardHistory|undefined}));
|
|
56
|
+
const sources: DependencySource[] = [], blocked = new Set<string>(), verified = input.verifiedPrunes;
|
|
57
|
+
let sourceBytes=0;
|
|
58
|
+
const admit=(row:unknown)=>{const bytes=Buffer.byteLength(canonicalJSON(row));if(sources.length>=SYNC_SCREEN_MAX_ROWS||sourceBytes+bytes>SYNC_SCREEN_MAX_BYTES)throw new SyncPlacementJournalError("bound");sourceBytes+=bytes;};
|
|
59
|
+
const prunedByTarget = new Map(workTargets.map(target => [target, target.pruned]));
|
|
60
|
+
for (const source of capture.rows.values()) {
|
|
61
|
+
const target = workTargets.find(target => sameRealm(physical(target), source.key.realm)); if (!target) refuse();
|
|
62
|
+
const pruned = prunedByTarget.get(target)!.has(source.key.id) && !target.fetched.has(source.key.id);
|
|
63
|
+
if (pruned) blocked.add(placementKeyId(source.key));
|
|
64
|
+
admit(source.row); sources.push({ key: source.key, row: source.row, sourcePlacement: source.sourcePlacement, policy: input.evaluate(source.row), held: !!projection.get(source.key)?.held || !!pruned });
|
|
65
|
+
}
|
|
66
|
+
for (const target of workTargets) for (const row of target.localPrunes.values()) {
|
|
67
|
+
const key = keyFor(target,row); if (sources.some(source => placementKeyId(source.key) === placementKeyId(key))) continue;
|
|
68
|
+
admit(row); sources.push({ key, row, sourcePlacement: "synced", policy: input.evaluate(row), held: !!projection.get(key)?.held });
|
|
69
|
+
}
|
|
70
|
+
// Incoming verified occurrences are selected immutable originals too. Classify them before import so a private
|
|
71
|
+
// remote goal cannot temporarily authorize a new dependent, and exposure/holds are durable in this same attempt.
|
|
72
|
+
const seenSources=new Set(sources.map(source=>placementKeyId(source.key)));
|
|
73
|
+
for(const [index,target]of workTargets.entries())for(const row of old[index]?.rows.values()??[])if(row.op!=="prune"||row.generation===target.generation){
|
|
74
|
+
const key=keyFor(target,row),id=placementKeyId(key);if(seenSources.has(id))continue;seenSources.add(id);
|
|
75
|
+
admit(row); sources.push({key,row,sourcePlacement:"synced",policy:input.evaluate(row),held:!!projection.get(key)?.held});
|
|
76
|
+
}
|
|
77
|
+
// Strong endpoints are already in sources and current holds cannot be bypassed by past publication. Only
|
|
78
|
+
// deletion/prune exceptions need a second verified occurrence fact; do not duplicate every historical body.
|
|
79
|
+
const publicNeeded=new Set<string>();
|
|
80
|
+
for(const source of sources){const row=source.row as ShardSyncRow;
|
|
81
|
+
if(row.op==="forget"||row.op==="demote")publicNeeded.add(row.target);
|
|
82
|
+
if(row.op==="prune")for(const target of row.targets)for(const ref of [target,target.basis])publicNeeded.add(ref.id);
|
|
83
|
+
}
|
|
84
|
+
const endpoints: VerifiedDependencyEndpoint[] = workTargets.flatMap((target,index) => [...(old[index]?.rows.values() ?? [])].filter((row): row is Entry => row.op !== "prune"&&publicNeeded.has(row.id)).map(row => ({ key: keyFor(target,row), row })));
|
|
85
|
+
const descriptorVerdicts = workTargets.map(target => input.evaluate({ schema: PLACEMENT_DESCRIPTOR_SCHEMA, ...target.realm, publicationFormat: PLACEMENT_FORMAT }));
|
|
86
|
+
const publishes = workTargets.map(() => false);
|
|
87
|
+
for (const source of sources) {
|
|
88
|
+
const index = workTargets.findIndex(target => sameRealm(physical(target),source.key.realm));
|
|
89
|
+
if (!descriptorVerdicts[index]!.ok) source.policy = descriptorVerdicts[index]!;
|
|
90
|
+
}
|
|
91
|
+
const sourceMap = new Map(sources.map(source => [placementKeyId(source.key), source]));
|
|
92
|
+
let decisions: PlacementPublicationDecision[] = [];
|
|
93
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
94
|
+
decisions = compilePlacementDependencies({ generation: input.generation, selectedRealms: workTargets.map(physical), rows:sources, publicEndpoints:endpoints,
|
|
95
|
+
verifiedPrunes:sources.filter(source => verified.has(placementKeyId(source.key))).map(source => source.key) }).map(decision => {
|
|
96
|
+
const source = sourceMap.get(placementKeyId(decision.key))!;
|
|
97
|
+
const index=workTargets.findIndex(target=>sameRealm(physical(target),decision.key.realm));
|
|
98
|
+
const original=old[index]?.rows.get(decision.key.id), published=!!original&&hashJSON(original)===decision.key.rowHash;
|
|
99
|
+
const reason=blocked.has(placementKeyId(decision.key)) && !projection.get(decision.key)?.held ? "related-local" : decision.reason ?? null;
|
|
100
|
+
return { ...decision, sourcePlacement:source.sourcePlacement, reason,
|
|
101
|
+
hold:!decision.eligible&&(reason!=="source-local"||published)&&!projection.get(decision.key)?.held };
|
|
102
|
+
});
|
|
103
|
+
let rejected = false;
|
|
104
|
+
for (const [index,target] of workTargets.entries()) {
|
|
105
|
+
const files = new Map(old[index]?.files), eligible = new Set(decisions.filter(decision => decision.eligible && sameRealm(decision.key.realm,physical(target))).map(decision => decision.key.id));
|
|
106
|
+
const publish = descriptorVerdicts[index]!.ok && (!!old[index] || eligible.size > 0); publishes[index] = publish;
|
|
107
|
+
if (publish) files.set("shard.json", Buffer.from(canonicalJSON({schema:PLACEMENT_DESCRIPTOR_SCHEMA,...target.realm,publicationFormat:PLACEMENT_FORMAT})+"\n"));
|
|
108
|
+
const additions = sources.filter(source => sameRealm(source.key.realm,physical(target)) && eligible.has(source.key.id) && !old[index]?.rows.has(source.key.id)).map(source => source.row as ShardSyncRow);
|
|
109
|
+
for (const [path,bytes] of encodeShardPacks(additions)) { const prior = files.get(path); if (prior && !prior.equals(bytes)) refuse(); files.set(path,bytes); }
|
|
110
|
+
const manifests = new Map(old[index]?.manifests), occurrences = new Map<string,{pack:string;blob:string}>();
|
|
111
|
+
for (const [path,bytes] of files) if (path.startsWith("rows/")) for (const row of decodeShardPack(path,bytes,target.realm,target.generation))
|
|
112
|
+
if (!occurrences.has(row.id)) occurrences.set(row.id,{pack:path,blob:createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex")});
|
|
113
|
+
const native = additions.filter((row):row is PruneRow => row.op==="prune" && row.generation===target.generation);
|
|
114
|
+
if (native.length) {
|
|
115
|
+
const lineages = input.publisherLineages(index,native);
|
|
116
|
+
for (const row of native) {
|
|
117
|
+
const lineage = lineages.get(row.id), own = occurrences.get(row.id); if (!lineage || !own) refuse();
|
|
118
|
+
const needed = new Map<string,string>(); for (const item of row.targets) for (const ref of [item,item.basis]) needed.set(ref.id,ref.rowHash);
|
|
119
|
+
const witnesses = [...needed].sort(([a],[b])=>a.localeCompare(b)).map(([id,rowHash])=>{ const place=occurrences.get(id); if(!place)refuse(); return{id,rowHash,...place}; });
|
|
120
|
+
const value:PublicPruneWitnessV1={schema:"agent-mem.public-prune-witness/v1",realm:target.realm,prune:row,lineage,pruneOccurrence:own,witnesses};
|
|
121
|
+
const encoded=encodeShardManifest(value), packs=new Map([own,...witnesses].map(ref=>[ref.pack,{pack:ref.pack,blob:ref.blob,bytes:files.get(ref.pack)!}]));
|
|
122
|
+
if(verifyPublicPruneWitness(value,{realm:target.realm,packs:[...packs.values()]}).status!=="verified")refuse();
|
|
123
|
+
const verdict=input.evaluate(value);
|
|
124
|
+
if(!verdict.ok){const source=sources.find(source=>source.key.id===row.id&&sameRealm(source.key.realm,physical(target)))!;source.policy=verdict;rejected=true;continue;}
|
|
125
|
+
files.set(encoded.path,encoded.bytes); manifests.set(encoded.path,{path:encoded.path,bytes:encoded.bytes,value,row,eligible:true,commit:"0".repeat(40),blob:createHash("sha1").update(`blob ${encoded.bytes.length}\0`).update(encoded.bytes).digest("hex")});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
target.objects=[];
|
|
129
|
+
if(publish){
|
|
130
|
+
const previous=old[index]&&target.remote?{tree:await input.readTree(index,frozen?.root.targets[index]?.expectedRemoteHead??target.remote!),commit:frozen?.root.targets[index]?.expectedRemoteHead??target.remote!}:undefined;
|
|
131
|
+
const objects=compilePlacementGitObjects(files,frozen?.root.targets[index]?.expectedRemoteHead??target.remote??null,createdUnixSeconds,previous);target.objects=objects.objects;target.commit=objects.commit;
|
|
132
|
+
for(const [path,manifest]of manifests)if(manifest.commit==="0".repeat(40))manifests.set(path,{...manifest,commit:objects.commit});
|
|
133
|
+
}else target.commit=target.remote;
|
|
134
|
+
const rows=new Map(old[index]?.rows);for(const row of additions)rows.set(row.id,row);
|
|
135
|
+
target.candidate={rows,files,origins:new Map(old[index]?.origins),version:publish?"v3":old[index]?.version??"v3",manifests};
|
|
136
|
+
}
|
|
137
|
+
if(!rejected)break;if(pass===1)refuse();
|
|
138
|
+
}
|
|
139
|
+
const plannedTargets:PlacementPublicationTarget[]=workTargets.map((target,index)=>{
|
|
140
|
+
const publish=publishes[index]!,descriptor=target.candidate?.files.get("shard.json");
|
|
141
|
+
return{realm:physical(target),publish,refHash:digest(shardSyncRef(target.realm)),expectedRemoteHead:frozen?.root.targets[index]?.expectedRemoteHead??target.remote??null,
|
|
142
|
+
expectedCacheHead:frozen?.root.targets[index]?.expectedCacheHead??target.local??null,descriptorHash:digest(descriptor??Buffer.alloc(0)),
|
|
143
|
+
exportSetHash:hashJSON([...target.candidate!.rows.values()].map(row=>({id:row.id,rowHash:hashJSON(row)})).sort((a,b)=>a.id.localeCompare(b.id))),
|
|
144
|
+
tree:publish?target.objects.find(object=>object.type==="tree"&&object.path==="")!.oid:target.remote?frozen?.root.targets[index]?.tree??null:null,commit:target.commit??null};
|
|
145
|
+
});
|
|
146
|
+
for(const [index,target]of workTargets.entries())if(!plannedTargets[index]!.publish&&target.remote)plannedTargets[index]!.tree=await input.readTree(index,target.remote);
|
|
147
|
+
const objects:PlacementPublicationObject[]=workTargets.flatMap((target,targetIndex)=>target.objects.map(object=>({targetIndex,type:object.type,path:object.path === "" ? null : object.path,oid:object.oid,sha256:digest(object.bytes),size:object.bytes.length})));
|
|
148
|
+
return {decisions,targets:workTargets.map(target=>({candidate:target.candidate!,objects:target.objects,commit:target.commit})),plannedTargets,objects};
|
|
149
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { types } from "node:util";
|
|
2
|
+
import { canonicalJSON, hashJSON, LOCAL_METADATA_MAX_BYTES } from "./generation.ts";
|
|
3
|
+
import {
|
|
4
|
+
PLACEMENT_FORMAT, PLACEMENT_PROFILE_SCHEMA, PLACEMENT_SHARDS_SCHEMA,
|
|
5
|
+
validatePlacementBaseV2, validatePlacementProfile, validatePlacementShards, validatePlacementMarkerTransition,
|
|
6
|
+
type PlacementProfile,
|
|
7
|
+
} from "./sync-placement-format.ts";
|
|
8
|
+
|
|
9
|
+
// ponytail: each preview inventory caps writers/realms at 256; larger inventories need a reviewed chunked-plan format before this ceiling is raised.
|
|
10
|
+
const INVENTORY_MAX = 256;
|
|
11
|
+
export const PLACEMENT_ACTIVATION_PLAN_SCHEMA = "agent-mem.sync-placement-activation-plan/v1";
|
|
12
|
+
export interface SyncPlacementWriterAttestation {
|
|
13
|
+
writerId: string; version: string; pin: string; publicationFormat: typeof PLACEMENT_FORMAT; compatible: true;
|
|
14
|
+
}
|
|
15
|
+
export type SyncPlacementRealmBinding = { scope: "user"; bindingHash: string }
|
|
16
|
+
| { scope: "project"; keyHash: string; bindingHash: string };
|
|
17
|
+
/** Data-only capture port. Stage 2 must obtain these facts from authoritative inputs again before any effect. */
|
|
18
|
+
export interface SyncPlacementActivationContext {
|
|
19
|
+
profile: unknown; shards: unknown;
|
|
20
|
+
homeHash: string; sourceHash: string; aliasesHash: string; policyHash: string;
|
|
21
|
+
realms: readonly SyncPlacementRealmBinding[];
|
|
22
|
+
writers: readonly SyncPlacementWriterAttestation[];
|
|
23
|
+
legacySyncQuiesced: true;
|
|
24
|
+
}
|
|
25
|
+
export interface SyncPlacementActivationPlan {
|
|
26
|
+
schema: typeof PLACEMENT_ACTIVATION_PLAN_SCHEMA; generation: string; contextHash: string;
|
|
27
|
+
previousProfileHash: string; previousShardsHash: string; targetProfile: PlacementProfile; targetShardsHash: string;
|
|
28
|
+
realms: SyncPlacementRealmBinding[]; writers: SyncPlacementWriterAttestation[]; legacySyncQuiesced: true; seal: string;
|
|
29
|
+
}
|
|
30
|
+
export interface SyncPlacementPersonAuthorization {
|
|
31
|
+
current(): SyncPlacementActivationContext;
|
|
32
|
+
/** The host authenticates person intent. Neither this callback's binding nor an actor/session string authenticates a caller. */
|
|
33
|
+
authorize(binding: Readonly<{ seal: string; generation: string; homeHash: string }>): boolean;
|
|
34
|
+
}
|
|
35
|
+
export type SyncPlacementPlanErrorCode = "invalid" | "bound" | "base" | "attestation" | "authorization" | "changed";
|
|
36
|
+
const messages: Record<SyncPlacementPlanErrorCode, string> = {
|
|
37
|
+
invalid: "sync placement plan is invalid; create and review a new complete plan before continuing",
|
|
38
|
+
bound: "sync placement plan exceeds supported metadata bounds; use a supported bounded migration before continuing",
|
|
39
|
+
base: "sync placement activation requires an active v2 public-prune shard base; run the person-authorized migrations first",
|
|
40
|
+
attestation: "sync placement activation requires compatible exact-pinned writers and quiesced legacy sync; upgrade and attest every sharing writer first",
|
|
41
|
+
authorization: "sync placement activation validation requires explicit person authorization; review the plan through an authenticated person port",
|
|
42
|
+
changed: "sync placement activation bindings changed or are unavailable; capture and review a fresh plan before continuing",
|
|
43
|
+
};
|
|
44
|
+
export class SyncPlacementPlanError extends Error {
|
|
45
|
+
constructor(readonly code: SyncPlacementPlanErrorCode) { super(messages[code]); this.name = "SyncPlacementPlanError"; }
|
|
46
|
+
}
|
|
47
|
+
const fail = (code: SyncPlacementPlanErrorCode = "invalid"): never => { throw new SyncPlacementPlanError(code); };
|
|
48
|
+
const hex = (value: unknown, length = 64): value is string => typeof value === "string" && value.length === length && /^[a-f0-9]+$/.test(value);
|
|
49
|
+
function object(value: unknown, keys: readonly string[]): Record<string, unknown> {
|
|
50
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || types.isProxy(value)
|
|
51
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value)) || Object.getOwnPropertySymbols(value).length) return fail();
|
|
52
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
53
|
+
if (Object.keys(fields).length !== keys.length || keys.some(key => !Object.hasOwn(fields, key))) return fail();
|
|
54
|
+
const result: Record<string, unknown> = {};
|
|
55
|
+
for (const key of keys) {
|
|
56
|
+
const field = fields[key]!;
|
|
57
|
+
if (!("value" in field) || !field.enumerable) return fail();
|
|
58
|
+
result[key] = field.value;
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
function list(value: unknown): unknown[] {
|
|
63
|
+
if (!Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype || value.length > INVENTORY_MAX || Object.getOwnPropertySymbols(value).length) return fail("bound");
|
|
64
|
+
const fields = Object.getOwnPropertyDescriptors(value);
|
|
65
|
+
if (Object.keys(fields).length !== value.length + 1) return fail();
|
|
66
|
+
const result: unknown[] = [];
|
|
67
|
+
for (let at = 0; at < value.length; at++) {
|
|
68
|
+
const field = fields[String(at)];
|
|
69
|
+
if (!field || !("value" in field) || !field.enumerable) return fail();
|
|
70
|
+
result.push(field.value);
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
function bounded(value: unknown): void { if (Buffer.byteLength(canonicalJSON(value)) > LOCAL_METADATA_MAX_BYTES) fail("bound"); }
|
|
75
|
+
function realms(value: unknown): SyncPlacementRealmBinding[] {
|
|
76
|
+
const result = list(value).map(item => {
|
|
77
|
+
// Inspect scope without invoking a getter before selecting its closed shape.
|
|
78
|
+
if (!item || typeof item !== "object" || types.isProxy(item)) return fail();
|
|
79
|
+
const scope = Object.getOwnPropertyDescriptor(item, "scope")?.value;
|
|
80
|
+
const row = object(item, scope === "user" ? ["scope", "bindingHash"] : ["scope", "keyHash", "bindingHash"]);
|
|
81
|
+
if (!hex(row.bindingHash)) return fail();
|
|
82
|
+
if (row.scope === "user") return { scope: "user" as const, bindingHash: row.bindingHash };
|
|
83
|
+
if (row.scope !== "project" || !hex(row.keyHash)) return fail();
|
|
84
|
+
return { scope: "project" as const, keyHash: row.keyHash, bindingHash: row.bindingHash };
|
|
85
|
+
});
|
|
86
|
+
const key = (row: SyncPlacementRealmBinding) => row.scope === "user" ? "user" : `project:${row.keyHash}`;
|
|
87
|
+
result.sort((a,b) => key(a).localeCompare(key(b)));
|
|
88
|
+
if (new Set(result.map(key)).size !== result.length) return fail();
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
function writers(value: unknown): SyncPlacementWriterAttestation[] {
|
|
92
|
+
const result = list(value).map(item => {
|
|
93
|
+
const row = object(item, ["writerId", "version", "pin", "publicationFormat", "compatible"]);
|
|
94
|
+
if (!hex(row.writerId) || !hex(row.pin) || typeof row.version !== "string" || row.version.length > 64
|
|
95
|
+
|| /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.exec(row.version)?.[0] !== row.version
|
|
96
|
+
|| row.publicationFormat !== PLACEMENT_FORMAT || row.compatible !== true) return fail("attestation");
|
|
97
|
+
return { writerId: row.writerId, version: row.version, pin: row.pin, publicationFormat: PLACEMENT_FORMAT as typeof PLACEMENT_FORMAT, compatible: true as const };
|
|
98
|
+
}).sort((a,b) => a.writerId.localeCompare(b.writerId));
|
|
99
|
+
if (!result.length || new Set(result.map(row => row.writerId)).size !== result.length) return fail("attestation");
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
function context(value: unknown) {
|
|
103
|
+
const row = object(value, ["profile", "shards", "homeHash", "sourceHash", "aliasesHash", "policyHash", "realms", "writers", "legacySyncQuiesced"]);
|
|
104
|
+
let base: ReturnType<typeof validatePlacementBaseV2>;
|
|
105
|
+
try { base = validatePlacementBaseV2(row.profile, row.shards); } catch { return fail("base"); }
|
|
106
|
+
if (![row.homeHash, row.sourceHash, row.aliasesHash, row.policyHash].every(value => hex(value))) return fail();
|
|
107
|
+
if (row.legacySyncQuiesced !== true) return fail("attestation");
|
|
108
|
+
const result = { profile: base.profile, shards: base.shards, homeHash: row.homeHash as string, sourceHash: row.sourceHash as string,
|
|
109
|
+
aliasesHash: row.aliasesHash as string, policyHash: row.policyHash as string, realms: realms(row.realms), writers: writers(row.writers), legacySyncQuiesced: true as const };
|
|
110
|
+
bounded(result); return result;
|
|
111
|
+
}
|
|
112
|
+
/** Pure preview builder. A sealed data plan is not a bearer capability and performs no activation. */
|
|
113
|
+
export function planSyncPlacementActivation(input: SyncPlacementActivationContext, placementHead: string): SyncPlacementActivationPlan {
|
|
114
|
+
const before = context(input);
|
|
115
|
+
if (!hex(placementHead)) return fail();
|
|
116
|
+
const targetProfile = validatePlacementProfile({ ...before.profile, schema: PLACEMENT_PROFILE_SCHEMA, publicationFormat: PLACEMENT_FORMAT, placementHead });
|
|
117
|
+
const targetShards = validatePlacementShards({ ...before.shards, schema: PLACEMENT_SHARDS_SCHEMA, selectorRevision: hashJSON(targetProfile), publicationFormat: PLACEMENT_FORMAT, placementHead }, targetProfile);
|
|
118
|
+
validatePlacementMarkerTransition(before.profile, before.shards, targetProfile, targetShards);
|
|
119
|
+
const body: Omit<SyncPlacementActivationPlan, "seal"> = { schema: PLACEMENT_ACTIVATION_PLAN_SCHEMA, generation: targetProfile.generation, contextHash: hashJSON(before),
|
|
120
|
+
previousProfileHash: hashJSON(before.profile), previousShardsHash: hashJSON(before.shards), targetProfile, targetShardsHash: hashJSON(targetShards),
|
|
121
|
+
realms: before.realms, writers: before.writers, legacySyncQuiesced: true as const };
|
|
122
|
+
const plan = { ...body, seal: hashJSON(body) }; bounded(plan); return plan;
|
|
123
|
+
}
|
|
124
|
+
/** Validate only the closed plan/seal. Source/authentication facts must be re-proved through the port below. */
|
|
125
|
+
export function validatePlacementPlan(value: unknown): SyncPlacementActivationPlan {
|
|
126
|
+
const row = object(value, ["schema", "generation", "contextHash", "previousProfileHash", "previousShardsHash", "targetProfile", "targetShardsHash", "realms", "writers", "legacySyncQuiesced", "seal"]);
|
|
127
|
+
if (row.schema !== PLACEMENT_ACTIVATION_PLAN_SCHEMA || !hex(row.generation, 32)
|
|
128
|
+
|| ![row.contextHash, row.previousProfileHash, row.previousShardsHash, row.targetShardsHash, row.seal].every(value => hex(value)) || row.legacySyncQuiesced !== true) return fail();
|
|
129
|
+
let targetProfile: PlacementProfile;
|
|
130
|
+
try { targetProfile = validatePlacementProfile(row.targetProfile); } catch { return fail(); }
|
|
131
|
+
if (targetProfile.generation !== row.generation) return fail();
|
|
132
|
+
const body: Omit<SyncPlacementActivationPlan, "seal"> = { schema: PLACEMENT_ACTIVATION_PLAN_SCHEMA, generation: row.generation, contextHash: row.contextHash as string,
|
|
133
|
+
previousProfileHash: row.previousProfileHash as string, previousShardsHash: row.previousShardsHash as string, targetProfile,
|
|
134
|
+
targetShardsHash: row.targetShardsHash as string, realms: realms(row.realms), writers: writers(row.writers), legacySyncQuiesced: true as const };
|
|
135
|
+
if (hashJSON(body) !== row.seal) return fail();
|
|
136
|
+
const plan = { ...body, seal: row.seal as string }; bounded(plan); return plan;
|
|
137
|
+
}
|
|
138
|
+
/** Person-port validation only: activated:false is intentional. No marker, journal, transport or filesystem effect exists in stage 1. */
|
|
139
|
+
export function validateSyncPlacementActivation(value: unknown, port: SyncPlacementPersonAuthorization): { validated: true; activated: false; plan: SyncPlacementActivationPlan } {
|
|
140
|
+
const plan = validatePlacementPlan(value);
|
|
141
|
+
let callbacks: Record<string, unknown>;
|
|
142
|
+
try { callbacks = object(port, ["current", "authorize"]); } catch { return fail("authorization"); }
|
|
143
|
+
if (typeof callbacks.current !== "function" || typeof callbacks.authorize !== "function") return fail("authorization");
|
|
144
|
+
const current = () => { try { return context((callbacks.current as () => unknown)()); } catch { return fail("changed"); } };
|
|
145
|
+
const before = current(), expected = planSyncPlacementActivation(before, plan.targetProfile.placementHead);
|
|
146
|
+
if (canonicalJSON(expected) !== canonicalJSON(plan)) return fail("changed");
|
|
147
|
+
const binding = Object.freeze({ seal: plan.seal, generation: plan.generation, homeHash: before.homeHash });
|
|
148
|
+
let authorized = false;
|
|
149
|
+
try { authorized = (callbacks.authorize as (scope: typeof binding) => unknown)(binding) === true; } catch { return fail("authorization"); }
|
|
150
|
+
if (!authorized) return fail("authorization");
|
|
151
|
+
if (canonicalJSON(current()) !== canonicalJSON(before)) return fail("changed");
|
|
152
|
+
return { validated: true, activated: false, plan };
|
|
153
|
+
}
|
|
154
|
+
/** Byte-bound and canonical framing precede parsing; the result still grants no effect authority. */
|
|
155
|
+
export function decodePlacementPlan(bytes: Uint8Array): SyncPlacementActivationPlan {
|
|
156
|
+
try {
|
|
157
|
+
if (types.isProxy(bytes) || !types.isUint8Array(bytes)) return fail();
|
|
158
|
+
const prototype = Object.getPrototypeOf(Uint8Array.prototype);
|
|
159
|
+
const intrinsic = (key: string) => Object.getOwnPropertyDescriptor(prototype, key)!.get!.call(bytes);
|
|
160
|
+
const size = intrinsic("byteLength") as number;
|
|
161
|
+
if (size > LOCAL_METADATA_MAX_BYTES) return fail("bound");
|
|
162
|
+
const view = new Uint8Array(intrinsic("buffer") as ArrayBuffer, intrinsic("byteOffset") as number, size);
|
|
163
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(view);
|
|
164
|
+
const plan = validatePlacementPlan(JSON.parse(text));
|
|
165
|
+
if (canonicalJSON(plan) + "\n" !== text) return fail();
|
|
166
|
+
return plan;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof SyncPlacementPlanError) throw error;
|
|
169
|
+
return fail();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { types } from "node:util";
|
|
2
|
+
import { canonicalJSON } from "./generation.ts";
|
|
3
|
+
import {
|
|
4
|
+
SyncPlacementJournalError, PLACEMENT_JOURNAL_MAX_RECORDS, placementEpochHash, placementKeyId,
|
|
5
|
+
validatePlacementEpoch, validatePlacementKey, validatePlacementRecord,
|
|
6
|
+
type PlacementEpoch, type PlacementKey, type PlacementRecord, type PlacementHoldReason,
|
|
7
|
+
type PlacementAmbiguousOutcome, type PlacementCommittedOutcome,
|
|
8
|
+
} from "./sync-placement-journal-format.ts";
|
|
9
|
+
|
|
10
|
+
export type PlacementAttemptState = (PlacementAmbiguousOutcome & { status: "ambiguous" }) | (PlacementCommittedOutcome & { status: "committed" });
|
|
11
|
+
export interface PlacementProjectedState {
|
|
12
|
+
key: PlacementKey; sourcePlacement: "local" | "synced"; held: boolean; reasons: PlacementHoldReason[];
|
|
13
|
+
exposure: "unknown" | "published"; attempts: PlacementAttemptState[];
|
|
14
|
+
}
|
|
15
|
+
const copy = (value: PlacementProjectedState): PlacementProjectedState => ({ ...value, key: validatePlacementKey(value.key), reasons: [...value.reasons], attempts: value.attempts.map(attempt => ({ ...attempt })) });
|
|
16
|
+
const identity = (key: PlacementKey) => canonicalJSON([key.realm, key.id]);
|
|
17
|
+
/** Data projection only. Journal fields and committed-outcome syntax are not person or remote-proof credentials. */
|
|
18
|
+
export class PlacementProjection {
|
|
19
|
+
readonly #epochHash: string;
|
|
20
|
+
#sequence = 0;
|
|
21
|
+
#headHash: string;
|
|
22
|
+
readonly #states = new Map<string, PlacementProjectedState>();
|
|
23
|
+
readonly #rowHashes = new Map<string, string>();
|
|
24
|
+
readonly #requests = new Map<string, PlacementRecord>();
|
|
25
|
+
constructor(epoch: PlacementEpoch) { this.#epochHash = placementEpochHash(validatePlacementEpoch(epoch)); this.#headHash = this.#epochHash; }
|
|
26
|
+
get epochHash(): string { return this.#epochHash; }
|
|
27
|
+
get sequence(): number { return this.#sequence; }
|
|
28
|
+
get headHash(): string { return this.#headHash; }
|
|
29
|
+
request(requestId: string): PlacementRecord | undefined { const record = this.#requests.get(requestId); return record ? validatePlacementRecord(record) : undefined; }
|
|
30
|
+
get(key: PlacementKey): PlacementProjectedState | undefined {
|
|
31
|
+
const normalized = validatePlacementKey(key), priorHash = this.#rowHashes.get(identity(normalized));
|
|
32
|
+
if (priorHash !== undefined && priorHash !== normalized.rowHash) throw new SyncPlacementJournalError("conflict");
|
|
33
|
+
const state = this.#states.get(placementKeyId(normalized)); return state ? copy(state) : undefined;
|
|
34
|
+
}
|
|
35
|
+
states(): PlacementProjectedState[] { return [...this.#states].sort(([a], [b]) => a.localeCompare(b)).map(([, state]) => copy(state)); }
|
|
36
|
+
apply(value: unknown): void {
|
|
37
|
+
const record = validatePlacementRecord(value);
|
|
38
|
+
if (record.epochHash !== this.#epochHash) throw new SyncPlacementJournalError("corrupt");
|
|
39
|
+
if (this.#requests.has(record.requestId)) throw new SyncPlacementJournalError("conflict");
|
|
40
|
+
if (record.sequence !== this.#sequence + 1 || record.previousHash !== this.#headHash) throw new SyncPlacementJournalError("corrupt");
|
|
41
|
+
const id = placementKeyId(record.key), rowIdentity = identity(record.key), oldHash = this.#rowHashes.get(rowIdentity);
|
|
42
|
+
if (oldHash !== undefined && oldHash !== record.key.rowHash) throw new SyncPlacementJournalError("conflict");
|
|
43
|
+
const prior = this.#states.get(id), next: PlacementProjectedState = prior ? copy(prior) : {
|
|
44
|
+
key: record.key, sourcePlacement: record.sourcePlacement, held: false, reasons: [], exposure: "unknown", attempts: [],
|
|
45
|
+
};
|
|
46
|
+
next.sourcePlacement = record.sourcePlacement;
|
|
47
|
+
if (record.kind === "hold") next.reasons = [...new Set([...next.reasons, record.reason])].sort();
|
|
48
|
+
else if (record.kind === "release") next.reasons = [];
|
|
49
|
+
else {
|
|
50
|
+
const outcome = record.outcome, at = next.attempts.findIndex(attempt => attempt.attemptHash === outcome.attemptHash), known = next.attempts[at];
|
|
51
|
+
if (known && (known.remoteHash !== outcome.remoteHash || known.refHash !== outcome.refHash)) throw new SyncPlacementJournalError("conflict");
|
|
52
|
+
if (record.kind === "publication-committed") {
|
|
53
|
+
if (known?.status === "committed" && (known.commit !== record.outcome.commit || known.proofHash !== record.outcome.proofHash)) throw new SyncPlacementJournalError("conflict");
|
|
54
|
+
const committed: PlacementAttemptState = { ...record.outcome, status: "committed" };
|
|
55
|
+
if (at < 0) next.attempts.push(committed); else next.attempts[at] = committed;
|
|
56
|
+
next.exposure = "published";
|
|
57
|
+
} else if (known?.status !== "committed") {
|
|
58
|
+
const ambiguous: PlacementAttemptState = { ...record.outcome, status: "ambiguous" };
|
|
59
|
+
if (at < 0) next.attempts.push(ambiguous); else next.attempts[at] = ambiguous;
|
|
60
|
+
}
|
|
61
|
+
next.attempts.sort((a, b) => a.attemptHash.localeCompare(b.attemptHash));
|
|
62
|
+
}
|
|
63
|
+
next.held = next.reasons.length > 0;
|
|
64
|
+
// All format/chain/request/source/outcome checks precede mutation: a rejected record never advances the frontier.
|
|
65
|
+
this.#states.set(id, next); this.#rowHashes.set(rowIdentity, record.key.rowHash); this.#requests.set(record.requestId, record);
|
|
66
|
+
this.#sequence = record.sequence; this.#headHash = record.seal;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function rebuildPlacementProjection(epoch: PlacementEpoch, records: readonly unknown[]): PlacementProjection {
|
|
70
|
+
if (types.isProxy(records) || !Array.isArray(records) || Object.getPrototypeOf(records) !== Array.prototype || Object.getOwnPropertySymbols(records).length || records.length > PLACEMENT_JOURNAL_MAX_RECORDS) throw new SyncPlacementJournalError("bound");
|
|
71
|
+
if (Object.getOwnPropertyNames(records).length !== records.length + 1) throw new SyncPlacementJournalError("invalid");
|
|
72
|
+
const result = new PlacementProjection(epoch);
|
|
73
|
+
for (let index = 0; index < records.length; index++) {
|
|
74
|
+
const descriptor = Object.getOwnPropertyDescriptor(records, String(index));
|
|
75
|
+
if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) throw new SyncPlacementJournalError("invalid");
|
|
76
|
+
result.apply(descriptor.value);
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|