@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.
- package/ARCHITECTURE.md +41 -25
- package/CHANGELOG.md +59 -0
- package/README.md +39 -16
- package/README.tr.md +40 -17
- package/dist/admin.js +3527 -622
- package/dist/core.js +4348 -924
- package/dist/server.js +4075 -874
- package/dist/types/capture/contract.d.ts +7 -5
- package/dist/types/capture/redact.d.ts +1 -0
- package/dist/types/context.d.ts +1 -1
- package/dist/types/core.d.ts +2 -2
- package/dist/types/db/backup.d.ts +2 -1
- package/dist/types/db/health.d.ts +2 -0
- package/dist/types/db/legacy-health.d.ts +2 -0
- package/dist/types/db/maintenance.d.ts +21 -0
- package/dist/types/db/migrations/v011.d.ts +3 -0
- package/dist/types/db/schema.d.ts +10 -1
- package/dist/types/db.d.ts +1 -0
- package/dist/types/hash.d.ts +4 -0
- package/dist/types/retrieval/contract.d.ts +15 -3
- package/dist/types/server.d.ts +1 -1
- package/dist/types/store/capture.d.ts +12 -4
- package/dist/types/store/outbox.d.ts +6 -3
- package/dist/types/store/retrieval.d.ts +2 -2
- package/dist/types/store.d.ts +12 -3
- package/dist/types/types.d.ts +1 -1
- package/dist/types/version.d.ts +1 -1
- package/docs/adr/hash-identity-v2.md +57 -0
- package/docs/adr/maintenance-lock.md +93 -0
- package/docs/backup-restore-runbook.md +36 -16
- package/docs/backup-restore-runbook.tr.md +36 -16
- package/docs/repository-hardening.md +125 -0
- package/docs/review-resolution.md +78 -0
- package/docs/schema-v11.md +75 -0
- package/package.json +19 -4
- package/skills/agz-memory/agz-memory.md +39 -0
- package/skills/index.json +9 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
|
-
export declare const CAPTURE_SCHEMA: "agz-memory.capture/
|
|
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/
|
|
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
|
|
100
|
-
export type
|
|
101
|
-
export
|
|
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 {};
|
package/dist/types/context.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const MEMORY_GUIDANCE = "Use project-scoped memory for durable facts across sessions.\n- Start with project_list.
|
|
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.";
|
package/dist/types/core.d.ts
CHANGED
|
@@ -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,
|
|
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,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;
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import
|
|
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;
|
package/dist/types/db.d.ts
CHANGED
|
@@ -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[];
|
package/dist/types/server.d.ts
CHANGED
|
@@ -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
|
+
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?:
|
|
24
|
-
markReconciled(sessionID: string, state:
|
|
25
|
-
|
|
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:
|
|
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 {
|
|
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,
|
|
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
|
|
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
|
|
17
|
+
private notes;
|
|
18
18
|
}
|
package/dist/types/store.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/types/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const SCHEMA_VERSION =
|
|
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];
|
package/dist/types/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const PRODUCT_VERSION: "0.
|
|
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.
|
|
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.
|
|
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.
|
|
36
|
-
bunx --package @vaur94/agz-memory@0.
|
|
37
|
-
bunx --package @vaur94/agz-memory@0.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
88
|
-
bunx --package @vaur94/agz-memory@0.
|
|
89
|
-
bunx --package @vaur94/agz-memory@0.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
```
|