Skip to content

Slice 13: pair a witnessed Run with its observed capture file, via a PII vault - #666

Merged
xmap merged 5 commits into
mainfrom
worktree-witnessed-run-slice13-capture-path-v2
Aug 16, 2026
Merged

Slice 13: pair a witnessed Run with its observed capture file, via a PII vault#666
xmap merged 5 commits into
mainfrom
worktree-witnessed-run-slice13-capture-path-v2

Conversation

@xmap

@xmap xmap commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Summary

  • Sources the observed capture filename from 2bmSP2:HDF1:FullFileName_RBV (the areaDetector file plugin's own readback, written at file OPEN) instead of tomoscan's own FullFileName PV, which upstream's end_scan() writes four statements after CORA's terminal trigger and would return the previous scan's filename.
  • The observed path is personal data (2-BM's directory layout embeds a surname + proposal number), so it never touches an event payload: it lands in a new run_capture_path PII vault (RLS+FORCE), mirroring the shipped actor_profile / ProfileStore pattern.
  • A dual-clock guard retains the latest reading per capture code and only attaches it at a capture's terminal if the reading's own substrate timestamp is at or after that capture's BEGUN time; absent, stale, or empty readings are rejected.
  • capture_code (already on RunStarted.external_refs) folds onto proj_run_summary and surfaces via list_runs. observed_capture_path resolves from the vault inside get_run's own handler (RunView, mirroring get_actor's ActorView) — deliberately not in list_runs, which is one shared handler instance read by internal composition-root callers under one coarse, unscoped-per-row grant.
  • capture_watch_preflight redacts the new full_file_name role (kind + length only), with defense-in-depth redaction for any path-shaped value regardless of role-key match.
  • A fifth independent kill switch, capture_path_recording_enabled, boot-gated like the existing four.
  • New architecture fitness test pins that no Run event payload ever carries a PII-shaped field.

Test plan

  • make test-noio — 47926 passed, 630 skipped
  • make test-db — 1203 passed, 1 skipped
  • make lint (ruff check + format --check, full tree)
  • make typecheck (pyright, 0 errors)
  • tach check — all modules validated
  • Naming review pass (R1-R6) applied
  • Full gate review (migration-safety, test-coverage, architecture, security, cross-BC consistency) — all findings addressed
  • Dual-clock guard race explicitly tested (retained reading predating BEGUN is not attached)
  • Empty-string reading not attached
  • No log line or exception message contains a full path (leak-surface test)

🤖 Generated with Claude Code

xmap added 5 commits August 16, 2026 07:13
The observed capture path is personal data by construction: 2-BM's
directory layout embeds a surname and proposal number
(tomoscan_2bm.py's DetectorTopDir composition). Events are
immutable/INSERT-only, so it can never live in an event payload. This
adds a dedicated mutable side table, mirroring the shipped
actor_profile / ProfileStore pattern: run_capture_path (RLS+FORCE,
no event ever references its content) plus CapturePathStore /
PostgresCapturePathStore / InMemoryCapturePathStore.

Bumps EXPECTED_SCHEMA_VERSION to the new newest migration.
RunWitnessRecorder retains the latest full_file_name reading per
capture code, each with its own substrate observed_at. At a capture's
terminal, the retained value is attached only if its observed_at is at
or after that capture's own BEGUN time (never CORA's local clock);
absent, stale, or empty-string readings are rejected and nothing is
recorded. The write happens after the outcome has already committed,
so a vault-write failure is logged (error class only, never the raw
exception text or the path) and never unwinds the Run's own state.

capture_path_recording_enabled is a fifth independent kill switch,
boot-gated exactly like the existing four: refuses to start if set
without run_witness_recording_enabled (no promoted Run's terminal to
attach an observed path to without it).

capture_path_store is built inside wire_run(deps), mirroring
feed_heartbeat_store: BC-specific stores stay BC-internal, never
promoted to Kernel fields (this concept has exactly one BC, unlike the
genuinely cross-BC ProfileStore).

extract_capture_code (folding the capture-code external_ref off the
FOLDED aggregate state) is promoted out of a private duplicate in
_run_witness.py to cora.run.aggregates.run.state, so it can become the
single source of truth shared with the read-model work landing next.
capture_code folds onto proj_run_summary from RunStarted.external_refs
(already carried there; no new event field, no new subscription).
list_runs surfaces it as a plain non-PII field on RunSummaryItem.

observed_capture_path resolves from the run_capture_path vault inside
get_run's own Handler (RunView, mirroring get_actor's ActorView):
None when there's no capture_code (a Conducted run, not applicable),
the tombstone when a capture code exists but the vault has no row yet
(never observed, or rejected by the dual-clock guard), the real path
otherwise -- this is the authorized surface an operator reads to find
the file for ingest_scan's producing_run_id.

Deliberately NOT resolved in list_runs: that handler is one shared
instance read by every internal composition-root caller
(rebuild_open_captures, the supervisor and initiator watchdogs) under
one coarse, cursor-paginated ListRuns grant, unscoped per row (BOLA
deferred until ReBAC per list_query.py). get_run has no internal
caller today, so it avoids that exposure outright, mirroring why
list_actors never touches ProfileStore while get_actor does.
The preflight tool's redaction was exact-role-match only: a config
typo on the role key (e.g. "full_filename") would print the raw
observed path. Adds defense-in-depth on top of the role check: any
string value that looks like a filesystem path (starts with "/") is
redacted regardless of which role or dict (watch or baseline PVs) it
came through, so a misconfiguration can't silently bypass redaction.
kind and length still render for the real role.
AST-walks every class in cora/run/aggregates/run/events.py (not just
Run-prefixed ones -- CautionAcknowledgement, DecisionDebriefRequested,
and HoldClaimReleased are real Run-stream events without that prefix)
against a PII deny-list covering this slice's own fields
(observed_path, capture_path, full_file_name) plus the User* PVs a
later slice already names as blocked. A regression here usually means
someone tried to carry the resolved path (or a raw substrate PV) onto
an event for convenience; it belongs in the run_capture_path vault via
CapturePathStore instead.
@github-actions

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/api
  _capture_observer.py
  _run_witness.py 694, 836, 931, 995-997
  capture_watch_preflight.py
  main.py
  apps/api/src/cora/infrastructure
  config.py
  schema_version.py
  apps/api/src/cora/run
  wire.py
  apps/api/src/cora/run/aggregates/run
  __init__.py
  capture_path.py
  state.py
  apps/api/src/cora/run/features/get_run
  handler.py
  route.py
  tool.py
  apps/api/src/cora/run/features/list_runs
  handler.py
  route.py
  tool.py
  apps/api/src/cora/run/ports
  capture_observer.py
  apps/api/src/cora/run/projections
  summary.py
Project Total  

This report was generated by python-coverage-comment-action

@xmap
xmap merged commit 9db14a4 into main Aug 16, 2026
19 checks passed
@xmap
xmap deleted the worktree-witnessed-run-slice13-capture-path-v2 branch August 16, 2026 13:29
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.

1 participant