Skip to content

feat: variation ontology provenance — stop silent promotion of machine-derived annotations (#608) - #615

Merged
berntpopp merged 29 commits into
masterfrom
feat/variation-ontology-provenance-608
Jul 30, 2026
Merged

feat: variation ontology provenance — stop silent promotion of machine-derived annotations (#608)#615
berntpopp merged 29 commits into
masterfrom
feat/variation-ontology-provenance-608

Conversation

@berntpopp

Copy link
Copy Markdown
Owner

Implements issue #608 (design revision 2, externally reviewed) for the application repo, plus two
pre-existing defects that blocked it.

The problem

SysNDD presents itself as an expert-curated resource, but the curation forms prefill their
variation-ontology picker from the entity's existing terms. A curator opening an entity to change one
sentence of synopsis got every existing term pre-checked, and saving rewrote all of them onto a
new, curator-attributed review. No user action distinguished "I read the papers and agree" from
"I did not notice the pre-checked box."

The 8,111 annotations written straight into the curated table by the February 2026 imports were
therefore one re-review away from being indistinguishable from curated content — and 1,981 of the
ClinVar batch rest on 1-star evidence alone.

What ships

  • A provenance data model (migration 047) — one assertion per
    (entity_id, vario_id, modifier_id), evidence rows per source batch. Deliberately not a column on
    the review-linked join table, which is deleted and re-inserted wholesale on every review save.
  • Server-side write reconciliation — the load-bearing fix. A submitted machine-derived term with
    no explicit confirmation stays unconfirmed. Confirmation is now an act, not a side effect.
  • Read surface — per-term provenance, an on-demand evidence route, and a Curator-gated
    suggestions route.
  • Public entity page — a quiet affordance on machine-derived terms plus a lazily-fetched,
    keyboard-operable evidence dialog.
  • Curation three-zone picker — Confirmed / Needs confirmation / Suggested, with the modifier per
    card and evidence inline, because in the curation flow the evidence is the decision.
  • A static guard that fails the build if anything writes the curated join table outside the one
    sanctioned path.
  • E2E fixtures and 18 Playwright tests, docs, and the 0.31.0 release.

Release gate — read before deploying

The feature is inert until the companion backfill runs. That backfill lives in
sysndd-administration and covers all three February batches. Until then there are zero assertion
rows, every term reports provenance: null, and the public card renders exactly as before. A
partial backfill is worse than none: because absence of an assertion row means curator-authored,
it would positively present the un-backfilled batches as curated (design §7.1).

That inertness is enforced structurally and locked by a golden-HTML assertion, not left to
inspection. The write-side fix is the opposite — ungated and valuable immediately, since every review
saved without it makes one more entity permanently ambiguous.

Deviations from the design, each deliberate

  • Migration 047 derives vario_id's charset from information_schema instead of the specified
    static utf8mb3. The design's SQL provably cannot apply: the referenced column has drifted to
    utf8mb4 and MySQL refuses the FK as incompatible. Verified both ways.
  • Rejection scope departs from the literal §5.3 row 3. As written, a Reviewer's draft save
    rejected every assertion on the entity while the approved review kept serving those terms — making
    them read as curator-authored. On that input the spec contradicts its own primary goal, and the
    goal governs: confirmation always applies, rejection only when the save determines the served set.
  • Phase 6 and Phase 7 items 1/3 deferred — tracked in Variation ontology provenance: remaining phases and residuals after #608 #612.

Beyond the design: three more fabrication paths closed

The design describes one laundering channel. Auditing found three more, each of which would have let
a machine-derived annotation read as curator-authored:

  1. A missing identity column made attach_provenance() mark every term curator-authored.
  2. A case-variant vario_id — R compared case-sensitively, the DB collation does not.
  3. An entity rename copied terms to a new entity_id and stripped provenance wholesale.

Two pre-existing defects fixed here

Caught only by running it in a browser

Two defects that unit tests with mocks could not reach, both in this branch's own code:

  • The "Needs confirmation" zone never rendered. Plumber does not auto-unbox, so
    provenance?.state === 'active_unconfirmed' was false against ["active_unconfirmed"] and every
    unconfirmed term was misfiled as Confirmed — the deliberate review step the feature exists to force
    was invisible. All 44 unit tests passed either way, because their fixtures used plain scalars. The
    typed client now normalizes the wire shape so its declared types are actually true.
  • Confirm and Accept failed with a 500 on every save. tibble() evaluates arguments sequentially
    with data masking, so needs_attribution re-indexed already-subsetted columns and yielded NA;
    the applier then wrote confirmed with a NULL confirmed_by and the CHECK constraint rejected it.
    It only misbehaves when some assertion is unchanged — the normal case — which is exactly why a
    902-line unit suite passed.

Verification

  • API: 886 provenance assertions, 0 failures, 0 skips (live MySQL 8.4)
  • Frontend: 299 files / 2261 assertions, 0 failures
  • Playwright: 18/18 provenance specs, and 164 passed / 1 failed / 3 skipped for the full suite — the
    single failure is the pre-existing /DataReleases case (this stack has no snapshots and no worker)
    and the 3 skips are the documented env-gated baseline
  • make code-quality-audit, make lint-api, make lint-app (0 errors), npm run type-check,
    npm run type-check:strict — all clean

The end-to-end proof that matters: saving without touching an unconfirmed term leaves it live and
unpromoted
, verified in a real browser, reading back through the public endpoint.

Design

Unconfirmed is styled as information, not an error — neutral tokens and a dotted treatment, zero
--status-danger/--status-warning use, state always carried in text, axe clean, no overflow at 390
or 1440. Two layout defects were found by looking at the rendered dialog: four competing alignments
(inherited centring from the card) and record rows on mismatched baselines at mobile widths. Both
fixed; the 44px row height was left alone after verifying it is the deliberate tap-target rule.

Closes #608
Closes #613

🤖 Generated with Claude Code

https://claude.ai/code/session_01W7njywYE4bCjoJ6YedziVQ

berntpopp and others added 29 commits July 30, 2026 13:58
Releases the consolidated dependabot bumps and the brace-expansion CVE
remediation from #609.

Bumps all four version surfaces:
- app/package.json
- app/package-lock.json (both root version fields)
- api/version_spec.json
- CHANGELOG.md (promotes [Unreleased] to [0.30.14])

Verified: `npm ci --legacy-peer-deps` and `make code-quality-audit`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W7njywYE4bCjoJ6YedziVQ
…ecution plan

Saves the externally-reviewed revision-2 design from issue #608 and the
application-repo execution plan alongside it, following the repo convention
that specs and plans live under .planning/superpowers/.

Refs #608
…on 047)

Two additive tables recording where a variation-ontology annotation came
from. An assertion is one (entity_id, vario_id, modifier_id) claim carrying
curator-facing state; evidence rows hang off it, one per source batch, so
two independent sources corroborating a term is representable.

modifier_id is part of the identity because modifier_list defines both
present (1) and absent (5) as valid for variation -- those are two different
claims with independent state.

Deliberately not columns on ndd_review_variation_ontology_connect: that
table is DELETEd and re-INSERTed wholesale on every review save
(ontology-repository.R:233-307), which would destroy a provenance column
each time.

The vario_id FK column derives its charset/collation from information_schema
at migration time rather than hardcoding utf8mb3. The referenced
variation_ontology_list.vario_id is not reliably utf8mb3 -- in this repo's
own sysndd_db_test it has drifted to utf8mb4_0900_ai_ci -- and a static
charset makes MySQL reject the FK as incompatible. Verified empirically both
ways; see the migration header.

Refs #608
Normalizes source-specific evidence into a comparable 0-4 strength so
sources stay sortable in the curator suggestion queue without unpacking
evidence_json.

Returns NA rather than guessing: an unrecognized source, a fractional
value, a non-digit string, a logical, a non-scalar, or an out-of-range
value never becomes a plausible strength. This feature exists to stop
fabricated provenance, so a normalizer that invents a strength would be
self-defeating.

There is no curator source type -- a curator-authored annotation has no
assertion row, so a curator evidence row cannot exist.

Refs #608
provenance_for_entity() reads one row per evidence record and deliberately
excludes evidence_json so the public endpoint stays small.

attach_provenance() joins on the full (entity_id, vario_id, modifier_id)
identity -- never (entity_id, vario_id), which would conflate 'missense is
present' with 'missense is absent'. Terms with no assertion row get NULL,
which the API contract defines as curator-authored. Multiple evidence rows
collapse into a sources array ordered by strength descending then
source_key ascending, so rendering order is deterministic.

Join keys coerce entity_id/modifier_id to integer first: paste(100000)
renders '1e+05' for a double but '100000' for an integer, so a double
entity_id arriving from a JSON round-trip would silently fail to match an
integer one from DBI.

An assertion with no evidence rows keeps its state but reports an empty
sources array rather than a single all-NA phantom source, because sources
is rendered directly to clients.

Refs #608
bootstrap_load_modules() is the single loader for both the API and the
durable worker, so one registration covers both. Files are not
autodiscovered.

Refs #608
)

The curation forms prefill their variation-ontology picker from the entity's
existing terms, so a curator editing one sentence of synopsis re-saves every
pre-checked term onto a new, curator-attributed review. No user action
distinguished 'I read the papers and agree' from 'I did not notice the
checkbox' -- which is how machine-imported annotations became
indistinguishable from curated content, one review at a time.

Reconciliation is server-side and identity-aware. Three independent frontend
surfaces prefill and resubmit terms (useEntityInfo, useReviewForm,
useReviewApprovalActions) and only two route submission through the shared
tag helper, so correctness cannot depend on a client sending the right
field: the server compares the previous assertion set against the submitted
set instead.

The load-bearing rule is that a submitted, machine-derived term with no
explicit action STAYS active_unconfirmed. The annotation does not vanish --
it stays live and stays submitted -- it is simply never silently upgraded.
Confirmation becomes an act, not a side effect.

Reconciliation runs inside review_write_mutate() on the same txn_conn as the
connect-table write, so provenance and curated membership commit or roll
back together. It is deliberately not exists()-guarded: a missing module
must fail loudly rather than silently restore the bug.

The risky logic is a pure planner (variation_provenance_plan_reconciliation)
covering every transition, so the state machine is fully unit-testable
without a database; only a thin applier touches SQL.

With zero assertion rows the whole path is a strict no-op, which keeps it
provably inert until the companion backfill runs.

Refs #608
Review findings on the provenance foundation.

attach_provenance() built its join key with paste(), which renders a missing
column as an empty string. A terms tibble lacking modifier_id therefore
produced keys that matched nothing and marked EVERY term as
curator-authored -- silently, through nothing worse than a caller typo or a
column rename. That is precisely the fabricated-provenance failure this
feature exists to prevent, so it now validates the identity columns up front
and stops, naming what is missing. A NULL provenance argument stays
permitted (no production caller passes one, and provenance_for_entity always
returns a tibble).

max_strength is now explicitly integer on the non-NA branch, locked by an
expect_identical assertion.

normalize_evidence_strength() range-checked its input only after coercing
it, so a digit string like "99999999999" or a double like 1e20 emitted
"NAs introduced by coercion to integer range" on the way to the correct NA
answer. It now range-checks as a double before coercing. A validator running
in the request path and the durable worker should reject cleanly rather than
leak a warning a tryCatch caller could escalate. Fixed by validating, not by
suppressWarnings().

Refs #608
The test resolved its FK-target tables from ambient schema left behind by
another test file. Run alone -- or with that file excluded or reordered -- it
skipped AFTER the table-existence assertions but BEFORE the identity, CHECK
and idempotency ones: green CI with zero constraint verification. It now
creates ndd_entity / variation_ontology_list / modifier_list itself, and the
skip path is gone. Verified by dropping all three tables first and
confirming the full assertion set still runs.

It also asserted no foreign key and no charset match, so deleting all four
FK fragments from the migration still passed 15/15 -- which would silently
un-justify the charset derivation the migration exists to get right. The two
gaps masked each other. Now asserted: all five FKs via KEY_COLUMN_USAGE with
their referenced table and column, charset/collation equality between
variation_ontology_assertion.vario_id and variation_ontology_list.vario_id,
FK rejection of a bogus entity_id, and the positive path of a confirmed row
with valid attribution.

The dynamic DDL embedded its ENUM/CHECK literals as double-quoted strings,
which are string literals only while sql_mode excludes ANSI_QUOTES -- 047
was the first migration in the repo to depend on that. Switched to escaped
single quotes and verified the migration applies under ANSI_QUOTES.

The header attributed the observed utf8mb4 collation to production restore
drift; it actually comes from a test fixture. Corrected, keeping the probe
results and the do-not-simplify warning.

Refs #608
Issue #608's provenance model rests on one invariant: absence of an
assertion row means the annotation is curator-authored. That holds only
while nothing writes curated variation-ontology rows without also recording
provenance -- the issue's own words are that an automated process may write
suggestions but may not write ndd_review_variation_ontology_connect.

Policy cannot hold that on its own: the historical import scripts inserted
into the connect table directly, and any future code repeating the pattern
silently manufactures curated-looking data. This freezes the boundary. The
sanctioned path is ontology-repository.R, reached through
review_write_mutate() where reconciliation runs on the same transaction;
anything else fails the guard with a message naming that path.

Proven able to fail, not just to pass: an injected INSERT in a scratch file
under functions/ made the guard fail, and removing it made it pass again.
Matching is whitespace-normalized and case-insensitive because the SQL in
this codebase is written as multi-line R string literals.

Also asserts the reconciliation stays inside the connect-table write's
transaction (conn = txn_conn). A refactor moving it onto its own connection
would silently stop provenance and curated membership being atomic, and
nothing else caught that.

svc_review_add_variation_ontology() and put_post_db_var_ont_con() in
review-service.R are confirmed dead (no callers) and delegate to the
sanctioned repository by name rather than raw SQL, so they do not trip the
guard -- but they are flagged in a comment as a bypass hazard if ever
resurrected.

Refs #608
…face

GET /api/entity/<id>/variation gains a per-term provenance object joined on
the full (entity_id, vario_id, modifier_id) identity, plus two new DB-only
routes: a Curator-gated per-entity suggestions read, and an evidence detail
read carrying the full evidence_json payloads.

The suggestions route is declared BEFORE the dynamic
/<vario_id>/<modifier_id>/evidence route. Plumber matches in declaration
order, so the reverse would capture the literal 'suggestions' as a vario_id
-- the same shadowing that once made GET /api/status/_list 403.

Two serializer defects found by probing the real decorator rather than
reasoning about it:

- Without null="null", a NULL list-column element serializes as {} rather
  than null. The contract that provenance is null for curator-authored terms
  would have shipped as "provenance":{}, quietly breaking the release gate
  it exists to enforce.
- na="string" renders a nested NA_integer_ as the literal string "NA", so
  an unrecorded strength would have arrived as text where a number or null
  belongs. NA scalars are normalized to NULL in the service.

Plumber routes a raw colon inside a path segment fine, so the CURIE works as
a path parameter -- but it does not percent-decode path parameters, so a
client using encodeURIComponent would look up a literal 'VariO%3A0017' and
404. The parameter guard URL-decodes so both encodings resolve identically,
and a malformed escape (which only warns) becomes a 400.

Provenance costs one extra query per request, never one per term, and the
approved-review gate is unchanged.

With zero assertion rows every term reports provenance null and the response
is identical to the pre-change shape. That inertness is what makes shipping
the read path safe before the companion backfill runs, and it is locked by
test rather than left to inspection.

Refs #608
Types and typed clients for the three provenance reads, so the public card and
the curation form both go through app/src/api/* rather than raw axios.

provenance is typed as optional-and-nullable: null IS the contract for a
curator-authored term, and the field is absent entirely on a pre-#608 API
build. strength is number | null because null means not recorded, which must
never render as zero stars. sources is documented as pre-ordered by the API so
no consumer re-sorts it and reintroduces nondeterministic rendering.

getEntityVariationEvidence deliberately does NOT encodeURIComponent the
vario_id CURIE. Plumber routes a raw colon in a path segment fine but does not
percent-decode path parameters, so an encoded VariO%3A0017 would arrive
verbatim; the API decodes defensively, but sending it raw is what actually
matches the route.

VariationEvidenceRecord documents that evidence_json carries only what the
import manifests contained -- no HGVS or protein labels, which were never
recorded and must not be displayed or inferred.

Refs #608
entity-rename-service.R copies an entity's variation-ontology terms onto a
brand-new entity_id. Provenance assertions are keyed on entity_id, so the
carried terms arrived with no assertion row -- and absence of an assertion
row is precisely how this feature encodes 'curator-authored'. Renaming an
entity therefore converted every machine-derived, unconfirmed annotation into
an apparently curated one, silently. A rename is not an act of confirmation.

This is a second laundering channel, independent of the review-save one, and
it defeats the same invariant, so it is closed rather than deferred.

Assertions and their evidence are copied preserving state, confirmed_by,
confirmed_at and rejected_reason exactly: a confirmed assertion keeps its
ORIGINAL curator's attribution rather than being re-attributed to whoever
performed the rename. Evidence reattaches to the new assertion_id. The copy
is idempotent, leaves the source entity's rows in place (the old entity may
still be referenced, and its history must survive), and is a no-op returning
zero when the source has no assertions -- which is every entity today, since
the companion backfill has not run.

The call is deliberately NOT gated on the connect-table snapshot being
non-empty: assertions are keyed independently of it and can exist when it is
empty, so gating would drop rows.

Extracted into its own module rather than growing the reconcile file past the
600-line ceiling. An earlier pass had trimmed documentation across three
attempts to squeeze under it, which trades away the readability the ceiling
exists to protect.

Refs #608
Two review findings, both of which let a machine-derived term end up reading
as curator-authored -- the failure this feature exists to prevent.

Reconciliation was entity-scoped, so a Reviewer's draft save that omitted
variation_ontology (or omitted the field entirely, which prepare() normalizes
to zero rows) rejected EVERY assertion on the entity while the approved
review went on serving those terms. The read path filters state IN
('active_unconfirmed','confirmed'), so the still-served terms then rendered
as curated. A draft edit laundered the entity.

The design's §5.3 row 3 mandates rejection on omission, but its primary goal
forbids a machine-derived annotation becoming curator-authored without an
explicit act. On this input the two contradict each other and the goal
governs. Confirmation transitions now always apply; REJECTION applies only
when the save actually determines the entity's served term set. Assertions
are entity-scoped, but what the public sees comes from the primary approved
review, so a draft's omission is not a statement about the served set --
deferring rejection to approval is strictly more faithful to row 3's stated
purpose. The predicate is computed in review_write_mutate() and passed in, so
the planner stays pure and DB-free.

Identity was compared case-sensitively in R while vario_id's collation is
utf8mb4_0900_ai_ci. The originally-reported path turns out unreachable (lookup
validation rejects a case variant with a 400 first, via a case-sensitive
setdiff), but the reachable direction is worse: if the backfill stores
non-canonical casing, every backfilled assertion looks omitted and gets
rejected. Both directions are now tested.

An unparseable submitted set previously degraded to empty, which reads as
'everything omitted' and pushes toward rejection. It now raises. A module
whose purpose is to stop fabricated provenance must not fail toward
destroying it.

Refs #608
A reader could not tell a curator's literature-based claim from a machine
batch import, or from a machine import that had been silently promoted to
curator-authored. This makes origin visible: a machine-derived term carries a
quiet marker and an evidence dialog listing the actual supporting records.

Quiet by design. An unconfirmed annotation is un-reviewed, not broken, so it
uses the neutral chip tone plus a dotted border -- never a warning or danger
tone. Saturated colour across thousands of entity pages would read as alarm
and train people to ignore it. State is carried in text on the trigger's
accessible name, never by glyph or colour alone, and the dialog is a labelled
dialog with focus moved in, focus returned on close, and Escape to dismiss.

The trigger is a separate button rather than the chip itself, so the existing
external ontology outlink keeps working unchanged.

Evidence is fetched on first open only and cached per entity, so the card
costs nothing extra on page load.

The inertness gate is the reason this is safe to ship before the companion
backfill runs: with no provenance the card renders exactly as before. That is
enforced structurally -- the body branches with v-if/v-else on <template>
rather than per-affordance, because a falsy per-affordance v-if leaves
<!--v-if--> placeholder comments in the DOM -- then proved by diffing the
rendered HTML against git show HEAD: of the old component, and locked by a
golden-HTML assertion plus explicit absence of every testid, button and
provenance term, plus a fetch mock that must never be called.

Nested scalars arrive as length-1 arrays because plumber does not auto-unbox,
so each field is unwrapped explicitly and the fixtures use the real wire
shape. A null strength renders as not recorded, never as zero stars, and no
protein or cDNA label is ever displayed: the importer never recorded them, so
showing one would be the fabrication this feature exists to prevent.

Refs #608
The curation form pre-checked every existing term, so a curator editing one
sentence of synopsis re-saved them all onto a new, curator-attributed review.
Nothing distinguished reading the papers and agreeing from not noticing a
checkbox.

The picker now has three zones. Confirmed holds curator-authored and
explicitly confirmed terms. Needs confirmation holds machine-derived ones:
they stay selected and are still submitted, so the annotation does not vanish,
but only Confirm changes their state and saving without touching them is
explicitly allowed. Suggested holds candidates that are not in the entity at
all, unchecked by default. Each card shows the modifier alongside the term,
because present and absent are different assertions with independent state,
and evidence is inline rather than behind a popover -- in the curation flow the
evidence is the decision.

Zone headers carry an honest count. Two terms need confirmation is actionable
in a way a pre-checked box never was.

Remove and Dismiss need no wire protocol: dropping a term from the submitted
set is what the server records, so no client-sent rejected-terms array is
invented. Only an explicit Confirm adds provenance_action, and confirming
marks the form dirty; it is stripped from the draft save/restore so a
localStorage restore can never re-assert a confirmation the curator did not
make.

Unconfirmed is styled quietly, not as an error -- saturated colour is reserved
for the action buttons. Every action button's accessible name names the term
it acts on rather than four identical Confirm labels.

With no provenance and no suggestions the form renders the plain picker, which
is production today until the companion backfill runs.

Refs #608
AGENTS.md gains an Architecture Invariants section, plus a Stack-Specific
Gotchas bullet for the serializer traps this work uncovered: without
null="null" a NULL list-column serializes as {} rather than null, na="string"
renders a nested NA as the literal string "NA", plumber does not auto-unbox
nested scalars, and it does not percent-decode path parameters.

The section documents what the code does, which in two places is not what the
design document says. Rejection scope departs from the literal reading of
spec §5.3 row 3, and that departure is written down with its reasoning so
nobody restores the unconditional form and reintroduces the draft-save
laundering. The charset derivation in migration 047 carries the same kind of
warning against being simplified back.

Residuals are recorded rather than omitted: the feature is inert until the
companion backfill runs and a partial backfill is worse than none; two
curation surfaces still lack the deliberate-act UI and are protected
server-side instead; edit-then-approve-separately never rejects; the
evidence_json key names are a contract the other repo must honour; and the
dead connect-table writers must not be re-wired.

08-development covers how to get provenance rows locally and what each test
file guards. 09-deployment covers the backfill gate, what an operator should
see once it lands, and that no new env vars, secrets or egress are involved.

Refs #608
partitionVariationZones() decided the zone with
`provenance?.state === 'active_unconfirmed'`, but plumber does not auto-unbox,
so the real payload is `"state":["active_unconfirmed"]`. Strict equality
against a length-1 array is false, so every machine-derived unconfirmed term
was misfiled into Confirmed and the Needs-confirmation zone rendered EMPTY --
the deliberate review step this whole feature exists to force was invisible.

Observed live before the fix: confirmed=3 (including the unconfirmed term),
needs-confirmation=0, suggested=2. After: 2 / 1 / 2.

All 44 existing unit tests passed both before and after, because every fixture
used plain scalars rather than the wire shape. The four new tests use the
verbatim real payload so this cannot regress. maxStrength, strength, summary
and source_key are hardened the same way -- they were array-typed too and only
worked by JS coercing a length-1 array in template and Number() contexts,
where strict === does not.

The public card was never affected; it already unwrapped field by field.

Refs #608
Radius and surface literals replaced with the existing tokens, and the legend
prose left-aligned rather than centred, per documentation/10-visual-design-guide.md.

Refs #608
…wser

The provenance tables are empty in every environment until the companion
backfill runs, so without seeded rows there is nothing to exercise and both
the specs and the design review would be vacuous.

The seed is shaped to hit every branch at once: a confirmed term with two
evidence sources (the second with a NULL strength, which must read "Not
recorded" and sort last), the weak-evidence active_unconfirmed case from the
issue, and a suggested term with no connect row. VariO:0001 deliberately has
NO assertion row -- that absence is the curator-authored control, and a SQL
comment says so, so nobody "completes" the fixture later. confirmed_by is
resolved by user name rather than hardcoded, because user_id is
AUTO_INCREMENT.

(VariO:0017, modifier 5) is seeded in a different state from modifier 1 on
purpose: it makes the identity invariant visible in a real browser, with the
same CURIE in two zones simultaneously. Keyed on vario_id alone the two
collapse and confirming one would confirm the other, so it also stops the
monkey pass's "no term in two zones" invariant from passing vacuously.

The public spec asserts the wire contract, the affordance appearing on only
machine-derived terms, the unchanged ontology outlink, lazy fetch with no
request on page load and no refetch on reopen, the absence of any HGVS or
protein label, full keyboard operation, no overflow at 390 and 1440, and axe.
The curate spec asserts zone membership and counts, the browser-visible
identity invariant, per-term accessible names, and a seeded monkey pass that
re-checks its invariants after every interaction.

Three curate tests are gated on a runtime probe and skip while #613 is open --
they re-activate automatically once a review save succeeds.

Refs #608
Pre-existing P0, live since v0.29.3 and unrelated to #608 -- fixed here
because #608's provenance reconciliation runs inside this same transaction, so
Confirm, Accept and reject-by-omission were all dead in production behind it.

The endpoint takes the request body verbatim, and review_json always carries
literature, phenotypes and variation_ontology (the handler reads them off the
same object to pass separately). review_write_mutate() forwarded the whole
object to review_update(), whose mass-assignment allowlist then aborted with
"Disallowed review field(s)" and rolled the transaction back. A curator lost
their work to an opaque 500. The allowlist's own comment claims the update path
"passes a fixed-column tibble (synopsis/comment)" -- that assumption had gone
stale, and its being stale is the bug.

The save path now projects the body to synopsis and comment before it reaches
review_update(). That is deliberately narrower than review_update()'s own
allowlist, and the narrowness is the second half of the fix: the allowlist
legitimately permits is_primary, review_approved and approving_user_id because
svc_review_update() and the approval path need them, so forwarding a
client-supplied review_json into it let a caller smuggle review_approved past
the Curator gate that /api/review/approve enforces. Latent rather than
exploited, since any such payload also carried the ontology keys and 400'd
first.

review_update()'s allowlist and review_create() are untouched -- POST does not
share the allowlist, which is why entity creation still worked and this stayed
hidden. The existing integration tests missed it because they built review_data
from allowlisted columns only; the new tests use the endpoint's real payload
shape and assert the escalation case against the stored row rather than against
the absence of an error.

Closes #613
Refs #608
variation_provenance_plan_reconciliation() built its result with

  tibble(from_state = from_state[changed],
         to_state   = to_state[changed],
         needs_attribution = to_state[changed] == 'confirmed' & ...)

tibble() evaluates its arguments sequentially with data masking, so by the
needs_attribution line to_state and from_state no longer referred to the outer
vectors -- they referred to the columns just created, already subsetted to
sum(changed) elements. Re-indexing those with the full-length changed mask read
out of bounds and produced NA.

The applier coerced that NA to FALSE, so it wrote state='confirmed' while
leaving confirmed_by NULL, and migration 047's chk_confirmed_attribution
rejected the row -- an opaque 500 that rolled the whole review save back. Every
Confirm and every Accept was broken, verified in a browser against the real
stack.

It only misbehaves when at least one assertion is UNCHANGED, which is the
normal case. When every row changes, the column length equals length(changed)
and the mask is all-TRUE, so the expression is accidentally correct -- which is
why a 902-line unit suite passed. The new tests exercise mixed changed and
unchanged sets and assert needs_attribution identically TRUE, plus anyNA(plan)
is false as a general invariant, since that is the property that was violated.

Refs #608
The sibling fix projected the PUT path's body down to synopsis and comment, but
POST still forwarded the raw review_json into review_create(), so a Reviewer
could submit is_primary, review_approved and approving_user_id and publish an
approved primary review without passing the Curator gate that
/api/review/approve enforces.

Post-#608 that is worse than it looks: review_write_save_determines_served_set()
consults exactly those columns, so a forged approved primary review would also
flip the provenance rejection gate and let a submission reject an entity's
assertions.

The create path is now restricted to the columns a submission may set --
entity_id, review_user_id, synopsis, comment. direct_approval still approves,
via review_approve() afterwards, where the role check lives. The new test
asserts a POST attempting to set the approval columns stores the review
unapproved and non-primary, reading the row back rather than trusting the
absence of an error.

Refs #608
…tion

attach_provenance() compared vario_id case-sensitively while
variation_ontology_list.vario_id collates utf8mb4_0900_ai_ci, so the FK accepts
an assertion stored as 'vario:0017' against a term served as 'VariO:0017'. The
write path was already normalized for exactly this reachable direction -- the
companion backfill lives in another repository and may store non-canonical
casing -- and the read path was not, so the two halves of the feature disagreed.

The consequence is the fabrication this feature exists to prevent: a mis-cased
assertion yields provenance: null, which the contract defines as
curator-authored, so the public card showed it as curated and the picker filed
it under Confirmed -- while the evidence route, which resolves in SQL and is
therefore case-insensitive, still returned its machine-derived evidence.

Normalized locally rather than by calling the write-path helper, deliberately:
the endpoint tests source only this module, and the write-path form
as.integer(trimws(as.character(x))) would have reintroduced a real bug, since
as.character(100000) is '1e+05' and would silently split the entity_id join that
an existing large-id test guards. Both key builds here now share one helper,
with roxygen cross-referencing the write path.

Also adds the static assertion the rename call site never had: entity-rename-
service.R must call variation_provenance_carry_forward_entity with the rename
transaction's connection. That call site was only parse()-verified, and a
dropped conn argument would silently lose provenance on a renamed entity.
Proven able to fail in both directions before being restored.

Refs #608
Plumber does not auto-unbox, so every scalar the R services build with list()
arrives as a length-1 array. The client declared them unboxed and returned the
raw payload, so TypeScript lied to every consumer -- and that is exactly how the
Needs-confirmation zone came to render empty: a strict-equality predicate was
silently false against ['active_unconfirmed'].

The three provenance reads now normalize the wire shape in the client, so the
declared types are accurate at the one documented boundary rather than being
patched up per consumer. The normalization is idempotent, so the two consumers'
existing defensive unwrapping keeps working and becomes a harmless no-op.

null is preserved exactly: provenance stays null (the curator-authored
contract) and an unrecorded strength stays null rather than becoming 0.
evidence_json is deliberately untouched -- it is typed unknown because its inner
shape is a cross-repo contract the dialog probes by alias, and normalizing it
could silently change what the dialog finds.

Refs #608
A review save resets review_approved = 0, so a term vanishes from the public
read until it is approved again. The write-path tests now re-approve through
PUT /api/review/approve and read back through the public endpoint, so each
assertion travels the same path a reader does instead of peeking at the
database.

Also records, in a comment rather than an assertion, that apply_rejections is
computed after review_update() has already reset the approval flag, so on the
plain PUT path reject-by-omission never fires and is reachable only via
direct_approval. That is consistent with the module's own "removal becomes real
when it becomes public" rationale, but whether it is the intended end state is a
product decision rather than a test one.

Refs #608
Two layout defects found by looking at the rendered dialog rather than the code.

The dialog is rendered from inside the entity evidence card, which centres its
text, so every free-flowing paragraph inherited that centring while the
label/value rows only LOOKED aligned because they are grids. The panel ended up
with four competing alignments: right-aligned labels, left-aligned values,
centred summary/heading/matched-via prose, and record rows at a fourth
x-position. Centred prose inside a left-aligned data panel reads as accidental,
so the dialog now establishes its own alignment instead of inheriting the card's.

Record rows used the default stretch alignment. A row mixes a monospace id with
smaller prose, and at 390px the prose wraps and the row grows -- measured, all
three children became 44px tall and the consequence text rendered ABOVE its own
id. They now share a baseline, which is what a reader expects of a text row.

The 44px row height itself is deliberate and left alone: it comes from the
global mobile tap-target rule, so each ClinVar link is a real touch target and
the spec's axe target-rule assertion depends on it.

Also fixes a fixture stutter -- source_version '2026-01 release' rendered as
'release 2026-01 release' under the dialog's own Imported label -- and makes the
public spec reseed once before it runs. The sibling curation spec accepts a
suggested term and saves reviews, which adds curated rows and re-inserts the rest
with fresh ids, so run after it this read-only spec saw a suggested term in the
curated set. That is order-coupling, not a product defect.

Refs #608
Three test files pinned EXPECTED_LATEST_MIGRATION and EXPECTED_MIGRATION_COUNT
to 046 / 44L, so adding migration 047 failed seven assertions across them. The
manifest is a single source of truth and every migration legitimately moves it;
these are the assertions that keep it honest.

Two of the test names hardcoded '046' as well, which would have gone stale again
on the next migration, so they now describe the invariant (the manifest tracks
the LATEST migration) rather than a specific number. The separate test that reads
migration 046's own file to assert its generator_json column still points at 046,
because that one is about that migration's contents rather than the manifest.

Caught by CI rather than locally because I had been running the provenance test
files directly instead of the gate. make test-api-fast now reports
FAIL 0 | PASS 9071.

Refs #608
@berntpopp
berntpopp merged commit ff3c6b7 into master Jul 30, 2026
12 checks passed
@berntpopp
berntpopp deleted the feat/variation-ontology-provenance-608 branch July 30, 2026 18:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant