Skip to content

Drafting as a mechanism — what the seven providers taught, and what is still missing

Written after RM228 put all seven *_draft.py providers on one scaffold, at the maintainer's ask: what patterns emerge, and what more structural or model elements does drafting need to be a mature mechanism rather than a patchwork of grassroots implementations of the same thing?

It is deliberately written after the migration rather than before it. Four of the findings below were invisible until the copies were put side by side, and two of them contradicted what this session predicted, which is the argument for not having written this as a design document up front.

The four patterns that emerged

1. A copy drifts in the direction of its author's mental model, not randomly. Four providers restated VariantRow's identity rule and each got it wrong differently: clinvar_draft stricter (demanding ref and alts), clinpgx_draft narrower (rsID only), mitomap_draft wider (adding clin_sig). Every one of those is a faithful description of that provider's own source. Nobody misread the model; they each wrote down the union of two rules and only one of the two was the model's. That is why the repair is a split rather than a shared predicate — a shared predicate would have deleted three true statements about three snapshots.

2. The correct-looking implementation is where the subtle bug lives. Two providers derived their rule instead of restating it, so "migrate everyone onto the derived one" was the obvious move. It was wrong: authoring_requirements' any_of grammar cannot express ref/alts require a position, so the derived implementation accepts {"rsid": "rs1", "alts": "G"} — a partial coordinate the model and a compile both refuse. The four restatements were visibly suspicious and got read; the derivation looked principled and did not. A derivation is only as good as the oracle it derives from, and an oracle that answers a simpler question than the thing it stands in for is the dangerous kind.

3. A justification narrower than its scope becomes a blind spot that reads as a decision. Three instances in one round. RM208: a roster's exempt set, where a guard walking the roster inherited its exemptions. RM230: an exemption on a class argued from what one method promises. RM225: a comment saying a vocabulary "is not enforced" that stayed true-sounding for two releases after the field shipped. The shape is always the same — a reason written at one scope, applied at a wider one, and indistinguishable afterwards from a considered choice. Every exemption should state what the exempted thing promises, not what one part of it does.

4. An import cycle is a diagnosis. Deriving DRAFT_PROJECTIONS from the provider registry created a cycle the moment the scaffold needed stamp_draft_digest. The cycle was not an obstacle to route around: it was the fact that draft_digest, stamp_draft_digest and drafted_unchanged had been drafting code living in provenance.py. That boundary was only holdable while the registry was a hand-kept copy — the copy was what kept the modules from needing each other. A duplicate is load bearing in the dependency graph, and removing it is what reveals where the boundary actually is.

What drafting still lacks

Ordered by how much each would have prevented, not by effort.

A. There is no DraftProvider.covered — every provider still hand-writes the predicate. This is the largest remaining hole. "Did this run contribute something?" is asked by all seven, the answer gates the licence row, and each computes it from its own result shape: result.report.outcomes on one, result.reports[0].outcomes on another, any(r.added for r in reports) on a third, and two rely on an early return instead. RM222 was exactly this predicate written wrong once. It is not unified today because the result types differ, which is the real gap: the providers have no common result protocol. A DraftResult protocol exposing outcomes would make covered a scaffold property and close the shape RM222 found, permanently.

B. PartialRow construction is still per-provider. Every drafter builds PartialRow(model=…, cells=…, stubbed=…, match_on=…) itself, and stubbed is computed differently in each. The registry already holds match_on and the table; it does not hold the stub set, so _STUBBED is still a private constant in six modules. This is the same shape as _MATCH_ON before RM228 and should go the same way.

C. Nothing derives which SourceTerms a provider records. DRAFT_PROVIDERS names the table and the kind, and then each drafter imports its own *_TERMS constant and passes it. The registry could hold it, and then record_draft_provenance would not need sources at all for the single-source case. The multi-source case (civic_draft plus the ClinGen Allele Registry) is the reason it was left as a parameter, and a secondary_sources field would cover it.

D. The covered / drafted distinction is undocumented outside one docstring. They are different questions — did this run establish the source versus did this run add a row — and the second gates the stale-label withdrawal while the first gates the licence row. Two providers got this pair right by copying a third. It belongs in the model, as two properties on a result protocol, not in prose.

E. There is no dry-run contract. Every provider takes dry_run and each decides independently what it suppresses. test_drafting_scaffold.py cannot check it, because there is nothing declarative to check against. The audit noted "no --dry-run path writes (verified empirically for civic)" — an empirical check on one provider is what a contract exists to replace.

F. A provider's withheld vocabulary is still a per-module literal. mitomap_draft folds an unknown member into a member; civic_draft keys its counters on strings that only its own warning formatter reads. A lane-local vocabulary carrying the lane's prefix is already the house rule (@a-lane-local-vocabulary-may-not-shadow-a-schema-one); drafting does not follow it yet.

The rule this round would write for the next one

When two implementations of one thing disagree, do not pick the better-looking one — find the question each is answering. Four of the seven disagreements here were not errors at all; they were two different true statements compressed into one expression. The scaffold's value is not that it removed duplication, it is that it made the two statements separately expressible, so the next reader can tell a source constraint from a misread of the model without reading three lines further down.