P1 scaffold
This commit is contained in:
commit
372751ea49
14 changed files with 504 additions and 0 deletions
5
.env.example
Normal file
5
.env.example
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Copy to .env and edit. Never commit .env.
|
||||
PG_USER=vep
|
||||
PG_PASS=change_me_to_a_real_secret
|
||||
DATABASE_URL=postgresql://vep:change_me_to_a_real_secret@postgres:5432/vep
|
||||
VEP_TENANT=t_default
|
||||
39
.forgejo/workflows/ci.yml
Normal file
39
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Forgejo Actions: P1 CI gate. Runs boundary tests + spine-check.
|
||||
# Blocks merge if either fails (red). Safe: no infrastructure changes, just verification.
|
||||
name: vep-p1-ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: python:3.12-slim
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:0.7.0-pg16
|
||||
env:
|
||||
POSTGRES_USER: vep
|
||||
POSTGRES_PASSWORD: vep_test
|
||||
POSTGRES_DB: vep
|
||||
ports:
|
||||
- "5432:5432"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install uv
|
||||
run: pip install uv && uv python install 3.12
|
||||
- name: Install deps
|
||||
run: uv pip install --system -r requirements.txt pytest httpx
|
||||
- name: Init schema
|
||||
env:
|
||||
DATABASE_URL: postgresql://vep:vep_test@postgres:5432/vep
|
||||
run: uv run python -m vep.initdb
|
||||
- name: Boundary tests
|
||||
env:
|
||||
DATABASE_URL: postgresql://vep:vep_test@postgres:5432/vep
|
||||
run: uv run pytest -q
|
||||
- name: Spine-check
|
||||
run: uv run python scripts/spine_check.py
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# VEP — git ignore (no secrets, no data, no artifacts)
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.uv_cache/
|
||||
*.log
|
||||
data/
|
||||
pgdata/
|
||||
8
Dockerfile
Normal file
8
Dockerfile
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
FROM python:3.12-slim
|
||||
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
COPY . .
|
||||
# Run schema init on container start (idempotent). Overridable for tests.
|
||||
CMD ["sh", "-c", "python -m vep.initdb && uvicorn vep.main:app --host 0.0.0.0 --port 8000"]
|
||||
48
compose.yml
Normal file
48
compose.yml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# VEP P1 compose — pinned, extendable per phase. Internal network only.
|
||||
# Postgres+pgvector not published to host. Caddy (host) proxies git.* and api.*.
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:0.7.0-pg16
|
||||
environment:
|
||||
POSTGRES_USER: ${PG_USER:-vep}
|
||||
POSTGRES_PASSWORD: ${PG_PASS:-vep_dev_pass}
|
||||
POSTGRES_DB: vep
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- ./schema/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
networks: [vep_net]
|
||||
# NOT published to host — internal only.
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${PG_USER:-vep}:${PG_PASS:-vep_dev_pass}@postgres:5432/vep
|
||||
VEP_TENANT: t_default
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
networks: [vep_net]
|
||||
# NOT published — Caddy proxies localhost:8000.
|
||||
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:10.0.3
|
||||
environment:
|
||||
FORGEJO__database__DB_TYPE: sqlite
|
||||
FORGEJO__server__SSH_PORT: 22
|
||||
FORGEJO__server__ROOT_URL: https://git.mangoopsdesign.com
|
||||
FORGEJO__server__APP_DATA_PATH: /data
|
||||
ports:
|
||||
- "2222:22" # host :2222 -> container ssh (avoids host :22)
|
||||
volumes:
|
||||
- forgejo_data:/data
|
||||
networks: [vep_net]
|
||||
|
||||
networks:
|
||||
vep_net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
forgejo_data:
|
||||
3
pytest.ini
Normal file
3
pytest.ini
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
asyncpg==0.30.0
|
||||
pydantic==2.10.4
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.24.0
|
||||
httpx==0.28.1
|
||||
92
schema/init.sql
Normal file
92
schema/init.sql
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
-- VEP P1 schema: multi-tenant, append-only event_log, provenance, dead-letter,
|
||||
-- actor model. Row-level security enforced from the first migration (JL-003 / #4).
|
||||
-- Run idempotently by app startup against the postgres container.
|
||||
|
||||
-- Extensions
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
|
||||
-- Tenant registry (commitment #4: tenancy at schema level day 1)
|
||||
CREATE TABLE IF NOT EXISTS tenant (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Actor registry (agency first-class, JL-002): humans + digital agents, tier + audit
|
||||
CREATE TABLE IF NOT EXISTS actor (
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenant(id),
|
||||
kind TEXT NOT NULL CHECK (kind IN ('human','digital')),
|
||||
authority_tier INT NOT NULL CHECK (authority_tier BETWEEN 0 AND 3), -- T0..T3
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Append-only event log (commitment #2). No UPDATE/DELETE granted to app role.
|
||||
-- create_capture: 'create' | 'capture' (new-market vs existing-demand probe)
|
||||
-- confidence: 0..1 honest uncertainty
|
||||
-- raw_ref: provenance pointer (source file / url / sheet cell / whatsapp msg id)
|
||||
CREATE TABLE IF NOT EXISTS event_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenant(id),
|
||||
actor_id TEXT NOT NULL, -- provenance (JL-002/#2)
|
||||
schema_ver INT NOT NULL DEFAULT 1,
|
||||
event_type TEXT NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
create_capture TEXT CHECK (create_capture IN ('create','capture','unknown')),
|
||||
confidence DOUBLE PRECISION CHECK (confidence BETWEEN 0 AND 1),
|
||||
raw_ref TEXT, -- provenance (JL-002/#2)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_log_tenant ON event_log(tenant_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_log_type ON event_log(tenant_id, event_type);
|
||||
|
||||
-- Dead-letter store for silent-loss prevention (JL-006/#2). Ingestion rejects land here.
|
||||
CREATE TABLE IF NOT EXISTS dead_letter (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id TEXT,
|
||||
reason TEXT NOT NULL,
|
||||
raw_payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Row-level security: every table gated on tenant_id. App connects with a role that
|
||||
-- can ONLY see its own tenant. DDL-level enforcement, not app-level.
|
||||
ALTER TABLE event_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE actor ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE dead_letter ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- The app connects as the role set below; we scope it per-tenant at connect time by
|
||||
-- setting a session variable and matching it in the policy. (Simplified for P1 single
|
||||
-- app connection; in P6 this moves to per-tenant JWT claims via Zitadel.)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'vep_app') THEN
|
||||
CREATE ROLE vep_app LOGIN PASSWORD 'vep_app'; -- overridden by .env in runtime
|
||||
END IF;
|
||||
END $$;
|
||||
GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA public TO vep_app;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vep_app;
|
||||
-- Explicitly DENY mutations on the log (append-only at the privilege layer)
|
||||
REVOKE UPDATE, DELETE ON event_log FROM vep_app;
|
||||
REVOKE UPDATE, DELETE ON dead_letter FROM vep_app;
|
||||
|
||||
-- RLS policy: app role only sees rows matching its session tenant.
|
||||
DROP POLICY IF EXISTS tenant_isolation_event_log ON event_log;
|
||||
CREATE POLICY tenant_isolation_event_log ON event_log
|
||||
FOR ALL TO vep_app
|
||||
USING (tenant_id = current_setting('app.current_tenant', true));
|
||||
DROP POLICY IF EXISTS tenant_isolation_actor ON actor;
|
||||
CREATE POLICY tenant_isolation_actor ON actor
|
||||
FOR ALL TO vep_app
|
||||
USING (tenant_id = current_setting('app.current_tenant', true));
|
||||
DROP POLICY IF EXISTS tenant_isolation_dead_letter ON dead_letter;
|
||||
CREATE POLICY tenant_isolation_dead_letter ON dead_letter
|
||||
FOR ALL TO vep_app
|
||||
USING (tenant_id IS NULL OR tenant_id = current_setting('app.current_tenant', true));
|
||||
|
||||
-- Seed a default tenant + a human T0 owner actor so the spine has a first row.
|
||||
INSERT INTO tenant (id, name) VALUES ('t_default','Default Tenant')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
INSERT INTO actor (id, tenant_id, kind, authority_tier)
|
||||
VALUES ('a_owner','t_default','human',0)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
80
scripts/spine_check.py
Normal file
80
scripts/spine_check.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env python3
|
||||
"""VEP spine-check — merge gate (commitment #5).
|
||||
|
||||
For every file changed in the diff that looks like code (.py/.sql/.yml/.yaml/.js/.ts),
|
||||
require evidence that it is justified by the spine:
|
||||
1. references a JL id (e.g. JL-013 or #2) in the file (JL justification)
|
||||
2. has a corresponding test entry OR is itself a test (value test)
|
||||
3. names an owning agent/actor (from contracts: Owner, Builder, T0..T3, council seats)
|
||||
If any code file fails, exit non-zero -> CI marks the PR red and blocks merge.
|
||||
|
||||
Usage: python scripts/spine_check.py [base_ref] (default: origin/main)
|
||||
Safe to run locally; does not modify anything.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CODE_EXTS = (".py", ".sql", ".yml", ".yaml", ".js", ".ts")
|
||||
JL_RE = re.compile(r"\b(JL-\d{3}|#[0-9]{1,2})\b")
|
||||
AGENT_RE = re.compile(
|
||||
r"\b(Owner|Builder|T0|T1|T2|T3|admin|actor|human|digital|tenant|analyst|advisor|finance|ops|commercial|people|data)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TEST_DIR_RE = re.compile(r"(^|/)tests?/", re.IGNORECASE)
|
||||
TEST_FILE_RE = re.compile(r"(test_|_test\.|conftest)", re.IGNORECASE)
|
||||
|
||||
|
||||
def changed_files(base: str) -> list[str]:
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["git", "diff", "--name-only", f"{base}...HEAD"], cwd=ROOT, text=True
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
# fall back to working tree
|
||||
out = subprocess.check_output(
|
||||
["git", "diff", "--name-only"], cwd=ROOT, text=True
|
||||
)
|
||||
return [f.strip() for f in out.splitlines() if f.strip()]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
base = sys.argv[1] if len(sys.argv) > 1 else "origin/main"
|
||||
files = changed_files(base)
|
||||
code_files = [f for f in files if f.endswith(CODE_EXTS)]
|
||||
if not code_files:
|
||||
print("spine-check: no code files changed -> PASS")
|
||||
return 0
|
||||
|
||||
failures = []
|
||||
for f in code_files:
|
||||
if TEST_FILE_RE.search(f) or TEST_DIR_RE.search(f):
|
||||
continue # tests are the value-proof themselves
|
||||
path = os.path.join(ROOT, f)
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
text = fh.read()
|
||||
problems = []
|
||||
if not JL_RE.search(text):
|
||||
problems.append("no JL justification (no JL-### or #n reference)")
|
||||
if not AGENT_RE.search(text):
|
||||
problems.append("no owning agent/actor named")
|
||||
if problems:
|
||||
failures.append((f, problems))
|
||||
|
||||
if failures:
|
||||
print("spine-check: FAIL")
|
||||
for f, probs in failures:
|
||||
print(f" - {f}: {'; '.join(probs)}")
|
||||
print("\nEvery non-test code change must cite a JL id and name an owning agent.")
|
||||
return 1
|
||||
print("spine-check: PASS (all changed code justified + agent-bound)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
95
tests/test_boundary.py
Normal file
95
tests/test_boundary.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""VEP P1 boundary tests (canonical Part 6 discipline): test CONTRACTS, not internals.
|
||||
These block merge (CI). Covers the four P1 acceptance criteria.
|
||||
|
||||
Run: cd /root/vep && uv run pytest -q
|
||||
Requires DATABASE_URL to point at a fresh P1 postgres (the compose 'postgres' service).
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import asyncpg
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
from vep.main import app
|
||||
from vep.initdb import init as db_init
|
||||
from vep.db import connection
|
||||
|
||||
URL = os.environ.get("DATABASE_URL", "postgresql://vep:vep_dev_pass@postgres:5432/vep")
|
||||
A_URL = URL.replace("vep", "vep_test_a")
|
||||
B_URL = URL.replace("vep", "vep_test_b")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="module")
|
||||
async def client():
|
||||
await db_init()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def _seed_tenant(conn, tid, actor="a_owner"):
|
||||
await conn.execute("INSERT INTO tenant (id,name) VALUES ($1,$2) ON CONFLICT DO NOTHING", tid, tid)
|
||||
await conn.execute(
|
||||
"INSERT INTO actor (id,tenant_id,kind,authority_tier) VALUES ($1,$2,'human',0) "
|
||||
"ON CONFLICT DO NOTHING", actor, tid)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_isolation(client):
|
||||
"""JL-003: tenant A cannot read tenant B's events."""
|
||||
# Insert an event for tenant A under A's session scope.
|
||||
async with connection("t_a") as conn:
|
||||
await _seed_tenant(conn, "t_a", "a_a")
|
||||
await conn.execute(
|
||||
"INSERT INTO event_log (tenant_id, actor_id, event_type, payload, create_capture, confidence, raw_ref) "
|
||||
"VALUES ($1,$2,$3,$4,$5,$6,$7)",
|
||||
"t_a", "a_a", "test", json.dumps({"x": 1}), "create", 0.9, "src://a",
|
||||
)
|
||||
# Now B tries to read: RLS should hide A's row.
|
||||
async with connection("t_b") as conn:
|
||||
await _seed_tenant(conn, "t_b", "a_b")
|
||||
n = await conn.fetchval("SELECT count(*) FROM event_log")
|
||||
assert n == 0, "tenant B saw tenant A's events — RLS isolation failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_unvalidated_row(client):
|
||||
"""JL-006: a missing actor_id is rejected AND recorded in dead_letter (no silent loss)."""
|
||||
before = await _dead_count()
|
||||
resp = await client.post("/events", json={"event_type": "no_actor", "payload": {}})
|
||||
assert resp.status_code == 422, "invalid event was accepted"
|
||||
after = await _dead_count()
|
||||
assert after == before + 1, "rejected event was silently dropped"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provenance_required(client):
|
||||
"""#2: a valid event records actor_id + raw_ref + confidence in the log."""
|
||||
async with connection("t_p") as conn:
|
||||
await _seed_tenant(conn, "t_p", "a_p")
|
||||
resp = await client.post("/events", json={
|
||||
"tenant_id": "t_p", "actor_id": "a_p", "event_type": "probe",
|
||||
"payload": {"v": 1}, "create_capture": "create", "confidence": 0.7, "raw_ref": "sheet://x",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
async with connection("t_p") as conn:
|
||||
row = await conn.fetchrow("SELECT actor_id, raw_ref, confidence FROM event_log WHERE id=$1", resp.json()["id"])
|
||||
assert row["actor_id"] == "a_p" and row["raw_ref"] == "sheet://x" and row["confidence"] == 0.7
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_only(client):
|
||||
"""#2 / JL-006: the log cannot be mutated via the app role (privilege layer)."""
|
||||
async with connection("t_ao") as conn:
|
||||
await _seed_tenant(conn, "t_ao", "a_ao")
|
||||
await conn.execute(
|
||||
"INSERT INTO event_log (tenant_id, actor_id, event_type, payload) VALUES ($1,$2,$3,$4)",
|
||||
"t_ao", "a_ao", "immutable", json.dumps({}),
|
||||
)
|
||||
with pytest.raises(Exception):
|
||||
await conn.execute("UPDATE event_log SET event_type='hacked' WHERE tenant_id='t_ao'")
|
||||
|
||||
|
||||
async def _dead_count():
|
||||
async with connection() as conn:
|
||||
return await conn.fetchval("SELECT count(*) FROM dead_letter")
|
||||
0
vep/__init__.py
Normal file
0
vep/__init__.py
Normal file
25
vep/db.py
Normal file
25
vep/db.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""VEP P1 database access. App connects as the least-privilege 'vep_app' role so that
|
||||
row-level security (RLS) policies actually apply (superuser/owner bypasses RLS).
|
||||
A short-lived pool is created per connection context to avoid cross-event-loop reuse
|
||||
bugs under pytest-asyncio. Volume is tiny in P1; this is fine.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import asyncpg
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "postgresql://vep_app:vep_app@postgres:5432/vep")
|
||||
VEP_TENANT = os.environ.get("VEP_TENANT", "t_default")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def connection(tenant: str | None = None):
|
||||
"""Yield a connection as vep_app with the tenant session var set (RLS enforcement)."""
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
try:
|
||||
await conn.execute(
|
||||
"SELECT set_config('app.current_tenant', $1, false)", tenant or VEP_TENANT
|
||||
)
|
||||
yield conn
|
||||
finally:
|
||||
await conn.close()
|
||||
25
vep/initdb.py
Normal file
25
vep/initdb.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Run schema init idempotently. Compose mounts init.sql as a DDL init; this is the
|
||||
explicit path used by the container CMD and by tests (in case initdb.d timing differs)."""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import asyncpg
|
||||
import pathlib
|
||||
|
||||
INIT_SQL = pathlib.Path(__file__).resolve().parent.parent / "schema" / "init.sql"
|
||||
|
||||
|
||||
async def init() -> None:
|
||||
# Run as the superuser (POSTGRES_USER) so roles/extensions/tables get created.
|
||||
url = os.environ.get("DATABASE_ADMIN_URL", "postgresql://vep:vep_dev_pass@postgres:5432/vep")
|
||||
conn = await asyncpg.connect(url)
|
||||
try:
|
||||
sql = INIT_SQL.read_text()
|
||||
await conn.execute(sql)
|
||||
print("VEP schema init OK")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
asyncio.run(init())
|
||||
67
vep/main.py
Normal file
67
vep/main.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""VEP P1 API — health + event ingest (with contract enforcement + dead-letter).
|
||||
|
||||
JL anchors (spine-check will verify these on every change):
|
||||
JL-002 Agency first-class: every event requires an actor_id.
|
||||
JL-003 Multi-tenant day 1: events are tenant-scoped; RLS enforces isolation.
|
||||
JL-006 No silent loss: an invalid event is written to dead_letter, never dropped.
|
||||
#2 Provenance: event_log carries actor_id, raw_ref, confidence, create_capture.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
from typing import Any, Literal
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from .db import connection, VEP_TENANT
|
||||
|
||||
|
||||
app = FastAPI(title="VEP", version="0.1.0")
|
||||
|
||||
|
||||
class EventIn(BaseModel):
|
||||
tenant_id: str = Field(default=VEP_TENANT)
|
||||
actor_id: str = Field(..., min_length=1) # JL-002 provenance
|
||||
event_type: str = Field(..., min_length=1)
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
create_capture: Literal["create", "capture", "unknown"] | None = None
|
||||
confidence: float | None = Field(default=None, ge=0.0, le=1.0) # honest uncertainty
|
||||
raw_ref: str | None = None # #2 provenance
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "product": "VEP", "phase": "P1"}
|
||||
|
||||
|
||||
@app.get("/events")
|
||||
async def list_events(limit: int = 50):
|
||||
async with connection() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id, tenant_id, actor_id, event_type, create_capture, confidence, raw_ref, created_at "
|
||||
"FROM event_log ORDER BY created_at DESC LIMIT $1",
|
||||
limit,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.post("/events")
|
||||
async def ingest(req: Request):
|
||||
"""Contract: validate -> insert, or on failure write dead_letter (no silent loss)."""
|
||||
raw = await req.json()
|
||||
try:
|
||||
ev = EventIn.model_validate(raw)
|
||||
except ValidationError as e:
|
||||
async with connection() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO dead_letter (tenant_id, reason, raw_payload) VALUES ($1,$2,$3)",
|
||||
raw.get("tenant_id"), f"validation: {e.errors()}", __import__("json").dumps(raw),
|
||||
)
|
||||
raise HTTPException(status_code=422, detail="rejected; recorded in dead_letter")
|
||||
|
||||
async with connection(tenant=ev.tenant_id) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"INSERT INTO event_log (tenant_id, actor_id, event_type, payload, create_capture, confidence, raw_ref) "
|
||||
"VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id, created_at",
|
||||
ev.tenant_id, ev.actor_id, ev.event_type,
|
||||
__import__("json").dumps(ev.payload), ev.create_capture, ev.confidence, ev.raw_ref,
|
||||
)
|
||||
return {"id": row["id"], "status": "recorded", "created_at": str(row["created_at"])}
|
||||
Loading…
Add table
Reference in a new issue