Coverage for node / src / stigmem_node / routes / facts / single.py: 90%
49 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"""Single-fact retrieval route."""
3from __future__ import annotations
5from typing import Annotated
7from fastapi import Depends, Header, HTTPException, status
9from ...auth import Identity, resolve_identity
10from ...cid import is_cid, is_valid_cid
11from ...db import db
12from ...garden_acl import require_garden_read
13from ...models.facts import FactRecord, row_to_record
14from ...recall.recall_pipeline import apply_recall_pipeline
15from ...session_graph import record_read_scopes
16from ..cid_integrity import enforce_read_path_cid
17from .common import FACT_PROJECTION_JOINS, FACT_PROJECTION_SELECT, router
20@router.get("/{fact_id}", response_model=FactRecord)
21def get_fact(
22 fact_id: str,
23 identity: Annotated[Identity, Depends(resolve_identity)],
24 session_id: Annotated[str | None, Header(alias="Stigmem-Session")] = None,
25) -> FactRecord:
26 """Retrieve a single fact by UUID or sha256: CID.
28 Covered by Spec-03-HTTP-API and Spec-21-Content-Addressed-IDs.
29 """
30 if not identity.can_read(): 30 ↛ 31line 30 didn't jump to line 31 because the condition on line 30 was never true
31 raise HTTPException(
32 status_code=status.HTTP_403_FORBIDDEN, detail="read permission required"
33 ) # noqa: E501
35 # §25.5: dual addressing — resolve CID to UUID via alias table
36 resolved_fact_id = fact_id
37 if is_cid(fact_id):
38 if not is_valid_cid(fact_id):
39 raise HTTPException(
40 status_code=400,
41 detail={
42 "code": "cid_malformed",
43 "message": "CID must be 'sha256:' followed by 64 hex chars",
44 }, # noqa: E501
45 )
46 with db() as conn:
47 alias = conn.execute(
48 "SELECT fact_id FROM fact_cid_aliases WHERE cid = ? AND tenant_id = ?",
49 (fact_id, identity.tenant_id),
50 ).fetchone()
51 if alias is None:
52 raise HTTPException(status_code=404, detail="fact not found")
53 resolved_fact_id = alias["fact_id"]
55 with db() as conn:
56 row = conn.execute(
57 f"SELECT {FACT_PROJECTION_SELECT} FROM facts f {FACT_PROJECTION_JOINS} " # noqa: S608 # nosec B608
58 "WHERE f.id = ? AND f.tenant_id = ?",
59 (resolved_fact_id, identity.tenant_id),
60 ).fetchone()
61 if row is None:
62 raise HTTPException(status_code=404, detail="fact not found")
64 # F-11 §25.6.1/§23.3.3: tombstone indistinguishability — tombstoned facts return 404
65 from ...lifecycle.tombstone_cache import is_tombstoned as _is_tombstoned_check
67 if _is_tombstoned_check(row["entity"], identity.tenant_id): 67 ↛ 68line 67 didn't jump to line 68 because the condition on line 67 was never true
68 raise HTTPException(status_code=404, detail="fact not found")
70 # Garden ACL: fact in a garden is only readable by members (spec §17.3)
71 row_keys = row.keys()
72 garden_id = (
73 row["projected_garden_id"] if "projected_garden_id" in row_keys else row["garden_id"]
74 )
75 if garden_id is not None:
76 with db() as conn:
77 garden_row = conn.execute(
78 "SELECT * FROM gardens WHERE id = ? AND tenant_id = ?",
79 (garden_id, identity.tenant_id),
80 ).fetchone()
81 if garden_row is not None: 81 ↛ 84line 81 didn't jump to line 84 because the condition on line 81 was always true
82 require_garden_read(dict(garden_row), identity)
84 with db() as conn:
85 sibling_count: int = conn.execute(
86 "SELECT COUNT(*) FROM facts WHERE entity=? AND relation=? AND scope=? AND tenant_id=?",
87 (row["entity"], row["relation"], row["scope"], identity.tenant_id),
88 ).fetchone()[0]
89 enforce_read_path_cid(row)
90 record = row_to_record(row, contradicted=sibling_count > 1)
91 # v1.1: recall pipeline (trust multiplier + sanitizer)
92 pipeline_results = apply_recall_pipeline([record], identity=identity, include_low_trust=True)
93 if pipeline_results: 93 ↛ 103line 93 didn't jump to line 103 because the condition on line 93 was always true
94 with db() as conn:
95 record_read_scopes(
96 conn,
97 identity=identity,
98 session_id=session_id,
99 scopes={pipeline_results[0].scope},
100 )
101 return pipeline_results[0]
102 # Pending-quarantine facts return 404 to normal callers
103 raise HTTPException(status_code=404, detail="fact not found")