@staix/agent-mem 0.21.0 → 0.22.1

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.
Files changed (38) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +27 -1
  3. package/package.json +5 -2
  4. package/src/cli-agent-reads.ts +3 -1
  5. package/src/cli.ts +36 -4
  6. package/src/commands-table.ts +3 -0
  7. package/src/index.ts +6 -0
  8. package/src/memory/commands.ts +3 -2
  9. package/src/memory/generation.ts +6 -3
  10. package/src/memory/profile-lock-diagnostic.ts +69 -0
  11. package/src/memory/public-prune-activation.ts +4 -4
  12. package/src/memory/reader-lease.ts +3 -2
  13. package/src/memory/shard-sync.ts +1 -2
  14. package/src/memory/shard.ts +4 -0
  15. package/src/memory/sync-cadence.ts +2 -1
  16. package/src/memory/sync-placement-abandon.ts +142 -0
  17. package/src/memory/sync-placement-activation-locks.ts +28 -0
  18. package/src/memory/sync-placement-activation-path.ts +2 -0
  19. package/src/memory/sync-placement-activation-proof.ts +68 -0
  20. package/src/memory/sync-placement-activation-storage.ts +167 -0
  21. package/src/memory/sync-placement-activation.ts +185 -0
  22. package/src/memory/sync-placement-capture.ts +104 -23
  23. package/src/memory/sync-placement-effective-writers.ts +84 -0
  24. package/src/memory/sync-placement-guard.ts +18 -8
  25. package/src/memory/sync-placement-inspection.ts +5 -1
  26. package/src/memory/sync-placement-journal-storage.ts +51 -7
  27. package/src/memory/sync-placement-journal.ts +11 -4
  28. package/src/memory/sync-placement-prerequisites.ts +50 -0
  29. package/src/memory/sync-placement-transport.ts +17 -6
  30. package/src/memory/sync-placement-writer-history.ts +278 -0
  31. package/src/memory/sync-placement-writer-renewal.ts +179 -0
  32. package/src/memory/sync-placement-writer-revision-format.ts +122 -0
  33. package/src/memory/sync-placement-writer-roots.ts +167 -0
  34. package/src/memory/sync-placement-writers.ts +135 -0
  35. package/src/memory/sync.ts +1 -2
  36. package/src/setup-toml.ts +3 -2
  37. package/src/setup.ts +101 -14
  38. package/src/sync-placement-writer-context.ts +136 -0
@@ -18,6 +18,10 @@ import { validatePruneRow, type PruneSourceRow, type PruneRow } from "./prune.ts
18
18
  import { shardKeyHash, SHARD_SCHEMA } from "./shard.ts";
19
19
  import { SyncPlacementJournalError, placementKeyId, type PlacementKey } from "./sync-placement-journal-format.ts";
20
20
  import type { Entry } from "./store.ts";
21
+ import { assertPlacementRuntime } from "./sync-placement-guard.ts";
22
+ import { activationSourceMarkers, assertPlacementActivationOwner, type PlacementActivationOwner, type PlacementActivationCaptureProof } from "./sync-placement-activation-proof.ts";
23
+ import { readEffectiveSyncPlacementWriters, writerBoundPlacementContextHash, writerInventoryEvidence, type EffectiveSyncPlacementWriters } from "./sync-placement-effective-writers.ts";
24
+ import { SharedWriterRevisionError } from "./sync-placement-writer-revision-format.ts";
21
25
 
22
26
  /** Host authentication/compatibility facts are re-read at every effect boundary, never inferred from a session marker. */
23
27
  export interface PlacementCapturePort {
@@ -35,10 +39,22 @@ export interface PlacementCapture {
35
39
  /** Diagnostic inventory only; these rows never enter the ordinary transport/capture row map implicitly. */
36
40
  specialRows: Map<string, PlacementSpecialSourceRow>;
37
41
  sourceRowsByRealm: Map<string, readonly PruneSourceRow[]>;
42
+ writerInventory?:EffectiveSyncPlacementWriters;
38
43
  assertCurrent(): void;
39
44
  /** Under live writer fences, inode/ctime/size checks protect the already-hashed prefixes across callbacks without reparsing rows. */
40
45
  assertUnchanged(): void;
41
46
  }
47
+ export function placementCaptureContextHash(capture:Pick<PlacementCapture,"context"|"writerInventory">):string{return writerBoundPlacementContextHash(capture.context,capture.writerInventory);}
48
+ export interface PlacementActivationMarkerSnapshot {
49
+ profileText: string; shardsText: string; profileStamp: [string,string,string,string,string]; shardsStamp: [string,string,string,string,string];
50
+ }
51
+ /** Source facts only: no writer attestation, quiescence claim or effect authority. */
52
+ export interface PlacementSourcePreview {
53
+ home: string; base: Omit<SyncPlacementActivationContext, "writers" | "legacySyncQuiesced">;
54
+ activeHead?: string; markerHash: string; markers: PlacementActivationMarkerSnapshot; directories: string[]; prefixes: PlacementPublicationSource[];
55
+ rows: Map<string, PlacementSourceRow>; specialRows: Map<string, PlacementSpecialSourceRow>;
56
+ sourceRowsByRealm: Map<string, readonly PruneSourceRow[]>; assertSourcesUnchanged(): void;
57
+ }
42
58
  function fail(code: "changed" | "invalid" | "bound" = "changed"): never { throw new SyncPlacementJournalError(code); }
43
59
  const digest = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex");
44
60
  const stamp = (s: BigIntStats) => [s.dev, s.ino, s.size, s.mtimeNs, s.ctimeNs].map(String);
@@ -50,7 +66,7 @@ function ownedDirectory(dir: string): BigIntStats {
50
66
  }
51
67
  /** ponytail: capture is capped at 256 metadata objects and 8 MiB/20000 source rows across all realms.
52
68
  * Larger homes need a reviewed chunked activation proof; no input is silently omitted at this ceiling. */
53
- function capture(homeInput: string, port: PlacementCapturePort, selected?: readonly PlacementRealm[], pending: readonly PlacementPendingSource[] = []): Omit<PlacementCapture, "assertCurrent"> {
69
+ function sourcePreview(homeInput: string, selected?: readonly PlacementRealm[], pending: readonly PlacementPendingSource[] = [], policyVersion?: string, ownerProof?: PlacementActivationCaptureProof, sourceOwner?:PlacementActivationOwner): PlacementSourcePreview {
54
70
  let selection: PlacementRealm[] | undefined;
55
71
  if (selected !== undefined) {
56
72
  if (!Array.isArray(selected) || types.isProxy(selected) || selected.length > 256) fail("bound");
@@ -58,11 +74,8 @@ function capture(homeInput: string, port: PlacementCapturePort, selected?: reado
58
74
  selection = copied.map(realm => validatePlacementKey({ realm, id: "0".repeat(32), rowHash: "0".repeat(64) }).realm);
59
75
  if (!selection.length || new Set(selection.map(realm => canonicalJSON(realm))).size !== selection.length) fail();
60
76
  }
61
- // All host callouts precede filesystem capture: a callback cannot alter input after its final proof.
62
- const attestation = memoryPolicySnapshot(port.attest()), policyVersion = port.policyVersion?.();
63
- if (!attestation) fail();
64
- if (port.policyVersion && typeof policyVersion !== "string") fail();
65
77
  const home = realPath(homeInput), root = ownedDirectory(home);
78
+ if(sourceOwner)assertPlacementActivationOwner(sourceOwner,home);
66
79
  let metadataObjects = 0, metadataBytes = 0;
67
80
  const facts: unknown[] = [], sourceChecks: (() => void)[] = [];
68
81
  const pendingByPath = new Map(pending.map(item => [canonicalJSON(item.realm)+":"+item.pathHash,item]));
@@ -90,7 +103,17 @@ function capture(homeInput: string, port: PlacementCapturePort, selected?: reado
90
103
  const input = bytes(file, optional); if (!input) return undefined;
91
104
  return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(input));
92
105
  };
93
- const rawProfile = json(join(home, "profile.json")), rawShards = json(join(home, "shards.json"));
106
+ const markerStart = facts.length;
107
+ const profileBytes = bytes(join(home, "profile.json"))!, shardsBytes = bytes(join(home, "shards.json"))!;
108
+ const actualMarkers: PlacementActivationMarkerSnapshot = { profileText: profileBytes.toString("utf8"), shardsText: shardsBytes.toString("utf8"),
109
+ profileStamp: [...(facts[markerStart] as [string,PlacementActivationMarkerSnapshot["profileStamp"],string])[1]], shardsStamp: [...(facts[markerStart+1] as [string,PlacementActivationMarkerSnapshot["shardsStamp"],string])[1]] };
110
+ const markers = ownerProof ? activationSourceMarkers(ownerProof,home,actualMarkers) : actualMarkers;
111
+ if (ownerProof) {
112
+ facts[markerStart] = [join(home,"profile.json"),markers.profileStamp,digest(Buffer.from(markers.profileText))];
113
+ facts[markerStart+1] = [join(home,"shards.json"),markers.shardsStamp,digest(Buffer.from(markers.shardsText))];
114
+ }
115
+ const decodeMarker = (text: string): unknown => JSON.parse(new TextDecoder("utf-8",{fatal:true}).decode(Buffer.from(text)));
116
+ const rawProfile = decodeMarker(markers.profileText) as { schema?: string }, rawShards = decodeMarker(markers.shardsText);
94
117
  let activeHead: string | undefined;
95
118
  let base: ReturnType<typeof validatePlacementBaseV2>;
96
119
  if (rawProfile?.schema === PLACEMENT_PROFILE_SCHEMA) {
@@ -109,7 +132,11 @@ function capture(homeInput: string, port: PlacementCapturePort, selected?: reado
109
132
  const machine = json(join(home, "machine.json"));
110
133
  if (machine?.id !== base.shards.machine.id) fail();
111
134
  // An in-flight migration/prune is never accepted as a stable placement source.
112
- for (const name of ["profile.lock", PUBLIC_PRUNE_INTENT]) if (lstatSync(join(home, name), { throwIfNoEntry: false })) fail();
135
+ for (const name of ["profile.lock", PUBLIC_PRUNE_INTENT]) {
136
+ if (name === "profile.lock" && ownerProof) continue; // The module-private proof validated this exact live owner and sealed intent.
137
+ if(name==="profile.lock"&&sourceOwner){assertPlacementActivationOwner(sourceOwner,home);continue;}
138
+ if (lstatSync(join(home,name),{throwIfNoEntry:false})) fail();
139
+ }
113
140
  const reader = new SegmentReader(16 * 1024 * 1024, { maxSourceBytes: 8 * 1024 * 1024, maxRows: 20000 });
114
141
  const rows = new Map<string, PlacementSourceRow>(), specialRows = new Map<string, PlacementSpecialSourceRow>(), sourceRowsByRealm = new Map<string, readonly PruneSourceRow[]>();
115
142
  const directories = [home], prefixes: PlacementPublicationSource[] = [];
@@ -242,29 +269,68 @@ function capture(homeInput: string, port: PlacementCapturePort, selected?: reado
242
269
  if (selection && realms.length !== selection.length) fail();
243
270
  const defaults = memoryPolicySnapshot({ names: DENY_NAMES, segments: DENY_SEGMENTS }); if (!defaults) fail();
244
271
  const configuration = loadAmemConfig(home).config;
245
- const context: SyncPlacementActivationContext = { ...base, homeHash: hashJSON([home, String(root.dev), String(root.ino), String(root.uid)]), sourceHash: hashJSON(facts),
246
- aliasesHash: digest(aliases ?? Buffer.alloc(0)), policyHash: hashJSON({ config: configuration, defaults, policyVersion }), realms,
247
- writers: attestation.writers, legacySyncQuiesced: attestation.legacySyncQuiesced };
248
- // Reuse the closed stage-1 context validator, including writer/version/realm bounds.
272
+ const contextBase: PlacementSourcePreview["base"] = { ...base,
273
+ homeHash: hashJSON([home, String(root.dev), String(root.ino), String(root.uid)]), sourceHash: hashJSON(facts),
274
+ aliasesHash: digest(aliases ?? Buffer.alloc(0)), policyHash: hashJSON({ config: configuration, defaults, policyVersion }), realms };
275
+ // ponytail: passive checks rely on native inode/size/ctime identity for the hashed prefixes.
276
+ // Immutable descriptor-bound source blocks are the upgrade path for hostile same-user raw writers.
277
+ const assertSourcesUnchanged = () => {
278
+ try {
279
+ const currentDefaults = memoryPolicySnapshot({ names: DENY_NAMES, segments: DENY_SEGMENTS });
280
+ if (!currentDefaults || hashJSON({ config: configuration, defaults: currentDefaults, policyVersion }) !== contextBase.policyHash) fail();
281
+ const now = ownedDirectory(home); if (now.dev !== root.dev || now.ino !== root.ino) fail();
282
+ for (const check of sourceChecks) check();
283
+ if (ownerProof) activationSourceMarkers(ownerProof,home,actualMarkers);
284
+ if(sourceOwner)assertPlacementActivationOwner(sourceOwner,home);
285
+ } catch { fail(); }
286
+ };
287
+ return { home, base: contextBase, assertSourcesUnchanged, ...(activeHead ? { activeHead } : {}), markerHash, markers,
288
+ directories: directories.sort(), prefixes, rows, specialRows, sourceRowsByRealm };
289
+ }
290
+ /** Read-only preview shares the source collector, without fabricating a quiescence attestation. */
291
+ export function previewSyncPlacementSources(home: string): PlacementSourcePreview {
292
+ return previewSources(home);
293
+ }
294
+ /** Internal renewal inventory under the exact live home/profile/mutation owner; no marker virtualization. */
295
+ export function previewOwnedSyncPlacementSources(home:string,owner:PlacementActivationOwner):PlacementSourcePreview {
296
+ assertPlacementActivationOwner(owner,home);return previewSources(home,owner);
297
+ }
298
+ function previewSources(home:string,owner?:PlacementActivationOwner):PlacementSourcePreview {
299
+ try { assertPlacementRuntime(home); const preview = sourcePreview(home,undefined,[],undefined,undefined,owner); preview.assertSourcesUnchanged(); return preview; }
300
+ catch (error) {
301
+ if (error && typeof error === "object" && !types.isProxy(error)) {
302
+ if (error instanceof SyncPlacementJournalError) throw error;
303
+ if (error instanceof SegmentReadBudgetError) return fail("bound");
304
+ }
305
+ return fail();
306
+ }
307
+ }
308
+ function capture(home: string, port: PlacementCapturePort, selected?: readonly PlacementRealm[], pending: readonly PlacementPendingSource[] = [], ownerProof?: PlacementActivationCaptureProof): Omit<PlacementCapture, "assertCurrent"> {
309
+ // All host callouts precede filesystem capture, including the final passive proof.
310
+ const attestation = memoryPolicySnapshot(port.attest()), policyVersion = port.policyVersion?.();
311
+ if (!attestation || port.policyVersion && typeof policyVersion !== "string") fail();
312
+ const source = sourcePreview(home, selected, pending, policyVersion, ownerProof);
313
+ const context: SyncPlacementActivationContext = { ...source.base, writers: attestation.writers, legacySyncQuiesced: attestation.legacySyncQuiesced };
249
314
  const validated = planSyncPlacementActivation(context, "0".repeat(64));
250
315
  context.writers = validated.writers; context.realms = validated.realms;
251
- // ponytail: fast rechecks rely on held mutation fences plus native inode/size/ctime identity for the already-hashed prefixes.
252
- // A hostile same-user raw writer is outside that contract; descriptor-bound immutable source blocks are the upgrade path.
316
+ const writerInventory=source.activeHead&&writerInventoryEvidence(source.home,source.activeHead)?readEffectiveSyncPlacementWriters(source.home):undefined;
317
+ if(writerInventory&&canonicalJSON(context.writers)!==canonicalJSON(writerInventory.writers))fail();
253
318
  const assertUnchanged = () => {
254
319
  try {
255
- // Host callbacks run first; the final filesystem proof never invokes caller code afterwards.
256
- const currentAttestation=memoryPolicySnapshot(port.attest()), currentVersion=port.policyVersion?.();
257
- if(!currentAttestation) fail();
258
- const checked=planSyncPlacementActivation({...context,writers:currentAttestation.writers,legacySyncQuiesced:currentAttestation.legacySyncQuiesced},"0".repeat(64));
259
- const currentDefaults=memoryPolicySnapshot({names:DENY_NAMES,segments:DENY_SEGMENTS});
260
- if(!currentDefaults)fail();
261
- if(canonicalJSON(checked.writers)!==canonicalJSON(context.writers)||hashJSON({config:configuration,defaults:currentDefaults,policyVersion:currentVersion})!==context.policyHash) fail();
262
- const now=ownedDirectory(home); if(now.dev!==root.dev||now.ino!==root.ino) fail();
263
- for(const check of sourceChecks)check();
320
+ const currentAttestation = memoryPolicySnapshot(port.attest()), currentVersion = port.policyVersion?.();
321
+ if (!currentAttestation) fail();
322
+ const checked = planSyncPlacementActivation({ ...context, writers: currentAttestation.writers, legacySyncQuiesced: currentAttestation.legacySyncQuiesced }, "0".repeat(64));
323
+ if (canonicalJSON(checked.writers) !== canonicalJSON(context.writers) || currentVersion !== policyVersion) fail();
324
+ writerInventory?.assertCurrent();
325
+ if(source.activeHead&&!writerInventory&&writerInventoryEvidence(source.home,source.activeHead))fail();
326
+ source.assertSourcesUnchanged();
264
327
  } catch { fail(); }
265
328
  };
266
- return { home, context, assertUnchanged, ...(activeHead ? { activeHead } : {}), markerHash, fingerprint: hashJSON({ context, markerHash }), directories: directories.sort(), prefixes, rows, specialRows, sourceRowsByRealm };
329
+ return { home: source.home, context, assertUnchanged, ...(source.activeHead ? { activeHead: source.activeHead } : {}),
330
+ ...(writerInventory?{writerInventory}:{}),markerHash: source.markerHash, fingerprint: hashJSON({ context, markerHash: source.markerHash,...(writerInventory?.revisionSequence?{writerRevisionHash:writerInventory.revisionHash}:{}) }), directories: source.directories,
331
+ prefixes: source.prefixes, rows: source.rows, specialRows: source.specialRows, sourceRowsByRealm: source.sourceRowsByRealm };
267
332
  }
333
+
268
334
  export function captureSyncPlacementContext(home: string, port: PlacementCapturePort, selected?: readonly PlacementRealm[], pendingInput: readonly PlacementPendingSource[] = []): PlacementCapture {
269
335
  try {
270
336
  const selection = selected === undefined ? undefined : memoryPolicySnapshot(selected);
@@ -282,6 +348,21 @@ export function captureSyncPlacementContext(home: string, port: PlacementCapture
282
348
  });
283
349
  const before = capture(home, port, selection, pending);
284
350
  return { ...before, assertCurrent: () => { try { if (capture(home, port, selection, pending).fingerprint !== before.fingerprint) fail(); } catch { fail(); } } };
351
+ } catch (error) {
352
+ if (error && typeof error === "object" && !types.isProxy(error)) {
353
+ if(error instanceof SharedWriterRevisionError){const code=Object.getOwnPropertyDescriptor(error,"code")?.value;if(code==="staging"||code==="pending")throw new SharedWriterRevisionError(code);}
354
+ if (error instanceof SyncPlacementJournalError) throw error;
355
+ if (error instanceof SegmentReadBudgetError) return fail("bound");
356
+ }
357
+ return fail();
358
+ }
359
+ }
360
+
361
+ /** Internal initial-activation capture only. Tokens require the actual profile owner, live home mutation and exact durable intent. */
362
+ export function captureSyncPlacementActivationContext(home: string, port: PlacementCapturePort, proof: PlacementActivationCaptureProof): PlacementCapture {
363
+ try {
364
+ const before = capture(home,port,undefined,[],proof);
365
+ return { ...before, assertCurrent: () => { if (capture(home,port,undefined,[],proof).fingerprint !== before.fingerprint) fail(); } };
285
366
  } catch (error) {
286
367
  if (error && typeof error === "object" && !types.isProxy(error)) {
287
368
  if (error instanceof SyncPlacementJournalError) throw error;
@@ -0,0 +1,84 @@
1
+ /** Expected inventory metadata only. Callers must still obtain fresh install attestations. */
2
+ import { lstatSync, opendirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { hashJSON } from "./generation.ts";
5
+ import { readPlacementRuntimeMarkers } from "./sync-placement-guard.ts";
6
+ import { readActivePlacementBinding, type ActivePlacementBinding } from "./sync-placement-active.ts";
7
+ import { readPlacementWriterJournalAnchors, placementWriterJournalToken } from "./sync-placement-journal-storage.ts";
8
+ import { readWriterHistory, isWriterRenewalIntentTempName } from "./sync-placement-writer-history.ts";
9
+ import { SharedWriterRevisionError, writerRevisionWriters, WRITER_RENEWAL_INTENT_FILE } from "./sync-placement-writer-revision-format.ts";
10
+ import { readInitialSyncPlacementWriterLocator, SYNC_PLACEMENT_WRITER_ROOTS_FILE } from "./sync-placement-writer-roots.ts";
11
+ import { requireWriterRenewalHomeFloor } from "./sync-placement-prerequisites.ts";
12
+ import type { SyncPlacementWriterAttestation } from "./sync-placement-plan.ts";
13
+
14
+ export interface EffectiveSyncPlacementWriters {
15
+ epochHash:string;activationHash:string;revisionSequence:number;revisionHash:string;roots?:readonly string[];
16
+ writers:readonly SyncPlacementWriterAttestation[];initialLocatorHash?:string;
17
+ assertCurrent():void;
18
+ }
19
+ function fail():never{throw new SharedWriterRevisionError("changed");}
20
+ function active(home:string):ActivePlacementBinding {
21
+ const markers=readPlacementRuntimeMarkers(home),value=readActivePlacementBinding(home,markers.profile,markers.shards);if(!value)fail();return value;
22
+ }
23
+ const key=(value:ActivePlacementBinding)=>hashJSON({epoch:value.epoch,activation:value.activation,head:value.profile.placementHead,generation:value.profile.generation});
24
+ const fileStamp=(home:string)=>{
25
+ const s=lstatSync(join(home,SYNC_PLACEMENT_WRITER_ROOTS_FILE),{bigint:true,throwIfNoEntry:false});
26
+ return s?[s.dev,s.ino,s.mode,s.uid,s.nlink,s.size,s.mtimeNs,s.ctimeNs].map(String).join(":"):undefined;
27
+ };
28
+ /** A source-only capture can bootstrap its initial fence; do not impose renewal admission on that old path. */
29
+ export function writerInventoryEvidence(home:string,epochHash:string):boolean{
30
+ const scan=(dir:string,maximum:number,match:(name:string)=>boolean)=>{
31
+ const reader=opendirSync(dir);let count=0,found=false;try{for(;;){const e=reader.readSync();if(!e)break;if(++count>maximum)throw new SharedWriterRevisionError("bound");if(match(e.name))found=true;}}finally{reader.closeSync();}return found;
32
+ };
33
+ const outside=scan(home,20000,name=>{
34
+ if(name.startsWith(WRITER_RENEWAL_INTENT_FILE+".pending-")&&!isWriterRenewalIntentTempName(name))throw new SharedWriterRevisionError("invalid");
35
+ return name===WRITER_RENEWAL_INTENT_FILE||isWriterRenewalIntentTempName(name);
36
+ });
37
+ let dir=home;
38
+ for(const part of ["sync-placement","epochs",epochHash]){
39
+ dir=join(dir,part);const s=lstatSync(dir,{throwIfNoEntry:false});if(!s)return outside;
40
+ if(!s.isDirectory()||s.isSymbolicLink()||s.uid!==process.getuid?.()||(s.mode&0o077)!==0)throw new SharedWriterRevisionError("invalid");
41
+ }
42
+ return scan(dir,255,name=>name.startsWith("writer-"))||outside;
43
+ }
44
+ export function readEffectiveSyncPlacementWriters(home:string):EffectiveSyncPlacementWriters {
45
+ const binding=active(home),bindingKey=key(binding),epochHash=binding.profile.placementHead;
46
+ writerInventoryEvidence(home,epochHash);
47
+ const history=readWriterHistory(home,{epochHash,activationHash:binding.activation.seal,homeHash:binding.epoch.homeHash,generation:binding.epoch.generation});
48
+ const initialStamp=fileStamp(home),initial=initialStamp===undefined?undefined:readInitialSyncPlacementWriterLocator(home);
49
+ if(history.sequence){
50
+ requireWriterRenewalHomeFloor(home);
51
+ if(!initial||history.current?.initialLocatorHash!==initial.seal)fail();
52
+ }
53
+ let anchorToken:string|undefined;
54
+ // ponytail: native inode/size/ctime identity permits prefix reuse; hostile same-user rewrites need immutable snapshot blocks.
55
+ const anchors=()=>{
56
+ if(!history.sequence)return;
57
+ const token=placementWriterJournalToken(home,epochHash);if(token===anchorToken)return;
58
+ const journal=readPlacementWriterJournalAnchors(home,epochHash);
59
+ if(journal.epoch.homeHash!==binding.epoch.homeHash||journal.activation.seal!==binding.activation.seal)fail();
60
+ for(const anchor of history.anchors){
61
+ if(anchor.sequence>journal.sequence||(anchor.sequence===0?epochHash:journal.records[anchor.sequence-1]?.seal)!==anchor.headHash)fail();
62
+ }
63
+ if(placementWriterJournalToken(home,epochHash)!==token)fail();anchorToken=token;
64
+ };
65
+ const assertCurrent=()=>{
66
+ history.assertCurrent();if(key(active(home))!==bindingKey||fileStamp(home)!==initialStamp)fail();
67
+ if(initial&&readInitialSyncPlacementWriterLocator(home).seal!==initial.seal)fail();
68
+ if(history.sequence)requireWriterRenewalHomeFloor(home);
69
+ anchors();history.assertCurrent();
70
+ };
71
+ anchors();assertCurrent();
72
+ const roots=history.current?.roots.map(root=>root.root)??initial?.roots.map(root=>root.root);
73
+ return{epochHash,activationHash:binding.activation.seal,revisionSequence:history.sequence,revisionHash:history.headHash,
74
+ writers:history.current?writerRevisionWriters(history.current):binding.activation.writers,
75
+ ...(roots?{roots}:{}),...(initial?{initialLocatorHash:initial.seal}:{}),assertCurrent};
76
+ }
77
+ /** Locators only; these paths and expected pins never replace a fresh callback. */
78
+ export function readEffectiveSyncPlacementWriterRoots(home:string):string[]{
79
+ const value=readEffectiveSyncPlacementWriters(home);if(!value.roots)throw new SharedWriterRevisionError("invalid");value.assertCurrent();return[...value.roots];
80
+ }
81
+ /** Keep existing no-revision artifacts byte-identical while binding renewed attempts to their committed head. */
82
+ export function writerBoundPlacementContextHash(context:unknown,inventory?:Pick<EffectiveSyncPlacementWriters,"revisionSequence"|"revisionHash">):string {
83
+ return inventory?.revisionSequence?hashJSON(["agent-mem.sync-placement-writer-bound-context/v1",context,inventory.revisionHash]):hashJSON(context);
84
+ }
@@ -6,12 +6,15 @@ import { join } from "node:path";
6
6
  import { LOCAL_METADATA_MAX_BYTES, legacyProfileMarkerShape, type BoundProfile } from "./generation.ts";
7
7
  import { legacyShardMarkerShape } from "./shard.ts";
8
8
  import { PLACEMENT_PROFILE_SCHEMA, PLACEMENT_SHARDS_SCHEMA, validatePlacementProfile, validatePlacementShards } from "./sync-placement-format.ts";
9
+ import { PLACEMENT_ACTIVATION_INTENT } from "./sync-placement-activation-path.ts";
10
+ import { profileLockRefusal, rememberProfileLockError } from "./profile-lock-diagnostic.ts";
9
11
 
10
12
  export const PLACEMENT_STAGE1_REFUSAL = "sync placement v3 requires matching durable activation metadata; keep the current compatible profile active until person-authorized activation is complete";
11
13
  export const PLACEMENT_MARKER_REFUSAL = "sync placement metadata is invalid or unsupported; person inspection and a compatible reader are required before continuing";
14
+ export const PLACEMENT_ACTIVATION_PENDING = "sync placement activation is pending or incomplete; have the person inspect retained evidence and run amem migrate --sync-placement --resume, or --abandon only while both markers retain their original v2 bytes";
12
15
  export class SyncPlacementRuntimeError extends Error {
13
16
  readonly code: "sync_placement_not_enabled" | "sync_placement_unavailable";
14
- constructor(reason: string) { super(reason); this.name = "SyncPlacementRuntimeError"; this.code = reason === PLACEMENT_STAGE1_REFUSAL ? "sync_placement_not_enabled" : "sync_placement_unavailable"; }
17
+ constructor(reason: string) { super(reason); this.name = "SyncPlacementRuntimeError"; this.code = reason === PLACEMENT_STAGE1_REFUSAL ? "sync_placement_not_enabled" : "sync_placement_unavailable";rememberProfileLockError(this,reason); }
15
18
  }
16
19
  class MarkerChanged extends Error {}
17
20
  const same = (left: BigIntStats, right: BigIntStats) => left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeNs === right.mtimeNs && left.ctimeNs === right.ctimeNs;
@@ -63,15 +66,22 @@ export function placementMarkerRefusal(profile: unknown, shards: unknown): strin
63
66
  /** Selector preflight only recognizes v3. Legacy malformed/split metadata must retain its existing reader/recovery path. */
64
67
  // This narrow probe is not a data/transport gate; effects must still use the full runtime guard.
65
68
  export function placementSelectorRefusal(home: string): string | undefined {
69
+ const refusal=selectorRefusal(home);return refusal?profileLockRefusal(home)??refusal:undefined;
70
+ }
71
+ function selectorRefusal(home:string):string|undefined {
66
72
  // A durable placement fence forbids falling back to raw legacy sync after marker rollback.
67
73
  const fenced = placementFencePresent(home);
74
+ const pending = placementActivationPending(home);
68
75
  let profile: unknown, shards: unknown;
69
76
  try { profile = readMarker(join(home, "profile.json")); } catch { /* The original selector reader reports this legacy failure. */ }
70
77
  try { shards = readMarker(join(home, "shards.json")); } catch { /* Ordinary migration still owns malformed split-state recovery. */ }
71
- if (schema(profile) !== PLACEMENT_PROFILE_SCHEMA && schema(shards) !== PLACEMENT_SHARDS_SCHEMA) return fenced ? PLACEMENT_MARKER_REFUSAL : undefined;
78
+ if (schema(profile) !== PLACEMENT_PROFILE_SCHEMA && schema(shards) !== PLACEMENT_SHARDS_SCHEMA) return pending ? PLACEMENT_ACTIVATION_PENDING : fenced ? PLACEMENT_MARKER_REFUSAL : undefined;
72
79
  const refusal = placementMarkerRefusal(profile, shards);
73
- if (refusal !== PLACEMENT_STAGE1_REFUSAL) return refusal;
74
- try { return readActivePlacementBinding(home, profile, shards) ? undefined : refusal; } catch { return PLACEMENT_MARKER_REFUSAL; }
80
+ if (refusal !== PLACEMENT_STAGE1_REFUSAL) return pending ? PLACEMENT_ACTIVATION_PENDING : refusal;
81
+ try { return readActivePlacementBinding(home, profile, shards) ? undefined : pending ? PLACEMENT_ACTIVATION_PENDING : refusal; } catch { return PLACEMENT_MARKER_REFUSAL; }
82
+ }
83
+ function placementActivationPending(home: string): boolean {
84
+ return lstatSync(join(home,PLACEMENT_ACTIVATION_INTENT),{throwIfNoEntry:false}) !== undefined;
75
85
  }
76
86
  function placementFencePresent(home: string): boolean {
77
87
  const root = lstatSync(join(home, "sync-placement"), { throwIfNoEntry: false });
@@ -82,17 +92,17 @@ function placementFencePresent(home: string): boolean {
82
92
  /** Bounded activation/view metadata only; no data/index open, cache mutation, lock or child/network call.
83
93
  * The v3 publication coordinator separately proves the complete journal, selected sources and policy. */
84
94
  export function placementRuntimeRefusal(home: string, bound?: Pick<BoundProfile,"kind"|"sharded"|"generation"|"publicationFormat"|"placementHead">): string | undefined {
85
- try { const fenced = placementFencePresent(home), markers = readPlacementRuntimeMarkers(home);
95
+ try { const fenced = placementFencePresent(home), pending = placementActivationPending(home), markers = readPlacementRuntimeMarkers(home);
86
96
  const refusal = placementMarkerRefusal(markers.profile, markers.shards);
87
97
  if (refusal === PLACEMENT_STAGE1_REFUSAL) {
88
98
  const active=readActivePlacementBinding(home,markers.profile,markers.shards);
89
- if(!active)return refusal;
99
+ if(!active)return pending ? PLACEMENT_ACTIVATION_PENDING : refusal;
90
100
  if(bound && (bound.kind!=="generation"||bound.sharded!==true||bound.generation!==active.profile.generation||bound.publicationFormat!==active.profile.publicationFormat||bound.placementHead!==active.profile.placementHead)) return PLACEMENT_MARKER_REFUSAL;
91
101
  return undefined;
92
102
  }
93
- return refusal ?? (fenced ? PLACEMENT_MARKER_REFUSAL : undefined); }
103
+ return refusal ?? (pending ? PLACEMENT_ACTIVATION_PENDING : fenced ? PLACEMENT_MARKER_REFUSAL : undefined); }
94
104
  catch { return PLACEMENT_MARKER_REFUSAL; }
95
105
  }
96
106
  export function assertPlacementRuntime(home: string): void {
97
- const refusal = placementRuntimeRefusal(home); if (refusal) throw new SyncPlacementRuntimeError(refusal);
107
+ const refusal = placementRuntimeRefusal(home); if (refusal) throw new SyncPlacementRuntimeError(profileLockRefusal(home)??refusal);
98
108
  }
@@ -25,6 +25,7 @@ import type { PlacementPublicationDecisionReason } from "./sync-placement-public
25
25
  import type { ShardSyncRealm, ShardSyncRow } from "./shard-sync-format.ts";
26
26
  import type { MemoryStore } from "./store.ts";
27
27
  import { captureInspectionPendingPrunes } from "./sync-placement-inspection-pending.ts";
28
+ import { SharedWriterRevisionError } from "./sync-placement-writer-revision-format.ts";
28
29
 
29
30
  export interface SyncPlacementInspectionOptions {
30
31
  screen?: RowScreen;
@@ -119,7 +120,7 @@ export async function inspectSyncPlacement(store: MemoryStore, input: SyncPlacem
119
120
  const port: PlacementCapturePort = { attest: options.placementAttestation, ...(options.policyVersion ? { policyVersion: options.policyVersion } : {}) };
120
121
  const capture = captureSyncPlacementContext(home, port, targets.map(target => physical(target.realm)));
121
122
  captured = true;
122
- if (capture.activeHead !== active.profile.placementHead || canonicalJSON(capture.context.writers) !== canonicalJSON(active.activation.writers)) fail("attestation-unavailable");
123
+ if (capture.activeHead !== active.profile.placementHead || canonicalJSON(capture.context.writers) !== canonicalJSON(capture.writerInventory?.writers??active.activation.writers)) fail("attestation-unavailable");
123
124
  phase = "journal-unavailable"; const retained = captureInspectionJournal(home, active.profile.placementHead);
124
125
  const check = () => {
125
126
  if (binding() !== beforeBinding) fail("binding-changed"); retained.assertCurrent(); capture.assertUnchanged();
@@ -219,6 +220,9 @@ export async function inspectSyncPlacement(store: MemoryStore, input: SyncPlacem
219
220
  } catch (error) {
220
221
  let reason: SyncPlacementInspectionIncompleteReason = phase;
221
222
  if (error && typeof error === "object" && !types.isProxy(error)) {
223
+ if(error instanceof SharedWriterRevisionError){const code=Object.getOwnPropertyDescriptor(error,"code")?.value;
224
+ if(code==="staging"||code==="pending")return{complete:false,reason:"journal-unavailable",nextStep:new SharedWriterRevisionError(code).message};
225
+ }
222
226
  if (error instanceof Incomplete) reason = error.reason;
223
227
  else if (error instanceof FormatFloorError) {
224
228
  if (error.code === "format_floor") reason = "reader-upgrade-required";
@@ -14,6 +14,8 @@ import { PLACEMENT_ACTIVE_FENCE_SCHEMA, PLACEMENT_JOURNAL_MAX_RECORDS, PLACEMENT
14
14
  import { validatePlacementPublicationRoot, validatePlacementPublicationBundle, validatePlacementPublicationFact, publicationFileNames,
15
15
  PLACEMENT_PUBLICATION_RECORD_MAX_BYTES, type PlacementPublicationRoot, type PlacementPublicationBundle, type PlacementPublicationFact } from "./sync-placement-publication-plan.ts";
16
16
  import { PlacementProjection } from "./sync-placement-projection.ts";
17
+ import { WRITER_RENEWAL_INTENT_FILE, WRITER_RENEWAL_INTENT_MAX_BYTES, WRITER_COMMIT_LOG, writerHistoryFileLimit, writerRevisionFile } from "./sync-placement-writer-revision-format.ts";
18
+ import { isWriterRenewalIntentTempName } from "./sync-placement-writer-history.ts";
17
19
 
18
20
  export type PlacementStoragePhase = "epoch-written" | "epoch-fsync" | "activation-written" | "activation-fsync" | "fence-written" | "fence-fsync"
19
21
  | "intent-written" | "intent-fsync" | "chunk-written" | "chunk-fsync" | "intent-cleared"
@@ -53,7 +55,18 @@ function names(dir: string, maximum: number): string[] {
53
55
  finally { reader.closeSync(); }
54
56
  return result.sort();
55
57
  }
56
- function inventory(p: Paths): { files: Map<string, BigIntStats>; token: string } {
58
+ function renewalIntentObjects(home:string):{count:number;token:unknown[]} {
59
+ const token:unknown[]=[];
60
+ for(const name of names(home,20000)){
61
+ if(name!==WRITER_RENEWAL_INTENT_FILE&&!isWriterRenewalIntentTempName(name))continue;
62
+ const s=lstatSync(join(home,name),{bigint:true});
63
+ if(!s.isFile()||s.isSymbolicLink()||s.uid!==BigInt(process.getuid?.()??-1)||(s.mode&0o777n)!==0o600n||![1n,2n].includes(s.nlink))fail();
64
+ if(s.size>BigInt(WRITER_RENEWAL_INTENT_MAX_BYTES))fail("bound");
65
+ token.push([name,String(s.dev),String(s.ino),String(s.size),String(s.nlink),String(s.mtimeNs),String(s.ctimeNs)]);
66
+ }
67
+ return{count:token.length,token};
68
+ }
69
+ function inventory(p: Paths): { files: Map<string, BigIntStats>; token: string; renewalObjects:number } {
57
70
  const parents: unknown[] = [];
58
71
  for (const dir of [p.home, p.root, p.epochs, p.dir]) { const stat = state(dir, true); parents.push([dir, stat ? [String(stat.dev), String(stat.ino)] : null]); }
59
72
  if (state(p.root, true)) for (const name of names(p.root, 2)) {
@@ -63,15 +76,17 @@ function inventory(p: Paths): { files: Map<string, BigIntStats>; token: string }
63
76
  const files = new Map<string, BigIntStats>();
64
77
  if (state(p.dir, true)) for (const name of names(p.dir, PLACEMENT_JOURNAL_MAX_OBJECTS - 1)) {
65
78
  const chunk = /^records-\d{5}\.jsonl$/.test(name);
66
- if (!chunk && !publicationName(name) && !["epoch.json", "activation.json", "append-intent.json"].includes(name)) fail();
79
+ const writerLimit=writerHistoryFileLimit(name);
80
+ if (!chunk && !publicationName(name) && writerLimit===undefined && !["epoch.json", "activation.json", "append-intent.json"].includes(name)) fail();
67
81
  const stat = state(join(p.dir, name))!;
68
- if (stat.size > BigInt(chunk || /-chunk-\d{5}\.json$/.test(name) ? PLACEMENT_JOURNAL_CHUNK_MAX_BYTES : PLACEMENT_JOURNAL_ROOT_MAX_BYTES)) fail("bound");
82
+ if (stat.size > BigInt(writerLimit??(chunk || /-chunk-\d{5}\.json$/.test(name) ? PLACEMENT_JOURNAL_CHUNK_MAX_BYTES : PLACEMENT_JOURNAL_ROOT_MAX_BYTES))) fail("bound");
69
83
  files.set(name, stat);
70
84
  }
71
85
  // One global active.json slot is always reserved, whether that fence exists yet or not.
72
- if (files.size + 1 > PLACEMENT_JOURNAL_MAX_OBJECTS) fail("bound");
86
+ const renewal=renewalIntentObjects(p.home);
87
+ if (files.size + 1 + renewal.count > PLACEMENT_JOURNAL_MAX_OBJECTS) fail("bound");
73
88
  const fence = state(join(p.root, "active.json"));
74
- return { files, token: canonicalJSON({ parents, files: [...files].map(([name, stat]) => [name, String(stat.dev), String(stat.ino), String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)]),
89
+ return { files, renewalObjects:renewal.count, token: canonicalJSON({ parents, renewal:renewal.token, files: [...files].map(([name, stat]) => [name, String(stat.dev), String(stat.ino), String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)]),
75
90
  fence: fence ? [String(fence.dev), String(fence.ino), String(fence.size), String(fence.mtimeNs), String(fence.ctimeNs)] : null }) };
76
91
  }
77
92
  function readOwned(file: string, maximum: number): Buffer | undefined {
@@ -152,7 +167,7 @@ function snapshot(p: Paths): Snapshot {
152
167
  if ((records.length > 0 || pendingBytes !== undefined) && (!activeFence || fencePending || activeFence.epochHash !== p.hash
153
168
  || activeFence.homeHash !== epoch.homeHash || activeFence.generation !== epoch.generation)) fail();
154
169
  if (inventory(p).token !== before.token) fail("changed");
155
- return { epoch, activation: plan, records, projection, headHash: projection.headHash, sequence: projection.sequence, objects: before.files.size + 1, pending: !!pending || intentPending || fencePending, chunks, intentPending, fencePending, ...(pending ? { pendingRecordSequence: pending.record.sequence, pendingRecord: validatePlacementRecord(pending.record) } : {}), ...(pending ? { intent: pending } : {}) };
170
+ return { epoch, activation: plan, records, projection, headHash: projection.headHash, sequence: projection.sequence, objects: before.files.size + 1 + before.renewalObjects, pending: !!pending || intentPending || fencePending, chunks, intentPending, fencePending, ...(pending ? { pendingRecordSequence: pending.record.sequence, pendingRecord: validatePlacementRecord(pending.record) } : {}), ...(pending ? { intent: pending } : {}) };
156
171
  }
157
172
  export function readPlacementJournal(home: string, epochHash: string): PlacementJournalRead {
158
173
  return protectedIO(() => {
@@ -168,12 +183,41 @@ export function readPlacementJournal(home: string, epochHash: string): Placement
168
183
  if (inventory(p).token !== before) fail("changed"); return result;
169
184
  });
170
185
  }
186
+ /** Writer history only needs the authoritative row prefix, including during owned publication-root writes.
187
+ * Full transport/recovery still uses readPlacementJournal and its reservation proof. */
188
+ export function placementWriterJournalToken(home:string,epochHash:string):string {
189
+ return protectedIO(()=>{
190
+ const p=paths(home,epochHash),dir=state(p.dir,true);if(!dir)fail("changed");
191
+ const rows:unknown[]=[["directory",String(dir.dev),String(dir.ino),String(dir.mode),String(dir.uid)]];
192
+ for(const name of names(p.dir,PLACEMENT_JOURNAL_MAX_OBJECTS-1))if(name.startsWith("records-")||name==="append-intent.json"){
193
+ const s=state(join(p.dir,name));if(!s)fail("changed");
194
+ rows.push([name,String(s.dev),String(s.ino),String(s.mode),String(s.uid),String(s.nlink),String(s.size),String(s.mtimeNs),String(s.ctimeNs)]);
195
+ }
196
+ return canonicalJSON(rows);
197
+ });
198
+ }
199
+ export function readPlacementWriterJournalAnchors(home:string,epochHash:string):Pick<PlacementJournalRead,"epoch"|"activation"|"records"|"sequence"|"headHash"> {
200
+ return protectedIO(()=>{
201
+ const read=snapshot(paths(home,epochHash));
202
+ return{epoch:read.epoch,activation:read.activation,records:read.records,sequence:read.sequence,headHash:read.headHash};
203
+ });
204
+ }
171
205
  function current(p: Paths, opts: PlacementStorageOptions, required: readonly string[] = []): void {
172
206
  try { assertMutationHandle(opts.mutation, p.home); } catch { return fail("busy"); }
173
207
  try { opts.assertCurrent(); } catch (error) { if (knownError(error)) throw error; return fail("changed"); }
174
208
  try { assertMutationHandle(opts.mutation, p.home); } catch { return fail("busy"); }
175
209
  const found = inventory(p), names = new Set([...found.files.keys(), ...required, ...reservedPublicationJournalNames(p)]);
176
- if (names.size + 1 > PLACEMENT_JOURNAL_MAX_OBJECTS) fail("bound");
210
+ if(found.renewalObjects)fail("pending");
211
+ if (names.size + 1 + found.renewalObjects > PLACEMENT_JOURNAL_MAX_OBJECTS) fail("bound");
212
+ }
213
+ /** One shared budget includes the outside intent and its transient immutable-publication companion. */
214
+ export function writerRenewalCapacity(home:string,epochHash:string,sequence:number):{objects:number;remainingObjects:number} {
215
+ return protectedIO(()=>{
216
+ const p=paths(home,epochHash),found=inventory(p),next=new Set([...found.files.keys(),...reservedPublicationJournalNames(p),WRITER_COMMIT_LOG,writerRevisionFile(sequence)]);
217
+ const objects=next.size+1+Math.max(2,found.renewalObjects);
218
+ if(objects>PLACEMENT_JOURNAL_MAX_OBJECTS)fail("bound");
219
+ return{objects,remainingObjects:PLACEMENT_JOURNAL_MAX_OBJECTS-objects};
220
+ });
177
221
  }
178
222
  function writer<T>(home: string, epochHash: string, opts: PlacementStorageOptions, work: (p: Paths) => T, incompleteAttempt?: string, preflight?: readonly string[] | ((p: Paths) => readonly string[])): T {
179
223
  return protectedIO(() => {
@@ -10,11 +10,12 @@ import { outboundPolicyEvaluator, type RowScreen } from "./sync-screen.ts";
10
10
  import { assertPlacementRuntime } from "./sync-placement-guard.ts";
11
11
  import { PLACEMENT_FORMAT } from "./sync-placement-format.ts";
12
12
  import { planSyncPlacementActivation, validatePlacementPlan, validateSyncPlacementActivation, type SyncPlacementActivationPlan } from "./sync-placement-plan.ts";
13
- import { captureSyncPlacementContext, type PlacementCapturePort, type PlacementCapture } from "./sync-placement-capture.ts";
13
+ import { captureSyncPlacementContext, placementCaptureContextHash, type PlacementCapturePort, type PlacementCapture } from "./sync-placement-capture.ts";
14
14
  import { PLACEMENT_EPOCH_SCHEMA, placementEpochHash, placementKeyId, validatePlacementEpoch, validatePlacementKey, validatePlacementEvent,
15
15
  SyncPlacementJournalError, type PlacementEpoch, type PlacementKey, type PlacementCommittedOutcome, type PlacementAmbiguousOutcome } from "./sync-placement-journal-format.ts";
16
16
  import { rebuildPlacementProjection } from "./sync-placement-projection.ts";
17
17
  import { preparePlacementEpoch, readPlacementJournal, appendPlacementEvent } from "./sync-placement-journal-storage.ts";
18
+ import { SharedWriterRevisionError } from "./sync-placement-writer-revision-format.ts";
18
19
 
19
20
  export interface PlacementJournalPreview { epoch: PlacementEpoch; plan: SyncPlacementActivationPlan }
20
21
  export interface PlacementAuthorizationBinding {
@@ -28,7 +29,13 @@ export interface PlacementJournalPort extends PlacementCapturePort {
28
29
  }
29
30
  function fail(code: "invalid" | "changed" | "authorization" | "policy" | "local" | "conflict" | "pending" | "busy" = "invalid"): never { throw new SyncPlacementJournalError(code); }
30
31
  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
+ try { return work(); } catch (error) {
33
+ if(error&&typeof error==="object"&&!types.isProxy(error)){
34
+ if(error instanceof SyncPlacementJournalError)throw error;
35
+ if(error instanceof SharedWriterRevisionError){const code=Object.getOwnPropertyDescriptor(error,"code")?.value;if(code==="staging"||code==="pending")throw new SharedWriterRevisionError(code);}
36
+ }
37
+ return fail("changed");
38
+ }
32
39
  }
33
40
  function person(port: PlacementJournalPort, action: "prepare" | "hold" | "release", seal: string, details: Omit<PlacementAuthorizationBinding, "action" | "seal"> = {}): void {
34
41
  let authorized = false;
@@ -86,7 +93,7 @@ function active(home: string, input: PlacementEffectRequest, port: PlacementCapt
86
93
  if (capture.activeHead !== input.epochHash || journal.epoch.homeHash !== capture.context.homeHash
87
94
  || journal.epoch.generation !== (capture.context.profile as { generation: string }).generation
88
95
  || journal.activation.targetProfile.placementHead !== input.epochHash
89
- || canonicalJSON(capture.context.writers) !== canonicalJSON(journal.activation.writers)
96
+ || canonicalJSON(capture.context.writers) !== canonicalJSON(capture.writerInventory?.writers??journal.activation.writers)
90
97
  || canonicalJSON(capture.context.realms.map(({ bindingHash: _hash, ...identity }) => identity))
91
98
  !== canonicalJSON(journal.activation.realms.map(({ bindingHash: _hash, ...identity }) => identity))) fail("changed");
92
99
  const markers = readPlacementRuntimeMarkers(capture.home), profile = validatePlacementProfile(markers.profile);
@@ -116,7 +123,7 @@ function effect(home: string, raw: PlacementEffectRequest, port: PlacementJourna
116
123
  if (!outboundPolicyEvaluator(capture.home, port.screen)(source.row as unknown as Record<string, unknown>).ok) fail("policy");
117
124
  partial = { ...fields, kind: "release" };
118
125
  } 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) });
126
+ const effectHash = hashJSON({ event: partial, expectedHead: input.expectedHead, markerHash: capture.markerHash, contextHash: placementCaptureContextHash(capture) });
120
127
  const event = validatePlacementEvent({ ...partial, effectHash });
121
128
  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
129
  expectedHead: input.expectedHead, policyHash: capture.context.policyHash });
@@ -0,0 +1,50 @@
1
+ /** Activation preflight only. Never migrates, opens an index, raises a floor or creates a home. */
2
+ import { lstatSync } from "node:fs";
3
+ import { realPath } from "./platform.ts";
4
+ import { hashJSON } from "./generation.ts";
5
+ import { assertFormatFloor, readFormatFloors } from "./formats.ts";
6
+ import { readPlacementRuntimeMarkers } from "./sync-placement-guard.ts";
7
+ import { readActivePlacementBinding, type ActivePlacementBinding } from "./sync-placement-active.ts";
8
+ import { PLACEMENT_PROFILE_SCHEMA, validatePlacementBaseV2, type PlacementBaseV2 } from "./sync-placement-format.ts";
9
+
10
+ export const PLACEMENT_ACTIVATION_MIN_READER = "0.21.0";
11
+ const preview = "amem migrate --sync-placement --dry-run";
12
+ function requireHomeFloor(home: string,minReader:string): string {
13
+ assertFormatFloor(home, "curated"); assertFormatFloor(home, "sync");
14
+ const floors = readFormatFloors(home), minimum = minReader.split(".").map(Number);
15
+ const adequate = floors?.some(row => {
16
+ if (row.surface !== "home" || !/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(row.minReader)) return false;
17
+ const parts = row.minReader.split(".").map(Number);
18
+ for (let i = 0; i < 3; i++) if (parts[i] !== minimum[i]) return parts[i]! > minimum[i]!;
19
+ return true;
20
+ });
21
+ if (!adequate) throw Error(`Sync placement requires a person-authored home floor of ${minReader} or later in formats.json; have the person append that record, then rerun ${preview}.`);
22
+ return hashJSON(floors);
23
+ }
24
+ export function requirePlacementHomeFloor(home:string):string{return requireHomeFloor(home,PLACEMENT_ACTIVATION_MIN_READER);}
25
+ export function requireWriterRenewalHomeFloor(home:string):string{return requireHomeFloor(home,"0.22.0");}
26
+ export interface PlacementActivationPrerequisites { home: string; floorHash: string; base?: PlacementBaseV2; active?: ActivePlacementBinding }
27
+ export function requirePlacementActivationPrerequisites(input: string): PlacementActivationPrerequisites {
28
+ let home: string;
29
+ try { home = realPath(input); const stat = lstatSync(home); if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) throw Error(); }
30
+ catch { throw Error("Sync placement memory home is unavailable or unsafe; select an existing private memory home and rerun amem migrate --sync-placement --dry-run."); }
31
+ assertFormatFloor(home, "curated"); assertFormatFloor(home, "sync");
32
+ const markers = readPlacementRuntimeMarkers(home);
33
+ const shards = markers.shards as { schema?: string; phase?: string } | undefined;
34
+ const profile = markers.profile as { schema?: string } | undefined;
35
+ if (!shards || shards.schema === "agent-mem.shards/v1" && shards.phase !== "active") {
36
+ throw Error("Sync placement requires an active shard home; run amem migrate --shards --dry-run and complete that person-reviewed migration first.");
37
+ }
38
+ if (shards.schema === "agent-mem.shards/v1") {
39
+ throw Error("Sync placement requires the v2 public-prune view; run amem migrate --public-prune-view --dry-run and complete that person-reviewed migration first.");
40
+ }
41
+ if (profile?.schema === PLACEMENT_PROFILE_SCHEMA) {
42
+ const active = readActivePlacementBinding(home, markers.profile, markers.shards);
43
+ if (!active) throw Error("Sync placement activation is incomplete; inspect retained evidence and run amem migrate --sync-placement --resume.");
44
+ return { home, floorHash: requirePlacementHomeFloor(home), active };
45
+ }
46
+ let base: PlacementBaseV2;
47
+ try { base = validatePlacementBaseV2(markers.profile, markers.shards); }
48
+ catch { throw Error("Sync placement base metadata is invalid or unsupported; inspect the active shard/public-prune metadata before rerunning amem migrate --sync-placement --dry-run."); }
49
+ return { home, floorHash: requirePlacementHomeFloor(home), base };
50
+ }