Skip to content

Key the capture-path vault by location, so a Run can hold more than one - #699

Merged
xmap merged 1 commit into
mainfrom
worktree-capture-path-locations
Aug 20, 2026
Merged

Key the capture-path vault by location, so a Run can hold more than one#699
xmap merged 1 commit into
mainfrom
worktree-capture-path-locations

Conversation

@xmap

@xmap xmap commented Aug 20, 2026

Copy link
Copy Markdown
Owner

What

run_capture_path held one observed path per Run (run_id as PRIMARY KEY, ON CONFLICT (run_id) DO UPDATE). It now holds one per storage location.

Why now

The next slice registers a second Distribution once a scan's bytes reach APS Data Management. That is a second real path for the same Run, carrying the same PI surname, so it needs the same vaulting. Under the old key, writing it would have overwritten the acquisition path in place and every cora-capture-path:// locator minted against it would have stopped resolving, silently, with no event recording that it happened.

Nothing writes a second path yet, so this closes a trap laid in the next slice's path rather than a live defect. It is cheapest now: the sweep that mints those locators is still switched off.

Schema

host and root are nullable, because rows written before this migration genuinely have no recorded location and NULL says so where a sentinel would assert a location nobody measured. That pulls in UNIQUE NULLS NOT DISTINCT (PG15+, deployed on PG18): under the default, every legacy row would be unique against itself and ON CONFLICT would insert duplicates instead of re-observing. It also forces the surrogate capture_path_id PK rather than merely recommending it, since a PRIMARY KEY cannot span nullable columns.

Mint from the recorded location, not from settings

resolve keys the vault lookup on host and root, so correctness depends on the minted pair equalling the stored pair. Deriving them independently at two different times (the Run's terminal, and the sweep tick arbitrarily later) meant any settings change between them produced a locator that resolved to nothing. The candidate now carries its recorded location and mint consumes it. Current settings still get a say, as an explicit refusal rather than a re-match.

Rows with no recorded location are excluded from candidacy outright, so they cannot become a stuck head the sweep re-selects every tick forever.

Root matching moved to cora.shared

It cannot live in either BC: cora.run.aggregates depends only on infrastructure and shared, so the aggregate that persists and keys on root could not import the function defining it. That is exactly how the normalization drifted, and the bug it produced was real: the recorder stored a configured root verbatim while the locator embedded an rstripped copy, so one trailing slash in settings meant the two held different strings for one directory and every lookup missed permanently.

Review

Four-lens gate panel (security, data/migration, architecture, test coverage). The migration was applied to a real PG18 container and measured statement by statement; no P0. Both P1s were the mint/record split above, found independently by three reviewers.

Also fixed from the panel: latest renamed get_latest with a total ordering (capture_path_id DESC) so ties are identical in both adapters; a docstring that described a mechanism the code did not contain; a stale comment contradicting the docstring it pointed at; an inaccurate precedent citation; and the erasure guidance redirected to run_id (deleting one location and leaving a sibling keeps the surname on disk).

Coverage the panel was right about: the recorder's location write had never executed in its matched branch, and the host axis of the new key was decorative (deleting it from the lookup left the whole suite green). Both now tested, with positive controls.

Verification

architecture 31,288 · integration + architecture re-run 32,556 · unit + integration + contract 18,582 · ruff, pyright, tach clean. No P0 findings.

🤖 Generated with Claude Code

run_capture_path had run_id as its PRIMARY KEY: one observed path per
Run, upserted with ON CONFLICT (run_id) DO UPDATE. The next slice
registers a second Distribution once a scan's bytes reach APS Data
Management, and that is a second real path for the same Run
(/gdata/dm/2BM/<yyyy-mm>/<exp>/data/<file>.h5 alongside
/local1/2BM/<exp>/<file>.h5) carrying the same PI surname, so it needs
the same vaulting. Writing it would have overwritten the acquisition
path in place, and every cora-capture-path:// locator minted against
that path would have stopped resolving, silently, with no event
recording that it happened.

Nothing writes a second path yet, so this closes a trap laid in the next
slice's path rather than a live defect, and it is cheapest now: the
sweep that mints those locators is still switched off.

host and root are NULLABLE because rows written before this migration
genuinely have no recorded location, and NULL says so where a sentinel
string would assert a location nobody measured. That pulls in UNIQUE
NULLS NOT DISTINCT (PG15+, deployed on PG18): under the default NULLS
DISTINCT every legacy row would be unique against itself and ON CONFLICT
would insert duplicates instead of re-observing. It also forces the
surrogate capture_path_id primary key rather than merely recommending
it, since a PRIMARY KEY cannot span nullable columns. That key DEFAULTs
to gen_random_uuid(): a key carrying no meaning a writer could get right
or wrong should not be ceremony every INSERT has to remember.

The locator is minted from the location the ROW RECORDED, not from a
fresh match against current settings. resolve() keys the vault lookup on
host and root, so correctness depends on the minted pair equalling the
stored pair; deriving them independently at two different times (the
Run's terminal, and the sweep tick arbitrarily later) meant any settings
change between them produced a locator that resolved to nothing. Current
settings still get a say, as an explicit refusal rather than a re-match:
a row whose recorded root has left the allowlist is skipped, because the
probe would decline to read it anyway. Rows with no recorded location
are excluded from candidacy outright, so they cannot become a stuck head
the sweep re-selects every tick forever.

Root matching and normalization moved to cora.shared.storage_root. They
cannot live in either BC: cora.run.aggregates depends only on
infrastructure and shared, so the aggregate that persists and keys on
root could not import the function that defines it, and that is exactly
how the normalization drifted. The recorder stored a configured root
verbatim while the locator embedded an rstripped copy, so a single
trailing slash in settings meant the two held different strings for one
directory and every lookup missed, permanently, for every run.

get_latest() replaces latest() (every sibling store method is a verb)
and orders by observed_at, updated_at, then capture_path_id, making the
tie total and identical in both adapters. Preferring the acquisition
tier instead would point the display read at exactly the copy that gets
capacity-purged first. The ordering is deliberately lossy and documented
as such: it answers where this was last seen, not everywhere CORA has
seen it.

Reviewed by a four-lens panel. The migration was applied to a real PG18
container and measured; no P0. Both P1s were the mint/record split above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@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_scan_ingestor.py
  _run_witness.py 977
  apps/api/src/cora/data/adapters
  capture_path_locator.py
  apps/api/src/cora/infrastructure
  schema_version.py
  apps/api/src/cora/run/aggregates/run
  capture_path.py
  apps/api/src/cora/shared
  storage_root.py
Project Total  

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

@xmap
xmap merged commit e372341 into main Aug 20, 2026
19 checks passed
@xmap
xmap deleted the worktree-capture-path-locations branch August 20, 2026 22:16
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