Integrating 0.6 — what changed against the 0.5 surfaces¶
For the repos that consume this one: just-dna-pipelines, just-dna-lite, just-dna-marketplace, just-dna-agents. It answers one question — given a working 0.5 integration, what do I have to check, and what do I have to change?
The baseline throughout is the published v0.5.4 tag, not main. That distinction matters:
main already carries eleven commits of early 0.6 work (manifest.readme, manifest.derived, the
Constitution's cadence amendment), so a delta taken against main under-reports two manifest fields
that a downstream lib pinned to PyPI has never seen. Everything below is measured against what is
actually installable today.
Status: 0.6.0 is cut and tagged v0.6.0 (2026-08-17, all three packages). This paragraph read
"0.6 is uncut" until then, which is the state the rest of the document was written in — so where a
section still says "in the tree", read it as shipped in v0.6.0. Tagged is not published: whether
it is installable from PyPI is a separate step and the maintainer's call, so check before promising a
field to anyone. That distinction is S34's, and it is the standing rule for every claim in this
document — answered and in the tree and cut and installable are four different states.
Eight defects were found in 0.6.0 and are fixed in v0.6.1 — RM93–RM100, filed 2026-08-18 from a
pass that regenerated the tier references from source and read them against the shipped documents.
Take 0.6.1, not 0.6.0, and read § 7 if you are pinned to
0.6.0 for any reason: four of the eight change what you should expect from a surface this document
told you to adopt. None of them changes a schema surface, so nothing in the delta below moves — the
one thing 0.6.1 adds is that authoring_reference(), describe, requirements and
json_schemas() now render 28 models rather than 23 (RM96), the five machine-written sidecar row
models that were outside the registry. Additive, and worth knowing if you snapshot that output.
One defect was found in 0.6.1 and is fixed in just-dna-enricher 0.6.2 — RM101, from
S37, and it is the only one of these that a consumer had already
written a workaround for. 0.6.2 is a partial cut: just-dna-format and just-dna-compiler did
not move and stay at 0.6.1, so upgrade the enricher alone. If you call an enricher pass from Python
— checking a module, running a dry run, wrapping a pass in a service — read
§ 8 before you upgrade, because it is the one item here where
removing your workaround at the wrong moment is what breaks you.
0.6.1 also closes RM88 and puts one ask to you. upload now refuses to overwrite
data/<name>/v<version>/ with a different artifact unless --force. Beside it, § 2.8 carries the one
change in this document we cannot make on our side: decide what a module contains from
manifest.artifact.files, not from what is in the directory, because the publisher never removes a
file and the discovery path fetches no manifest.
1. The headline: nothing you have breaks¶
Measured, not asserted — the 0.6 compiler was run over the eleven v0.5.4 reference example specs,
byte-for-byte unmodified, and the resulting manifests compared field by field against the ones
v0.5.4 produces from the same inputs:
| result | |
|---|---|
| Specs that still compile | 11 / 11 — no refusals, no new errors |
content_signature moved |
0 / 11 |
sources.signature moved |
0 / 11 |
resolution_signature moved |
0 / 11 |
artifact.digest moved |
11 / 11 |
schema_version |
unchanged, "1.0" |
| Fields removed, retyped, or promoted to required | none |
| Requiredness relaxed | one — StudyRow's identifier rule (RM47), below |
That shape is exactly what Principle 3 promises for a minor: new optional and stamped columns move
the byte identity and leave the content identity alone. Principle 4 already scopes byte
reproducibility to a fixed compiler_version, so a moved artifact.digest across a compiler
upgrade is the documented behaviour rather than a surprise.
Both directions of the manifest were also checked against the real files:
- A
v0.5.4client parses all sixteen 0.6 manifests without error. - A 0.6 client parses all eleven
v0.5.4manifests without error. weights.parquetreads in both directions (37 columns at 0.5.4, 39 at 0.6), and so doesstudies.parquet(19 → 20).
So a mixed deployment — an old reader against a new artifact, or the reverse — does not fault. What an old reader does not get is the new information; see §3.
One exception to the headline, and it is a name rather than a behaviour: _OUTPUT_FILES was made
public as ARTIFACT_PARQUETS (§2.4), so a consumer who imported the private name gets an
ImportError at module scope. Importing an underscore is nobody's recommendation, but re-listing
that set by hand is the defect §2.8 and S35 both trace the broken publisher to — so the consumer who
did it had the better of the two arguments, and "nothing you have breaks" is the sentence they
carried into an upgrade that broke on its first import. Reported as S40; making the name public is the
right fix and needs nothing further.
The one thing you must re-pin¶
Any stored artifact.digest from a 0.5.4 compile will not reproduce under 0.6. If you cache
digests, gate on them, or compare a recompile against a stored value, recompile and re-pin at the
version boundary. If you key on content_signature — the authored-content identity — no action:
it did not move anywhere.
Two checks can newly refuse an author's spec¶
Neither fired on the corpus, but both are real tightenings and both are fixes:
- RM50 — a PMC id is refused by name.
PMC 3110566used to be accepted as PMID 3110566, a real id for an unrelated article. A cell that compiled before may refuse now. - RM48 — a wrong-build coordinate is an error in both modes. A position past its contig's end, or
a contig only the other assembly names. It is arithmetic rather than judgement, so it deliberately
does not follow the mode ladder and
--strictis not the switch.
These matter to just-dna-pipelines, which compiles other people's specs: a spec that passed CI at 0.5.4 can fail at 0.6, and that is the intended outcome.
And one check stopped refusing — which is the asymmetric half, and needs saying more, not less¶
RM47 relaxed StudyRow's identifier requirement: REQUIRED_ANY_OF went from ({rsid}, {chrom}) to
(). A study row citing no variant at all — a paper grounding a bin boundary, a method, a
population — is now legal, which is the whole point of the change. It is not a removed, retyped or
newly-required field, so the table above stays literally true; it is also invisible to a corpus run,
because a relaxation can only turn a red green. A consumer holding a negative test sees it
immediately:
with pytest.raises(Exception, match="At least one identifier"):
StudyRow(pmid="12345", conclusion="Test") # 0.5.4: raised. 0.6.1: accepted.
The load-bearing half is one layer out, and it is a join rather than a validator.
StudyRow.variant_key can now be None. Anything joining studies.parquet to a lead table on
variant_key meets a null key, and in polars a null join key is a silently smaller result, not an
error — no warning, no row count anyone would question. Filtering on rsid instead is safe for the
same reason it looks unsafe: a null rsid matches nothing, which is the correct outcome for a citation
that grounds a threshold rather than a variant.
So: check whether you key studies on variant_key, and pin the consequence rather than the
acceptance — row.variant_key is None and REQUIRED_ANY_OF == (). Do not repair a null key into a
string. Reported as S40, whose author found it in the first seven test failures after the bump.
2. The surface delta, by layer¶
2.1 manifest.json¶
Seven new top-level blocks, all optional, all absent-means-nothing-was-said:
| field | type | what it is |
|---|---|---|
verification |
block or absent | what was checked, by whom, and whether the module was closed. Every field inside is marked untrusted. Absent reads as says nothing, never as passed. |
gene_validity |
block or absent | the ClinGen/GenCC gene–disease validity fact block (RM24) |
clinical_assertions |
block or absent | the ClinVar clinical-assertion fact block (RM25) |
readme |
entry or absent | the module's prose, attested so it can travel (S25) |
derived |
mapping or absent | relative paths of machine-written sidecars, for a derived/-split tree (RM49). Transport-only. |
gwas_effects |
block or absent | the GWAS Catalog effect-size fact block (RM90). Read units and without_effect_allele before using any of it — see below. |
weighting |
block or absent | what the module's authored weight column means: scale, method, note, all free text (RM92). Absent means the module has not said, which is not the same as saying its weights are comparable. |
readme and derived are on main already but are not in v0.5.4 — if your integration is
pinned to PyPI, treat them as new too.
Five new counters under manifest.compilation, all int | None:
| field | what it counts |
|---|---|
resolution_subjects |
the denominator fully_resolved quantifies over, taken after rsID expansion (RM44) |
positional_rows / positional_rows_placed |
how much of the PGx/positional side actually joins to a VCF. Complete is placed == rows — parts, not a ratio (S31) |
expanded_keys / expanded_rows |
the one-to-many rsID expansion, as two numbers (S33) |
None is load-bearing on all five and is not 0. 0 is a real answer ("this module has no
positional rows"); None means this compiler did not count, which is what every pre-0.6 manifest
honestly is. It is how you tell the eras apart without probing parquet. Do not coalesce it.
gwas_effects publishes two facets you should gate on, not just count. units is the set of
effect_unit values present: more than one member means those betas are on different scales and must
not be pooled. Measured on a real module — hfe_hemochromatosis, rs1800562 — that set has 12
members across 62 traits, of which SD units/SD/s.d. are three spellings of one thing and
g/dL/g/dl differ only in case, while 138 of the rows carry the Catalog's uninformative unit.
without_effect_allele counts the associations the Catalog published without establishing which
allele carries the effect (it writes rs4149056-?): 42 of 195 there. Those rows are real evidence
and cannot be used as a weight in any direction. They are counted rather than filtered precisely
so that neither silently dropping them nor silently keeping them is something you can do by accident.
weighting is the answer to "can I combine two modules' weights". It exists because a consumer
reported that authored weights "construct nonsense" across a corpus, and the artifact had no way to
say so. Treat an absent block as unknown, and unknown as do not aggregate across modules.
Two consequences worth acting on:
fully_resolved: truebesideresolution_subjects: 0is vacuously true — it isall()over an empty list. Five of the eleven 0.5-era examples are in that state. If you badge trust onresolution_mode == "strict" or fully_resolved, read the denominator beside it. That trust rule's reader is a catalog, not a general consumer — the docs said "a consumer" and that was wrong.UNJOINABLE_PHRASEand its substring-matching workaround stay. Already-published artifacts carry neither new field, so the sentence is still the only signal on them. Keep the fallback; add the fields as the preferred path.
Nothing was removed and required is unchanged in both directions.
2.2 Parquet columns¶
Additive everywhere. The two that change a join:
weights.parquetgainslocus_indexandlocus_count(RM87). An rsID resolving onto N loci becomes K×N rows, and until now an expanded row was indistinguishable from an authored one — a consumer produced 3,762 false findings from exactly that.locus_countdefaults to1, not0, solocus_count > 1is a predicate you can apply holding a single row. Both areexclude=True, so nocontent_signaturemoves. On a pre-0.6 artifact the columns are absent;reverseprefers the stored column and keeps the encounter-order recompute for older artifacts.annotations.parquetgainsgenotype(RM80/S29) — the column that distinguishes its rows, which was in no column.annotations.parquetnow carries and keys ongenotype. If you join annotations without it, you are matching a larger set than you mean to.
The rest, by row model:
| model / parquet | new columns |
|---|---|
VariantRow → weights |
locus_index, locus_count |
HaplotypeRow, PharmVariantRow |
alts, authored_ident, variant_key |
HeteroplasmyRow |
authored_ident, variant_key, measure_tiling, pmid, source_element |
MeasureBinRow, RepeatAlleleRow, ActivityPhenotypeRow |
measure_tiling, pmid, source_element |
CopyNumberRow |
measure_tiling, modifier_copy_number, pmid, source_element |
LiteratureRow |
license, commercial_use, share_alike, redistribution, doi_checked |
StudyRow → studies |
effect_allele (RM91) |
SourceRow |
draft_digest |
studies.parquet gains effect_allele (RM91), and it matters for the same reason the column on
weights.parquet does: effect_size is stated relative to an allele, and until 0.6 a study row
named none. If you read StudyRow.effect_size today you are reading a magnitude whose sign you cannot
interpret; on a pre-0.6 artifact the column is absent, and on a 0.6 one it may still be null, which
means the study did not state one — not that the reference allele is implied.
Three new parquets: gene_validity.parquet, clinical_assertions.parquet and
gwas_effects.parquet (RM90). All are inside artifact.digest — a module carrying them has a
different artifact identity, correctly — and all sit in ARTIFACT_PARQUETS, so a publisher or
verifier deriving its file list from that constant picks them up with no edit.
resolution.csv still gets no parquet, deliberately. That is the first consequence of the 0.6
charter amendment and it is written into SCHEMAS.md because "publish it as a parquet" is the first
repair anyone proposes.
2.3 Files in a spec directory¶
| change | detail |
|---|---|
licensing.csv |
the preferred spelling of the licence-terms sidecar. sources.csv is deprecated in 0.6 and removed at 1.0 — warn-only, reads exactly as before (RM51). |
derived/ subdirectory |
tolerated on input for machine-written sidecars — never required, never canonical. reverse_module still emits a flat tree (RM49). |
verification.json |
new derived file: the check records and the closure. |
gene_validity.csv, clinical_assertions.csv |
new derived fact tables. |
gwas_effects.csv |
new derived fact table (RM90) — GWAS Catalog effect sizes, written by the enricher. |
The rename stops at the file. sources.parquet and manifest.sources keep their names for the
whole 0.x tail — both are inside the digest or are published keys, and renaming either breaks a
reader. So the chain reads licensing.csv → sources.parquet → manifest.sources. That is a real
legibility regression and it is deliberate; a test pins it so a well-meaning follow-up cannot
"finish" the rename into a published key. Do not rename anything on your side to match.
Writing rule, shared by both changes: write to the file you read, and both spellings present is an error naming both paths — never a merge, never newest-wins.
2.4 Python API¶
New public symbols worth knowing:
| symbol | tier | why you care |
|---|---|---|
just_dna_format.alleles.split_genotype |
format | the one definition of the genotype split. A validated cell in, alleles in authored order out — never sorted. There were three private copies; a consumer re-derived the rule from prose and got it wrong twice in opposite directions. Use this instead of your own (S30). |
just_dna_compiler.compiler.ARTIFACT_PARQUETS |
compiler | every parquet the artifact may contain. Was private _OUTPUT_FILES. |
just_dna_compiler.compiler.LEAD_PARQUETS |
compiler | weights plus the nine 0.4 families — what discovery actually probes. |
just_dna_format.layout |
format | the sidecar names, the derived/ constant, and one resolver. Pure pathlib. Four parties must agree on this layout (compiler reads, enricher writes, publisher uploads, registry re-splits) and every past disagreement was silent. |
just_dna_format.verification |
format | close, attest, merge_records, module_binding, verification_block. |
just_dna_format.integrity.newline_normalized_file_entry |
format | the binding's entry builder (see §2.7). |
just_dna_format.gwas.GwasEffectRow |
format | one published GWAS association: magnitude, its unit, the allele it is relative to, the trait and the study (RM90). |
just_dna_format.integrity.gwas_effect_signature |
format | the fact-hash for that table. effect_unit is inside it; the churning trait label is not. |
just_dna_format.manifest.Weighting / GwasEffects |
format | the two new manifest blocks (RM92 / RM90). |
Also on the format tier: SYMBOLIC_ALLELE_TYPES, is_symbolic_allele, parse_symbolic_allele,
is_unobservable_allele, MISSING_ALLELE, UNOBSERVABLE_ALLELE, GENOTYPE_SEPARATORS,
vocab.match_vocab, VALID_VERIFICATION_CHECKS, VALID_GENE_VALIDITY, VALID_INHERITANCE_MODE,
and the VCF pointer helpers (split_field_pointer, vcf_field_number, is_multi_valued_number).
Two vocabulary notes for 0.6's last batch. vocab.VALID_EFFECT_DIRECTIONS ({increase, decrease})
is not VALID_DIRECTIONS — it states which way an effect allele moves the measured trait, while
VariantRow.direction states a clinical judgement (protective|risk|neutral|unknown). Increasing HDL
and increasing LDL are both increase; if you map one onto the other you will invert half your
conclusions. And vocab.VALID_SOURCE_LAYERS gains gwas_effect — if you validate layers against a
hard-coded set, add it.
RECOMMENDED_EFFECT_MEASURES moved from spec to vocab so a fact-table module could bind it
without importing the authored-DSL module. spec re-exports it, so from just_dna_format.spec import
RECOMMENDED_EFFECT_MEASURES still works and no importer had to change.
One trap in an existing signature. verify_manifest's require_marketplace defaults to True,
the marketplace policy — so a naive call rejects every locally-compiled module, ours included, since
the reference compiler leaves compiled_by null by design. It is a fork, not an optional step: one
policy per install route. The guarantee that is actually load-bearing is the pinned public_key.
2.5 CLI¶
Five new commands. No flag was removed or retyped; the only new flag on an existing command is
just-dna-enricher draft-panel --download/--no-download.
| command | what it does |
|---|---|
just-dna-compiler close |
writes VerificationDoc.closure — a human declaring the module final. Authoring now has an end (RM73). |
just-dna-enricher gene-validity |
drafts gene_validity.csv from ClinGen / GenCC |
just-dna-enricher assertions |
drafts clinical_assertions.csv from ClinVar |
just-dna-enricher gwas |
fills gwas_effects.csv from the GWAS Catalog REST API (RM90). It does not fill weight — that is the point of it, not a limitation. --no-study-facts drops the per-association _links follows: the budget is 1 + 2N per variant and was measured at 382 requests for one real module, so this is worth knowing before you script it. |
just-dna-enricher hint recover |
which rs-number GRCh37 dbSNP records at an hg19/GRCh37 coordinate — the diagnostic beside RM48's refusal. Reports; never fills. |
check-identifiers and check-acmg changed their promise. They used to say "Writes nothing".
They now record that the question was put — gene_symbol_currency, trait_currency,
gene_locus_agreement and acmg_secondary_findings — unconditionally, with no flag, because an
optional record is ambiguous between "not run" and "ran without the flag". They still write no
authored cell. If you scripted these expecting a read-only tree, that assumption is now false.
Relatedly, merge_records no longer lets a skipped record displace a ran one.
2.6 Warning texts, which are an API¶
manifest.compilation.warnings is a surface consumers parse (RM44), so the phrases are pinned
constants. New in 0.6:
FRACTIONAL_MEASURE_PHRASE, SPANNING_MEASUREMENT_PHRASE, DEPRECATED_MODIFIER_PHRASE,
QUAL_INVERSION_PHRASE, MISSING_ALLELE_PHRASE, UNCLOSED_PHRASE.
Two new findings in 0.6's last batch carry no pinned constant yet, so match them loosely or not at all:
a study row whose effect_allele is not an allele its locus can host (RM91 — "effect_allele … is not
among the resolved alleles at this locus", warning in best_effort, error in strict), and an
over-broad GWAS sidecar ("gwas_effects.csv carries associations for N identity(ies) no variant in this
module carries", warning in both modes). The first is the one that can newly refuse a strict compile
of a spec that used to pass — though only if the module both authors a study effect_allele and
resolves the locus, so nothing in the corpus fires it.
Import the constant rather than copying the sentence. FRACTIONAL_MEASURE_PHRASE is byte-identical
to its earlier 0.6 development spelling on purpose, but the warning that carries it went from
unconditional to conditional — so its frequency changes even though its text does not.
2.7 Line endings no longer count as an edit¶
The attestation binding now normalizes \r\n → \n before hashing (RM82), through a separate
entry builder. An author whose editor or core.autocrlf rewrote line endings used to un-close a
module without touching a cell.
manifest.inputs[] and artifact.digest deliberately still follow raw bytes — they answer a
different question. Do not "fix" the asymmetry. The trap worth knowing if you reimplement any of
this: size is inside the hashed listing, so normalizing only the hashed bytes and reporting
stat().st_size is a no-op that looks like a fix.
2.8 The publisher path (marketplace / registry)¶
The publisher now writes data/<name>/v<version>/ nested inside the flat data/<name>/, which
keeps meaning latest (RM84). The segment is v<version> verbatim. Enricher-only: no schema, no
digest, no signature.
Two things to plan for, and one ask — the third is the only thing in this document that needs a change on your side that we cannot make on ours.
- Nothing prunes the versioned copies. The collection grows one artifact set per release. That is known and not being fixed here.
- The versioned path now refuses to become a different release (RM88, 0.6.1).
uploadreads the publishedmanifest.jsonatdata/<name>/v<version>/and comparesartifact.digest; a different digest refuses unless--force. Identical bytes are not a collision, so re-running a publish after a failed second commit still works. The flat path is deliberately not guarded — it means latest, and overwriting it is what it is for. If the check itself cannot run, the publish proceeds with a warning: nothing established a collision, so nothing asserts one. Note that recompiling an unchanged spec under a newer compiler moves the digest too (P4), so it will trip the gate — correctly, since the versioned path really would come to hold different bytes.
The ask: read artifact.files, and treat what it does not name as not part of the module¶
upload_folder adds and replaces; it never removes. A recompile that stops emitting a table — a
module whose studies.csv was deleted, so studies.parquet is no longer produced — leaves the
previous release's file sitting at the path beside a manifest that does not attest it. So a republish
leaves a union of two releases, not a replacement, and it does this on the flat path, every
time, version bumped or not.
The format's answer is that this does not matter, and we would like it to become true.
manifest.artifact.files states which parquets are the module, artifact.digest is a Merkle root
over exactly those, and an unattested leftover is outside both — so a reader that starts from the
manifest never sees one, and verification passes because nothing was corrupted. On that reading the
leftover is an inert fossil.
What stops it being true is the reader. Recorded in
MODULE_LIFECYCLE § 6.8 and verified in the
reference consumer's tree rather than inferred: the discovery path adds "no manifest fetch and no
digest check", verify_manifest "has no call sites there", and the scan is fs.ls at one level
plus fs.exists on named files. On the registry path the fossil really is inert, because there is
a per-version audit and the manifest is read. On the discovery path nothing consults the list that
would make it inert, and a leftover parquet is indistinguishable from a live one.
The concrete failure is a shape misreport, not corruption, and it needs two things at once: a
module whose table set shrank between publishes, read over discovery. A SNP-core module re-authored
as a table-only PGx module keeps a fossil weights.parquet, so a probe for named files still finds a
SNP core — the old release's. Nothing is mis-hashed; the module is mis-typed.
So the ask is: decide what a module contains from manifest.artifact.files, not from what is in the
directory. That is one read, it is the list the digest is computed over, and it closes this for good
on every module including the ones already published.
Why we are not fixing it here, stated so the choice is auditable rather than implied: the publisher
could pass delete_patterns and make the flat path a replacement. It was considered and declined for
this release. It leaves already-published fossils untouched (only a republish would clean them), it
does nothing for a consumer that probes rather than reads, and it is one wildcard away from being
dangerous — HuggingFace filters delete patterns with fnmatch, whose * crosses path separators,
so a single *.parquet in the publisher's allowlist would delete every archived version's parquets in
one commit. The allowlist is literal basenames today and the archive is safe by that accident. A fix
whose safety rests on an accident, and which does not close the case anyway, is the wrong half of the
answer; the manifest read is the right half.
And the reason to upgrade the publisher at all: the 0.5 allowlist was hand-kept, and it was not
merely refusing table-only modules — it was dropping the data of the ones it accepted. Measured
over the sixteen reference examples: seven refused outright, and eight of the remaining nine
published a manifest attesting parquets that were never uploaded, so the published artifact.digest
could not be reproduced from what arrived. Fifteen of sixteen wrong. sources.parquet was in the
dropped set every time it existed — a module published that way arrives with no licence terms.
Nothing is known to have been published through it, so this is would publish, not did. After the
fix: 16 of 16 publish and all 16 digests verify, and the allowlist is derived from
ARTIFACT_PARQUETS so a new table family reaches the publisher in the commit that adds it.
3. Per-consumer check / change lists¶
just-dna-lite (reference consumer, the annotating engine)¶
Change
- Adopt
locus_count > 1to filter or label expanded rows. This is the fix for the 3,762 false findings; your own mitigation (withhold any locus spelled with more than oneref) misses same-refexpansions, andreference_examples/shox_par1/is the module to instantiate one on. The committed example does not contain one — itsresolution.csvis 10 rows, 10 distinct rsIDs, every one on chrX, so a compile givesexpanded_keys=0andlocus_count == 1throughout. That sentence read as though the shape were shipped and it is not; S40's author went looking for it and built it instead. To make one: re-runenrich --keep-par-twin(which records both contigs of a pseudoautosomal locus where the default keeps only the X spelling), or, without a reference to hand, copy oneresolution.csvrow onto chrY at the offset-matched position. The VRS check will then refuse the copy, correctly — avrs_idcarried across to a different position is corruption rather than a difference of opinion — and it prints the recomputed ids, so pasting the two reported values back in is the whole edit. What you get isexpanded_keys=1,expanded_rows=2, two rows sharingrefandstartwithlocus_count=2andlocus_index0 and 1: the shape aref-spelling guard passes straight through. That construction is now a fixture —compiler/tests/test_expansion_counts.py::test_two_loci_sharing_a_ref_still_count_as_twobuilds the twin throughpar_partnerand asserts both halves, that the expanded rows carry exactly one distinctrefbetween them and thatlocus_countreads 2 — so the claim above is under test rather than merely stated. Read it as the worked example; the corpus's other expansion (pathogenic_clinvar'srs1554917888) differs inrefand cannot stand in for it. - Join
annotations.parquetongenotype. Without it you match a larger set than you mean to. - Replace any local genotype-splitting with
just_dna_format.alleles.split_genotype. Authored order, never sorted. - Read
manifest.sources/sources.parquetfor licence terms as before — but if you consumed anything published through the old uploader, re-fetch it; the licence table was being dropped and your report footer would render "Not stated". - Decide what a module contains from
manifest.artifact.files, not from what is in the directory — the ask in §2.8. The publisher'supload_foldernever removes, so a module whose table set shrank between releases leaves the previous release's parquet at the path; on the discovery path, which fetches no manifest, that fossil is indistinguishable from a live table and the module reads as the wrong kind. One read of the list the digest is computed over closes it, including on modules already published, which no publisher-side fix can reach.
Check
- Re-pin any stored
artifact.digest.content_signatureneeds nothing. - Where you badge trust, read
resolution_subjectsbesidefully_resolved, and keep theUNJOINABLE_PHRASEfallback for pre-0.6 artifacts. weights.parquetsplits the genotype while the 0.4 families keep the string. That is still true — unifying it is RM81 and it is 1.0, because it retypes a published column.- If you aggregate
weightacross modules, stop, or gate it onmanifest.weighting. This is your own report (S36) coming back as a surface:weighthas no unit column, every module means something different by it, and until 0.6 the artifact could not say so. An absentweightingblock means the module has not said — read that as do not combine, not as safe. gwas_effects.parquetis not a drop-in replacement for a weight, and must not be pooled. Join it per trait (trait_efo_id), and readmanifest.gwas_effects.unitsfirst: one real variant carries 12 distinct units, three of which are spellings of one. Skip or label rows with a nulleffect_allele— 42 of 195 on that module — because an effect relative to an unknown allele has no direction you can apply to a genotype.- If you read
StudyRow.effect_size, readeffect_allelebeside it now that it exists (RM91). Null still means the study did not state one, never that the reference allele is implied.
just-dna-marketplace (catalog / storage / serving)¶
Change
- Handle the nested
data/<name>/v<version>/path. A nested versioned subdirectory does not disturb a flat-path scan by construction, but your catalog should now be able to name a version. - Plan for unbounded growth of versioned copies — nothing prunes them.
- Derive a module's file set from
manifest.artifact.files— the ask in §2.8, and it matters more here than anywhere: the publisher never removes, so a path can hold a union of two releases, andrevalidateis the one place that enumerates published versions and could say so. A listing diffed againstartifact.filesis the only thing that finds a fossil on a module nobody republishes, which no publisher-side fix can reach. - Surface the
verificationblock. Absent means says nothing, never passed. Every field inside is marked untrusted; the closure (closed_at,closed_by) is the only record that a human declared the module final. verify_manifest(require_marketplace=True)is your policy — the default is yours, and it is correct for you and wrong for a local compile. Pin thepublic_key; that is the load-bearing part.
Check
- Consume
positional_rows/positional_rows_placedandexpanded_keys/expanded_rowsinstead of substring-matching warning prose. TreatNoneas not measured, never as0. derivedrecords relative paths for a split tree, soFileEntry.namecan carryderived/…. That block is documented transport-only.- Three new parquets may appear in an artifact's file list. Derive from
ARTIFACT_PARQUETSrather than hand-keeping a list — that is precisely the defect that broke the publisher, andgwas_effects.parquetis the first one added since the fix, so it is also the test of it. - Two new manifest blocks to surface on a module page:
weighting(what the module says its weights mean — free text, show it verbatim) andgwas_effects. If you render a facet from the latter, renderunitsandwithout_effect_allele, not justrow_count: they are what tell a reader whether those effects are usable, and a row count alone reads as confidence.
just-dna-pipelines (compiler / discovery)¶
Change
- Import
ARTIFACT_PARQUETS/LEAD_PARQUETSinstead of a local copy. - Move drafting output to
licensing.csv;sources.csvstill works and warns. Adopt the write-what- you-read rule, and treat both-present as an error. - Add
just-dna-compiler closeto the authoring flow if you drive it end to end.validatestays read-only; closure is its own phase. - Expect
check-identifiers/check-acmgto writeverification.jsonrecords now.
Check
- Re-baseline any digest-comparison CI. Specs that passed at 0.5.4 can newly fail on RM50 (a PMC id
in a
pmidcell) and RM48 (a wrong-build coordinate — an error in both modes). - New optional authored columns are available but nothing forces them:
measure_tiling, the bin-rowpmid,source_element, symbolic alleles (<DEL:1500>), the unobservable allele*,chrMfolding toMT, and namespaced VCF pointers (INFO/DPvsFORMAT/DP).
just-dna-agents (MCP surface)¶
Change
get_spec_format/list_colors/list_iconsdrift further out of date this release. The replacements areauthoring_reference()and theRECOMMENDED_*constants — the format tier now also exportsRECOMMENDED_SYMBOLIC_SUBTYPES, and the vocabularies gainedVALID_VERIFICATION_CHECKS,VALID_GENE_VALIDITY,VALID_INHERITANCE_MODE,VALID_MEASURE_TILINGSandVALID_ELEMENT_RULES.- Closed vocabularies now accept
-where a_goes and store the declared spelling — so any member list you echo to a model should be the canonical one, and a hyphenated answer no longer fails.
Check
- Any hand-maintained table of table kinds or columns: two derived tables and roughly two dozen columns are new.
4. Deprecated in 0.6, removed at 1.0¶
The cadence changed this release: deprecate in a minor, remove at the next major (0.6 → 1.0), and a deprecation only lands in a minor where its audience can act on it. Nothing below stops working in 0.x.
| deprecated | replacement |
|---|---|
sources.csv (the file) |
licensing.csv |
CopyNumberRow.modifier_cn (int) |
modifier_copy_number (float), read via effective_modifier_copy_number |
the panel: block in module_spec.yaml |
— |
ensembl_cache= (deprecated since 0.5) |
an injected resolution.csv |
Already visible as 1.0 work, so do not design around it: RM81 — weights.parquet splits the
genotype while the 0.4 families keep the string, and unifying that retypes a published column. The
minor-legal parallel-column workaround was refused as two spellings of one value in one table.
A 1.0 also now carries an obligation it did not before: a major ships its upgrade procedure. A removal whose upgrade path is left to the reader to work out is not ready to ship.
5. Readiness¶
Gates, run on this branch today:
| gate | result |
|---|---|
uv run pytest |
2722 passed, 8 skipped, 0 failed (2568 at the 0.6.0 cut) |
uv run ruff check |
clean |
Open consumer inbox (CONSUMER_SUGGESTIONS.md) |
empty — nothing owed (S36 answered and archived 2026-08-17) |
| Open roadmap items | none in format scope. RM88 closed in 0.6.1; RM7 carries a ## RMn heading but is marked not format scope — a just-dna-lite contract, listed only so it is not mistaken for ours. |
| 0.5.4 spec corpus under the 0.6 compiler | 11 / 11 compile |
| Reference corpus | 16 / 16 compile (measured here); 16 / 16 publish with verifying digests (measured in the RM89 fix round) |
| Corpus movement from the S36 batch | artifact.digest moved on 10 / 16 — exactly the ten carrying a studies.parquet, since RM91 adds a column to it. content_signature moved on 0 / 16. |
One test failed when this audit started — test_doc_links on a dead
ROADMAP.md#rm89-… anchor, left behind when RM89 shipped and moved to ROADMAP_HISTORY.md in the
branch's last commit. Fixed in CONSUMER_SUGGESTIONS_HISTORY.md; the count above is after that fix.
CI does not gate this branch on push. .github/workflows/ci.yml runs on push to main,
pull_request, and workflow_dispatch — so the 160 commits here have never been through the
matrix. That shape is deliberate (a full matrix on every branch push mostly measures work in
progress), but it means merge via a PR, or dispatch the workflow against the branch first. This
project has been bitten once already by a green-looking CI that never ran, on the 0.5.0 release.
RM88 — closed in 0.6.1. Republishing without bumping version: overwrote a versioned path with
different bytes. The policy was the whole of the delay, not the code: refuse-unless---force, decided
2026-08-18, with the comparator (artifact.digest) already in the manifest. The half that is not
closed is the one nobody here can close — the publisher never removes a file, so a path can hold a
union of two releases, and only a reader that starts from manifest.artifact.files is immune. That is
the ask in § 2.8.
Verdict: ready to cut, with three caveats that are release-management rather than code.
- Run CI against the branch before merging — it has not run.
- Version numbers are already
0.6.0in all threepyproject.tomlfiles whilegit tagstops atv0.5.4. Anything published from here must be a real cut; wipedist/before building, sinceuv publishuploads everything in it. - The three packages version independently in principle, but this release moves all three together — the format tier gains models, the compiler gains columns and a command, the enricher gains three drafting commands and the publisher fix. There is no partial cut available here.
- The S36 batch (RM90–RM92) landed after this document's first draft and is folded in above. It
moves
artifact.digeston the ten examples carrying astudies.parquet— which is inside the "re-pin your digests" instruction already in §1, not a new obligation — and moves nocontent_signatureanywhere.
6. What deliberately did not change¶
State these to anyone who asks, because each is a repair somebody has proposed:
content_signature,sources.signatureandresolution_signaturedo not move across the version boundary. Measured at 0/11.schema_versionstays"1.0". It moves at a major.sources.parquetandmanifest.sourceskeep their names for the whole 0.x tail, even though the CSV was renamed.resolution.csvgets no parquet.manifest.inputs[]andartifact.digeststill hash raw bytes, while the attestation binding normalizes newlines. Two questions, two answers.- The one-to-many rsID expansion stays. Filtering it is refused;
locus_countis the read-side answer instead. Note thatexpanded_rows - expanded_keysis not the unmatchable-row count — that needs a per-key authored-genotype number the manifest does not carry. fully_resolvedstaysbool. Consumers branch on it directly, so aNonewould be a breaking read for all of them; the denominator went beside it instead.UNJOINABLE_PHRASEand its test stay. Already-published artifacts carry neither new field.- No module-level "evaluate me against a callset that can express the reference genotype" claim. Whether a hom-ref row can ever match is a property of the file a consumer brings — a variant-only VCF emits no such record, a gVCF and an array do — so that is the annotator's call, not this format's. Restoration and imputation stay consumer-side.
7. What 0.6.0 got wrong, and 0.6.1 fixed¶
Filed 2026-08-18 as RM93–RM100 and all shipped in v0.6.1 the same day. Kept here rather than
deleted with the fix, because a consumer pinned to 0.6.0 still meets every one of them and the table
below is the only place that says what to do about it. The action for everyone else is one line:
take 0.6.1. No schema surface moved in either direction, so § 2's delta stands unchanged; what
moved is behaviour.
| Item | Who hits it on 0.6.0 | What to do if you are pinned to 0.6.0 |
|---|---|---|
| RM93 | anyone using validate as a pre-flight for compile |
The one to know. validate is not currently a complete pre-flight: a module with frequencies.csv, or a table-only module with studies.csv, can pass validate --strict and then be refused by compile --strict. If your pipeline gates on validate and treats a later compile failure as an infrastructure error, it will misclassify these two. Gate on compile into a temporary directory if you need certainty today. |
| RM94 | anyone reading manifest.compilation.warnings |
A p_value/p_value_num disagreement appears twice, byte-identical. If you count warnings or show them to a user, dedupe on the string — which is worth doing regardless, since the field has never promised uniqueness. |
| RM97 | anyone calling the enricher against gnomAD or dbSNP | A 5xx from either escapes as a raw httpx.HTTPStatusError rather than as this tier's own error type, so except GnomadError / except EutilsError does not hold it and a dbSNP 5xx can abort a run. Catch httpx.HTTPError alongside the tier's exceptions until this lands. This row was incomplete and 0.6.1 did not finish the job: OntologyClient (OLS4/HGNC, behind check_identifiers) kept leaking raw httpx through 0.6.1 as well, and the passes leaked their client's type on top of that — see § 8. On anything below 0.6.2, keep the httpx.HTTPError catch and add the client types. |
| RM98 | anyone running enrich --offline or gene-metrics --offline without a cache |
The artifact records status="not_found" — a definite negative — where nothing was consulted. Do not read a not_found from an offline run with no cache as evidence the source lacks the record. With a cache present the behaviour is correct; it is the empty-cache case that fabricates. |
| RM95, RM96 | module authors | measure_kind=copy-number is accepted by MeasureBinRow and rejected by its subclasses; write the underscore spelling. Two unenforced/misattributed model guards, neither of which lets bad data into a surface you read. |
| RM99, RM100 | registries serving a derived/ layout; anyone invoking the enricher as a module |
Three passes write their sidecar to the spec root regardless of layout, so an enrich run can leave one module with both. And use the just-dna-enricher entry point rather than python -m just_dna_enricher.cli, which is missing three commands. |
What this list is not. None of these is a regression against 0.5.4 — RM93's two checks and RM98's offline paths behaved this way before 0.6 as well, and RM95's vocabulary slip has been there since the column existed. They are recorded against 0.6 because that is when someone looked. The reason they are in this document at all is § 1's promise that nothing you have breaks: that promise is about the surface, and it holds, but a consumer planning an integration deserves the behavioural caveats in the same place as the surface delta rather than one document over.
8. What 0.6.1 got wrong, and 0.6.2 fixed¶
One item — RM101,
shipped in just-dna-enricher 0.6.2. just-dna-format and just-dna-compiler are untouched and
stay at 0.6.1. No schema surface moves, so § 2's delta stands; what changes is which exception
type comes out of an enricher pass, and this is the one item in this document where the upgrade
order matters.
The defect. § 7's RM97 row made each client raise its own type instead of httpx's. But you do
not call a client — you call a pass, and five passes held their client in try: … finally: close()
with no except, so the client's type walked straight out. except FrequencyEnrichmentError — the
type enrich_frequencies documents — did not fire for a gnomAD 503. Same for enrich_gene_metrics,
enrich_literature, and both identifiers entry points. Our own CLI had it too: just-dna-enricher
frequencies <dir> promises FREQUENCIES FAILED: <reason> and exit 1, and on a 5xx it printed
nothing and let the exception out — so if you shell out and parse stderr for that marker, it was
not there.
Read this before you delete your workaround. Almost everyone hitting this has compensated by catching the client's type. Which of the two shapes you wrote decides what happens:
| what your handler looks like today | on 0.6.2 |
|---|---|
except (FrequencyEnrichmentError, GnomadError) — both, in one tuple |
keeps working, unchanged. Nothing to do. Drop the client half whenever convenient |
except FrequencyEnrichmentError — the documented type only |
starts working. This is the fix; it was silently dead before |
except GnomadError — the client type instead of the pass's |
stops firing. This is the one that breaks. Add the pass's type before upgrading |
| both, as two separate arms with the parent first | the outage arm goes dead. Move the *Unavailable arm above the parent: Python takes the first matching clause, and the subclass is now the more specific one. This is the row that fails silently |
Read the first and fourth rows together, because "we catch both" describes either. They differ only
in punctuation and they behave oppositely, which is why the fourth is written out rather than left as a
consequence of the third. If your two types meant different things to you — a plain warning for one, an
unreachable field for the other — then you wrote separate arms, and on 0.6.2 the parent arm catches
everything because the subclass is a parent instance. Nothing raises, nothing 500s, and a handler
asserting only a status code sees nothing wrong; just-dna-registry found it in three of four handlers
(S38) and only because their guards assert the field. The mechanical check is structural: walk
your own except clauses and fail on any arm an earlier one in the same try already catches. That is
enricher/tests/test_shadowed_handlers.py here, and it is about thirty lines of ast in yours.
The third row is the whole hazard for a handler that never named the pass's type, and it is why a flat
translation was rejected: the obvious repair
(raise FrequencyEnrichmentError(...) from exc) would have broken exactly the consumers who had done
the sensible thing. The repair is a subclass instead, so every except <Pass>Error catches
strictly more than it did and nothing catches less.
What you gain. Each pass now has an unavailability subclass, so "the source is down" and "your
data is wrong" are separable by type rather than by reading exc.__cause__:
| pass | catch | source could not be reached |
|---|---|---|
enrich_frequencies |
FrequencyEnrichmentError |
FrequencyUnavailable |
enrich_literature |
LiteratureEnrichmentError |
LiteratureUnavailable |
enrich_gene_metrics |
GeneMetricsEnrichmentError |
GeneMetricsUnavailable |
check_rsids / check_identifiers |
IdentifierCheckError |
IdentifierUnavailable |
enrich_dosage_sensitivity |
ClinGenError |
ClinGenUnavailable |
enrich_gene_validity |
GeneValidityError |
GeneValidityUnavailable |
Every one is a subclass of the type beside it, and the client's exception stays on __cause__. Order
the two arms narrow-first if you write them separately — see the fourth row above. The full table,
including the passes that deliberately degrade rather than raise, is in
ENRICHER § Exception contract.
verify_acmg_sf is the same distinction one level finer, and it predates this.
AcmgListUnavailable carries a skip holding a VALID_VERIFICATION_SKIPS member, decided where the
failure happens: unreachable when the source was asked and never answered, no_reference when
something was there and no list could be read out of it. A caller collapsing the two reports an
offline run with no snapshot as an outage, and sends an operator to check a network that is fine —
just-dna-registry was doing exactly that until they read exc.skip (S38). If you report per-source
availability anywhere, that field is the one to read rather than the type alone.
Two conflations you may be working around. ClinGenError and GeneValidityError each covered
"could not fetch the source" and "the local CSV will not parse" — opposite histories. If you
separate them today by inspecting exc.__cause__ (chained for the fetch, bare for the table), that
is a private detail you can now stop depending on. Do not separate them by matching the message:
neither string is in the pinned warning-text catalogue of § 2.6, so a reword would silently flip your
verdict from "unchecked" to "your table is broken".
| Item | Who hits it on 0.6.1 | What to do if you are pinned to 0.6.1 |
|---|---|---|
| RM101 | anyone calling an enricher pass from Python | Catch the client's type alongside the pass's, in one tuple — except (FrequencyEnrichmentError, GnomadError), except (LiteratureEnrichmentError, EutilsError), except (IdentifierCheckError, EutilsError, httpx.HTTPError). The last one needs httpx because OntologyClient still leaks it on 0.6.1. If you keep them as separate arms instead, order them narrow-first now, or the upgrade kills the second one (fourth row above) |
| same | anyone shelling out to just-dna-enricher frequencies / literature |
A 5xx produces a traceback and no FREQUENCIES FAILED: line. Treat a non-zero exit with no marker as a source failure rather than assuming your parse broke |
| same | anyone distinguishing "ClinGen unreachable" from "our gene_metrics.csv is bad" |
Read exc.__cause__ — None means the local table. Do not match on the message |
Not a regression against 0.6.0 or 0.5.4. Every one of these behaved this way before 0.6 as well;
gene_validity's conflation has been there since the pass existed. They are recorded against 0.6.1
because that is when a consumer looked, and because § 7's RM97 row told you the client half was
finished when it was not.
9. The one 0.7 change a consumer must act on before the release lands¶
0.7 has since landed in the tree and has its own document — INTEGRATION_0_7.md. This section stays as written, because it is what a consumer reading it before the release was told; the full delta, and the registry ask below restated with the release actually here, are there.
overrides.csv (RM124) is a new authored file in the spec directory, and one repo has to be told
its name.
just-dna-registry — one entry, and it is the same one-line change every new table kind needs.
The registry rebuilds a spec directory from RECOGNIZED_SPEC_FILES, built from SPEC_DATA_FILES — a
hand-kept mirror of our table constants. A name missing there is a file dropped on the next
re-publish, which is how licensing.csv was lost before their 0.16.2. Add overrides.csv to
SPEC_DATA_FILES. The consequence of not doing it is worse than for an ordinary table: an overlay row
is an author's recorded judgement that a derived value is wrong, and losing it silently restores the
value they rejected while leaving the module compiling green.
Everyone else: nothing to do, and nothing breaks. The table is optional and no module published to
date carries one, so neither content_signature nor artifact.digest moves anywhere. When a module
does carry one:
- The compiled artifact gains
overrides.parquet— only when the module carries an overlay.artifact.filesis name-sorted, so it lands betweenliterature.parquetandpgs.parquetrather than at the end. A consumer iteratingartifact.filessees one more entry; a consumer reading a fixed list of parquets is unaffected, because nothing else moved. - Read the derived parquets, not the derived CSVs, if you want what the module asserts. The
overlay is applied at compile, so
frequencies.parquetand its siblings are post-overlay while the CSVs beside the spec stay exactly as the enricher wrote them. That asymmetry is the design: the CSV is a build product and the overlay is the authored correction on top of it. reverseround-trips it, so a registry that splits and rebuilds trees carries the corrections through unchanged — provided the filename is recognised, which is the entry above.
ProvenanceItem.outranks is unchanged and not deprecated in 0.7. If you read it, keep reading it;
its succession by the overlay is filed as RM135 for 1.0 and will ship with a deprecation warning only
once the overlay reaches authored tables, which is where an author warned off outranks would have
somewhere to go.