Proposal — 0.5.1: four seams a downstream consumer cannot cross without re-implementing something¶
Status: ✅ all four decided and shipped in 0.5.1 (2026-08-07), alongside
RM38. Each is
enricher/compiler API shape, out of artifact.digest, and touches no parquet — which is what made
them patch-shippable inside the closed 0.5 digest window (CONSTITUTION P3/P8) rather than 0.6 schema
work. What shipped, and the shape decisions taken while building, are recorded per item in
ROADMAP_HISTORY.md; this file keeps
the filing — the argument as the consumer made it — because that is the part worth not re-deriving.
One thing changed on contact. RM41 was filed as "either half closes it, preferably both", and both
shipped — which makes 0.5.1 a two-package cut: just-dna-compiler takes the public
load_csv_rows / load_spec_variants, just-dna-enricher requires >=0.5.1, and just-dna-format
stays at 0.5.0 with nothing changed.
These came from a consumer field report rather than from design, in the same shape as
CONSUMER_SUGGESTIONS.md's S1/S2: just-dna-registry 0.11 wires the 0.5
pipeline server-side (enrich() → compile_module(strict=…), plus the optional passes behind a
/check pre-flight endpoint), and each of the four below is a place where doing that correctly
required either special-casing one pass against its siblings, copying logic out of this workspace, or
reaching into a decorator's state to change behaviour nothing exposes.
The through-line is worth stating, because it is the same argument each time and it is one this
codebase already makes elsewhere: a number this workspace computed and then discarded gets
recomputed by every consumer, and a recomputation is a place to drift. That is exactly why
base.field_category exists (two surfaces answering one requiredness question, and the drift-proof
one was the stale one) and why resolution.hosting_verdict is shared with the deprecated DuckDB path
rather than duplicated. Each item below is the same shape, one tier out.
RM39 — enrich_dosage_sensitivity is the only pass with no offline parameter¶
The asymmetry. Every other pass in the enricher takes offline: bool and degrades on it —
enrich, enrich_frequencies, enrich_gene_metrics, enrich_literature, enrich_pgx,
verify_acmg_sf. clingen.enrich_dosage_sensitivity(spec_dir, *, mode, declared_use, write,
curation_text, url) does not. It downloads ClinGen's gene-curation TSV unconditionally, and the only
way to stop it is to inject curation_text= — which requires the caller to have fetched the thing
already, i.e. to have solved the problem the parameter would solve. The dosage CLI command has no
--offline flag either, so the asymmetry is user-visible and not merely internal.
What it costs. ENRICHER.md documents --offline as "clamps to local caches /
sidecars" and the registry advertises the same guarantee — an offline dry run is asserted to make
zero egress. A caller running the family under one flag therefore has to know, out of band, that one
member of it does not honour the flag, and hoist a if not offline: around that call specifically.
The registry now does; it is a guard that has to be re-derived by every consumer, and the failure
mode of forgetting it is silent egress from a path documented as having none.
Why the shape matters more than the flag. enrich_frequencies is the model to copy: online-only,
and --offline makes it a no-op with a warning, reported as FrequencyResult.skipped_offline.
That is a first-class answer a caller can render ("the frequency pass did not run because this
deployment is offline"), and it is different both from "it ran and found nothing" and from "it
failed". ClinGenResult has no equivalent, so even a caller that does hoist the guard has nowhere to
put the reason except its own prose.
Ask. enrich_dosage_sensitivity(..., offline: bool = False), no-op-with-a-warning when set,
ClinGenResult.skipped_offline beside it, and --offline on the dosage command. Additive on every
axis; nothing that exists today changes behaviour.
Not asked for, deliberately: a ClinGen snapshot. That is RM38's family and a much bigger question. This is only about the flag meaning the same thing in every function that takes it.
RM40 — VRS coverage is computed by enrich() and thrown away¶
The finding. vrs.mint_resolution_rows returns a MintResult carrying exactly the two numbers
compile_module will later stamp into manifest.compilation.vrs_alleles /
vrs_alleles_identified — plus unmintable_reasons, the grouped-by-reason breakdown that is the
actionable half — and enrich() logs coverage_warnings() and drops the object.
EnrichmentResult carries rows, unresolved, sources, mode, ref_mismatches,
clin_sig_conflicts, stale_rsids, par_twins_dropped — and nothing about minting.
Why that is a defect rather than a missing convenience. The whole point of the coverage counters,
as COMPILER.md puts it, is that "a consumer can read the reliability of the identity
scheme instead of inferring it". A consumer that wants to read it before a compile — which is
what a publish dry run is — cannot, so it re-implements the counting over EnrichmentResult.rows.
The registry's services/enrich.py::vrs_coverage is that re-implementation, and it has to get two
non-obvious rules right to agree with the manifest a publish would produce:
- count per ALT slot, not per row, because
vrs_idis a parallel array ofalts; - treat an absent cell as
len(alts)unnamed slots rather than as zero slots, or a table where nothing minted reports flawless coverage out of a denominator of nothing.
Both are stated in MintResult's own docstring. A consumer that reads only the field list gets the
second one wrong in the direction that reports a problem as a success — the failure mode the
identified == alleles derivation exists to prevent.
And the reasons are unreachable at all. unmintable_reasons is where "no refget table for build
'GRCh37'" and "needs the reference sequence" live — the difference between a finding an author can
act on and one that is the tier's own limit, which is precisely the distinction the verify pass's
three-outcome table is built on. Today that survives only as a log line, so a service reporting to a
publisher over HTTP can show the shortfall and not the reason for it.
Ask. EnrichmentResult.vrs: MintResult | None, populated when mint_vrs=True and None when the
pass did not run (None ≠ a coverage of zero — the house rule). Purely additive: a dataclass field
with a default, no behaviour change, no signature change on enrich().
RM41 — _load_csv_rows is private, and it is the only way to load an authored CSV¶
The finding. Two checks take rows rather than a spec directory —
acmg.verify_acmg_sf(variants: list[VariantRow], …) and
identifiers.check_identifiers(variants, …) — unlike every other pass, which takes spec_dir. So a
caller has to turn variants.csv into VariantRows itself, and the only thing that does that
correctly is just_dna_compiler.compiler._load_csv_rows, which is private. This workspace's own
just-dna-enricher CLI reaches across the package boundary for it in both check-acmg and
check-identifiers.
Why re-implementing it is a trap rather than a chore. It is not csv.DictReader plus
Model(**row). It carries two rules a hand-rolled loader gets wrong:
- An empty cell becomes
None, and the key is kept. SCHEMAS.md documents the consequence in the requiredness discussion:MeasureBinRow.measure_kindhas a default, sois_required()isFalse, but the model receivesNonerather than its default and fails on type. A""where the loader would have putNoneis a different failure again. genome_buildis told to each row, not read from it. A pydantic model built from a CSV dict has nomodule_spec.yamlin scope, so a loader that does not inject the module's declared build mints GRCh38 identities for a GRCh37 module — the exact bug_restamp_for_buildexists to fix, one layer up.
A consumer that writes its own loader therefore has a second, drifting copy of the rule this workspace already had to fix once.
Ask — either half closes it. Preferably both:
- Make the loader public:
compiler.load_csv_rows(path, row_model, file_label, genome_build), same body, same three-tuple return. It is already de-facto public — this workspace consumes it across a package boundary, which is the definition. - Give the two row-taking checks a spec-dir entry point, so they match the shape of every other
pass:
verify_acmg_sf(spec_dir=…)/check_identifiers(spec_dir=…)alongside the existingvariants=form. The row-taking form should stay — it is the right thing for an in-process caller that already holds the rows.
Until then a consumer picks between reaching for a private symbol and re-implementing a loader with
two known traps in it. just-dna-registry chose the private symbol and pinned the signature with a
test, on the reasoning that a rename failing CI is strictly better than a silent divergence in row
normalization — but that is a workaround for a boundary, not a place to leave it.
RM42 — the retry policies are import-time constants, so a deployment cannot tune persistence¶
The finding. Every live client retries, and the policy is sound: tenacity, exponential jitter,
on transport errors, timeouts and the two clients' own rate-limit exceptions, and — the part that
makes this safe to touch at all — paced before the retry, so an extra attempt spends a slot of
the budget rather than bursting past it. What a caller cannot do is choose how many. The nine
policies are @retry(stop=stop_after_attempt(3)) (or (4) for gnomAD and eutils) evaluated at
import, with no parameter, no setting and no environment variable.
Why one number cannot serve both callers. Three attempts is right for the audience the CLI was
written for: an author at a terminal, who would rather see a failure in ten seconds than wait out a
flapping upstream. It is wrong for the other deployment shape the 0.5 tiering created — a server
running enrich() inside a publish. That work is unattended, it has already been queued, nobody is
watching a spinner, and giving up on a transient 502 does not cost ten seconds: it costs the
publisher a whole re-upload of a module the server had already accepted, validated and dedup-checked.
The two want opposite things from the same constant, which is the definition of a knob.
What a consumer does today. just-dna-registry walks the package at boot, finds every attribute
that is an instance of tenacity.BaseRetrying, and assigns policy.stop = stop_after_attempt(n) —
raising only, so a client already more persistent keeps its own policy. It works, BaseRetrying.stop
is documented and stable, and it is pinned by a test that asserts the walk still finds all nine. It
is also plainly a consumer reaching into another package's decorator state to change behaviour its
author did not expose, which is the thing an RM is for.
Ask. A single EnricherSettings-style attempt count (or JUST_DNA_HTTP_RETRY_ATTEMPTS) read
where the clients are constructed, defaulting to today's values so nothing changes for the CLI. Two
notes on shape, from having built the workaround:
- A floor, not a setting per client. The per-client differences are deliberate — gnomAD and
eutils are at 4 because their budgets are tightest — so a single number that raises everything
to at least
npreserves that tuning, where a single number that sets it would flatten it. - Leave a composed
stopalone.stop_after_attempt(3) | stop_after_delay(60)means both, and raising one term silently changes a policy whose author meant the conjunction. None of the nine is composed today; the rule matters the day one is.
What is not here¶
Three things the same audit looked at and concluded were correct as they are, recorded so they are not re-raised. One of them has since changed for a reason the audit could not have known, and the note is kept rather than deleted because the reasoning was right at the time:
enrich_clinpgxtaking noofflineparameter. ~~Unlike RM39 this is right: the pass is snapshot-only and never fetches, so a flag would be a parameter with one legal value.~~ (It did cost a consumer bug — the registry skipped the whole PGx family offline, ClinPGx included — but the fix belonged in the consumer, and the note now in ENRICHER.md pass 6 is what it needed.) Overtaken by RM38, shipping in the same cut. The premise was that the pass never fetches, and that was true only because its snapshot was orphaned from the plumbing — nolocationsresolver, noensure_*, so it skipped itself unless handed--snapshotby hand. Now that it can provision one, it has something to decline to do, andofflinehas a second legal value. The general lesson is worth more than the item: "a flag with one legal value" is a claim about the current wiring, not about the function — re-ask it whenever the wiring changes.refget_accessionraisingUnsupportedBuildErrorrather than returningNone. Documented, deliberate, and the caller-side catch is the point — a build with no table should be heard as "not built yet", not answered in GRCh38.- The
--usespelling split (non-commercialon the CLI,non_commercialinVALID_DECLARED_USE). The CLI normalizes at its own boundary, which is where a user-facing spelling belongs; a consumer exposing the vocabulary member directly just has to validate against the frozenset, which is one line and the same thing every other vocabulary asks for.