Coverage for node / src / stigmem_node / routes / intents.py: 77%

183 statements  

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

1"""Intent Envelope route — spec §4, §5.14 (v0.8). 

2 

3POST /v1/intents 

4 Accept a structured IntentEnvelope, validate it, decompose it into atomic 

5 facts in the fabric, and return a receipt with the generated intent ID and 

6 all written fact IDs. 

7 

8GET /v1/intents/:intent_id 

9 Reconstruct and return the envelope by querying its reified facts. 

10 

11Fact reification schema (all facts share the same intent_id entity, except 

12sub-entities for constraints/preferences/deferences/artifacts): 

13 

14 (intent_id, "intent:from", ref, from_uri, scope) 

15 (intent_id, "intent:goal", text, from_uri, scope) 

16 (intent_id, "intent:to", ref, from_uri, scope) — one per target 

17 (intent_id, "intent:escalation", string, from_uri, scope) — priority 

18 (intent_id, "intent:escalate_to", ref, from_uri, scope) 

19 (intent_id, "intent:escalation:channel", string, ...) 

20 (intent_id, "intent:escalation:context", string "true"|"false", ...) 

21 (intent_id, "intent:handoff_to", ref, from_uri, scope) — to[0] when handoff present 

22 (intent_id, "intent:handoff_summary",text, from_uri, scope) 

23 (intent_id, "intent:context_ref", ref, from_uri, scope) — one per fact_ref 

24 (intent_id, "intent:continuation", text, from_uri, scope) 

25 (intent_id, "intent:constraint", ref, from_uri, scope) — one per constraint sub-entity 

26 (intent_id, "intent:preference", ref, from_uri, scope) — one per preference sub-entity 

27 (intent_id, "intent:deference", ref, from_uri, scope) — one per deference sub-entity 

28 (intent_id, "intent:artifact", ref, from_uri, scope) — one per artifact sub-entity 

29 

30Sub-entities use the pattern "{intent_id}:{kind}:{index}" for stable referencing. 

31""" 

32 

33from __future__ import annotations 

34 

35import uuid 

36from datetime import UTC, datetime 

37from typing import Annotated, Any 

38 

39from fastapi import APIRouter, Depends, HTTPException, status 

40 

41from ..auth import Identity, resolve_identity 

42from ..db import db 

43from ..entity_normalizer import NormalizationError, normalize_entity_uri 

44from ..fact_visibility import PROJECTED_GARDEN_JOIN, caller_read_scope, visible_facts_where 

45from ..hlc import node_hlc 

46from ..models.facts import FactValue 

47from ..models.intents import ( 

48 Constraint, 

49 DeferenceRule, 

50 EscalationPolicy, 

51 HandoffArtifact, 

52 HandoffPayload, 

53 IntentEnvelopeRecord, 

54 IntentEnvelopeRequest, 

55 Preference, 

56) 

57 

58router = APIRouter(prefix="/v1/intents", tags=["intents"]) 

59 

60# Relations that identify a fact row as the root of an IntentEnvelope. 

61_ROOT_RELATION = "intent:goal" 

62 

63 

64# --------------------------------------------------------------------------- 

65# Helpers 

66# --------------------------------------------------------------------------- 

67 

68 

69def _encode_v(vtype: str, v: Any) -> str: 

70 if vtype == "null": 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true

71 return "null" 

72 if vtype == "boolean": 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true

73 return "true" if v else "false" 

74 return str(v) 

75 

76 

77def _insert( 

78 conn: Any, 

79 entity: str, 

80 relation: str, 

81 vtype: str, 

82 vraw: Any, 

83 source: str, 

84 scope: str, 

85 valid_until: str | None, 

86 now: str, 

87 tenant_id: str, 

88) -> str: 

89 fact_id = str(uuid.uuid4()) 

90 hlc = node_hlc.tick() 

91 conn.execute( 

92 """INSERT INTO facts 

93 (id, entity, relation, value_type, value_v, source, timestamp, 

94 valid_until, confidence, scope, hlc, received_from, tenant_id) 

95 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", 

96 ( 

97 fact_id, 

98 entity, 

99 relation, 

100 vtype, 

101 _encode_v(vtype, vraw), 

102 source, 

103 now, 

104 valid_until, 

105 1.0, 

106 scope, 

107 hlc, 

108 None, 

109 tenant_id, 

110 ), 

111 ) 

112 return fact_id 

113 

114 

115def _decompose( 

116 conn: Any, 

117 intent_id: str, 

118 req: IntentEnvelopeRequest, 

119 now: str, 

120 tenant_id: str, 

121) -> list[str]: 

122 """Write all atomic facts for an IntentEnvelope; return list of fact IDs.""" 

123 src = req.from_uri 

124 scope = req.scope 

125 exp = req.expires_at 

126 ids: list[str] = [] 

127 

128 def ins(entity: str, relation: str, vtype: str, vraw: Any) -> None: 

129 ids.append(_insert(conn, entity, relation, vtype, vraw, src, scope, exp, now, tenant_id)) 

130 

131 # Core facts on intent_id 

132 ins(intent_id, "intent:from", "ref", req.from_uri) 

133 ins(intent_id, "intent:goal", "text", req.goal) 

134 for to_uri in req.to: 

135 ins(intent_id, "intent:to", "ref", to_uri) 

136 

137 # Escalation 

138 if req.escalation: 

139 esc = req.escalation 

140 ins(intent_id, "intent:escalation", "string", esc.priority) 

141 ins(intent_id, "intent:escalate_to", "ref", esc.escalate_to) 

142 ins(intent_id, "intent:escalation:channel", "string", esc.channel) 

143 ins( 

144 intent_id, 

145 "intent:escalation:context", 

146 "string", 

147 "true" if esc.include_context else "false", 

148 ) 

149 

150 # Handoff 

151 if req.handoff: 

152 h = req.handoff 

153 # Emit intent:handoff_to pointing at first `to` for adapter compat 

154 if req.to: 154 ↛ 156line 154 didn't jump to line 156 because the condition on line 154 was always true

155 ins(intent_id, "intent:handoff_to", "ref", req.to[0]) 

156 ins(intent_id, "intent:handoff_summary", "text", h.summary) 

157 for ref_uri in h.fact_refs: 

158 ins(intent_id, "intent:context_ref", "ref", ref_uri) 

159 if h.continuation: 159 ↛ 161line 159 didn't jump to line 161 because the condition on line 159 was always true

160 ins(intent_id, "intent:continuation", "text", h.continuation) 

161 for i, artifact in enumerate(h.artifacts): 

162 art_id = f"{intent_id}:artifact:{i}" 

163 ins(art_id, "intent:artifact:name", "string", artifact.name) 

164 ins(art_id, "intent:artifact:ref", "ref", artifact.ref) 

165 ins(intent_id, "intent:artifact", "ref", art_id) 

166 

167 # Constraints 

168 for i, c in enumerate(req.constraint): 

169 sub = f"{intent_id}:constraint:{i}" 

170 ins(sub, "intent:constraint:kind", "string", c.kind) 

171 ins(sub, "intent:constraint:limit", c.limit.type, c.limit.v) 

172 if c.unit is not None: 172 ↛ 174line 172 didn't jump to line 174 because the condition on line 172 was always true

173 ins(sub, "intent:constraint:unit", "string", c.unit) 

174 ins(intent_id, "intent:constraint", "ref", sub) 

175 

176 # Preferences 

177 for i, p in enumerate(req.preference): 

178 sub = f"{intent_id}:preference:{i}" 

179 ins(sub, "intent:preference:kind", "string", p.kind) 

180 ins(sub, "intent:preference:value", p.value.type, p.value.v) 

181 ins(sub, "intent:preference:weight", "number", p.weight) 

182 ins(intent_id, "intent:preference", "ref", sub) 

183 

184 # Deferences 

185 for i, d in enumerate(req.deference): 

186 sub = f"{intent_id}:deference:{i}" 

187 ins(sub, "intent:deference:condition", "text", d.condition) 

188 ins(sub, "intent:deference:defer_to", "ref", d.defer_to) 

189 if d.timeout_s is not None: 189 ↛ 191line 189 didn't jump to line 191 because the condition on line 189 was always true

190 ins(sub, "intent:deference:timeout_s", "number", d.timeout_s) 

191 ins(intent_id, "intent:deference", "ref", sub) 

192 

193 return ids 

194 

195 

196def _index_root_rows(root: list[Any]) -> tuple[dict[str, list[str]], str | None, str | None]: 

197 """Group root rows by relation; surface earliest timestamp + last valid_until.""" 

198 by_rel: dict[str, list[str]] = {} 

199 created_at: str | None = None 

200 expires_at: str | None = None 

201 for row in root: 

202 by_rel.setdefault(row["relation"], []).append(row["value_v"]) 

203 if created_at is None or row["timestamp"] < created_at: 

204 created_at = row["timestamp"] 

205 if row["valid_until"] is not None: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true

206 expires_at = row["valid_until"] 

207 return by_rel, created_at, expires_at 

208 

209 

210def _build_escalation(by_rel: dict[str, list[str]]) -> EscalationPolicy | None: 

211 if "intent:escalation" not in by_rel: 

212 return None 

213 return EscalationPolicy( 

214 escalate_to=(by_rel.get("intent:escalate_to") or [""])[0], 

215 channel=(by_rel.get("intent:escalation:channel") or ["stigmem"])[0], 

216 priority=by_rel["intent:escalation"][0], 

217 include_context=(by_rel.get("intent:escalation:context") or ["true"])[0] == "true", 

218 ) 

219 

220 

221def _build_handoff( 

222 by_rel: dict[str, list[str]], 

223 rows_by_entity: dict[str, list[Any]], 

224) -> HandoffPayload | None: 

225 if "intent:handoff_summary" not in by_rel: 225 ↛ 227line 225 didn't jump to line 227 because the condition on line 225 was always true

226 return None 

227 artifacts: list[HandoffArtifact] = [] 

228 for art_id in by_rel.get("intent:artifact", []): 

229 art_by_rel = {r["relation"]: r["value_v"] for r in rows_by_entity.get(art_id, [])} 

230 artifacts.append( 

231 HandoffArtifact( 

232 name=art_by_rel.get("intent:artifact:name", ""), 

233 ref=art_by_rel.get("intent:artifact:ref", ""), 

234 ) 

235 ) 

236 cont = by_rel.get("intent:continuation") 

237 return HandoffPayload( 

238 summary=by_rel["intent:handoff_summary"][0], 

239 fact_refs=by_rel.get("intent:context_ref", []), 

240 continuation=cont[0] if cont else None, 

241 artifacts=artifacts, 

242 ) 

243 

244 

245def _build_constraints( 

246 by_rel: dict[str, list[str]], 

247 rows_by_entity: dict[str, list[Any]], 

248) -> list[Constraint]: 

249 out: list[Constraint] = [] 

250 for sub_id in by_rel.get("intent:constraint", []): 250 ↛ 251line 250 didn't jump to line 251 because the loop on line 250 never started

251 sub_rel = {r["relation"]: r for r in rows_by_entity.get(sub_id, [])} 

252 kind_row = sub_rel.get("intent:constraint:kind") 

253 limit_row = sub_rel.get("intent:constraint:limit") 

254 if kind_row and limit_row: 

255 unit_row = sub_rel.get("intent:constraint:unit") 

256 out.append( 

257 Constraint( 

258 kind=kind_row["value_v"], 

259 limit=FactValue(type=limit_row["value_type"], v=limit_row["value_v"]), 

260 unit=unit_row["value_v"] if unit_row else None, 

261 ) 

262 ) 

263 return out 

264 

265 

266def _build_preferences( 

267 by_rel: dict[str, list[str]], 

268 rows_by_entity: dict[str, list[Any]], 

269) -> list[Preference]: 

270 out: list[Preference] = [] 

271 for sub_id in by_rel.get("intent:preference", []): 271 ↛ 272line 271 didn't jump to line 272 because the loop on line 271 never started

272 sub_rel = {r["relation"]: r for r in rows_by_entity.get(sub_id, [])} 

273 kind_row = sub_rel.get("intent:preference:kind") 

274 val_row = sub_rel.get("intent:preference:value") 

275 if kind_row and val_row: 

276 wt_row = sub_rel.get("intent:preference:weight") 

277 out.append( 

278 Preference( 

279 kind=kind_row["value_v"], 

280 value=FactValue(type=val_row["value_type"], v=val_row["value_v"]), 

281 weight=float(wt_row["value_v"]) if wt_row else 1.0, 

282 ) 

283 ) 

284 return out 

285 

286 

287def _build_deferences( 

288 by_rel: dict[str, list[str]], 

289 rows_by_entity: dict[str, list[Any]], 

290) -> list[DeferenceRule]: 

291 out: list[DeferenceRule] = [] 

292 for sub_id in by_rel.get("intent:deference", []): 292 ↛ 293line 292 didn't jump to line 293 because the loop on line 292 never started

293 sub_rel = {r["relation"]: r for r in rows_by_entity.get(sub_id, [])} 

294 cond_row = sub_rel.get("intent:deference:condition") 

295 defer_row = sub_rel.get("intent:deference:defer_to") 

296 if cond_row and defer_row: 

297 timeout_row = sub_rel.get("intent:deference:timeout_s") 

298 out.append( 

299 DeferenceRule( 

300 condition=cond_row["value_v"], 

301 defer_to=defer_row["value_v"], 

302 timeout_s=int(float(timeout_row["value_v"])) if timeout_row else None, 

303 ) 

304 ) 

305 return out 

306 

307 

308def _reconstruct( 

309 intent_id: str, rows_by_entity: dict[str, list[Any]] 

310) -> IntentEnvelopeRecord | None: 

311 """Rebuild an IntentEnvelopeRecord from raw DB rows grouped by entity.""" 

312 root = rows_by_entity.get(intent_id, []) 

313 if not root: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

314 return None 

315 

316 by_rel, created_at, expires_at = _index_root_rows(root) 

317 if _ROOT_RELATION not in by_rel: 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true

318 return None 

319 

320 return IntentEnvelopeRecord( 

321 id=intent_id, 

322 **{"from": (by_rel.get("intent:from") or [""])[0]}, 

323 to=by_rel.get("intent:to", []), 

324 goal=by_rel[_ROOT_RELATION][0], 

325 scope=root[0]["scope"], 

326 created_at=created_at or datetime.now(UTC).isoformat(), 

327 expires_at=expires_at, 

328 constraint=_build_constraints(by_rel, rows_by_entity), 

329 preference=_build_preferences(by_rel, rows_by_entity), 

330 deference=_build_deferences(by_rel, rows_by_entity), 

331 escalation=_build_escalation(by_rel), 

332 handoff=_build_handoff(by_rel, rows_by_entity), 

333 fact_ids=[], # not returned on GET; fact_ids are a POST-only receipt 

334 ) 

335 

336 

337# --------------------------------------------------------------------------- 

338# Routes 

339# --------------------------------------------------------------------------- 

340 

341 

342@router.post( 

343 "", 

344 response_model=IntentEnvelopeRecord, 

345 response_model_by_alias=True, 

346 status_code=status.HTTP_201_CREATED, 

347) 

348def submit_intent( 

349 req: IntentEnvelopeRequest, 

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

351) -> IntentEnvelopeRecord: 

352 """Submit an IntentEnvelope (Spec-X8-Intent-Envelope). 

353 

354 Validates the envelope, normalizes URIs, decomposes it into atomic facts, 

355 and returns a receipt with the generated intent ID and all written fact IDs. 

356 Idempotent when the caller supplies a stable ``id``: if that intent_id already 

357 has a ``intent:goal`` fact in the fabric, returns 409 Conflict. 

358 """ 

359 if not identity.can_write(): 

360 raise HTTPException( 

361 status_code=status.HTTP_403_FORBIDDEN, detail="write permission required" 

362 ) 

363 

364 # Normalize URIs 

365 try: 

366 from_uri = normalize_entity_uri(req.from_uri) 

367 except NormalizationError as exc: 

368 raise HTTPException( 

369 status_code=status.HTTP_400_BAD_REQUEST, 

370 detail=f"invalid_entity_uri: from — {exc}", 

371 ) from exc 

372 

373 normalized_to: list[str] = [] 

374 for i, t in enumerate(req.to): 

375 try: 

376 normalized_to.append(normalize_entity_uri(t)) 

377 except NormalizationError as exc: 

378 raise HTTPException( 

379 status_code=status.HTTP_400_BAD_REQUEST, 

380 detail=f"invalid_entity_uri: to[{i}] — {exc}", 

381 ) from exc 

382 

383 # Resolve intent_id 

384 intent_id = req.id if req.id else f"intent:{uuid.uuid4()}" 

385 

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

387 

388 with db() as conn: 

389 # Idempotency check: reject if intent_id already exists in this tenant 

390 # (tenant-scoped so it is not a cross-tenant existence oracle). 

391 existing = conn.execute( 

392 "SELECT id FROM facts WHERE entity=? AND relation=? AND tenant_id=? LIMIT 1", 

393 (intent_id, _ROOT_RELATION, identity.tenant_id), 

394 ).fetchone() 

395 if existing: 

396 raise HTTPException( 

397 status_code=status.HTTP_409_CONFLICT, 

398 detail=( 

399 f"intent {intent_id!r} already exists; supply a new id " 

400 "or omit for auto-generation" 

401 ), 

402 ) 

403 

404 # Build a normalised copy for decomposition 

405 normalised_req = req.model_copy(update={"from_uri": from_uri, "to": normalized_to}) 

406 fact_ids = _decompose(conn, intent_id, normalised_req, now, identity.tenant_id) 

407 

408 return IntentEnvelopeRecord( 

409 id=intent_id, 

410 **{"from": from_uri}, 

411 to=normalized_to, 

412 goal=req.goal, 

413 scope=req.scope, 

414 created_at=now, 

415 expires_at=req.expires_at, 

416 constraint=req.constraint, 

417 preference=req.preference, 

418 deference=req.deference, 

419 escalation=req.escalation, 

420 handoff=req.handoff, 

421 fact_ids=fact_ids, 

422 ) 

423 

424 

425@router.get( 

426 "/{intent_id:path}", 

427 response_model=IntentEnvelopeRecord, 

428 response_model_by_alias=True, 

429) 

430def get_intent( 

431 intent_id: str, 

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

433) -> IntentEnvelopeRecord: 

434 """Retrieve an IntentEnvelope by ID, reconstructed from its reified facts. 

435 

436 Covered by Spec-X8-Intent-Envelope. 

437 """ 

438 if not identity.can_read(): 438 ↛ 439line 438 didn't jump to line 439 because the condition on line 438 was never true

439 raise HTTPException( 

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

441 ) 

442 

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

444 # Escape LIKE metacharacters so a caller-supplied intent_id cannot inject 

445 # wildcards into the prefix scan (audit intents sibling of F-10). 

446 safe_prefix = intent_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") 

447 prefix = f"{safe_prefix}:%" 

448 

449 # Restrict to facts the caller may read — own tenant + visible garden (audit 

450 # intents cross-tenant/garden sibling). visible_facts_where adds the 

451 # tenant + projected-garden predicate; PROJECTED_GARDEN_JOIN supplies fgm. 

452 read_scope = caller_read_scope(identity) 

453 scope_sql, scope_params = visible_facts_where(read_scope) 

454 sql = ( 

455 f"SELECT f.* FROM facts f {PROJECTED_GARDEN_JOIN}" # noqa: S608 # nosec B608 

456 " WHERE (f.entity = ? OR f.entity LIKE ? ESCAPE '\\')" 

457 " AND f.confidence > 0.0" 

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

459 f" {scope_sql}" 

460 " ORDER BY f.entity, f.relation" 

461 ) 

462 

463 with db() as conn: 

464 rows = conn.execute(sql, [intent_id, prefix, now, *scope_params]).fetchall() 

465 

466 if not rows: 

467 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="intent not found") 

468 

469 rows_by_entity: dict[str, list[Any]] = {} 

470 for row in rows: 

471 rows_by_entity.setdefault(row["entity"], []).append(row) 

472 

473 record = _reconstruct(intent_id, rows_by_entity) 

474 if record is None: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true

475 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="intent not found") 

476 

477 return record