Local Development & Testing¶
This document captures how a contributor develops and tests PrivaCI locally. It is the day-1 reference for setting up a workstation, running the test suites, and exercising the engine end-to-end against real PostgreSQL databases.
The conventions here are derived from .cursorrules, the OpenSpec
change init-privaci-engine, and the deployment-artifacts spec.
Where this document and .cursorrules disagree, .cursorrules wins.
1. Workstation Prerequisites¶
| Tool | Minimum version | Notes |
|---|---|---|
| Python | 3.12 (exactly) | Per ADR-0002. No 3.11 shims. SpaCy (nlp extra) has no wheels for 3.13/3.14 — a non-3.12 venv silently lacks SpaCy. Verify with python --version. |
| Docker or Podman + Compose v2 | recent | For compose fixtures and the demo run. Podman is the rootless Fedora-native option (see §4.4). On Windows use Docker Desktop (WSL2 backend) or WSL2 — see deployment.md § Windows. |
make |
any | Convenience targets only |
git |
recent | |
pre-commit |
recent | Installed via pip install pre-commit |
A working psql is helpful for ad-hoc inspection but not required —
tests use asyncpg directly.
2. First-Time Setup¶
# 1. Clone and enter the repo
git clone git@github.com:privaci/privaci.git
cd privaci
# 2. Create and activate a venv — MUST be Python 3.12
python3.12 -m venv .venv
source .venv/bin/activate
python --version # verify: Python 3.12.x
# NOTE: The venv interpreter MUST be 3.12. The `nlp` extra (SpaCy 3.8.x) has
# no wheels for 3.13/3.14, so a 3.14 venv will silently skip SpaCy and the
# spikes will fail with "No module named spacy". If `python --version` is not
# 3.12, recreate the venv: `rm -rf .venv && python3.12 -m venv .venv`.
# 3. Install pinned dev dependencies (lockfile from pip-compile)
pip install -r requirements-dev.txt
pip install -e ".[nlp]"
python -m spacy download en_core_web_sm
# 4. Initialize git (required for pre-commit) and install hooks
git init
pre-commit install
pre-commit install --hook-type commit-msg # ADR-0007 commit-message language guard
# 5. Create a local .env (never commit this file)
cp .env.example .env
# Edit .env to point at your local Postgres instances and salt source.
# 6. Generate a local salt
privaci gen-salt > .privaci-salt
chmod 600 .privaci-salt
# Add ANONYMIZATION_SALT=file:///$(pwd)/.privaci-salt to .env
# 7. Bring up local Postgres (Docker or Podman — see §4)
docker compose -f compose.dev.yml up -d
# Fedora/rootless alternative:
# podman compose -f compose.dev.yml up -d
# 8. Sanity-check
pytest -q -m "not integration"
After step 8 you should see all unit tests pass in seconds.
2.1 Branch-first workflow¶
Do not push feature or fix commits directly to main. Use a branch and a PR:
git checkout main
git pull
git checkout -b feat/my-change
# … edit, commit …
# Same gates as CI (run before push):
black --check src/ tests/
isort --check-only --profile black src/ tests/
ruff check src/ tests/
mypy src/ --strict
pytest --cov=src --cov-fail-under=85 -m "not integration"
python scripts/check_public_repo_language.py
git push -u origin HEAD
gh pr create
Release tags (v*) are cut on main after merge, when you intentionally ship a
release — not from unreviewed feature branches.
3. The Three Test Layers¶
Tests are organized so the fast layer runs in seconds and the slow layer is opt-in. The CI default mirrors the fast layer exactly so local and CI feedback loops are identical.
┌──────────────────────────────────────────────────────────────┐
│ Layer 1 — Unit (default `pytest`) │
│ • masking pipeline (purity, determinism, NULL preservation) │
│ • deterministic faker (property tests via hypothesis) │
│ • config validation, secrets URI parsing │
│ • CLI parsing via typer.testing.CliRunner │
│ • No network. No real DB. Runtime: seconds. │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Layer 2 — Integration (`pytest -m integration`) │
│ • catalog introspection vs real schemas │
│ • schema replication (DDL clone) │
│ • COPY-binary streaming source→target │
│ • `_privaci` schema lifecycle, checkpoint atomicity │
│ • resume after crash │
│ • Backed by pytest-postgresql or compose Postgres │
│ • Runtime: minutes. │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Layer 3 — Smoke / End-to-End │
│ • `docker compose up` against the demo dataset │
│ • Full container, full CLI, full mask run │
│ • Validates packaging, image, image size, non-root user │
│ • Runtime: ~60 seconds for the demo dataset │
└──────────────────────────────────────────────────────────────┘
3.1. Markers and Filtering¶
Per .cursorrules §2, any test that opens a real DB connection MUST
be marked @pytest.mark.integration. The CI gate runs:
pytest --cov=src --cov-fail-under=85 -m "not integration"
Integration tests are a separate CI job that runs less frequently (every push, but in a parallel workflow) so the main feedback loop is fast.
Useful invocations:
pytest # unit only (default)
pytest -m integration # integration only (Demo Corp e2e, catalog, edge-case SQL)
pytest -m "integration and not slow" # subset
pytest tests/masking/ # one package
pytest tests/masking/test_faker.py::TestEmailFaker::test_determinism
pytest -k "deterministic" # name match
pytest --lf # only failures from last run
pytest --cov=src/masking --cov-report=term-missing tests/masking/
3.2. Coverage¶
- ≥85% overall (CI gate via
--cov-fail-under=85). - 100% on
src/masking/,src/config/, and the commercialsrc/billing/package when it exists. - Every new function MUST have at least one positive and one negative/edge test.
To inspect coverage gaps locally:
pytest --cov=src --cov-report=term-missing -m "not integration"
pytest --cov=src --cov-report=html -m "not integration"
open htmlcov/index.html
3.3. Fixtures¶
- All shared setup uses
pytest.fixture. Scope is explicit (functionis the default;module/sessionwhen shared). - DB fixtures use
pytest-postgresql(preferred for per-test isolation) or a long-running compose Postgres (for performance tests). Tests SHALL NEVER point at a shared or production DB. - Test constants live in
tests/fixtures/constants.py. No magic strings inline in test bodies. - Data generation uses
Fakerseeded withFaker().seed_instance(42)for repeatability. - Mocking uses
pytest-mock(themockerfixture). Directmonkeypatchis reserved for env vars wheremocker.patch.dictis clumsy.
3.4. Mutation testing (opt-in)¶
Coverage tells you which lines ran; mutation testing tells you whether the
tests would actually catch a bug in those lines. We use
cosmic-ray against the masking core
(src/privaci/mask/), scoped by the committed cosmic-ray.toml.
# One-time per session DB (re-init after changing source or tests):
cosmic-ray init cosmic-ray.toml mutation.sqlite
cosmic-ray baseline cosmic-ray.toml # tests must pass un-mutated
cosmic-ray exec cosmic-ray.toml mutation.sqlite
cr-rate mutation.sqlite # surviving-mutant % (lower is better)
cr-report mutation.sqlite # per-mutant detail
A surviving mutant is either a missing assertion (add a test) or an
equivalent mutant that cannot change observable behaviour (e.g. an
lru_cache capacity). Session files (*.sqlite) are git-ignored.
4. Local Postgres via Docker Compose¶
Two compose files coexist:
| File | Purpose |
|---|---|
compose.dev.yml |
Long-running source + target Postgres for development and integration tests |
compose.yml |
Self-contained demo: source + target + engine; used for smoke runs and customer-facing evaluation |
4.1. compose.dev.yml (Development)¶
services:
source-pg:
image: postgres:16
environment:
POSTGRES_PASSWORD: dev
POSTGRES_DB: privaci_source
ports:
- "55432:5432"
volumes:
- source-pgdata:/var/lib/postgresql/data
- ./tests/fixtures/sql/demo-corp:/docker-entrypoint-initdb.d:ro
target-pg:
image: postgres:16
environment:
POSTGRES_PASSWORD: dev
POSTGRES_DB: privaci_target
ports:
- "55433:5432"
volumes:
- target-pgdata:/var/lib/postgresql/data
volumes:
source-pgdata:
target-pgdata:
Notable choices:
- Ports 55432 / 55433 avoid clashing with a system Postgres on 5432.
- Source is auto-seeded from
tests/fixtures/sql/demo-corp/via thedocker-entrypoint-initdb.dconvention on first volume creation. Seedocs/test-fixtures.mdfor the schema, tiers, and regeneration commands (make fixtures-generate). - Target is empty by default — this matches the engine's default
on_existing_data: fail.
Environment vars for a contributor's .env:
SOURCE_DB_URL=postgresql://postgres:dev@localhost:55432/privaci_source
TARGET_DB_URL=postgresql://postgres:dev@localhost:55433/privaci_target
ANONYMIZATION_SALT=file:///absolute/path/to/.privaci-salt
PRIVACI_LOG_LEVEL=info
4.2. compose.yml (Demo / Smoke)¶
The demo compose file at the repo root is what a customer or evaluator runs:
export ANONYMIZATION_SALT="$(python -m privaci gen-salt)"
docker compose up
It builds the Dockerfile image, seeds source Postgres from
deploy/demo-seed/, and runs privaci run with
examples/mask-rules.demo.yaml under a read-only root filesystem.
Expected behaviour per the deployment-artifacts spec:
- Within 60 seconds the engine streams source → masked target.
- stdout emits a
run.endevent withstatus: succeeded. - Container exits 0.
This file is part of release CI's smoke gate: every release pipeline
must successfully docker compose up against the published image
before the tag is promoted.
4.3. Resetting the Local Environment¶
docker compose -f compose.dev.yml down -v # nukes volumes
docker compose -f compose.dev.yml up -d # re-seed source
For a faster reset during integration test development:
pytest-postgresql --help # one-shot ephemeral instances per test
4.4. Docker vs Podman (Fedora / rootless)¶
The compose file is engine-agnostic. Pick whichever is installed:
# Docker (rootful by default on Fedora): add yourself to the docker group once
sudo usermod -aG docker "$USER" && newgrp docker
docker compose -f compose.dev.yml up -d
# Podman (rootless, Fedora-native) — drop-in replacement
podman compose -f compose.dev.yml up -d
# If 'podman compose' is unavailable:
sudo dnf install -y podman-compose && podman-compose -f compose.dev.yml up -d
If you see
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock, your user is not in thedockergroup — use theusermodline above or switch to Podman.
5. The Day-to-Day Loop¶
A typical inner loop while implementing a module:
# 1. Edit src/privaci/<module>/...
# 2. Fast unit tests for the package you touched
pytest tests/<module>/ -q
# 3. Lint + types incrementally
ruff check src/privaci/<module>/
mypy src/privaci/<module>/ --strict
# 4. Coverage check on the touched package
pytest --cov=src/privaci/<module> --cov-report=term-missing tests/<module>/
# 5. (when DB matters) integration tests
pytest -m integration tests/<module>/
When you're ready to push:
# Run exactly what CI runs (per .cursorrules §7)
black --check src/ tests/
isort --check-only --profile black src/ tests/
ruff check src/ tests/
mypy src/ --strict
pytest -m "not integration" --cov=src --cov-fail-under=85
pip-audit --requirement requirements.txt
Pre-commit hooks run a subset of these on git commit automatically.
6.1 Capability test harness¶
Resource-safe by default. Postgres compose and integration tests require
--allow-heavy plus healthy RAM/swap. Use --plan to preview without running.
SpaCy required for masking audit. Capabilities that exercise demo-corp L2
(ner_mask on ticket_messages.body, patient_visits.visit_notes) declare
requires_nlp. The suite refuses to start if spacy or en_core_web_sm is
missing — install per §1 step 3 before standard / full suites.
Reports are version 2: each capability includes scope (infrastructure vs masking audit), a Masking confidence summary (leak probes, verify, subsetting verdict), and per-capability metrics (retention tables, FK checks, pytest summary).
# List capabilities, groups, and suites
./scripts/capability-test.sh --list
# Safe — unit tests only (no Postgres)
./scripts/capability-test.sh --cap public-detect-drift,commercial-jsonb-transform
# Quick suite — all public + commercial unit tests
./scripts/capability-test-suite.sh quick
# Full validation — unit, then public integration, then commercial integration
./scripts/capability-test-suite.sh standard --allow-heavy --reset-volumes
# Single integration capability
./scripts/capability-test.sh --plan --allow-heavy --cap commercial-subsetting
./scripts/capability-test.sh --allow-heavy --cap commercial-subsetting --no-compose
Suites: quick (unit only) · public · commercial · standard / full
(phased: unit → public integration → commercial integration, one Postgres session).
Reports: reports/capability-tests/ in the repo you invoke (absolute paths at
the end). From privaci-commercial, use ./scripts/capability-test.sh or
./scripts/capability-test-suite.sh — reports default to
privaci-commercial/reports/capability-tests/.
If the script prints REFUSED, free memory first — do not bypass.
6. Exercising the Engine Locally¶
With compose databases running and env vars set (SOURCE_DB_URL,
TARGET_DB_URL, ANONYMIZATION_SALT):
source .venv/bin/activate
export SOURCE_DB_URL=postgresql://postgres:dev@127.0.0.1:55432/privaci_source
export TARGET_DB_URL=postgresql://postgres:dev@127.0.0.1:55433/privaci_target
export ANONYMIZATION_SALT="$(privaci gen-salt)"
# Pre-flight only, no writes
privaci dry-run --config examples/mask-rules.example.yaml
# Actual masking run (same as bare `privaci` with default flags)
privaci run --config examples/mask-rules.example.yaml
# Inspect what the engine recorded
psql "$TARGET_DB_URL" -c "SELECT * FROM _privaci.runs ORDER BY started_at DESC LIMIT 5;"
psql "$TARGET_DB_URL" -c "SELECT event_type, count(*) FROM _privaci.audit_log GROUP BY 1;"
# Test the resume path (manual)
privaci run --config tests/fixtures/configs/demo-corp.yaml &
sleep 5
kill -9 %1
privaci resume --config tests/fixtures/configs/demo-corp.yaml
7. What You Do NOT Need Locally¶
| Capability | Why it's not needed for local dev |
|---|---|
| AWS account | Engine uses local Postgres + env:// or file:// secret URIs |
| Marketplace / metering | Community-mode NoOpLicenseValidator runs unrestricted |
| Bedrock / Azure OpenAI | L3 is commercial; L1+L2 are sufficient for local tests |
| Helm / Kubernetes | Compose covers local; Helm only for prod-shape verification |
| LocalStack | Only needed if you're modifying aws-sm:// resolver code |
| Azurite | Only needed if you're modifying azure-kv:// resolver code |
| Real production data | Explicitly forbidden — .cursorrules §2 / §3 |
8. Security Discipline for Test Code¶
These are non-negotiable per .cursorrules §3:
- No real PII, ever — not in fixtures, not in commit messages, not in test docstrings. Even synthetic-looking real data is prohibited.
- No hardcoded secrets. All test-time secrets are either
generated at runtime (
secrets.token_hex) or read from env vars that the contributor sets in their.env. - Tests must verify the original value is not present in output
for any masking test. The
assert original not in serialized_rowpattern is mandatory for masking unit tests. .env,.privaci-salt,*.pem,*.keyare in.gitignore. Verify this is true before committing in any new branch.
9. Common Pitfalls¶
| Symptom | Likely cause |
|---|---|
No module named spacy |
venv is not Python 3.12 (SpaCy has no 3.13/3.14 wheels). python --version; if not 3.12, rm -rf .venv && python3.12 -m venv .venv and reinstall. |
permission denied ... /var/run/docker.sock |
User not in docker group. sudo usermod -aG docker $USER && newgrp docker, or use Podman (§4.4). |
Connect call failed (... 55432) |
Postgres never started (usually a downstream effect of the docker.sock error). Bring compose up first. |
| Tests pass locally, fail in CI | Forgot to run pre-commit / different Python version. Re-create the venv with python3.12. |
_privaci schema permission errors |
Local target Postgres role lacks CREATE SCHEMA. Use the superuser postgres for dev. |
| Integration tests hang | Old compose volumes hold a partial run. docker compose down -v and retry. |
| Coverage falls below 85% | Find untested lines: pytest --cov-report=term-missing and read the report. Add tests, do not lower the gate. |
mypy errors only locally |
Stub package out of date. pip install -U types-PyYAML types-... or re-run pip-compile. |
pip-audit fails |
A dependency has a new CVE. Update the lockfile and review the changelog before bumping security-sensitive packages. |
10. Where to Look Next¶
docs/README.md— the documentation index (start here).docs/error-codes.md— every exit code and the Context + Cause + Remediation message format.docs/architecture/memory-model.md— how RAM stays bounded on large databases (batch sizing, backpressure, K8s sizing).docs/test-fixtures.md— the representative "Demo Corp" source schema, generation strategy, and tier-1/2/3 dataset sizes.docs/spikes/— Week-1 architecture spikes and how to run them.docs/adr/— every load-bearing architectural decision with context and consequences.openspec/changes/init-privaci-engine/— the source of truth for what the MVP must do and how.