@oscharko-dev/keiko-memory-vault 0.2.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 (59) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/access.d.ts +12 -0
  3. package/dist/access.d.ts.map +1 -0
  4. package/dist/access.js +79 -0
  5. package/dist/cipher.d.ts +17 -0
  6. package/dist/cipher.d.ts.map +1 -0
  7. package/dist/cipher.js +95 -0
  8. package/dist/db.d.ts +12 -0
  9. package/dist/db.d.ts.map +1 -0
  10. package/dist/db.js +80 -0
  11. package/dist/edges.d.ts +8 -0
  12. package/dist/edges.d.ts.map +1 -0
  13. package/dist/edges.js +47 -0
  14. package/dist/embeddings.d.ts +10 -0
  15. package/dist/embeddings.d.ts.map +1 -0
  16. package/dist/embeddings.js +116 -0
  17. package/dist/errors.d.ts +14 -0
  18. package/dist/errors.d.ts.map +1 -0
  19. package/dist/errors.js +20 -0
  20. package/dist/index.d.ts +7 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +9 -0
  23. package/dist/memories.d.ts +11 -0
  24. package/dist/memories.d.ts.map +1 -0
  25. package/dist/memories.js +195 -0
  26. package/dist/migrate-encrypt.d.ts +4 -0
  27. package/dist/migrate-encrypt.d.ts.map +1 -0
  28. package/dist/migrate-encrypt.js +69 -0
  29. package/dist/paths.d.ts +6 -0
  30. package/dist/paths.d.ts.map +1 -0
  31. package/dist/paths.js +82 -0
  32. package/dist/redact-record.d.ts +8 -0
  33. package/dist/redact-record.d.ts.map +1 -0
  34. package/dist/redact-record.js +67 -0
  35. package/dist/schema.d.ts +5 -0
  36. package/dist/schema.d.ts.map +1 -0
  37. package/dist/schema.js +206 -0
  38. package/dist/scope-key.d.ts +4 -0
  39. package/dist/scope-key.d.ts.map +1 -0
  40. package/dist/scope-key.js +27 -0
  41. package/dist/serialize.d.ts +67 -0
  42. package/dist/serialize.d.ts.map +1 -0
  43. package/dist/serialize.js +213 -0
  44. package/dist/tombstones.d.ts +8 -0
  45. package/dist/tombstones.d.ts.map +1 -0
  46. package/dist/tombstones.js +57 -0
  47. package/dist/types.d.ts +127 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +4 -0
  50. package/dist/validate.d.ts +8 -0
  51. package/dist/validate.d.ts.map +1 -0
  52. package/dist/validate.js +71 -0
  53. package/dist/vault.d.ts +3 -0
  54. package/dist/vault.d.ts.map +1 -0
  55. package/dist/vault.js +301 -0
  56. package/dist/version.d.ts +2 -0
  57. package/dist/version.d.ts.map +1 -0
  58. package/dist/version.js +1 -0
  59. package/package.json +31 -0
package/dist/schema.js ADDED
@@ -0,0 +1,206 @@
1
+ // Memory vault schema V1. Forward-only migration runner keyed off PRAGMA user_version, applied
2
+ // inside a single transaction so a partial failure leaves user_version unchanged. STRICT tables
3
+ // pin the column types at runtime so SQLite cannot silently coerce a wrong-shape insert.
4
+ //
5
+ // Index strategy:
6
+ // - (scope_kind, scope_coordinate) for the canonical scoped list (#206 AC)
7
+ // - (scope_kind, scope_coordinate, created_at) for bounded retrieval scans (#210)
8
+ // - (scope_kind, scope_coordinate, type|status) for the common filter combinations
9
+ // - pinned partial index for "list pinned in scope X"
10
+ // - valid_until for the consolidation sweep (#208)
11
+ // - updated_at for the "recently changed" surface
12
+ // - edges from/to and created_at for graph traversal (#210)
13
+ // - tombstones scope for the forgetting audit surface (#214)
14
+ //
15
+ // `provenance_*` columns are denormalised onto `memories` so a single SELECT can answer
16
+ // "give me this memory plus its capture lineage" without joining a sidecar table. The structural
17
+ // payload is JSON-encoded into `payload_json` because storing it as a normalised table would
18
+ // require a schema change every time a payload kind landed (#205 ships only two kinds today).
19
+ import { encryptExistingContent } from "./migrate-encrypt.js";
20
+ // v2 = encryption-at-rest (ADR-0035). v1 stored content columns in plaintext; v2 seals them via an
21
+ // eager code sweep (no column changes). The bump is one-way: a v2 DB is unreadable by v1 code.
22
+ // v3 = access tracking (#204). Adds the `memory_access` counter table that feeds the decay /
23
+ // reinforcement maintenance cycle. The table holds ONLY counters + timestamps (no memory
24
+ // content), so it stays CLEARTEXT — the cipher is never applied to it.
25
+ // v4 = tombstone provenance hardening (#209). Adds reviewer_id and original_status to deletion
26
+ // tombstones so audit consumers can distinguish who initiated deletion and what lifecycle state
27
+ // was removed without storing memory body content.
28
+ // v5 = retrieval-path performance indexes (#210). Adds additive composite indexes matching the
29
+ // scoped retrieval ORDER BY and per-memory edge ORDER BY shapes; no data rewrite.
30
+ // v6 = outcome-driven forgetting (#204, O-V1). Adds two counters to memory_access — outcome_count
31
+ // and utility_sum — recording governed retention OUTCOMES (a proposal accepted/rejected, a conflict
32
+ // won/lost, an accepted correction superseding its origin). The maintenance strength model folds
33
+ // their mean into a utility factor so proven-useful memories resist disuse-forgetting and proven-bad
34
+ // ones fade sooner. Counters only (no content) => the table stays CLEARTEXT. Additive: the columns
35
+ // default 0, which yields a neutral factor of 1, so the strength model is byte-identical until a
36
+ // real outcome lands.
37
+ export const MEMORY_VAULT_SCHEMA_VERSION = 6;
38
+ const ENCRYPTION_VERSION = 2;
39
+ const V1_SQL = `
40
+ CREATE TABLE memories (
41
+ id TEXT NOT NULL PRIMARY KEY,
42
+ schema_version TEXT NOT NULL,
43
+ type TEXT NOT NULL,
44
+ scope_kind TEXT NOT NULL,
45
+ scope_coordinate TEXT NOT NULL,
46
+ body TEXT NOT NULL,
47
+ payload_json TEXT,
48
+ status TEXT NOT NULL,
49
+ sensitivity TEXT NOT NULL,
50
+ pinned INTEGER NOT NULL DEFAULT 0,
51
+ confidence REAL NOT NULL,
52
+ valid_from INTEGER NOT NULL,
53
+ valid_until INTEGER,
54
+ stale_reason TEXT,
55
+ tags_json TEXT NOT NULL,
56
+ source_kind TEXT NOT NULL,
57
+ source_conversation_id TEXT,
58
+ source_workflow_run_id TEXT,
59
+ source_evidence_manifest_id TEXT,
60
+ captured_at INTEGER NOT NULL,
61
+ capture_rationale TEXT,
62
+ model_provider TEXT,
63
+ model_id TEXT,
64
+ model_revision TEXT,
65
+ retention_policy_key TEXT,
66
+ retention_retain_until INTEGER,
67
+ retention_notes TEXT,
68
+ created_at INTEGER NOT NULL,
69
+ updated_at INTEGER NOT NULL
70
+ ) STRICT;
71
+
72
+ CREATE INDEX idx_memories_scope ON memories(scope_kind, scope_coordinate);
73
+ CREATE INDEX idx_memories_scope_created ON memories(scope_kind, scope_coordinate, created_at DESC);
74
+ CREATE INDEX idx_memories_scope_type ON memories(scope_kind, scope_coordinate, type);
75
+ CREATE INDEX idx_memories_scope_status ON memories(scope_kind, scope_coordinate, status);
76
+ CREATE INDEX idx_memories_pinned ON memories(scope_kind, scope_coordinate) WHERE pinned = 1;
77
+ CREATE INDEX idx_memories_valid_until ON memories(valid_until);
78
+ CREATE INDEX idx_memories_updated_at ON memories(updated_at);
79
+
80
+ CREATE TABLE memory_edges (
81
+ id TEXT NOT NULL PRIMARY KEY,
82
+ schema_version TEXT NOT NULL,
83
+ from_memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
84
+ to_memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
85
+ kind TEXT NOT NULL,
86
+ created_at INTEGER NOT NULL,
87
+ confidence REAL,
88
+ provenance_summary TEXT
89
+ ) STRICT;
90
+
91
+ CREATE INDEX idx_edges_from ON memory_edges(from_memory_id, kind);
92
+ CREATE INDEX idx_edges_to ON memory_edges(to_memory_id, kind);
93
+ CREATE INDEX idx_edges_from_created ON memory_edges(from_memory_id, created_at ASC);
94
+ CREATE INDEX idx_edges_to_created ON memory_edges(to_memory_id, created_at ASC);
95
+
96
+ CREATE TABLE memory_embeddings (
97
+ memory_id TEXT NOT NULL PRIMARY KEY REFERENCES memories(id) ON DELETE CASCADE,
98
+ provider TEXT NOT NULL,
99
+ model_id TEXT NOT NULL,
100
+ model_revision TEXT,
101
+ vector_dimensions INTEGER NOT NULL,
102
+ vector_metric TEXT NOT NULL,
103
+ vector BLOB NOT NULL,
104
+ created_at INTEGER NOT NULL
105
+ ) STRICT;
106
+
107
+ CREATE TABLE memory_tombstones (
108
+ id TEXT NOT NULL PRIMARY KEY,
109
+ memory_id TEXT NOT NULL,
110
+ scope_kind TEXT NOT NULL,
111
+ scope_coordinate TEXT NOT NULL,
112
+ type TEXT NOT NULL,
113
+ forgotten_at INTEGER NOT NULL,
114
+ forgetter_surface TEXT NOT NULL,
115
+ reason TEXT
116
+ ) STRICT;
117
+
118
+ CREATE INDEX idx_tombstones_scope ON memory_tombstones(scope_kind, scope_coordinate);
119
+ CREATE INDEX idx_tombstones_memory_id ON memory_tombstones(memory_id);
120
+ `;
121
+ // v3 access-tracking table. STRICT pins the column types; ON DELETE CASCADE removes the access
122
+ // row when its memory is hard-deleted (FK enforcement is enabled via PRAGMA foreign_keys = ON in
123
+ // db.ts). No content column => no cipher. The index on last_accessed_at supports a future
124
+ // "least-recently-touched" sweep without scanning the whole table.
125
+ const V3_SQL = `
126
+ CREATE TABLE memory_access (
127
+ memory_id TEXT NOT NULL PRIMARY KEY REFERENCES memories(id) ON DELETE CASCADE,
128
+ last_accessed_at INTEGER NOT NULL,
129
+ access_count INTEGER NOT NULL DEFAULT 0
130
+ ) STRICT;
131
+
132
+ CREATE INDEX idx_memory_access_last ON memory_access(last_accessed_at);
133
+ `;
134
+ const V4_SQL = `
135
+ ALTER TABLE memory_tombstones ADD COLUMN reviewer_id TEXT;
136
+ ALTER TABLE memory_tombstones ADD COLUMN original_status TEXT;
137
+ `;
138
+ const V5_SQL = `
139
+ CREATE INDEX IF NOT EXISTS idx_memories_scope_created
140
+ ON memories(scope_kind, scope_coordinate, created_at DESC);
141
+ CREATE INDEX IF NOT EXISTS idx_edges_from_created
142
+ ON memory_edges(from_memory_id, created_at ASC);
143
+ CREATE INDEX IF NOT EXISTS idx_edges_to_created
144
+ ON memory_edges(to_memory_id, created_at ASC);
145
+ `;
146
+ // v6 outcome counters on the existing access table (#204, O-V1). STRICT-compatible ALTERs with a
147
+ // constant DEFAULT, so the rewrite is metadata-only and every pre-existing row reads 0/0 (neutral
148
+ // utility factor). No content column => the table stays CLEARTEXT, like the rest of memory_access.
149
+ const V6_SQL = `
150
+ ALTER TABLE memory_access ADD COLUMN outcome_count INTEGER NOT NULL DEFAULT 0;
151
+ ALTER TABLE memory_access ADD COLUMN utility_sum REAL NOT NULL DEFAULT 0;
152
+ `;
153
+ const MIGRATIONS = [
154
+ { version: 1, sql: V1_SQL },
155
+ { version: 3, sql: V3_SQL },
156
+ { version: 4, sql: V4_SQL },
157
+ { version: 5, sql: V5_SQL },
158
+ { version: 6, sql: V6_SQL },
159
+ ];
160
+ function currentUserVersion(db) {
161
+ const row = db.prepare("PRAGMA user_version").get();
162
+ return typeof row?.user_version === "number" ? row.user_version : 0;
163
+ }
164
+ function setUserVersion(db, v) {
165
+ // user_version cannot be parameter-bound. `v` is a hard-coded integer from MIGRATIONS, never
166
+ // caller-supplied, so string interpolation here is not an injection surface.
167
+ db.exec(`PRAGMA user_version = ${String(v)}`);
168
+ }
169
+ export function runMigrations(db, cipher) {
170
+ const start = currentUserVersion(db);
171
+ const pendingDdl = MIGRATIONS.filter((m) => m.version > start);
172
+ const needsEncryption = start < ENCRYPTION_VERSION;
173
+ if (pendingDdl.length === 0 && !needsEncryption)
174
+ return;
175
+ // An EXISTING (already-created) DB crossing into the encryption version had plaintext on disk;
176
+ // its superseded pages must be purged from the WAL so the plaintext does not linger after upgrade.
177
+ const upgradedExistingDb = start > 0 && needsEncryption;
178
+ db.exec("BEGIN");
179
+ try {
180
+ for (const m of pendingDdl) {
181
+ db.exec(m.sql);
182
+ setUserVersion(db, m.version);
183
+ }
184
+ if (needsEncryption) {
185
+ // Idempotent: skips values already sealed, so a fresh DB (no rows) and a re-run are no-ops.
186
+ // The encryption sweep is keyed to ENCRYPTION_VERSION (2) but is NOT a user_version write:
187
+ // post-v2 migrations (v3+) own the version. Setting the version is deferred to the line below
188
+ // so encryption never regresses a DB that already applied a later DDL migration.
189
+ encryptExistingContent(db, cipher);
190
+ }
191
+ // Pin the final version to the current schema head once every pending DDL and the encryption
192
+ // sweep have run. A fresh DB applies v1 + later DDL and the encryption sweep, then lands on
193
+ // the current schema head; older encrypted DBs apply only the later DDL they missed.
194
+ setUserVersion(db, MEMORY_VAULT_SCHEMA_VERSION);
195
+ db.exec("COMMIT");
196
+ }
197
+ catch (error) {
198
+ db.exec("ROLLBACK");
199
+ throw error;
200
+ }
201
+ if (upgradedExistingDb) {
202
+ // Outside the transaction (checkpoint cannot run inside one): truncate the WAL so pages that
203
+ // held the now-re-encrypted plaintext are reclaimed immediately, not at the next close.
204
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
205
+ }
206
+ }
@@ -0,0 +1,4 @@
1
+ import type { MemoryScope, MemoryScopeKind } from "@oscharko-dev/keiko-contracts/memory";
2
+ export declare function scopeKindOf(scope: MemoryScope): MemoryScopeKind;
3
+ export declare function scopeCoordinateOf(scope: MemoryScope): string;
4
+ //# sourceMappingURL=scope-key.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scope-key.d.ts","sourceRoot":"","sources":["../src/scope-key.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AAEzF,wBAAgB,WAAW,CAAC,KAAK,EAAE,WAAW,GAAG,eAAe,CAE/D;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,WAAW,GAAG,MAAM,CAe5D"}
@@ -0,0 +1,27 @@
1
+ // Canonical scope coordinate for storage. The `(scope_kind, scope_coordinate)` column pair is the
2
+ // SOLE identity surface a scoped query keys on, so the encoding MUST be:
3
+ // - deterministic (same scope -> same string, bit-for-bit)
4
+ // - kind-disjoint at the row level (kind is its own column; coordinate is only the id)
5
+ //
6
+ // We deliberately do NOT serialise `kind:id` into one column. Kind is already stored separately;
7
+ // concatenating would make two records at different kinds but matching coordinates structurally
8
+ // equal if a caller ever forgot the kind filter. The factory enforces both filters together.
9
+ export function scopeKindOf(scope) {
10
+ return scope.kind;
11
+ }
12
+ export function scopeCoordinateOf(scope) {
13
+ switch (scope.kind) {
14
+ case "user":
15
+ return scope.userId;
16
+ case "workspace":
17
+ return scope.workspaceId;
18
+ case "project":
19
+ return scope.projectId;
20
+ case "workflow":
21
+ return scope.workflowDefinitionId;
22
+ case "global":
23
+ // Global scope has no coordinate value; the empty string is the canonical placeholder so the
24
+ // NOT NULL column is always populated and an indexed equality check still works.
25
+ return "";
26
+ }
27
+ }
@@ -0,0 +1,67 @@
1
+ import type { MemoryRecord } from "@oscharko-dev/keiko-contracts/memory";
2
+ import type { MemoryContentCipher } from "./cipher.js";
3
+ export interface MemoryRow {
4
+ readonly id: string;
5
+ readonly schema_version: string;
6
+ readonly type: string;
7
+ readonly scope_kind: string;
8
+ readonly scope_coordinate: string;
9
+ readonly body: string;
10
+ readonly payload_json: string | null;
11
+ readonly status: string;
12
+ readonly sensitivity: string;
13
+ readonly pinned: number;
14
+ readonly confidence: number;
15
+ readonly valid_from: number;
16
+ readonly valid_until: number | null;
17
+ readonly stale_reason: string | null;
18
+ readonly tags_json: string;
19
+ readonly source_kind: string;
20
+ readonly source_conversation_id: string | null;
21
+ readonly source_workflow_run_id: string | null;
22
+ readonly source_evidence_manifest_id: string | null;
23
+ readonly captured_at: number;
24
+ readonly capture_rationale: string | null;
25
+ readonly model_provider: string | null;
26
+ readonly model_id: string | null;
27
+ readonly model_revision: string | null;
28
+ readonly retention_policy_key: string | null;
29
+ readonly retention_retain_until: number | null;
30
+ readonly retention_notes: string | null;
31
+ readonly created_at: number;
32
+ readonly updated_at: number;
33
+ }
34
+ export declare function rowToMemoryRecord(row: MemoryRow, cipher: MemoryContentCipher): MemoryRecord;
35
+ export interface MemoryRowWrite {
36
+ readonly id: string;
37
+ readonly schema_version: string;
38
+ readonly type: string;
39
+ readonly scope_kind: string;
40
+ readonly scope_coordinate: string;
41
+ readonly body: string;
42
+ readonly payload_json: string | null;
43
+ readonly status: string;
44
+ readonly sensitivity: string;
45
+ readonly pinned: number;
46
+ readonly confidence: number;
47
+ readonly valid_from: number;
48
+ readonly valid_until: number | null;
49
+ readonly stale_reason: string | null;
50
+ readonly tags_json: string;
51
+ readonly source_kind: string;
52
+ readonly source_conversation_id: string | null;
53
+ readonly source_workflow_run_id: string | null;
54
+ readonly source_evidence_manifest_id: string | null;
55
+ readonly captured_at: number;
56
+ readonly capture_rationale: string | null;
57
+ readonly model_provider: string | null;
58
+ readonly model_id: string | null;
59
+ readonly model_revision: string | null;
60
+ readonly retention_policy_key: string | null;
61
+ readonly retention_retain_until: number | null;
62
+ readonly retention_notes: string | null;
63
+ readonly created_at: number;
64
+ readonly updated_at: number;
65
+ }
66
+ export declare function memoryRecordToRow(record: MemoryRecord, cipher: MemoryContentCipher): MemoryRowWrite;
67
+ //# sourceMappingURL=serialize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../src/serialize.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,213 @@
1
+ // Pure row↔record translation. The row shape mirrors the schema.ts column layout 1:1; the record
2
+ // shape is the contract's MemoryRecord. We keep both layers in this single module so a schema
3
+ // column rename has exactly one place to update.
4
+ //
5
+ // Encoding choices:
6
+ // - tags → JSON array string
7
+ // - payload → JSON-stringified discriminated union (NULL when absent)
8
+ // - validUntil → NULL when absent (NOT undefined-as-string)
9
+ // - staleReason → NULL when absent
10
+ // - retentionHint → flattened across three columns; NULL all when absent
11
+ // - sensitivity → flat column (validator treats it as a provenance attribute but storing it
12
+ // flat keeps the scoped-list query free of payload parsing for the common case)
13
+ import { scopeCoordinateOf, scopeKindOf } from "./scope-key.js";
14
+ import { MemoryStorageError } from "./errors.js";
15
+ // Content-vs-metadata split (ADR-0035): only the columns that carry remembered TEXT are sealed —
16
+ // body, payload_json, tags_json, capture_rationale, stale_reason. Every other column (ids, scope,
17
+ // status, sensitivity, pinned, confidence, all timestamps, source_*, model_*, retention_*) stays
18
+ // cleartext so SQL indexes and the UI scope display keep working without a key. Crypto is confined
19
+ // to the two wrapper functions below; the pure row↔record builders never see a key.
20
+ function openNullable(cipher, value) {
21
+ return value === null ? null : cipher.openString(value);
22
+ }
23
+ // Returns a row whose sealed content columns are decrypted in place, so the pure builders below run
24
+ // on plaintext exactly as they did before encryption landed.
25
+ function decryptContentColumns(row, cipher) {
26
+ return {
27
+ ...row,
28
+ body: cipher.openString(row.body),
29
+ payload_json: openNullable(cipher, row.payload_json),
30
+ tags_json: cipher.openString(row.tags_json),
31
+ capture_rationale: openNullable(cipher, row.capture_rationale),
32
+ stale_reason: openNullable(cipher, row.stale_reason),
33
+ };
34
+ }
35
+ function sealNullable(cipher, value) {
36
+ return value === null ? null : cipher.sealString(value);
37
+ }
38
+ // Returns a write-row whose content columns are sealed, leaving every metadata column untouched.
39
+ function encryptContentColumns(row, cipher) {
40
+ return {
41
+ ...row,
42
+ body: cipher.sealString(row.body),
43
+ payload_json: sealNullable(cipher, row.payload_json),
44
+ tags_json: cipher.sealString(row.tags_json),
45
+ capture_rationale: sealNullable(cipher, row.capture_rationale),
46
+ stale_reason: sealNullable(cipher, row.stale_reason),
47
+ };
48
+ }
49
+ function buildScopeFromRow(kind, coord) {
50
+ switch (kind) {
51
+ case "user":
52
+ return { kind: "user", userId: coord };
53
+ case "workspace":
54
+ return { kind: "workspace", workspaceId: coord };
55
+ case "project":
56
+ return { kind: "project", projectId: coord };
57
+ case "workflow":
58
+ return { kind: "workflow", workflowDefinitionId: coord };
59
+ case "global":
60
+ return { kind: "global" };
61
+ default:
62
+ throw new MemoryStorageError("schema-mismatch", "Stored memory scope kind is not recognized.");
63
+ }
64
+ }
65
+ function buildProvenanceFromRow(row) {
66
+ const base = {
67
+ sourceKind: row.source_kind,
68
+ capturedAt: row.captured_at,
69
+ confidence: row.confidence,
70
+ sensitivity: row.sensitivity,
71
+ };
72
+ if (row.source_conversation_id !== null) {
73
+ base.sourceConversationId = row.source_conversation_id;
74
+ }
75
+ if (row.source_workflow_run_id !== null) {
76
+ base.sourceWorkflowRunId = row.source_workflow_run_id;
77
+ }
78
+ if (row.source_evidence_manifest_id !== null) {
79
+ base.sourceEvidenceManifestId = row.source_evidence_manifest_id;
80
+ }
81
+ if (row.capture_rationale !== null) {
82
+ base.captureRationale = row.capture_rationale;
83
+ }
84
+ if (row.model_provider !== null && row.model_id !== null) {
85
+ const identity = {
86
+ provider: row.model_provider,
87
+ modelId: row.model_id,
88
+ };
89
+ if (row.model_revision !== null)
90
+ identity.modelRevision = row.model_revision;
91
+ base.modelIdentity = identity;
92
+ }
93
+ return base;
94
+ }
95
+ function buildValidityFromRow(row) {
96
+ return row.valid_until === null
97
+ ? { validFrom: row.valid_from }
98
+ : { validFrom: row.valid_from, validUntil: row.valid_until };
99
+ }
100
+ function buildRetentionHintFromRow(row) {
101
+ if (row.retention_policy_key === null)
102
+ return undefined;
103
+ const hint = {
104
+ policyKey: row.retention_policy_key,
105
+ };
106
+ if (row.retention_retain_until !== null)
107
+ hint.retainUntil = row.retention_retain_until;
108
+ if (row.retention_notes !== null)
109
+ hint.notes = row.retention_notes;
110
+ return hint;
111
+ }
112
+ function parseTagsJson(raw) {
113
+ let parsed;
114
+ try {
115
+ parsed = JSON.parse(raw);
116
+ }
117
+ catch {
118
+ throw new MemoryStorageError("schema-mismatch", "Stored memory tags JSON is invalid.");
119
+ }
120
+ if (!Array.isArray(parsed))
121
+ return [];
122
+ return parsed.filter((v) => typeof v === "string");
123
+ }
124
+ function parsePayloadJson(raw) {
125
+ if (raw === null)
126
+ return undefined;
127
+ try {
128
+ return JSON.parse(raw);
129
+ }
130
+ catch {
131
+ throw new MemoryStorageError("schema-mismatch", "Stored memory payload JSON is invalid.");
132
+ }
133
+ }
134
+ export function rowToMemoryRecord(row, cipher) {
135
+ const decrypted = decryptContentColumns(row, cipher);
136
+ return decryptedRowToMemoryRecord(decrypted);
137
+ }
138
+ function decryptedRowToMemoryRecord(row) {
139
+ const payload = parsePayloadJson(row.payload_json);
140
+ const retentionHint = buildRetentionHintFromRow(row);
141
+ const base = {
142
+ id: row.id,
143
+ schemaVersion: "1",
144
+ scope: buildScopeFromRow(row.scope_kind, row.scope_coordinate),
145
+ type: row.type,
146
+ body: row.body,
147
+ provenance: buildProvenanceFromRow(row),
148
+ validity: buildValidityFromRow(row),
149
+ status: row.status,
150
+ pinned: row.pinned === 1,
151
+ tags: parseTagsJson(row.tags_json),
152
+ createdAt: row.created_at,
153
+ updatedAt: row.updated_at,
154
+ };
155
+ const out = {
156
+ ...base,
157
+ ...(payload !== undefined ? { payload } : {}),
158
+ ...(row.stale_reason !== null ? { staleReason: row.stale_reason } : {}),
159
+ ...(retentionHint !== undefined ? { retentionHint } : {}),
160
+ };
161
+ return out;
162
+ }
163
+ function modelIdentityFieldsToRow(identity) {
164
+ return {
165
+ model_provider: identity?.provider ?? null,
166
+ model_id: identity?.modelId ?? null,
167
+ model_revision: identity?.modelRevision ?? null,
168
+ };
169
+ }
170
+ function provenanceFieldsToRow(prov) {
171
+ return {
172
+ sensitivity: prov.sensitivity,
173
+ confidence: prov.confidence,
174
+ source_kind: prov.sourceKind,
175
+ source_conversation_id: prov.sourceConversationId ?? null,
176
+ source_workflow_run_id: prov.sourceWorkflowRunId ?? null,
177
+ source_evidence_manifest_id: prov.sourceEvidenceManifestId ?? null,
178
+ captured_at: prov.capturedAt,
179
+ capture_rationale: prov.captureRationale ?? null,
180
+ ...modelIdentityFieldsToRow(prov.modelIdentity),
181
+ };
182
+ }
183
+ function retentionFieldsToRow(hint) {
184
+ return {
185
+ retention_policy_key: hint?.policyKey ?? null,
186
+ retention_retain_until: hint?.retainUntil ?? null,
187
+ retention_notes: hint?.notes ?? null,
188
+ };
189
+ }
190
+ export function memoryRecordToRow(record, cipher) {
191
+ return encryptContentColumns(plainRecordToRow(record), cipher);
192
+ }
193
+ function plainRecordToRow(record) {
194
+ return {
195
+ id: record.id,
196
+ schema_version: record.schemaVersion,
197
+ type: record.type,
198
+ scope_kind: scopeKindOf(record.scope),
199
+ scope_coordinate: scopeCoordinateOf(record.scope),
200
+ body: record.body,
201
+ payload_json: record.payload === undefined ? null : JSON.stringify(record.payload),
202
+ status: record.status,
203
+ pinned: record.pinned ? 1 : 0,
204
+ valid_from: record.validity.validFrom,
205
+ valid_until: record.validity.validUntil ?? null,
206
+ stale_reason: record.staleReason ?? null,
207
+ tags_json: JSON.stringify(record.tags),
208
+ ...provenanceFieldsToRow(record.provenance),
209
+ ...retentionFieldsToRow(record.retentionHint),
210
+ created_at: record.createdAt,
211
+ updated_at: record.updatedAt,
212
+ };
213
+ }
@@ -0,0 +1,8 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import type { MemoryScope } from "@oscharko-dev/keiko-contracts/memory";
3
+ import type { MemoryTombstone } from "./types.js";
4
+ import type { MemoryContentCipher } from "./cipher.js";
5
+ export declare function insertTombstoneRow(db: DatabaseSync, tombstone: MemoryTombstone, cipher: MemoryContentCipher): void;
6
+ export declare function listTombstonesByScopeRows(db: DatabaseSync, scope: MemoryScope, cipher: MemoryContentCipher): readonly MemoryTombstone[];
7
+ export declare function deleteTombstonesByScopeBeforeRows(db: DatabaseSync, scope: MemoryScope, forgottenBeforeMs: number): number;
8
+ //# sourceMappingURL=tombstones.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tombstones.d.ts","sourceRoot":"","sources":["../src/tombstones.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAGV,WAAW,EAIZ,MAAM,sCAAsC,CAAC;AAE9C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAClD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAsDvD,wBAAgB,kBAAkB,CAChC,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,eAAe,EAC1B,MAAM,EAAE,mBAAmB,GAC1B,IAAI,CAcN;AAED,wBAAgB,yBAAyB,CACvC,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,mBAAmB,GAC1B,SAAS,eAAe,EAAE,CAK5B;AAED,wBAAgB,iCAAiC,CAC/C,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,WAAW,EAClB,iBAAiB,EAAE,MAAM,GACxB,MAAM,CAKR"}
@@ -0,0 +1,57 @@
1
+ // Prepared SQL for the memory_tombstones table. Tombstones are intentionally NOT a foreign key
2
+ // against memories.id — the memory row is gone by the time the tombstone is written; the FK
3
+ // would either reject the insert or force ON DELETE SET NULL, both of which lose the audit
4
+ // signal. We therefore store memory_id as a denormalised TEXT column and accept that listing a
5
+ // tombstone tells you "this id existed in this scope at this time," not "follow the FK."
6
+ import { scopeCoordinateOf, scopeKindOf } from "./scope-key.js";
7
+ const INSERT_SQL = `
8
+ INSERT INTO memory_tombstones (
9
+ id, memory_id, scope_kind, scope_coordinate, type, forgotten_at,
10
+ forgetter_surface, reviewer_id, original_status, reason
11
+ ) VALUES (?,?,?,?,?,?,?,?,?,?)
12
+ `;
13
+ const LIST_BY_SCOPE_SQL = `
14
+ SELECT * FROM memory_tombstones
15
+ WHERE scope_kind = ? AND scope_coordinate = ?
16
+ ORDER BY forgotten_at ASC
17
+ `;
18
+ const DELETE_BY_SCOPE_BEFORE_SQL = `
19
+ DELETE FROM memory_tombstones
20
+ WHERE scope_kind = ? AND scope_coordinate = ? AND forgotten_at < ?
21
+ `;
22
+ // `reason` is the only free-text tombstone column, so it is the only sealed one (ADR-0035).
23
+ function rowToTombstone(row, cipher) {
24
+ const base = {
25
+ id: row.id,
26
+ memoryId: row.memory_id,
27
+ scopeKind: row.scope_kind,
28
+ scopeCoordinate: row.scope_coordinate,
29
+ type: row.type,
30
+ forgottenAt: row.forgotten_at,
31
+ forgetterSurface: row.forgetter_surface,
32
+ };
33
+ return {
34
+ ...base,
35
+ ...(row.reviewer_id === null ? {} : { reviewerId: row.reviewer_id }),
36
+ ...(row.original_status === null
37
+ ? {}
38
+ : { originalStatus: row.original_status }),
39
+ ...(row.reason === null ? {} : { reason: cipher.openString(row.reason) }),
40
+ };
41
+ }
42
+ export function insertTombstoneRow(db, tombstone, cipher) {
43
+ const reason = tombstone.reason === undefined ? null : cipher.sealString(tombstone.reason);
44
+ db.prepare(INSERT_SQL).run(tombstone.id, tombstone.memoryId, tombstone.scopeKind, tombstone.scopeCoordinate, tombstone.type, tombstone.forgottenAt, tombstone.forgetterSurface, tombstone.reviewerId ?? null, tombstone.originalStatus ?? null, reason);
45
+ }
46
+ export function listTombstonesByScopeRows(db, scope, cipher) {
47
+ const rows = db
48
+ .prepare(LIST_BY_SCOPE_SQL)
49
+ .all(scopeKindOf(scope), scopeCoordinateOf(scope));
50
+ return rows.map((row) => rowToTombstone(row, cipher));
51
+ }
52
+ export function deleteTombstonesByScopeBeforeRows(db, scope, forgottenBeforeMs) {
53
+ const info = db
54
+ .prepare(DELETE_BY_SCOPE_BEFORE_SQL)
55
+ .run(scopeKindOf(scope), scopeCoordinateOf(scope), forgottenBeforeMs);
56
+ return Number(info.changes);
57
+ }