Skip to content

Commit 0d38583

Browse files
author
Arnold Cartagena
committed
feat: add contracts view backend endpoints + SSE event
- POST /api/v1/bundles/evaluate — playground endpoint for testing contracts in dashboard (dashboard auth only, never called by agents) - GET /api/v1/stats/contracts — per-contract coverage stats with time-window filtering (since/until) - GET /api/v1/deployments — list deployments with env filter + limit - SSE bundle_uploaded event fired on bundle upload - Amend CLAUDE.md Principle #2 to document evaluate exception - 32 new tests (12 evaluate, 3 stats, 4 deployments, 1 SSE, 10 adversarial security, 2 tenant isolation) - 140/140 total tests pass, 0 regressions
1 parent bf6126d commit 0d38583

File tree

17 files changed

+1131
-10
lines changed

17 files changed

+1131
-10
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Svelte 5 runes are too new — LLMs still confuse Svelte 3/4/5 syntax and produc
6262
## Non-Negotiable Principles
6363

6464
1. **edictum core works without the server.** The library is standalone. Server is an optional enhancement. Never introduce a server dependency into the core library.
65-
2. **All governance runs in the agent process.** The server NEVER evaluates contracts. Zero latency on tool calls. Server stores events, manages approvals, pushes contract updates.
65+
2. **All governance runs in the agent process.** The server NEVER evaluates contracts in production. Zero latency on tool calls. Server stores events, manages approvals, pushes contract updates. **Exception:** `POST /api/v1/bundles/evaluate` is a development-time playground endpoint for testing contracts in the dashboard. It is never called by agents. Production evaluation remains agent-side only.
6666
3. **Fail closed.** Server unreachable → errors propagate → deny. Never fail open.
6767
4. **Single Docker image.** FastAPI serves the SPA at `/dashboard`, API at `/api/v1/*`, marketing/landing at `/`. One deployment.
6868
5. **Local-first auth.** `EDICTUM_AUTH_PROVIDER=local` is the default. No external auth dependency required.

src/edictum_server/main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
approvals,
2323
auth,
2424
bundles,
25+
deployments,
26+
evaluate,
2527
events,
2628
health,
2729
keys,
@@ -217,6 +219,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
217219
app.include_router(auth.router)
218220
app.include_router(keys.router)
219221
app.include_router(bundles.router)
222+
app.include_router(evaluate.router)
223+
app.include_router(deployments.router)
220224
app.include_router(stream.router)
221225
app.include_router(events.router)
222226
app.include_router(sessions.router)

src/edictum_server/push/manager.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"approval_created",
1515
"approval_decided",
1616
"approval_timeout",
17+
"bundle_uploaded",
1718
"contract_update",
1819
})
1920

src/edictum_server/routes/bundles.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ async def upload(
5353
body: BundleUploadRequest,
5454
auth: AuthContext = Depends(require_dashboard_auth),
5555
db: AsyncSession = Depends(get_db),
56+
push: PushManager = Depends(get_push_manager),
5657
) -> BundleResponse:
5758
"""Upload a new contract bundle (dashboard-authenticated users)."""
5859
try:
@@ -68,6 +69,13 @@ async def upload(
6869
except ValueError as exc:
6970
raise HTTPException(status_code=422, detail=str(exc)) from exc
7071

72+
push.push_to_dashboard(auth.tenant_id, {
73+
"type": "bundle_uploaded",
74+
"version": bundle.version,
75+
"revision_hash": bundle.revision_hash,
76+
"uploaded_by": auth.user_id or "unknown",
77+
})
78+
7179
return _bundle_to_response(bundle)
7280

7381

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Deployment listing endpoint -- ``GET /api/v1/deployments``."""
2+
3+
from __future__ import annotations
4+
5+
from fastapi import APIRouter, Depends, Query
6+
from sqlalchemy import select
7+
from sqlalchemy.ext.asyncio import AsyncSession
8+
9+
from edictum_server.auth.dependencies import AuthContext, require_dashboard_auth
10+
from edictum_server.db.engine import get_db
11+
from edictum_server.db.models import Deployment
12+
from edictum_server.schemas.bundles import DeploymentResponse
13+
14+
router = APIRouter(prefix="/api/v1/deployments", tags=["deployments"])
15+
16+
17+
@router.get("", response_model=list[DeploymentResponse], summary="List deployments")
18+
async def list_deployments(
19+
env: str | None = Query(default=None, description="Filter by environment"),
20+
limit: int = Query(default=50, ge=1, le=200, description="Max results"),
21+
auth: AuthContext = Depends(require_dashboard_auth),
22+
db: AsyncSession = Depends(get_db),
23+
) -> list[DeploymentResponse]:
24+
"""List deployments for the tenant, newest first."""
25+
stmt = (
26+
select(Deployment)
27+
.where(Deployment.tenant_id == auth.tenant_id)
28+
.order_by(Deployment.created_at.desc())
29+
.limit(limit)
30+
)
31+
if env is not None:
32+
stmt = stmt.where(Deployment.env == env)
33+
34+
result = await db.execute(stmt)
35+
return [DeploymentResponse.model_validate(d) for d in result.scalars().all()]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Evaluate (playground) endpoint — stateless contract evaluation."""
2+
3+
from __future__ import annotations
4+
5+
from fastapi import APIRouter, Depends, HTTPException
6+
7+
from edictum_server.auth.dependencies import (
8+
AuthContext,
9+
require_dashboard_auth,
10+
)
11+
from edictum_server.schemas.evaluate import EvaluateRequest, EvaluateResponse
12+
from edictum_server.services.evaluate_service import evaluate_contracts
13+
14+
router = APIRouter(prefix="/api/v1/bundles", tags=["evaluate"])
15+
16+
17+
@router.post("/evaluate", response_model=EvaluateResponse)
18+
async def evaluate(
19+
body: EvaluateRequest,
20+
auth: AuthContext = Depends(require_dashboard_auth),
21+
) -> EvaluateResponse:
22+
"""Evaluate a tool call against YAML contracts (dashboard playground).
23+
24+
This is a development-time endpoint for testing contracts in the dashboard.
25+
It is never called by agents during production execution.
26+
"""
27+
try:
28+
return evaluate_contracts(
29+
yaml_content=body.yaml_content,
30+
tool_name=body.tool_name,
31+
tool_args=body.tool_args,
32+
environment=body.environment,
33+
principal_input=body.principal,
34+
)
35+
except ValueError as exc:
36+
raise HTTPException(status_code=422, detail=str(exc)) from exc

src/edictum_server/routes/stats.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
"""Dashboard statistics endpoint -- ``GET /api/v1/stats/overview``."""
1+
"""Dashboard statistics endpoints."""
22

33
from __future__ import annotations
44

5-
from fastapi import APIRouter, Depends
5+
from datetime import UTC, datetime, timedelta
6+
7+
from fastapi import APIRouter, Depends, Query
68
from sqlalchemy.ext.asyncio import AsyncSession
79

8-
from edictum_server.auth.dependencies import AuthContext, get_current_tenant
10+
from edictum_server.auth.dependencies import AuthContext, get_current_tenant, require_dashboard_auth
911
from edictum_server.db.engine import get_db
10-
from edictum_server.schemas.stats import StatsOverviewResponse
11-
from edictum_server.services.stats_service import get_overview
12+
from edictum_server.schemas.stats import ContractStatsResponse, StatsOverviewResponse
13+
from edictum_server.services.stats_service import get_contract_stats, get_overview
1214

1315
router = APIRouter(prefix="/api/v1/stats", tags=["stats"])
1416

@@ -24,3 +26,21 @@ async def stats_overview(
2426
) -> StatsOverviewResponse:
2527
"""Return aggregate stats for the dashboard home view."""
2628
return await get_overview(db, auth.tenant_id)
29+
30+
31+
@router.get(
32+
"/contracts",
33+
response_model=ContractStatsResponse,
34+
summary="Get per-contract evaluation statistics",
35+
)
36+
async def contract_stats(
37+
since: datetime | None = Query(default=None, description="Start of time window (ISO 8601)"),
38+
until: datetime | None = Query(default=None, description="End of time window (ISO 8601)"),
39+
auth: AuthContext = Depends(require_dashboard_auth),
40+
db: AsyncSession = Depends(get_db),
41+
) -> ContractStatsResponse:
42+
"""Return per-contract aggregated evaluation stats."""
43+
now = datetime.now(UTC)
44+
effective_until = until or now
45+
effective_since = since or (now - timedelta(hours=24))
46+
return await get_contract_stats(db, auth.tenant_id, effective_since, effective_until)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Request and response schemas for the evaluate (playground) endpoint."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from pydantic import BaseModel, Field
8+
9+
10+
class PrincipalInput(BaseModel):
11+
"""Optional principal context for evaluation."""
12+
13+
user_id: str | None = None
14+
role: str | None = None
15+
claims: dict[str, Any] | None = None
16+
17+
18+
class EvaluateRequest(BaseModel):
19+
"""Request body for contract evaluation playground."""
20+
21+
yaml_content: str = Field(..., description="YAML contract bundle to evaluate against")
22+
tool_name: str = Field(..., description="Tool name to simulate")
23+
tool_args: dict[str, Any] = Field(default_factory=dict, description="Tool arguments")
24+
environment: str = Field(default="production", description="Environment context")
25+
agent_id: str = Field(default="test-agent", description="Simulated agent ID")
26+
principal: PrincipalInput | None = Field(
27+
default=None, description="Optional principal identity"
28+
)
29+
30+
31+
class ContractEvaluation(BaseModel):
32+
"""Result of evaluating a single contract."""
33+
34+
id: str
35+
type: str
36+
matched: bool
37+
effect: str | None = None
38+
message: str | None = None
39+
observed: bool = False
40+
tags: list[str] = []
41+
42+
43+
class EvaluateResponse(BaseModel):
44+
"""Response from the evaluate playground endpoint."""
45+
46+
verdict: str
47+
mode: str
48+
contracts_evaluated: list[ContractEvaluation]
49+
deciding_contract: str | None = None
50+
policy_version: str
51+
evaluation_time_ms: float

src/edictum_server/schemas/stats.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Schemas for the stats overview endpoint."""
1+
"""Schemas for the stats endpoints."""
22

33
from __future__ import annotations
44

@@ -19,3 +19,22 @@ class StatsOverviewResponse(BaseModel):
1919
contracts_triggered_24h: int = Field(
2020
0, description="Distinct contracts triggered in the last 24 hours"
2121
)
22+
23+
24+
class ContractCoverage(BaseModel):
25+
"""Per-contract aggregated stats."""
26+
27+
decision_name: str = Field(..., description="Name of the contract")
28+
total_evaluations: int = Field(..., description="Total evaluation events")
29+
total_denials: int = Field(..., description="Events with verdict=denied")
30+
total_warnings: int = Field(..., description="Events with verdict=call_would_deny")
31+
last_triggered: str | None = Field(None, description="ISO timestamp of last event")
32+
33+
34+
class ContractStatsResponse(BaseModel):
35+
"""Contract-level statistics for a time window."""
36+
37+
coverage: list[ContractCoverage] = Field(default_factory=list)
38+
total_events: int = Field(..., description="Total events in the time window")
39+
period_start: str = Field(..., description="ISO timestamp of window start")
40+
period_end: str = Field(..., description="ISO timestamp of window end")
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Stateless contract evaluation service (development-time playground)."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import time
7+
from typing import Any
8+
9+
from edictum import Edictum, EvaluationResult, Principal
10+
11+
from edictum_server.schemas.evaluate import (
12+
ContractEvaluation,
13+
EvaluateResponse,
14+
PrincipalInput,
15+
)
16+
17+
18+
def _build_principal(inp: PrincipalInput | None) -> Principal | None:
19+
"""Convert API input to an edictum Principal, or None."""
20+
if inp is None:
21+
return None
22+
return Principal(
23+
user_id=inp.user_id,
24+
role=inp.role,
25+
claims=inp.claims or {},
26+
)
27+
28+
29+
def _map_result(result: EvaluationResult, mode: str, yaml_hash: str) -> EvaluateResponse:
30+
"""Map an edictum EvaluationResult to the API response schema."""
31+
contracts = [
32+
ContractEvaluation(
33+
id=cr.contract_id,
34+
type=cr.contract_type,
35+
matched=cr.passed,
36+
effect=cr.effect,
37+
message=cr.message,
38+
observed=cr.observed,
39+
tags=list(cr.tags),
40+
)
41+
for cr in result.contracts
42+
]
43+
44+
# The deciding contract is the first failing non-observed contract
45+
deciding: str | None = None
46+
for cr in result.contracts:
47+
if not cr.passed and not cr.observed:
48+
deciding = cr.contract_id
49+
break
50+
51+
return EvaluateResponse(
52+
verdict=result.verdict,
53+
mode=mode,
54+
contracts_evaluated=contracts,
55+
deciding_contract=deciding,
56+
policy_version=yaml_hash[:12],
57+
evaluation_time_ms=0.0, # overwritten by caller
58+
)
59+
60+
61+
def evaluate_contracts(
62+
*,
63+
yaml_content: str,
64+
tool_name: str,
65+
tool_args: dict[str, Any],
66+
environment: str = "production",
67+
principal_input: PrincipalInput | None = None,
68+
) -> EvaluateResponse:
69+
"""Evaluate a tool call against YAML contracts.
70+
71+
This is a stateless, synchronous operation for the dashboard playground.
72+
It never persists data or touches the database.
73+
74+
Args:
75+
yaml_content: Raw YAML contract bundle.
76+
tool_name: Tool name to evaluate.
77+
tool_args: Arguments for the tool call.
78+
environment: Environment context (default "production").
79+
principal_input: Optional principal identity.
80+
81+
Returns:
82+
EvaluateResponse with verdict, matched contracts, timing.
83+
84+
Raises:
85+
ValueError: If the YAML is invalid or cannot be parsed as contracts.
86+
"""
87+
principal = _build_principal(principal_input)
88+
yaml_hash = hashlib.sha256(yaml_content.encode("utf-8")).hexdigest()
89+
90+
start = time.monotonic()
91+
92+
try:
93+
edictum_instance = Edictum.from_yaml_string(
94+
yaml_content,
95+
environment=environment,
96+
)
97+
except Exception as exc:
98+
raise ValueError(f"Invalid contract YAML: {exc}") from exc
99+
100+
result: EvaluationResult = edictum_instance.evaluate(
101+
tool_name,
102+
tool_args,
103+
principal=principal,
104+
)
105+
106+
elapsed_ms = (time.monotonic() - start) * 1000
107+
108+
# Determine mode from the edictum instance
109+
mode = getattr(edictum_instance, "mode", "enforce") or "enforce"
110+
111+
response = _map_result(result, mode, yaml_hash)
112+
response.evaluation_time_ms = round(elapsed_ms, 2)
113+
return response

0 commit comments

Comments
 (0)