Status: decided — the S1/S2 + version items implemented, pending a release the user cuts; the
Ensembl cache authority leaves the compiler item (below) decided but not yet implemented. This is
the "means → draft schema → decision" stage for a 0.4.1 patch, responding to the registry's field
report in CONSUMER_SUGGESTIONS.md (S1/S2) and — for the cache-authority
item — to the just-dna-datasets split design. Everything here is out of artifact.digest
(identity/metadata validation only, or a resolver-input path that changes no output bytes), so it is
patch-shippable (CONSTITUTION P3/P8).
Supersedes the earlier draft that proposed a hardcoded
vocab.REGISTRY_OWNED_KEYSfrozenset
- an accept-and-drop
mode="before"validator onModuleInfo. That baked one consumer's identity conventions into the format and made the validator fix input. Both are wrong per the charter (see Why not hardcode below); the design below replaces them.
0.4 made the authored module: block extra="forbid" — correct: it catches colour: / nam: typos
and the genome_bild: safety trap. But the same guard now hard-rejects keys the registry fills on
publish, not the author: essentially every pre-0.4 module_spec.yaml in the wild carries
module.version (an author's informal v2/3), and some carry namespace / owner. Under 0.3 these
were silently dropped; under 0.4 they are Extra inputs are not permitted, breaking three registry
paths for the entire legacy corpus (import → 422, upgrade → 422 mid-upgrade, revalidate → a
needs_upgrade flag that never clears).
The format documents these as marketplace-filled: Identity.{namespace,version,canonical_id} and
manifest.owner are Optional, stamped by the registry on publish and overriding any authored
value. So the authored copies are vestigial by construction.
The earlier draft lumped four keys together. They are not the same:
versionis universal author intent — a human marker every module has. It is not a consumer convention; the format should genuinely adopt it as a field, not strip it.namespace/owner/canonical_idare authority-stamped identity. The author has no business setting them; which keys a given publishing authority stamps is that authority's convention, not the format's knowledge.
The format already lives by inject-only for reference data (P2: "any reference is injected by the caller; the libraries do not fetch"). The same shape applies here: the consumer ships the set of authority-owned keys it stamps; the format owns the reference stripper that consumes that set. The format hardcodes no consumer's key list and applies nothing by default.
Two schema tools, new module just_dna_format/normalize.py (dependency-light leaf, like vocab):
strip_authority_keys(block, authority_keys) -> (clean, dropped)— pure, order-preserving, byte-preserving when nothing matches; the reference implementation of the registry's ownstrip_registry_owned_keys(). Inject-only:authority_keysis the caller's set.IDENTITY_AUTHORITY_KEYS = frozenset({"namespace", "owner", "canonical_id"})+IDENTITY_AUTHORITY_REASONS— a documented convenience a consumer may inject. Noteversionis absent (it is a real field now).
The strip runs before validation, in the loader (_load_yaml), never as a mode="before"
validator. Consequences, all deliberate:
- The validator keeps
extra="forbid"'s full teeth. A genuine typo (namespac:) is not in the injected set, so it still fails hard — the strip is exact. - If a caller forgets to inject, or a key slips the strip,
forbiderrors loudly, pointing at exactly where expectation broke — instead of a validator silently repairing bad input. - Reporting is trivial: the loader returns the dropped list;
validate_specsurfaces it on.info.
validate_spec(spec_dir, authority_keys=None) and compile_module(..., authority_keys=None) thread
the set through; a bare call strips nothing and still forbids stray identity keys.
- Hardcoding
REGISTRY_OWNED_KEYSmakes the format track a consumer's naming — the registry's. A different authority might stamp a different set; the format cannot and should not know. DI is the charter-correct source of the list. - The reserved namespace (
vocab.RESERVED_NAMES_0_4, P5) is for names expected to become future module columns — the opposite of these, which will never be authored. Reusing it would corrupt its meaning.IDENTITY_AUTHORITY_KEYSis a separate, distinctly-documented vocabulary.
ModuleInfo gains version: Optional[str] = None, freeform (accepts the whole legacy corpus's
v2/3). It is advisory: the registry stamps the canonical SemVer Identity.version on publish and
overrides it.
- Live SemVer preview, today. The coercion algorithm (
normalize.normalize_version) is built now but used read-only:validate_speccomputesnormalize_version(version)and, only when it differs from the authored value, emits a warning previewing what a future release will read (v2→ "will read it as2.0.0"). A clean1.2.3stays silent. This makes the advice actionable and pre-tests the 0.5 algorithm. Enforcement (coerce/validate) is 0.5 — seePROPOSAL_0_5.mdV1. - Flows to the manifest only when clean.
_build_manifestpasses an authored version intoIdentity.versioniff it is already valid SemVer (a freeformv2staysNone; the registry stamps it). So the SemVer-validatedIdentity.versionnever sees a messy value, and manifest validation never breaks. - Round-trip.
version(liketitle/description) is out-of-digestmodule:metadata, not materialized to any parquet, soreverse_modulecannot recover it from the artifact — it gains aversion=parameter to re-emit it when a caller supplies it (e.g. frommanifest.identity.version), symmetric with the existingtitle/descriptionparameters. Digest-neutral throughout (proven by test).
Rather than a hand-maintained table that drifts, the boundary is surfaced from the generated
reference.authoring_reference():
versionnow appears inModuleInfo's field list automatically (it is a live field).- A new
"registry_stamped_keys"section listsnamespace/owner/canonical_idwith reasons — legibly distinct from"reserved_names"(future module columns). An authoring agent reads it and omits them.COMPILER.mdcarries the human-facing coverage rows.
The registry's broader lossy --trim pass (for stray defaults: keys / legacy column aliases) stays
consumer-side (explicit human ops); no format change is requested for it. If the registry's
whole-corpus run surfaces a concrete next offender, it graduates through the design cycle.
Status: implemented on the 0.4.1 branch. From a third field report: a registry's dedup pre-check
keys on artifact.digest, but importing a module recompiles locally (a different/complete Ensembl
reference → different parquet bytes → different digest) and publish strips metadata, so the digest
check misses the dedup path entirely. The only authoritative dedup is the server's content_signature
(a hash of the raw data CSVs), but it is enforced only at publish (409 duplicate_content) with no
client-reachable lookup — so the UI pre-check can't be made robust client-side.
artifact.digest is a Merkle root over the compiled parquet, which is GRCh38-coordinate-relative:
it moves when the same spec is recompiled against a different reference, and it embeds the module name
(materialized into the module column), so it also moves on rename/metadata-strip. It is a
byte-reproducibility identity, not a content-dedup identity. Those are two different jobs.
A new integrity.content_signature(tables) (the schema package — the lightest tier, so any consumer
can compute it) hashes the raw authored data rows, and the compiler stamps it into
manifest.content_signature (optional, out of artifact.digest). It is:
- Reference-independent — computed from the rows before resolution, so recompiling against a
different/complete reference does not change it. (This is also why it is read from disk at compile
time, not from the already-resolved in-memory rows.) Corrected in 0.5: this bullet said
"Ensembl/build-independent", conflating the reference used to resolve with the module's declared
assembly — see
integrity.content_signatureand RM36. - Name/metadata-independent — the identity/display half of
module_spec.yamlis excluded.genome_buildis the exception and feeds the hash (when non-default), because identical coordinate rows on two assemblies are two different loci, not one module described twice. - Normalized — each row is
model_dump(mode="json", exclude_none=True), so CSV reformatting and additive schema growth (a new optional column left unset) do not change it. - Deterministically sorted, order-independent — rows are sorted by canonical JSON and files by
name, so reordering rows yields the same signature. (Deliberately unlike
artifact.digest, which preserves authored row order — the two are different identities.) - All authored data CSVs —
variants.csv+studies.csv+ the 0.4 table kinds, so a PGx/PGS-only module (novariants.csv) still gets a signature.
The compiler exposes content_signature(spec_dir) and a just-dna-compiler signature <spec_dir> CLI
command, so a client computes it without recompiling and looks it up — surviving both
metadata-strip and recompile.
The format owns the canonical algorithm, but the intent is to not break the marketplace's existing
content_signature: it keeps the marketplace-compatible conventions (the sha256: prefix, the raw
data-CSV file set, per-file grouping). The one deliberate divergence is raw-bytes → normalized +
deterministically-sorted rows — chosen for robustness (survives reformat/reorder/recompile), which
the marketplace's raw-CSV hash does not. Adopting it is a one-time backfill of stored signatures (the
marketplace already has find_versions_by_content internally — it re-derives with this algorithm and
exposes a client lookup endpoint). Where the marketplace's current bytes already agree, nothing moves;
where they don't (the normalization), the backfill reconciles them.
- P2 (no network): untouched — pure local hashing of authored data.
- P3/P8: additive — a new optional manifest field + new helpers; demotes nothing, retypes nothing.
artifact.digest: unchanged —content_signatureis a sibling identity, never fed into the digest. Patch-shippable.
Status: decided, not yet implemented. This item is not from a consumer field note; it fell out of
the just-dna-datasets split design (separating dataset download / reference-module recreation from
the reference consumer). It shares this patch's inject-only spirit, so it rides the 0.4.1 window.
The compiler carries just_dna_compiler/cache.py: env-var precedence
($JUST_DNA_ENSEMBL_CACHE › $JUST_DNA_PIPELINES_CACHE_DIR › a platformdirs just-dna-pipelines
user-cache default), a .env loader, and resolve_ensembl_reference() that probes those locations for
a usable reference. resolve_variants(..., ensembl_cache=None) calls it, so a bare compile_module()
auto-discovers an upstream deployment's cache.
That is upstream knowledge in the wrong repo. Where an Ensembl/ClinVar cache lives on disk, and which
env vars name it, is a deployment fact owned by just-dna-datasets / just-dna-pipelines — not the
schema-driven compiler. It entered here only as a convenience so test_resolver_integration.py could
exercise the resolver in battle against a real cache. The need to test the compiler against real data
is genuine; siting the cache-location authority inside the compiler to get it was a hack.
Leaving it here is also what makes the datasets split circular: the compiler reaching for a default
cache location is the one back-edge (compiler → datasets) against the natural datasets → compiler
(module re-tracing needs the engine). Severing it makes the graph one-way.
The cache-location authority is removed from the compiler entirely — not relocated to a leaf module
here, not copied. just-dna-datasets owns it (location + download + recreate); just-dna-pipelines /
the marketplace resolve a reference and inject it. This repo keeps only the reference reading
convention — "how to open a reference I was handed" — which is legitimate consumer knowledge.
- Deleted:
just_dna_compiler/cache.pyin full —resolve_ensembl_reference,default_ensembl_cache_dir,load_env, the$JUST_DNA_*precedence, the platformdirs default. - Stays (relocated into
resolver.py):DUCKDB_NAMEand the opening logic (_connect,_view_over_parquet) — the reference-format knowledge the compiler needs as the party that reads it. - Tests — kept local, via a dev-only datasets dep. The compiler keeps its in-battle coverage here.
test_resolver_integration.pystays, rewired fromjust_dna_compiler.cachetojust_dna_datasets.locations— the cache resolver (and downloader) arrive as a dev-only dependency onjust-dna-datasets(base, no[recreate]), exactly as this repo already dev-depspytest/ruff. The synthetic-injectedtest_resolver_unit.pyand thetest_identity_roundtrip.pyfixtures stay as-is — they inject aPathand need no cache, so they run in ordinary CI with no 190 MB reference. Guardrail: the dev pin is base datasets without[recreate];[recreate]depends on the compiler, so pulling it into the dev group would re-introduce the very cycle this item removes. The integration test only resolves + reads a cache (never recreates a module), so base is all it needs — and the import graph stays acyclic (the compiler never imports datasets at runtime;locationsnever imports the compiler).
Pure inject-only, matching P2's letter:
resolve_variants(variants, ensembl_cache, genome_build=...)—ensembl_cacheis a resolved reference (a.duckdbfile or a parquet dir) supplied by the caller.None→ resolution is skipped with a warning; no env read, no.envload, no platformdirs, no default path. (The skip warning drops itsset JUST_DNA_…advice — the compiler no longer names upstream env vars.)compile_module(..., ensembl_cache=None)— signature unchanged;Nonenow means skip, not auto-discover. Pipelines / marketplace / datasets resolve the reference and pass it — they already inject in practice, so this narrows only the ambient-discovery convenience (the hack).
platformdirs and python-dotenv were used only by cache.py; both leave the compiler's
runtime deps in compiler/pyproject.toml, which drop to just-dna-format, polars, duckdb,
pyyaml. The dev group gains just-dna-datasets (base) for the integration test — a test-only dep
like pytest / ruff, never shipped to a consumer and (base-only) never a runtime or cyclic edge.
- P2 (no network / inject-only): the point of the change — the compiler stops computing a deployment path and becomes strictly inject-only. The env/platformdirs fallback was the single place it "knew" upstream layout; removing it tightens conformance rather than amending the charter.
- P3/P8: unlike the S1/S2 items (relaxations), this is a scope removal —
Noneno longer auto-discovers. Still legal: it changes no field, no schema, and noartifact.digest; any caller that injects a reference (pipelines, marketplace) is byte-for-byte unaffected. Only a bare standalone compile relying on ambient discovery changes, and that reliance is the hack being retired. Patch-shippable, not major-gated. artifact.digest: unchanged for a given(input, injected reference)— resolution output is identical; only the default disappears.
Status: implemented on the 0.4.1 branch. This item is not a consumer field-note ask either — it fell out of the Issue 2 incident ("post-publish local hash differs from published"). It shares this patch's reproducibility/inject-only spirit.
A module was published, then flagged its own local copy as differing from the published one — a
scary-looking "conflict" that was a false alarm. Root cause: an incomplete local Ensembl cache
(a corrupt chr1) left the local compile with 7 position remains unset warnings, i.e. a
half-compiled artifact. The registry recompiled the same spec against its complete reference →
different bytes → different artifact.digest → the "conflict".
The consumer side is already fixed (publish now trusts the server's returned manifest as authoritative instead of a local-vs-server byte comparison, and explains a benign digest difference in plain words — "the registry recompiled from your spec with its reference build; reinstall to sync"). The format-side gap is what this item closes: the compiler only has a best-effort mode, so an incomplete reference silently yields a partial, non-reproducible artifact.
resolver.resolve_variants (compiler/src/just_dna_compiler/resolver.py) is best-effort by design: an
rsid it cannot place appends "{rsid}: not found in Ensembl, position remains unset" (resolver.py:216)
and a position with no dbSNP id appends "Position {key}: no rsid found in Ensembl" (resolver.py:180)
— but the row is still materialized with a null position. So the artifact's bytes depend on how
complete the injected reference happened to be. That best-effort mode is the "0.3 half-compile" — it
stays the default; this item adds the all-or-nothing counterpart.
When strict=True, after resolution the compiler checks that every VariantRow has a resolved
genomic position (chrom + start); if any is still unset, it fails with a clear error instead
of writing a partial artifact. strict=False (the default) preserves today's best-effort behavior; a
publisher/registry passes strict=True.
- Gate semantics (precise). Fails when, after resolution ran, any
VariantRowlacks(chrom, start)— which naturally covers both the incomplete-cache case (the incident) and the resolution-skipped-because-no-reference case (withstrict, "no cache + rsid-only variants" is a hard error, not a silent partial). Scope is the SNP-coreVariantRowonly — the 0.4 table kinds (diplotypes/pgs/…) carry no positions and are untouched. rsid↔coord consistency disagreements stay warnings (a real dbSNP merge/build difference, not a cache-completeness failure — never fatal, per the resolver's stance). - Why position, not rsid.
artifact.digestis a Merkle root over the compiled parquet and is GRCh38-coordinate-relative — the coordinate is the anchor. An unresolved position is the reproducibility hazard; a missing rsid on a positioned row is not. - Placement. In
compile_module, right after the post-resolution_cross_validate_variantsre-check (compiler.py:765) and before the parquet writes (compiler.py:773), so nothing partial ever reaches disk. - Error shape. List the offending
rsid/variant_keyvalues and advise "provide a complete Ensembl reference or compile without--strict."
- P2 (no network / inject-only): untouched — strict reads no reference the caller didn't inject; it only insists the injected one was sufficient.
- P3/P8: additive — a new opt-in flag, default
False; demotes nothing, retypes nothing. artifact.digest: unchanged for any compile that succeeds — strict emits identical bytes; it only converts a would-be-partial success into a failure. Patch-shippable, not major-gated.
Status: implemented on the 0.4.1 branch. There was no CLI — no [project.scripts], no
argparse/typer/__main__; the compiler's whole surface was the Python API (validate_spec /
compile_module / reverse_module). A catalog operator or CI job had to write a Python snippet to
compile a module. This item adds a first-class command-line front door.
New just_dna_compiler/cli.py, exposed via a [project.scripts] console entry (proposed name
just-dna-compiler; short alias jdc optional), with typer added to the compiler's runtime
deps. Never just-dna-format — dependency tiers are sacred (CONSTITUTION Goal 2): Typer/click ride
with the already-heavy compiler (polars/duckdb), not the pydantic-only schema package.
The command surface mirrors the three public functions one-to-one, so the CLI is a thin, testable shell:
validate <spec_dir>→validate_spec; prints errors/warnings/info; exit 1 if invalid. Options:--strip-identity(injectnormalize.IDENTITY_AUTHORITY_KEYS) and/or a repeatable--authority-key KEY(inject a custom set).compile <spec_dir> <output_dir>→compile_module; exit 1 on failure, printsartifact.digeston success. Options:--strict/--no-strict(the front door to the section above),--ensembl-cache PATH,--no-resolve,--compression,--compiled-by,--strip-identity/--authority-key.reverse <parquet_dir> <output_dir>→reverse_module; options--module-name/--title/--description/--report-title/--icon/--color/--version(theversion=param 0.4.1 added).
Exit codes 0/1 make it CI/registry-gateable, e.g. a publisher runs
just-dna-compiler compile spec/ out/ --strict --ensembl-cache ref.duckdb. The CLI is additive — no
schema, no artifact.digest impact.
Interaction with the cache-authority removal (above): --ensembl-cache is the inject point; the CLI
shape does not change when cache.py is deleted — only the meaning of an omitted --ensembl-cache
flips from auto-discover to skip-with-warning (and, under --strict, that skip becomes a hard error for
any rsid-only variant).
- P2 (no network): untouched — pure local validation; and the DI shape extends P2's inject-only spirit rather than amending the charter (the stripper is a schema tool, not a network concern).
- P3/P8 (additive / requiredness): a relaxation — accepts strictly more inputs (a new optional field + an injected strip), demotes no required field, changes no output.
artifact.digest: unchanged —versionand the authority keys are identity/metadata, never materialized into parquet. Patch-shippable, not major-gated.- P5 (reserved namespace): deliberately not used —
IDENTITY_AUTHORITY_KEYSis a separate vocabulary, keeping the reserved namespace meaning "future module axis" intact. - P7 (round-trip): the data round-trips as before; out-of-digest
module:metadata (version, like display) is manifest-carried, re-emitted byreverse_moduleonly when the caller supplies it — the same statustitle/descriptionalready have.
just_dna_format/normalize.py—IDENTITY_AUTHORITY_KEYS,IDENTITY_AUTHORITY_REASONS,strip_authority_keys,normalize_version.ModuleInfo.version(freeform advisory);reference.authoring_reference()gainsregistry_stamped_keys.- Compiler:
validate_spec(spec_dir, authority_keys=None)+compile_module(..., authority_keys=None)pre-strip in_load_yaml, surface dropped keys on.info, warn with the SemVer preview;_build_manifestfillsIdentity.versionfrom a clean authored SemVer;reverse_module(..., version=None). content_signature:just_dna_format.integrity.content_signature(tables)(canonical, owned) +just_dna_compiler.compiler.content_signature(spec_dir)convenience +manifest.content_signature(optional, out of digest) + ajust-dna-compiler signatureCLI command.- Strict (all-or-nothing) compile:
compile_module(..., strict: bool = False)+ the post-resolution unresolved-position gate (fails before any parquet is written). - Compiler CLI (Typer):
just_dna_compiler/cli.py(validate/compile/reverse), thetyperruntime dep, and thejust-dna-compiler[project.scripts]entry.--strict/--strip-identity/--authority-key/--ensembl-cache/--no-resolve; exit codes 0/1. - Dev tooling + metadata:
ruffadded to the workspace-root dev group (it was referenced but present in none of the three dev groups);authors+maintainers(Newton Winter) on both packages'[project]. - Tests:
schema/tests/test_normalize.py,compiler/tests/test_authority_keys.py,compiler/tests/test_strict_compile.py,compiler/tests/test_content_signature.py,compiler/tests/test_cli.py, plus a reference-surface assertion. - Version bump
0.4.0 → 0.4.1(schema_versionstays"1.0") is the user's to cut — not done here.
Pending in this patch, not yet in the tree — design-only, deferred to a later pass:
- Ensembl cache authority leaves the compiler: delete
just_dna_compiler/cache.py; makeresolve_variants/compile_modulepure inject-only (None→ skip with a warning); relocateDUCKDB_NAMEintoresolver.py; dropplatformdirs+python-dotenvfrom the compiler's runtime deps and addjust-dna-datasets(base) to its dev group; keeptest_resolver_integration.py, rewiring it fromjust_dna_compiler.cachetojust_dna_datasets.locations. (Deferred here because it needs thejust-dna-datasetspackage to coordinate against — the CLI's--ensembl-cacheis already the inject point, so its shape is unaffected when this lands.)