Coverage for node / src / stigmem_node / routes / gardens.py: 82%

186 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-25 01:49 +0000

1"""Garden CRUD + membership routes — spec §5.14–§5.18, §17.""" 

2 

3from __future__ import annotations 

4 

5import re 

6import uuid 

7from datetime import UTC, datetime 

8from typing import Annotated, Any 

9 

10from fastapi import APIRouter, Depends, HTTPException, status 

11 

12from ..audit_event import INSTRUCTION_PROMOTED, emit_instruction_event_if_applicable 

13from ..auth import Identity, resolve_identity 

14from ..billing import BillingEvent, get_hook_bus 

15from ..db import db 

16from ..garden_acl import ( 

17 get_garden_by_slug_or_id, 

18 get_member_role, 

19 is_node_admin, 

20 quarantine_garden_has_pending_facts, 

21 require_garden_admin, 

22 require_garden_read, 

23 require_quarantine_moderator_or_admin, 

24) 

25from ..lifecycle.immutability import ( 

26 set_fact_garden_membership, 

27 set_fact_quarantine_status, 

28 set_fact_validity_override, 

29) 

30from ..models.constants import VALID_SCOPES 

31from ..models.gardens import ( 

32 GardenCreateRequest, 

33 GardenMemberRecord, 

34 GardenMemberRequest, 

35 GardenMemberUpdateRequest, 

36 GardenRecord, 

37 QuarantinePromoteRequest, 

38 QuarantineRejectRequest, 

39) 

40from ..settings import settings 

41 

42router = APIRouter(prefix="/v1/gardens", tags=["gardens"]) 

43 

44_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,62}$") 

45 

46 

47def _build_garden_id_uri(slug: str) -> str: 

48 """Construct the canonical garden_id URI from a slug.""" 

49 from urllib.parse import urlparse 

50 

51 parsed = urlparse(settings.node_url) 

52 authority = parsed.netloc or parsed.path 

53 return f"stigmem://{authority}/garden/{slug}" 

54 

55 

56def _members_for_garden(garden_uuid: str) -> list[GardenMemberRecord]: 

57 with db() as conn: 

58 rows = conn.execute( 

59 "SELECT * FROM garden_members WHERE garden_id = ? ORDER BY added_at", 

60 (garden_uuid,), 

61 ).fetchall() 

62 return [ 

63 GardenMemberRecord( 

64 entity_uri=r["entity_uri"], 

65 role=r["role"], 

66 added_by=r["added_by"], 

67 added_at=r["added_at"], 

68 ) 

69 for r in rows 

70 ] 

71 

72 

73def _row_to_garden_record(row: dict[str, Any], include_members: bool = True) -> GardenRecord: 

74 members = _members_for_garden(row["id"]) if include_members else [] 

75 return GardenRecord( 

76 id=row["id"], 

77 garden_id=_build_garden_id_uri(row["slug"]), 

78 slug=row["slug"], 

79 name=row["name"], 

80 scope=row["scope"], 

81 description=row.get("description"), 

82 created_by=row["created_by"], 

83 created_at=row["created_at"], 

84 members=members, 

85 quarantine=bool(row.get("quarantine", 0)), 

86 ) 

87 

88 

89@router.post("", response_model=GardenRecord, status_code=status.HTTP_201_CREATED) 

90def create_garden( 

91 req: GardenCreateRequest, 

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

93) -> GardenRecord: 

94 """Create a new garden (Spec-02-Scopes-and-ACL). Creator is auto-added as admin.""" 

95 if not identity.can_write(): 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true

96 raise HTTPException( 

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

98 ) 

99 

100 slug = req.slug.lower().strip() 

101 if not _SLUG_RE.match(slug): 

102 raise HTTPException( 

103 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, 

104 detail="slug must match ^[a-z0-9][a-z0-9\\-]{0,62}$", 

105 ) 

106 if req.scope not in VALID_SCOPES: 106 ↛ 107line 106 didn't jump to line 107 because the condition on line 106 was never true

107 raise HTTPException( 

108 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, 

109 detail=f"scope must be one of {VALID_SCOPES}", 

110 ) 

111 

112 garden_uuid = str(uuid.uuid4()) 

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

114 

115 with db() as conn: 

116 existing = conn.execute( 

117 "SELECT id FROM gardens WHERE slug = ? AND tenant_id = ?", 

118 (slug, identity.tenant_id), 

119 ).fetchone() 

120 if existing: 

121 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="slug already exists") 

122 

123 conn.execute( 

124 """INSERT INTO gardens 

125 (id, slug, name, scope, description, created_by, created_at, 

126 tenant_id, quarantine) 

127 VALUES (?,?,?,?,?,?,?,?,?)""", 

128 ( 

129 garden_uuid, 

130 slug, 

131 req.name, 

132 req.scope, 

133 req.description, 

134 identity.entity_uri, 

135 now, 

136 identity.tenant_id, 

137 int(req.quarantine), 

138 ), 

139 ) 

140 conn.execute( 

141 """INSERT INTO garden_members (garden_id, entity_uri, role, added_by, added_at) 

142 VALUES (?,?,?,?,?)""", 

143 (garden_uuid, identity.entity_uri, "admin", identity.entity_uri, now), 

144 ) 

145 row = conn.execute("SELECT * FROM gardens WHERE id = ?", (garden_uuid,)).fetchone() 

146 

147 get_hook_bus().emit( 

148 BillingEvent( 

149 event_type="garden_created", 

150 tenant_id=identity.tenant_id, 

151 entity_uri=identity.entity_uri, 

152 garden_id=_build_garden_id_uri(slug), 

153 ) 

154 ) 

155 

156 return _row_to_garden_record(dict(row)) 

157 

158 

159@router.get("", response_model=list[GardenRecord]) 

160def list_gardens( 

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

162) -> list[GardenRecord]: 

163 """List accessible gardens (Spec-02-Scopes-and-ACL). 

164 

165 Node admins see all; others see only their gardens. 

166 """ 

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

168 raise HTTPException( 

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

170 ) 

171 

172 with db() as conn: 

173 if is_node_admin(identity): 173 ↛ 179line 173 didn't jump to line 179 because the condition on line 173 was always true

174 rows = conn.execute( 

175 "SELECT * FROM gardens WHERE tenant_id = ? ORDER BY created_at", 

176 (identity.tenant_id,), 

177 ).fetchall() 

178 else: 

179 rows = conn.execute( 

180 """SELECT g.* FROM gardens g 

181 JOIN garden_members m ON g.id = m.garden_id 

182 WHERE m.entity_uri = ? AND g.tenant_id = ? 

183 ORDER BY g.created_at""", 

184 (identity.entity_uri, identity.tenant_id), 

185 ).fetchall() 

186 

187 return [_row_to_garden_record(dict(r), include_members=False) for r in rows] 

188 

189 

190@router.get("/{garden_slug_or_id}", response_model=GardenRecord) 

191def get_garden( 

192 garden_slug_or_id: str, 

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

194) -> GardenRecord: 

195 """Get a garden by slug or UUID (Spec-02-Scopes-and-ACL).""" 

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

197 raise HTTPException( 

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

199 ) 

200 

201 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id) 

202 if garden is None: 

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

204 

205 if not is_node_admin(identity): 

206 require_garden_read(garden, identity) 

207 

208 return _row_to_garden_record(garden) 

209 

210 

211@router.delete("/{garden_slug_or_id}", status_code=status.HTTP_204_NO_CONTENT) 

212def delete_garden( 

213 garden_slug_or_id: str, 

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

215) -> None: 

216 """Delete a garden (Spec-02-Scopes-and-ACL). 

217 

218 Requires garden admin role. Facts are orphaned, not deleted. 

219 Quarantine gardens with pending facts cannot be deleted 

220 (Spec-08-Quarantine-Garden; HTTP 409). 

221 """ 

222 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id) 

223 if garden is None: 

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

225 

226 require_garden_admin(garden, identity) 

227 

228 # Guard: quarantine garden cannot be deleted while it has pending facts (§19.5.2) 

229 if garden.get("quarantine") and quarantine_garden_has_pending_facts(garden["id"]): 

230 raise HTTPException( 

231 status_code=status.HTTP_409_CONFLICT, 

232 detail="quarantine_has_pending_facts", 

233 ) 

234 

235 with db() as conn: 

236 conn.execute("DELETE FROM gardens WHERE id = ?", (garden["id"],)) 

237 

238 

239# --------------------------------------------------------------------------- 

240# Membership routes (spec §5.18) 

241# --------------------------------------------------------------------------- 

242 

243 

244@router.get("/{garden_slug_or_id}/members", response_model=list[GardenMemberRecord]) 

245def list_members( 

246 garden_slug_or_id: str, 

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

248) -> list[GardenMemberRecord]: 

249 """List members of a garden (Spec-02-Scopes-and-ACL). Requires any member role.""" 

250 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id) 

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

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

253 require_garden_read(garden, identity) 

254 return _members_for_garden(garden["id"]) 

255 

256 

257@router.post( 

258 "/{garden_slug_or_id}/members", 

259 response_model=GardenMemberRecord, 

260 status_code=status.HTTP_201_CREATED, 

261) 

262def add_member( 

263 garden_slug_or_id: str, 

264 req: GardenMemberRequest, 

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

266) -> GardenMemberRecord: 

267 """Add a member to a garden (Spec-02-Scopes-and-ACL). Requires admin role.""" 

268 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id) 

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

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

271 

272 require_garden_admin(garden, identity) 

273 

274 existing_role = get_member_role(garden["id"], req.entity_uri) 

275 if existing_role is not None: 

276 raise HTTPException( 

277 status_code=status.HTTP_409_CONFLICT, 

278 detail=( 

279 f"entity is already a member with role '{existing_role}'; use PATCH to change role" 

280 ), 

281 ) 

282 

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

284 with db() as conn: 

285 conn.execute( 

286 """INSERT INTO garden_members (garden_id, entity_uri, role, added_by, added_at) 

287 VALUES (?,?,?,?,?)""", 

288 (garden["id"], req.entity_uri, req.role, identity.entity_uri, now), 

289 ) 

290 

291 return GardenMemberRecord( 

292 entity_uri=req.entity_uri, 

293 role=req.role, 

294 added_by=identity.entity_uri, 

295 added_at=now, 

296 ) 

297 

298 

299@router.patch("/{garden_slug_or_id}/members/{entity_uri:path}", response_model=GardenMemberRecord) 

300def update_member_role( 

301 garden_slug_or_id: str, 

302 entity_uri: str, 

303 req: GardenMemberUpdateRequest, 

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

305) -> GardenMemberRecord: 

306 """Change a member's role (Spec-02-Scopes-and-ACL). Requires admin.""" 

307 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id) 

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

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

310 

311 require_garden_admin(garden, identity) 

312 

313 existing_role = get_member_role(garden["id"], entity_uri) 

314 if existing_role is None: 314 ↛ 315line 314 didn't jump to line 315 because the condition on line 314 was never true

315 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member not found") 

316 

317 if existing_role == "admin" and req.role != "admin": 

318 _guard_last_admin(garden["id"], entity_uri) 

319 

320 with db() as conn: 

321 conn.execute( 

322 "UPDATE garden_members SET role = ? WHERE garden_id = ? AND entity_uri = ?", 

323 (req.role, garden["id"], entity_uri), 

324 ) 

325 row = conn.execute( 

326 "SELECT * FROM garden_members WHERE garden_id = ? AND entity_uri = ?", 

327 (garden["id"], entity_uri), 

328 ).fetchone() 

329 

330 return GardenMemberRecord( 

331 entity_uri=row["entity_uri"], 

332 role=row["role"], 

333 added_by=row["added_by"], 

334 added_at=row["added_at"], 

335 ) 

336 

337 

338@router.delete( 

339 "/{garden_slug_or_id}/members/{entity_uri:path}", 

340 status_code=status.HTTP_204_NO_CONTENT, 

341) 

342def remove_member( 

343 garden_slug_or_id: str, 

344 entity_uri: str, 

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

346) -> None: 

347 """Remove a member from a garden (Spec-02-Scopes-and-ACL). Cannot remove last admin.""" 

348 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id) 

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

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

351 

352 require_garden_admin(garden, identity) 

353 

354 existing_role = get_member_role(garden["id"], entity_uri) 

355 if existing_role is None: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true

356 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="member not found") 

357 

358 if existing_role == "admin": 

359 _guard_last_admin(garden["id"], entity_uri) 

360 

361 with db() as conn: 

362 conn.execute( 

363 "DELETE FROM garden_members WHERE garden_id = ? AND entity_uri = ?", 

364 (garden["id"], entity_uri), 

365 ) 

366 

367 

368def _guard_last_admin(garden_uuid: str, entity_uri: str) -> None: 

369 """Raise 403 if removing/demoting entity_uri would leave the garden with no admins.""" 

370 with db() as conn: 

371 admin_count: int = conn.execute( 

372 "SELECT COUNT(*) FROM garden_members WHERE garden_id = ? AND role = 'admin'", 

373 (garden_uuid,), 

374 ).fetchone()[0] 

375 if admin_count <= 1: 375 ↛ exitline 375 didn't return from function '_guard_last_admin' because the condition on line 375 was always true

376 raise HTTPException( 

377 status_code=status.HTTP_403_FORBIDDEN, 

378 detail="cannot remove or demote the last admin; promote another member first", 

379 ) 

380 

381 

382def _guard_last_elevated_role(garden_uuid: str, entity_uri: str) -> None: 

383 """For quarantine gardens: raise 409 if removal would leave no admin or moderator (§19.5.3).""" 

384 with db() as conn: 

385 elevated_count: int = conn.execute( 

386 """SELECT COUNT(*) FROM garden_members 

387 WHERE garden_id = ? AND role IN ('admin', 'quarantine:moderator')""", 

388 (garden_uuid,), 

389 ).fetchone()[0] 

390 if elevated_count <= 1: 

391 raise HTTPException( 

392 status_code=status.HTTP_409_CONFLICT, 

393 detail="quarantine garden must retain at least one admin or quarantine:moderator", 

394 ) 

395 

396 

397# --------------------------------------------------------------------------- 

398# Quarantine promote / reject (spec §5.25, §19.5.5) 

399# --------------------------------------------------------------------------- 

400 

401 

402@router.post("/{garden_slug_or_id}/promote", status_code=status.HTTP_200_OK) 

403def promote_fact( 

404 garden_slug_or_id: str, 

405 req: QuarantinePromoteRequest, 

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

407) -> dict[str, Any]: 

408 """Promote a quarantined fact to a target garden (Spec-08-Quarantine-Garden). 

409 

410 Requires quarantine:moderator or admin role. 

411 """ 

412 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id) 

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

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

415 

416 if not garden.get("quarantine"): 

417 raise HTTPException( 

418 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, 

419 detail="this endpoint is only valid for quarantine gardens", 

420 ) 

421 

422 require_quarantine_moderator_or_admin(garden, identity) 

423 

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

425 

426 with db() as conn: 

427 fact_row = conn.execute( 

428 """SELECT f.id, f.entity, f.relation, 

429 COALESCE(fqs.quarantine_status, f.quarantine_status) 

430 AS quarantine_status, 

431 COALESCE(fqs.quarantine_garden_id, f.quarantine_garden_id) 

432 AS quarantine_garden_id 

433 FROM facts f 

434 LEFT JOIN fact_quarantine_status fqs ON fqs.fact_id = f.id 

435 WHERE f.id = ?""", 

436 (req.fact_id,), 

437 ).fetchone() 

438 

439 if fact_row is None or fact_row["quarantine_garden_id"] != garden["id"]: 

440 raise HTTPException( 

441 status_code=status.HTTP_404_NOT_FOUND, 

442 detail="fact not found in quarantine garden", 

443 ) 

444 

445 if fact_row["quarantine_status"] != "pending": 

446 raise HTTPException( 

447 status_code=status.HTTP_409_CONFLICT, 

448 detail="fact_not_quarantine_pending", 

449 ) 

450 

451 # Resolve target garden UUID if provided as slug 

452 target_garden_db_id: str | None = None 

453 if req.target_garden_id: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true

454 tg = get_garden_by_slug_or_id(req.target_garden_id, tenant_id=identity.tenant_id) 

455 if tg is None: 

456 raise HTTPException( 

457 status_code=status.HTTP_404_NOT_FOUND, 

458 detail="target garden not found", 

459 ) 

460 target_garden_db_id = tg["id"] 

461 

462 set_fact_garden_membership( 

463 conn, 

464 fact_id=req.fact_id, 

465 garden_id=target_garden_db_id, 

466 updated_by=identity.entity_uri, 

467 ) 

468 set_fact_quarantine_status( 

469 conn, 

470 fact_id=req.fact_id, 

471 quarantine_garden_id=garden["id"], 

472 quarantine_status="promoted", 

473 quarantine_reason=req.reason or "promoted", 

474 quarantine_acted_by=identity.entity_uri, 

475 quarantine_acted_at=now, 

476 ) 

477 

478 # Audit log (§19.5.6) 

479 import uuid as _uuid 

480 

481 audit_id = str(_uuid.uuid4()) 

482 conn.execute( 

483 "INSERT INTO fact_audit_log" 

484 " (id, fact_id, event_type, entity_uri, oidc_sub, source," 

485 " attested_key_id, ts)" 

486 " VALUES (?,?,?,?,?,?,?,?)", 

487 ( 

488 audit_id, 

489 req.fact_id, 

490 "quarantine_promote", 

491 identity.entity_uri, 

492 identity.oidc_sub, 

493 identity.entity_uri, 

494 None, 

495 now, 

496 ), 

497 ) 

498 emit_instruction_event_if_applicable( 

499 INSTRUCTION_PROMOTED, 

500 fact_id=req.fact_id, 

501 fact_entity=fact_row["entity"], 

502 fact_relation=fact_row["relation"], 

503 actor_uri=identity.entity_uri, 

504 tenant_id=identity.tenant_id, 

505 oidc_sub=identity.oidc_sub, 

506 source=identity.entity_uri, 

507 detail={ 

508 "reason": req.reason or "promoted", 

509 "quarantine_garden_id": garden["id"], 

510 "target_garden_id": target_garden_db_id, 

511 }, 

512 conn=conn, 

513 ) 

514 

515 return { 

516 "fact_id": req.fact_id, 

517 "action": "promoted", 

518 "target_garden_id": target_garden_db_id, 

519 "acted_by": identity.entity_uri, 

520 "acted_at": now, 

521 } 

522 

523 

524@router.post("/{garden_slug_or_id}/reject", status_code=status.HTTP_200_OK) 

525def reject_fact( 

526 garden_slug_or_id: str, 

527 req: QuarantineRejectRequest, 

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

529) -> dict[str, Any]: 

530 """Reject a quarantined fact (Spec-08-Quarantine-Garden). 

531 

532 Sets confidence = 0.0 and quarantine_status = 'rejected'. 

533 Requires quarantine:moderator or admin role. 

534 """ 

535 garden = get_garden_by_slug_or_id(garden_slug_or_id, tenant_id=identity.tenant_id) 

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

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

538 

539 if not garden.get("quarantine"): 539 ↛ 540line 539 didn't jump to line 540 because the condition on line 539 was never true

540 raise HTTPException( 

541 status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, 

542 detail="this endpoint is only valid for quarantine gardens", 

543 ) 

544 

545 require_quarantine_moderator_or_admin(garden, identity) 

546 

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

548 

549 with db() as conn: 

550 fact_row = conn.execute( 

551 """SELECT f.id, 

552 COALESCE(fqs.quarantine_status, f.quarantine_status) 

553 AS quarantine_status, 

554 COALESCE(fqs.quarantine_garden_id, f.quarantine_garden_id) 

555 AS quarantine_garden_id 

556 FROM facts f 

557 LEFT JOIN fact_quarantine_status fqs ON fqs.fact_id = f.id 

558 WHERE f.id = ?""", 

559 (req.fact_id,), 

560 ).fetchone() 

561 

562 if fact_row is None or fact_row["quarantine_garden_id"] != garden["id"]: 562 ↛ 563line 562 didn't jump to line 563 because the condition on line 562 was never true

563 raise HTTPException( 

564 status_code=status.HTTP_404_NOT_FOUND, 

565 detail="fact not found in quarantine garden", 

566 ) 

567 

568 if fact_row["quarantine_status"] != "pending": 568 ↛ 569line 568 didn't jump to line 569 because the condition on line 568 was never true

569 raise HTTPException( 

570 status_code=status.HTTP_409_CONFLICT, 

571 detail="fact_not_quarantine_pending", 

572 ) 

573 

574 set_fact_validity_override( 

575 conn, 

576 fact_id=req.fact_id, 

577 confidence=0.0, 

578 reason=req.reason or "rejected", 

579 updated_by=identity.entity_uri, 

580 ) 

581 set_fact_quarantine_status( 

582 conn, 

583 fact_id=req.fact_id, 

584 quarantine_garden_id=garden["id"], 

585 quarantine_status="rejected", 

586 quarantine_reason=req.reason or "rejected", 

587 quarantine_acted_by=identity.entity_uri, 

588 quarantine_acted_at=now, 

589 ) 

590 

591 # Append-only retraction log (§24.2.1 c.3) 

592 conn.execute( 

593 "INSERT INTO fact_retractions" 

594 " (id, fact_id, retracted_at, retracted_by) VALUES (?,?,?,?)", 

595 (str(uuid.uuid4()), req.fact_id, now, identity.entity_uri), 

596 ) 

597 

598 # Audit log (§19.5.6) 

599 import uuid as _uuid 

600 

601 audit_id = str(_uuid.uuid4()) 

602 conn.execute( 

603 "INSERT INTO fact_audit_log" 

604 " (id, fact_id, event_type, entity_uri, oidc_sub, source," 

605 " attested_key_id, ts)" 

606 " VALUES (?,?,?,?,?,?,?,?)", 

607 ( 

608 audit_id, 

609 req.fact_id, 

610 "quarantine_reject", 

611 identity.entity_uri, 

612 identity.oidc_sub, 

613 identity.entity_uri, 

614 None, 

615 now, 

616 ), 

617 ) 

618 

619 return { 

620 "fact_id": req.fact_id, 

621 "action": "rejected", 

622 "acted_by": identity.entity_uri, 

623 "acted_at": now, 

624 }