From a622842d08c2f181d1e08aeef4bf969f729c28c6 Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:44:22 +0800 Subject: [PATCH 1/7] test(mvp): fail until sample policy fixtures exist (#81) Add red fixture tests and handoff stubs for #81/#76/#75. Ensure pytest resolves the backend package via pythonpath. Signed-off-by: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> --- backend/pyproject.toml | 1 + backend/tests/test_mvp_fixtures.py | 23 +++++++++++++ .../2026-07-24-mvp-sample-policies.md | 33 +++++++++++++++++++ docs/handoffs/2026-07-24-policy-versioning.md | 24 ++++++++++++++ .../handoffs/2026-07-24-verdict-log-schema.md | 23 +++++++++++++ docs/handoffs/README.md | 3 ++ 6 files changed, 107 insertions(+) create mode 100644 backend/tests/test_mvp_fixtures.py create mode 100644 docs/handoffs/2026-07-24-mvp-sample-policies.md create mode 100644 docs/handoffs/2026-07-24-policy-versioning.md create mode 100644 docs/handoffs/2026-07-24-verdict-log-schema.md diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4275dce..83d6a89 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -26,6 +26,7 @@ include = '\.pyi?$' [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] +pythonpath = ["."] [dependency-groups] dev = [ diff --git a/backend/tests/test_mvp_fixtures.py b/backend/tests/test_mvp_fixtures.py new file mode 100644 index 0000000..c6c3571 --- /dev/null +++ b/backend/tests/test_mvp_fixtures.py @@ -0,0 +1,23 @@ +"""MVP sample policy pack (#81) — fixtures must exist for E2E.""" + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +FIXTURES = REPO_ROOT / "docs" / "mvp" / "fixtures" + + +def test_fixtures_dir_exists(): + assert FIXTURES.is_dir(), f"missing fixtures dir: {FIXTURES}" + + +def test_at_least_one_markdown_policy(): + files = sorted(FIXTURES.glob("*.md")) + assert files, "expected ≥1 docs/mvp/fixtures/*.md policy" + + +def test_fixture_has_title_and_controls(): + files = sorted(FIXTURES.glob("*.md")) + assert files + text = files[0].read_text(encoding="utf-8") + assert text.lstrip().startswith("#"), "fixture needs an H1 title" + assert "## Controls" in text or "## controls" in text.lower() diff --git a/docs/handoffs/2026-07-24-mvp-sample-policies.md b/docs/handoffs/2026-07-24-mvp-sample-policies.md new file mode 100644 index 0000000..235e26b --- /dev/null +++ b/docs/handoffs/2026-07-24-mvp-sample-policies.md @@ -0,0 +1,33 @@ +# HANDOFF — MVP sample policy pack (#81) + +**Status:** In progress +**Branch:** `mvp/75-76-81-foundation` +**Issue(s):** #81 +**Last updated:** 2026-07-24 + +## Bottom line +Minimal Markdown policy fixtures for ingest/E2E — not a hackathon corpus. + +## Current state +Starting TDD: tests require fixtures under `docs/mvp/fixtures/`. + +## What shipped +| PR / commit | Delivers | +|-------------|---------| +| (pending) | | + +## Hook points +- `docs/mvp/fixtures/` +- `backend/tests/test_mvp_fixtures.py` + +## Open follow-ups +- [ ] Wire fixtures into #78 E2E runbook + +## How to verify +```bash +cd backend && uv run pytest tests/test_mvp_fixtures.py -v +``` + +## Done means +- [ ] ≥1 MD fixture with title + controls section +- [ ] Tests green diff --git a/docs/handoffs/2026-07-24-policy-versioning.md b/docs/handoffs/2026-07-24-policy-versioning.md new file mode 100644 index 0000000..94d83e0 --- /dev/null +++ b/docs/handoffs/2026-07-24-policy-versioning.md @@ -0,0 +1,24 @@ +# HANDOFF — Policy storage schema & versioning (#75) + +**Status:** In progress +**Branch:** `mvp/75-76-81-foundation` +**Issue(s):** #75 (legacy #10) +**Last updated:** 2026-07-24 + +## Bottom line +SQLModel Policy with version + content hash; ingest writes a version row; list/get API. + +## Hook points +- `backend/app/models/policy.py` +- `backend/app/api/policies.py` / ingest wiring +- `backend/tests/integration/test_policies.py` + +## How to verify +```bash +cd backend && uv run pytest tests/integration/test_policies.py -v +``` + +## Done means +- [ ] Policy model + table +- [ ] Ingest creates/updates version +- [ ] GET list/current works diff --git a/docs/handoffs/2026-07-24-verdict-log-schema.md b/docs/handoffs/2026-07-24-verdict-log-schema.md new file mode 100644 index 0000000..54a1aaf --- /dev/null +++ b/docs/handoffs/2026-07-24-verdict-log-schema.md @@ -0,0 +1,23 @@ +# HANDOFF — Structured verdict logging schema (#76) + +**Status:** In progress +**Branch:** `mvp/75-76-81-foundation` +**Issue(s):** #76 (legacy #4) +**Last updated:** 2026-07-24 + +## Bottom line +Canonical JSON verdict event shape for every scan/validate decision. Loki push is #77. + +## Hook points +- `backend/app/services/verdict_log.py` +- `backend/app/api/validate.py` +- `backend/tests/test_verdict_log.py` + +## How to verify +```bash +cd backend && uv run pytest tests/test_verdict_log.py tests/integration/test_observability.py -v +``` + +## Done means +- [ ] Required keys present on validate path +- [ ] Schema documented in this handoff + code constants diff --git a/docs/handoffs/README.md b/docs/handoffs/README.md index 7798269..f6fcb2f 100644 --- a/docs/handoffs/README.md +++ b/docs/handoffs/README.md @@ -38,3 +38,6 @@ Agents: use project skill **`feature-plan`** (`.agents/skills/feature-plan/` and |------|---------|----------------|--------| | 2026-07-24 | [stabilize-contracts-and-ci](2026-07-24-stabilize-contracts-and-ci.md) | `dev` / #61–#64, #73, #84 | Done | | 2026-07-24 | [mvp-continuous-compliance-gate](2026-07-24-mvp-continuous-compliance-gate.md) | `docs/mvp-handoffs` / #74 | In progress | +| 2026-07-24 | [mvp-sample-policies](2026-07-24-mvp-sample-policies.md) | `mvp/75-76-81-foundation` / #81 | In progress | +| 2026-07-24 | [verdict-log-schema](2026-07-24-verdict-log-schema.md) | `mvp/75-76-81-foundation` / #76 | In progress | +| 2026-07-24 | [policy-versioning](2026-07-24-policy-versioning.md) | `mvp/75-76-81-foundation` / #75 | In progress | From c5300580da2085e06368dfce9f65f58b27e540ac Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:44:36 +0800 Subject: [PATCH 2/7] feat(mvp): add sample policy fixtures (#81) Two Markdown policies with Controls sections for ingest/E2E demos. Signed-off-by: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> --- docs/handoffs/2026-07-24-mvp-sample-policies.md | 10 +++++----- docs/handoffs/README.md | 2 +- docs/mvp/fixtures/access-control.md | 12 ++++++++++++ docs/mvp/fixtures/data-classification.md | 12 ++++++++++++ 4 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 docs/mvp/fixtures/access-control.md create mode 100644 docs/mvp/fixtures/data-classification.md diff --git a/docs/handoffs/2026-07-24-mvp-sample-policies.md b/docs/handoffs/2026-07-24-mvp-sample-policies.md index 235e26b..b4c12d5 100644 --- a/docs/handoffs/2026-07-24-mvp-sample-policies.md +++ b/docs/handoffs/2026-07-24-mvp-sample-policies.md @@ -1,6 +1,6 @@ # HANDOFF — MVP sample policy pack (#81) -**Status:** In progress +**Status:** Done **Branch:** `mvp/75-76-81-foundation` **Issue(s):** #81 **Last updated:** 2026-07-24 @@ -9,12 +9,12 @@ Minimal Markdown policy fixtures for ingest/E2E — not a hackathon corpus. ## Current state -Starting TDD: tests require fixtures under `docs/mvp/fixtures/`. +Two fixtures under `docs/mvp/fixtures/` with H1 title + `## Controls`. ## What shipped | PR / commit | Delivers | |-------------|---------| -| (pending) | | +| (this branch) | `data-classification.md`, `access-control.md` + `test_mvp_fixtures.py` | ## Hook points - `docs/mvp/fixtures/` @@ -29,5 +29,5 @@ cd backend && uv run pytest tests/test_mvp_fixtures.py -v ``` ## Done means -- [ ] ≥1 MD fixture with title + controls section -- [ ] Tests green +- [x] ≥1 MD fixture with title + controls section +- [x] Tests green diff --git a/docs/handoffs/README.md b/docs/handoffs/README.md index f6fcb2f..f218c81 100644 --- a/docs/handoffs/README.md +++ b/docs/handoffs/README.md @@ -38,6 +38,6 @@ Agents: use project skill **`feature-plan`** (`.agents/skills/feature-plan/` and |------|---------|----------------|--------| | 2026-07-24 | [stabilize-contracts-and-ci](2026-07-24-stabilize-contracts-and-ci.md) | `dev` / #61–#64, #73, #84 | Done | | 2026-07-24 | [mvp-continuous-compliance-gate](2026-07-24-mvp-continuous-compliance-gate.md) | `docs/mvp-handoffs` / #74 | In progress | -| 2026-07-24 | [mvp-sample-policies](2026-07-24-mvp-sample-policies.md) | `mvp/75-76-81-foundation` / #81 | In progress | +| 2026-07-24 | [mvp-sample-policies](2026-07-24-mvp-sample-policies.md) | `mvp/75-76-81-foundation` / #81 | Done | | 2026-07-24 | [verdict-log-schema](2026-07-24-verdict-log-schema.md) | `mvp/75-76-81-foundation` / #76 | In progress | | 2026-07-24 | [policy-versioning](2026-07-24-policy-versioning.md) | `mvp/75-76-81-foundation` / #75 | In progress | diff --git a/docs/mvp/fixtures/access-control.md b/docs/mvp/fixtures/access-control.md new file mode 100644 index 0000000..f5196ae --- /dev/null +++ b/docs/mvp/fixtures/access-control.md @@ -0,0 +1,12 @@ +# Access Control Policy + +Minimal sample policy for MVP validation and HITL demos. + +## Purpose +Limit system access to authorized roles and revoke it when roles change. + +## Controls +- Grant production access by role, not by individual exception when avoidable. +- Require MFA for administrative and production accounts. +- Revoke access within one business day of role change or offboarding. +- Log privileged actions and retain logs for the audit window defined by compliance. diff --git a/docs/mvp/fixtures/data-classification.md b/docs/mvp/fixtures/data-classification.md new file mode 100644 index 0000000..080ed3f --- /dev/null +++ b/docs/mvp/fixtures/data-classification.md @@ -0,0 +1,12 @@ +# Data Classification Policy + +Minimal sample policy for MVP ingest and E2E demos. + +## Purpose +Classify company data so access and retention controls can be applied consistently. + +## Controls +- Label data as Public, Internal, Confidential, or Restricted before sharing outside the team. +- Store Restricted data only in approved systems with encryption at rest. +- Do not commit secrets, credentials, or Restricted payloads to source control. +- Review classification when data ownership or retention requirements change. From 49f6555c07f7da9a2b723e1a3ba989f0072e6ab4 Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:44:57 +0800 Subject: [PATCH 3/7] test(mvp): fail until verdict log schema exists (#76) Red tests for REQUIRED_VERDICT_KEYS and build_verdict_event. Signed-off-by: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> --- backend/tests/test_verdict_log.py | 97 +++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 backend/tests/test_verdict_log.py diff --git a/backend/tests/test_verdict_log.py b/backend/tests/test_verdict_log.py new file mode 100644 index 0000000..8261572 --- /dev/null +++ b/backend/tests/test_verdict_log.py @@ -0,0 +1,97 @@ +"""MVP structured verdict logging schema (#76).""" + +from datetime import UTC, datetime + +import pytest + +REQUIRED_KEYS = { + "action", + "verdict", + "repo", + "timestamp", + "policy_id", + "validation_id", + "rule_id", +} + + +def test_verdict_log_module_exports_schema(): + from app.services.verdict_log import REQUIRED_VERDICT_KEYS, build_verdict_event + + assert set(REQUIRED_VERDICT_KEYS) >= REQUIRED_KEYS + assert callable(build_verdict_event) + + +def test_build_verdict_event_has_required_keys(): + from app.services.verdict_log import build_verdict_event + + event = build_verdict_event( + action="risk_validation", + verdict="HIGH", + repo="liitkud/complyaigent", + policy_id="pol-1", + validation_id="val-1", + rule_id="rule-1", + timestamp=datetime(2026, 7, 24, 4, 0, tzinfo=UTC), + ) + missing = REQUIRED_KEYS - set(event) + assert not missing, f"missing keys: {missing}" + assert event["verdict"] == "HIGH" + assert event["action"] == "risk_validation" + # Accept camelCase alias for policy id consumers + assert "policyId" in event or event.get("policy_id") == "pol-1" + + +def test_validate_path_persists_verdict_schema(client, monkeypatch): + """Validate endpoint should attach structured verdict fields to activity details.""" + from uuid import uuid4 + + from app.models.rule import ( + GovernanceRule, + ImpactRadius, + RiskLevel, + RuleType, + SourceCategory, + ) + from app.models.task import IngestionTask + + async def fake_validate(code_snippet, rule_content): + return {"verdict": "LOW", "reasoning": "ok"} + + monkeypatch.setattr( + "app.api.validate.validator.validate_risk", fake_validate + ) + + # Seed task + rule via overridden session is awkward; hit validate without rule. + resp = client.post( + "/validate", + json={ + "code_snippet": "print('hi')", + "rule_id": str(uuid4()), + "context": "unit", + }, + ) + assert resp.status_code == 202, resp.text + validation_id = resp.json()["validation_id"] + + detail = client.get(f"/validate/{validation_id}") + assert detail.status_code == 200 + body = detail.json() + assert body["verdict"] == "LOW" + + # Structured event must be readable from activity details + from sqlmodel import Session, select + + from app.core.db import get_session + from app.services.logger import ActivityLog + from main import app + + # Use test client override session by querying through ActivityLog via app DB is hard; + # instead re-fetch list and assert activity endpoint or inspect via get_validation extras. + # Contract: GET validate detail includes verdict_event or nested schema keys. + assert "verdict_event" in body or all( + k in body for k in ("validation_id", "verdict") + ) + if "verdict_event" in body: + missing = REQUIRED_KEYS - set(body["verdict_event"]) + assert not missing, f"missing in verdict_event: {missing}" From d58b60ed543e6aa7fee218d44f3c18367cacdf68 Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:45:59 +0800 Subject: [PATCH 4/7] feat(mvp): structured verdict_event schema on validate (#76) Add build_verdict_event helpers, wire validate responses, and fix ActivityLog detached-instance returns after commit. Signed-off-by: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> --- backend/app/api/validate.py | 70 ++++++++++++++----- backend/app/services/logger.py | 3 + backend/app/services/verdict_log.py | 56 +++++++++++++++ backend/tests/test_verdict_log.py | 60 +++++----------- .../handoffs/2026-07-24-verdict-log-schema.md | 35 +++++++--- docs/handoffs/README.md | 2 +- 6 files changed, 155 insertions(+), 71 deletions(-) create mode 100644 backend/app/services/verdict_log.py diff --git a/backend/app/api/validate.py b/backend/app/api/validate.py index bec5c62..7354d79 100644 --- a/backend/app/api/validate.py +++ b/backend/app/api/validate.py @@ -5,10 +5,12 @@ from pydantic import BaseModel from sqlmodel import Session, col, select +from ..core.config import settings from ..core.db import get_session from ..models.rule import GovernanceRule -from ..services.logger import ActivityLog, log_activity +from ..services.logger import ActivityLog, engine as activity_engine, log_activity from ..services.validator import validator +from ..services.verdict_log import build_verdict_event, emit_verdict_log router = APIRouter() @@ -17,6 +19,8 @@ class ValidateRequest(BaseModel): code_snippet: str rule_id: str context: str | None = None + repo: str | None = None + policy_id: str | None = None class HITLAction(BaseModel): @@ -40,19 +44,44 @@ async def submit_validation( # Perform validation result = await validator.validate_risk(request.code_snippet, rule_content) - # Log activity + status = "complete" if result["verdict"] != "MID" else "pending" + + # Pre-allocate validation id via activity log, then attach structured verdict + details = { + "request": request.model_dump(), + "result": result, + } log = log_activity( action="risk_validation", - status="complete" if result["verdict"] != "MID" else "pending", - details={ - "request": request.model_dump(), - "result": result, - }, + status=status, + details=details, + ) + + verdict_event = build_verdict_event( + action="risk_validation", + verdict=result["verdict"], + repo=request.repo or getattr(settings, "PROJECT_NAME", "") or "", + policy_id=request.policy_id, + validation_id=str(log.id), + rule_id=request.rule_id, + timestamp=log.timestamp if log.timestamp.tzinfo else log.timestamp.replace(tzinfo=None), ) + emit_verdict_log(verdict_event) + + # Persist structured event on the activity row (same engine as log_activity) + with Session(activity_engine) as s: + row = s.get(ActivityLog, log.id) + if row: + merged = dict(row.details or {}) + merged["verdict_event"] = verdict_event + row.details = merged + s.add(row) + s.commit() return { "validation_id": str(log.id), "status": "processing" if result["verdict"] == "MID" else "complete", + "verdict_event": verdict_event, } @@ -63,7 +92,8 @@ async def get_validation(id: UUID, session: Session = Depends(get_session)): raise HTTPException(status_code=404, detail="Validation result not found") result = log.details.get("result", {}) - return { + verdict_event = log.details.get("verdict_event") + payload = { "validation_id": str(log.id), "verdict": result.get("verdict", "HIGH"), "reasoning": result.get("reasoning", "No reasoning provided"), @@ -71,6 +101,9 @@ async def get_validation(id: UUID, session: Session = Depends(get_session)): "created_at": log.timestamp.isoformat(), "status": log.status, } + if verdict_event: + payload["verdict_event"] = verdict_event + return payload @router.patch("/validate/{id}") @@ -118,15 +151,16 @@ async def list_validations( if verdict and current_verdict != verdict.upper(): continue - results.append( - { - "validation_id": str(log.id), - "verdict": current_verdict, - "reasoning": res.get("reasoning"), - "activity_logged": True, - "created_at": log.timestamp.isoformat(), - "status": log.status, - } - ) + item = { + "validation_id": str(log.id), + "verdict": current_verdict, + "reasoning": res.get("reasoning"), + "activity_logged": True, + "created_at": log.timestamp.isoformat(), + "status": log.status, + } + if "verdict_event" in (log.details or {}): + item["verdict_event"] = log.details["verdict_event"] + results.append(item) return results diff --git a/backend/app/services/logger.py b/backend/app/services/logger.py index 8c9449a..1df16b0 100644 --- a/backend/app/services/logger.py +++ b/backend/app/services/logger.py @@ -28,6 +28,7 @@ def log_activity( ): """ Persist activity log to database. + Returns an expunged instance safe to read after the session closes. """ with Session(engine) as session: log = ActivityLog( @@ -35,4 +36,6 @@ def log_activity( ) session.add(log) session.commit() + session.refresh(log) + session.expunge(log) return log diff --git a/backend/app/services/verdict_log.py b/backend/app/services/verdict_log.py new file mode 100644 index 0000000..3286777 --- /dev/null +++ b/backend/app/services/verdict_log.py @@ -0,0 +1,56 @@ +"""Canonical structured verdict event schema (#76). Loki shipping is #77.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from app.core.logging import logger + +REQUIRED_VERDICT_KEYS = ( + "action", + "verdict", + "repo", + "timestamp", + "policy_id", + "policyId", + "validation_id", + "rule_id", +) + + +def build_verdict_event( + *, + action: str, + verdict: str, + repo: str | None = None, + policy_id: str | None = None, + validation_id: str | None = None, + rule_id: str | None = None, + timestamp: datetime | None = None, + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build a JSON-serializable verdict event with required keys.""" + ts = timestamp or datetime.now(UTC) + pid = policy_id + event: dict[str, Any] = { + "action": action, + "verdict": verdict, + "repo": repo or "", + "timestamp": ts.isoformat(), + "policy_id": pid, + "policyId": pid, + "validation_id": validation_id, + "rule_id": rule_id, + } + if extra: + event.update(extra) + return event + + +def emit_verdict_log(event: dict[str, Any]) -> None: + """Emit structured verdict to application logger (stdout JSON-ish).""" + missing = [k for k in ("action", "verdict", "timestamp", "validation_id") if k not in event] + if missing: + logger.warning("verdict_event missing keys: %s", missing) + logger.info("verdict_event %s", event) diff --git a/backend/tests/test_verdict_log.py b/backend/tests/test_verdict_log.py index 8261572..1c65248 100644 --- a/backend/tests/test_verdict_log.py +++ b/backend/tests/test_verdict_log.py @@ -1,8 +1,7 @@ """MVP structured verdict logging schema (#76).""" from datetime import UTC, datetime - -import pytest +from uuid import uuid4 REQUIRED_KEYS = { "action", @@ -38,60 +37,33 @@ def test_build_verdict_event_has_required_keys(): assert not missing, f"missing keys: {missing}" assert event["verdict"] == "HIGH" assert event["action"] == "risk_validation" - # Accept camelCase alias for policy id consumers - assert "policyId" in event or event.get("policy_id") == "pol-1" - - -def test_validate_path_persists_verdict_schema(client, monkeypatch): - """Validate endpoint should attach structured verdict fields to activity details.""" - from uuid import uuid4 + assert event.get("policyId") == "pol-1" or event.get("policy_id") == "pol-1" - from app.models.rule import ( - GovernanceRule, - ImpactRadius, - RiskLevel, - RuleType, - SourceCategory, - ) - from app.models.task import IngestionTask +def test_validate_path_returns_verdict_event(client, monkeypatch): async def fake_validate(code_snippet, rule_content): return {"verdict": "LOW", "reasoning": "ok"} - monkeypatch.setattr( - "app.api.validate.validator.validate_risk", fake_validate - ) + monkeypatch.setattr("app.api.validate.validator.validate_risk", fake_validate) - # Seed task + rule via overridden session is awkward; hit validate without rule. resp = client.post( "/validate", json={ "code_snippet": "print('hi')", "rule_id": str(uuid4()), "context": "unit", + "repo": "liitkud/complyaigent", + "policy_id": "pol-demo", }, ) assert resp.status_code == 202, resp.text - validation_id = resp.json()["validation_id"] - - detail = client.get(f"/validate/{validation_id}") - assert detail.status_code == 200 - body = detail.json() - assert body["verdict"] == "LOW" - - # Structured event must be readable from activity details - from sqlmodel import Session, select - - from app.core.db import get_session - from app.services.logger import ActivityLog - from main import app - - # Use test client override session by querying through ActivityLog via app DB is hard; - # instead re-fetch list and assert activity endpoint or inspect via get_validation extras. - # Contract: GET validate detail includes verdict_event or nested schema keys. - assert "verdict_event" in body or all( - k in body for k in ("validation_id", "verdict") - ) - if "verdict_event" in body: - missing = REQUIRED_KEYS - set(body["verdict_event"]) - assert not missing, f"missing in verdict_event: {missing}" + body = resp.json() + assert "validation_id" in body + assert "verdict_event" in body + event = body["verdict_event"] + missing = REQUIRED_KEYS - set(event) + assert not missing, f"missing keys: {missing}" + assert event["verdict"] == "LOW" + assert event["repo"] == "liitkud/complyaigent" + assert event["policy_id"] == "pol-demo" + assert event["validation_id"] == body["validation_id"] diff --git a/docs/handoffs/2026-07-24-verdict-log-schema.md b/docs/handoffs/2026-07-24-verdict-log-schema.md index 54a1aaf..9d4fbfb 100644 --- a/docs/handoffs/2026-07-24-verdict-log-schema.md +++ b/docs/handoffs/2026-07-24-verdict-log-schema.md @@ -1,6 +1,6 @@ # HANDOFF — Structured verdict logging schema (#76) -**Status:** In progress +**Status:** Done **Branch:** `mvp/75-76-81-foundation` **Issue(s):** #76 (legacy #4) **Last updated:** 2026-07-24 @@ -8,16 +8,35 @@ ## Bottom line Canonical JSON verdict event shape for every scan/validate decision. Loki push is #77. -## Hook points -- `backend/app/services/verdict_log.py` -- `backend/app/api/validate.py` -- `backend/tests/test_verdict_log.py` +## Schema +Required keys on `verdict_event`: + +| Key | Notes | +|-----|--------| +| `action` | e.g. `risk_validation` | +| `verdict` | allow/block/HITL band (`LOW`/`MID`/`HIGH`) | +| `repo` | optional repo slug; empty string if unset | +| `timestamp` | ISO-8601 | +| `policy_id` / `policyId` | both set for consumers | +| `validation_id` | activity log id | +| `rule_id` | rule UUID string | + +## What shipped +| Artifact | Role | +|----------|------| +| `backend/app/services/verdict_log.py` | `REQUIRED_VERDICT_KEYS`, `build_verdict_event`, `emit_verdict_log` | +| `backend/app/api/validate.py` | returns + persists `verdict_event` | +| `backend/tests/test_verdict_log.py` | schema + validate path | + +## Open follow-ups +- [ ] #77 Loki/promtail shipper +- [ ] Align activity DB session with FastAPI `get_session` (dual-engine smell) ## How to verify ```bash -cd backend && uv run pytest tests/test_verdict_log.py tests/integration/test_observability.py -v +cd backend && uv run pytest tests/test_verdict_log.py -v ``` ## Done means -- [ ] Required keys present on validate path -- [ ] Schema documented in this handoff + code constants +- [x] Required keys present on validate path +- [x] Schema documented in this handoff + code constants diff --git a/docs/handoffs/README.md b/docs/handoffs/README.md index f218c81..20763b1 100644 --- a/docs/handoffs/README.md +++ b/docs/handoffs/README.md @@ -39,5 +39,5 @@ Agents: use project skill **`feature-plan`** (`.agents/skills/feature-plan/` and | 2026-07-24 | [stabilize-contracts-and-ci](2026-07-24-stabilize-contracts-and-ci.md) | `dev` / #61–#64, #73, #84 | Done | | 2026-07-24 | [mvp-continuous-compliance-gate](2026-07-24-mvp-continuous-compliance-gate.md) | `docs/mvp-handoffs` / #74 | In progress | | 2026-07-24 | [mvp-sample-policies](2026-07-24-mvp-sample-policies.md) | `mvp/75-76-81-foundation` / #81 | Done | -| 2026-07-24 | [verdict-log-schema](2026-07-24-verdict-log-schema.md) | `mvp/75-76-81-foundation` / #76 | In progress | +| 2026-07-24 | [verdict-log-schema](2026-07-24-verdict-log-schema.md) | `mvp/75-76-81-foundation` / #76 | Done | | 2026-07-24 | [policy-versioning](2026-07-24-policy-versioning.md) | `mvp/75-76-81-foundation` / #75 | In progress | From 6b57cfae6ba8106f46e170708deb89bb50eef892 Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:46:47 +0800 Subject: [PATCH 5/7] test(mvp): fail until policy versioning API exists (#75) Red integration tests for Policy model, ingest version rows, and GET /policies. Signed-off-by: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> --- backend/tests/integration/test_policies.py | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 backend/tests/integration/test_policies.py diff --git a/backend/tests/integration/test_policies.py b/backend/tests/integration/test_policies.py new file mode 100644 index 0000000..828a16f --- /dev/null +++ b/backend/tests/integration/test_policies.py @@ -0,0 +1,92 @@ +"""MVP policy storage schema & versioning (#75).""" + +import io + +from fastapi.testclient import TestClient + + +def test_policy_model_fields(): + from app.models.policy import Policy + + fields = set(Policy.model_fields) + for required in ("id", "name", "source_url", "version", "hash"): + assert required in fields, f"Policy missing field: {required}" + + +def test_ingest_creates_policy_version(client: TestClient): + content = b"# Sample\n\n## Controls\n- one\n" + resp = client.post( + "/ingest", + files={"file": ("data-classification.md", io.BytesIO(content), "text/markdown")}, + ) + assert resp.status_code == 202, resp.text + body = resp.json() + assert "task_id" in body + assert "policy_id" in body, "ingest should return policy_id" + + listed = client.get("/policies") + assert listed.status_code == 200, listed.text + policies = listed.json() + assert isinstance(policies, list) + assert policies, "expected at least one policy after ingest" + current = policies[0] + assert current["name"] == "data-classification.md" + assert current["version"] == 1 + assert current["hash"] + assert current["id"] == body["policy_id"] + + got = client.get(f"/policies/{current['id']}") + assert got.status_code == 200 + assert got.json()["hash"] == current["hash"] + + +def test_reingest_same_hash_is_idempotent(client: TestClient): + content = b"# Same\n\n## Controls\n- a\n" + r1 = client.post( + "/ingest", + files={"file": ("access-control.md", io.BytesIO(content), "text/markdown")}, + ) + r2 = client.post( + "/ingest", + files={"file": ("access-control.md", io.BytesIO(content), "text/markdown")}, + ) + assert r1.status_code == 202 + assert r2.status_code == 202 + assert r1.json()["policy_id"] == r2.json()["policy_id"] + + policies = client.get("/policies").json() + named = [p for p in policies if p["name"] == "access-control.md"] + assert len(named) == 1 + assert named[0]["version"] == 1 + + +def test_reingest_new_hash_bumps_version(client: TestClient): + r1 = client.post( + "/ingest", + files={ + "file": ( + "access-control.md", + io.BytesIO(b"# V1\n\n## Controls\n- a\n"), + "text/markdown", + ) + }, + ) + r2 = client.post( + "/ingest", + files={ + "file": ( + "access-control.md", + io.BytesIO(b"# V2\n\n## Controls\n- b\n"), + "text/markdown", + ) + }, + ) + assert r1.status_code == 202 + assert r2.status_code == 202 + assert r1.json()["policy_id"] != r2.json()["policy_id"] + + current = client.get("/policies").json() + named = [p for p in current if p["name"] == "access-control.md"] + assert len(named) == 1 + assert named[0]["version"] == 2 + assert named[0]["id"] == r2.json()["policy_id"] From 8186bedd99a132267c757be4320db18bab5612e1 Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:47:33 +0800 Subject: [PATCH 6/7] feat(mvp): policy versioning model and GET /policies (#75) Ingest upserts Policy rows by content hash/name and exposes list/get APIs. Signed-off-by: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> --- backend/app/api/ingest.py | 63 ++++++++++++++++--- backend/app/api/policies.py | 43 +++++++++++++ backend/app/models/policy.py | 17 +++++ backend/main.py | 3 +- docs/handoffs/2026-07-24-policy-versioning.md | 35 ++++++++--- docs/handoffs/README.md | 2 +- 6 files changed, 146 insertions(+), 17 deletions(-) create mode 100644 backend/app/api/policies.py create mode 100644 backend/app/models/policy.py diff --git a/backend/app/api/ingest.py b/backend/app/api/ingest.py index 2899a14..400c24b 100644 --- a/backend/app/api/ingest.py +++ b/backend/app/api/ingest.py @@ -2,10 +2,11 @@ import tempfile from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile -from sqlmodel import Session, select +from sqlmodel import Session, col, select from ..core.db import get_session from ..core.hashing import calculate_sha256 +from ..models.policy import Policy from ..models.task import IngestionTask, TaskStatus from ..services.pipeline import start_ingestion_pipeline from ..worker.tasks import run_background_task @@ -13,6 +14,46 @@ router = APIRouter() +def _upsert_policy( + session: Session, + *, + name: str, + file_hash: str, + task_id, + source_url: str | None = None, +) -> Policy: + """Create or reuse a Policy version row for this ingest.""" + existing_hash = session.exec( + select(Policy).where(Policy.hash == file_hash) + ).first() + if existing_hash: + return existing_hash + + previous = session.exec( + select(Policy) + .where(Policy.name == name, Policy.is_current.is_(True)) + .order_by(col(Policy.version).desc()) + ).first() + + next_version = (previous.version + 1) if previous else 1 + if previous: + previous.is_current = False + session.add(previous) + + policy = Policy( + name=name, + source_url=source_url, + version=next_version, + hash=file_hash, + task_id=task_id, + is_current=True, + ) + session.add(policy) + session.commit() + session.refresh(policy) + return policy + + @router.post("/ingest", status_code=202) async def ingest_document( background_tasks: BackgroundTasks, @@ -21,16 +62,21 @@ async def ingest_document( ): content = await file.read() file_hash = calculate_sha256(content) + name = file.filename or "untitled" # Check for existing task (idempotency) existing_task = session.exec( select(IngestionTask).where(IngestionTask.source_hash == file_hash) ).first() if existing_task: + policy = _upsert_policy( + session, name=name, file_hash=file_hash, task_id=existing_task.id + ) return { "task_id": existing_task.id, "status": existing_task.status, "message": "File already processed or in progress.", + "policy_id": str(policy.id), } # Create new task @@ -39,9 +85,11 @@ async def ingest_document( session.commit() session.refresh(task) + policy = _upsert_policy(session, name=name, file_hash=file_hash, task_id=task.id) + # Save file temporarily for processing temp_dir = tempfile.mkdtemp(prefix=f"ingest_{task.id}_") - temp_path = os.path.join(temp_dir, file.filename) + temp_path = os.path.join(temp_dir, name) with open(temp_path, "wb") as f: f.write(content) @@ -50,11 +98,12 @@ async def ingest_document( run_background_task, start_ingestion_pipeline, str(task.id), temp_path ) - task_id = str(task.id) - task_status = task.status - task_progress = task.progress_pct - - return {"task_id": task_id, "status": task_status, "progress_pct": task_progress} + return { + "task_id": str(task.id), + "status": task.status, + "progress_pct": task.progress_pct, + "policy_id": str(policy.id), + } @router.get("/ingest/{task_id}") diff --git a/backend/app/api/policies.py b/backend/app/api/policies.py new file mode 100644 index 0000000..e4811ed --- /dev/null +++ b/backend/app/api/policies.py @@ -0,0 +1,43 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session, col, select + +from ..core.db import get_session +from ..models.policy import Policy + +router = APIRouter() + + +def _serialize(policy: Policy) -> dict: + return { + "id": str(policy.id), + "name": policy.name, + "source_url": policy.source_url, + "version": policy.version, + "hash": policy.hash, + "task_id": str(policy.task_id) if policy.task_id else None, + "is_current": policy.is_current, + "created_at": policy.created_at.isoformat(), + } + + +@router.get("/policies") +async def list_policies( + current_only: bool = True, + session: Session = Depends(get_session), +): + statement = select(Policy) + if current_only: + statement = statement.where(Policy.is_current.is_(True)) + statement = statement.order_by(col(Policy.name), col(Policy.version).desc()) + policies = session.exec(statement).all() + return [_serialize(p) for p in policies] + + +@router.get("/policies/{policy_id}") +async def get_policy(policy_id: UUID, session: Session = Depends(get_session)): + policy = session.get(Policy, policy_id) + if not policy: + raise HTTPException(status_code=404, detail="Policy not found") + return _serialize(policy) diff --git a/backend/app/models/policy.py b/backend/app/models/policy.py new file mode 100644 index 0000000..892d299 --- /dev/null +++ b/backend/app/models/policy.py @@ -0,0 +1,17 @@ +"""Policy metadata + version history (#75).""" + +from datetime import UTC, datetime +from uuid import UUID, uuid4 + +from sqlmodel import Field, SQLModel + + +class Policy(SQLModel, table=True): + id: UUID = Field(default_factory=uuid4, primary_key=True) + name: str = Field(index=True) + source_url: str | None = None + version: int = Field(default=1) + hash: str = Field(index=True) + task_id: UUID | None = Field(default=None, foreign_key="ingestiontask.id") + is_current: bool = Field(default=True, index=True) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) diff --git a/backend/main.py b/backend/main.py index ca1e6e6..c59bb49 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,7 +3,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api import activity, ingest, manifest, validate +from app.api import activity, ingest, manifest, policies, validate from app.core.config import settings from app.core.db import init_db from app.core.logging import logger @@ -36,6 +36,7 @@ async def lifespan(app: FastAPI): # Include routers app.include_router(ingest.router, tags=["Ingestion"]) +app.include_router(policies.router, tags=["Policies"]) app.include_router(manifest.router, tags=["Manifest"]) app.include_router(validate.router, tags=["Validation"]) app.include_router(activity.router, tags=["Activity"]) diff --git a/docs/handoffs/2026-07-24-policy-versioning.md b/docs/handoffs/2026-07-24-policy-versioning.md index 94d83e0..f9148c1 100644 --- a/docs/handoffs/2026-07-24-policy-versioning.md +++ b/docs/handoffs/2026-07-24-policy-versioning.md @@ -1,6 +1,6 @@ # HANDOFF — Policy storage schema & versioning (#75) -**Status:** In progress +**Status:** Done **Branch:** `mvp/75-76-81-foundation` **Issue(s):** #75 (legacy #10) **Last updated:** 2026-07-24 @@ -8,10 +8,29 @@ ## Bottom line SQLModel Policy with version + content hash; ingest writes a version row; list/get API. -## Hook points -- `backend/app/models/policy.py` -- `backend/app/api/policies.py` / ingest wiring -- `backend/tests/integration/test_policies.py` +## Model +`Policy`: `id`, `name`, `source_url`, `version`, `hash`, `task_id`, `is_current`, `created_at` + +## API +- `POST /ingest` → includes `policy_id` +- `GET /policies` → current versions (`current_only=true` default) +- `GET /policies/{id}` + +## Version rules +- Same content hash → reuse Policy row (idempotent) +- Same name, new hash → bump `version`, flip previous `is_current=false` + +## What shipped +| Artifact | Role | +|----------|------| +| `backend/app/models/policy.py` | table | +| `backend/app/api/policies.py` | list/get | +| `backend/app/api/ingest.py` | `_upsert_policy` | +| `backend/tests/integration/test_policies.py` | TDD coverage | + +## Open follow-ups +- [ ] Alembic migrations instead of `create_all` for prod +- [ ] Disable/mock background ingest pipeline in unit tests (LLM noise) ## How to verify ```bash @@ -19,6 +38,6 @@ cd backend && uv run pytest tests/integration/test_policies.py -v ``` ## Done means -- [ ] Policy model + table -- [ ] Ingest creates/updates version -- [ ] GET list/current works +- [x] Policy model + table +- [x] Ingest creates/updates version +- [x] GET list/current works diff --git a/docs/handoffs/README.md b/docs/handoffs/README.md index 20763b1..d06eb55 100644 --- a/docs/handoffs/README.md +++ b/docs/handoffs/README.md @@ -40,4 +40,4 @@ Agents: use project skill **`feature-plan`** (`.agents/skills/feature-plan/` and | 2026-07-24 | [mvp-continuous-compliance-gate](2026-07-24-mvp-continuous-compliance-gate.md) | `docs/mvp-handoffs` / #74 | In progress | | 2026-07-24 | [mvp-sample-policies](2026-07-24-mvp-sample-policies.md) | `mvp/75-76-81-foundation` / #81 | Done | | 2026-07-24 | [verdict-log-schema](2026-07-24-verdict-log-schema.md) | `mvp/75-76-81-foundation` / #76 | Done | -| 2026-07-24 | [policy-versioning](2026-07-24-policy-versioning.md) | `mvp/75-76-81-foundation` / #75 | In progress | +| 2026-07-24 | [policy-versioning](2026-07-24-policy-versioning.md) | `mvp/75-76-81-foundation` / #75 | Done | From f215881b6b357aba88645058034114927ab7c033 Mon Sep 17 00:00:00 2001 From: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:47:48 +0800 Subject: [PATCH 7/7] docs(agents): document MVP fixtures, verdict_event, policies API Keep project AGENTS.md aligned with #75/#76/#81 contracts for the next agent. Signed-off-by: kuyacarlo <106532351+kuyacarlo@users.noreply.github.com> --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 71637ae..f18801b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,5 +18,12 @@ - Do not reintroduce floating unpinned `ruff`/`ty` in CI without lockfile pins - Prefer project-scoped changes; no drive-by refactors outside the issue +## MVP foundation (issues #75–#81) +- Sample policy fixtures: [`docs/mvp/fixtures/`](docs/mvp/fixtures/) — Markdown with H1 + `## Controls`; covered by `backend/tests/test_mvp_fixtures.py` +- Verdict events: `backend/app/services/verdict_log.py` — validate returns `verdict_event` with required keys (`action`, `verdict`, `repo`, `timestamp`, `policy_id`/`policyId`, `validation_id`, `rule_id`) +- Policies API: `GET /policies`, `GET /policies/{id}`; `POST /ingest` returns `policy_id` and versions by content hash +- Backend tests: `cd backend && uv run pytest` (pyproject sets `pythonpath = ["."]`) +- Prefer TDD on MVP slices; update the matching `docs/handoffs/YYYY-MM-DD-*.md` when status changes + ## Speckit Feature plans live under `specs/` and `.specify/`. Constitution: `docs/constitution.md` → `.specify/memory/constitution.md`.