Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:

jobs:
oracle:
name: Oracle + generator tests (must be green)
name: Oracle + engine + generator fixtures
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -16,10 +16,10 @@ jobs:
python-version: "3.11"
- run: python -m pip install --upgrade pip
- run: pip install -e ".[dev]"
- run: pytest tests/test_oracle.py tests/test_generators.py -v
- run: pytest tests/test_oracle.py tests/test_rebac.py tests/test_generators.py -v

differential:
name: Differential harness (xfail until W1, XPASS breaks build)
name: Differential harness (5000 examples — W1 acceptance gate)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -28,8 +28,35 @@ jobs:
python-version: "3.11"
- run: python -m pip install --upgrade pip
- run: pip install -e ".[dev]"
# Marked xfail(strict=True) in evals/differential.py until W1 lands the
# real engine. When W1 wires check() in, the tests will XPASS and this
# job will fail — forcing removal of the marker, at which point the
# harness becomes a real gate.
- run: pytest -m differential -v
# 5000 examples: the W1 acceptance criterion from warden-engineering-doc.md.
# Any disagreement between core/oracle.py and core/rebac.py on any generated
# graph will fail this job.
- run: WARDEN_HYP_MAX=5000 pytest -m differential -v

postgres-integration:
name: PostgresStore integration tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: warden
POSTGRES_PASSWORD: warden
POSTGRES_DB: warden
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U warden -d warden"
--health-interval 2s
--health-timeout 3s
--health-retries 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: python -m pip install --upgrade pip
- run: pip install -e ".[dev]"
- run: pytest tests/test_postgres_store.py -v
env:
WARDEN_TEST_DB_URL: postgres://warden:warden@localhost:5432/warden
33 changes: 28 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,20 +1,43 @@
.PHONY: install test lint format oracle differential
.PHONY: install test lint format oracle rebac differential differential-gate \
postgres-up postgres-down postgres-integration

install:
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"

# Everything except the Postgres integration tests. Green on a fresh clone.
test:
.venv/bin/pytest

# Only the handwritten oracle fixture tests. These MUST be green.
# Handwritten oracle fixtures — the seatbelt. MUST be green.
oracle:
.venv/bin/pytest tests/test_oracle.py -v

# Property tests vs. oracle. Expected to FAIL until W1 lands the real engine.
# That failure is the correct state for W0 acceptance.
# Handwritten engine fixtures. MUST be green.
rebac:
.venv/bin/pytest tests/test_rebac.py -v

# Property tests vs. oracle at the dev default of 200 examples.
differential:
.venv/bin/pytest -m differential -v || true
.venv/bin/pytest -m differential -v

# The W1 acceptance gate: 5000 examples. Slow (~2 min); run before pushing.
differential-gate:
WARDEN_HYP_MAX=5000 .venv/bin/pytest -m differential -v

postgres-up:
docker compose up -d postgres
@echo "Waiting for Postgres to be ready..."
@until docker compose exec postgres pg_isready -U warden -d warden >/dev/null 2>&1; do sleep 1; done

postgres-down:
docker compose down -v

# Integration tests for PostgresStore. Requires `make postgres-up` first
# (or WARDEN_TEST_DB_URL set to a running instance).
postgres-integration: postgres-up
WARDEN_TEST_DB_URL=postgres://warden:warden@localhost:5432/warden \
.venv/bin/pytest tests/test_postgres_store.py -v

lint:
.venv/bin/ruff check .
Expand Down
231 changes: 231 additions & 0 deletions core/postgres_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
"""
Postgres-backed TupleStore.

Read-through only; writes go through the CRUD helpers at the bottom. Every
public method matches the `TupleStore` Protocol from core/store.py, so the
engine (`core/rebac.py`) can point at this or at `InMemoryStore` without
knowing the difference.

Not used by the differential harness (that runs against InMemoryStore
for speed). Used by the Postgres integration tests, and — from W2
onward — by the retrieval and gateway layers.
"""

from __future__ import annotations

from collections.abc import Iterable
from datetime import datetime
from pathlib import Path

import psycopg
from psycopg.rows import dict_row

from core.algebra import (
Barrier,
Graph,
Object,
Subject,
Tuple,
)

_SQL_DIR = Path(__file__).resolve().parent.parent / "sql"


def apply_migrations(conn: psycopg.Connection) -> None:
"""Apply every SQL file in sql/ in lexical order.

Idempotent — every migration uses IF NOT EXISTS. This is minimal by
design; when the migration set grows past ~5 files, swap in alembic or
sqlx-cli. For W1 it's two CREATE TABLEs and nothing that needs the
complexity.
"""
for path in sorted(_SQL_DIR.glob("*.sql")):
conn.execute(path.read_text())
conn.commit()


class PostgresStore:
"""A TupleStore backed by Postgres.

Takes an open psycopg Connection. The connection's transaction discipline
is the caller's responsibility — this class does not begin/commit anything.
"""

def __init__(self, conn: psycopg.Connection) -> None:
self._conn = conn

# ---- TupleStore Protocol -----------------------------------------

def outgoing(self, subject: Subject) -> Iterable[Tuple]:
rows = self._conn.execute(
"""
SELECT subject_type, subject_id, subject_rel, relation,
object_type, object_id, expires_at
FROM tuples
WHERE subject_type = %s AND subject_id = %s
""",
(subject.type, subject.id),
).fetchall()
return [_row_to_tuple(row) for row in rows]

def barriers(self) -> Iterable[Barrier]:
rows = self._conn.cursor(row_factory=dict_row).execute(
"SELECT id, name, side_a, side_b FROM barriers"
).fetchall()
return [
Barrier(id=r["id"], name=r["name"], side_a=r["side_a"], side_b=r["side_b"])
for r in rows
]

def group_memberships(self, principal: Subject) -> set[str]:
"""Recursive CTE up the member graph. Cycle-safe via UNION (dedup on
the row set — not on a visited column, but functionally equivalent
for reachability).

This uses a single round trip to Postgres regardless of nesting
depth, versus the InMemoryStore's iterative walk. Same result.
"""
rows = self._conn.execute(
"""
WITH RECURSIVE membership AS (
SELECT object_type, object_id
FROM tuples
WHERE subject_type = %s AND subject_id = %s
AND relation = 'member'
AND object_type IN ('group', 'org')
UNION
SELECT t.object_type, t.object_id
FROM tuples t
JOIN membership m
ON t.subject_type = m.object_type
AND t.subject_id = m.object_id
WHERE t.relation = 'member'
AND t.object_type IN ('group', 'org')
)
SELECT object_id FROM membership WHERE object_type = 'group'
""",
(principal.type, principal.id),
).fetchall()
return {row[0] for row in rows}

def document_barrier_tags(self, doc_id: str) -> frozenset[int]:
# In W1 the schema doesn't yet have a documents table (that's W2).
# For the integration tests we route barrier tags through an in-memory
# side channel: the test harness pre-loads them via a companion
# method. This keeps the W1 store honest about not knowing docs while
# still letting the differential API be uniform.
return self._doc_tags.get(doc_id, frozenset())

# ---- W1 side channel for doc tags (removed when W2 adds documents) ----

_doc_tags: dict[str, frozenset[int]] = {}

def load_doc_tags(self, tags: dict[str, frozenset[int]]) -> None:
"""Test-only shim. Documents are a W2 concern; W1 tests that need to
exercise barrier evaluation load tag mappings via this side channel.
Remove when W2 lands and PostgresStore reads from the documents table.
"""
self._doc_tags = dict(tags)


# ---------------------------------------------------------------------------
# CRUD helpers (W1.1 acceptance: tuple CRUD)
# ---------------------------------------------------------------------------

def write_tuple(conn: psycopg.Connection, t: Tuple) -> None:
"""Idempotent write. If the same PK exists, do nothing.

Idempotency matters because ACL sync jobs frequently re-emit tuples that
already exist — if a duplicate write raised, every sync would need
per-tuple existence checks. Better to make writes safe and let the sync
be dumb.
"""
conn.execute(
"""
INSERT INTO tuples
(subject_type, subject_id, subject_rel, relation,
object_type, object_id, expires_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT DO NOTHING
""",
(
t.subject.type,
t.subject.id,
"", # subject_rel (userset rewrites are a P1 feature)
t.relation,
t.object.type,
t.object.id,
t.expires_at,
),
)


def delete_tuple(conn: psycopg.Connection, t: Tuple) -> None:
"""Idempotent delete. Missing row is not an error."""
conn.execute(
"""
DELETE FROM tuples
WHERE subject_type = %s AND subject_id = %s AND subject_rel = %s
AND relation = %s AND object_type = %s AND object_id = %s
""",
(t.subject.type, t.subject.id, "", t.relation, t.object.type, t.object.id),
)


def list_tuples(conn: psycopg.Connection) -> list[Tuple]:
"""Return every tuple. Test-only; production has no reason to select-star
the tuples table."""
rows = conn.execute(
"""
SELECT subject_type, subject_id, subject_rel, relation,
object_type, object_id, expires_at
FROM tuples
"""
).fetchall()
return [_row_to_tuple(row) for row in rows]


def write_barrier(conn: psycopg.Connection, barrier: Barrier) -> int:
"""Insert a barrier. Returns the assigned id (barriers use BIGSERIAL).
If a barrier with the given `id` already exists, do nothing and return it.
"""
row = conn.execute(
"""
INSERT INTO barriers (name, side_a, side_b)
VALUES (%s, %s, %s)
RETURNING id
""",
(barrier.name, barrier.side_a, barrier.side_b),
).fetchone()
return row[0]


def load_graph(conn: psycopg.Connection, graph: Graph) -> dict[int, int]:
"""Bulk-load a Graph into Postgres. Returns a mapping from the graph's
conceptual barrier IDs to their assigned BIGSERIAL IDs.

Used by the integration tests to hydrate a fresh DB from a Hypothesis-
generated graph. Wipes existing state first (test-only).
"""
conn.execute("TRUNCATE tuples, barriers RESTART IDENTITY")
for t in graph.tuples:
write_tuple(conn, t)
id_map: dict[int, int] = {}
for b in graph.barriers:
new_id = write_barrier(conn, b)
id_map[b.id] = new_id
conn.commit()
return id_map


def _row_to_tuple(row: tuple) -> Tuple:
return Tuple(
subject=Subject(type=row[0], id=row[1]),
relation=row[3],
object=Object(type=row[4], id=row[5]),
expires_at=row[6],
)


# Object is imported for _row_to_tuple; keep it in scope
_ = Object
Loading
Loading