Coverage for node / src / stigmem_node / routes / facts / query.py: 93%

205 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-18 05:34 +0000

1"""GET /v1/facts query route and query helpers.""" 

2 

3from __future__ import annotations 

4 

5import uuid 

6from datetime import UTC, datetime, timedelta 

7from typing import Annotated, Any 

8 

9from fastapi import Depends, Header, HTTPException, Query, Response, status 

10 

11from ... import settings as _settings_pkg 

12from ...auth import Identity, resolve_identity 

13from ...db import db 

14from ...entity_normalizer import NormalizationError, normalize_entity_uri 

15from ...fact_visibility import ReadScope, caller_read_scope 

16from ...garden_acl import get_garden_by_garden_uri, require_garden_read 

17from ...memory_garden_acl_gate import garden_acl_enforced 

18from ...metrics import FACT_READ 

19from ...models.constants import VALID_SCOPES 

20from ...models.facts import FactRecord, QueryResponse, row_to_record 

21from ...models.tombstones import TombstoneNotice 

22from ...plugins import Deny, Failure, Success, TenantContext, get_registry 

23from ...recall.recall_pipeline import apply_recall_pipeline 

24from ...session_graph import record_read_scopes 

25from ..cid_integrity import enforce_read_path_cid 

26from ..time_travel_gate import require_time_travel_enabled 

27from .common import _get_tombstone_filter, logger, router 

28 

29 

30def _validate_as_of(as_of: str) -> datetime: 

31 """Parse and validate an as_of timestamp per §24.2.2.""" 

32 import re 

33 

34 # URL query strings decode + as space; restore the + in timezone offsets like "+00:00". 

35 normalized = re.sub(r" (\d{2}:\d{2})$", r"+\1", as_of).replace("Z", "+00:00") 

36 try: 

37 ts = datetime.fromisoformat(normalized) 

38 except ValueError as exc: 

39 raise HTTPException( 

40 status_code=400, 

41 detail={"code": "as_of_invalid_timestamp", "message": str(exc)}, 

42 ) from exc 

43 if ts.tzinfo is None: 43 ↛ 44line 43 didn't jump to line 44 because the condition on line 43 was never true

44 ts = ts.replace(tzinfo=UTC) 

45 if ts > datetime.now(UTC) + timedelta(seconds=5): 

46 raise HTTPException( 

47 status_code=400, 

48 detail={"code": "as_of_future", "message": "as_of must not be in the future (§24.2.2)"}, 

49 ) 

50 floor = _settings_pkg.settings.as_of_retention_floor 

51 if floor: 

52 try: 

53 floor_ts = datetime.fromisoformat(floor.replace("Z", "+00:00")) 

54 if floor_ts.tzinfo is None: 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true

55 floor_ts = floor_ts.replace(tzinfo=UTC) 

56 if ts < floor_ts: 

57 raise HTTPException( 

58 status_code=400, 

59 detail={ 

60 "code": "as_of_before_retention_floor", 

61 "message": "as_of predates the retention horizon for this deployment (§24.2.2)", # noqa: E501 

62 }, 

63 ) 

64 except HTTPException: 

65 raise 

66 except Exception as exc: # nosec B110 

67 logger.warning("could not read retention floor while validating as_of: %s", exc) 

68 return ts 

69 

70 

71def _legal_hold_blocks_query(conn: Any, entity: str) -> bool: 

72 """F-14 §24.3.2: True if a legal-hold tombstone covers *entity* (no active revocation).""" 

73 legal_hold_row = conn.execute( 

74 # Same-issuer binding: only a revocation from the tombstone's OWN issuer 

75 # (r.signed_by = t.signed_by) lifts the suppression — a forged/cross-issuer 

76 # revocation can never clear a legal-hold tombstone (RTBF integrity). 

77 """SELECT 1 FROM tombstones t 

78 WHERE t.entity_uri = ? 

79 AND t.legal_hold = 1 

80 AND NOT EXISTS ( 

81 SELECT 1 FROM tombstone_revocations r 

82 WHERE r.tombstone_id = t.id AND r.signed_by = t.signed_by 

83 ) 

84 LIMIT 1""", 

85 (entity,), 

86 ).fetchone() 

87 return legal_hold_row is not None 

88 

89 

90_AS_OF_SELECT_SQL = ( 

91 "SELECT f.*, COALESCE(fgm.garden_id, f.garden_id) AS projected_garden_id" 

92 " FROM facts f" 

93 " LEFT JOIN fact_garden_membership fgm ON fgm.fact_id = f.id" 

94 " WHERE f.tenant_id = ?" 

95 " AND f.timestamp <= ?" 

96 " AND (f.valid_until IS NULL OR f.valid_until > ?)" 

97 " AND NOT EXISTS (" 

98 " SELECT 1 FROM fact_retractions fr" 

99 " WHERE fr.fact_id = f.id AND fr.retracted_at <= ?" 

100 " )" 

101 " AND (? IS NULL" 

102 " OR f.entity = ?" 

103 " OR f.entity IN (" 

104 " SELECT raw_uri FROM entity_aliases WHERE canonical_uri = ? AND tenant_id = ?))" 

105 " AND (? IS NULL OR f.relation = ?)" 

106 " AND (? IS NULL OR f.scope = ?)" 

107 " AND (? IS NULL OR f.id > ?)" 

108 " ORDER BY f.timestamp DESC, f.id DESC" 

109 " LIMIT ?" 

110) 

111 

112_GARDEN_VISIBILITY_NONE = 0 

113_GARDEN_VISIBILITY_EXACT = 1 

114_GARDEN_VISIBILITY_VISIBLE_SET = 2 

115_GARDEN_VISIBILITY_NULL_ONLY = 3 

116 

117_FACT_QUERY_SQL = ( 

118 "SELECT f.*, " 

119 "COALESCE(fvo.valid_until, f.valid_until) AS projected_valid_until, " 

120 "COALESCE(fvo.confidence, f.confidence) AS projected_confidence, " 

121 "COALESCE(fgm.garden_id, f.garden_id) AS projected_garden_id, " 

122 "COALESCE(fqs.quarantine_status, f.quarantine_status) AS projected_quarantine_status, " 

123 "COALESCE(fqs.quarantine_garden_id, f.quarantine_garden_id) " 

124 "AS projected_quarantine_garden_id, " 

125 "COALESCE(f.cid, (SELECT fca.cid FROM fact_cid_aliases fca " 

126 "WHERE fca.fact_id = f.id ORDER BY fca.cid LIMIT 1)) AS projected_cid" 

127 " FROM facts f" 

128 " LEFT JOIN fact_validity_overrides fvo ON fvo.fact_id = f.id" 

129 " LEFT JOIN fact_garden_membership fgm ON fgm.fact_id = f.id" 

130 " LEFT JOIN fact_quarantine_status fqs ON fqs.fact_id = f.id" 

131 " WHERE COALESCE(fvo.confidence, f.confidence) >= ?" 

132 " AND f.tenant_id = ?" 

133 " AND (" 

134 " ? = 0" 

135 " OR (? = 1 AND COALESCE(fgm.garden_id, f.garden_id) = ?)" 

136 " OR (? = 2 AND (COALESCE(fgm.garden_id, f.garden_id) IS NULL" 

137 " OR EXISTS (SELECT 1 FROM _query_visible_gardens qvg" 

138 " WHERE qvg.id = COALESCE(fgm.garden_id, f.garden_id))))" 

139 " OR (? = 3 AND COALESCE(fgm.garden_id, f.garden_id) IS NULL)" 

140 " )" 

141 " AND (? IS NULL OR f.attested = ?)" 

142 " AND (? IS NULL" 

143 " OR f.entity = ?" 

144 " OR f.entity IN (" 

145 " SELECT raw_uri FROM entity_aliases WHERE canonical_uri = ? AND tenant_id = ?))" 

146 " AND (? IS NULL OR f.relation = ?)" 

147 " AND (? IS NULL" 

148 " OR f.source = ?" 

149 " OR f.source IN (" 

150 " SELECT raw_uri FROM entity_aliases WHERE canonical_uri = ? AND tenant_id = ?))" 

151 " AND (? IS NULL OR f.scope = ?)" 

152 " AND (? IS NULL OR f.timestamp > ?)" 

153 " AND (? IS NULL OR f.id > ?)" 

154 " AND (? = 1" 

155 " OR COALESCE(fvo.valid_until, f.valid_until) IS NULL" 

156 " OR COALESCE(fvo.valid_until, f.valid_until) > ?)" 

157 " ORDER BY f.timestamp DESC, f.id DESC LIMIT ?" 

158) 

159 

160def _build_as_of_params( 

161 *, 

162 entity: str | None, 

163 scope: str | None, 

164 relation: str | None, 

165 as_of: str, 

166 tenant_id: str, 

167 cursor: str | None, 

168 limit: int, 

169) -> list[Any]: 

170 """Return the bind values for ``_AS_OF_SELECT_SQL``. 

171 

172 The SQL text is the ``_AS_OF_SELECT_SQL`` module-level constant; this 

173 helper only computes bind values. Keeping the SQL string out of any 

174 function that takes user input prevents CodeQL from interprocedurally 

175 tainting it — see issue #121 for why a function that takes user 

176 inputs and returns ``(sql, params)`` still trips ``py/sql-injection`` 

177 even when the returned SQL value is invariant. 

178 """ 

179 if scope is not None and scope not in VALID_SCOPES: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 raise HTTPException(status_code=400, detail=f"scope must be one of {VALID_SCOPES}") 

181 

182 # Normalize empty strings to None so the IS NULL gate matches the 

183 # previous ``if entity:`` truthiness behaviour. 

184 entity_p = entity or None 

185 relation_p = relation or None 

186 scope_p = scope or None 

187 cursor_p = cursor or None 

188 

189 return [ 

190 tenant_id, 

191 as_of, 

192 as_of, 

193 as_of, 

194 entity_p, 

195 entity_p, 

196 entity_p, 

197 tenant_id, # entity-alias subquery tenant scope 

198 relation_p, 

199 relation_p, 

200 scope_p, 

201 scope_p, 

202 cursor_p, 

203 cursor_p, 

204 limit + 1, 

205 ] 

206 

207 

208def _query_facts_as_of_impl( 

209 conn: Any, 

210 *, 

211 entity: str | None, 

212 scope: str | None, 

213 relation: str | None, 

214 as_of: str, 

215 is_admin_caller: bool, 

216 tenant_id: str, 

217 read_scope: ReadScope, 

218 limit: int, 

219 cursor: str | None, 

220) -> QueryResponse: 

221 """Return facts visible at as_of per §24.4. 

222 

223 Retraction gating uses fact_retractions.retracted_at (append-only log), NOT facts.confidence. 

224 Expiry gating uses facts.valid_until. 

225 Tombstone filter per §24.3: retroactive RTBF unless legal_hold=true AND is_admin_caller. 

226 """ 

227 # F-14 §24.3.2: pre-check — agent-key callers get empty results if a legal-hold 

228 # tombstone covers the queried entity (short-circuit before executing the query) 

229 if entity and not is_admin_caller and _legal_hold_blocks_query(conn, entity): 

230 return QueryResponse(facts=[], total=None, cursor=None) 

231 

232 params = _build_as_of_params( 

233 entity=entity, 

234 scope=scope, 

235 relation=relation, 

236 as_of=as_of, 

237 tenant_id=tenant_id, 

238 cursor=cursor, 

239 limit=limit, 

240 ) 

241 

242 raw = conn.execute(_AS_OF_SELECT_SQL, params).fetchall() 

243 has_more = len(raw) > limit 

244 page = raw[:limit] 

245 # Garden ACL (fail-closed): drop facts whose projected garden the caller 

246 # cannot see, BEFORE building records/contradiction counts. The id-cursor 

247 # below continues from the page boundary (page[-1]), so dropping rows here 

248 # never skips or duplicates a visible fact across pages (audit F-AS-OF-FACTS; 

249 # the recall as_of path was fixed in M3, this is the /v1/facts?as_of= sibling). 

250 rows = [r for r in page if read_scope.garden_allows(r["projected_garden_id"])] 

251 

252 seen: dict[tuple[str, str, str], int] = {} 

253 for r in rows: 

254 key = (r["entity"], r["relation"], r["scope"]) 

255 seen[key] = seen.get(key, 0) + 1 

256 

257 for r in rows: 

258 enforce_read_path_cid(r) 

259 records = [ 

260 row_to_record(r, contradicted=seen[(r["entity"], r["relation"], r["scope"])] > 1) 

261 for r in rows 

262 ] 

263 

264 # §24.3: tombstone filter for as_of queries 

265 tombstone_notices: list[TombstoneNotice] = [] 

266 tombstone_filtered = False 

267 if records: 

268 entity_uris = list({r.entity for r in records}) 

269 excluded, tombstone_notices = _get_tombstone_filter( 

270 conn, entity_uris, scope or "local", is_admin_caller, tenant_id 

271 ) 

272 if excluded: 

273 records = [r for r in records if r.entity not in excluded] 

274 tombstone_filtered = True 

275 

276 # Cursor advances from the page boundary (not the last visible row) so 

277 # garden-filtered rows don't stall pagination. 

278 next_cursor = page[-1]["id"] if has_more and page else None 

279 # §23.3.3 r.3: suppress total when tombstone filtering was applied to prevent oracle leakage 

280 total = None if tombstone_filtered else len(records) 

281 return QueryResponse( 

282 facts=records, 

283 total=total, 

284 cursor=next_cursor, 

285 tombstone_notices=tombstone_notices, 

286 ) 

287 

288 

289@router.get("", response_model=QueryResponse) 

290def query_facts( 

291 identity: Annotated[Identity, Depends(resolve_identity)], 

292 response: Response, 

293 session_id: Annotated[str | None, Header(alias="Stigmem-Session")] = None, 

294 entity: str | None = Query(None), 

295 relation: str | None = Query(None), 

296 source: str | None = Query(None), 

297 scope: str | None = Query(None), 

298 min_confidence: float = Query(0.0, ge=0.0, le=1.0), 

299 include_contradicted: bool = Query(False), 

300 include_expired: bool = Query(False), 

301 after: str | None = Query( 

302 None, description="Return facts with timestamp > this ISO 8601 value" 

303 ), # noqa: E501 

304 cursor: str | None = Query(None, description="Opaque pagination cursor (fact id)"), 

305 limit: int = Query(50, ge=1, le=500), 

306 garden_id: str | None = Query( 

307 None, description="Filter to facts in this garden (Spec-02-Scopes-and-ACL)" 

308 ), # noqa: E501 

309 attested: bool | None = Query( 

310 None, description="Filter by source-attestation status (Spec-X6-Source-Attestation)" 

311 ), # noqa: E501 

312 include_low_trust: bool = Query( 

313 False, 

314 description="Include facts with effective_confidence < 0.3 (Spec-05-Federation-Trust)", 

315 ), # noqa: E501 

316 as_of: str | None = Query( 

317 None, 

318 description="Time-travel query: return facts visible at this ISO 8601 timestamp (Spec-X3-Time-Travel-Queries)", # noqa: E501 

319 ), # noqa: E501 

320) -> QueryResponse: 

321 """Query facts by pattern (Spec-03-HTTP-API). 

322 

323 Omitted fields are wildcards. Entity/source are normalized by Spec-01-Fact-Model. 

324 """ 

325 if not identity.can_read(): 

326 raise HTTPException( 

327 status_code=status.HTTP_403_FORBIDDEN, detail="read permission required" 

328 ) # noqa: E501 

329 

330 request_id = str(uuid.uuid4()) 

331 tenant = TenantContext( 

332 tenant_id=identity.tenant_id, 

333 metadata={"tenant_context_source": "hook"}, 

334 ) 

335 registry = get_registry() 

336 query_payload: dict[str, Any] = { 

337 "entity": entity, 

338 "relation": relation, 

339 "source": source, 

340 "scope": scope, 

341 "min_confidence": min_confidence, 

342 "include_contradicted": include_contradicted, 

343 "include_expired": include_expired, 

344 "after": after, 

345 "cursor": cursor, 

346 "limit": limit, 

347 "garden_id": garden_id, 

348 "attested": attested, 

349 "include_low_trust": include_low_trust, 

350 "as_of": as_of, 

351 } 

352 decision = registry.fire_voting( 

353 "pre_recall_authorize", 

354 identity=identity, 

355 tenant=tenant, 

356 request_id=request_id, 

357 query=query_payload, 

358 ) 

359 if isinstance(decision, Deny): 

360 registry.fire_fire_and_forget( 

361 "post_recall_audit", 

362 result=None, 

363 identity=identity, 

364 tenant=tenant, 

365 request_id=request_id, 

366 outcome=Failure(reason=decision.reason), 

367 ) 

368 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=decision.reason) 

369 

370 rewritten_query = registry.fire_filter_chain( 

371 "pre_recall_rewrite", 

372 query_payload, 

373 identity=identity, 

374 tenant=tenant, 

375 request_id=request_id, 

376 ) 

377 entity = rewritten_query["entity"] 

378 relation = rewritten_query["relation"] 

379 source = rewritten_query["source"] 

380 scope = rewritten_query["scope"] 

381 min_confidence = rewritten_query["min_confidence"] 

382 include_contradicted = rewritten_query["include_contradicted"] 

383 include_expired = rewritten_query["include_expired"] 

384 after = rewritten_query["after"] 

385 cursor = rewritten_query["cursor"] 

386 limit = rewritten_query["limit"] 

387 garden_id = rewritten_query["garden_id"] 

388 attested = rewritten_query["attested"] 

389 include_low_trust = rewritten_query["include_low_trust"] 

390 as_of = rewritten_query["as_of"] 

391 

392 # §24.4: time-travel query — delegate to as_of implementation 

393 if as_of is not None: 

394 require_time_travel_enabled(registry, surface="fact_query") 

395 _validate_as_of(as_of) 

396 with db() as conn: 

397 result = _query_facts_as_of_impl( 

398 conn, 

399 entity=entity, 

400 scope=scope, 

401 relation=relation, 

402 as_of=as_of, 

403 is_admin_caller=identity.is_admin(), 

404 tenant_id=identity.tenant_id, 

405 read_scope=caller_read_scope(identity), 

406 limit=limit, 

407 cursor=cursor, 

408 ) 

409 if result.total is not None: 

410 response.headers["X-Total-Count"] = str(result.total) 

411 with db() as conn: 

412 record_read_scopes( 

413 conn, 

414 identity=identity, 

415 session_id=session_id, 

416 scopes={fact.scope for fact in result.facts}, 

417 ) 

418 registry.fire_fire_and_forget( 

419 "post_recall_audit", 

420 result=result, 

421 identity=identity, 

422 tenant=tenant, 

423 request_id=request_id, 

424 outcome=Success(), 

425 ) 

426 return result 

427 

428 FACT_READ.labels(principal=identity.entity_uri, tenant=identity.tenant_id).inc() 

429 

430 # Garden ACL: resolve and enforce membership before querying (spec §5.20, §17.3) 

431 garden = _resolve_garden_or_404(garden_id, identity) 

432 

433 with db() as conn: 

434 garden_visibility_mode, exact_garden_id, visible_garden_ids = _resolve_garden_visibility( 

435 conn, garden, identity 

436 ) 

437 _prepare_garden_visibility_table(conn, visible_garden_ids) 

438 params = _build_query_params( 

439 identity=identity, 

440 garden_visibility_mode=garden_visibility_mode, 

441 exact_garden_id=exact_garden_id, 

442 entity=entity, 

443 relation=relation, 

444 source=source, 

445 scope=scope, 

446 min_confidence=min_confidence, 

447 attested=attested, 

448 after=after, 

449 cursor=cursor, 

450 include_expired=include_expired, 

451 limit=limit, 

452 ) 

453 rows = conn.execute(_FACT_QUERY_SQL, params).fetchall() 

454 

455 has_more = len(rows) > limit 

456 rows = rows[:limit] 

457 

458 records = _rows_to_records(rows) 

459 if not include_contradicted: 

460 records = [r for r in records if not r.contradicted] 

461 

462 # v1.1: apply recall-time trust multiplier + content sanitizer (§19.4.4, §19.7) 

463 records = apply_recall_pipeline(records, identity=identity, include_low_trust=include_low_trust) 

464 

465 # §23.3: tombstone filter — must be applied after scope filtering, before packing 

466 records, tombstone_filtered = _apply_tombstone_filter(records, scope, identity) 

467 records = registry.fire_filter_chain( 

468 "recall_filter", 

469 records, 

470 identity=identity, 

471 tenant=tenant, 

472 request_id=request_id, 

473 ) 

474 score_deltas = registry.fire_score_delta( 

475 "recall_rank", 

476 records, 

477 identity=identity, 

478 tenant=tenant, 

479 request_id=request_id, 

480 ) 

481 if score_deltas: 481 ↛ 482line 481 didn't jump to line 482 because the condition on line 481 was never true

482 records = sorted(records, key=lambda record: score_deltas.get(record.id, 0.0), reverse=True) 

483 

484 next_cursor = rows[-1]["id"] if has_more and rows else None 

485 # §23.3.3 r.3: suppress total when tombstone filtering was applied to prevent oracle leakage 

486 total = None if tombstone_filtered else len(records) 

487 result = QueryResponse(facts=records, total=total, cursor=next_cursor) 

488 if result.total is not None: 

489 response.headers["X-Total-Count"] = str(result.total) 

490 with db() as conn: 

491 record_read_scopes( 

492 conn, 

493 identity=identity, 

494 session_id=session_id, 

495 scopes={fact.scope for fact in records}, 

496 ) 

497 registry.fire_fire_and_forget( 

498 "post_recall_audit", 

499 result=result, 

500 identity=identity, 

501 tenant=tenant, 

502 request_id=request_id, 

503 outcome=Success(), 

504 ) 

505 return result 

506 

507 

508def _resolve_garden_or_404(garden_id: str | None, identity: Identity) -> Any: 

509 """Return the garden row when ``garden_id`` is set; 404 if missing; enforce read ACL.""" 

510 if garden_id is None: 

511 return None 

512 garden = get_garden_by_garden_uri(garden_id, tenant_id=identity.tenant_id) 

513 if garden is None: 513 ↛ 514line 513 didn't jump to line 514 because the condition on line 513 was never true

514 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="garden not found") 

515 require_garden_read(garden, identity) 

516 return garden 

517 

518 

519def _resolve_garden_visibility( 

520 conn: Any, 

521 garden: Any, 

522 identity: Identity, 

523) -> tuple[int, str | None, list[str]]: 

524 """Return query visibility mode, exact garden id, and visible garden id set.""" 

525 if garden is not None: 

526 return _GARDEN_VISIBILITY_EXACT, garden["id"], [] 

527 if not garden_acl_enforced(): 527 ↛ 528line 527 didn't jump to line 528 because the condition on line 527 was never true

528 return _GARDEN_VISIBILITY_NONE, None, [] 

529 

530 visible_garden_ids = [ 

531 row["id"] 

532 for row in conn.execute( 

533 "SELECT g.id FROM gardens g" 

534 " WHERE g.tenant_id = ?" 

535 " AND EXISTS (" 

536 " SELECT 1 FROM garden_members gm" 

537 " WHERE gm.garden_id = g.id AND gm.entity_uri = ?" 

538 " )", 

539 (identity.tenant_id, identity.entity_uri), 

540 ).fetchall() 

541 ] 

542 if visible_garden_ids: 

543 return _GARDEN_VISIBILITY_VISIBLE_SET, None, visible_garden_ids 

544 return _GARDEN_VISIBILITY_NULL_ONLY, None, [] 

545 

546 

547def _prepare_garden_visibility_table(conn: Any, visible_garden_ids: list[str]) -> None: 

548 """Populate the per-connection visibility table referenced by ``_FACT_QUERY_SQL``.""" 

549 conn.execute("CREATE TEMP TABLE IF NOT EXISTS _query_visible_gardens (id TEXT PRIMARY KEY)") 

550 conn.execute("DELETE FROM _query_visible_gardens") 

551 for garden_id in visible_garden_ids: 

552 conn.execute("INSERT OR IGNORE INTO _query_visible_gardens (id) VALUES (?)", (garden_id,)) 

553 

554 

555def _normalise_uri_or_raw(raw_value: str) -> str: 

556 """Best-effort URI normalisation; falls back to the raw value on failure.""" 

557 try: # noqa: SIM105 

558 return normalize_entity_uri(raw_value) 

559 except NormalizationError: 

560 return raw_value # malformed — fall through to exact match 

561 

562 

563def _build_query_params( # noqa: PLR0913 — narrow internal helper, keeps query_facts signature flat 

564 *, 

565 identity: Identity, 

566 garden_visibility_mode: int, 

567 exact_garden_id: str | None, 

568 entity: str | None, 

569 relation: str | None, 

570 source: str | None, 

571 scope: str | None, 

572 min_confidence: float, 

573 attested: bool | None, 

574 after: str | None, 

575 cursor: str | None, 

576 include_expired: bool, 

577 limit: int, 

578) -> list[Any]: 

579 """Return bind values for the fixed ``_FACT_QUERY_SQL`` template.""" 

580 if scope and scope not in VALID_SCOPES: 580 ↛ 581line 580 didn't jump to line 581 because the condition on line 580 was never true

581 raise HTTPException(status_code=400, detail=f"scope must be one of {VALID_SCOPES}") 

582 

583 normalised_entity = _normalise_uri_or_raw(entity) if entity else None 

584 normalised_source = _normalise_uri_or_raw(source) if source else None 

585 attested_value = None if attested is None else 1 if attested else 0 

586 now = datetime.now(UTC).isoformat() 

587 return [ 

588 min_confidence, 

589 identity.tenant_id, 

590 garden_visibility_mode, 

591 garden_visibility_mode, 

592 exact_garden_id, 

593 garden_visibility_mode, 

594 garden_visibility_mode, 

595 attested_value, 

596 attested_value, 

597 normalised_entity, 

598 normalised_entity, 

599 normalised_entity, 

600 identity.tenant_id, # entity-alias subquery tenant scope 

601 relation or None, 

602 relation or None, 

603 normalised_source, 

604 normalised_source, 

605 normalised_source, 

606 identity.tenant_id, # source-alias subquery tenant scope 

607 scope or None, 

608 scope or None, 

609 after or None, 

610 after or None, 

611 cursor or None, 

612 cursor or None, 

613 1 if include_expired else 0, 

614 now, 

615 limit + 1, 

616 ] 

617 

618 

619def _rows_to_records(rows: list[Any]) -> list[FactRecord]: 

620 """Convert raw rows into FactRecords; mark within-key duplicates contradicted.""" 

621 seen: dict[tuple[str, str, str], int] = {} 

622 for r in rows: 

623 enforce_read_path_cid(r) 

624 key = (r["entity"], r["relation"], r["scope"]) 

625 seen[key] = seen.get(key, 0) + 1 

626 return [ 

627 row_to_record(r, contradicted=seen[(r["entity"], r["relation"], r["scope"])] > 1) 

628 for r in rows 

629 ] 

630 

631 

632def _apply_tombstone_filter( 

633 records: list[FactRecord], 

634 scope: str | None, 

635 identity: Identity, 

636) -> tuple[list[FactRecord], bool]: 

637 """Return (filtered, tombstone_filtered_flag). Empty input → no work.""" 

638 if not records: 

639 return records, False 

640 entity_uris_in_result = list({r.entity for r in records}) 

641 with db() as _tc_conn: 

642 excluded, _notices = _get_tombstone_filter( 

643 _tc_conn, 

644 entity_uris_in_result, 

645 scope or "local", 

646 identity.is_admin(), 

647 identity.tenant_id, 

648 ) 

649 if not excluded: 

650 return records, False 

651 return [r for r in records if r.entity not in excluded], True