Files
praxis/db/pg_store.py
T
Praxis CI f2a12f9fed docs(milestone): complete v0.4-operator-tier — v0.1.9 tagged, milestone release, merged to main
v0.4 (Operator Tier — Cohort Dashboard + Auth + Postgres) milestone complete.

Phases:
  ✓ P0  pre-execution (planning)        → v0.1.6
  ✓ P1  operator foundation (Postgres+auth+VC migration) → v0.1.7
  ✓ P2  cohort dashboard + aggregation   → v0.1.8
  ✓ P3  final review + ship              → v0.1.9 (= v0.4 milestone release)

Requirements covered (8/8):
  REQ-MT-01 (Postgres store), REQ-MT-02 (aggregation pipeline),
  REQ-AUTH-01 (operator auth), REQ-DASH-01 (cohort dashboard),
  REQ-NFR-AUTH-01 (auth NFRs), REQ-NFR-MT-01 (Postgres-in-LXC),
  REQ-NFR-DASH-01 (k-anonymity ≥10), REQ-NFR-DASH-02 (freshness ≤24h)

Grill MUSTs honored (6/6): G-008, G-011, G-027, G-031, G-038, G-041

Tests: 317 pytest pass, 36 skip (Postgres-requiring), 0 fail; 17/17 vitest pass
Review: APPROVE_WITH_NOTES (6/6 personas, 0 P0, 8 P1+ carry-forward)
Audit: HEALTHY (reconstruction PASS, 8/8 REQ, 6/6 grill)

---ci---
project: praxis
phase: 3
milestone: v0.4
status: complete
phase_role: final
milestone_complete: true
milestone_merged_to_main: true
tag: v0.1.9
requirements:
  covered: [REQ-MT-01, REQ-MT-02, REQ-AUTH-01, REQ-DASH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02]
  partial: []
---/ci---
2026-08-04 11:58:44 +00:00

280 lines
11 KiB
Python

"""Postgres store — operator-tier access layer (D-040, D-050, TASK-01-06).
Async access via an asyncpg.Pool. Implements the IssuerKeyStore protocol
(server/vc/issuer_keys.py) so VC verification can use either PraxisStore
(SQLite, v0.3) or PgStore (Postgres, v0.4). No cross-DB joins (D-031);
`learner_ref` is an opaque string in Postgres (not a FK to SQLite).
"""
from __future__ import annotations
import json
import uuid
from typing import Any
import asyncpg
class PgStore:
"""Async Postgres store for the v0.4 operator tier."""
def __init__(self, pool: asyncpg.Pool) -> None:
self.pool = pool
# ── Operator CRUD ────────────────────────────────────────────────────
async def get_operator_by_username(self, username: str) -> dict | None:
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, username, password_hash, display_name, role, "
"is_active, created_at, last_login_at "
"FROM operators WHERE username = $1",
username,
)
return dict(row) if row else None
async def get_operator_by_id(self, operator_id: str) -> dict | None:
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, username, password_hash, display_name, role, "
"is_active, created_at, last_login_at "
"FROM operators WHERE id = $1",
operator_id,
)
return dict(row) if row else None
async def update_last_login(self, operator_id: str) -> None:
async with self.pool.acquire() as conn:
await conn.execute(
"UPDATE operators SET last_login_at = now() WHERE id = $1",
operator_id,
)
async def insert_operator(
self,
username: str,
password_hash: str,
display_name: str | None = None,
*,
on_conflict_update: bool = False,
) -> str | None:
"""Insert an operator (idempotent on username). Returns the id, or
None if the row already existed and on_conflict_update is False."""
async with self.pool.acquire() as conn:
if on_conflict_update:
row = await conn.fetchrow(
"INSERT INTO operators (username, password_hash, display_name) "
"VALUES ($1, $2, $3) "
"ON CONFLICT (username) DO UPDATE SET "
"password_hash = excluded.password_hash, "
"display_name = excluded.display_name "
"RETURNING id",
username,
password_hash,
display_name,
)
return str(row["id"]) if row else None
row = await conn.fetchrow(
"INSERT INTO operators (username, password_hash, display_name) "
"VALUES ($1, $2, $3) "
"ON CONFLICT (username) DO NOTHING "
"RETURNING id",
username,
password_hash,
display_name,
)
return str(row["id"]) if row else None
# ── Cohort aggregate read/write ──────────────────────────────────────
async def get_cohort_aggregates(
self,
path: str,
metric: str,
since_date: Any,
) -> list[dict]:
async with self.pool.acquire() as conn:
rows = await conn.fetch(
"SELECT path, metric, window_start, window_end, value, "
"cell_count, cell_suppressed, updated_at "
"FROM cohort_aggregates "
"WHERE path = $1 AND metric = $2 AND window_start >= $3 "
"ORDER BY window_start",
path,
metric,
since_date,
)
return [dict(r) for r in rows]
async def upsert_cohort_aggregate(
self,
path: str,
metric: str,
window_start: Any,
window_end: Any,
value: float | None,
cell_count: int,
cell_suppressed: bool,
) -> None:
async with self.pool.acquire() as conn:
await conn.execute(
"INSERT INTO cohort_aggregates "
"(path, metric, window_start, window_end, value, cell_count, "
"cell_suppressed, updated_at) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, now()) "
"ON CONFLICT (path, metric, window_start) DO UPDATE SET "
"window_end = excluded.window_end, value = excluded.value, "
"cell_count = excluded.cell_count, "
"cell_suppressed = excluded.cell_suppressed, "
"updated_at = now()",
path,
metric,
window_start,
window_end,
value,
cell_count,
cell_suppressed,
)
# ── IssuerKeyStore protocol (D-051, TASK-04-02) ──────────────────────
async def init_issuer_key(
self,
key_id: str,
public_key: str,
private_key_enc: bytes | None,
) -> None:
async with self.pool.acquire() as conn:
await conn.execute(
"INSERT INTO issuer_keys (id, public_key, private_key_enc, status) "
"VALUES ($1, $2, $3, 'active') "
"ON CONFLICT (id) DO NOTHING",
key_id,
public_key,
private_key_enc if private_key_enc is not None else b"",
)
async def get_active_signing_key_row(self) -> dict | None:
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, public_key, private_key_enc, status, created_at "
"FROM issuer_keys WHERE status = 'active' "
"ORDER BY created_at DESC LIMIT 1"
)
return dict(row) if row else None
async def get_public_key_row(self, key_id: str) -> dict | None:
# Queries by id (NOT status) so superseded keys are found too —
# this is the R-VC-MIG-01 verification fallback (D-051).
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, public_key, private_key_enc, status, created_at "
"FROM issuer_keys WHERE id = $1",
key_id,
)
return dict(row) if row else None
async def set_issuer_key_superseded(self, key_id: str) -> None:
async with self.pool.acquire() as conn:
await conn.execute(
"UPDATE issuer_keys SET status = 'superseded' WHERE id = $1",
key_id,
)
# ── Credential methods ───────────────────────────────────────────────
async def insert_credential(
self,
cred_id: str,
learner_ref: str,
payload_json: str,
signature_b64: str,
*,
operator_id: str | None = None,
vc_type: str = "MasteryCredential",
) -> None:
async with self.pool.acquire() as conn:
await conn.execute(
"INSERT INTO issued_credentials "
"(id, operator_id, learner_ref, vc_type, payload_jsonb, "
"signature_b64, status) "
"VALUES ($1, $2, $3, $4, $5::jsonb, $6, 'active')",
cred_id,
operator_id,
learner_ref,
vc_type,
payload_json,
signature_b64,
)
async def get_credential(self, cred_id: str) -> dict | None:
# Returns a row shaped like PraxisStore.get_credential so the
# verification code can use either store interchangeably.
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT id, learner_ref, "
"payload_jsonb::text AS vc_payload_json, signature_b64, "
"status, issued_at "
"FROM issued_credentials WHERE id = $1",
cred_id,
)
return dict(row) if row else None
async def set_credential_status(self, cred_id: str, status: str) -> None:
extra = ", revoked_at = now()" if status == "revoked" else ""
async with self.pool.acquire() as conn:
await conn.execute(
f"UPDATE issued_credentials SET status = $1{extra} WHERE id = $2",
status,
cred_id,
)
async def list_credentials(self, operator_id: str | None = None) -> list[dict]:
async with self.pool.acquire() as conn:
if operator_id is None:
rows = await conn.fetch(
"SELECT id, learner_ref, vc_type, status, issued_at, "
"revoked_at FROM issued_credentials ORDER BY issued_at DESC"
)
else:
rows = await conn.fetch(
"SELECT id, learner_ref, vc_type, status, issued_at, "
"revoked_at FROM issued_credentials "
"WHERE operator_id = $1 ORDER BY issued_at DESC",
operator_id,
)
return [dict(r) for r in rows]
# ── Mastery gate event ───────────────────────────────────────────────
async def record_gate_event(
self,
learner_ref: str,
path_id: str,
scenario_id: str | None = None,
gate_outcome: str | None = None,
rubric_scores_jsonb: Any | None = None,
) -> str:
event_id = str(uuid.uuid4())
scores_json = (
rubric_scores_jsonb
if isinstance(rubric_scores_jsonb, str)
else (json.dumps(rubric_scores_jsonb) if rubric_scores_jsonb is not None else None)
)
async with self.pool.acquire() as conn:
await conn.execute(
"INSERT INTO mastery_gate_events "
"(id, learner_ref, scenario_id, path_id, gate_outcome, "
"rubric_scores_jsonb, source) "
"VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'sync')",
event_id,
learner_ref,
scenario_id,
path_id,
gate_outcome,
scores_json,
)
return event_id
__all__ = ["PgStore"]