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
rootmemory/__init__.py ADDED
@@ -0,0 +1,84 @@
1
+ """RootMemory - provenance-aware memory for multi-agent AI systems.
2
+
3
+ It tells corroboration apart from repetition. Every belief records what it was
4
+ derived from, so three agents echoing one document are counted as one piece of
5
+ evidence rather than three.
6
+
7
+ Quick start::
8
+
9
+ from rootmemory import RootMemory, open_memory
10
+
11
+ with open_memory("sqlite+pysqlite:///memory.db") as session:
12
+ memory = RootMemory(session)
13
+ agent = memory.register_agent("ResearchAgent")
14
+
15
+ article = memory.create_observation("news", "Revenue may have slipped.")
16
+ belief = memory.create_belief(
17
+ agent.id, "The supplier is struggling.", "supplier_risk", 0.6,
18
+ derived_from=[article], # required: cite what you read
19
+ )
20
+
21
+ claim = memory.get_or_create_claim("supplier_risk", "Supplier is struggling.")
22
+ memory.support_claim(claim.id, belief.id)
23
+
24
+ verdict = memory.evaluate_claim(claim.id)
25
+ print(verdict.independent_support_count) # 1, not 3
26
+ print(verdict.explanation)
27
+
28
+ The async client and the framework adapters live in ``rootmemory.integrations``
29
+ and are imported on demand, so nothing here pulls in FastAPI or LangGraph.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from rootmemory.client import RootMemory
35
+ from rootmemory.db.session import create_isolated_session, open_memory, session_scope
36
+ from rootmemory.errors import (
37
+ ImmutableRecordError,
38
+ NodeNotFoundError,
39
+ PromotionForbiddenError,
40
+ ProvenanceCycleError,
41
+ ProvenanceRequiredError,
42
+ RootMemoryError,
43
+ )
44
+ from rootmemory.models.enums import (
45
+ ClaimStatus,
46
+ DecisionStatus,
47
+ EdgeType,
48
+ EpistemicStatus,
49
+ NodeType,
50
+ ValidityStatus,
51
+ Visibility,
52
+ )
53
+ from rootmemory.models.node_ref import NodeRef
54
+ from rootmemory.services.promotion_service import PromotionEvaluation, PromotionPolicy
55
+
56
+ __version__ = "0.1.0"
57
+
58
+ __all__ = [
59
+ # the client you will actually use
60
+ "RootMemory",
61
+ "open_memory",
62
+ "session_scope",
63
+ "create_isolated_session",
64
+ # the promotion policy and its verdict
65
+ "PromotionPolicy",
66
+ "PromotionEvaluation",
67
+ # errors worth catching
68
+ "RootMemoryError",
69
+ "NodeNotFoundError",
70
+ "ProvenanceRequiredError",
71
+ "ProvenanceCycleError",
72
+ "ImmutableRecordError",
73
+ "PromotionForbiddenError",
74
+ # vocabulary
75
+ "NodeRef",
76
+ "NodeType",
77
+ "EdgeType",
78
+ "ValidityStatus",
79
+ "EpistemicStatus",
80
+ "Visibility",
81
+ "ClaimStatus",
82
+ "DecisionStatus",
83
+ "__version__",
84
+ ]
@@ -0,0 +1,25 @@
1
+ """API layer: routing and serialization only, no epistemic rules."""
2
+
3
+ from rootmemory.api import (
4
+ agents,
5
+ beliefs,
6
+ claims,
7
+ graph,
8
+ invalidation,
9
+ observations,
10
+ promotion,
11
+ provenance,
12
+ )
13
+
14
+ ROUTERS = [
15
+ agents.router,
16
+ observations.router,
17
+ beliefs.router,
18
+ claims.router,
19
+ promotion.router,
20
+ provenance.router,
21
+ invalidation.router,
22
+ graph.router,
23
+ ]
24
+
25
+ __all__ = ["ROUTERS"]
@@ -0,0 +1,38 @@
1
+ """Agent endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from fastapi import APIRouter, status
8
+
9
+ from rootmemory.api.deps import MemoryDep
10
+ from rootmemory.errors import NodeNotFoundError
11
+ from rootmemory.schemas.agent import AgentCreate, AgentRead
12
+
13
+ router = APIRouter(prefix="/agents", tags=["agents"])
14
+
15
+
16
+ @router.post("", response_model=AgentRead, status_code=status.HTTP_201_CREATED)
17
+ def create_agent(payload: AgentCreate, memory: MemoryDep) -> AgentRead:
18
+ """Register an agent. Names are unique, so this is idempotent."""
19
+ agent = memory.register_agent(
20
+ name=payload.name,
21
+ role=payload.role,
22
+ description=payload.description,
23
+ trust_score=payload.trust_score,
24
+ )
25
+ return AgentRead.model_validate(agent)
26
+
27
+
28
+ @router.get("", response_model=list[AgentRead])
29
+ def list_agents(memory: MemoryDep) -> list[AgentRead]:
30
+ return [AgentRead.model_validate(a) for a in memory.agents.list()]
31
+
32
+
33
+ @router.get("/{agent_id}", response_model=AgentRead)
34
+ def get_agent(agent_id: uuid.UUID, memory: MemoryDep) -> AgentRead:
35
+ agent = memory.agents.get(agent_id)
36
+ if agent is None:
37
+ raise NodeNotFoundError(f"agent {agent_id} does not exist")
38
+ return AgentRead.model_validate(agent)
@@ -0,0 +1,66 @@
1
+ """Belief endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from fastapi import APIRouter, status
8
+
9
+ from rootmemory.api.deps import MemoryDep
10
+ from rootmemory.models.node_ref import NodeRef
11
+ from rootmemory.schemas.belief import BeliefCreate, BeliefRead, BeliefWithProvenance
12
+ from rootmemory.schemas.common import NodeReference
13
+ from rootmemory.services.belief_service import ParentSpec
14
+
15
+ router = APIRouter(prefix="/beliefs", tags=["beliefs"])
16
+
17
+
18
+ @router.post("", response_model=BeliefRead, status_code=status.HTTP_201_CREATED)
19
+ def create_belief(payload: BeliefCreate, memory: MemoryDep) -> BeliefRead:
20
+ """Create a belief.
21
+
22
+ Returns 422 when no parent is given (a belief must be traceable) and 409
23
+ when the requested provenance would create a cycle.
24
+ """
25
+ parents = [ParentSpec(id=node.id, type=node.type) for node in payload.parent_nodes]
26
+ belief = memory.beliefs.create_belief(
27
+ agent_id=payload.agent_id,
28
+ claim_text=payload.claim_text,
29
+ normalized_claim_key=payload.normalized_claim_key,
30
+ confidence=payload.confidence,
31
+ parents=parents,
32
+ llm_generated=payload.llm_generated,
33
+ meta=payload.metadata,
34
+ )
35
+ return BeliefRead.model_validate(belief)
36
+
37
+
38
+ @router.get("/{belief_id}", response_model=BeliefWithProvenance)
39
+ def get_belief(belief_id: uuid.UUID, memory: MemoryDep) -> BeliefWithProvenance:
40
+ belief = memory.get_belief(belief_id)
41
+ roots = memory.provenance.find_evidence_root_observations(NodeRef.belief(belief_id))
42
+ return BeliefWithProvenance(
43
+ **BeliefRead.model_validate(belief).model_dump(),
44
+ evidence_roots=[o.id for o in roots],
45
+ independent_source_count=len({o.independence_key for o in roots}),
46
+ )
47
+
48
+
49
+ @router.post("/{belief_id}/parents", status_code=status.HTTP_204_NO_CONTENT)
50
+ def add_parent(belief_id: uuid.UUID, parent: NodeReference, memory: MemoryDep) -> None:
51
+ """Attach another causal parent to an existing belief.
52
+
53
+ Returns 409 if the edge would make the belief its own ancestor
54
+ (spec section 28).
55
+ """
56
+ memory.beliefs.add_parent(belief_id, ParentSpec(id=parent.id, type=parent.type))
57
+
58
+
59
+ @router.post("/{belief_id}/propose", response_model=BeliefRead)
60
+ def propose_shared_belief(belief_id: uuid.UUID, memory: MemoryDep) -> BeliefRead:
61
+ """Move a private belief to candidate_shared.
62
+
63
+ This is as far as an agent can push its own belief; entering shared memory
64
+ requires the promotion gate.
65
+ """
66
+ return BeliefRead.model_validate(memory.propose_shared_belief(belief_id))
@@ -0,0 +1,87 @@
1
+ """Claim endpoints: creation, support, contradiction and honest reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from fastapi import APIRouter, status
8
+
9
+ from rootmemory.api.deps import MemoryDep
10
+ from rootmemory.errors import NodeNotFoundError
11
+ from rootmemory.models.enums import ClaimStatus
12
+ from rootmemory.schemas.claim import (
13
+ ClaimContextResponse,
14
+ ClaimCreate,
15
+ ClaimLinkRequest,
16
+ ClaimRead,
17
+ EvidenceSide,
18
+ )
19
+
20
+ router = APIRouter(prefix="/claims", tags=["claims"])
21
+
22
+
23
+ @router.post("", response_model=ClaimRead, status_code=status.HTTP_201_CREATED)
24
+ def create_claim(payload: ClaimCreate, memory: MemoryDep) -> ClaimRead:
25
+ """Create (or fetch) a claim.
26
+
27
+ A new claim always starts unconfirmed. Shared/confirmed state can only be
28
+ reached through the promotion gate (spec section 46).
29
+ """
30
+ claim = memory.get_or_create_claim(payload.claim_key, payload.canonical_text)
31
+ return ClaimRead.model_validate(claim)
32
+
33
+
34
+ @router.get("", response_model=list[ClaimRead])
35
+ def list_claims(memory: MemoryDep) -> list[ClaimRead]:
36
+ return [ClaimRead.model_validate(c) for c in memory.claims.list()]
37
+
38
+
39
+ @router.get("/{claim_id}", response_model=ClaimRead)
40
+ def get_claim(claim_id: uuid.UUID, memory: MemoryDep) -> ClaimRead:
41
+ claim = memory.claims.get(claim_id)
42
+ if claim is None:
43
+ raise NodeNotFoundError(f"claim {claim_id} does not exist")
44
+ return ClaimRead.model_validate(claim)
45
+
46
+
47
+ @router.post("/{claim_id}/support", status_code=status.HTTP_204_NO_CONTENT)
48
+ def register_support(claim_id: uuid.UUID, payload: ClaimLinkRequest, memory: MemoryDep) -> None:
49
+ memory.support_claim(claim_id, payload.belief_id)
50
+
51
+
52
+ @router.post("/{claim_id}/contradict", status_code=status.HTTP_204_NO_CONTENT)
53
+ def register_contradiction(
54
+ claim_id: uuid.UUID, payload: ClaimLinkRequest, memory: MemoryDep
55
+ ) -> None:
56
+ """Record a belief that argues against the claim.
57
+
58
+ Nothing is overwritten or deleted: both sides stay in memory and both are
59
+ reported back (spec section 23).
60
+ """
61
+ memory.contradict_claim(claim_id, payload.belief_id)
62
+
63
+
64
+ @router.get("/{claim_id}/context", response_model=ClaimContextResponse)
65
+ def get_claim_context(claim_id: uuid.UUID, memory: MemoryDep) -> ClaimContextResponse:
66
+ """Read a claim the way an agent should: both sides, independence counted."""
67
+ context = memory.get_claim_context(claim_id)
68
+ return ClaimContextResponse(
69
+ claim_id=context.claim.id,
70
+ claim_key=context.claim.claim_key,
71
+ claim=context.claim.canonical_text,
72
+ status=ClaimStatus(context.claim.status),
73
+ support=EvidenceSide(
74
+ belief_count=context.support.belief_count,
75
+ agreeing_agents=context.support.agreeing_agent_count,
76
+ independent_sources=context.support.independent_source_count,
77
+ confidence=context.support.aggregate_confidence,
78
+ root_observation_ids=context.support.root_observation_ids,
79
+ ),
80
+ contradictions=EvidenceSide(
81
+ belief_count=context.contradiction.belief_count,
82
+ agreeing_agents=context.contradiction.agreeing_agent_count,
83
+ independent_sources=context.contradiction.independent_source_count,
84
+ confidence=context.contradiction.aggregate_confidence,
85
+ root_observation_ids=context.contradiction.root_observation_ids,
86
+ ),
87
+ )
rootmemory/api/deps.py ADDED
@@ -0,0 +1,21 @@
1
+ """FastAPI dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ from fastapi import Depends
8
+ from sqlalchemy.orm import Session
9
+
10
+ from rootmemory.client import RootMemory
11
+ from rootmemory.db.session import get_db
12
+
13
+ SessionDep = Annotated[Session, Depends(get_db)]
14
+
15
+
16
+ def get_memory_client(session: SessionDep) -> RootMemory:
17
+ """One memory client per request."""
18
+ return RootMemory(session)
19
+
20
+
21
+ MemoryDep = Annotated[RootMemory, Depends(get_memory_client)]
@@ -0,0 +1,36 @@
1
+ """Mapping of domain errors onto HTTP status codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.responses import JSONResponse
7
+
8
+ from rootmemory.errors import (
9
+ ImmutableRecordError,
10
+ NodeNotFoundError,
11
+ PromotionForbiddenError,
12
+ ProvenanceCycleError,
13
+ ProvenanceRequiredError,
14
+ RootMemoryError,
15
+ )
16
+
17
+ STATUS_BY_ERROR: list[tuple[type[RootMemoryError], int]] = [
18
+ (NodeNotFoundError, 404),
19
+ (ProvenanceCycleError, 409),
20
+ (ProvenanceRequiredError, 422),
21
+ (PromotionForbiddenError, 403),
22
+ (ImmutableRecordError, 409),
23
+ (RootMemoryError, 400),
24
+ ]
25
+
26
+
27
+ def register_error_handlers(app: FastAPI) -> None:
28
+ @app.exception_handler(RootMemoryError)
29
+ async def handle_domain_error(_: Request, exc: RootMemoryError) -> JSONResponse:
30
+ status = next(
31
+ (code for error_type, code in STATUS_BY_ERROR if isinstance(exc, error_type)), 400
32
+ )
33
+ return JSONResponse(
34
+ status_code=status,
35
+ content={"error": type(exc).__name__, "detail": str(exc)},
36
+ )
@@ -0,0 +1,32 @@
1
+ """Graph snapshot endpoint, used by the portal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from fastapi import APIRouter
8
+
9
+ from rootmemory.api.deps import MemoryDep, SessionDep
10
+ from rootmemory.errors import NodeNotFoundError
11
+ from rootmemory.services.graph_service import GraphService
12
+
13
+ router = APIRouter(tags=["graph"])
14
+
15
+
16
+ @router.get("/graph")
17
+ def get_graph(session: SessionDep) -> dict:
18
+ """The whole memory graph in one call: nodes, edges and headline counts.
19
+
20
+ Each belief carries its evidence roots and each claim carries both the
21
+ agreement count and the independent-source count, so the portal never has
22
+ to infer the difference for itself.
23
+ """
24
+ return GraphService(session).snapshot().as_dict()
25
+
26
+
27
+ @router.get("/graph/claims/{claim_id}/sides")
28
+ def get_claim_sides(claim_id: uuid.UUID, session: SessionDep, memory: MemoryDep) -> dict:
29
+ """Which beliefs support and which contradict a claim."""
30
+ if memory.claims.get(claim_id) is None:
31
+ raise NodeNotFoundError(f"claim {claim_id} does not exist")
32
+ return GraphService(session).claim_supporters(claim_id)
@@ -0,0 +1,47 @@
1
+ """Invalidation endpoints: retracting a source and repairing what it touched."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from fastapi import APIRouter
8
+
9
+ from rootmemory.api.deps import MemoryDep
10
+ from rootmemory.schemas.observation import (
11
+ InvalidationRequest,
12
+ InvalidationResponse,
13
+ RepairReportRead,
14
+ )
15
+
16
+ router = APIRouter(tags=["invalidation"])
17
+
18
+
19
+ @router.post("/observations/{observation_id}/invalidate", response_model=InvalidationResponse)
20
+ def invalidate_observation(
21
+ observation_id: uuid.UUID, payload: InvalidationRequest, memory: MemoryDep
22
+ ) -> InvalidationResponse:
23
+ """Mark a source invalid and repair everything downstream of it.
24
+
25
+ The observation itself is not edited or deleted; only its validity moves,
26
+ and the traversal is recorded as a repair report.
27
+ """
28
+ summary = memory.invalidate_observation(observation_id, payload.reason)
29
+ return InvalidationResponse(
30
+ repair_report_id=summary.report_id,
31
+ trigger_node_id=summary.trigger_node_id,
32
+ trigger_reason=summary.trigger_reason,
33
+ affected_beliefs=summary.affected_beliefs,
34
+ affected_claims=summary.affected_claims,
35
+ affected_decisions=summary.affected_decisions,
36
+ details=summary.details,
37
+ )
38
+
39
+
40
+ @router.get("/repair-reports", response_model=list[RepairReportRead])
41
+ def list_repair_reports(memory: MemoryDep) -> list[RepairReportRead]:
42
+ return [RepairReportRead.model_validate(r) for r in memory.invalidation.audits.list_repairs()]
43
+
44
+
45
+ @router.get("/repair-reports/{report_id}", response_model=RepairReportRead)
46
+ def get_repair_report(report_id: uuid.UUID, memory: MemoryDep) -> RepairReportRead:
47
+ return RepairReportRead.model_validate(memory.invalidation.get_report(report_id))
@@ -0,0 +1,46 @@
1
+ """Observation endpoints.
2
+
3
+ There is deliberately no DELETE and no PUT: observations are immutable
4
+ historical records. The only thing that can change is validity, and that goes
5
+ through the invalidation endpoint so a repair report is always produced.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+
12
+ from fastapi import APIRouter, status
13
+
14
+ from rootmemory.api.deps import MemoryDep
15
+ from rootmemory.errors import NodeNotFoundError
16
+ from rootmemory.schemas.observation import ObservationCreate, ObservationRead
17
+
18
+ router = APIRouter(prefix="/observations", tags=["observations"])
19
+
20
+
21
+ @router.post("", response_model=ObservationRead, status_code=status.HTTP_201_CREATED)
22
+ def create_observation(payload: ObservationCreate, memory: MemoryDep) -> ObservationRead:
23
+ observation = memory.create_observation(
24
+ source_type=payload.source_type,
25
+ content=payload.content,
26
+ source_uri=payload.source_uri,
27
+ source_actor=payload.source_actor,
28
+ created_by_agent_id=payload.created_by_agent_id,
29
+ reliability_score=payload.reliability_score,
30
+ source_family_id=payload.source_family_id,
31
+ meta=payload.metadata,
32
+ )
33
+ return ObservationRead.model_validate(observation)
34
+
35
+
36
+ @router.get("", response_model=list[ObservationRead])
37
+ def list_observations(memory: MemoryDep) -> list[ObservationRead]:
38
+ return [ObservationRead.model_validate(o) for o in memory.observations.list()]
39
+
40
+
41
+ @router.get("/{observation_id}", response_model=ObservationRead)
42
+ def get_observation(observation_id: uuid.UUID, memory: MemoryDep) -> ObservationRead:
43
+ observation = memory.observations.get(observation_id)
44
+ if observation is None:
45
+ raise NodeNotFoundError(f"observation {observation_id} does not exist")
46
+ return ObservationRead.model_validate(observation)
@@ -0,0 +1,37 @@
1
+ """Promotion endpoints: the only route into shared memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from fastapi import APIRouter
8
+
9
+ from rootmemory.api.deps import MemoryDep
10
+ from rootmemory.schemas.promotion import PromotionAuditRead, PromotionEvaluationResponse
11
+
12
+ router = APIRouter(prefix="/claims", tags=["promotion"])
13
+
14
+
15
+ @router.post("/{claim_id}/evaluate-promotion", response_model=PromotionEvaluationResponse)
16
+ def evaluate_promotion(claim_id: uuid.UUID, memory: MemoryDep) -> PromotionEvaluationResponse:
17
+ """Run the gate and report the verdict without changing the claim."""
18
+ evaluation = memory.evaluate_claim(claim_id)
19
+ return PromotionEvaluationResponse.model_validate(evaluation.as_dict())
20
+
21
+
22
+ @router.post("/{claim_id}/promote", response_model=PromotionEvaluationResponse)
23
+ def promote_claim(claim_id: uuid.UUID, memory: MemoryDep) -> PromotionEvaluationResponse:
24
+ """Attempt to promote a claim into shared memory.
25
+
26
+ The gate is re-run here from scratch: a caller's earlier evaluation is
27
+ never trusted (spec section 27.8).
28
+ """
29
+ evaluation = memory.promote_claim(claim_id)
30
+ return PromotionEvaluationResponse.model_validate(evaluation.as_dict())
31
+
32
+
33
+ @router.get("/{claim_id}/promotion-audits", response_model=list[PromotionAuditRead])
34
+ def list_promotion_audits(claim_id: uuid.UUID, memory: MemoryDep) -> list[PromotionAuditRead]:
35
+ """Every promotion decision ever made about this claim, in order."""
36
+ audits = memory.promotion.audits.promotions_for_claim(claim_id)
37
+ return [PromotionAuditRead.model_validate(a) for a in audits]
@@ -0,0 +1,88 @@
1
+ """Provenance inspection and decision endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from fastapi import APIRouter, Query, status
8
+
9
+ from rootmemory.api.deps import MemoryDep
10
+ from rootmemory.errors import NodeNotFoundError
11
+ from rootmemory.models.enums import NodeType
12
+ from rootmemory.models.node_ref import NodeRef
13
+ from rootmemory.schemas.provenance import (
14
+ DecisionCreate,
15
+ DecisionRead,
16
+ EvidenceRootRead,
17
+ ProvenanceResponse,
18
+ )
19
+
20
+ router = APIRouter(tags=["provenance"])
21
+
22
+
23
+ @router.get("/provenance/{node_id}", response_model=ProvenanceResponse)
24
+ def inspect_provenance(
25
+ node_id: uuid.UUID,
26
+ memory: MemoryDep,
27
+ node_type: NodeType = Query(
28
+ default=NodeType.BELIEF, description="Which table the node lives in."
29
+ ),
30
+ include_paths: bool = Query(default=True),
31
+ ) -> ProvenanceResponse:
32
+ """Answer 'where did this come from?' for any node in the graph."""
33
+ ref = NodeRef(node_id, node_type)
34
+ if not memory.provenance.node_exists(ref):
35
+ raise NodeNotFoundError(f"{node_type}:{node_id} does not exist")
36
+
37
+ roots = memory.provenance.find_evidence_roots(ref)
38
+ observations = memory.observations.get_many(roots.roots)
39
+ return ProvenanceResponse(
40
+ node_id=node_id,
41
+ node_type=node_type,
42
+ evidence_roots=sorted(roots.roots, key=str),
43
+ excluded_roots={str(k): v for k, v in roots.excluded_roots.items()},
44
+ independent_source_count=len({o.independence_key for o in observations}),
45
+ root_details=[
46
+ EvidenceRootRead(
47
+ id=o.id,
48
+ source_type=o.source_type,
49
+ source_uri=o.source_uri,
50
+ validity_status=o.validity_status,
51
+ reliability_score=o.reliability_score,
52
+ source_family_id=o.source_family_id,
53
+ independence_key=o.independence_key,
54
+ )
55
+ for o in observations
56
+ ],
57
+ paths=memory.provenance.enumerate_paths(ref) if include_paths else [],
58
+ )
59
+
60
+
61
+ @router.post("/decisions", response_model=DecisionRead, status_code=status.HTTP_201_CREATED)
62
+ def create_decision(payload: DecisionCreate, memory: MemoryDep) -> DecisionRead:
63
+ """Record a decision and the claims it depends on.
64
+
65
+ Those dependencies are what let cascade repair flag the decision if the
66
+ evidence underneath it is later retracted.
67
+ """
68
+ decision = memory.decision_service.create_decision(
69
+ agent_id=payload.agent_id,
70
+ decision_type=payload.decision_type,
71
+ content=payload.content,
72
+ claim_ids=payload.depends_on_claim_ids,
73
+ meta=payload.metadata,
74
+ )
75
+ return DecisionRead.model_validate(decision)
76
+
77
+
78
+ @router.get("/decisions", response_model=list[DecisionRead])
79
+ def list_decisions(memory: MemoryDep) -> list[DecisionRead]:
80
+ return [DecisionRead.model_validate(d) for d in memory.decisions.list()]
81
+
82
+
83
+ @router.get("/decisions/{decision_id}", response_model=DecisionRead)
84
+ def get_decision(decision_id: uuid.UUID, memory: MemoryDep) -> DecisionRead:
85
+ decision = memory.decisions.get(decision_id)
86
+ if decision is None:
87
+ raise NodeNotFoundError(f"decision {decision_id} does not exist")
88
+ return DecisionRead.model_validate(decision)
@@ -0,0 +1,53 @@
1
+ """API key authentication.
2
+
3
+ Off by default so a fresh clone and the test-suite still work with no setup.
4
+ Set ``API_KEYS`` and it turns on for every route except ``/health``.
5
+
6
+ Why this matters here specifically: ``POST /observations/{id}/invalidate``
7
+ cascades through the graph, downgrading claims and flagging decisions. On an
8
+ open port that is a one-request way to poison shared memory, so an instance
9
+ reachable by anything other than localhost must set ``API_KEYS``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hmac
15
+
16
+ from fastapi import Depends, HTTPException, Request, status
17
+ from fastapi.security import APIKeyHeader
18
+
19
+ from rootmemory.config import Settings, get_settings
20
+
21
+ API_KEY_HEADER = "X-API-Key"
22
+
23
+ #: Routes that never require a key: liveness, and the portal shell itself
24
+ #: (the page prompts for a key and then calls the API with it).
25
+ OPEN_PATHS = frozenset({"/health", "/ui", "/", "/docs", "/openapi.json", "/redoc"})
26
+
27
+ _header_scheme = APIKeyHeader(name=API_KEY_HEADER, auto_error=False)
28
+
29
+
30
+ def _is_open(path: str) -> bool:
31
+ return path in OPEN_PATHS or path.startswith("/static/")
32
+
33
+
34
+ def require_api_key(
35
+ request: Request,
36
+ provided: str | None = Depends(_header_scheme),
37
+ settings: Settings = Depends(get_settings),
38
+ ) -> None:
39
+ """Dependency enforcing the API key when one is configured."""
40
+ if not settings.auth_enabled or _is_open(request.url.path):
41
+ return
42
+
43
+ # Compared with hmac.compare_digest so a wrong key cannot be found by
44
+ # timing the response.
45
+ for candidate in settings.api_key_list:
46
+ if provided is not None and hmac.compare_digest(provided, candidate):
47
+ return
48
+
49
+ raise HTTPException(
50
+ status_code=status.HTTP_401_UNAUTHORIZED,
51
+ detail=f"a valid {API_KEY_HEADER} header is required",
52
+ headers={"WWW-Authenticate": API_KEY_HEADER},
53
+ )