beadhub 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. beadhub/__init__.py +12 -0
  2. beadhub/api.py +260 -0
  3. beadhub/auth.py +101 -0
  4. beadhub/aweb_context.py +65 -0
  5. beadhub/aweb_introspection.py +70 -0
  6. beadhub/beads_sync.py +514 -0
  7. beadhub/cli.py +330 -0
  8. beadhub/config.py +65 -0
  9. beadhub/db.py +129 -0
  10. beadhub/defaults/invariants/01-tracking-bdh-only.md +11 -0
  11. beadhub/defaults/invariants/02-communication-mail-first.md +36 -0
  12. beadhub/defaults/invariants/03-communication-chat.md +60 -0
  13. beadhub/defaults/invariants/04-identity-no-impersonation.md +17 -0
  14. beadhub/defaults/invariants/05-collaborate.md +12 -0
  15. beadhub/defaults/roles/backend.md +55 -0
  16. beadhub/defaults/roles/coordinator.md +44 -0
  17. beadhub/defaults/roles/frontend.md +77 -0
  18. beadhub/defaults/roles/implementer.md +73 -0
  19. beadhub/defaults/roles/reviewer.md +56 -0
  20. beadhub/defaults/roles/startup-expert.md +93 -0
  21. beadhub/defaults.py +262 -0
  22. beadhub/events.py +704 -0
  23. beadhub/internal_auth.py +121 -0
  24. beadhub/jsonl.py +68 -0
  25. beadhub/logging.py +62 -0
  26. beadhub/migrations/beads/001_initial.sql +70 -0
  27. beadhub/migrations/beads/002_search_indexes.sql +20 -0
  28. beadhub/migrations/server/001_initial.sql +279 -0
  29. beadhub/names.py +33 -0
  30. beadhub/notifications.py +275 -0
  31. beadhub/pagination.py +125 -0
  32. beadhub/presence.py +495 -0
  33. beadhub/rate_limit.py +152 -0
  34. beadhub/redis_client.py +11 -0
  35. beadhub/roles.py +35 -0
  36. beadhub/routes/__init__.py +1 -0
  37. beadhub/routes/agents.py +303 -0
  38. beadhub/routes/bdh.py +655 -0
  39. beadhub/routes/beads.py +778 -0
  40. beadhub/routes/claims.py +141 -0
  41. beadhub/routes/escalations.py +471 -0
  42. beadhub/routes/init.py +348 -0
  43. beadhub/routes/mcp.py +338 -0
  44. beadhub/routes/policies.py +833 -0
  45. beadhub/routes/repos.py +538 -0
  46. beadhub/routes/status.py +568 -0
  47. beadhub/routes/subscriptions.py +362 -0
  48. beadhub/routes/workspaces.py +1642 -0
  49. beadhub/workspace_config.py +202 -0
  50. beadhub-0.1.0.dist-info/METADATA +254 -0
  51. beadhub-0.1.0.dist-info/RECORD +54 -0
  52. beadhub-0.1.0.dist-info/WHEEL +4 -0
  53. beadhub-0.1.0.dist-info/entry_points.txt +2 -0
  54. beadhub-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,833 @@
1
+ """BeadHub project policy endpoints.
2
+
3
+ Provides server-backed project policies with versioned bundles containing:
4
+ - Global invariants (guidance for all workspaces)
5
+ - Role playbooks (role-specific guidance)
6
+ - Adapters (tool-specific templates)
7
+
8
+ Security:
9
+ - All reads are project-scoped via `get_project_from_auth`
10
+ - Writes (POST /v1/policies, POST /v1/policies/{id}/activate) require admin permission
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import logging
18
+ from datetime import datetime
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
22
+ from pgdbm import AsyncDatabaseManager
23
+ from pydantic import BaseModel, Field
24
+
25
+ from beadhub.auth import validate_workspace_id
26
+ from beadhub.aweb_introspection import get_identity_from_auth, get_project_from_auth
27
+
28
+ from ..db import DatabaseInfra, get_db_infra
29
+ from ..defaults import get_default_bundle
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ router = APIRouter(prefix="/v1/policies", tags=["policies"])
34
+
35
+
36
+ # Default policy bundle for new projects.
37
+ # Loaded from markdown files in beadhub/defaults/ for easier editing.
38
+ # This is a backward-compatible alias for code that imports DEFAULT_POLICY_BUNDLE.
39
+ DEFAULT_POLICY_BUNDLE: Dict[str, Any] = get_default_bundle()
40
+
41
+
42
+ class PolicyBundle(BaseModel):
43
+ """Policy bundle containing invariants, roles, and adapters."""
44
+
45
+ invariants: List[Dict[str, Any]] = Field(default_factory=list)
46
+ roles: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
47
+ adapters: Dict[str, Any] = Field(default_factory=dict)
48
+
49
+
50
+ class PolicyVersion(BaseModel):
51
+ """A versioned policy record."""
52
+
53
+ policy_id: str
54
+ project_id: str
55
+ version: int
56
+ bundle: PolicyBundle
57
+ created_by_workspace_id: Optional[str]
58
+ created_at: datetime
59
+ updated_at: datetime
60
+
61
+
62
+ async def get_active_policy(
63
+ db: AsyncDatabaseManager,
64
+ project_id: str,
65
+ *,
66
+ bootstrap_if_missing: bool = True,
67
+ ) -> Optional[PolicyVersion]:
68
+ """
69
+ Get the active policy for a project.
70
+
71
+ If no policy exists and bootstrap_if_missing is True, creates a default
72
+ policy and sets it as active.
73
+
74
+ Args:
75
+ db: The server database manager
76
+ project_id: The project UUID
77
+ bootstrap_if_missing: If True, create default policy when none exists
78
+
79
+ Returns:
80
+ The active PolicyVersion, or None if no policy and bootstrap disabled
81
+ """
82
+ # Check if project has an active policy
83
+ result = await db.fetch_one(
84
+ """
85
+ SELECT pp.policy_id, pp.project_id, pp.version, pp.bundle_json,
86
+ pp.created_by_workspace_id, pp.created_at, pp.updated_at
87
+ FROM {{tables.projects}} p
88
+ JOIN {{tables.project_policies}} pp ON pp.policy_id = p.active_policy_id
89
+ WHERE p.id = $1
90
+ """,
91
+ project_id,
92
+ )
93
+
94
+ if result:
95
+ # Parse bundle_json - may be dict or string depending on asyncpg codec
96
+ bundle_data = result["bundle_json"]
97
+ if isinstance(bundle_data, str):
98
+ bundle_data = json.loads(bundle_data)
99
+
100
+ return PolicyVersion(
101
+ policy_id=str(result["policy_id"]),
102
+ project_id=str(result["project_id"]),
103
+ version=result["version"],
104
+ bundle=PolicyBundle(**bundle_data),
105
+ created_by_workspace_id=(
106
+ str(result["created_by_workspace_id"])
107
+ if result["created_by_workspace_id"]
108
+ else None
109
+ ),
110
+ created_at=result["created_at"],
111
+ updated_at=result["updated_at"],
112
+ )
113
+
114
+ if not bootstrap_if_missing:
115
+ return None
116
+
117
+ # Bootstrap default policy
118
+ logger.info("Bootstrapping default policy for project %s", project_id)
119
+ policy = await create_policy_version(
120
+ db,
121
+ project_id=project_id,
122
+ base_policy_id=None,
123
+ bundle=get_default_bundle(),
124
+ created_by_workspace_id=None,
125
+ )
126
+ await activate_policy(db, project_id=project_id, policy_id=policy.policy_id)
127
+ return policy
128
+
129
+
130
+ async def create_policy_version(
131
+ db: AsyncDatabaseManager,
132
+ *,
133
+ project_id: str,
134
+ base_policy_id: Optional[str],
135
+ bundle: Dict[str, Any],
136
+ created_by_workspace_id: Optional[str],
137
+ ) -> PolicyVersion:
138
+ """
139
+ Create a new policy version for a project.
140
+
141
+ Version numbers are allocated atomically to prevent races. Each new version
142
+ is one greater than the current maximum for the project.
143
+
144
+ Args:
145
+ db: The server database manager
146
+ project_id: The project UUID
147
+ base_policy_id: The policy this version is based on (for audit trail)
148
+ bundle: The policy bundle (invariants, roles, adapters)
149
+ created_by_workspace_id: The workspace creating this version (optional)
150
+
151
+ Returns:
152
+ The created PolicyVersion
153
+
154
+ Raises:
155
+ HTTPException 404: If project doesn't exist
156
+ """
157
+ # Verify project exists and is not soft-deleted
158
+ project = await db.fetch_one(
159
+ "SELECT id FROM {{tables.projects}} WHERE id = $1 AND deleted_at IS NULL",
160
+ project_id,
161
+ )
162
+ if not project:
163
+ raise HTTPException(status_code=404, detail="Project not found")
164
+
165
+ # Allocate version atomically by locking the project row, then computing max+1
166
+ # The unique constraint on (project_id, version) provides final safety
167
+ result = await db.fetch_one(
168
+ """
169
+ WITH locked_project AS (
170
+ SELECT id FROM {{tables.projects}}
171
+ WHERE id = $1 AND deleted_at IS NULL
172
+ FOR UPDATE
173
+ ),
174
+ next_version AS (
175
+ SELECT COALESCE(MAX(version), 0) + 1 AS version
176
+ FROM {{tables.project_policies}}
177
+ WHERE project_id = $1
178
+ )
179
+ INSERT INTO {{tables.project_policies}} (project_id, version, bundle_json, created_by_workspace_id)
180
+ SELECT $1, nv.version, $2::jsonb, $3
181
+ FROM next_version nv, locked_project lp
182
+ RETURNING policy_id, project_id, version, bundle_json, created_by_workspace_id, created_at, updated_at
183
+ """,
184
+ project_id,
185
+ json.dumps(bundle),
186
+ created_by_workspace_id,
187
+ )
188
+
189
+ logger.info(
190
+ "Created policy version %d for project %s (policy_id=%s)",
191
+ result["version"],
192
+ project_id,
193
+ result["policy_id"],
194
+ )
195
+
196
+ # Parse bundle_json - may be dict or string depending on asyncpg codec
197
+ bundle_data = result["bundle_json"]
198
+ if isinstance(bundle_data, str):
199
+ bundle_data = json.loads(bundle_data)
200
+
201
+ return PolicyVersion(
202
+ policy_id=str(result["policy_id"]),
203
+ project_id=str(result["project_id"]),
204
+ version=result["version"],
205
+ bundle=PolicyBundle(**bundle_data),
206
+ created_by_workspace_id=(
207
+ str(result["created_by_workspace_id"]) if result["created_by_workspace_id"] else None
208
+ ),
209
+ created_at=result["created_at"],
210
+ updated_at=result["updated_at"],
211
+ )
212
+
213
+
214
+ async def activate_policy(
215
+ db: AsyncDatabaseManager,
216
+ *,
217
+ project_id: str,
218
+ policy_id: str,
219
+ ) -> bool:
220
+ """
221
+ Set the active policy for a project.
222
+
223
+ Args:
224
+ db: The server database manager
225
+ project_id: The project UUID
226
+ policy_id: The policy UUID to activate
227
+
228
+ Returns:
229
+ True if activation succeeded
230
+
231
+ Raises:
232
+ HTTPException 404: If project or policy doesn't exist
233
+ HTTPException 400: If policy doesn't belong to the project
234
+ """
235
+ # Verify policy exists and belongs to project
236
+ policy = await db.fetch_one(
237
+ """
238
+ SELECT policy_id, project_id FROM {{tables.project_policies}}
239
+ WHERE policy_id = $1
240
+ """,
241
+ policy_id,
242
+ )
243
+ if not policy:
244
+ raise HTTPException(status_code=404, detail="Policy not found")
245
+
246
+ if str(policy["project_id"]) != project_id:
247
+ raise HTTPException(
248
+ status_code=400,
249
+ detail="Policy does not belong to this project",
250
+ )
251
+
252
+ # Update project's active policy
253
+ result = await db.fetch_one(
254
+ """
255
+ UPDATE {{tables.projects}}
256
+ SET active_policy_id = $2
257
+ WHERE id = $1
258
+ RETURNING id
259
+ """,
260
+ project_id,
261
+ policy_id,
262
+ )
263
+
264
+ if not result:
265
+ raise HTTPException(status_code=404, detail="Project not found")
266
+
267
+ logger.info("Activated policy %s for project %s", policy_id, project_id)
268
+ return True
269
+
270
+
271
+ def _generate_etag(policy_id: str, updated_at: datetime) -> str:
272
+ """Generate ETag from policy_id and updated_at timestamp."""
273
+ content = f"{policy_id}:{updated_at.isoformat()}"
274
+ return f'"{hashlib.sha256(content.encode()).hexdigest()[:16]}"'
275
+
276
+
277
+ class Invariant(BaseModel):
278
+ """A policy invariant."""
279
+
280
+ id: str
281
+ title: str
282
+ body_md: str
283
+
284
+
285
+ class RolePlaybook(BaseModel):
286
+ """A role playbook."""
287
+
288
+ title: str
289
+ playbook_md: str
290
+
291
+
292
+ class SelectedRole(BaseModel):
293
+ """Selected role information."""
294
+
295
+ role: str
296
+ title: str
297
+ playbook_md: str
298
+
299
+
300
+ class ActivePolicyResponse(BaseModel):
301
+ """Response for GET /v1/policies/active."""
302
+
303
+ policy_id: str
304
+ project_id: str
305
+ version: int
306
+ updated_at: datetime
307
+ invariants: List[Invariant]
308
+ roles: Dict[str, RolePlaybook]
309
+ selected_role: Optional[SelectedRole] = None
310
+ adapters: Dict[str, Any] = Field(default_factory=dict)
311
+
312
+
313
+ @router.get("/active")
314
+ async def get_active_policy_endpoint(
315
+ request: Request,
316
+ response: Response,
317
+ role: Optional[str] = Query(
318
+ None,
319
+ description="Role to select. If provided, includes selected_role in response.",
320
+ ),
321
+ only_selected: bool = Query(
322
+ False,
323
+ description="If true, return only invariants + selected role (requires role param).",
324
+ ),
325
+ if_none_match: Optional[str] = Header(None, alias="If-None-Match"),
326
+ db: DatabaseInfra = Depends(get_db_infra),
327
+ ) -> ActivePolicyResponse:
328
+ """
329
+ Get the active policy for the project.
330
+
331
+ Returns the active policy bundle including invariants, role playbooks, and adapters.
332
+ If no policy exists, bootstraps a default policy and returns it.
333
+
334
+ Supports conditional requests via ETag/If-None-Match for efficient caching.
335
+
336
+ Requires an authenticated project context.
337
+ """
338
+ project_id = await get_project_from_auth(request, db)
339
+ server_db = db.get_manager("server")
340
+
341
+ # Get or bootstrap active policy
342
+ policy = await get_active_policy(server_db, project_id)
343
+ if not policy:
344
+ # This shouldn't happen since get_active_policy bootstraps by default
345
+ raise HTTPException(status_code=404, detail="Project not found")
346
+
347
+ # Generate ETag
348
+ etag = _generate_etag(policy.policy_id, policy.updated_at)
349
+ response.headers["ETag"] = etag
350
+
351
+ # Check If-None-Match for conditional GET
352
+ if if_none_match and if_none_match == etag:
353
+ return Response(status_code=304, headers={"ETag": etag})
354
+
355
+ # Validate role selection
356
+ available_roles = list(policy.bundle.roles.keys())
357
+ selected_role_data = None
358
+
359
+ if role:
360
+ if role not in policy.bundle.roles:
361
+ raise HTTPException(
362
+ status_code=400,
363
+ detail=f"Role '{role}' not found. Available roles: {available_roles}",
364
+ )
365
+ role_info = policy.bundle.roles[role]
366
+ selected_role_data = SelectedRole(
367
+ role=role,
368
+ title=role_info.get("title", role),
369
+ playbook_md=role_info.get("playbook_md", ""),
370
+ )
371
+
372
+ if only_selected and not role:
373
+ raise HTTPException(
374
+ status_code=400,
375
+ detail="only_selected=true requires a role parameter",
376
+ )
377
+
378
+ # Build response
379
+ invariants = [
380
+ Invariant(
381
+ id=inv.get("id", ""),
382
+ title=inv.get("title", ""),
383
+ body_md=inv.get("body_md", ""),
384
+ )
385
+ for inv in policy.bundle.invariants
386
+ ]
387
+
388
+ if only_selected:
389
+ # Return only invariants + selected role
390
+ # Type narrowing: role is guaranteed non-None here (validated at line 370-374)
391
+ assert role is not None
392
+ roles = {role: RolePlaybook(**policy.bundle.roles[role])}
393
+ else:
394
+ roles = {
395
+ k: RolePlaybook(
396
+ title=v.get("title", k),
397
+ playbook_md=v.get("playbook_md", ""),
398
+ )
399
+ for k, v in policy.bundle.roles.items()
400
+ }
401
+
402
+ return ActivePolicyResponse(
403
+ policy_id=policy.policy_id,
404
+ project_id=policy.project_id,
405
+ version=policy.version,
406
+ updated_at=policy.updated_at,
407
+ invariants=invariants,
408
+ roles=roles,
409
+ selected_role=selected_role_data,
410
+ adapters=policy.bundle.adapters,
411
+ )
412
+
413
+
414
+ # Admin endpoints for policy management
415
+
416
+
417
+ class CreatePolicyRequest(BaseModel):
418
+ """Request body for POST /v1/policies."""
419
+
420
+ bundle: PolicyBundle = Field(
421
+ ...,
422
+ description="Policy bundle containing invariants, roles, and adapters.",
423
+ )
424
+ base_policy_id: Optional[str] = Field(
425
+ None,
426
+ description="Optional: policy this version is based on (for audit trail).",
427
+ )
428
+ created_by_workspace_id: Optional[str] = Field(
429
+ None,
430
+ description="Optional: workspace_id of the creator (for audit trail).",
431
+ )
432
+
433
+
434
+ class CreatePolicyResponse(BaseModel):
435
+ """Response for POST /v1/policies."""
436
+
437
+ policy_id: str
438
+ project_id: str
439
+ version: int
440
+ created: bool = True
441
+
442
+
443
+ class ActivatePolicyResponse(BaseModel):
444
+ """Response for POST /v1/policies/{id}/activate."""
445
+
446
+ activated: bool
447
+ active_policy_id: str
448
+
449
+
450
+ class ResetPolicyResponse(BaseModel):
451
+ """Response for POST /v1/policies/reset."""
452
+
453
+ reset: bool
454
+ active_policy_id: str
455
+ version: int
456
+
457
+
458
+ class PolicyHistoryItem(BaseModel):
459
+ """A policy version in the history list."""
460
+
461
+ policy_id: str
462
+ version: int
463
+ created_at: datetime
464
+ created_by_workspace_id: Optional[str]
465
+ is_active: bool
466
+
467
+
468
+ class PolicyHistoryResponse(BaseModel):
469
+ """Response for GET /v1/policies/history."""
470
+
471
+ policies: List[PolicyHistoryItem]
472
+
473
+
474
+ @router.get("/history")
475
+ async def list_policy_history(
476
+ request: Request,
477
+ limit: int = Query(20, ge=1, le=100, description="Max number of versions to return"),
478
+ db: DatabaseInfra = Depends(get_db_infra),
479
+ ) -> PolicyHistoryResponse:
480
+ """
481
+ List policy version history for the project.
482
+
483
+ Returns policy versions ordered by version descending (newest first).
484
+ Each entry indicates whether it's the currently active policy.
485
+
486
+ Requires an authenticated project context.
487
+ """
488
+ project_id = await get_project_from_auth(request, db)
489
+ server_db = db.get_manager("server")
490
+
491
+ # Ensure the project has a default policy (v1) so newly-registered projects
492
+ # have a consistent policy history surface.
493
+ await get_active_policy(server_db, project_id, bootstrap_if_missing=True)
494
+
495
+ # Get active policy ID for this project
496
+ active_result = await server_db.fetch_one(
497
+ "SELECT active_policy_id FROM {{tables.projects}} WHERE id = $1 AND deleted_at IS NULL",
498
+ project_id,
499
+ )
500
+ active_policy_id = (
501
+ str(active_result["active_policy_id"])
502
+ if active_result and active_result["active_policy_id"]
503
+ else None
504
+ )
505
+
506
+ # Fetch policy versions
507
+ rows = await server_db.fetch_all(
508
+ """
509
+ SELECT policy_id, version, created_at, created_by_workspace_id
510
+ FROM {{tables.project_policies}}
511
+ WHERE project_id = $1
512
+ ORDER BY version DESC
513
+ LIMIT $2
514
+ """,
515
+ project_id,
516
+ limit,
517
+ )
518
+
519
+ policies = [
520
+ PolicyHistoryItem(
521
+ policy_id=str(row["policy_id"]),
522
+ version=row["version"],
523
+ created_at=row["created_at"],
524
+ created_by_workspace_id=(
525
+ str(row["created_by_workspace_id"]) if row["created_by_workspace_id"] else None
526
+ ),
527
+ is_active=(str(row["policy_id"]) == active_policy_id),
528
+ )
529
+ for row in rows
530
+ ]
531
+
532
+ return PolicyHistoryResponse(policies=policies)
533
+
534
+
535
+ @router.post("")
536
+ async def create_policy_endpoint(
537
+ request: Request,
538
+ payload: CreatePolicyRequest,
539
+ db: DatabaseInfra = Depends(get_db_infra),
540
+ ) -> CreatePolicyResponse:
541
+ """
542
+ Create a new policy version for the project.
543
+
544
+ Requires an authenticated project context.
545
+
546
+ The new policy is NOT automatically activated. Use POST /v1/policies/{id}/activate
547
+ to set it as the active policy.
548
+ """
549
+ identity = await get_identity_from_auth(request, db)
550
+ project_id = identity.project_id
551
+ server_db = db.get_manager("server")
552
+
553
+ # Convert Pydantic model to dict for storage
554
+ bundle_dict = payload.bundle.model_dump()
555
+
556
+ created_by_workspace_id: Optional[str] = identity.agent_id if identity.agent_id else None
557
+ if payload.created_by_workspace_id:
558
+ try:
559
+ created_by_workspace_id = validate_workspace_id(payload.created_by_workspace_id)
560
+ except ValueError as e:
561
+ raise HTTPException(status_code=422, detail=str(e))
562
+ if identity.agent_id is not None and identity.agent_id != created_by_workspace_id:
563
+ raise HTTPException(
564
+ status_code=403,
565
+ detail="created_by_workspace_id does not match API key identity",
566
+ )
567
+
568
+ workspace = await server_db.fetch_one(
569
+ """
570
+ SELECT workspace_id
571
+ FROM {{tables.workspaces}}
572
+ WHERE workspace_id = $1 AND project_id = $2 AND deleted_at IS NULL
573
+ """,
574
+ created_by_workspace_id,
575
+ project_id,
576
+ )
577
+ if not workspace:
578
+ raise HTTPException(
579
+ status_code=403,
580
+ detail="Workspace not found or does not belong to your project",
581
+ )
582
+
583
+ # Create the policy version
584
+ policy = await create_policy_version(
585
+ server_db,
586
+ project_id=project_id,
587
+ base_policy_id=payload.base_policy_id,
588
+ bundle=bundle_dict,
589
+ created_by_workspace_id=created_by_workspace_id,
590
+ )
591
+
592
+ # Add audit log entry
593
+ await server_db.execute(
594
+ """
595
+ INSERT INTO {{tables.audit_log}} (project_id, workspace_id, event_type, details)
596
+ VALUES ($1, $2, $3, $4::jsonb)
597
+ """,
598
+ project_id,
599
+ created_by_workspace_id,
600
+ "policy_created",
601
+ json.dumps(
602
+ {
603
+ "project_id": project_id,
604
+ "policy_id": policy.policy_id,
605
+ "version": policy.version,
606
+ "base_policy_id": payload.base_policy_id,
607
+ }
608
+ ),
609
+ )
610
+
611
+ logger.info(
612
+ "Policy created via API: project=%s policy_id=%s version=%d",
613
+ project_id,
614
+ policy.policy_id,
615
+ policy.version,
616
+ )
617
+
618
+ return CreatePolicyResponse(
619
+ policy_id=policy.policy_id,
620
+ project_id=policy.project_id,
621
+ version=policy.version,
622
+ )
623
+
624
+
625
+ @router.get("/{policy_id}")
626
+ async def get_policy_by_id_endpoint(
627
+ request: Request,
628
+ response: Response,
629
+ policy_id: str,
630
+ db: DatabaseInfra = Depends(get_db_infra),
631
+ ) -> ActivePolicyResponse:
632
+ """
633
+ Get a specific policy version by ID.
634
+
635
+ Used for previewing previous policy versions without activating them.
636
+ Requires an authenticated project context.
637
+ Returns 404 if policy doesn't exist or belongs to a different project.
638
+ """
639
+ project_id = await get_project_from_auth(request, db)
640
+ server_db = db.get_manager("server")
641
+
642
+ # Fetch the policy, scoped to the project
643
+ result = await server_db.fetch_one(
644
+ """
645
+ SELECT pp.policy_id, pp.project_id, pp.version, pp.bundle_json,
646
+ pp.created_by_workspace_id, pp.created_at, pp.updated_at
647
+ FROM {{tables.project_policies}} pp
648
+ WHERE pp.policy_id = $1 AND pp.project_id = $2
649
+ """,
650
+ policy_id,
651
+ project_id,
652
+ )
653
+
654
+ if not result:
655
+ raise HTTPException(
656
+ status_code=404,
657
+ detail="Policy not found or does not belong to this project",
658
+ )
659
+
660
+ # Parse bundle_json
661
+ bundle_data = result["bundle_json"]
662
+ if isinstance(bundle_data, str):
663
+ bundle_data = json.loads(bundle_data)
664
+
665
+ bundle = PolicyBundle(**bundle_data)
666
+
667
+ # Build response (same shape as GET /active)
668
+ invariants = [
669
+ Invariant(
670
+ id=inv.get("id", ""),
671
+ title=inv.get("title", ""),
672
+ body_md=inv.get("body_md", ""),
673
+ )
674
+ for inv in bundle.invariants
675
+ ]
676
+
677
+ roles = {
678
+ k: RolePlaybook(
679
+ title=v.get("title", k),
680
+ playbook_md=v.get("playbook_md", ""),
681
+ )
682
+ for k, v in bundle.roles.items()
683
+ }
684
+
685
+ return ActivePolicyResponse(
686
+ policy_id=str(result["policy_id"]),
687
+ project_id=str(result["project_id"]),
688
+ version=result["version"],
689
+ updated_at=result["updated_at"],
690
+ invariants=invariants,
691
+ roles=roles,
692
+ selected_role=None,
693
+ adapters=bundle.adapters,
694
+ )
695
+
696
+
697
+ @router.post("/{policy_id}/activate")
698
+ async def activate_policy_endpoint(
699
+ request: Request,
700
+ policy_id: str,
701
+ db: DatabaseInfra = Depends(get_db_infra),
702
+ ) -> ActivatePolicyResponse:
703
+ """
704
+ Set a policy as the active policy for the project.
705
+
706
+ Requires an authenticated project context.
707
+ """
708
+ project_id = await get_project_from_auth(request, db)
709
+ server_db = db.get_manager("server")
710
+
711
+ # Get current active policy for audit
712
+ current_active = await server_db.fetch_one(
713
+ "SELECT active_policy_id FROM {{tables.projects}} WHERE id = $1 AND deleted_at IS NULL",
714
+ project_id,
715
+ )
716
+ previous_policy_id = (
717
+ str(current_active["active_policy_id"])
718
+ if current_active and current_active["active_policy_id"]
719
+ else None
720
+ )
721
+
722
+ # Activate the policy (validates ownership)
723
+ await activate_policy(
724
+ server_db,
725
+ project_id=project_id,
726
+ policy_id=policy_id,
727
+ )
728
+
729
+ # Add audit log entry
730
+ await server_db.execute(
731
+ """
732
+ INSERT INTO {{tables.audit_log}} (project_id, event_type, details)
733
+ VALUES ($1, $2, $3::jsonb)
734
+ """,
735
+ project_id,
736
+ "policy_activated",
737
+ json.dumps(
738
+ {
739
+ "project_id": project_id,
740
+ "policy_id": policy_id,
741
+ "previous_policy_id": previous_policy_id,
742
+ }
743
+ ),
744
+ )
745
+
746
+ logger.info(
747
+ "Policy activated via API: project=%s policy_id=%s (was: %s)",
748
+ project_id,
749
+ policy_id,
750
+ previous_policy_id,
751
+ )
752
+
753
+ return ActivatePolicyResponse(
754
+ activated=True,
755
+ active_policy_id=policy_id,
756
+ )
757
+
758
+
759
+ @router.post("/reset")
760
+ async def reset_policy_to_default_endpoint(
761
+ request: Request,
762
+ db: DatabaseInfra = Depends(get_db_infra),
763
+ ) -> ResetPolicyResponse:
764
+ """
765
+ Reset the project's policy to the current default bundle.
766
+
767
+ Reloads default invariants and roles from markdown files on disk, creates
768
+ a new policy version, and activates it. Prior versions are preserved.
769
+
770
+ Requires an authenticated project context.
771
+ """
772
+ project_id = await get_project_from_auth(request, db)
773
+ server_db = db.get_manager("server")
774
+
775
+ current_active = await server_db.fetch_one(
776
+ "SELECT active_policy_id FROM {{tables.projects}} WHERE id = $1 AND deleted_at IS NULL",
777
+ project_id,
778
+ )
779
+ previous_policy_id = (
780
+ str(current_active["active_policy_id"])
781
+ if current_active and current_active["active_policy_id"]
782
+ else None
783
+ )
784
+
785
+ # Reload defaults from disk (atomic, protected by lock)
786
+ try:
787
+ fresh_bundle = get_default_bundle(force_reload=True)
788
+ except Exception as e:
789
+ logger.error("Failed to reload default bundle: %s", e, exc_info=True)
790
+ raise HTTPException(
791
+ status_code=500,
792
+ detail=f"Failed to reload default policy bundle: {e}",
793
+ )
794
+
795
+ policy = await create_policy_version(
796
+ server_db,
797
+ project_id=project_id,
798
+ base_policy_id=previous_policy_id,
799
+ bundle=fresh_bundle,
800
+ created_by_workspace_id=None,
801
+ )
802
+ await activate_policy(server_db, project_id=project_id, policy_id=policy.policy_id)
803
+
804
+ await server_db.execute(
805
+ """
806
+ INSERT INTO {{tables.audit_log}} (project_id, event_type, details)
807
+ VALUES ($1, $2, $3::jsonb)
808
+ """,
809
+ project_id,
810
+ "policy_reset_to_default",
811
+ json.dumps(
812
+ {
813
+ "project_id": project_id,
814
+ "policy_id": policy.policy_id,
815
+ "version": policy.version,
816
+ "previous_policy_id": previous_policy_id,
817
+ }
818
+ ),
819
+ )
820
+
821
+ logger.info(
822
+ "Policy reset to default via API: project=%s policy_id=%s version=%d (was: %s)",
823
+ project_id,
824
+ policy.policy_id,
825
+ policy.version,
826
+ previous_policy_id,
827
+ )
828
+
829
+ return ResetPolicyResponse(
830
+ reset=True,
831
+ active_policy_id=policy.policy_id,
832
+ version=policy.version,
833
+ )