rootmemory 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.
Files changed (75) hide show
  1. rootmemory/__init__.py +84 -0
  2. rootmemory/api/__init__.py +25 -0
  3. rootmemory/api/agents.py +38 -0
  4. rootmemory/api/beliefs.py +66 -0
  5. rootmemory/api/claims.py +87 -0
  6. rootmemory/api/deps.py +21 -0
  7. rootmemory/api/errors.py +36 -0
  8. rootmemory/api/graph.py +32 -0
  9. rootmemory/api/invalidation.py +47 -0
  10. rootmemory/api/observations.py +46 -0
  11. rootmemory/api/promotion.py +37 -0
  12. rootmemory/api/provenance.py +88 -0
  13. rootmemory/api/security.py +53 -0
  14. rootmemory/cli.py +96 -0
  15. rootmemory/client.py +219 -0
  16. rootmemory/config.py +64 -0
  17. rootmemory/db/__init__.py +0 -0
  18. rootmemory/db/base.py +42 -0
  19. rootmemory/db/migrations/env.py +59 -0
  20. rootmemory/db/migrations/script.py.mako +26 -0
  21. rootmemory/db/migrations/versions/d336d5cf7fb9_initial_schema.py +260 -0
  22. rootmemory/db/session.py +138 -0
  23. rootmemory/errors.py +27 -0
  24. rootmemory/integrations/__init__.py +16 -0
  25. rootmemory/integrations/async_client.py +268 -0
  26. rootmemory/integrations/langchain_tools.py +237 -0
  27. rootmemory/integrations/langgraph_memory.py +179 -0
  28. rootmemory/integrations/langgraph_store.py +402 -0
  29. rootmemory/logging_config.py +30 -0
  30. rootmemory/main.py +133 -0
  31. rootmemory/models/__init__.py +22 -0
  32. rootmemory/models/agent.py +30 -0
  33. rootmemory/models/audit.py +51 -0
  34. rootmemory/models/belief.py +39 -0
  35. rootmemory/models/claim.py +52 -0
  36. rootmemory/models/decision.py +38 -0
  37. rootmemory/models/edge.py +43 -0
  38. rootmemory/models/enums.py +104 -0
  39. rootmemory/models/node_ref.py +42 -0
  40. rootmemory/models/observation.py +51 -0
  41. rootmemory/py.typed +0 -0
  42. rootmemory/repositories/__init__.py +19 -0
  43. rootmemory/repositories/agent_repo.py +44 -0
  44. rootmemory/repositories/audit_repo.py +43 -0
  45. rootmemory/repositories/belief_repo.py +63 -0
  46. rootmemory/repositories/claim_repo.py +102 -0
  47. rootmemory/repositories/decision_repo.py +76 -0
  48. rootmemory/repositories/edge_repo.py +77 -0
  49. rootmemory/repositories/observation_repo.py +76 -0
  50. rootmemory/schemas/__init__.py +52 -0
  51. rootmemory/schemas/agent.py +26 -0
  52. rootmemory/schemas/belief.py +49 -0
  53. rootmemory/schemas/claim.py +54 -0
  54. rootmemory/schemas/common.py +27 -0
  55. rootmemory/schemas/observation.py +69 -0
  56. rootmemory/schemas/promotion.py +50 -0
  57. rootmemory/schemas/provenance.py +51 -0
  58. rootmemory/services/__init__.py +35 -0
  59. rootmemory/services/belief_service.py +161 -0
  60. rootmemory/services/contradiction_service.py +148 -0
  61. rootmemory/services/decision_service.py +65 -0
  62. rootmemory/services/graph_service.py +316 -0
  63. rootmemory/services/independence_service.py +159 -0
  64. rootmemory/services/invalidation_service.py +164 -0
  65. rootmemory/services/normalization_service.py +234 -0
  66. rootmemory/services/promotion_service.py +318 -0
  67. rootmemory/services/provenance_service.py +282 -0
  68. rootmemory/services/scoring_service.py +53 -0
  69. rootmemory/static/index.html +866 -0
  70. rootmemory-0.1.0.dist-info/METADATA +266 -0
  71. rootmemory-0.1.0.dist-info/RECORD +75 -0
  72. rootmemory-0.1.0.dist-info/WHEEL +5 -0
  73. rootmemory-0.1.0.dist-info/entry_points.txt +2 -0
  74. rootmemory-0.1.0.dist-info/licenses/LICENSE +21 -0
  75. rootmemory-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,402 @@
1
+ """A LangGraph ``BaseStore`` backed by RootMemory.
2
+
3
+ This is the drop-in for the long-term memory slot::
4
+
5
+ agent = create_react_agent(model, tools, store=RootMemoryStore(memory, agent_id))
6
+
7
+ Two things are worth understanding before using it.
8
+
9
+ **Which slot this is.** ``create_react_agent`` has two memory parameters.
10
+ ``checkpointer=`` holds thread state - the message history of one conversation -
11
+ and this is *not* that; keep using ``MemorySaver`` or a Postgres checkpointer
12
+ there. ``store=`` is long-term memory shared across threads, and that is what
13
+ this fills.
14
+
15
+ **What the store interface can and cannot enforce.** ``BaseStore.put`` is a
16
+ plain key-value write with nowhere to say "here is what I read first". So this
17
+ adapter infers it: the store remembers every item it has served to the agent,
18
+ and cites those as the parents of whatever the agent writes next. An agent that
19
+ writes a conclusion after reading a colleague's note is therefore recorded as
20
+ deriving from that note, automatically, with no cooperation required.
21
+
22
+ That gives you provenance capture for free. It does not give you enforcement:
23
+ a store cannot stop a model from ignoring what it read. For that, use the tools
24
+ in ``langchain_tools`` (which make citation a required argument) or the node
25
+ wrapper in ``langgraph_memory``. The three are complementary - this one is the
26
+ lowest-effort way in.
27
+
28
+ Namespaces
29
+ ----------
30
+
31
+ ``("observations",)`` evidence from outside; writing here records a source
32
+ ``("beliefs", <claim_key>)`` conclusions about one proposition
33
+ ``("claims",)`` read-only view of claims, with the honest counts
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ import uuid
40
+ from collections.abc import Iterable
41
+ from datetime import UTC, datetime
42
+ from typing import TYPE_CHECKING, Any
43
+
44
+ from rootmemory.client import RootMemory
45
+ from rootmemory.db.session import get_session_factory
46
+ from rootmemory.errors import ImmutableRecordError, NodeNotFoundError, ProvenanceRequiredError
47
+ from rootmemory.models.node_ref import NodeRef
48
+ from rootmemory.services.promotion_service import PromotionPolicy
49
+
50
+ if TYPE_CHECKING: # pragma: no cover - typing only
51
+ from langgraph.store.base import Item, Op, Result, SearchItem
52
+
53
+ OBSERVATIONS = "observations"
54
+ BELIEFS = "beliefs"
55
+ CLAIMS = "claims"
56
+
57
+
58
+ def _require_langgraph() -> Any:
59
+ try:
60
+ from langgraph.store import base
61
+ except ModuleNotFoundError as exc: # pragma: no cover - environment dependent
62
+ raise ModuleNotFoundError(
63
+ "The LangGraph store adapter needs langgraph. Install it with: "
64
+ 'pip install "rootmemory[langgraph]"'
65
+ ) from exc
66
+ return base
67
+
68
+
69
+ def _base_store_class() -> Any:
70
+ return _require_langgraph().BaseStore
71
+
72
+
73
+ class RootMemoryStore(_base_store_class()): # type: ignore[misc]
74
+ """LangGraph long-term memory whose writes carry provenance.
75
+
76
+ ``BaseStore`` only requires ``batch`` and ``abatch``; every convenience
77
+ method (``get``, ``put``, ``search``, ``aput`` ...) is built on those, so
78
+ implementing the pair makes the whole interface work.
79
+
80
+ One instance per agent: read tracking is per-instance, which is what lets a
81
+ write cite the reads that preceded it.
82
+ """
83
+
84
+ #: How many recent reads may be cited by a single write.
85
+ max_cited_reads = 16
86
+
87
+ def __init__(
88
+ self,
89
+ agent_id: uuid.UUID,
90
+ session_factory: Any = None,
91
+ policy: PromotionPolicy | None = None,
92
+ auto_cite_reads: bool = True,
93
+ ) -> None:
94
+ self.agent_id = agent_id
95
+ self._session_factory = session_factory or get_session_factory()
96
+ self._policy = policy
97
+ self._auto_cite_reads = auto_cite_reads
98
+ #: ids this agent has been shown, most recent last
99
+ self._reads: list[uuid.UUID] = []
100
+
101
+ # ------------------------------------------------------------------
102
+ # BaseStore contract
103
+ # ------------------------------------------------------------------
104
+
105
+ def batch(self, ops: Iterable[Op]) -> list[Result]:
106
+ base = _require_langgraph()
107
+ session = self._session_factory()
108
+ try:
109
+ memory = RootMemory(session, self._policy)
110
+ results = [self._apply(memory, op, base) for op in ops]
111
+ session.commit()
112
+ return results
113
+ except Exception:
114
+ session.rollback()
115
+ raise
116
+ finally:
117
+ session.close()
118
+
119
+ async def abatch(self, ops: Iterable[Op]) -> list[Result]:
120
+ materialized = list(ops)
121
+ return await asyncio.to_thread(self.batch, materialized)
122
+
123
+ # ------------------------------------------------------------------
124
+
125
+ def _apply(self, memory: RootMemory, op: Op, base: Any) -> Result:
126
+ if isinstance(op, base.GetOp):
127
+ return self._get(memory, op, base)
128
+ if isinstance(op, base.PutOp):
129
+ self._put(memory, op)
130
+ return None
131
+ if isinstance(op, base.SearchOp):
132
+ return self._search(memory, op, base)
133
+ if isinstance(op, base.ListNamespacesOp):
134
+ return self._namespaces(memory)
135
+ raise NotImplementedError(f"unsupported store operation: {type(op).__name__}")
136
+
137
+ # ------------------------------------------------------------------
138
+ # reads
139
+ # ------------------------------------------------------------------
140
+
141
+ def _get(self, memory: RootMemory, op: Any, base: Any) -> Item | None:
142
+ node_id = _as_uuid(op.key)
143
+ if node_id is None:
144
+ return None
145
+ root = op.namespace[0] if op.namespace else ""
146
+
147
+ if root == OBSERVATIONS:
148
+ observation = memory.observations.get(node_id)
149
+ if observation is None:
150
+ return None
151
+ self._note_read(observation.id)
152
+ return _item(op.namespace, op.key, self._observation_value(observation), base)
153
+
154
+ if root == BELIEFS:
155
+ belief = memory.find_belief(node_id)
156
+ if belief is None:
157
+ return None
158
+ self._note_read(belief.id)
159
+ return _item(op.namespace, op.key, self._belief_value(memory, belief), base)
160
+
161
+ if root == CLAIMS:
162
+ claim = memory.claims.get(node_id)
163
+ if claim is None:
164
+ return None
165
+ return _item(op.namespace, op.key, self._claim_value(memory, claim.id), base)
166
+
167
+ return None
168
+
169
+ def _search(self, memory: RootMemory, op: Any, base: Any) -> list[SearchItem]:
170
+ prefix = op.namespace_prefix
171
+ root = prefix[0] if prefix else ""
172
+ query = (op.query or "").lower()
173
+ items: list[SearchItem] = []
174
+
175
+ if root in ("", OBSERVATIONS):
176
+ for observation in memory.observations.list():
177
+ if query and query not in observation.content.lower():
178
+ continue
179
+ self._note_read(observation.id)
180
+ items.append(
181
+ _search_item(
182
+ (OBSERVATIONS,),
183
+ str(observation.id),
184
+ self._observation_value(observation),
185
+ base,
186
+ )
187
+ )
188
+
189
+ if root in ("", BELIEFS):
190
+ claim_key = prefix[1] if len(prefix) > 1 else None
191
+ for belief in memory.list_beliefs():
192
+ if claim_key and belief.normalized_claim_key != claim_key:
193
+ continue
194
+ if query and query not in belief.claim_text.lower():
195
+ continue
196
+ self._note_read(belief.id)
197
+ items.append(
198
+ _search_item(
199
+ (BELIEFS, belief.normalized_claim_key),
200
+ str(belief.id),
201
+ self._belief_value(memory, belief),
202
+ base,
203
+ )
204
+ )
205
+
206
+ if root in ("", CLAIMS):
207
+ for claim in memory.claims.list():
208
+ if query and query not in claim.canonical_text.lower():
209
+ continue
210
+ items.append(
211
+ _search_item(
212
+ (CLAIMS,), str(claim.id), self._claim_value(memory, claim.id), base
213
+ )
214
+ )
215
+
216
+ return items[op.offset : op.offset + op.limit]
217
+
218
+ def _namespaces(self, memory: RootMemory) -> list[tuple[str, ...]]:
219
+ keys = {b.normalized_claim_key for b in memory.list_beliefs()}
220
+ return [(OBSERVATIONS,), (CLAIMS,), *[(BELIEFS, key) for key in sorted(keys)]]
221
+
222
+ # ------------------------------------------------------------------
223
+ # writes
224
+ # ------------------------------------------------------------------
225
+
226
+ def _put(self, memory: RootMemory, op: Any) -> None:
227
+ if op.value is None:
228
+ # Memory is append-only: nothing is ever deleted (spec section 46).
229
+ raise ImmutableRecordError(
230
+ "RootMemory does not delete. Retract the underlying source instead, "
231
+ "which downgrades what was built on it and leaves the history intact."
232
+ )
233
+
234
+ namespace = op.namespace
235
+ root = namespace[0] if namespace else ""
236
+ value = dict(op.value)
237
+
238
+ if root == OBSERVATIONS:
239
+ observation = memory.create_observation(
240
+ source_type=str(value.get("source_type", "agent_input")),
241
+ content=str(value.get("content", "")),
242
+ source_actor=value.get("source_actor"),
243
+ source_uri=value.get("source_uri"),
244
+ created_by_agent_id=self.agent_id,
245
+ reliability_score=float(value.get("reliability_score", 1.0)),
246
+ source_family_id=value.get("source_family_id"),
247
+ )
248
+ self._note_read(observation.id)
249
+ return None
250
+
251
+ if root == BELIEFS:
252
+ claim_key = namespace[1] if len(namespace) > 1 else str(value.get("claim_key", ""))
253
+ if not claim_key:
254
+ raise ProvenanceRequiredError(
255
+ "a belief needs a claim key: write to ('beliefs', '<claim_key>')"
256
+ )
257
+ parents = self._resolve_parents(memory, value)
258
+ if not parents:
259
+ raise ProvenanceRequiredError(
260
+ "nothing to cite: this agent has not read any evidence, and no "
261
+ "'derived_from' was supplied. Read from the store first, or pass "
262
+ "derived_from explicitly."
263
+ )
264
+ belief = memory.create_belief(
265
+ agent_id=self.agent_id,
266
+ claim_text=str(value.get("content", value.get("text", ""))),
267
+ claim_key=claim_key,
268
+ confidence=float(value.get("confidence", 0.5)),
269
+ derived_from=parents,
270
+ llm_generated=bool(value.get("llm_generated", True)),
271
+ meta={"store_key": op.key},
272
+ )
273
+ claim_text = str(value.get("claim_text", claim_key))
274
+ claim = memory.get_or_create_claim(claim_key, claim_text)
275
+ if str(value.get("stance", "support")) == "contradict":
276
+ memory.contradict_claim(claim.id, belief.id)
277
+ else:
278
+ memory.support_claim(claim.id, belief.id)
279
+ return None
280
+
281
+ raise NotImplementedError(
282
+ f"writes to namespace {namespace!r} are not supported; use "
283
+ f"('{OBSERVATIONS}',) for evidence or ('{BELIEFS}', '<claim_key>') for a conclusion"
284
+ )
285
+
286
+ def _resolve_parents(self, memory: RootMemory, value: dict) -> list[Any]:
287
+ """What this write should cite.
288
+
289
+ An explicit ``derived_from`` always wins. Otherwise the agent cites what
290
+ it was actually shown, which is the honest reading of "where did this
291
+ come from".
292
+ """
293
+ explicit = value.get("derived_from")
294
+ if explicit:
295
+ ids = [_as_uuid(str(x)) for x in explicit]
296
+ missing = [x for x, parsed in zip(explicit, ids, strict=True) if parsed is None]
297
+ if missing:
298
+ raise NodeNotFoundError(f"derived_from contains unusable ids: {missing}")
299
+ candidates = [i for i in ids if i is not None]
300
+ elif self._auto_cite_reads:
301
+ candidates = list(self._reads[-self.max_cited_reads :])
302
+ else:
303
+ candidates = []
304
+
305
+ parents: list[Any] = []
306
+ for node_id in candidates:
307
+ observation = memory.observations.get(node_id)
308
+ if observation is not None:
309
+ parents.append(observation)
310
+ continue
311
+ belief = memory.find_belief(node_id)
312
+ if belief is not None:
313
+ parents.append(belief)
314
+ elif explicit:
315
+ raise NodeNotFoundError(f"no observation or belief with id {node_id}")
316
+ return parents
317
+
318
+ # ------------------------------------------------------------------
319
+ # values handed back to the agent
320
+ # ------------------------------------------------------------------
321
+
322
+ def _observation_value(self, observation: Any) -> dict[str, Any]:
323
+ return {
324
+ "kind": "observation",
325
+ "content": observation.content,
326
+ "source_type": observation.source_type,
327
+ "source_actor": observation.source_actor,
328
+ "validity_status": observation.validity_status,
329
+ "reliability_score": observation.reliability_score,
330
+ }
331
+
332
+ def _belief_value(self, memory: RootMemory, belief: Any) -> dict[str, Any]:
333
+ roots = memory.provenance.find_evidence_root_observations(NodeRef.belief(belief.id))
334
+ return {
335
+ "kind": "belief",
336
+ "content": belief.claim_text,
337
+ "claim_key": belief.normalized_claim_key,
338
+ "confidence": belief.confidence,
339
+ "status": belief.epistemic_status,
340
+ "held_by_agent_id": str(belief.agent_id),
341
+ # The agent is always told how much real evidence is underneath.
342
+ "independent_source_count": len({o.independence_key for o in roots}),
343
+ "evidence": [
344
+ {"source_type": o.source_type, "content": o.content, "status": o.validity_status}
345
+ for o in roots
346
+ ],
347
+ }
348
+
349
+ def _claim_value(self, memory: RootMemory, claim_id: uuid.UUID) -> dict[str, Any]:
350
+ context = memory.get_claim_context(claim_id)
351
+ payload: dict[str, Any] = dict(context.as_dict())
352
+ support: dict[str, Any] = payload["support"]
353
+ payload["warning"] = (
354
+ "agreement between agents is not evidence; judge this on independent_sources"
355
+ )
356
+ if context.support.shares_single_source:
357
+ payload["false_corroboration"] = (
358
+ f"{support['belief_count']} beliefs from {support['agreeing_agents']} agents "
359
+ "all descend from one source"
360
+ )
361
+ return payload
362
+
363
+ # ------------------------------------------------------------------
364
+
365
+ def _note_read(self, node_id: uuid.UUID) -> None:
366
+ if node_id in self._reads:
367
+ self._reads.remove(node_id)
368
+ self._reads.append(node_id)
369
+
370
+ def forget_reads(self) -> None:
371
+ """Clear the read history.
372
+
373
+ Call this between independent tasks so a new conclusion does not cite
374
+ evidence the agent looked at while doing something unrelated.
375
+ """
376
+ self._reads.clear()
377
+
378
+ @property
379
+ def cited_reads(self) -> list[uuid.UUID]:
380
+ """What the next write would cite, oldest first."""
381
+ return list(self._reads[-self.max_cited_reads :])
382
+
383
+
384
+ def _item(namespace: tuple[str, ...], key: str, value: dict, base: Any) -> Item:
385
+ now = datetime.now(UTC)
386
+ return base.Item(
387
+ value=value, key=key, namespace=namespace, created_at=now, updated_at=now
388
+ )
389
+
390
+
391
+ def _search_item(namespace: tuple[str, ...], key: str, value: dict, base: Any) -> SearchItem:
392
+ now = datetime.now(UTC)
393
+ return base.SearchItem(
394
+ namespace=namespace, key=key, value=value, created_at=now, updated_at=now
395
+ )
396
+
397
+
398
+ def _as_uuid(value: str) -> uuid.UUID | None:
399
+ try:
400
+ return uuid.UUID(value)
401
+ except (ValueError, AttributeError, TypeError):
402
+ return None
@@ -0,0 +1,30 @@
1
+ """Structured logging setup (spec section 50)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ import structlog
9
+
10
+
11
+ def configure_logging(level: str = "INFO") -> None:
12
+ """Configure structlog + stdlib logging once at process start."""
13
+ logging.basicConfig(format="%(message)s", level=getattr(logging, level.upper(), logging.INFO))
14
+ structlog.configure(
15
+ processors=[
16
+ structlog.contextvars.merge_contextvars,
17
+ structlog.processors.add_log_level,
18
+ structlog.processors.TimeStamper(fmt="iso"),
19
+ structlog.processors.JSONRenderer(),
20
+ ],
21
+ wrapper_class=structlog.make_filtering_bound_logger(
22
+ getattr(logging, level.upper(), logging.INFO)
23
+ ),
24
+ cache_logger_on_first_use=True,
25
+ )
26
+
27
+
28
+ def get_logger(name: str, **initial: Any) -> Any:
29
+ """Return a bound structlog logger."""
30
+ return structlog.get_logger(name).bind(**initial)
rootmemory/main.py ADDED
@@ -0,0 +1,133 @@
1
+ """FastAPI application entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import uuid
7
+ from collections.abc import AsyncIterator, Awaitable, Callable
8
+ from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
+
11
+ from fastapi import Depends, FastAPI, Request, Response
12
+ from fastapi.responses import FileResponse, RedirectResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+
15
+ from rootmemory.api import ROUTERS
16
+ from rootmemory.api.errors import register_error_handlers
17
+ from rootmemory.api.security import require_api_key
18
+ from rootmemory.config import get_settings
19
+ from rootmemory.db.session import init_db
20
+ from rootmemory.logging_config import configure_logging, get_logger
21
+ from rootmemory.services.promotion_service import PromotionPolicy
22
+
23
+ DESCRIPTION = """
24
+ A provenance-aware memory layer for multi-agent AI systems.
25
+
26
+ It prevents agents from mistaking repeated copies of the same inference for
27
+ independent evidence: every belief is traced back to the observations it rests
28
+ on, and only claims backed by enough *independent* sources enter shared memory.
29
+ """
30
+
31
+ logger = get_logger("rootmemory.api")
32
+
33
+ STATIC_DIR = Path(__file__).parent / "static"
34
+
35
+
36
+ @asynccontextmanager
37
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
38
+ settings = get_settings()
39
+ configure_logging(settings.log_level)
40
+ # Convenient for local/dev and SQLite; PostgreSQL deployments run Alembic.
41
+ init_db()
42
+ logger.info(
43
+ "startup",
44
+ database=settings.database_url.split("@")[-1],
45
+ policy=PromotionPolicy.from_settings(settings).as_dict(),
46
+ auth_enabled=settings.auth_enabled,
47
+ claim_normalization=settings.claim_normalization,
48
+ )
49
+ if not settings.auth_enabled:
50
+ logger.warning(
51
+ "auth_disabled",
52
+ detail=(
53
+ "No API_KEYS configured: every endpoint is open, including "
54
+ "observation invalidation. Set API_KEYS before exposing this."
55
+ ),
56
+ )
57
+ yield
58
+
59
+
60
+ app = FastAPI(
61
+ title="RootMemory",
62
+ version="0.1.0",
63
+ description=DESCRIPTION,
64
+ lifespan=lifespan,
65
+ )
66
+ register_error_handlers(app)
67
+ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
68
+
69
+ for router in ROUTERS:
70
+ app.include_router(router, dependencies=[Depends(require_api_key)])
71
+
72
+
73
+ @app.middleware("http")
74
+ async def log_requests(
75
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
76
+ ) -> Response:
77
+ """Structured request logging (spec section 50)."""
78
+ request_id = str(uuid.uuid4())
79
+ started = time.perf_counter()
80
+ response = await call_next(request)
81
+ response.headers["X-Request-ID"] = request_id
82
+ logger.info(
83
+ "request",
84
+ request_id=request_id,
85
+ method=request.method,
86
+ path=request.url.path,
87
+ status_code=response.status_code,
88
+ duration_ms=round((time.perf_counter() - started) * 1000, 2),
89
+ )
90
+ return response
91
+
92
+
93
+ @app.get("/", include_in_schema=False)
94
+ def index() -> RedirectResponse:
95
+ return RedirectResponse("/ui")
96
+
97
+
98
+ @app.get("/ui", tags=["meta"], include_in_schema=False)
99
+ def portal() -> FileResponse:
100
+ """The visualization portal.
101
+
102
+ Served from the API itself so the page talks to the same origin and sees
103
+ whatever is in the configured database.
104
+ """
105
+ return FileResponse(STATIC_DIR / "index.html")
106
+
107
+
108
+ @app.get("/health", tags=["meta"])
109
+ def health() -> dict[str, str]:
110
+ """Liveness. Deliberately does no database work and needs no API key, so a
111
+ load balancer can call it cheaply."""
112
+ return {"status": "ok"}
113
+
114
+
115
+ @app.get("/ready", tags=["meta"])
116
+ def ready() -> dict[str, object]:
117
+ """Readiness: verifies the database is actually reachable."""
118
+ from sqlalchemy import text as sql_text
119
+
120
+ from rootmemory.db.session import get_engine
121
+
122
+ try:
123
+ with get_engine().connect() as connection:
124
+ connection.execute(sql_text("SELECT 1"))
125
+ except Exception as exc: # noqa: BLE001 - the probe reports, never raises
126
+ return {"status": "unavailable", "database": "unreachable", "detail": str(exc)}
127
+ return {"status": "ok", "database": "reachable"}
128
+
129
+
130
+ @app.get("/policy", tags=["meta"])
131
+ def policy() -> dict[str, object]:
132
+ """The promotion policy currently in force."""
133
+ return PromotionPolicy.from_settings().as_dict()
@@ -0,0 +1,22 @@
1
+ """ORM models. Importing this package registers every mapper."""
2
+
3
+ from rootmemory.models.agent import Agent
4
+ from rootmemory.models.audit import PromotionAudit, RepairReport
5
+ from rootmemory.models.belief import Belief
6
+ from rootmemory.models.claim import Claim, ClaimBeliefLink
7
+ from rootmemory.models.decision import Decision, DecisionDependency
8
+ from rootmemory.models.edge import ProvenanceEdge
9
+ from rootmemory.models.observation import Observation
10
+
11
+ __all__ = [
12
+ "Agent",
13
+ "Belief",
14
+ "Claim",
15
+ "ClaimBeliefLink",
16
+ "Decision",
17
+ "DecisionDependency",
18
+ "Observation",
19
+ "PromotionAudit",
20
+ "ProvenanceEdge",
21
+ "RepairReport",
22
+ ]
@@ -0,0 +1,30 @@
1
+ """Agent registry (spec section 15)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ from sqlalchemy import Float, String
9
+ from sqlalchemy.orm import Mapped, mapped_column
10
+
11
+ from rootmemory.db.base import Base, json_column, pk_column, timestamp_column
12
+
13
+
14
+ class Agent(Base):
15
+ """An actor that can create observations, beliefs and decisions.
16
+
17
+ ``trust_score`` is deliberately *not* used by the promotion gate. A trusted
18
+ agent can still repeat another agent's bad conclusion, so trust must never
19
+ stand in for provenance (spec section 15).
20
+ """
21
+
22
+ __tablename__ = "agents"
23
+
24
+ id: Mapped[uuid.UUID] = pk_column()
25
+ name: Mapped[str] = mapped_column(String(200), unique=True, index=True)
26
+ role: Mapped[str] = mapped_column(String(200), default="")
27
+ description: Mapped[str] = mapped_column(String(2000), default="")
28
+ trust_score: Mapped[float] = mapped_column(Float, default=0.5)
29
+ created_at: Mapped[datetime] = timestamp_column()
30
+ meta: Mapped[dict] = json_column()
@@ -0,0 +1,51 @@
1
+ """Audit records: every promotion decision and every repair is written down."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from datetime import datetime
7
+
8
+ from sqlalchemy import JSON, Float, ForeignKey, Integer, String, Uuid
9
+ from sqlalchemy.orm import Mapped, mapped_column
10
+
11
+ from rootmemory.db.base import Base, json_column, pk_column, timestamp_column
12
+
13
+
14
+ class PromotionAudit(Base):
15
+ """Immutable record of one promotion evaluation (spec section 30).
16
+
17
+ Both agreement counts are stored, because the difference between them is
18
+ the whole point of the system (spec section 64).
19
+ """
20
+
21
+ __tablename__ = "promotion_audits"
22
+
23
+ id: Mapped[uuid.UUID] = pk_column()
24
+ claim_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("claims.id"), index=True)
25
+ evaluated_at: Mapped[datetime] = timestamp_column()
26
+ supporting_belief_count: Mapped[int] = mapped_column(Integer, default=0)
27
+ agreeing_agent_count: Mapped[int] = mapped_column(Integer, default=0)
28
+ independent_support_count: Mapped[int] = mapped_column(Integer, default=0)
29
+ contradiction_count: Mapped[int] = mapped_column(Integer, default=0)
30
+ independent_contradiction_count: Mapped[int] = mapped_column(Integer, default=0)
31
+ aggregate_confidence: Mapped[float] = mapped_column(Float, default=0.0)
32
+ result: Mapped[str] = mapped_column(String(20), index=True)
33
+ reasons: Mapped[list] = mapped_column(JSON, default=list)
34
+ policy_version: Mapped[str] = mapped_column(String(50), default="v1")
35
+ policy_snapshot: Mapped[dict] = json_column()
36
+
37
+
38
+ class RepairReport(Base):
39
+ """What a source invalidation touched (spec section 26)."""
40
+
41
+ __tablename__ = "repair_reports"
42
+
43
+ id: Mapped[uuid.UUID] = pk_column()
44
+ trigger_node_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), index=True)
45
+ trigger_node_type: Mapped[str] = mapped_column(String(20), default="observation")
46
+ trigger_reason: Mapped[str] = mapped_column(String(2000), default="")
47
+ affected_beliefs: Mapped[list] = mapped_column(JSON, default=list)
48
+ affected_claims: Mapped[list] = mapped_column(JSON, default=list)
49
+ affected_decisions: Mapped[list] = mapped_column(JSON, default=list)
50
+ details: Mapped[dict] = json_column()
51
+ created_at: Mapped[datetime] = timestamp_column()