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

1"""Memory cards route — spec §20 (Phase 9). 

2 

3GET /v1/cards/{entity_uri} Fetch (and optionally force-refresh) the memory card 

4 for a specific entity. 

5""" 

6 

7from __future__ import annotations 

8 

9from datetime import UTC, datetime 

10from typing import Annotated 

11 

12from fastapi import APIRouter, Depends, HTTPException, Query, status 

13 

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 

21 

22router = APIRouter(prefix="/v1/cards", tags=["cards"]) 

23 

24 

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). 

33 

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 ) 

46 

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 

54 

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 

62 

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 ) 

71 

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 ) 

77 

78 if card is None: 

79 raise HTTPException( 

80 status_code=status.HTTP_404_NOT_FOUND, 

81 detail="no facts found for entity", 

82 ) 

83 

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 )