Skip to content

Slice 14a: give a witnessed Run its experiment identity - #667

Merged
xmap merged 9 commits into
mainfrom
worktree-witnessed-run-slice14a-experiment-identity
Aug 16, 2026
Merged

Slice 14a: give a witnessed Run its experiment identity#667
xmap merged 9 commits into
mainfrom
worktree-witnessed-run-slice14a-experiment-identity

Conversation

@xmap

@xmap xmap commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Summary

Gives a witnessed Run a proposal / ESAF / ESAF-DOI experiment identity, so an operator can tell which experiment a recorded scan belongs to, without auto-harvesting a re-identifying fact onto an immutable event.

  • Default home is the vault, not the event. RecordWitnessedRun has no operator behind it the way start_run.external_refs does, so writing these PVs onto RunStarted would be CORA auto-harvesting off an unauthenticated channel with no operator gesture, and events are immutable/INSERT-only. run_experiment_identity mirrors slice 13's run_capture_path shape (a sibling table, not a generalization: a capture path and a proposal/ESAF number are different kinds of fact).
  • ESAF DOI resolvability, verified rather than assumed. Traced the upstream dmagic/APS-DM-SDK source: ESAFDOINumber is populated via EsafApsDbApi.getStationEsafById, an internal authenticated APS API, not a DOI registration agency; a DataCite search returned zero APS ESAF records. Unconfirmed as a genuinely resolvable public identifier, so it vaults alongside the other two — no event-schema change, no disposition entry, no naming review on a scheme literal.
  • Two traps named in the task, both handled in resolved_experiment_identity_text: the substrate's own "Unknown" placeholder (matched case-insensitively) and an empty string both read as ABSENT, never as a plausible value; each accepted field is paired with its own substrate produced_at so staleness is visible rather than guessed at (these PVs persist across beamtimes with no in-band freshness signal — a staff question, not a code question).
  • Sixth kill switch (capture_experiment_identity_recording_enabled), gated at boot on run_witness_recording_enabled exactly like the five before it.
  • capture_watch_preflight extended with a third sweep whose verdict distinguishes placeholder / empty / text(len=N), so an unpopulated PV is visible before the recording switch flips rather than reading as a healthy value.
  • Widened slice 13's test_run_events_carry_no_pii.py deny-list with esaf_number / esaf_doi_number (proposal_number was already there), making that fitness test the mechanism enforcing this decision against a future agent who reaches for the event payload.
  • Naming reviewed (naming-r3-reviewer): renamed ExperimentIdentityReaderCaptureExperimentIdentityReader, esaf_doiesaf_doi_number, widened bare identityexperiment_identity throughout (collided with cora.shared.identity vocabulary), ABSENT_IDENTITY_LITERALUNKNOWN_EXPERIMENT_IDENTITY_LITERAL, preflight verdict unknownplaceholder.
  • Code-reviewed: fixed two sequential-await-should-be-concurrent spots (RunWitnessRecorder._promote's baseline+identity reads, get_run's two vault lookups) and the case-sensitivity gap above.

Slice 14b (User* PVs) stays blocked on D0's research, per the task; not touched.

Test plan

  • make lint — clean
  • make typecheck — clean
  • make test-noio — 47998 passed, 630 skipped
  • make test-db — 1210 passed, 1 skipped
  • New unit tests for the vault store, the reader (including both absence traps, dual-clock-style failure independence), the preflight sweep, and the get_run surface
  • New integration tests against real Postgres (RLS, CHECK bounds, upsert idempotency)
  • naming-r3-reviewer pass, findings applied
  • Manual security/PII review of the actual diff (the security-review skill mis-scoped its own diff against a stale base pulling in ~14 unrelated slices; redid it by hand against the real https-origin/main..HEAD diff — no findings)

🤖 Generated with Claude Code

xmap added 9 commits August 16, 2026 09:05
Adds Settings.capture_experiment_identity_pvs (code -> closed role ->
PV, for proposal_number/esaf_number/esaf_doi) and a sixth,
independent kill switch, capture_experiment_identity_recording_enabled,
gated at boot on run_witness_recording_enabled exactly like the
baseline and capture-path switches before it.

A public-resolvability check (DataCite search + the upstream
dmagic/APS-DM-SDK source) found ESAFDOINumber is populated from an
internal, authenticated APS API with no public DOI-registry record,
so it is not confirmed as a genuinely resolvable DOI. All three PVs
therefore vault together; none rides an event, so this slice adds no
event field, no disposition, and needs no schema gate review.

No behavior change yet: the PVs are declared and the switch exists,
but nothing reads or writes through them until the vault module and
reader land in the next commits.
A sibling table to run_capture_path, not a generalization of it: a
capture path and a proposal/ESAF number are different kinds of fact,
even though both share the PII-vault-shaped posture (mutable side
table keyed on run_id, RLS+FORCE, never referenced from an event).

Each of proposal_number/esaf_number/esaf_doi is independently
nullable with its own *_observed_at (the substrate's own reading
time), because a deployment may configure fewer than three roles, or
one PV may read a real value while a sibling still reads "Unknown".
No tombstone placeholder on the read side (unlike observed_capture_path):
none of these three values is personal data, so a plain None is
already honest.

Bumps EXPECTED_SCHEMA_VERSION to this migration.

Still inert: nothing writes to this table until the reader lands in
the next commit.
Mirrors CaptureBaselineReader's shape (one read-every-role-and-write-
once call, never raises, per-role failure independence) but simpler:
writes directly to the run_experiment_identity vault, not through
AppendObservations, so it needs no principal and no authz call.

resolved_identity_text is the single place Trap 1 (the substrate's
"Unknown" placeholder, and an empty string, must read as absent, not
as a plausible value) is decided; capture_watch_preflight will import
it directly in the next commit so its own verdict can never drift.

Trap 2 (these PVs persist across beamtimes with no in-band staleness
signal) is not solved here -- it can't be, per the design memo -- but
each accepted value is paired with its own substrate produced_at so a
reader downstream can see how old it is.

Still unwired: nothing calls this reader yet.
…_run

RunWitnessRecorder._promote now calls _read_experiment_identity right
after _read_baseline, same defense-in-depth wrapper, gated on both a
reader being configured (main.py wires one whenever
capture_experiment_identity_pvs is declared) and the sixth kill switch.
run_witness_lifespan gains capture_experiment_identity_pvs +
experiment_identity_store params and builds the reader exactly like
CaptureBaselineReader (a distinct reader object, since it does its own
ControlPort reads, unlike capture_path_store's handed-straight-through
style).

get_run.bind now also takes experiment_identity_store; RunView, the
REST route, and the MCP tool each grow six fields (proposal_number,
esaf_number, esaf_doi, each paired with its own *_observed_at). No
tombstone placeholder here, unlike observed_capture_path: none of
these three values is personal data, so a plain None is already
honest, and capture_code already tells a Conducted Run apart from a
Witnessed Run with nothing recorded yet.

Regenerates the OpenAPI snapshot for the six new RunResponse fields.
The single highest-value thing this preflight can say about the
proposal/ESAF/ESAF-DOI PVs: the substrate's own "Unknown" placeholder
must never look like a healthy, generic string. The identity sweep's
verdict now distinguishes unknown / empty / text(len=N), all OK, from
non-text, the only BAD outcome -- dispatched through
resolved_identity_text (exported alongside ABSENT_IDENTITY_LITERAL
from the reader module) so the preflight's verdict can never drift
from what ExperimentIdentityReader actually vaults.

None of the three roles is personal data, so the raw value prints
unredacted; the same defensive path-shape redaction as the baseline
sweep still guards against a full_file_name PV being misdeclared here.
proposal_number was already pre-emptively denylisted in slice 13.
Adding the other two makes this fitness test the mechanism that
enforces the vault-not-event decision against a future agent who
reaches for RunStarted's payload, even though none of the three
fields is personal data: the decision rests on the auto-harvest /
no-operator-gesture asymmetry, not on PII specifically.
- ExperimentIdentityReader -> CaptureExperimentIdentityReader: the
  class dropped the Capture prefix its own file, log events, and
  Settings key all carry (R2, matching CaptureBaselineReader's
  file-to-class correspondence).
- esaf_doi -> esaf_doi_number (column, dataclass field, ROLE_ESAF_DOI
  -> ROLE_ESAF_DOI_NUMBER): restores the shared <qualifier>_number
  skeleton the other two fields use, and stops implying a claimed
  scheme (CORA's PersistentIdentifier already reserves DOI as a
  resolvable-identifier scheme, which this slice explicitly did not
  confirm ESAFDOINumber to be).
- Bare "identity" truncated the family noun on the reader's public
  surface (identity_pvs, resolved_identity_text, the preflight's
  group="identity" and its verdict/report-key wiring) and collided
  with cora.shared.identity's own vocabulary (ActorId, principal
  identity). Widened to experiment_identity throughout.
- ABSENT_IDENTITY_LITERAL -> UNKNOWN_EXPERIMENT_IDENTITY_LITERAL: the
  old name asserted "absent" for a constant whose value is the string
  "Unknown"; the new name agrees with its own value, mirroring
  UNOBSERVED_CAPTURE_PATH's name-matches-value discipline.
- Preflight verdict "unknown" -> "placeholder": the report's own
  status role already uses "unrecognized" for a different failure
  (CORA's decoder rejected the literal, vs. the substrate never
  populating it); "unknown" would have read as a synonym.

Mechanical rename; no behavior change. Regenerates the OpenAPI
snapshot and the migration checksum for the column rename.
- RunWitnessRecorder._promote awaited _read_baseline then
  _read_experiment_identity sequentially, doubling the single-consumer
  loop's stall time on a promotion when both switches are on, even
  though every module in this ladder stresses that this path "must
  not block the loop". Both readers already catch every failure
  internally, so a plain asyncio.gather is safe.
- get_run's handler paid the same sequential cost on capture_path_store
  and experiment_identity_store; same fix.
- resolved_experiment_identity_text matched the substrate's "Unknown"
  placeholder by exact case. The exact casing is an observed IOC
  default, not a wire-level guarantee; match case-insensitively so a
  differently-cased variant still reads as "not populated" rather than
  as a real value.
- Simplified RunView's six repeated
  "experiment_identity is not None else None" ternaries by hoisting
  the guard to the concurrent-read site instead of the composition.
The full-suite run (not scoped to slice-14a-touched files) caught what
my targeted test runs couldn't: an exact dict-equality assertion on
GET /runs/{run_id}'s response body, which the six new fields now fail
by being present. Adds them at None, mirroring the existing
capture_code/observed_capture_path comment: a Conducted Run never
touches the run_experiment_identity vault either.
@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_experiment_identity_reader.py 189, 218
  _run_witness.py 712
  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
  experiment_identity.py
  apps/api/src/cora/run/features/get_run
  handler.py
  route.py
  tool.py
Project Total  

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

@xmap
xmap merged commit 52fbea2 into main Aug 16, 2026
19 checks passed
@xmap
xmap deleted the worktree-witnessed-run-slice14a-experiment-identity branch August 16, 2026 17:22
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