@vaur94/agz-memory 0.4.1 → 0.5.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.
@@ -1,5 +1,5 @@
1
1
  import * as z from "zod/v4";
2
- export declare const CAPTURE_SCHEMA: "agz-memory.capture/1";
2
+ export declare const CAPTURE_SCHEMA: "agz-memory.capture/2";
3
3
  export declare const SUPPORTED_OPENCODE_VERSION: "0.0.0-beta-18743";
4
4
  export declare const CAPTURE_EVENT_MAX_BYTES: number;
5
5
  export declare const CAPTURE_CONTENT_MAX_CHARACTERS = 4800;
@@ -34,7 +34,7 @@ declare const candidateSchema: z.ZodObject<{
34
34
  }>;
35
35
  }, z.core.$strict>;
36
36
  export declare const captureEventSchema: z.ZodObject<{
37
- schema: z.ZodLiteral<"agz-memory.capture/1">;
37
+ schema: z.ZodLiteral<"agz-memory.capture/2">;
38
38
  idempotencyKey: z.ZodString;
39
39
  projectID: z.ZodUUID;
40
40
  bindingKey: z.ZodString;
@@ -96,7 +96,9 @@ export declare const captureEventSchema: z.ZodObject<{
96
96
  truncated: z.ZodBoolean;
97
97
  }, z.core.$strict>;
98
98
  }, z.core.$strict>;
99
- export type MemoryCandidateV1 = z.infer<typeof candidateSchema>;
100
- export type CaptureEventV1 = z.infer<typeof captureEventSchema>;
101
- export declare function parseCaptureEvent(value: unknown): CaptureEventV1;
99
+ export type MemoryCandidateV2 = z.infer<typeof candidateSchema>;
100
+ export type CaptureEventV2 = z.infer<typeof captureEventSchema>;
101
+ export type MemoryCandidateV1 = MemoryCandidateV2;
102
+ export type CaptureEventV1 = CaptureEventV2;
103
+ export declare function parseCaptureEvent(value: unknown): CaptureEventV2;
102
104
  export {};
@@ -9,4 +9,5 @@ export interface RedactionResult {
9
9
  export declare function redactText(value: string, options?: {
10
10
  maxCharacters?: number;
11
11
  denylist?: readonly string[];
12
+ sourceTruncated?: boolean;
12
13
  }): RedactionResult;
@@ -1 +1 @@
1
- export declare const MEMORY_GUIDANCE = "Use project-scoped memory for durable facts across sessions.\n- Start with project_list. Use the immutable projectID for stable references; projectName is a convenient unique lookup.\n- Create a project with project_create before storing its first note. Renaming a project never changes its ID.\n- Every memory_recall, memory_read, memory_update, memory_link, and memory_pin call must select exactly one project by projectID or projectName.\n- No notes are injected automatically; use memory_recall for relevant project history.\n- Read indexed note bodies and graph neighbors with memory_read.\n- Store only durable verified facts, decisions, procedures, research, preferences, or substantial completed work.\n- Use memory_pin to prioritize important matching notes inside their project. Pinning never moves notes between projects.\n- Never save transcripts, guesses, secrets, or routine progress.\n- project_delete permanently destroys the project and all of its memory. Call project_list first and provide the immutable ID, exact current name, and required confirmation phrase only when deletion is explicitly intended.";
1
+ export declare const MEMORY_GUIDANCE = "Use project-scoped memory for durable facts across sessions.\n- Start with project_list. Reuse a project only when it intentionally represents the same durable workspace or product; create one only when no matching project exists. If the listed projects are ambiguous, ask rather than guessing from a directory or session name.\n- Prefer the immutable projectID for stable references. projectName is a convenient unique lookup, but names can change.\n- Every memory_recall, memory_read, memory_update, memory_link, and memory_pin call must select exactly one project by projectID or projectName.\n- The MCP server does not inject notes automatically. Recall relevant history before relying on prior decisions, and use memory_read for full indexed content and graph neighbors.\n- After substantial completed work, update an existing note or create a new one only for durable verified facts, decisions, procedures, research, preferences, tasks, or context.\n- Never save transcripts, guesses, secrets, credentials, hidden reasoning, or routine progress.\n- Use memory_pin only to prioritize important matching notes. Read directed links as sourceID PREDICATE targetID; links never cross projects.\n- Inspect every result from a batch because mutations are ordered and non-atomic: earlier items remain applied when a later item fails.\n- memory_update with delete:true permanently deletes one note. project_delete permanently deletes a project and all owned memory. Verify current IDs first and use destructive operations only when explicitly intended.";
@@ -1,4 +1,4 @@
1
- import type { RetrievalBackend } from "./retrieval/contract";
1
+ import type { OutboxBackend, RetrievalBackend } from "./retrieval/contract";
2
2
  import { type OpenedDB } from "./db";
3
3
  import { CaptureStore } from "./store/capture";
4
4
  import { MemoryStore } from "./store";
@@ -7,7 +7,7 @@ import { RetrievalStore } from "./store/retrieval";
7
7
  export interface MemoryCoreOptions {
8
8
  indexBackends?: readonly string[];
9
9
  retrievalBackend?: RetrievalBackend;
10
- outboxBackends?: ReadonlyMap<string, RetrievalBackend>;
10
+ outboxBackends?: ReadonlyMap<string, OutboxBackend>;
11
11
  }
12
12
  export declare class MemoryCore {
13
13
  private opened;
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { type MaintenanceGate } from "./maintenance";
2
3
  export declare const BACKUP_FORMAT: "agz-memory-backup/1";
3
4
  export interface BackupManifest {
4
5
  format: typeof BACKUP_FORMAT;
@@ -21,5 +22,5 @@ export interface VerifiedBackup {
21
22
  }
22
23
  export declare function createVerifiedBackup(db: Database, databasePath: string, sourceSchema: number, targetSchema: number, productVersion: string): VerifiedBackup;
23
24
  export declare function verifyBackupManifest(manifestPath: string): VerifiedBackup;
24
- export declare function restoreVerifiedBackup(manifestPath: string, targetPath: string, confirmation: string): string;
25
+ export declare function restoreVerifiedBackup(manifestPath: string, targetPath: string, confirmation: string, existingGate?: MaintenanceGate, expectedSha256?: string): string;
25
26
  export declare function inspectBackupDatabase(path: string): import("./health").DatabaseHealth;
@@ -7,5 +7,7 @@ export interface DatabaseHealth {
7
7
  }
8
8
  export declare function inspectDatabase(db: Database): DatabaseHealth;
9
9
  export declare function assertHealthyDatabase(db: Database): DatabaseHealth;
10
+ export declare function assertSchemaV11(db: Database): void;
11
+ export declare function isSQLiteBusyError(error: unknown): boolean;
10
12
  export declare function hasTable(db: Database, table: string): boolean;
11
13
  export declare function hasColumn(db: Database, table: string, column: string): boolean;
@@ -0,0 +1,2 @@
1
+ import type { Database } from "bun:sqlite";
2
+ export declare function assertLegacySchemaIdentity(db: Database, version: number): void;
@@ -0,0 +1,21 @@
1
+ export interface DatabaseLease {
2
+ databasePath: string;
3
+ release: () => void;
4
+ }
5
+ export interface MaintenanceGate {
6
+ databasePath: string;
7
+ release: () => void;
8
+ assertOwned: () => void;
9
+ retain: () => void;
10
+ }
11
+ export interface MaintenanceRecovery {
12
+ ownerID: string;
13
+ confirmation: "RECOVER_RETAINED_MAINTENANCE_GATE";
14
+ }
15
+ export declare function acquireDatabaseLease(databasePath: string): DatabaseLease;
16
+ export declare function acquireMaintenanceGate(databasePath: string, recovery?: MaintenanceRecovery): MaintenanceGate;
17
+ export declare function recoverStaleMaintenanceGate(databasePath: string, validate: () => void): boolean;
18
+ export declare function assertMaintenanceGateFor(gate: MaintenanceGate, databasePath: string): void;
19
+ export declare function canonicalDatabasePath(path: string): string;
20
+ export declare function ensureDatabaseParent(path: string): void;
21
+ export declare function assertNoSymbolicLinks(path: string, allowMissingLeaf?: boolean): void;
@@ -0,0 +1,3 @@
1
+ import type { Database } from "bun:sqlite";
2
+ export declare function migrateV10ToV11(db: Database): void;
3
+ export declare function assertV10SourceDatabase(db: Database): void;
@@ -1,6 +1,15 @@
1
- import type { Database } from "bun:sqlite";
1
+ import { Database } from "bun:sqlite";
2
+ export declare const APPLICATION_ID = 1095195213;
3
+ export declare const PRODUCT_ID: "agz-memory";
4
+ export declare const HASH_POLICY: "hash-tuple/2";
2
5
  export declare const SCHEMA_TABLES: string;
3
6
  export declare const FTS_V9 = "\nCREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(\n title, summary, content,\n content='notes', content_rowid='rowid',\n tokenize='unicode61'\n);\nCREATE TRIGGER IF NOT EXISTS notes_fts_ai AFTER INSERT ON notes BEGIN\n INSERT INTO notes_fts(rowid, title, summary, content)\n VALUES (new.rowid, new.title, new.summary, new.content);\nEND;\nCREATE TRIGGER IF NOT EXISTS notes_fts_ad AFTER DELETE ON notes BEGIN\n INSERT INTO notes_fts(notes_fts, rowid, title, summary, content)\n VALUES ('delete', old.rowid, old.title, old.summary, old.content);\nEND;\nCREATE TRIGGER IF NOT EXISTS notes_fts_au AFTER UPDATE OF title, summary, content ON notes BEGIN\n INSERT INTO notes_fts(notes_fts, rowid, title, summary, content)\n VALUES ('delete', old.rowid, old.title, old.summary, old.content);\n INSERT INTO notes_fts(rowid, title, summary, content)\n VALUES (new.rowid, new.title, new.summary, new.content);\nEND;\n";
7
+ export declare const SCHEMA_V11_TABLES: string;
8
+ export declare function captureEventsTableV11(table?: "capture_events" | "capture_events_v11"): string;
9
+ export declare function schemaFingerprint(db: Database): string;
10
+ export declare function expectedSchemaFingerprint(): string;
11
+ export declare function createSchemaV11(db: Database): void;
12
+ export declare function insertV11Identity(db: Database, databaseID?: `${string}-${string}-${string}-${string}-${string}`, createdAt?: number): void;
4
13
  export declare function createSchema(db: Database): void;
5
14
  export declare function rebuildFts(db: Database): void;
6
15
  export declare function captureEventsTable(table?: "capture_events" | "capture_events_v10"): string;
@@ -4,3 +4,4 @@ export interface OpenedDB {
4
4
  close: () => void;
5
5
  }
6
6
  export declare function openMemoryDatabase(path: string): OpenedDB;
7
+ export declare function openReadOnlyMemoryDatabase(path: string): OpenedDB;
@@ -0,0 +1,4 @@
1
+ export type HashTupleValue = null | string | boolean | number | Uint8Array;
2
+ export declare function hashTuple(domain: string, version: number, fields: readonly HashTupleValue[]): string;
3
+ export declare function noteContentHash(kind: string, title: string, summary: string, content: string): string;
4
+ export declare const canonicalNoteHash: typeof noteContentHash;
@@ -32,11 +32,23 @@ export interface BackendHealth {
32
32
  version?: string;
33
33
  errorCode?: string;
34
34
  }
35
+ export interface BackendOperationContext {
36
+ operationKey: string;
37
+ sequence: number;
38
+ fence: number;
39
+ }
40
+ export interface OutboxBackend extends RetrievalBackend {
41
+ readonly outboxProtocol: "agz-memory-outbox/1";
42
+ upsert(document: DerivedDocument, signal: AbortSignal, operation: BackendOperationContext): Promise<void>;
43
+ delete(ref: DerivedRef, signal: AbortSignal, operation: BackendOperationContext): Promise<void>;
44
+ purgeProject(projectID: string, signal: AbortSignal, operation: BackendOperationContext): Promise<void>;
45
+ }
35
46
  export interface RetrievalBackend {
36
47
  id: string;
37
- upsert(document: DerivedDocument, signal: AbortSignal): Promise<void>;
38
- delete(ref: DerivedRef, signal: AbortSignal): Promise<void>;
39
- purgeProject(projectID: string, signal: AbortSignal): Promise<void>;
48
+ upsert(document: DerivedDocument, signal: AbortSignal, operation?: BackendOperationContext): Promise<void>;
49
+ delete(ref: DerivedRef, signal: AbortSignal, operation?: BackendOperationContext): Promise<void>;
50
+ purgeProject(projectID: string, signal: AbortSignal, operation?: BackendOperationContext): Promise<void>;
40
51
  query(projectID: string, query: string, limit: number, signal: AbortSignal): Promise<RankedHit[]>;
41
52
  health(signal: AbortSignal): Promise<BackendHealth>;
42
53
  }
54
+ export declare function validateBackendHits(value: unknown): value is RankedHit[];
@@ -1,5 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/server";
2
2
  import type { MemoryStore } from "./store";
3
3
  export declare const SERVER_NAME = "agz-memory";
4
- export declare const SERVER_VERSION: "0.4.1";
4
+ export declare const SERVER_VERSION: "0.5.0";
5
5
  export declare function createMemoryServer(store: MemoryStore): McpServer;
@@ -6,6 +6,7 @@ export interface ProjectBindingInput {
6
6
  workspaceID?: string;
7
7
  }
8
8
  export type CaptureMode = "shadow" | "auto-write";
9
+ type CheckpointState = "active" | "idle" | "unavailable" | "closed";
9
10
  export interface CaptureIngestResult {
10
11
  outcome: "shadowed" | "materialized" | "duplicate" | "ignored" | "review" | "rejected" | "quarantined";
11
12
  idempotencyKey: string;
@@ -17,15 +18,18 @@ export declare class CaptureStore {
17
18
  private indexBackends;
18
19
  constructor(db: Database, indexBackends?: readonly string[]);
19
20
  bindProject(input: ProjectBindingInput): {
21
+ ok: true;
20
22
  bindingKey: string;
21
23
  projectID: string;
22
24
  };
23
- checkpoint(sessionID: string, bindingKey: string, projectID: string, messageID?: string, state?: "active" | "idle" | "unavailable" | "closed"): void;
24
- markReconciled(sessionID: string, state: "active" | "idle" | "unavailable" | "closed", lastMessageID?: string, failed?: boolean): void;
25
- getCheckpoint(sessionID: string): {
25
+ checkpoint(sessionID: string, bindingKey: string, projectID: string, messageID?: string, state?: CheckpointState): void;
26
+ markReconciled(sessionID: string, state: CheckpointState, lastMessageID?: string, failed?: boolean, bindingKey?: string, projectID?: string): void;
27
+ markReconciled(sessionID: string, bindingKey: string, state: CheckpointState, lastMessageID?: string, failed?: boolean, projectID?: string): void;
28
+ markReconciled(sessionID: string, bindingKey: string, projectID: string, state: CheckpointState, lastMessageID?: string, failed?: boolean): void;
29
+ getCheckpoint(sessionID: string, bindingKey?: string, projectID?: string): {
26
30
  sessionID: string;
27
31
  lastMessageID?: string;
28
- state: "active" | "idle" | "unavailable" | "closed";
32
+ state: CheckpointState;
29
33
  } | undefined;
30
34
  ingest(input: unknown, mode: CaptureMode, options?: {
31
35
  allowedKinds?: readonly string[];
@@ -48,4 +52,8 @@ export declare class CaptureStore {
48
52
  private enqueueOutbox;
49
53
  private finishEvent;
50
54
  private binding;
55
+ private hasBinding;
56
+ private checkpointRow;
57
+ private uniqueCheckpointForSession;
51
58
  }
59
+ export {};
@@ -1,14 +1,17 @@
1
1
  import type { Database } from "bun:sqlite";
2
- import type { RetrievalBackend } from "../retrieval/contract";
3
- export type OutboxOutcome = "idle" | "succeeded" | "stale" | "quarantined" | "retry" | "dead";
2
+ import type { OutboxBackend } from "../retrieval/contract";
3
+ export type OutboxOutcome = "idle" | "succeeded" | "stale" | "quarantined" | "retry" | "dead" | "lost_lease";
4
4
  export declare class OutboxWorker {
5
5
  private db;
6
6
  private backends;
7
7
  private now;
8
8
  private random;
9
9
  private readonly workerID;
10
- constructor(db: Database, backends: ReadonlyMap<string, RetrievalBackend>, now?: () => number, random?: () => number);
10
+ constructor(db: Database, backends: ReadonlyMap<string, OutboxBackend>, now?: () => number, random?: () => number);
11
11
  processNext(): Promise<OutboxOutcome>;
12
+ private claim;
13
+ private runBackend;
14
+ private stale;
12
15
  private succeed;
13
16
  private fail;
14
17
  }
@@ -1,5 +1,5 @@
1
1
  import type { Database } from "bun:sqlite";
2
- import type { RetrievalBackend, RetrievalRequest } from "../retrieval/contract";
2
+ import { type RetrievalBackend, type RetrievalRequest } from "../retrieval/contract";
3
3
  import type { RecallCard } from "../types";
4
4
  export interface RetrievalResult {
5
5
  cards: RecallCard[];
@@ -14,5 +14,5 @@ export declare class RetrievalStore {
14
14
  private lexical;
15
15
  private graph;
16
16
  private graphPredicates;
17
- private note;
17
+ private notes;
18
18
  }
@@ -55,11 +55,18 @@ export declare class MemoryStore {
55
55
  };
56
56
  update(projectID: string, input: UpdateInput): UpdateResult;
57
57
  pin(projectID: string, id: string, pinned: boolean): {
58
- ok: boolean;
59
- reason: string;
60
- id?: undefined;
61
58
  projectID?: undefined;
62
59
  projectName?: undefined;
60
+ id?: undefined;
61
+ ok: boolean;
62
+ reason: string;
63
+ pinned?: undefined;
64
+ } | {
65
+ ok: boolean;
66
+ id: string;
67
+ projectID: string;
68
+ projectName: string;
69
+ reason: string;
63
70
  pinned?: undefined;
64
71
  } | {
65
72
  reason?: undefined;
@@ -88,8 +95,10 @@ export declare class MemoryStore {
88
95
  };
89
96
  recall(projectID: string, query: string, limit?: number): RecallCard[];
90
97
  private getProjectRow;
98
+ private getProjectByNormalizedName;
91
99
  private projectNameExists;
92
100
  private getNoteRow;
93
101
  private recordCurrentRevision;
94
102
  private enqueueOutbox;
103
+ private immediateTransaction;
95
104
  }
@@ -1,4 +1,4 @@
1
- export declare const SCHEMA_VERSION = 10;
1
+ export declare const SCHEMA_VERSION = 11;
2
2
  export declare const INLINE_LIMIT = 1200;
3
3
  export declare const KINDS: readonly ["decision", "fact", "procedure", "context", "research", "preference", "task"];
4
4
  export type Kind = (typeof KINDS)[number];
@@ -1 +1 @@
1
- export declare const PRODUCT_VERSION: "0.4.1";
1
+ export declare const PRODUCT_VERSION: "0.5.0";
@@ -0,0 +1,57 @@
1
+ # ADR: Version 2 Tuple Hash Identities
2
+
3
+ Status: Accepted
4
+
5
+ Date: 2026-09-02
6
+
7
+ ## Context
8
+
9
+ Schema 10 hashes several user-controlled tuples by joining strings with NUL bytes. NUL is not rejected by every producer, so two different tuples can serialize to the same byte sequence. JavaScript string length also counts UTF-16 code units rather than UTF-8 bytes, which makes an implicit character-based framing contract unsuitable for persisted identities.
10
+
11
+ The affected values include canonical note hashes, derived-document hashes, OpenCode capture identities, project binding keys, and capture payload hashes. These values survive process restarts and must be deterministic on every supported platform.
12
+
13
+ ## Decision
14
+
15
+ Schema 11 uses one exported helper:
16
+
17
+ ```ts
18
+ hashTuple(domain, version, fields)
19
+ ```
20
+
21
+ The encoder writes the following byte sequence into SHA-256:
22
+
23
+ 1. A fixed `agz-memory/hash-tuple` format marker.
24
+ 2. A length-prefixed UTF-8 domain.
25
+ 3. An unsigned version integer.
26
+ 4. A field count.
27
+ 5. For every field, a one-byte type tag, an unsigned UTF-8/byte payload length, and the payload.
28
+
29
+ `null`, strings, booleans, finite numbers, and byte arrays use distinct tags. Empty strings and `null` are therefore different. String lengths are measured after UTF-8 encoding, never with JavaScript `String.length`. Numbers use a canonical finite representation and unsafe integers are rejected.
30
+
31
+ Each use has a separate domain. The initial version 2 domains are:
32
+
33
+ - `canonical-note`
34
+ - `derived-note`
35
+ - `capture-identity`
36
+ - `capture-payload`
37
+ - `project-binding`
38
+ - `checkpoint-identity`
39
+ - `outbox-operation`
40
+
41
+ Schema 11 recomputes canonical and revision hashes from persisted source fields. Derived hashes are recomputed from `deriveDocument()`. Capture keys are recomputed from strict source identities; a legacy row that cannot satisfy its event-kind identity contract stops migration with a safe row identifier and error code. Different legacy rows mapping to the same version 2 key stop migration; they are never merged or ignored.
42
+
43
+ The capture writer emits `agz-memory.capture/2`. The migration reader accepts `/1` only while migrating persisted schema 10 rows. Runtime ingestion accepts `/2` and independently recomputes the idempotency key before insertion.
44
+
45
+ ## Consequences
46
+
47
+ - NUL and Unicode tuple collision counterexamples no longer collide.
48
+ - Hashes intentionally change during the schema 10 to 11 migration.
49
+ - Version 2 databases cannot be safely written by version 0.4.1; the existing newer-schema guard must reject them before any DDL.
50
+ - Hashes identify content or operation tuples; they are not secret storage and do not replace redaction.
51
+ - Migration produces an aggregate mapping audit without recording note bodies, prompts, credentials, or other private payloads.
52
+
53
+ ## Rejected Alternatives
54
+
55
+ - Delimiter escaping was rejected because every producer would need identical escaping and type/null handling.
56
+ - `JSON.stringify` was rejected because object/key representation and numeric edge cases are not the persisted contract we need.
57
+ - Reusing version 1 hashes was rejected because it preserves the collision class.
@@ -0,0 +1,93 @@
1
+ # ADR: Cross-Process Database Maintenance Gate
2
+
3
+ Status: Accepted
4
+
5
+ Date: 2026-09-02
6
+
7
+ ## Context
8
+
9
+ SQLite WAL coordinates transactions but does not make replacing the database pathname safe while another process holds an open connection. An old connection can continue to use the replaced inode and its WAL after restore. Before schema 11, the migration lock serialized migration owners only; normal MCP and plugin handles did not participate.
10
+
11
+ AGZ Memory must run on Linux, macOS, and Windows under Bun. Bun does not currently expose one portable shared/exclusive advisory-file-lock API for this package, so schema 11 uses a conservative filesystem protocol and fails closed whenever ownership cannot be established.
12
+
13
+ ## Decision
14
+
15
+ Every normal database handle owns a lease for its complete lifetime. Migration, restore, backup publication, and prune use one exclusive maintenance gate associated with the canonical database path.
16
+
17
+ ### Normal Open
18
+
19
+ 1. Resolve and validate the canonical path and parent policy.
20
+ 2. Reject an existing maintenance gate.
21
+ 3. Publish a private lease file by exclusive staging and atomic rename. Its record contains an opaque owner ID, PID, process-start marker, hostname, and creation time. It contains no database content or configured private path.
22
+ 4. Check the maintenance gate again. If it appeared, remove only the caller's verified lease and retry or fail.
23
+ 5. Open and validate SQLite.
24
+ 6. Keep the lease until statements and the SQLite handle are closed.
25
+
26
+ The second gate check closes the race where maintenance creates the gate between the first check and lease publication. Maintenance either observes the published lease, or the opener observes the gate and withdraws.
27
+
28
+ ### Maintenance
29
+
30
+ 1. Atomically create the gate. Only one owner can succeed.
31
+ 2. Validate the gate owner record after publication.
32
+ 3. Enumerate leases. A local lease is stale only when PID liveness and process-start identity prove that its owner is gone or the PID was reused. A remote-host or unverifiable lease remains active and blocks maintenance.
33
+ 4. If any active lease exists, remove only the caller's gate and return `active_database_handles`.
34
+ 5. Perform the operation without exposing a normal handle.
35
+ 6. Verify the installed canonical database before removing the gate.
36
+
37
+ There is no `--force` bypass for active or unverifiable leases. Stale cleanup requires current owner identity checks. Gate and lease deletion never recursively removes an unverified replacement pathname.
38
+
39
+ Migration waiters recheck the canonical schema under a normal lease while they
40
+ still own the migration lock. A waiter that finds the target schema returns that
41
+ handle without creating another maintenance gate. After a successful migration,
42
+ the owner releases the maintenance gate and publishes its normal lease before
43
+ releasing the migration lock. This handoff prevents queued stale observations
44
+ from creating a new gate between migration completion and reopen.
45
+
46
+ An active gate left by a crashed local process is reclaimed in place: an
47
+ exclusive takeover record serializes contenders and atomically replaces the
48
+ stale owner while the gate directory remains continuously present. A reused PID
49
+ is stale only when both process-start markers exist and differ. Remote owners,
50
+ live owners, unavailable markers, malformed records, and missing records remain
51
+ fail-closed.
52
+
53
+ `retain()` atomically persists `state: recovery-required` before returning. Such
54
+ a gate is never reclaimed automatically. A verified restore may take it over
55
+ only with the exact recorded owner ID and
56
+ `RECOVER_RETAINED_MAINTENANCE_GATE`; the restore keeps the gate continuously
57
+ held and validates the installed database before release.
58
+
59
+ ### Restore
60
+
61
+ While the maintenance gate is held and no leases exist:
62
+
63
+ 1. Open the backup and manifest through the validated no-symlink policy.
64
+ 2. Copy the source into a private same-parent staging file while streaming SHA-256 and byte count.
65
+ 3. Validate manifest hash/size, application ID, database UUID/product, schema version/fingerprint, row counts, `integrity_check`, and `foreign_key_check` on the staging inode.
66
+ 4. Checkpoint and preserve the current canonical database.
67
+ 5. Fsync staging and its parent, atomically replace the canonical pathname, and quarantine stale WAL/SHM files.
68
+ 6. Reopen the installed target and repeat identity, fingerprint, count, and health validation.
69
+ 7. On failure, restore the preserved source while still holding the gate. If rollback cannot be verified, retain the gate as a recovery-required marker and fail closed.
70
+
71
+ Backup hashing is streaming. Restore never copies a pathname that was validated and then reopened as the source of truth.
72
+
73
+ ## Platform Policy
74
+
75
+ - The database, backup root, manifest, maintenance gate, lease registry, lock records, and their existing parents must not be symbolic links.
76
+ - Existing components must have the expected file type and private ownership/permissions when the platform exposes those attributes.
77
+ - New files use exclusive creation and private modes.
78
+ - An unsupported no-follow or identity check causes the sensitive operation to fail closed rather than silently weaken the policy.
79
+ - Process-start markers use the strongest local facility available. An unavailable marker never justifies breaking a live lease.
80
+
81
+ ## Consequences
82
+
83
+ - Restore is offline-safe, not an online hot swap.
84
+ - Long-lived MCP/plugin handles explicitly block maintenance until clean shutdown.
85
+ - A crashed owner can be reclaimed only with verifiable stale-owner evidence.
86
+ - The protocol is cooperative against same-user processes; it does not protect a database directory writable by an untrusted account. Unsafe ownership or permissions are rejected.
87
+
88
+ ## Rejected Alternatives
89
+
90
+ - WAL checkpoint alone was rejected because it does not invalidate old file descriptors.
91
+ - PID-only lock files were rejected because PIDs are reused.
92
+ - Unconditional stale timeout and `--force` were rejected because a paused live writer could lose acknowledged writes.
93
+ - Path-only verify-then-copy was rejected because it leaves a TOCTOU window.
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [Türkçe](backup-restore-runbook.tr.md)
4
4
 
5
- This runbook applies to `@vaur94/agz-memory@0.4.1` and SQLite schema v10.
5
+ This runbook applies to `@vaur94/agz-memory@0.5.0` and SQLite schema v11.
6
6
 
7
7
  ## Preconditions
8
8
 
@@ -25,20 +25,20 @@ Do not proceed with a guessed or empty path.
25
25
  Run a read-only health report first:
26
26
 
27
27
  ```sh
28
- bunx --package @vaur94/agz-memory@0.4.1 agz-memory-admin doctor
28
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin doctor
29
29
  ```
30
30
 
31
31
  `ok` must be `true`. Record `schemaVersion`, row counts, and invariant counts.
32
32
  Then create a standalone verified backup and upgrade:
33
33
 
34
34
  ```sh
35
- bunx --package @vaur94/agz-memory@0.4.1 agz-memory-admin backup
36
- bunx --package @vaur94/agz-memory@0.4.1 agz-memory-admin upgrade --to 10
37
- bunx --package @vaur94/agz-memory@0.4.1 agz-memory-admin doctor
35
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin backup
36
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin upgrade --to 11
37
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin doctor
38
38
  ```
39
39
 
40
40
  The upgrade itself creates another verified pre-migration backup when the
41
- database schema is older than v10. Preserve each printed manifest path and
41
+ database schema is older than v11. Preserve each printed manifest path and
42
42
  SHA-256. Do not start a writer if the final report has `ok: false`.
43
43
 
44
44
  ## Verify A Backup
@@ -54,16 +54,16 @@ The manifest format is `agz-memory-backup/1`. `agz-memory-admin restore` verifie
54
54
  that the manifest and database are regular files in the same backup directory,
55
55
  then checks size, SHA-256, SQLite integrity, foreign keys, and row counts.
56
56
 
57
- Final `0.4.1` does not accept prerelease manifest formats. Use the originating
57
+ Final `0.5.0` does not accept prerelease manifest formats. Use the originating
58
58
  prerelease to restore such a backup, run its doctor check, and only then upgrade
59
- that restored database with `0.4.1`.
59
+ that restored database with `0.5.0`.
60
60
 
61
61
  ## Restore Rehearsal
62
62
 
63
63
  Keep all writers stopped. First request a dry run by omitting confirmation:
64
64
 
65
65
  ```sh
66
- bunx --package @vaur94/agz-memory@0.4.1 agz-memory-admin restore \
66
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin restore \
67
67
  "$OPENCODE_MEMORY_DATABASE_PATH.backup/<backup>.manifest.json"
68
68
  ```
69
69
 
@@ -71,7 +71,7 @@ Compare `targetPath`, `sourceSchema`, `targetSchema`, row counts, size, and
71
71
  SHA-256 with the recorded backup. Then use the exact manifest hash:
72
72
 
73
73
  ```sh
74
- bunx --package @vaur94/agz-memory@0.4.1 agz-memory-admin restore \
74
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin restore \
75
75
  "$OPENCODE_MEMORY_DATABASE_PATH.backup/<backup>.manifest.json" \
76
76
  --sha256 <manifest-database-sha256> \
77
77
  --confirm RESTORE_DATABASE_FROM_VERIFIED_BACKUP
@@ -84,9 +84,9 @@ database passes all checks.
84
84
  ## Post-Restore Validation
85
85
 
86
86
  ```sh
87
- bunx --package @vaur94/agz-memory@0.4.1 agz-memory-admin doctor
88
- bunx --package @vaur94/agz-memory@0.4.1 agz-memory-admin capture status
89
- bunx --package @vaur94/agz-memory@0.4.1 agz-memory-admin outbox status
87
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin doctor
88
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin capture status
89
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin outbox status
90
90
  ```
91
91
 
92
92
  Start only the MCP server and perform read-only `project_list`, `memory_recall`,
@@ -94,6 +94,26 @@ and `memory_read` smoke calls. Compare project/note counts with the manifest.
94
94
  Only after those checks pass should OpenCode be restarted. Keep the plugin in
95
95
  `off` until a separate rollout decision is made.
96
96
 
97
+ ## Retained Maintenance Gate
98
+
99
+ `<database>.maintenance/owner.json` with `state: recovery-required` means a
100
+ previous restore could not verify its rollback. It is never removed
101
+ automatically. Stop every MCP/plugin process, preserve the database, sidecars,
102
+ gate, and restore artifacts, then select a verified backup. Supply the exact
103
+ recorded owner ID only on the restoring command:
104
+
105
+ ```sh
106
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin restore <manifest> \
107
+ --sha256 <manifest-sha256> \
108
+ --confirm RESTORE_DATABASE_FROM_VERIFIED_BACKUP \
109
+ --maintenance-owner <owner-id> \
110
+ --maintenance-confirm RECOVER_RETAINED_MAINTENANCE_GATE
111
+ ```
112
+
113
+ Remote, live, malformed, or otherwise unverifiable owners remain blocked. Never
114
+ delete the gate manually; the recovery restore atomically takes ownership while
115
+ the gate directory remains present.
116
+
97
117
  ## Stale Migration Lock
98
118
 
99
119
  The lock is `<database>.migration.lock/owner.json`. Never remove it while the
@@ -104,7 +124,7 @@ style error first if uncertain. Break only a proven stale lock with the exact
104
124
  owner ID and confirmation:
105
125
 
106
126
  ```sh
107
- bunx --package @vaur94/agz-memory@0.4.1 agz-memory-admin unlock \
127
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin unlock \
108
128
  --owner <owner-id> \
109
129
  --confirm BREAK_STALE_MIGRATION_LOCK
110
130
  ```
@@ -118,13 +138,13 @@ The first command is non-destructive and returns a digest over the exact backup
118
138
  set:
119
139
 
120
140
  ```sh
121
- bunx --package @vaur94/agz-memory@0.4.1 agz-memory-admin backup prune
141
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin backup prune
122
142
  ```
123
143
 
124
144
  Review every listed manifest/database pair. Delete only that unchanged set:
125
145
 
126
146
  ```sh
127
- bunx --package @vaur94/agz-memory@0.4.1 agz-memory-admin backup prune \
147
+ bunx --package @vaur94/agz-memory@0.5.0 agz-memory-admin backup prune \
128
148
  --digest <dry-run-digest> \
129
149
  --confirm DELETE_VERIFIED_BACKUPS
130
150
  ```