Coverage for node / src / stigmem_node / routes / cards.py: 95%
31 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-18 05:34 +0000
1"""Memory cards route — spec §20 (Phase 9).
3GET /v1/cards/{entity_uri} Fetch (and optionally force-refresh) the memory card
4 for a specific entity.
5"""
7from __future__ import annotations
9from datetime import UTC, datetime
10from typing import Annotated
12from fastapi import APIRouter, Depends, HTTPException, Query, status
14from ..auth import Identity, resolve_identity
15from ..card_materializer import get_fresh_card, refresh_card
16from ..db import db
17from ..entity_normalizer import NormalizationError, normalize_entity_uri
18from ..memory_garden_acl_gate import garden_acl_enforced
19from ..models.cards import MemoryCardResponse
20from ..models.constants import VALID_SCOPES
22router = APIRouter(prefix="/v1/cards", tags=["cards"])
25@router.get("/{entity_uri:path}", response_model=MemoryCardResponse)
26def get_card(
27 entity_uri: str,
28 identity: Annotated[Identity, Depends(resolve_identity)],
29 scope: str = Query("local"),
30 refresh: bool = Query(False, description="Force refresh even if card is fresh"),
31) -> MemoryCardResponse:
32 """Fetch the synthesized memory card for an entity (Spec-X11-Recall-Graph).
34 Returns 404 when the entity has no live facts.
35 """
36 if not identity.can_read(): 36 ↛ 37line 36 didn't jump to line 37 because the condition on line 36 was never true
37 raise HTTPException(
38 status_code=status.HTTP_403_FORBIDDEN,
39 detail="read permission required",
40 )
41 if scope not in VALID_SCOPES:
42 raise HTTPException(
43 status_code=status.HTTP_400_BAD_REQUEST,
44 detail=f"scope must be one of {sorted(VALID_SCOPES)}",
45 )
47 try:
48 entity_uri = normalize_entity_uri(entity_uri)
49 except NormalizationError as exc:
50 raise HTTPException(
51 status_code=status.HTTP_400_BAD_REQUEST,
52 detail=f"invalid_entity_uri: {exc}",
53 ) from exc
55 with db() as conn:
56 # Garden ACL: the card summary aggregates the entity's fact values
57 # verbatim with no garden filter, so it must not be served when any
58 # contributing fact lives in a (projected) garden the caller cannot see
59 # (audit cards-route sibling of H1). Reuse the recall card gate so the
60 # two routes serving the same card cannot diverge. Hide as 404.
61 from .recall.orchestration import _caller_sees_all_card_gardens
63 now = datetime.now(UTC).isoformat()
64 if garden_acl_enforced() and not _caller_sees_all_card_gardens(
65 entity_uri, scope, identity, conn, now
66 ):
67 raise HTTPException(
68 status_code=status.HTTP_404_NOT_FOUND,
69 detail="no facts found for entity",
70 )
72 card = (
73 refresh_card(entity_uri, scope, identity.tenant_id, conn)
74 if refresh
75 else get_fresh_card(entity_uri, scope, identity.tenant_id, conn)
76 )
78 if card is None:
79 raise HTTPException(
80 status_code=status.HTTP_404_NOT_FOUND,
81 detail="no facts found for entity",
82 )
84 return MemoryCardResponse(
85 entity_uri=card.entity_uri,
86 scope=card.scope,
87 summary=card.summary,
88 fact_hashes=card.fact_hashes,
89 avg_confidence=card.avg_confidence,
90 refreshed_at=card.refreshed_at,
91 is_stale=card.is_stale,
92 has_contradictions=card.has_contradictions,
93 )