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