Roadmap — 1.0¶
What this file is. Items that need a major, plus the one item that is release-blocking for it.
Split out of ROADMAP.md on 2026-08-13, alongside the minor-deferral file — then
ROADMAP_0_7.md, now ROADMAP_0_8.md, with the closed round in
history/ — so the
active roadmap describes the line being built.
What makes an item belong here. Under the amended charter (2026-08-11) a new optional column or
table is additive and lands in a minor. Major-only is removal, promotion to required, and
retyping — the moves that break an existing reader or invalidate published data — plus any change to
what a published identity means. "It moves a recompile's artifact.digest" is not a reason;
Principle 4 already scopes byte-reproducibility to a fixed compiler_version.
A third class lives here too, added 2026-08-27: an item that is gated on a major without needing one. RM69 is the case — its own repair is minor-legal, and every way of reaching it before RM15 lands was refused. An item is filed against the release that decides it, not the release its diff would be legal in, because filing by legality leaves it in the minor file waiting on a version that file promises nothing waits on. Such an entry names its gate in the status line and is listed under the gating item, so building the gate closes it rather than leaving it to be re-found.
Read the 1.0 cleanup tracker with this file. It still lives in
ROADMAP.md § The 1.0 cleanup and holds the unnumbered
items (the state alias, the pathogenic/benign booleans, StudyRow.p_value: str, the
weights.parquet dead columns, the sources.csv → licensing.csv rename's removal half, and now the
panel: block deprecated in 0.6). Indexed in RM_TOC.md.
The amended cadence, which several tracker entries predate: deprecate in a minor, remove at the next major. An item whose replacement already exists takes its warn-only deprecation in a 0.x release and is gone at 1.0, not deprecated at 1.0 and lingering to 2.0. What genuinely keeps its old shape is whatever Principle 8 makes mandatory, because an author cannot comply with a warning about a field they must still set.
RM260 — the re-export shells left behind by the monolith split¶
Severity low · Status open — 1.0 (the split shipped in the 0.8 line; removing the shells moves
import paths, which only a major may do) · Owner compiler + enricher · Motivating case the
2026-09-25 wheel-size read: compiler.py was 492 KB in one file, cli.py 244 KB, enrich.py 152 KB
What shipped. The three modules became packages under the same import paths:
just_dna_compiler.compiler/ (16 submodules), just_dna_enricher.cli/ (15 command modules plus
_shared and __main__), just_dna_enricher.enrich/ (6 submodules). Each package's __init__.py
re-exports every name the old module bound, using the name as name idiom, so all 378 + 248 + 136
old dotted paths resolve to the same objects. No importer here or in the sibling repos changed. The
logger names stayed the same too.
What is left for 1.0. The shells are the pure-re-export __init__.py the coding standards say to
avoid, because they obscure where a symbol lives. They exist only because the paths were published.
At 1.0:
- importers move to the defining submodule (just_dna_compiler.compiler.pipeline.compile_module,
just_dna_enricher.enrich.orchestration.enrich, …);
- each __init__.py keeps only what the package itself owns (for cli, the app assembly);
- the sibling repos move with it. just-dna-lite imports enrich, EnrichmentError,
EnrichmentResult and the cli app; marketplace names enrich._collect_subjects in a comment.
The two-step cadence applies: a DeprecationWarning on the shell path in the last 0.x minor, then
removal at 1.0.
The hazard any further split inherits. A test that monkeypatches a name on the shell stops
reaching the code once the caller lives in a submodule. Two such patches in enrich had been going to
the live network and passing anyway. Patch where the name is looked up, and prove it with
outbound connections blocked.
RM226 — the three declared warts the code carries in comments and no tracker¶
Severity low · Status open — 1.0 (both repairs move published identities; neither is legal in a minor) · Owner format + compiler · Motivating case filed out of the 2026-09-11 re-derivation's self-declared candidates (schema D6/D7, compiler 13.7) rather than left as comments
Three things the code already says about itself, given a number so a major has a list instead of a grep. None is a discovery and none is a bug report: each is a decision whose reversal is what needs a major, and the reason they are filed is that a comment is not a tracked item — the 1.0 cleanup cannot enumerate what lives only in docstrings.
A. VariantRow.variant_key / authored_ident are inside content_signature while the identical
stamped columns on HeteroplasmyRow, HaplotypeRow and PharmVariantRow are outside it (they carry
exclude=True; these two carry compiler_managed with exclude unset). base.stamped_identity_field
calls this "a grandfathered inconsistency, not a precedent", carried because either repair moves
published signatures — un-excluding there or excluding here. Measured and confirmed during the round.
The rule it bends is a real one: a compiler-stamped column is meant to be outside content identity,
because a stamp is a pure function of the authored cells and so says nothing they do not.
B. likely_pathogenic / likely_benign are hardcoded False in every weights.parquet row
(compiler.py), with no authored field behind either, pinned as deliberate by test_v03.py — "a
permanent wart of the 0.x line rather than repaired". Filling them would change what an existing
reader is told with no way for it to notice; removing a published column is major-only. So the column
is a published False that means "not stated", which is the one thing the house algebra says a
None may never collapse into (None is never False). It is the sharpest example in the tree of
an axis that predates the rule.
C. VariantRow._freeze_identity derives its key with no build=, so it takes
derive_variant_key's GRCh38 default whatever the module declares. base.py says this is deliberate
— VariantRow keeps its existing restamp, and the correction plus the "keyed by coordinate instead"
warning a GRCh37 module must hear both live one tier up. It is listed because the same shape was a
real bug four times (the removed authored_key, "the exact identity falsification the 2026-08-06
sweep found in four other places"), and because a VariantRow used outside the compiler silently
gets a GRCh38 key with nothing to correct it.
What 1.0 owes each. A: pick a side and state which published signatures move, under RM52's upgrade procedure. B: either retire the two columns or give them an authored source; both are major. C: decide whether the format tier may mint a build-blind key at all, which is RM15's question — file C under RM15 if that lands first.
RM227 — derive_variant_key's coordinate fallback does not fold allele case¶
Severity medium · Status open — needs a decision on which release, not on whether · Owner
format · Motivating case surfaced by RM215 rather than fixed by it (@fix-vs-surface)
RM215 made content_signature fold the case of an allele cell whose grammar is case-insensitive, so
one heterozygote stopped having four content identities. It deliberately did not touch
derive_variant_key, and the asymmetry that leaves is measured:
VA path (GRCh38, single alt) ref='a' alts='g' → ga4gh:VA.4egzSm1kjc9hSnLPm3JZhmsA1kttQa5b
ref='A' alts='G' → ga4gh:VA.4egzSm1kjc9hSnLPm3JZhmsA1kttQa5b (equal)
coordinate fallback ref='a' alts='g,t' → 1:100:a:g,t
ref='A' alts='G,T' → 1:100:A:G,T (differ)
VRS normalizes case, so the VA path is already case-insensitive; the chrom:start:ref[:alts] fallback
is not. It fires for a multi-alt row or a non-GRCh38 build — both ordinary. What splits there
is not identity-for-dedup but joins: two rows naming one locus get two keys, and @vkey-precedence
means a module can hit the folding path and the non-folding path in the same table.
Why it is not RM215's diff. variant_key is a stored cell — stamped into the row, written to
parquet, and joined on by consumers. Folding it rewrites published data rather than a derived key, so
the corrected-derivation argument that made RM215 minor-legal does not reach it, and the round-trip and
@verbatim-except-order questions both open up.
Also unresolved: ref/alts are not grammar-checked at all (both accept zz today, because a
non-nucleotide there is a spelling defect a later pass diagnoses, @non-nucleotide-spelling). So a
fold there cannot key on "it is an allele column" the way RM215's marker does — it would have to fold
only values that parse as nucleotides, and decide what to do with the ones that do not.
Pinned meanwhile by test_allele_case_is_outside_content_identity.py's last test, which asserts the
current splitting behaviour so this note cannot rot into a silent fix.
RM269 — DiplotypeRow.haplotype_b is required, so a hemizygous star-allele call has no row¶
Severity low · Status open, 1.0 — a Principle 8 demotion · Owner format (pgx.DiplotypeRow) ·
Motivating case S119, note 1
What was confirmed. haplotype_b has been required since 0.4.0, while variants.csv has spelled a
hemizygous genotype as a single allele since the same widening. So an X-linked phenotype in a male,
G6PD being the common one, can be stated per variant and not per haplotype. The source already writes
it: CPIC's diplotypes table (the local snapshot) spells 187 G6PD rows as a single haplotype with no
/ (A, Aachen, A- 202A_376G). Our drafter never meets them, because every G6PD name fails
STAR_ALLELE_PATTERN whether it is a pair or not, so the gap is masked there rather than reported.
The reporter's caller is diploid and reads a haploid contig as no_match, so nothing is being
silently miscalled today.
Why this is 1.0. The requested repair, a nullable haplotype_b meaning "no second copy", demotes a
required field, which Principle 8 forbids inside a major. An older reader would meet a pair with one
half missing.
Why the in-line alternatives are wrong.
- A sentinel name in
haplotype_b. It is legal today and it is the trap. Probed:-is accepted and canonicalization (a <= b) sorts it intohaplotype_a, soB/-is stored as-/B, andnoneis accepted as an allele called "none". A name column would then carry a structural claim that no reader can tell from a real allele (P5). - Repeat the haplotype plus a new optional
ploidycolumn. Minor-legal, and harmful: every reader that predates the column readsB/Bas a homozygous female. A new column may not change what the old ones mean. - A new optional haplotype-keyed phenotype table. Minor-legal and harmless to old readers. But it
is a second home for "a genotype → a phenotype" beside
diplotypes.csv, one table over, for one ploidy. If a corpus needs it before 1.0, it is the one to weigh against waiting.
The 1.0 shape. Make haplotype_b optional, and give the empty value its own meaning ("no second
copy") distinct from unknown, which the format never states on this table. The ledger row in RM52 is
owed when it lands. The author's upgrade route is "no action needed", since every existing row sets the
field. A consumer needs to handle a null haplotype_b in diplotypes.parquet.
RM15 — Build-agnostic identity & multi-build support¶
Severity large · Status deferred to 1.0 — and not for digest reasons · Owner format (schema + compiler) · Motivating case GRCh37 / T2T modules; cross-build annotatability
Today a coordinate is implicitly GRCh38: genome_build is authored/manifest metadata, but every
chrom/start/ref, the resolver, and all coordinate reasoning assume GRCh38. Coordinates are not
absolute — GRCh37, GRCh38 and T2T-CHM13 disagree — and the rs-number↔coordinate mapping is
build-specific: an rs-number may resolve in one build and be unplaceable in another.
This item makes the build a first-class axis: coordinates tagged by (or resolved per) build, a build-aware resolver (a module/reference build mismatch degrades to unverified rather than a false consistency error), and cross-build annotatability recorded as data. It also generalizes one-to-many rs-number expansion to multi-build — which loci and how many are build-specific; the GRCh38 expansion itself is not deferred.
Why it is a major. It changes the semantics of variant_key and of every coordinate. That is the
identity-change class a major exists for, and it is the one thing reverse_module cannot mitigate: a
third party who stored joins against the old keys is not helped by a faithful round-trip.
What already shipped out of it, so it is not re-scoped. The build-naming half closed as RM19: a GA4GH VRS allele id names its build, because the sequence is addressed by its refget accession, so GRCh38 and GRCh37 mint distinct, correctly non-colliding ids. That resolved the "coordinate-first identity" parking on its own stated condition.
What a non-GRCh38 module gets today is pinned, and was not before. The 2026-08-06 audit found four
paths quietly answering a GRCh38 question in a GRCh37 module's name — all four survived because every
reference example was GRCh38, so "reads the build" and "writes GRCh38" were indistinguishable.
reference_examples/grch37_build/ closes that and test_reference_examples_roundtrip.py asserts the
corpus spans more than one build. This does not shrink RM15: RM15 is about supporting another build;
what shipped is only that the tools decline to answer for one rather than answering wrongly.
Not to be confused with RM48 (an hg19 coordinate reaching a GRCh38 module), which is one-way, authoring-time, re-keys nothing, and is built in 0.6 — see PROPOSAL_0_6.md.
Paired with the weights.parquet end column in the cleanup tracker: both need the coordinate
convention for a second coordinate settled (interbase-half-open vs inclusive). The authored start
half closed in 0.5 — it is 1-based VCF POS and now says so, pinned by a test.
Interacts with RM5 (structural alleles differ across assemblies) and the reserved reference_db axis.
What lands with it, so whoever builds RM15 knows what it closes (the list is the point — until 2026-08-27 both of these named RM15 and RM15 named neither, which is how a gated item stops being findable from its gate):
- RM69
closes outright. The
_resolve_positional_tablesskip ongenome_build != DEFAULT_GENOME_BUILDis the RM15 gate; un-gating the fill makes the injected rows reach the positional parquets, which is the missing field Principle 7's own remedy clause points at. Nothing else reaches it. - RM68 loses its premise but keeps its own exit. Once a provider can write the coordinate under the build it came from there is nothing left to refuse or strip. It stays filed in 0.7 because its other exit — an author with a non-GRCh38 module saying which outcome they wanted — is reachable first and would settle it without RM15; check its status before assuming this closes it too.
- RM32's PAR multi-build half lands here. PAR intervals are per-assembly, so
par_partnerwithholds on every build but GRCh38; RM32 (0.5) handed the generalization to this item. Multi-build support has to carry a PAR table per assembly, or PAR modules stay GRCh38-only.
RM69 — resolution_signature is not a round-trip invariant when the positional fill is skipped¶
Severity low · Status gated on RM15,
moved here from ROADMAP_0_7.md (now closed) on 2026-08-27 — the repair itself needs no major, but
every route to it before RM15 lands is refused below, so 1.0 is the release that decides it. Until then
it is a documented limit of Principle 7 on non-GRCh38 modules, not a breach · Owner compiler ·
Found by dogfooding on 2026-08-13, the D6 corpus sweep
What was observed¶
The D6 sweep round-tripped all sixteen reference examples and compared four signatures.
artifact.digest, content_signature and source_signature are a fixed point everywhere.
resolution_signature moves on four modules, in two distinct shapes, and only one of them is this item:
None → valueon the three carrying noresolution.csv(grch37_build,mt_heteroplasmy,cyp2d6_structural). Reverse materializes the table from the parquets, nothing is lost, and lap two is stable. Not a defect.value → valueon exactly one,cyp2c9_warfarin_grch37:c6fd3238…→a0558501….
The mechanism is closed. _resolve_positional_tables skips the positional fill when
genome_build != DEFAULT_GENOME_BUILD, because the identity minting behind those keys is GRCh38-only
(RM15). So the module's injected coordinates never reach pharm_variants.parquet; and
_write_resolution_csv rebuilds the table from weights.parquet plus the positional parquets, while a
PGx module has no weights.parquet at all. The three source=manual rows — rs12777823, rs2108622,
rs9923231, each carrying a ref/alts pair a curator supplied by hand — have nothing to be rebuilt
from and are simply gone.
Say what is lost precisely: those three rows are hand-curated, not machine-derived, so re-running
enrich does not reproduce them. Recovering them after a round trip means re-authoring them. That is
why the item exists at all; it is low severity because the module in the repository is the source of
truth and no published workflow round-trips a module and then discards the original, not because the
content is cheap.
Is this a Principle 7 violation?¶
No, on the letter, and the charter's own next sentence is the diagnosis. Principle 7 says
"Lossless round-trip. compile_module → reverse_module → compile_module preserves every
authored value." resolution.csv is not an authored value: it is a machine-written, build-time derived
sidecar, priced at half by the 0.6 charter amendment, whose only consumers are the compiler and the
enricher's update run. Every authored value does survive — content_signature and artifact.digest
are equal before and after (81a03fe9… and e0e14408… at 0.7; they read 989e8298… and d7a4f37e…
when this was first measured at 0.6, and moved when RM70 populated requires_callable on the module's
two positional tables — the finding is the equality across the two laps, not the values).
The clause that does apply is the one immediately after it: "If a value cannot survive the round-trip,
the artifact is missing a field, not the spec." That is exactly right here, and it is the whole
finding. The missing field is the coordinate on the positional parquet. RM43 added it in 0.6 for
pharm_variants / haplotypes / heteroplasmy, and RM15 gates the fill on GRCh38. So Principle 7's
own stated remedy points at a blocker Principle 7 cannot remove, and the honest statement is a
documented limit of P7 on non-GRCh38 modules, not a breach.
Candidate repairs, and why each is wrong¶
- Join
resolution.csvonto the positional tables regardless of build. This is precisely what the RM15 gate refuses.resolve_from_tablefilters on thegenome_buildcolumn, andderive_variant_key/derive_vrs_allele_iddefault to GRCh38 — so the join would place rows against loci the compiler cannot re-derive a key for, and mint GRCh38 identities for GRCh37 coordinates. That is the defectreverse_module's hardcoded constant caused andtest_build_call_sites.pynow walks the AST to prevent. The compiler would be manufacturing the identity it cannot check. - Have
reversecarry the injected table through verbatim. It cannot:reverse_moduleis a function of the artifact, and takes an artifact directory. The originalresolution.csvis not one of its inputs. Making it one would turn reverse into a function of the spec directory as well, which is a different transform — and it would re-emit provenance reverse discards on purpose (sourcebecomesreversed,statusbecomesresolved,fetched_atempties). - Write the injected coordinate into the positional parquet without joining. The join spelled
differently. Writing the coordinate is what the gate forbids, and doing it without re-deriving the key
yields a parquet row whose coordinate and whose
variant_keywere computed on two different assemblies. - Add
resolution.parquet. RM43 refused it explicitly, and the charter amendment states the reason in general (this is the first repair anyone proposes on finding a table with no coordinate). It also does not fix anything here: it would stabilise the signature while the fill it exists to feed stayed skipped, so the module would hash as though its resolution survived when the data remained unjoined. A stable hash over unusable rows is worse than a moving one. - Stop comparing
resolution_signatureacross the round trip. The delete-the-test repair, and the D6 sweep is the argument against it: the sweep readgetattr(manifest, "resolution_signature", None)while the field lives onmanifest.compilation, so it comparedNone == Nonefor eleven examples for the whole of 0.6. Un-comparing it now restores exactly the blindness just repaired. What this wants is a named, single exception, not a dropped comparison.
What would unblock it: RM15. Nothing smaller reaches it — a sixteen-module corpus produces exactly one instance, because it takes a non-GRCh38 module that also carries a positional table with an injected coordinate.
Why it was not simply closed instead, 2026-08-27¶
Closing was the live alternative when the misfiling was found, and the case for it is real: severity low, no incident behind it, and a mechanism reproduced once in sixteen modules is normally grounds to close rather than to carry. It stays open because the fix arrives on its own — RM15 un-gates the fill and the signature becomes an invariant with no work filed against this entry, so filing it as a "documented divergence" the way RM67 is filed would say the wrong thing — RM67 is not work, a limit nobody intends to lift, and this one is scheduled. (Neither is closed: RM_TOC's ✖ section holds two items and neither of these is one.) The other half of the argument is the candidate-repair list above, which is the record of five refusals. Closing the entry retires that record just as the release most likely to re-propose all five arrives.
RM52 — 1.0 ships an upgrade procedure, or 1.0 does not ship¶
Severity high — release-blocking by charter · Status open, 1.0, mandatory · Owner format + compiler + enricher · Origin the 0.6 charter amendment (CONSTITUTION § Amendments), which permits breakage at a major only when it arrives mitigated
The obligation. A major carries the documented route from the previous line: per item, either the mechanical migration or an explicit no action needed. A removal whose upgrade path is left to the reader to work out is not ready to ship, however long it was deprecated first. This is a principle, so 1.0 is blocked on it the way it is blocked on the round-trip tests.
It is not the CHANGELOG, and the difference is the audience. Two audiences break in different places and cannot use each other's instructions:
- A module author holding a 0.x spec — what moved on the authored surface (the
sources.csv→licensing.csvrename, thepanel:block, theStudyRow.pmidrequiredness demotion, thealt/refvocabulary members dropped from the read set, whateverstateand thepathogenic/benignbooleans become). Their remedy is an edit, a rename, or a tool run. - A consumer holding a 0.x artifact — a parquet filename, a manifest key, a
variant_keysemantics change (RM15). No edit of theirs helps: they need to know what to re-read, what to re-key, and what silently still works.
What "mechanical migration" can mean, because the primitive exists. reverse_module rebuilds the
authored spec from a compiled artifact, so for anything expressible in the DSL the upgrade is reverse
under the old compiler, recompile under the new one — and the procedure's real job is to say which
items that covers and which it does not. It does not cover an identity re-key. Naming that boundary is
most of the value.
Three rules for how it gets written, all of which exist because the alternative has already failed
here. The line is written when the item lands, not when the release is assembled. A "no action
needed" must be stated explicitly, because silence is indistinguishable from an oversight — the same
reason clin_sig_not_checked and gene_loci_not_checked exist. And the claims are checkable, so
check them: "reverse and recompile" can be run across reference_examples/ and asserted to reproduce.
An unrun migration is prose, and prose that has never been executed is how a documented start
convention shifted 3,038 variants.
Decided 2026-08-13: the 0.6 batch does not owe per-item ledger rows. Imposing the discipline on
every work item now is premature decision-making — the obligation belongs here, where it can be settled
against the shape that actually exists by then. 0.6's deprecations (panel:, and sources.csv
alongside licensing.csv) are recorded in the 1.0 cleanup tracker in the ordinary way.
The ledger¶
One line per breaking item, written when the item lands. Deprecations count: the removal is the breaking half, and the line is owed by whoever created the obligation.
| landed | item | what breaks at 1.0 | upgrade route |
|---|---|---|---|
| 0.6.0 | RM51 — sources.csv deprecated in favour of licensing.csv |
the old input filename stops being read | Author: git mv sources.csv licensing.csv. No content change, and no signature moves — verified across all eleven reference examples when four of them were renamed. Consumer: no action needed — sources.parquet and manifest.sources keep their names through the whole 0.x line and are untouched by this item. |
| 0.6.0 | RM4 — the module_spec.yaml panel: block deprecated |
the block stops being read | Author: the block's one machine reader moved to the licence row's dataset column (what the tautology check reads), so once that column is filled you may delete the block if you do not need the three fields it also carried. But genes, significance and reference_sha256 have no replacement anywhere (S69), so for a drafted panel deleting the block loses the gene denominator that distinguishes not in the panel from in the panel, nothing found, and if the licence row's dataset is empty — a module drafted before the drafter filled it, which the never-clobber merge does not backfill — it loses the only record of the drafted-from snapshot too. Deleting a block that carries nothing you need moves neither artifact.digest nor content_signature (measured on reference_examples/apoe_epsilon). The home for the three fields is an open design question — RM265; this row is not settled until it is. Meanwhile the consumer-side workaround is to record the three in a provenance log hashed into manifest.logs, which survives the removal. Consumer: no action needed — manifest.panel is passthrough metadata nothing derives from. |
| 1.0 (planned) | RM73 (gate half) — a closed authoring attestation becomes a precondition of compiling | a module with no closure stops compiling; since 0.6.0 it only warns. Blocked, not merely planned — see § RM73 below: the gate refuses on step 3 of the round trip | Author: run just-dna-compiler close spec/ once the module is finished, and re-run it after any edit. It is a deliberate act by design — validate stays read-only, so nothing stamps behind your back. Consumer: no action; the artifact already carries manifest.verification.closure where a module was closed, and reading it is optional. The whole mechanism (Closure, close, the warning) shipped additively in 0.6.0; what waits for the major is only the promotion of the warning to a refusal, which is P8. |
| 1.0 (planned) | fetched_at → updated_at/recorded_at on the seven sidecar models — bundled with the sources.parquet rename, which moves the same digests. Unnumbered; see the 1.0 cleanup tracker |
the column name changes in six parquets and seven CSVs. No signature moves — it is outside all seven fact sets — so content_signature and every *_signature are untouched; only artifact.digest moves, on modules carrying a sidecar |
Author: no action needed. The loader keeps accepting fetched_at as a deprecated input spelling through 1.x, so an existing sidecar loads unchanged; the writer emits the new name from the next pass that touches it. No 0.x deprecation warning is owed, because nobody authors this column and extra="forbid" means an author could not act on one. Consumer: re-read the column by its new name in frequencies/gene_metrics/literature/gene_validity/clinical_assertions/sources.parquet; nothing derives from it, and it is in no signature |
| 0.6.0 | RM55 — CopyNumberRow.modifier_cn deprecated in favour of modifier_copy_number |
the integer column is removed (not retyped — the float one has existed since 0.6), and with it the kind-keyed tiling defaults | Author: move the dosage to modifier_copy_number; a whole number is still a whole number, so no re-authoring of the value is needed, and setting both was always an error so there is nothing to reconcile. If your copy_number/repeat_count bins are a genuine grid, state measure_tiling: quantised before 1.0 removes the default that assumed it. Consumer: read modifier_copy_number (or effective_modifier_copy_number, which is what everything in-tier already reads) and take a group's tiling from measure_tiling rather than from measure_kind. |
RM55 (removal half) — the integer copy-number and repeat-count columns¶
Severity high · Status the whole usable fix shipped in 0.6, so what is left here is a
removal and nothing else: CopyNumberRow.modifier_cn, deprecated warn-only since 0.6, and the
kind-keyed tiling defaults · Owner format (schema) + compiler
VCF 4.4 stopped treating copy number as an integer (§7.2, with fractional worked examples throughout)
and standardises the repeat count as a Float (§3). Both kinds were modelled here as integral, so a
fractional measurement matches no bin at all, silently, --strict included.
Only the removal is major. The route was decided on 2026-08-13 as three releases precisely so that the usable fix would not wait for the major, and then collapsed to two when the fix landed in 0.6.
Re-dated on 2026-08-16 and built the same week; the route is now two releases rather than three. PROPOSAL_0_6_PT2.md § RM55 took the usable fix back into 0.6, so what this entry calls "0.7" is 0.6, and what is left here is a removal and nothing else. Two corrections to the description above:
- The parallel-column shape does not apply to the bounds —
measure_min/measure_maxhave beenfloat | Nonesince 0.4. What ships instead is an optionalmeasure_tilingcolumn ({quantised, continuous}) that the gap and shared-endpoint rules read in place of_INTEGER_KINDS, as an effective value: declared, else forced by a fractional value in the group, else the kind's default. So 1.0 removes the kind-keyed tiling defaults, not a capability — by then the tiling is either stated or forced by the data, and the default is the only part left that guesses. - It does apply to
CopyNumberRow.modifier_cn, which is the one genuineint— 0.6 addsmodifier_copy_number: float | Nonebeside it, read through an effective-value alias that falls back tofloat(modifier_cn), with both-set an error and_KEY_FIELDSkeying on the effective value so the key never holds two spellings.modifier_cnis deprecated in 0.6 (actionable, so the amended cadence permits it) and removed here. That is why 1.0 inherits a removal rather than the retype this entry was written expecting.
Why the direct correction could not be taken in a minor, recorded so it is not re-argued: it is a
retype (CopyNumberRow.modifier_cn: int → float) and a change to what published bin tilings mean —
[2,2] beside [3,3] is a legal integer tiling today, and under continuous rules both the
shared-endpoint rule and the gap warning change meaning. There were no published copy-number modules to
break, and the shortcut was declined anyway: that is the cost of holding a semi-immutable schema, and
the implementation being wrong is not a licence to break the rule that keeps it trustworthy.
Design detail, settled on 2026-08-16 rather than carried forward: quantised-versus-continuous is a
declaration, not a sixth measure kind — measure_kind answers what is measured and tiling answers
how the axis is divided, and folding the second into the first is the state anti-pattern the
Constitution names by name (P5), besides being a product rather than a sum as kinds are added. It lands
as measure_tiling in 0.6, shipped. So the removal here leaves behind a schema where the tiling is
stated, not one where it has to be inferred from the kind. See
PROPOSAL_0_6_PT2.md § RM55, and SCHEMAS.md for the built shape —
including the one place the build departed from the proposal, which is that the fractional inference
fires only against a quantised default (activity_score is fractional by nature and asserts nothing
about the grid, so reading it as continuous invents coverage gaps rather than revealing them).
RM81 — one artifact spells a genotype two ways¶
Severity medium · Status open — 1.0 (a retype of a published parquet column) · Owner format (schema) + compiler · Motivating case S30 in CONSUMER_SUGGESTIONS_HISTORY.md
weights.parquet stores genotype as List(Utf8) — the compiler splits the authored cell on
//| — while the 0.4-family tables (pharm_variants.parquet and the rest) are materialized verbatim
from their authored CSV and keep the string "C/C". Both are documented and neither is wrong on its
own, but a consumer joining either family to the same VCF meets two representations of one concept, and
the split is invisible until the join raises:
SchemaError: datatypes of join keys don't match - `genotype`: list[str] on left
does not match `genotype`: str on right
The report that produced this is the same one that produced alleles.split_genotype (S30, shipped in
0.6): the reader half — how do I split it — is closed, because there is now one public leaf every
tier and every consumer calls. What is left is the artifact half, and it is major-only.
Why not a minor. Changing pharm_variants.parquet.genotype from Utf8 to List(Utf8) is a
retype of a published column, which P3/P8 put in a major exactly because it breaks an existing
reader — and here the reader is not hypothetical: the reporting consumer reads that column as a string
today and normalizes it themselves. RM43's fill was additive (stamped columns that were not there
before); this is not the same move.
Why not the additive workaround. Adding a parallel genotype_alleles: List(Utf8) beside the string
is minor-legal and is the wrong shape: it puts two spellings of one value in one table, which is the
desync failure ResolutionRow.vrs_id needed two guards for, and it leaves the original defect — this
artifact spells a genotype two ways — in place with a third spelling added to it. A consumer meeting
both columns has to know which is authoritative, and nothing in the parquet says.
What to decide at 1.0, since the direction is not obvious: unify up (split everywhere, so every
table matches weights.parquet) or down (store the cell verbatim everywhere and let each reader
call split_genotype). Up is what the reporting consumer implemented and what a join wants; down is
what reverse_module wants, since the 0.4 families re-emit their cell byte-for-byte and a split column
has to be rejoined — and a rejoin must reproduce the authored separator, which means the phased bit
travels too, as it already does for weights.parquet. Whichever is taken, it is one rule for the whole
artifact, and split_genotype's "never sorted" contract holds under both.
RM73 (gate half) — the closure gate refuses on step 3 of the round trip¶
Severity high, and release-blocking for the gate itself · Status open; a precondition of the ledger row above, not a detail of it · Owner compiler · Found by probing whether compile should warn on an absent closure, 2026-08-16 — the answer for 0.x turned out to be the reason the 1.0 half is blocked
The mechanism shipped in 0.6.0
— Closure, just-dna-compiler close, and a warning when a compile publishes none — and it is
warn-only and charter-clean. Promoting that warning to a refusal is what this row is, and it does not
work as written:
reverse_modulerebuilds authored files from parquet alone. It cannot re-emitverification.jsonand already says so, warning that the source artifact carried an attestation the reversed spec will not. So a reversed spec is unclosed by construction, not by omission.- Under the gate,
compile_module→reverse_module→compile_moduletherefore refuses on step 3 — for every module, including all sixteen reference examples. Principle 7's lossless round-trip is enforced by tests rather than asserted, and those tests do not start failing subtly; the sequence stops being runnable at all.
Why this is invisible today, which is the part worth recording. Probed while deciding the 0.x
behaviour: artifact_digest is a Merkle root over _OUTPUT_FILES, which is parquet only. manifest.json
is not in it, warnings feed no signature, and no round-trip test compares them. So the same asymmetry
already exists — a closed module and its round trip differ in manifest.compilation.warnings — and in
0.x it costs exactly nothing enforceable, even though RM44 established that a catalog parses that
field. verification.json has carried the identical asymmetry since RM45 with no consequence, for
precisely this reason. The precedent is therefore silent about the danger, and the gate is what converts
a free divergence into a refusal.
The obvious repair is closed too: manifest.verification carries the checks but not difficulty
or nonce, so reverse cannot rebuild a valid attestation document out of the artifact it holds.
Candidate answers, none decided. Stated with their costs so the gate is not built on whichever occurs to someone first:
- Widen
Verificationto carry the whole document, so reverse can re-emit it. Additive and minor-legal, so it could land well before 1.0 — but it makesreversewrite a closure it did not witness, which is the self-asserted provenance RM73 rejected when it rejected theorigin:column. And reverse holds no key, so whatever it writes is unsigned by construction. - Exempt a reversed spec from the gate. Requires the reversed spec to state that it came from reverse: the same self-assertion one level down, in a marker any author can type.
- Require a human to re-close a reversed spec. Honest, and possibly correct — reverse produces a
spec, not a finished module — but it changes what the round-trip guarantee means. The fixed point
would still hold over every authored value, while
compile → reverse → compilestops being a sequence that runs without a human in it. If this is the answer then Principle 7's wording is what needs amending, deliberately and on purpose, and the reference-example corpus needs a documented closing step.
What would unblock it: picking one of the three before the gate is built. The severity change is one line; this question is the whole of the item.