Skip to content

ART M7: sovereignty, classification, filtered audit reports and performance (#111) - #228

Merged
amar-python merged 7 commits into
mainfrom
claude/issue-111-sovereignty-reports
Nov 10, 2026
Merged

amar-python merged 7 commits into
mainfrom
claude/issue-111-sovereignty-reports

Conversation

@amar-python

Copy link
Copy Markdown
Owner

What

M7 of the ART plan: sovereignty and reports (BR-35, BR-37; decisions 7 and 8). Tracker: #111.

Stacked on #227 (M6), which is stacked on #226 (M5). The base is the M6 branch, so this diff shows only M7. Merge #226, then #227, and GitHub retargets each in turn.

  • Filtered audit reports (BR-35). GET /api/audit/events filters by actor, action, file, table and time (from inclusive, to exclusive), pages, or downloads format=csv with spreadsheet formulas neutralised exactly as the frontend export does. GET /api/csv/uploads takes status, uploader, file, from and to, and each failed, rejected, duplicate or partly imported attempt now says why (failure_reason). A rejected upload's message used to be returned to the caller and never stored; it is now.
  • Classification and retention (decision 7). Per deployment, as ADR-0003 says: DATA_CLASSIFICATION (default OFFICIAL: Sensitive) is marked on every API response in X-Data-Classification and exposed to browsers; AUDIT_RETENTION_YEARS (default 7). GET /api/policy reads both. PROTECTED is refused at startup, with the IRAP reason.
  • No runtime external services (BR-37). Three automated checks: static scans of the API and frontend source; pytest --block-egress, which refuses any in-process connection that isn't to this machine and fails the run if one is attempted even when the code swallows the error (make test-egress-blocked, and a new CI step); and scripts/check_bundle_egress.py for a built frontend.
  • Performance (decision 8). scripts/perf_import.py times the whole approval flow, with a smoke test in tests/performance. It also found a problem: the first preview page parsed every row of a large file in Python. It now parses only the head of a file over 200,000 characters and counts the rest with the C csv reader.

Results

100,000 rows (9.8 MB, 8 typed columns), measured here:

Step 4 CPUs Pinned to 2 CPUs
First preview page 0.26 s (was 1.66 s) 0.23 s
Stage to imported (whole flow) 10.8 s 10.3 s

Targets: under 2 s and under 60 s. The built frontend (client and server bundles) has no load paths and no analytics or CDN hosts; it names only XML namespaces and documentation links. The API tests pass with egress blocked, with zero attempts.

Decisions to check

  • The performance targets are met here but not proven for the reference instance. This is a 4-CPU, 15.7 GB machine. Pinning the API and PostgreSQL to two CPUs approximates the CPU limit, not the 4 GB memory limit (the API process peaked at 424 MB). The run on a real 2 vCPU / 4 GB instance is still to do: python scripts/perf_import.py --rows 100000 --enforce. tests/performance asserts the targets only with PERF_ENFORCE=1, because a time on a laptop or CI runner says nothing about that instance.
  • BR-37 stays Partial. Still open: the offline install dry run on a clean VM (2026-11-15: ship air-gapped install procedure or keep interim wording #60). The egress guard covers Python in-process, not libpq or child processes (it refuses a remote database host instead). I did not add an OS-level network block to CI: a hosted runner needs the network to report its results.
  • BR-35 moves to verified. The frontend audit page doesn't call the new endpoints yet.
  • totalRowsApprox on large files is now an approximation (as its name says): it differs from a full parse only by trailing blank rows. Small files and files whose head ends in blank lines still get the full parse, so their results are identical. The frontend only displays it as "~N data rows".
  • Classification and retention are per deployment, not per tenant, following ADR-0003. If you want per-tenant values, that is a control-schema migration.
  • Retention isn't enforced by code: nothing deletes audit rows (triggers forbid it), and the setting binds any operator purge. Raw uploads are kept until a recorded purge, which doesn't exist yet.

Also found

A CORS bug in M6: browsers couldn't send Idempotency-Key to the import endpoint, so a preflight got 400. Fixed on the M6 branch (#227) with a test, not here.

Verification

Full suite (everything except the browser job) against PostgreSQL 16: all 584 tests pass, and tests/system also passes under --block-egress. scripts/lint.sh clean, bandit shows only the existing api/db.py notes, docs/TEST_CASES.md regenerated.

58 new tests: test_reports_and_policy.py (23), test_sovereignty.py (13), test_egress_guard.py (10), test_preview_fast_path.py (10), test_import_scale.py (2). Breaking the formula neutralisation, dropping the marking from one router, or breaking the fast-path row count makes the relevant tests fail, and a negative control confirms a swallowed egress attempt fails the run only when the guard is on.

#111 stays open until this merges.


Generated by Claude Code

…ance (M7, #111; BR-35, BR-37; decisions 7 and 8)

- Audit reports: GET /api/audit/events filters by actor, action, file, table and time
  with a formula-safe CSV download; GET /api/csv/uploads takes status, uploader, file
  and time filters and gives each failed attempt's stored reason. A rejected upload's
  message is now stored with it, so a report can say why.
- Classification and retention (ADR-0003: per deployment): DATA_CLASSIFICATION
  (default OFFICIAL: Sensitive) is marked on every response in X-Data-Classification
  and exposed to browsers; AUDIT_RETENTION_YEARS (default 7). GET /api/policy reads
  them. PROTECTED is refused at startup.
- Sovereignty: pytest --block-egress refuses any in-process connection that isn't
  local and fails the run if one is attempted, even if swallowed (make
  test-egress-blocked, and a CI step). Static scans of the API and frontend source,
  and scripts/check_bundle_egress.py for a built frontend.
- Performance: scripts/perf_import.py times the whole approval flow, with a smoke test
  in tests/performance. The first preview page parsed every row of a large file
  (1.66 s for 100,000 rows); it now parses only the head and counts the rest with the C
  csv reader (0.26 s).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T6WqhUfEMZv2Dxerqs2Eq
The Windows unit job failed three guard tests with AttributeError on
socket.AF_UNIX. Look it up with getattr, and skip the Unix-socket check where
it doesn't exist. Verified against an interpreter with the attribute removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T6WqhUfEMZv2Dxerqs2Eq
Base automatically changed from claude/issue-110-approval-staged-import to claude/issue-109-dedup-versioned-datasets November 10, 2026 03:15
It imported the Unix-only resource module, so it crashed at startup on Windows.
Peak and total memory now come from the Windows API there, and degrade to
"unknown" instead of failing anywhere they can't be read. A missing httpx2
(which starlette's TestClient needs, and api/requirements.txt doesn't list)
now gives a one-line fix instead of a traceback.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T6WqhUfEMZv2Dxerqs2Eq
…e database (M7, #111)

The API's own start-up retries a failed connection 30 times, built for a database
that is still starting. A wrong password or port therefore looked like a hang of
about five minutes. The script now tries once first and says what is wrong,
including how to set the variables in PowerShell.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T6WqhUfEMZv2Dxerqs2Eq
…111)

On a real Windows run the memory readouts came back "unknown": the current-process
pseudo-handle (-1) was passed to GetProcessMemoryInfo without argument types, which
overflows on 64-bit Windows. Declare them, and read total and peak memory
separately so one failing lookup can't hide the other. Still degrades to unknown
rather than failing the run. Verified on Linux and with a simulated no-resource
interpreter; the Windows API path itself can only be exercised on Windows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011T6WqhUfEMZv2Dxerqs2Eq
@amar-python
amar-python marked this pull request as ready for review November 10, 2026 05:08
@amar-python
amar-python changed the base branch from claude/issue-109-dedup-versioned-datasets to main November 10, 2026 05:08
@amar-python
amar-python merged commit 87d4bc5 into main Nov 10, 2026
4 checks passed
@amar-python
amar-python deleted the claude/issue-111-sovereignty-reports branch November 10, 2026 05:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants