diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml
index 588d027..874722b 100644
--- a/.github/workflows/verify.yml
+++ b/.github/workflows/verify.yml
@@ -7,6 +7,20 @@ on:
jobs:
verify:
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_PASSWORD: civicaccess
+ POSTGRES_USER: civicaccess
+ POSTGRES_DB: civicaccess_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U civicaccess -d civicaccess_test"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
@@ -16,5 +30,9 @@ jobs:
run: python -m pip install https://github.com/CivicSuite/civiccore/releases/download/v1.2.0/civiccore-1.2.0-py3-none-any.whl
- name: Install package
run: python -m pip install -e ".[dev]"
+ - name: Assert CivicCore release version
+ run: python -c "import civiccore; assert civiccore.__version__ == '1.2.0', civiccore.__version__"
- name: Run release gate
+ env:
+ CIVICACCESS_POSTGRES_TEST_URL: postgresql+psycopg2://civicaccess:civicaccess@localhost:5432/civicaccess_test
run: bash scripts/verify-release.sh
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3fbabbf..c021073 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,25 @@ The format follows Keep a Changelog, and this project follows Semantic Versionin
## [Unreleased]
+## [0.4.0] - 2026-06-28
+
+City-core hardening: closes probe gaps #2 (authz), #3 (audit), and #4 (backup/restore), and makes the shared CivicCore PostgreSQL the default review store.
+
+### Added
+
+- Added a trusted-write guard: persistent writes (`POST /api/v1/civicaccess/review`, `POST /api/v1/civicaccess/reviews/{id}/records-export`) now require the `CIVICACCESS_TRUSTED_WRITE_TOKEN` server secret, sent as the `X-CivicAccess-Write-Token` header. Missing/invalid token returns 403; unconfigured guard fails closed with 503. (Probe gap #2.)
+- Added `POST /api/v1/civicaccess/analyze`: a stateless, public, no-persistence accessibility check. The public `/civicaccess` surface now uses it, so public users can no longer write city records.
+- Added a persisted `audit_events` table and module audit events on writes/exports: `review.create` is committed atomically in the same transaction as the review record; `review.records_export` emits a standalone audit row on export. (Probe gap #3.)
+- Added durability proofs: a Postgres reconnect round-trip for the default store (review + audit survive a fresh engine) and a SQLite dev-fallback backup/restore round-trip. (Probe gap #4.)
+- Added a mandatory PostgreSQL release gate: `verify-release.sh` and CI require `CIVICACCESS_POSTGRES_TEST_URL` so PostgreSQL persistence coverage cannot be skipped, plus `tests/test_postgres_persistence.py`.
+
+### Changed
+
+- Defaulted the review store to the shared CivicCore PostgreSQL: the module reads the supervisor's `DATABASE_URL` (asyncpg) and derives a sync psycopg2 URL. `CIVICACCESS_REVIEW_DB_URL` still overrides; SQLite is now an explicit dev fallback rather than the default.
+- Moved `psycopg2-binary` from dev/optional dependencies to a runtime dependency so the PostgreSQL default works out of the box.
+- Renamed the schema migration id to `civicaccess-windows-local-state-v1` to match the CivicCore Windows Local module convention.
+- Staff `/civicaccess/staff` surface sends an operator-supplied write token (entered in the UI, kept in the browser session) on save and export. The server secret is never embedded in served HTML.
+
## [0.3.0] - 2026-06-25
### Added
diff --git a/PROBE-PROGRESS.md b/PROBE-PROGRESS.md
new file mode 100644
index 0000000..2d51460
--- /dev/null
+++ b/PROBE-PROGRESS.md
@@ -0,0 +1,40 @@
+# CivicAccess City-Core Probe Progress
+
+Tracks the city-core readiness probe gaps for CivicAccess. The probe demoted CivicAccess from
+city-core (`excluded_from_city_core_needs_work_probe`) until these gaps are closed with evidence.
+
+Phase A (this release, **v0.4.0**) closes the module-repo gaps #1–#4. Gaps #5–#6 are integration/QA
+gaps owned by later phases of the CivicAccess → city-core plan
+(`CivicSuite/civicsuite` → `docs/roadmap/civicaccess-citycore-integration/`).
+
+| Gap | Description | Status | Evidence |
+|-----|-------------|--------|----------|
+| #1 | Clean install with the published CivicCore v1.2.0 wheel pin | **Closed** (v0.3.0) | `pyproject.toml` pins `civiccore` to the v1.2.0 release wheel + SHA256; `tests/test_runtime_foundation.py::test_pyproject_uses_published_civiccore_release_wheel` (and asserts the bad `civiccore==1.1.0`/`1.0.0` pins are absent). CI installs the wheel and asserts `civiccore.__version__ == "1.2.0"`. |
+| #2 | Staff/public authz boundary on persistent writes | **Closed** (v0.4.0) | Trusted-write guard `_authorize_persistent_write` (`civicaccess/main.py`) on `POST /api/v1/civicaccess/review` and `POST /api/v1/civicaccess/reviews/{id}/records-export` — requires `CIVICACCESS_TRUSTED_WRITE_TOKEN` via `X-CivicAccess-Write-Token`; 403 on missing/invalid, 503 fail-closed when unconfigured. Public surface uses the new stateless `POST /api/v1/civicaccess/analyze` (no persistence, no token). Tests: `tests/test_citycore_hardening.py::test_review_write_rejects_missing_and_wrong_token`, `::test_records_export_write_requires_token`, `::test_write_guard_not_configured_returns_503`, `::test_analyze_is_open_and_never_persists`. |
+| #3 | Module audit logging on writes/exports | **Closed** (v0.4.0) | `audit_events` table (`civicaccess/access_review.py`) + `record_audit_event`; `review.create` is written in the same transaction as the review, `review.records_export` on export. Tests: `tests/test_citycore_hardening.py::test_audit_event_persisted_on_review_create`, `::test_audit_event_persisted_on_records_export`; Postgres-side in `tests/test_postgres_persistence.py`. |
+| #4 | Backup/restore proof (not declaration) | **Closed** (v0.4.0) | Default store (Postgres) durability: `tests/test_postgres_persistence.py::test_postgres_review_and_audit_survive_reconnect` writes a review + audit, disposes the engine (simulated process restart), reconnects with a fresh engine, and asserts both reload — the property the supervisor's wholesale `Data/postgres` backup relies on. Dev fallback (SQLite): `tests/test_citycore_hardening.py::test_backup_restore_roundtrip_preserves_records_and_audit` backs up the live Data file, loses it, restores, and asserts records + audit survive. The supervisor's end-to-end backup/restore on a clean VM is exercised in Phase D. |
+| #5 | Installer / desktop registry record (6-module city-core) | **Deferred → Phases B–C** | The `civicaccess` record in `CivicSuite/civicsuite` `installer/modules.json` still carries the stale `civiccore_requirement: "1.1.0"` and lacks the full contract fields. Authoring the runtime-valid record (Phase B) and flipping the city-core profile to 6 modules (Phase C) are out of scope for the module repo. |
+| #6 | Clean-VM browser QA + full accessibility acceptance | **Deferred → Phase D** | Exercised on a clean VM (Windows Sandbox) against the installer-built stack with a full accessibility + export-correctness acceptance pass. |
+
+## Phase A persistence model (v0.4.0)
+
+- **Default store:** the shared CivicCore PostgreSQL. The module reads the supervisor-injected
+ `DATABASE_URL` (`postgresql+asyncpg://…:15432/…`) and derives a sync psycopg2 URL via
+ `_sync_database_url` (`civicaccess/main.py`).
+- **Override:** `CIVICACCESS_REVIEW_DB_URL` (a dev SQLite path or a pre-built Postgres URL).
+- **Fallback:** SQLite under `CIVICACCESS_DATA_DIR` only when neither is set (explicit dev use).
+- **Release gate:** `CIVICACCESS_POSTGRES_TEST_URL` is required by `scripts/verify-release.sh` and CI
+ (a `postgres:16` service), so PostgreSQL persistence coverage cannot be silently skipped.
+
+## Notes / deliberate decisions
+
+- `POST /api/v1/civicaccess/export` (the generic export-checklist builder) is **not** token-guarded:
+ it is stateless advisory compute with no persisted data, in the same class as the form/plain-language/
+ workflow planning routes. The records-grade export that reads persisted data
+ (`/reviews/{id}/records-export`) **is** guarded. This is the correct reading of "every
+ persistence-write route".
+- The write token is **never embedded in served HTML**. The staff surface provides a field where the
+ operator pastes the token; it is kept in the browser session (`sessionStorage`) and sent as
+ `X-CivicAccess-Write-Token` on save/export only. The token gates the API for all callers and fails
+ closed (503) when `CIVICACCESS_TRUSTED_WRITE_TOKEN` is unconfigured. The token comparison is
+ constant-time (`hmac.compare_digest`).
diff --git a/README.md b/README.md
index f898d1f..6ed9875 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
CivicAccess is the CivicSuite module for accessibility, plain-language, multilingual, and ADA Title II review-support workflows.
-Current state: **v0.3.0 standalone readiness candidate**. This repo contains a FastAPI package aligned to the published CivicCore v1.2.0 release wheel, health/root endpoints, readiness gates, WCAG-aligned review support, local database-backed review records, accessible form planning, accessible publishing workflow checks, plain-language rewrites, multilingual draft variants, ADA Title II review-support packages, tagged-PDF expectations, records-ready export checklists, an API-backed public review UI at `/civicaccess`, and a staff review/export workspace at `/civicaccess/staff`. The previous `v1.0.0` release was published in error and remains historical evidence only.
+Current state: **v0.4.0 standalone readiness candidate**. This repo contains a FastAPI package aligned to the published CivicCore v1.2.0 release wheel, health/root endpoints, readiness gates, WCAG-aligned review support, database-backed review records that default to the shared CivicCore PostgreSQL, accessible form planning, accessible publishing workflow checks, plain-language rewrites, multilingual draft variants, ADA Title II review-support packages, tagged-PDF expectations, records-ready export checklists, a stateless public accessibility checker at `/civicaccess`, and a staff review/export workspace at `/civicaccess/staff`. As of v0.4.0, persistent writes (saving reviews and records exports) require a trusted-write token, every write/export emits a persisted audit event, and the public surface analyzes without persisting. The previous `v1.0.0` release was published in error and remains historical evidence only.
CivicAccess does **not** provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval. City staff, ADA coordinators, translators, and qualified reviewers remain responsible for publication decisions.
diff --git a/README.txt b/README.txt
index 38fec7c..fcb2749 100644
--- a/README.txt
+++ b/README.txt
@@ -3,7 +3,7 @@ CivicAccess
CivicAccess is the CivicSuite module for accessibility, plain-language, multilingual, and ADA Title II review-support workflows.
-Current state: v0.3.0 corrective demotion state. This repo contains a deterministic scaffold with a FastAPI package aligned to the published CivicCore v1.2.0 release wheel, health/root endpoints, readiness gates, WCAG-aligned review support, optional database-backed review records via CIVICACCESS_REVIEW_DB_URL, accessible form planning, accessible publishing workflow checks, plain-language rewrites, multilingual draft variants, ADA Title II review-support packages, tagged-PDF expectations, records-ready export checklists, and an API-backed public review UI at /civicaccess. The previous v1.0.0 release was published in error and is superseded by this honest sub-1.0.0 label.
+Current state: v0.4.0 standalone readiness candidate. This repo contains a deterministic FastAPI package aligned to the published CivicCore v1.2.0 release wheel, health/root endpoints, readiness gates, WCAG-aligned review support, database-backed review records that default to the shared CivicCore PostgreSQL (with a SQLite dev fallback), accessible form planning, accessible publishing workflow checks, plain-language rewrites, multilingual draft variants, ADA Title II review-support packages, tagged-PDF expectations, records-ready export checklists, a stateless public accessibility checker at /civicaccess, and a trusted-write-token-guarded staff persistence/export surface with persisted audit events. The previous v1.0.0 release was published in error and is superseded by this honest sub-1.0.0 label.
CivicAccess does not provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval. City staff, ADA coordinators, translators, and qualified reviewers remain responsible for publication decisions.
diff --git a/SECURITY.md b/SECURITY.md
index 9ee7fd5..47e658c 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,7 +1,9 @@
# Security
-CivicAccess version: `0.3.0`.
+CivicAccess version: `0.4.0`.
CivicAccess is self-hosted municipal software. It provides advisory accessibility, plain-language, multilingual draft, and ADA Title II review-support workflows; it does not make legal, certification, translation, or publication decisions.
+Persistent writes (saving reviews and records exports) require the `CIVICACCESS_TRUSTED_WRITE_TOKEN` server secret, sent as the `X-CivicAccess-Write-Token` header and compared in constant time. The token is **never embedded in served HTML**: the staff surface provides a field where the operator pastes it, and it is kept only in the browser session. The public surface (`/civicaccess`) analyzes content statelessly and never persists. When the write token is not configured, persistence-backed writes fail closed (HTTP 503) rather than accepting unauthenticated writes.
+
Report vulnerabilities privately through the CivicSuite project maintainers. Do not include secrets, resident data, or protected municipal records in public issues.
diff --git a/USER-MANUAL.md b/USER-MANUAL.md
index a2d6826..41996b2 100644
--- a/USER-MANUAL.md
+++ b/USER-MANUAL.md
@@ -4,7 +4,7 @@
CivicAccess helps cities make public information easier to read, reach, translate, review, and preserve. It supports accessibility review, accessible forms, public publishing workflows, plain-language rewrites, multilingual draft variants, ADA Title II review support, tagged-PDF expectations, and records-ready export checklists.
-Current state: `0.3.0` standalone readiness candidate. CivicAccess includes deterministic checks, local database-backed review records, readiness gates, an API-backed public review UI at `/civicaccess`, a staff review/export workspace at `/civicaccess/staff`, and CivicCore v1.2.0 release-wheel alignment. The previous `v1.0.0` release was published in error and remains historical evidence only. CivicAccess does not provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval.
+Current state: `0.4.0` standalone readiness candidate. CivicAccess includes deterministic checks, database-backed review records that default to the shared CivicCore PostgreSQL (with a SQLite dev fallback), readiness gates, a stateless public accessibility checker at `/civicaccess`, a staff review/export workspace at `/civicaccess/staff`, trusted-write-token-guarded persistence with persisted audit events, and CivicCore v1.2.0 release-wheel alignment. The previous `v1.0.0` release was published in error and remains historical evidence only. CivicAccess does not provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval.
## For IT And Technical Staff
diff --git a/USER-MANUAL.txt b/USER-MANUAL.txt
index 365fb62..1905b2d 100644
--- a/USER-MANUAL.txt
+++ b/USER-MANUAL.txt
@@ -3,7 +3,7 @@ CivicAccess User Manual
CivicAccess helps cities make public information easier to read, reach, translate, review, and preserve. It supports accessibility review, accessible forms, public publishing workflows, plain-language rewrites, multilingual draft variants, ADA Title II review support, tagged-PDF expectations, and records-ready export checklists.
-Current state: 0.3.0 corrective demotion state. CivicAccess includes deterministic checks, optional database-backed review records, readiness gates, an API-backed public review UI at /civicaccess, and CivicCore v1.2.0 release-wheel alignment. The previous v1.0.0 release was published in error and is superseded by this honest sub-1.0.0 label. It does not provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval.
+Current state: 0.4.0 standalone readiness candidate. CivicAccess includes deterministic checks, database-backed review records that default to the shared CivicCore PostgreSQL (with a SQLite dev fallback), readiness gates, a stateless public accessibility checker at /civicaccess, a staff review/export workspace at /civicaccess/staff, trusted-write-token-guarded persistence with persisted audit events, and CivicCore v1.2.0 release-wheel alignment. The previous v1.0.0 release was published in error and is superseded by this honest sub-1.0.0 label. It does not provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval.
Runtime routes:
diff --git a/civicaccess/__init__.py b/civicaccess/__init__.py
index 3f3c522..6dc0269 100644
--- a/civicaccess/__init__.py
+++ b/civicaccess/__init__.py
@@ -1,3 +1,3 @@
"""civicaccess package."""
-__version__ = "0.3.0"
+__version__ = "0.4.0"
diff --git a/civicaccess/access_review.py b/civicaccess/access_review.py
index c30ce3a..95b5cef 100644
--- a/civicaccess/access_review.py
+++ b/civicaccess/access_review.py
@@ -1,4 +1,4 @@
-"""Deterministic accessibility review helpers for CivicAccess v1.0.0."""
+"""Deterministic accessibility review helpers for CivicAccess."""
from __future__ import annotations
@@ -49,7 +49,7 @@ class StoredAccessibilityReview:
metadata = sa.MetaData()
-SCHEMA_VERSION = "2026-06-05-001"
+SCHEMA_VERSION = "civicaccess-windows-local-state-v1"
accessibility_review_records = sa.Table(
"accessibility_review_records",
@@ -66,6 +66,17 @@ class StoredAccessibilityReview:
schema="civicaccess",
)
+audit_events = sa.Table(
+ "audit_events",
+ metadata,
+ sa.Column("event_id", sa.String(36), primary_key=True),
+ sa.Column("action", sa.String(80), nullable=False),
+ sa.Column("subject_id", sa.String(80), nullable=True),
+ sa.Column("actor", sa.String(120), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ schema="civicaccess",
+)
+
schema_migrations = sa.Table(
"schema_migrations",
metadata,
@@ -120,7 +131,7 @@ def schema_status(self) -> SchemaStatus:
inspector = sa.inspect(self.engine)
translated_schema = None if self.engine.dialect.name == "sqlite" else "civicaccess"
available_tables = set(inspector.get_table_names(schema=translated_schema))
- expected_tables = {"accessibility_review_records", "schema_migrations"}
+ expected_tables = {"accessibility_review_records", "audit_events", "schema_migrations"}
missing_tables = tuple(sorted(expected_tables - available_tables))
schema_version = None
if "schema_migrations" not in missing_tables:
@@ -143,7 +154,7 @@ def review_count(self) -> int:
return connection.execute(sa.select(sa.func.count()).select_from(accessibility_review_records)).scalar_one()
def create_review(
- self, *, title: str, body: str, has_alt_text: bool, language: str
+ self, *, title: str, body: str, has_alt_text: bool, language: str, actor: str = "staff"
) -> StoredAccessibilityReview:
review = review_accessibility(
title=title,
@@ -176,8 +187,50 @@ def create_review(
created_at=stored.created_at,
)
)
+ # Audit the write in the same transaction so the trail cannot drift from the record.
+ self.record_audit_event(
+ action="review.create",
+ subject_id=stored.review_id,
+ actor=actor,
+ connection=connection,
+ )
return stored
+ def record_audit_event(
+ self,
+ *,
+ action: str,
+ subject_id: str | None = None,
+ actor: str = "staff",
+ connection: object | None = None,
+ ) -> str:
+ """Persist a who/what/when audit row for a write or export action."""
+
+ event_id = str(uuid4())
+ statement = audit_events.insert().values(
+ event_id=event_id,
+ action=action,
+ subject_id=subject_id,
+ actor=actor,
+ created_at=datetime.now(UTC),
+ )
+ if connection is not None:
+ connection.execute(statement)
+ else:
+ with self.engine.begin() as own_connection:
+ own_connection.execute(statement)
+ return event_id
+
+ def list_audit_events(self, *, limit: int = 50) -> tuple[dict[str, object], ...]:
+ bounded_limit = max(1, min(limit, 200))
+ with self.engine.begin() as connection:
+ rows = connection.execute(
+ sa.select(audit_events)
+ .order_by(audit_events.c.created_at.desc())
+ .limit(bounded_limit)
+ ).mappings().all()
+ return tuple(dict(row) for row in rows)
+
def get_review(self, review_id: str) -> StoredAccessibilityReview | None:
with self.engine.begin() as connection:
row = connection.execute(
diff --git a/civicaccess/exports.py b/civicaccess/exports.py
index 00f0ace..e739a1e 100644
--- a/civicaccess/exports.py
+++ b/civicaccess/exports.py
@@ -1,4 +1,4 @@
-"""Records-ready accessibility export helpers for CivicAccess v1.0.0."""
+"""Records-ready accessibility export helpers for CivicAccess."""
from __future__ import annotations
diff --git a/civicaccess/main.py b/civicaccess/main.py
index 0dd8393..4b1d6ab 100644
--- a/civicaccess/main.py
+++ b/civicaccess/main.py
@@ -1,16 +1,22 @@
"""FastAPI runtime foundation for CivicAccess."""
+import hmac
import os
from pathlib import Path
from civiccore import __version__ as CIVICCORE_VERSION
-from fastapi import FastAPI, HTTPException, Request
+from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel, Field
+from sqlalchemy.engine import make_url
from civicaccess import __version__
-from civicaccess.access_review import AccessibilityReviewRepository, StoredAccessibilityReview
+from civicaccess.access_review import (
+ AccessibilityReviewRepository,
+ StoredAccessibilityReview,
+ review_accessibility,
+)
from civicaccess.exports import build_accessible_export
from civicaccess.multilingual import create_language_variant
from civicaccess.plain_language import rewrite_plain_language
@@ -153,13 +159,39 @@ def public_civicaccess_page() -> str:
@app.get("/civicaccess/staff", response_class=HTMLResponse)
def staff_civicaccess_page() -> str:
- """Return the staff publication review workspace."""
+ """Return the staff publication review workspace.
+
+ The page never embeds the server write token; the operator supplies it in the UI.
+ """
return render_staff_page()
+@app.post("/api/v1/civicaccess/analyze")
+def analyze_accessibility(request: AccessibilityReviewRequest) -> dict[str, object]:
+ """Stateless public accessibility analysis. No persistence, no token required."""
+
+ review = review_accessibility(
+ title=request.title,
+ body=request.body,
+ has_alt_text=request.has_alt_text,
+ language=request.language,
+ )
+ return {
+ "status": review.status,
+ "findings": [finding.__dict__ for finding in review.findings],
+ "disclaimer": review.disclaimer,
+ "next_steps": list(review.next_steps),
+ "persisted": False,
+ }
+
+
@app.post("/api/v1/civicaccess/review")
-def accessibility_review(request: AccessibilityReviewRequest) -> dict[str, object]:
+def accessibility_review(
+ request: AccessibilityReviewRequest,
+ x_civicaccess_write_token: str | None = Header(default=None),
+) -> dict[str, object]:
+ _authorize_persistent_write(x_civicaccess_write_token)
stored = _get_review_repository().create_review(
title=request.title,
body=request.body,
@@ -194,8 +226,13 @@ def get_accessibility_review(review_id: str) -> dict[str, object]:
@app.post("/api/v1/civicaccess/reviews/{review_id}/records-export")
-def export_accessibility_review_record(review_id: str) -> dict[str, object]:
- stored = _get_review_repository().get_review(review_id)
+def export_accessibility_review_record(
+ review_id: str,
+ x_civicaccess_write_token: str | None = Header(default=None),
+) -> dict[str, object]:
+ _authorize_persistent_write(x_civicaccess_write_token)
+ repository = _get_review_repository()
+ stored = repository.get_review(review_id)
if stored is None:
raise HTTPException(
status_code=404,
@@ -205,6 +242,7 @@ def export_accessibility_review_record(review_id: str) -> dict[str, object]:
},
)
export = build_accessible_export(title=stored.title or "Untitled accessible publication")
+ repository.record_audit_event(action="review.records_export", subject_id=review_id)
return {
"status": "records-export-ready",
"module": "civicaccess",
@@ -336,10 +374,55 @@ def accessible_export(request: AccessibleExportRequest) -> dict[str, object]:
}
+def _trusted_write_token() -> str | None:
+ return os.environ.get("CIVICACCESS_TRUSTED_WRITE_TOKEN")
+
+
+def _authorize_persistent_write(provided_token: str | None) -> None:
+ expected_token = _trusted_write_token()
+ if not expected_token:
+ raise HTTPException(
+ status_code=503,
+ detail={
+ "message": "CivicAccess durable write guard is not configured.",
+ "fix": "Set CIVICACCESS_TRUSTED_WRITE_TOKEN before enabling persistence-backed writes.",
+ },
+ )
+ if not hmac.compare_digest(provided_token or "", expected_token):
+ raise HTTPException(
+ status_code=403,
+ detail={
+ "message": "CivicAccess durable write token is missing or invalid.",
+ "fix": "Send the configured X-CivicAccess-Write-Token header for persistence-backed writes.",
+ },
+ )
+
+
+def _sync_database_url(url: str) -> str:
+ """Convert the supervisor's async DATABASE_URL to a sync psycopg2 URL (non-postgres passes through).
+
+ Rewrites only the scheme so passwords/db names containing scheme-marker substrings survive.
+ """
+
+ try:
+ parsed = make_url(url)
+ except Exception:
+ return url
+ if parsed.drivername.startswith(("postgresql", "postgres")):
+ return parsed.set(drivername="postgresql+psycopg2").render_as_string(hide_password=False)
+ return url
+
+
def _review_database_url() -> str | None:
+ # Explicit per-module override wins (dev SQLite or a pre-built Postgres URL).
configured = os.environ.get("CIVICACCESS_REVIEW_DB_URL")
if configured:
- return configured
+ return _sync_database_url(configured)
+ # Default to the shared CivicCore Postgres the desktop supervisor injects.
+ supervisor_url = os.environ.get("DATABASE_URL")
+ if supervisor_url:
+ return _sync_database_url(supervisor_url)
+ # ponytail: SQLite is the explicit dev fallback only when no shared Postgres is configured.
data_dir = Path(os.environ.get("CIVICACCESS_DATA_DIR", Path.cwd() / "data")).resolve()
data_dir.mkdir(parents=True, exist_ok=True)
return f"sqlite:///{data_dir / 'civicaccess-reviews.db'}"
diff --git a/civicaccess/multilingual.py b/civicaccess/multilingual.py
index 64b0229..531d408 100644
--- a/civicaccess/multilingual.py
+++ b/civicaccess/multilingual.py
@@ -1,4 +1,4 @@
-"""Multilingual variant helpers for CivicAccess v1.0.0."""
+"""Multilingual variant helpers for CivicAccess."""
from __future__ import annotations
diff --git a/civicaccess/plain_language.py b/civicaccess/plain_language.py
index ad4194e..b674e1f 100644
--- a/civicaccess/plain_language.py
+++ b/civicaccess/plain_language.py
@@ -1,4 +1,4 @@
-"""Plain-language rewrite helpers for CivicAccess v1.0.0."""
+"""Plain-language rewrite helpers for CivicAccess."""
from __future__ import annotations
diff --git a/civicaccess/public_ui.py b/civicaccess/public_ui.py
index f301410..63f186a 100644
--- a/civicaccess/public_ui.py
+++ b/civicaccess/public_ui.py
@@ -1,4 +1,4 @@
-"""Public UI for CivicAccess v0.3.0."""
+"""Public UI for CivicAccess."""
from __future__ import annotations
@@ -53,7 +53,7 @@ def render_public_lookup_page() -> str:
CivicSuite / CivicAccess
Make public information easier to read, reach, and preserve.
CivicAccess gives staff a deterministic review path for accessible forms, public notices, plain-language rewrites, multilingual samples, ADA Title II review support, tagged-PDF expectations, and municipal-record exports.
-
v0.3.0 standalone readiness candidate
+
v0.4.0 standalone readiness candidate
@@ -146,7 +146,7 @@ def render_public_lookup_page() -> str:
setResult("pending", "Loading review", "Checking the notice text and publication fields.", []);
runReview.disabled = true;
try {
- const response = await fetch("/api/v1/civicaccess/review", {
+ const response = await fetch("/api/v1/civicaccess/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -182,7 +182,11 @@ def render_public_lookup_page() -> str:
def render_staff_page() -> str:
- """Render the staff review workspace for saved CivicAccess work."""
+ """Render the staff review workspace for saved CivicAccess work.
+
+ The page never embeds the server write token. Staff paste it into a field; it is kept in
+ sessionStorage and sent as the X-CivicAccess-Write-Token header on save/export only.
+ """
return """
@@ -240,9 +244,11 @@ def render_staff_page() -> str:
+
+
-
Saved reviews appear in the staff queue and can be exported for records retention.
+
Saving and exporting require the staff write token. Paste it above; it stays in this browser session only.
Accessibility support that keeps humans responsible.
CivicAccess is the accessibility, plain-language, multilingual, and ADA Title II review-support module for CivicSuite.
- v0.3.0 corrective demotion state
+ v0.4.0 standalone readiness candidate
Current State
-
The package contains deterministic accessibility review support, CivicCore v1.2.0 release-wheel alignment, readiness gates, optional database-backed review records via CIVICACCESS_REVIEW_DB_URL, accessible form planning, accessible publishing workflow checks, plain-language rewrite, multilingual draft variants, ADA Title II review-support packages, tagged-PDF expectations, records-ready export checklists, and an API-backed public review UI. The previous v1.0.0 release was published in error and is superseded by this honest sub-1.0.0 label.
+
The package contains deterministic accessibility review support, CivicCore v1.2.0 release-wheel alignment, readiness gates, database-backed review records that default to the shared CivicCore PostgreSQL (with a SQLite dev fallback), accessible form planning, accessible publishing workflow checks, plain-language rewrite, multilingual draft variants, ADA Title II review-support packages, tagged-PDF expectations, records-ready export checklists, a stateless public accessibility checker, and a token-guarded staff persistence/export surface with persisted audit events. The previous v1.0.0 release was published in error and is superseded by this honest sub-1.0.0 label.
CivicAccess does not provide legal advice, certified ADA compliance, official translation certification, live LLM calls, or final publication approval.
diff --git a/pyproject.toml b/pyproject.toml
index 5ed6d49..84f1eac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "civicaccess"
-version = "0.3.0"
+version = "0.4.0"
description = "civicaccess runtime foundation for accessibility, plain-language, multilingual, and ADA review support."
readme = "README.md"
requires-python = ">=3.11"
@@ -15,6 +15,7 @@ authors = [
dependencies = [
"civiccore @ https://github.com/CivicSuite/civiccore/releases/download/v1.2.0/civiccore-1.2.0-py3-none-any.whl#sha256=a94ce958e36fb03c8d961e4db4672ce5bcfa25765c57d75886e999cf15703ec7",
"fastapi>=0.115.0,<1.0.0",
+ "psycopg2-binary>=2.9.0,<3.0.0",
"sqlalchemy>=2.0.0,<3.0.0",
"uvicorn[standard]>=0.30.0,<1.0.0",
]
@@ -29,7 +30,6 @@ allow-direct-references = true
dev = [
"build>=1.2.0,<2.0.0",
"httpx>=0.27.0,<1.0.0",
- "psycopg2-binary>=2.9.0,<3.0.0",
"pytest>=8.0.0,<9.0.0",
"ruff>=0.11.0",
]
diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh
index 49bb823..774812e 100644
--- a/scripts/verify-release.sh
+++ b/scripts/verify-release.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
-VERSION="0.3.0"
+VERSION="0.4.0"
find_python() {
local candidates=()
@@ -40,7 +40,7 @@ ${PYTHON_BIN} - <<'PY'
from pathlib import Path
import tomllib
-version = "0.3.0"
+version = "0.4.0"
root = Path(".")
pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
assert pyproject["project"]["version"] == version, pyproject["project"]["version"]
@@ -56,12 +56,27 @@ for path in [
"SECURITY.md",
]:
text = (root / path).read_text(encoding="utf-8")
- assert "0.3.0" in text, f"missing release version in {path}"
+ assert version in text, f"missing release version in {path}"
assert "0.1.0.dev0" not in text, f"stale dev version in {path}"
+for source in sorted((root / "civicaccess").glob("*.py")):
+ assert "v1.0.0" not in source.read_text(encoding="utf-8"), (
+ f"discredited v1.0.0 label in {source.as_posix()} (the false release; do not reintroduce)"
+ )
print("PASS: version surfaces synchronized")
PY
echo "==> Test suite"
+if [[ -z "${CIVICACCESS_POSTGRES_TEST_URL:-}" ]]; then
+ echo "FAIL: CIVICACCESS_POSTGRES_TEST_URL is required for the release gate so PostgreSQL persistence coverage cannot be skipped." >&2
+ exit 1
+fi
+${PYTHON_BIN} - <<'PY'
+import os
+assert os.environ.get("CIVICACCESS_POSTGRES_TEST_URL"), (
+ "CIVICACCESS_POSTGRES_TEST_URL is not visible to the selected Python interpreter."
+)
+print("PASS: PostgreSQL test URL is visible to the selected Python interpreter")
+PY
${PYTHON_BIN} -m pytest -q
echo "==> Documentation gate"
@@ -81,8 +96,8 @@ from pathlib import Path
import hashlib
dist = Path("dist")
-wheel = dist / "civicaccess-0.3.0-py3-none-any.whl"
-sdist = dist / "civicaccess-0.3.0.tar.gz"
+wheel = dist / "civicaccess-0.4.0-py3-none-any.whl"
+sdist = dist / "civicaccess-0.4.0.tar.gz"
assert wheel.exists(), f"missing {wheel}"
assert sdist.exists(), f"missing {sdist}"
lines = []
diff --git a/tests/conftest.py b/tests/conftest.py
index bca4dab..e20f4d6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,9 +3,16 @@
import civicaccess.main as main_module
+TEST_WRITE_TOKEN = "test-write-token"
+
+
@pytest.fixture(autouse=True)
def isolated_default_data_dir(monkeypatch, tmp_path):
monkeypatch.setenv("CIVICACCESS_DATA_DIR", str(tmp_path / "civicaccess-data"))
+ # Keep the default SQLite path hermetic: do not pick up a shell DATABASE_URL.
+ monkeypatch.delenv("DATABASE_URL", raising=False)
+ # Configure the durable-write guard so persistence tests can authorize writes.
+ monkeypatch.setenv("CIVICACCESS_TRUSTED_WRITE_TOKEN", TEST_WRITE_TOKEN)
yield
main_module._dispose_review_repository()
main_module._review_db_url = None
diff --git a/tests/test_accessibility_foundation.py b/tests/test_accessibility_foundation.py
index fc2a713..fe2fceb 100644
--- a/tests/test_accessibility_foundation.py
+++ b/tests/test_accessibility_foundation.py
@@ -75,6 +75,7 @@ def test_api_review_success_shape() -> None:
response = client.post(
"/api/v1/civicaccess/review",
json={"title": "", "body": "A public notice.", "has_alt_text": False, "language": "en"},
+ headers={"X-CivicAccess-Write-Token": "test-write-token"},
)
assert response.status_code == 200
@@ -85,6 +86,21 @@ def test_api_review_success_shape() -> None:
assert payload["review_id"]
+def test_api_analyze_is_public_and_stateless() -> None:
+ response = client.post(
+ "/api/v1/civicaccess/analyze",
+ json={"title": "", "body": "A public notice.", "has_alt_text": False, "language": "en"},
+ )
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["status"] == "needs-fixes"
+ assert payload["findings"][0]["fix"]
+ assert payload["next_steps"]
+ assert payload["persisted"] is False
+ assert "review_id" not in payload
+
+
def test_api_review_validation_is_actionable() -> None:
missing = client.post("/api/v1/civicaccess/review", json={"title": "Notice"})
oversized = client.post(
@@ -147,7 +163,7 @@ def test_api_public_use_workflow_routes() -> None:
def test_adversarial_inputs_are_actionable_and_non_certifying() -> None:
empty_review = client.post(
- "/api/v1/civicaccess/review",
+ "/api/v1/civicaccess/analyze",
json={"title": "", "body": "", "has_alt_text": False, "language": "en"},
)
unsupported_language = client.post(
@@ -180,9 +196,9 @@ def test_public_ui_route_is_accessible_and_honest() -> None:
text = response.text
assert 'Skip to main content' in text
assert '' in text
- assert "v0.3.0 standalone readiness candidate" in text
+ assert "v0.4.0 standalone readiness candidate" in text
assert 'id="runReview"' in text
- assert 'fetch("/api/v1/civicaccess/review"' in text
+ assert 'fetch("/api/v1/civicaccess/analyze"' in text
assert "result.replaceChildren()" in text
assert "result.innerHTML" not in text
assert "Show empty state" not in text
@@ -206,4 +222,16 @@ def test_staff_ui_route_is_api_wired_and_contract_aware() -> None:
assert 'fetch("/api/v1/civicaccess/integration-contracts")' in text
assert 'fetch("/api/v1/civicaccess/reviews")' in text
assert 'records-export' in text
+ assert "X-CivicAccess-Write-Token" in text
+ assert 'id="writeToken"' in text
+ assert "currentToken()" in text
assert "result.innerHTML" not in text
+
+
+def test_staff_page_never_leaks_the_write_token(monkeypatch) -> None:
+ monkeypatch.setenv("CIVICACCESS_TRUSTED_WRITE_TOKEN", "super-secret-sentinel-do-not-leak")
+ response = client.get("/civicaccess/staff")
+
+ assert response.status_code == 200
+ # The server secret must never appear in HTML served from an unauthenticated GET.
+ assert "super-secret-sentinel-do-not-leak" not in response.text
diff --git a/tests/test_citycore_hardening.py b/tests/test_citycore_hardening.py
new file mode 100644
index 0000000..f786426
--- /dev/null
+++ b/tests/test_citycore_hardening.py
@@ -0,0 +1,155 @@
+"""Phase A city-core hardening proofs: write authz (#2), audit events (#3), backup/restore (#4)."""
+
+from __future__ import annotations
+
+import shutil
+
+from fastapi.testclient import TestClient
+
+import civicaccess.main as main_module
+from civicaccess.access_review import AccessibilityReviewRepository
+from civicaccess.main import app
+
+
+client = TestClient(app)
+
+VALID_HEADER = {"X-CivicAccess-Write-Token": "test-write-token"}
+WRONG_HEADER = {"X-CivicAccess-Write-Token": "not-the-token"}
+REVIEW_BODY = {"title": "Notice", "body": "A public notice.", "has_alt_text": True, "language": "en"}
+
+
+# --- probe gap #2: persistent writes require the trusted-write token -------------------------------
+
+def test_review_write_rejects_missing_and_wrong_token(monkeypatch, tmp_path) -> None:
+ monkeypatch.setenv("CIVICACCESS_REVIEW_DB_URL", f"sqlite:///{tmp_path / 'authz.db'}")
+ try:
+ no_token = client.post("/api/v1/civicaccess/review", json=REVIEW_BODY)
+ wrong_token = client.post("/api/v1/civicaccess/review", json=REVIEW_BODY, headers=WRONG_HEADER)
+ ok = client.post("/api/v1/civicaccess/review", json=REVIEW_BODY, headers=VALID_HEADER)
+ finally:
+ main_module._dispose_review_repository()
+ main_module._review_db_url = None
+
+ assert no_token.status_code == 403
+ assert "missing or invalid" in no_token.json()["detail"]["message"]
+ assert wrong_token.status_code == 403
+ assert ok.status_code == 200
+ assert ok.json()["review_id"]
+
+
+def test_records_export_write_requires_token(monkeypatch, tmp_path) -> None:
+ monkeypatch.setenv("CIVICACCESS_REVIEW_DB_URL", f"sqlite:///{tmp_path / 'authz-export.db'}")
+ try:
+ created = client.post("/api/v1/civicaccess/review", json=REVIEW_BODY, headers=VALID_HEADER)
+ review_id = created.json()["review_id"]
+ no_token = client.post(f"/api/v1/civicaccess/reviews/{review_id}/records-export")
+ ok = client.post(
+ f"/api/v1/civicaccess/reviews/{review_id}/records-export", headers=VALID_HEADER
+ )
+ finally:
+ main_module._dispose_review_repository()
+ main_module._review_db_url = None
+
+ assert no_token.status_code == 403
+ assert ok.status_code == 200
+ assert ok.json()["status"] == "records-export-ready"
+
+
+def test_write_guard_not_configured_returns_503(monkeypatch, tmp_path) -> None:
+ monkeypatch.setenv("CIVICACCESS_REVIEW_DB_URL", f"sqlite:///{tmp_path / 'authz-unset.db'}")
+ monkeypatch.delenv("CIVICACCESS_TRUSTED_WRITE_TOKEN", raising=False)
+ try:
+ response = client.post("/api/v1/civicaccess/review", json=REVIEW_BODY, headers=VALID_HEADER)
+ finally:
+ main_module._dispose_review_repository()
+ main_module._review_db_url = None
+
+ assert response.status_code == 503
+ assert "CIVICACCESS_TRUSTED_WRITE_TOKEN" in response.json()["detail"]["fix"]
+
+
+def test_analyze_is_open_and_never_persists(monkeypatch, tmp_path) -> None:
+ monkeypatch.setenv("CIVICACCESS_REVIEW_DB_URL", f"sqlite:///{tmp_path / 'analyze.db'}")
+ try:
+ response = client.post("/api/v1/civicaccess/analyze", json=REVIEW_BODY)
+ listed = client.get("/api/v1/civicaccess/reviews")
+ finally:
+ main_module._dispose_review_repository()
+ main_module._review_db_url = None
+
+ assert response.status_code == 200
+ assert response.json()["persisted"] is False
+ assert listed.json()["count"] == 0 # analyze wrote nothing
+
+
+# --- probe gap #3: audit events emitted + persisted on writes/exports ------------------------------
+
+def test_audit_event_persisted_on_review_create(tmp_path) -> None:
+ repository = AccessibilityReviewRepository(db_url=f"sqlite:///{tmp_path / 'audit.db'}")
+ try:
+ stored = repository.create_review(title="N", body="text", has_alt_text=True, language="en")
+ events = repository.list_audit_events()
+ finally:
+ repository.engine.dispose()
+
+ assert len(events) == 1
+ assert events[0]["action"] == "review.create"
+ assert events[0]["subject_id"] == stored.review_id
+ assert events[0]["actor"] == "staff"
+ assert events[0]["created_at"] is not None
+
+
+def test_audit_event_persisted_on_records_export(monkeypatch, tmp_path) -> None:
+ monkeypatch.setenv("CIVICACCESS_REVIEW_DB_URL", f"sqlite:///{tmp_path / 'audit-export.db'}")
+ try:
+ created = client.post("/api/v1/civicaccess/review", json=REVIEW_BODY, headers=VALID_HEADER)
+ review_id = created.json()["review_id"]
+ client.post(
+ f"/api/v1/civicaccess/reviews/{review_id}/records-export", headers=VALID_HEADER
+ )
+ events = main_module._get_review_repository().list_audit_events()
+ finally:
+ main_module._dispose_review_repository()
+ main_module._review_db_url = None
+
+ actions = {event["action"] for event in events}
+ assert "review.create" in actions
+ assert "review.records_export" in actions
+
+
+# --- probe gap #4: data survives a backup -> restore round-trip ------------------------------------
+
+def test_backup_restore_roundtrip_preserves_records_and_audit(tmp_path) -> None:
+ """SQLite dev-fallback durability: back up the live Data dir, lose it, restore, reload.
+
+ The Postgres default store's durability is proven separately in
+ tests/test_postgres_persistence.py::test_postgres_review_and_audit_survive_reconnect.
+ """
+
+ db_path = tmp_path / "data" / "civicaccess-reviews.db"
+ db_path.parent.mkdir(parents=True, exist_ok=True)
+
+ repository = AccessibilityReviewRepository(db_url=f"sqlite:///{db_path}")
+ stored = repository.create_review(title="Hearing", body="text", has_alt_text=True, language="en")
+
+ # Supervisor backup = recursive file copy of the Data dir, taken while the service is live.
+ backup_path = tmp_path / "backup" / "civicaccess-reviews.db"
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(db_path, backup_path)
+ repository.engine.dispose()
+
+ # Lose the live data, then restore from the backup copy.
+ db_path.unlink()
+ shutil.copy2(backup_path, db_path)
+
+ restored = AccessibilityReviewRepository(db_url=f"sqlite:///{db_path}")
+ try:
+ reloaded = restored.get_review(stored.review_id)
+ audit = restored.list_audit_events()
+ finally:
+ restored.engine.dispose()
+
+ assert reloaded is not None
+ assert reloaded.review_id == stored.review_id
+ assert reloaded.status == stored.status
+ assert any(event["subject_id"] == stored.review_id for event in audit)
diff --git a/tests/test_database_url_selection.py b/tests/test_database_url_selection.py
new file mode 100644
index 0000000..2b2c4f9
--- /dev/null
+++ b/tests/test_database_url_selection.py
@@ -0,0 +1,76 @@
+"""Coverage for the Phase A persistence default: DATABASE_URL -> sync Postgres, SQLite fallback."""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy.engine import make_url
+
+import civicaccess.main as main_module
+
+
+@pytest.mark.parametrize(
+ "raw",
+ [
+ "postgresql+asyncpg://civicsuite:abc123@127.0.0.1:15432/civicsuite",
+ "postgres+asyncpg://u:p@h:15432/db",
+ "postgresql://u:p@h:5432/db",
+ "postgres://u:p@h/db",
+ ],
+)
+def test_sync_database_url_flips_scheme_to_psycopg2(raw) -> None:
+ src = make_url(raw)
+ out = make_url(main_module._sync_database_url(raw))
+ assert out.drivername == "postgresql+psycopg2"
+ # Everything except the driver must be preserved exactly.
+ assert (out.username, out.password, out.host, out.port, out.database) == (
+ src.username,
+ src.password,
+ src.host,
+ src.port,
+ src.database,
+ )
+
+
+def test_sync_database_url_passes_through_sqlite() -> None:
+ assert main_module._sync_database_url("sqlite:///x/y.db") == "sqlite:///x/y.db"
+
+
+def test_sync_database_url_preserves_credentials_with_marker_substrings() -> None:
+ # A password/dbname containing scheme-marker text must survive (no global str.replace mangling).
+ raw = make_url("postgresql+asyncpg://u:pw@h:15432/db").set(
+ password="x-postgres+asyncpg-y"
+ )
+ out = make_url(main_module._sync_database_url(raw.render_as_string(hide_password=False)))
+ assert out.drivername == "postgresql+psycopg2"
+ assert out.password == "x-postgres+asyncpg-y"
+ assert out.database == "db"
+
+
+def test_review_database_url_prefers_supervisor_postgres(monkeypatch) -> None:
+ monkeypatch.delenv("CIVICACCESS_REVIEW_DB_URL", raising=False)
+ monkeypatch.setenv(
+ "DATABASE_URL", "postgresql+asyncpg://civicsuite:pw@127.0.0.1:15432/civicsuite"
+ )
+ assert (
+ main_module._review_database_url()
+ == "postgresql+psycopg2://civicsuite:pw@127.0.0.1:15432/civicsuite"
+ )
+
+
+def test_review_database_url_override_wins_over_supervisor(monkeypatch) -> None:
+ monkeypatch.setenv(
+ "DATABASE_URL", "postgresql+asyncpg://civicsuite:pw@127.0.0.1:15432/civicsuite"
+ )
+ monkeypatch.setenv(
+ "CIVICACCESS_REVIEW_DB_URL", "postgresql+psycopg2://o:o@h:5432/override"
+ )
+ assert main_module._review_database_url() == "postgresql+psycopg2://o:o@h:5432/override"
+
+
+def test_review_database_url_falls_back_to_sqlite(monkeypatch, tmp_path) -> None:
+ monkeypatch.delenv("CIVICACCESS_REVIEW_DB_URL", raising=False)
+ monkeypatch.delenv("DATABASE_URL", raising=False)
+ monkeypatch.setenv("CIVICACCESS_DATA_DIR", str(tmp_path / "data"))
+ url = main_module._review_database_url()
+ assert url.startswith("sqlite:///")
+ assert "civicaccess-reviews.db" in url
diff --git a/tests/test_postgres_persistence.py b/tests/test_postgres_persistence.py
new file mode 100644
index 0000000..f7d64bb
--- /dev/null
+++ b/tests/test_postgres_persistence.py
@@ -0,0 +1,89 @@
+"""PostgreSQL persistence coverage. Gated on CIVICACCESS_POSTGRES_TEST_URL (the release gate)."""
+
+from __future__ import annotations
+
+import os
+
+import pytest
+import sqlalchemy as sa
+
+from civicaccess.access_review import AccessibilityReviewRepository
+
+
+@pytest.mark.skipif(
+ not os.environ.get("CIVICACCESS_POSTGRES_TEST_URL"),
+ reason="CIVICACCESS_POSTGRES_TEST_URL is required for PostgreSQL persistence coverage.",
+)
+def test_postgres_persistence_creates_schema_and_round_trips_reviews() -> None:
+ db_url = os.environ["CIVICACCESS_POSTGRES_TEST_URL"]
+ engine = sa.create_engine(db_url, future=True)
+ with engine.begin() as connection:
+ connection.execute(sa.text("DROP SCHEMA IF EXISTS civicaccess CASCADE"))
+ engine.dispose()
+
+ repository = AccessibilityReviewRepository(db_url=db_url)
+ stored = repository.create_review(
+ title="Budget hearing notice",
+ body="Residents may ask for help before the hearing.",
+ has_alt_text=True,
+ language="en",
+ )
+ repository.record_audit_event(action="review.records_export", subject_id=stored.review_id)
+
+ reloaded = repository.get_review(stored.review_id)
+ assert reloaded is not None
+ assert reloaded.review_id == stored.review_id
+ assert reloaded.title == "Budget hearing notice"
+
+ actions = {event["action"] for event in repository.list_audit_events()}
+ assert actions == {"review.create", "review.records_export"}
+
+ status = repository.schema_status()
+ assert status.ready is True
+ assert status.dialect == "postgresql"
+
+ with repository.engine.begin() as connection:
+ schema_exists = connection.execute(
+ sa.text(
+ "select exists(select 1 from information_schema.schemata "
+ "where schema_name='civicaccess')"
+ )
+ ).scalar_one()
+ repository.engine.dispose()
+ assert schema_exists is True
+
+
+@pytest.mark.skipif(
+ not os.environ.get("CIVICACCESS_POSTGRES_TEST_URL"),
+ reason="CIVICACCESS_POSTGRES_TEST_URL is required for PostgreSQL persistence coverage.",
+)
+def test_postgres_review_and_audit_survive_reconnect() -> None:
+ """Durability proof for the DEFAULT store: data persists in the cluster across a fresh engine.
+
+ This is the property the desktop supervisor's wholesale Data/postgres backup relies on; the
+ end-to-end supervisor backup/restore is exercised on a clean VM in Phase D.
+ """
+
+ db_url = os.environ["CIVICACCESS_POSTGRES_TEST_URL"]
+ engine = sa.create_engine(db_url, future=True)
+ with engine.begin() as connection:
+ connection.execute(sa.text("DROP SCHEMA IF EXISTS civicaccess CASCADE"))
+ engine.dispose()
+
+ repository = AccessibilityReviewRepository(db_url=db_url)
+ stored = repository.create_review(
+ title="Durable", body="text", has_alt_text=True, language="en"
+ )
+ repository.record_audit_event(action="review.records_export", subject_id=stored.review_id)
+ repository.engine.dispose() # drop the connection/engine — simulate a process restart
+
+ reconnected = AccessibilityReviewRepository(db_url=db_url)
+ try:
+ reloaded = reconnected.get_review(stored.review_id)
+ actions = {event["action"] for event in reconnected.list_audit_events()}
+ finally:
+ reconnected.engine.dispose()
+
+ assert reloaded is not None
+ assert reloaded.review_id == stored.review_id
+ assert {"review.create", "review.records_export"} <= actions
diff --git a/tests/test_production_depth_review_persistence.py b/tests/test_production_depth_review_persistence.py
index 6a2ec4c..e639951 100644
--- a/tests/test_production_depth_review_persistence.py
+++ b/tests/test_production_depth_review_persistence.py
@@ -79,6 +79,7 @@ def test_api_persists_and_retrieves_review_records(monkeypatch, tmp_path) -> Non
create_response = client.post(
"/api/v1/civicaccess/review",
json={"title": "", "body": "A public notice.", "has_alt_text": False, "language": "en"},
+ headers={"X-CivicAccess-Write-Token": "test-write-token"},
)
review_id = create_response.json()["review_id"]
get_response = client.get(f"/api/v1/civicaccess/reviews/{review_id}")
@@ -180,10 +181,14 @@ def test_review_list_and_records_export_contract(monkeypatch, tmp_path) -> None:
"has_alt_text": True,
"language": "en",
},
+ headers={"X-CivicAccess-Write-Token": "test-write-token"},
)
review_id = create_response.json()["review_id"]
list_response = client.get("/api/v1/civicaccess/reviews")
- export_response = client.post(f"/api/v1/civicaccess/reviews/{review_id}/records-export")
+ export_response = client.post(
+ f"/api/v1/civicaccess/reviews/{review_id}/records-export",
+ headers={"X-CivicAccess-Write-Token": "test-write-token"},
+ )
contracts_response = client.get("/api/v1/civicaccess/integration-contracts")
finally:
main_module._dispose_review_repository()
diff --git a/tests/test_runtime_foundation.py b/tests/test_runtime_foundation.py
index 79ec33b..ad9988b 100644
--- a/tests/test_runtime_foundation.py
+++ b/tests/test_runtime_foundation.py
@@ -10,8 +10,8 @@
ROOT = Path(__file__).resolve().parents[1]
-def test_package_version_is_020() -> None:
- assert civicaccess.__version__ == "0.3.0"
+def test_package_version_is_040() -> None:
+ assert civicaccess.__version__ == "0.4.0"
def test_pyproject_uses_published_civiccore_release_wheel() -> None:
@@ -33,7 +33,7 @@ def test_root_endpoint_states_runtime_boundary() -> None:
payload = response.json()
assert payload["name"] == "CivicAccess"
- assert payload["version"] == "0.3.0"
+ assert payload["version"] == "0.4.0"
assert payload["status"] == "standalone readiness candidate"
assert "staff interfaces" in payload["message"]
assert "does not provide legal advice" in payload["message"]
@@ -47,5 +47,5 @@ def test_health_endpoint_reports_versions() -> None:
assert payload["status"] == "ok"
assert payload["service"] == "civicaccess"
- assert payload["version"] == "0.3.0"
+ assert payload["version"] == "0.4.0"
assert payload["civiccore_version"] == "1.2.0"