ember-custody 0.1.0__py3-none-any.whl

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.
ember/__init__.py ADDED
@@ -0,0 +1,93 @@
1
+ """Ember: cryptographic custody, redactable Merkle receipts, and verifiable cascade erasure."""
2
+
3
+ from .adapters import (
4
+ AdapterHit,
5
+ InMemoryStoreAdapter,
6
+ Mem0Adapter,
7
+ MemoryStoreAdapter,
8
+ SQLiteStoreAdapter,
9
+ VectorStoreAdapter,
10
+ )
11
+ from .audit import SUITE_V2, SubjectScenario, run_benchmark, verify_suite_hygiene
12
+ from .crypto import CIPHER_NAME, ERASED_TOMBSTONE, DecryptionError
13
+ from .keystore import FileKeyStore, KeyStore, MemoryKeyStore, ShredReport
14
+ from .ledger import IdempotencyConflict, LedgerIntegrityError, SQLiteLedger
15
+ from .merkle import RedactableDoc, inclusion_proof, leaf_hash, merkle_root, split_parts
16
+ from .receipts import Receipt, locate_span, mint_flat_receipt, mint_redactable_receipt
17
+ from .records import ClosureManifest, ErasureCertificate, LedgerEvent, MemoryItem, SourceRecord
18
+ from .reducer import (
19
+ EmberState,
20
+ ErasureIncomplete,
21
+ InvariantViolation,
22
+ ReductionError,
23
+ TombstonedSubjectError,
24
+ apply_event,
25
+ compute_closure,
26
+ decrypt_item_metadata,
27
+ decrypt_item_payload,
28
+ )
29
+ from .signing import AnchorReceipt, CallbackAnchor, Ed25519Signer, NullAnchor, TimestampAnchor
30
+ from .vault import CustodyVault, QueryHit, RememberResult, SearchResult
31
+ from .verify import verify_bundle, verify_ed25519_certificate, verify_ledger_chain, verify_redactable_receipt
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ __all__ = [
36
+ "AdapterHit",
37
+ "AnchorReceipt",
38
+ "CIPHER_NAME",
39
+ "CallbackAnchor",
40
+ "ClosureManifest",
41
+ "CustodyVault",
42
+ "DecryptionError",
43
+ "ERASED_TOMBSTONE",
44
+ "Ed25519Signer",
45
+ "EmberState",
46
+ "ErasureCertificate",
47
+ "ErasureIncomplete",
48
+ "FileKeyStore",
49
+ "IdempotencyConflict",
50
+ "InMemoryStoreAdapter",
51
+ "InvariantViolation",
52
+ "KeyStore",
53
+ "LedgerEvent",
54
+ "LedgerIntegrityError",
55
+ "Mem0Adapter",
56
+ "MemoryItem",
57
+ "MemoryKeyStore",
58
+ "MemoryStoreAdapter",
59
+ "NullAnchor",
60
+ "QueryHit",
61
+ "Receipt",
62
+ "RedactableDoc",
63
+ "ReductionError",
64
+ "RememberResult",
65
+ "SQLiteLedger",
66
+ "SQLiteStoreAdapter",
67
+ "SUITE_V2",
68
+ "SearchResult",
69
+ "ShredReport",
70
+ "SourceRecord",
71
+ "SubjectScenario",
72
+ "TimestampAnchor",
73
+ "TombstonedSubjectError",
74
+ "VectorStoreAdapter",
75
+ "__version__",
76
+ "apply_event",
77
+ "compute_closure",
78
+ "decrypt_item_metadata",
79
+ "decrypt_item_payload",
80
+ "inclusion_proof",
81
+ "leaf_hash",
82
+ "locate_span",
83
+ "merkle_root",
84
+ "mint_flat_receipt",
85
+ "mint_redactable_receipt",
86
+ "run_benchmark",
87
+ "split_parts",
88
+ "verify_bundle",
89
+ "verify_ed25519_certificate",
90
+ "verify_ledger_chain",
91
+ "verify_redactable_receipt",
92
+ "verify_suite_hygiene",
93
+ ]
ember/adapters.py ADDED
@@ -0,0 +1,525 @@
1
+ """Pluggable store adapters for Ember cryptographic custody.
2
+
3
+ Enables developers to wrap an existing memory engine or vector store (Mem0, LangChain/Chroma/
4
+ Qdrant-style stores, SQLite keyword stores) without rewriting their application interface.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable, Sequence
10
+ from dataclasses import dataclass, field
11
+ import inspect
12
+ from pathlib import Path
13
+ import re
14
+ import sqlite3
15
+ import threading
16
+ from typing import Any, Protocol, runtime_checkable
17
+ import uuid
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class AdapterHit:
22
+ """Normalized record returned by any MemoryStoreAdapter search call."""
23
+
24
+ record_id: str
25
+ text: str
26
+ score: float
27
+ metadata: dict[str, Any] = field(default_factory=dict)
28
+
29
+
30
+ @runtime_checkable
31
+ class MemoryStoreAdapter(Protocol):
32
+ """Minimal contract any backing vector store or agent memory engine satisfies."""
33
+
34
+ name: str
35
+
36
+ def upsert(
37
+ self,
38
+ *,
39
+ item_id: str,
40
+ text: str,
41
+ subjects: tuple[str, ...],
42
+ metadata: dict[str, Any],
43
+ ) -> tuple[str, ...]: ...
44
+
45
+ def search(
46
+ self,
47
+ query: str,
48
+ *,
49
+ limit: int = 5,
50
+ filters: dict[str, Any] | None = None,
51
+ ) -> list[AdapterHit]: ...
52
+
53
+ def delete(self, record_ids: Sequence[str]) -> int: ...
54
+
55
+
56
+ _TOKEN_RE = re.compile(r"[^\W_]+", re.UNICODE)
57
+
58
+
59
+ def _tokenize(text: str) -> set[str]:
60
+ return set(_TOKEN_RE.findall(text.lower()))
61
+
62
+
63
+ _STOPWORDS = frozenset(
64
+ {
65
+ "a",
66
+ "an",
67
+ "and",
68
+ "are",
69
+ "at",
70
+ "based",
71
+ "before",
72
+ "did",
73
+ "do",
74
+ "does",
75
+ "everyone",
76
+ "how",
77
+ "in",
78
+ "is",
79
+ "list",
80
+ "living",
81
+ "many",
82
+ "me",
83
+ "of",
84
+ "people",
85
+ "tell",
86
+ "the",
87
+ "was",
88
+ "what",
89
+ "when",
90
+ "where",
91
+ "which",
92
+ "who",
93
+ "with",
94
+ "work",
95
+ "works",
96
+ }
97
+ )
98
+
99
+
100
+ def _content_tokens(text: str) -> set[str]:
101
+ toks = _tokenize(text)
102
+ stripped = toks - _STOPWORDS
103
+ return stripped or toks
104
+
105
+
106
+ def _matches_filters(metadata: dict[str, Any], filters: dict[str, Any] | None) -> bool:
107
+ if not filters:
108
+ return True
109
+ for k, v in filters.items():
110
+ if metadata.get(k) != v:
111
+ return False
112
+ return True
113
+
114
+
115
+ class InMemoryStoreAdapter:
116
+ """Deterministic keyword-overlap store useful for unit tests, benchmarks, and baselines."""
117
+
118
+ name = "in-memory"
119
+
120
+ def __init__(self) -> None:
121
+ self._lock = threading.RLock()
122
+ self._records: dict[str, dict[str, Any]] = {}
123
+
124
+ def upsert(
125
+ self,
126
+ *,
127
+ item_id: str,
128
+ text: str,
129
+ subjects: tuple[str, ...],
130
+ metadata: dict[str, Any],
131
+ ) -> tuple[str, ...]:
132
+ rec_id = f"mem_{uuid.uuid4().hex[:12]}"
133
+ meta = dict(metadata)
134
+ meta.setdefault("ember_item_id", item_id)
135
+ if subjects and "user_id" not in meta:
136
+ meta["user_id"] = subjects[0]
137
+ with self._lock:
138
+ self._records[rec_id] = {
139
+ "record_id": rec_id,
140
+ "text": text,
141
+ "subjects": list(subjects),
142
+ "metadata": meta,
143
+ }
144
+ return (rec_id,)
145
+
146
+ def search(
147
+ self,
148
+ query: str,
149
+ *,
150
+ limit: int = 5,
151
+ filters: dict[str, Any] | None = None,
152
+ ) -> list[AdapterHit]:
153
+ if limit <= 0:
154
+ return []
155
+ q_toks = _content_tokens(query)
156
+ scored: list[AdapterHit] = []
157
+ with self._lock:
158
+ for rec in self._records.values():
159
+ if not _matches_filters(rec["metadata"], filters):
160
+ continue
161
+ d_toks = _tokenize(rec["text"])
162
+ overlap = len(q_toks & d_toks)
163
+ if overlap == 0 and query.strip().lower() not in rec["text"].lower():
164
+ continue
165
+ score = overlap / max(1, len(q_toks))
166
+ scored.append(
167
+ AdapterHit(
168
+ record_id=rec["record_id"],
169
+ text=rec["text"],
170
+ score=score,
171
+ metadata=dict(rec["metadata"]),
172
+ )
173
+ )
174
+ scored.sort(key=lambda h: (-h.score, h.record_id))
175
+ return scored[:limit]
176
+
177
+ def delete(self, record_ids: Sequence[str]) -> int:
178
+ removed = 0
179
+ with self._lock:
180
+ for rid in record_ids:
181
+ if self._records.pop(rid, None) is not None:
182
+ removed += 1
183
+ return removed
184
+
185
+ def delete_by_user_id_naive(self, user_id: str) -> int:
186
+ """Deliberately naive user_id-only delete used as baseline control in benchmarks."""
187
+ with self._lock:
188
+ doomed = [
189
+ rid
190
+ for rid, rec in self._records.items()
191
+ if rec["metadata"].get("user_id") == user_id
192
+ ]
193
+ for rid in doomed:
194
+ del self._records[rid]
195
+ return len(doomed)
196
+
197
+ def all_records(self) -> list[dict[str, Any]]:
198
+ with self._lock:
199
+ return [dict(r) for r in self._records.values()]
200
+
201
+
202
+ class SQLiteStoreAdapter:
203
+ """Persistent single-file SQLite store implementing `MemoryStoreAdapter` with zero extra deps."""
204
+
205
+ name = "sqlite-store"
206
+
207
+ def __init__(self, path: str = ":memory:") -> None:
208
+ if path != ":memory:":
209
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
210
+ self._conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
211
+ self._lock = threading.RLock()
212
+ self._conn.execute("PRAGMA journal_mode = WAL")
213
+ self._conn.execute("PRAGMA synchronous = FULL")
214
+ self._conn.execute(
215
+ """
216
+ CREATE TABLE IF NOT EXISTS external_memories (
217
+ record_id TEXT PRIMARY KEY,
218
+ item_id TEXT NOT NULL,
219
+ text TEXT NOT NULL,
220
+ meta_json TEXT NOT NULL
221
+ )
222
+ """
223
+ )
224
+
225
+ def close(self) -> None:
226
+ with self._lock:
227
+ self._conn.close()
228
+
229
+ def upsert(
230
+ self,
231
+ *,
232
+ item_id: str,
233
+ text: str,
234
+ subjects: tuple[str, ...],
235
+ metadata: dict[str, Any],
236
+ ) -> tuple[str, ...]:
237
+ import json
238
+
239
+ rec_id = f"sql_{uuid.uuid4().hex[:12]}"
240
+ meta = dict(metadata)
241
+ meta.setdefault("ember_item_id", item_id)
242
+ if subjects and "user_id" not in meta:
243
+ meta["user_id"] = subjects[0]
244
+ with self._lock:
245
+ self._conn.execute("BEGIN IMMEDIATE")
246
+ try:
247
+ self._conn.execute(
248
+ "INSERT INTO external_memories (record_id, item_id, text, meta_json) VALUES (?, ?, ?, ?)",
249
+ (rec_id, item_id, text, json.dumps(meta, sort_keys=True)),
250
+ )
251
+ self._conn.execute("COMMIT")
252
+ except BaseException:
253
+ self._conn.execute("ROLLBACK")
254
+ raise
255
+ return (rec_id,)
256
+
257
+ def search(
258
+ self,
259
+ query: str,
260
+ *,
261
+ limit: int = 5,
262
+ filters: dict[str, Any] | None = None,
263
+ ) -> list[AdapterHit]:
264
+ import json
265
+
266
+ if limit <= 0:
267
+ return []
268
+ q_toks = _content_tokens(query)
269
+ with self._lock:
270
+ rows = self._conn.execute(
271
+ "SELECT record_id, text, meta_json FROM external_memories ORDER BY rowid ASC"
272
+ ).fetchall()
273
+ scored: list[AdapterHit] = []
274
+ for rec_id, text, meta_json in rows:
275
+ meta = json.loads(meta_json)
276
+ if not _matches_filters(meta, filters):
277
+ continue
278
+ overlap = len(q_toks & _tokenize(text))
279
+ if overlap == 0 and query.strip().lower() not in text.lower():
280
+ continue
281
+ scored.append(
282
+ AdapterHit(
283
+ record_id=rec_id,
284
+ text=text,
285
+ score=overlap / max(1, len(q_toks)),
286
+ metadata=meta,
287
+ )
288
+ )
289
+ scored.sort(key=lambda h: (-h.score, h.record_id))
290
+ return scored[:limit]
291
+
292
+ def delete(self, record_ids: Sequence[str]) -> int:
293
+ if not record_ids:
294
+ return 0
295
+ with self._lock:
296
+ self._conn.execute("BEGIN IMMEDIATE")
297
+ try:
298
+ before = self._conn.total_changes
299
+ self._conn.executemany(
300
+ "DELETE FROM external_memories WHERE record_id = ?",
301
+ [(rid,) for rid in record_ids],
302
+ )
303
+ deleted = max(0, self._conn.total_changes - before)
304
+ self._conn.execute("COMMIT")
305
+ return deleted
306
+ except BaseException:
307
+ self._conn.execute("ROLLBACK")
308
+ raise
309
+
310
+
311
+ class Mem0Adapter:
312
+ """Drop-in custody adapter wrapping `mem0.Memory` or `mem0.MemoryClient`.
313
+
314
+ Tracks every returned Mem0 `memory_id` in Ember's structural closure so erasing a
315
+ subject deletes direct facts, shared workspace notes, and downstream derived
316
+ summaries across Mem0 regardless of `user_id` bucket fragmentation.
317
+ """
318
+
319
+ name = "mem0"
320
+
321
+ def __init__(
322
+ self,
323
+ client: Any,
324
+ *,
325
+ default_user_id: str | None = None,
326
+ default_agent_id: str | None = None,
327
+ infer: bool = False,
328
+ ) -> None:
329
+ self.client = client
330
+ self.default_user_id = default_user_id
331
+ self.default_agent_id = default_agent_id
332
+ self.infer = infer
333
+
334
+ def upsert(
335
+ self,
336
+ *,
337
+ item_id: str,
338
+ text: str,
339
+ subjects: tuple[str, ...],
340
+ metadata: dict[str, Any],
341
+ ) -> tuple[str, ...]:
342
+ meta = dict(metadata)
343
+ meta["ember_item_id"] = item_id
344
+ meta["ember_subjects"] = list(subjects)
345
+
346
+ kwargs: dict[str, Any] = {"metadata": meta, "infer": self.infer}
347
+ user_id = meta.get("user_id") or self.default_user_id or (subjects[0] if subjects else None)
348
+ agent_id = meta.get("agent_id") or self.default_agent_id
349
+ if user_id is not None:
350
+ kwargs["user_id"] = user_id
351
+ if agent_id is not None:
352
+ kwargs["agent_id"] = agent_id
353
+
354
+ sig = inspect.signature(self.client.add)
355
+ accepted = set(sig.parameters)
356
+ has_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
357
+ filtered_kwargs = {k: v for k, v in kwargs.items() if k in accepted or has_varkw}
358
+ if "messages" in accepted:
359
+ raw = self.client.add(messages=text, **filtered_kwargs)
360
+ else:
361
+ raw = self.client.add(text, **filtered_kwargs)
362
+
363
+ if raw is None:
364
+ return ()
365
+ rows = raw.get("results", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
366
+ ids: list[str] = []
367
+ for entry in rows:
368
+ if isinstance(entry, dict) and entry.get("id"):
369
+ ids.append(str(entry["id"]))
370
+ if not ids and isinstance(raw, dict) and raw.get("id"):
371
+ ids.append(str(raw["id"]))
372
+ return tuple(ids)
373
+
374
+ def search(
375
+ self,
376
+ query: str,
377
+ *,
378
+ limit: int = 5,
379
+ filters: dict[str, Any] | None = None,
380
+ ) -> list[AdapterHit]:
381
+ if limit <= 0:
382
+ return []
383
+ sig = inspect.signature(self.client.search)
384
+ params = set(sig.parameters)
385
+ kwargs: dict[str, Any] = {}
386
+ if "limit" in params:
387
+ kwargs["limit"] = limit
388
+ if filters:
389
+ if "filters" in params:
390
+ kwargs["filters"] = filters
391
+ else:
392
+ for k, v in filters.items():
393
+ if k in params:
394
+ kwargs[k] = v
395
+ raw = self.client.search(query, **kwargs)
396
+ if raw is None:
397
+ return []
398
+ rows = raw.get("results", []) if isinstance(raw, dict) else (raw if isinstance(raw, list) else [])
399
+ hits: list[AdapterHit] = []
400
+ for entry in rows:
401
+ if not isinstance(entry, dict):
402
+ continue
403
+ hits.append(
404
+ AdapterHit(
405
+ record_id=str(entry.get("id", "")),
406
+ text=str(entry.get("memory") or entry.get("text") or entry.get("content") or ""),
407
+ score=float(entry.get("score", 0.0) or 0.0),
408
+ metadata=dict(entry.get("metadata") or {}),
409
+ )
410
+ )
411
+ return hits[:limit]
412
+
413
+ def delete(self, record_ids: Sequence[str]) -> int:
414
+ deleted = 0
415
+ for rid in record_ids:
416
+ if not rid:
417
+ continue
418
+ try:
419
+ sig = inspect.signature(self.client.delete)
420
+ if "memory_id" in sig.parameters:
421
+ self.client.delete(memory_id=rid)
422
+ else:
423
+ self.client.delete(rid)
424
+ deleted += 1
425
+ except TypeError:
426
+ self.client.delete(rid)
427
+ deleted += 1
428
+ return deleted
429
+
430
+
431
+ class VectorStoreAdapter:
432
+ """Adapter for LangChain / Chroma / Qdrant-compatible `.add_texts` / `.similarity_search` / `.delete`."""
433
+
434
+ name = "vector-store"
435
+
436
+ def __init__(self, store: Any) -> None:
437
+ self.store = store
438
+
439
+ def upsert(
440
+ self,
441
+ *,
442
+ item_id: str,
443
+ text: str,
444
+ subjects: tuple[str, ...],
445
+ metadata: dict[str, Any],
446
+ ) -> tuple[str, ...]:
447
+ meta = {**metadata, "ember_item_id": item_id, "ember_subjects": list(subjects)}
448
+ rec_id = f"vec_{uuid.uuid4().hex[:12]}"
449
+ sig = inspect.signature(self.store.add_texts)
450
+ params = set(sig.parameters)
451
+ has_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
452
+ kwargs: dict[str, Any] = {}
453
+ if "metadatas" in params or has_varkw:
454
+ kwargs["metadatas"] = [meta]
455
+ if "ids" in params or has_varkw:
456
+ kwargs["ids"] = [rec_id]
457
+ returned = self.store.add_texts(texts=[text], **kwargs)
458
+ if isinstance(returned, (list, tuple)) and returned:
459
+ return tuple(str(x) for x in returned)
460
+ return (rec_id,) if ("ids" in kwargs) else ()
461
+
462
+ def search(
463
+ self,
464
+ query: str,
465
+ *,
466
+ limit: int = 5,
467
+ filters: dict[str, Any] | None = None,
468
+ ) -> list[AdapterHit]:
469
+ if limit <= 0:
470
+ return []
471
+ if hasattr(self.store, "similarity_search_with_score"):
472
+ sig = inspect.signature(self.store.similarity_search_with_score)
473
+ params = set(sig.parameters)
474
+ has_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
475
+ kwargs: dict[str, Any] = {}
476
+ if "k" in params or has_varkw:
477
+ kwargs["k"] = limit
478
+ if filters is not None and ("filter" in params or has_varkw):
479
+ kwargs["filter"] = filters
480
+ pairs = self.store.similarity_search_with_score(query, **kwargs)
481
+ hits: list[AdapterHit] = []
482
+ for doc, score in pairs:
483
+ meta = dict(getattr(doc, "metadata", {}) or {})
484
+ hits.append(
485
+ AdapterHit(
486
+ record_id=str(getattr(doc, "id", None) or meta.get("id") or meta.get("ember_item_id") or ""),
487
+ text=str(getattr(doc, "page_content", "")),
488
+ score=float(score),
489
+ metadata=meta,
490
+ )
491
+ )
492
+ return hits[:limit]
493
+ sig = inspect.signature(self.store.similarity_search)
494
+ params = set(sig.parameters)
495
+ has_varkw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
496
+ kwargs = {}
497
+ if "k" in params or has_varkw:
498
+ kwargs["k"] = limit
499
+ if filters is not None and ("filter" in params or has_varkw):
500
+ kwargs["filter"] = filters
501
+ docs = self.store.similarity_search(query, **kwargs)
502
+ return [
503
+ AdapterHit(
504
+ record_id=str(getattr(doc, "id", None) or (getattr(doc, "metadata", {}) or {}).get("ember_item_id", "")),
505
+ text=str(getattr(doc, "page_content", "")),
506
+ score=1.0,
507
+ metadata=dict(getattr(doc, "metadata", {}) or {}),
508
+ )
509
+ for doc in docs
510
+ ][:limit]
511
+
512
+ def delete(self, record_ids: Sequence[str]) -> int:
513
+ ids = list(dict.fromkeys(rid for rid in record_ids if rid))
514
+ if not ids:
515
+ return 0
516
+ sig = inspect.signature(self.store.delete)
517
+ params = set(sig.parameters)
518
+ if "ids" in params or any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
519
+ self.store.delete(ids=ids)
520
+ else:
521
+ self.store.delete(ids)
522
+ return len(ids)
523
+
524
+
525
+ ProbeFilterFn = Callable[[AdapterHit], bool]