@staix/agent-mem 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +27 -1
  3. package/package.json +1 -1
  4. package/src/cli.ts +34 -4
  5. package/src/commands-table.ts +3 -0
  6. package/src/index.ts +6 -0
  7. package/src/memory/commands.ts +3 -2
  8. package/src/memory/public-prune-activation.ts +4 -4
  9. package/src/memory/shard-sync.ts +1 -2
  10. package/src/memory/sync-cadence.ts +2 -1
  11. package/src/memory/sync-placement-abandon.ts +142 -0
  12. package/src/memory/sync-placement-activation-locks.ts +28 -0
  13. package/src/memory/sync-placement-activation-path.ts +2 -0
  14. package/src/memory/sync-placement-activation-proof.ts +68 -0
  15. package/src/memory/sync-placement-activation-storage.ts +167 -0
  16. package/src/memory/sync-placement-activation.ts +185 -0
  17. package/src/memory/sync-placement-capture.ts +104 -23
  18. package/src/memory/sync-placement-effective-writers.ts +84 -0
  19. package/src/memory/sync-placement-guard.ts +12 -6
  20. package/src/memory/sync-placement-inspection.ts +5 -1
  21. package/src/memory/sync-placement-journal-storage.ts +51 -7
  22. package/src/memory/sync-placement-journal.ts +11 -4
  23. package/src/memory/sync-placement-prerequisites.ts +50 -0
  24. package/src/memory/sync-placement-transport.ts +17 -6
  25. package/src/memory/sync-placement-writer-history.ts +278 -0
  26. package/src/memory/sync-placement-writer-renewal.ts +179 -0
  27. package/src/memory/sync-placement-writer-revision-format.ts +122 -0
  28. package/src/memory/sync-placement-writer-roots.ts +167 -0
  29. package/src/memory/sync-placement-writers.ts +135 -0
  30. package/src/memory/sync.ts +1 -2
  31. package/src/setup.ts +101 -14
  32. package/src/sync-placement-writer-context.ts +136 -0
@@ -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,9 +6,11 @@ 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";
9
10
 
10
11
  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
12
  export const PLACEMENT_MARKER_REFUSAL = "sync placement metadata is invalid or unsupported; person inspection and a compatible reader are required before continuing";
13
+ 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
14
  export class SyncPlacementRuntimeError extends Error {
13
15
  readonly code: "sync_placement_not_enabled" | "sync_placement_unavailable";
14
16
  constructor(reason: string) { super(reason); this.name = "SyncPlacementRuntimeError"; this.code = reason === PLACEMENT_STAGE1_REFUSAL ? "sync_placement_not_enabled" : "sync_placement_unavailable"; }
@@ -65,13 +67,17 @@ export function placementMarkerRefusal(profile: unknown, shards: unknown): strin
65
67
  export function placementSelectorRefusal(home: string): string | undefined {
66
68
  // A durable placement fence forbids falling back to raw legacy sync after marker rollback.
67
69
  const fenced = placementFencePresent(home);
70
+ const pending = placementActivationPending(home);
68
71
  let profile: unknown, shards: unknown;
69
72
  try { profile = readMarker(join(home, "profile.json")); } catch { /* The original selector reader reports this legacy failure. */ }
70
73
  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;
74
+ if (schema(profile) !== PLACEMENT_PROFILE_SCHEMA && schema(shards) !== PLACEMENT_SHARDS_SCHEMA) return pending ? PLACEMENT_ACTIVATION_PENDING : fenced ? PLACEMENT_MARKER_REFUSAL : undefined;
72
75
  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; }
76
+ if (refusal !== PLACEMENT_STAGE1_REFUSAL) return pending ? PLACEMENT_ACTIVATION_PENDING : refusal;
77
+ try { return readActivePlacementBinding(home, profile, shards) ? undefined : pending ? PLACEMENT_ACTIVATION_PENDING : refusal; } catch { return PLACEMENT_MARKER_REFUSAL; }
78
+ }
79
+ function placementActivationPending(home: string): boolean {
80
+ return lstatSync(join(home,PLACEMENT_ACTIVATION_INTENT),{throwIfNoEntry:false}) !== undefined;
75
81
  }
76
82
  function placementFencePresent(home: string): boolean {
77
83
  const root = lstatSync(join(home, "sync-placement"), { throwIfNoEntry: false });
@@ -82,15 +88,15 @@ function placementFencePresent(home: string): boolean {
82
88
  /** Bounded activation/view metadata only; no data/index open, cache mutation, lock or child/network call.
83
89
  * The v3 publication coordinator separately proves the complete journal, selected sources and policy. */
84
90
  export function placementRuntimeRefusal(home: string, bound?: Pick<BoundProfile,"kind"|"sharded"|"generation"|"publicationFormat"|"placementHead">): string | undefined {
85
- try { const fenced = placementFencePresent(home), markers = readPlacementRuntimeMarkers(home);
91
+ try { const fenced = placementFencePresent(home), pending = placementActivationPending(home), markers = readPlacementRuntimeMarkers(home);
86
92
  const refusal = placementMarkerRefusal(markers.profile, markers.shards);
87
93
  if (refusal === PLACEMENT_STAGE1_REFUSAL) {
88
94
  const active=readActivePlacementBinding(home,markers.profile,markers.shards);
89
- if(!active)return refusal;
95
+ if(!active)return pending ? PLACEMENT_ACTIVATION_PENDING : refusal;
90
96
  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
97
  return undefined;
92
98
  }
93
- return refusal ?? (fenced ? PLACEMENT_MARKER_REFUSAL : undefined); }
99
+ return refusal ?? (pending ? PLACEMENT_ACTIVATION_PENDING : fenced ? PLACEMENT_MARKER_REFUSAL : undefined); }
94
100
  catch { return PLACEMENT_MARKER_REFUSAL; }
95
101
  }
96
102
  export function assertPlacementRuntime(home: string): void {
@@ -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
+ }
@@ -11,7 +11,10 @@ import { memoryPolicySnapshot } from "./policy.ts";
11
11
  import { outboundPolicyEvaluator } from "./sync-screen.ts";
12
12
  import { readActivePlacementBinding } from "./sync-placement-active.ts";
13
13
  import { readPlacementRuntimeMarkers } from "./sync-placement-guard.ts";
14
- import { captureSyncPlacementContext, type PlacementCapture, type PlacementPendingSource } from "./sync-placement-capture.ts";
14
+ import { captureSyncPlacementContext, placementCaptureContextHash, type PlacementCapture, type PlacementPendingSource } from "./sync-placement-capture.ts";
15
+ import { readEffectiveSyncPlacementWriters, type EffectiveSyncPlacementWriters } from "./sync-placement-effective-writers.ts";
16
+ import { SharedWriterRevisionError } from "./sync-placement-writer-revision-format.ts";
17
+ import { types } from "node:util";
15
18
  import type { PlannedGitObject } from "./sync-placement-git.ts";
16
19
  import { PLACEMENT_FORMAT } from "./sync-placement-format.ts";
17
20
  import { createPlacementRecord, placementKeyId, type PlacementEvent, type PlacementRealm, type PlacementHoldReason, type PlacementRecord } from "./sync-placement-journal-format.ts";
@@ -50,15 +53,18 @@ class PlacementTransport {
50
53
  targets: Target[] = []; homeMutation?: MutationHandle; rootMutation?: MutationHandle; cacheIdentity?: {dev:number;ino:number}; cacheConfigIdentity?: string; epochHash = ""; journal!: PlacementJournalRead; capture?: PlacementCapture;
51
54
  machineId = ""; url = ""; revision = ""; accepted = false; factual = false; attempted = false; lastPolicyCheckUnixMs?: number; lastPolicyHash?: string; policyCheckSource?: "live"|"durable-intent"; attemptHash?: string;
52
55
  evaluator?: ReturnType<typeof outboundPolicyEvaluator>; applications?: ShardSyncApplications; current?: Materialized; recovered = false;
56
+ writerInventory?:EffectiveSyncPlacementWriters;
53
57
  constructor(store: MemoryStore, opts: SyncOptions, phase?: Phase, testOnlyError?: (error:unknown)=>void) { this.testOnlyError=testOnlyError; this.store = store; this.opts = opts; this.phase = phase; this.home = store.profile.home; this.root = join(this.home, "shard-sync-placement"); this.cache = join(this.root, "objects.git"); }
54
58
  attest(): void {
55
59
  if (!this.opts.placementAttestation || this.opts.screen && !this.opts.policyVersion) refuse();
56
60
  const value = memoryPolicySnapshot(this.opts.placementAttestation());
57
61
  if (!value || value.legacySyncQuiesced !== true || !Array.isArray(value.writers)) refuse();
58
62
  const writers = [...value.writers].sort((a,b) => a.writerId.localeCompare(b.writerId));
59
- if (canonicalJSON(writers) !== canonicalJSON(this.journal.activation.writers)) refuse();
63
+ if(!this.writerInventory||canonicalJSON(writers)!==canonicalJSON(this.writerInventory.writers))refuse();
64
+ this.writerInventory.assertCurrent();
60
65
  }
61
66
  metadata(): void {
67
+ this.writerInventory?.assertCurrent();
62
68
  if(this.rootMutation)assertMutationHandle(this.rootMutation,this.root);
63
69
  if(this.cacheIdentity){const stat=lstatSync(this.cache);if(stat.isSymbolicLink()||!stat.isDirectory()||stat.dev!==this.cacheIdentity.dev||stat.ino!==this.cacheIdentity.ino)refuse();}
64
70
  if(this.cacheIdentity){
@@ -105,7 +111,7 @@ class PlacementTransport {
105
111
  async initialize(): Promise<void> {
106
112
  const markers = readPlacementRuntimeMarkers(this.home), active = readActivePlacementBinding(this.home, markers.profile, markers.shards);
107
113
  if (!active || this.store.profile.publicationFormat !== PLACEMENT_FORMAT || this.store.profile.placementHead !== active.profile.placementHead) refuse();
108
- this.epochHash = active.profile.placementHead; this.machineId = active.shards.machine.id; this.revision = readSelector(this.home).revision; this.journal = readPlacementJournal(this.home, this.epochHash); this.attest();
114
+ this.epochHash = active.profile.placementHead; this.machineId = active.shards.machine.id; this.revision = readSelector(this.home).revision; this.journal = readPlacementJournal(this.home, this.epochHash); this.writerInventory=readEffectiveSyncPlacementWriters(this.home);this.attest();
109
115
  const opened = this.store.openedSyncTargets();
110
116
  if (!opened.length || opened.length > 2 || !opened.includes(this.store) || opened.some(item => item.profile.home !== this.home || !item.profile.sharded || !item.profile.generation
111
117
  || !item.profile.shardUser && item.profile.shardKey !== this.store.profile.shardKey)) refuse();
@@ -186,7 +192,8 @@ class PlacementTransport {
186
192
  pathHash: digest(`log/${item.row.machine}.jsonl`), offset: item.offset, prefixHash: item.prefixHash, line: item.line })));
187
193
  this.capture = captureSyncPlacementContext(this.home, { attest: this.opts.placementAttestation!, ...(this.opts.policyVersion ? { policyVersion: this.opts.policyVersion } : {}) }, this.targets.map(physical), pending);
188
194
  if (this.capture.activeHead !== this.epochHash || this.capture.context.homeHash !== this.journal.epoch.homeHash
189
- || canonicalJSON(this.capture.context.writers) !== canonicalJSON(this.journal.activation.writers)) refuse();
195
+ || canonicalJSON(this.capture.context.writers) !== canonicalJSON(this.writerInventory?.writers)
196
+ || !!this.writerInventory?.revisionSequence&&this.capture.writerInventory?.revisionHash!==this.writerInventory.revisionHash) refuse();
190
197
  this.check();
191
198
  }
192
199
  async applicationCoordinator(): Promise<ShardSyncApplications> {
@@ -232,7 +239,7 @@ class PlacementTransport {
232
239
  publisherLineages:(index,rows)=>{const target=this.targets[index]!;assertPublisherBoundary(target.store.profile,target.mutation!);return publisherPruneLineages(target.store.profile,target.mutation!,rows);}});
233
240
  for(const [index,target]of this.targets.entries())Object.assign(target,materialized.targets[index]!);
234
241
  const {decisions,plannedTargets:targets,objects}=materialized;
235
- const body={epochHash:this.epochHash,generation:this.journal.epoch.generation,homeHash:capture.context.homeHash,contextHash:hashJSON(capture.context),markerHash:capture.markerHash,
242
+ const body={epochHash:this.epochHash,generation:this.journal.epoch.generation,homeHash:capture.context.homeHash,contextHash:placementCaptureContextHash(capture),markerHash:capture.markerHash,
236
243
  sourceHash:capture.context.sourceHash,aliasesHash:capture.context.aliasesHash,policyHash:capture.context.policyHash,remoteHash:digest(this.url),
237
244
  journal:frozen?.root.journal??{sequence:frontier.sequence,headHash:frontier.headHash},createdUnixSeconds,policySnapshotUnixMs:frozen?.root.policySnapshotUnixMs??this.lastPolicyCheckUnixMs??Date.now(),targets,reservedRecords:0,reservedRecordBytes:[] as number[]};
238
245
  let bundle=planPlacementPublication(body,{sources:capture.prefixes,decisions,objects});
@@ -446,7 +453,7 @@ class PlacementTransport {
446
453
  catch { if(retained)return await this.terminalUnknown(retained);throw new Error("placement source preflight unavailable"); }
447
454
  if(retained){
448
455
  const root=retained.bundle.root;
449
- const stable=root.contextHash===hashJSON(this.capture!.context)&&root.markerHash===this.capture!.markerHash&&root.sourceHash===this.capture!.context.sourceHash
456
+ const stable=root.contextHash===placementCaptureContextHash(this.capture!)&&root.markerHash===this.capture!.markerHash&&root.sourceHash===this.capture!.context.sourceHash
450
457
  &&root.policyHash===this.capture!.context.policyHash&&root.targets.every((target,index)=>target.expectedRemoteHead===(this.targets[index]?.remote??null));
451
458
  if(!stable)return await this.terminalUnknown(retained);
452
459
  }
@@ -476,6 +483,10 @@ class PlacementTransport {
476
483
  this.factual=true;return await this.finish(material);
477
484
  }catch(error){
478
485
  try { this.testOnlyError?.(error); } catch { /* Test observers never alter production results. */ }
486
+ if(error&&typeof error==="object"&&!types.isProxy(error)&&error instanceof SharedWriterRevisionError){
487
+ const code=Object.getOwnPropertyDescriptor(error,"code")?.value;
488
+ if(code==="staging"||code==="pending")return this.result(false,this.attemptHash?"unknown":"not-needed",new SharedWriterRevisionError(code).message);
489
+ }
479
490
  return this.result(false,this.attemptHash?"unknown":"not-needed",this.attemptHash?"Placement sync stopped with a retained attempt that may have published; recheck its exact receipts before another publication.":"Placement sync is unavailable; verify the active epoch, selected sources, policy, private remote and writer pins before retrying.");
480
491
  }finally{
481
492
  let ownedCache=true;try{this.metadata();}catch{ownedCache=false;}