diff --git a/Model/lib/dst/microarrayDeRisiTimeSeries.dst b/Model/lib/dst/microarrayDeRisiTimeSeries.dst
new file mode 100644
index 0000000000..598f48f3a5
--- /dev/null
+++ b/Model/lib/dst/microarrayDeRisiTimeSeries.dst
@@ -0,0 +1,193 @@
+[templateStart]
+name=microarrayDeRisiTimeSeriesFoldChangeQuestion
+anchorFile=ApiCommonModel/Model/lib/wdk/model/questions/geneQuestions.xml
+prop=datasetName
+prop=includeProjects
+prop=includeProjectsExcludeEuPathDB
+>templateTextStart<
+
+
+
+
+
+
+
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+After selecting samples you have the option to take the average, minimum, or maximum expression value within each group. (If choosing only one sample from a group, the selected 'operation' will not affect your results). Time series experiments will offer an extra parameter called "Global min/max" which allows you to filter your results further. Finally, you can choose the directionality and the magnitude of the difference. For example, selecting up-regulated with a fold difference of 2 will only show results where the comparator is twice that of the reference.
+
+
+
+ ]]>
+
+
+
+
+
+ Display the histogram of the values of this attribute
+ int
+
+
+
+
+ Display the histogram of the values of this attribute
+ int
+
+
+
+
+
+ Display the histogram of the values of this attribute
+ int
+
+
+
+
+ Display the histogram of the values of this attribute
+ int
+
+
+
+
+ Display the histogram of the values of this attribute
+ int
+
+
+
+
+ Display the histogram of the values of this attribute
+ int
+
+
+
+
+ Display the histogram of the values of this attribute
+ int
+
+
+
+
+ Display the histogram of the values of this attribute
+ int
+
+
+
+
+ Display the histogram of the values of this attribute
+ int
+
+
+
+
+ Display the histogram of the values of this attribute
+ int
+
+
+
+
+
+ fold_change
+
+
+>templateTextEnd<
+
+[templateStart]
+name=microarrayDeRisiTimeSeriesPercentileQuestion
+anchorFile=ApiCommonModel/Model/lib/wdk/model/questions/geneQuestions.xml
+prop=datasetName
+prop=includeProjects
+prop=includeProjectsExcludeEuPathDB
+>templateTextStart<
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ]]>
+
+
+
+
+
+ Display the histogram of the values of this attribute
+ int
+
+
+
+
+ Display the histogram of the values of this attribute
+ int
+
+
+
+
+
+ percentile
+
+
+>templateTextEnd<
diff --git a/Model/lib/wdk/apiCommonModel.xml b/Model/lib/wdk/apiCommonModel.xml
index 3c098ce9ff..79eb71c524 100644
--- a/Model/lib/wdk/apiCommonModel.xml
+++ b/Model/lib/wdk/apiCommonModel.xml
@@ -423,6 +423,13 @@
+
+
+
+
+
+
+
+
+ />
diff --git a/Model/lib/wdk/model/questions/geneQuestions.xml b/Model/lib/wdk/model/questions/geneQuestions.xml
index a2a1866e00..898f56d6f4 100644
--- a/Model/lib/wdk/model/questions/geneQuestions.xml
+++ b/Model/lib/wdk/model/questions/geneQuestions.xml
@@ -3696,182 +3696,11 @@ For further experiment details please refer the data sources listed below. ]]>
-
-
-
-
-
-
-
-
- 1
-
-
-
-
-
-
-
-
-
-
-After selecting samples you have the option to take the average, minimum, or maximum expression value within each group. (If choosing only one sample from a group, the selected 'operation' will not affect your results). Time series experiments will offer an extra parameter called "Global min/max" which allows you to filter your results further. Finally, you can choose the directionality and the magnitude of the difference. For example, selecting up-regulated with a fold difference of 2 will only show results where the comparator is twice that of the reference.
-
-
-
- ]]>
-
-
-
-
-
- Display the histogram of the values of this attribute
- int
-
-
-
-
- Display the histogram of the values of this attribute
- int
-
-
-
-
-
- Display the histogram of the values of this attribute
- int
-
-
-
-
- Display the histogram of the values of this attribute
- int
-
-
-
-
- Display the histogram of the values of this attribute
- int
-
-
-
-
- Display the histogram of the values of this attribute
- int
-
-
-
-
- Display the histogram of the values of this attribute
- int
-
-
-
-
- Display the histogram of the values of this attribute
- int
-
-
-
-
- Display the histogram of the values of this attribute
- int
-
-
-
-
- Display the histogram of the values of this attribute
- int
-
-
-
-
-
- fold_change
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ]]>
-
-
-
-
-
- Display the histogram of the values of this attribute
- int
-
-
-
-
- Display the histogram of the values of this attribute
- int
-
-
-
-
-
- percentile
-
-
+
+
+
diff --git a/Model/lib/wdk/model/questions/organismQuestions.xml b/Model/lib/wdk/model/questions/organismQuestions.xml
index 1002fed34d..ec612c42ee 100644
--- a/Model/lib/wdk/model/questions/organismQuestions.xml
+++ b/Model/lib/wdk/model/questions/organismQuestions.xml
@@ -22,17 +22,17 @@
@@ -69,12 +69,12 @@ This search allows you to identify organisms based on the name of the organism.
recordClassRef="OrganismRecordClasses.OrganismRecordClass">
diff --git a/Model/lib/wdk/model/questions/params/strainSegmentParams.xml b/Model/lib/wdk/model/questions/params/strainSegmentParams.xml
new file mode 100644
index 0000000000..3988709e93
--- /dev/null
+++ b/Model/lib/wdk/model/questions/params/strainSegmentParams.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+ One or more strains or isolates whose coordinate systems the returned segments
+ are expressed in: the search returns one segment per selected strain. The list is
+ all strains that have indel data loaded. A selected strain that does not belong to
+ the reference sequence's organism is dropped from the result rather than being an
+ error.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Model/lib/wdk/model/questions/queries/strainSegmentQueries.xml b/Model/lib/wdk/model/questions/queries/strainSegmentQueries.xml
new file mode 100644
index 0000000000..3dfbe0f484
--- /dev/null
+++ b/Model/lib/wdk/model/questions/queries/strainSegmentQueries.xml
@@ -0,0 +1,206 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = 1
+ AND seg.ref_end >= seg.ref_start
+ AND seg.ref_end <= seg.seq_length
+ -- GATE 4 (spec 5.1 numbering). ':' is the ONLY delimiter in the primary
+ -- key this query mints, so a reference sequence whose source_id contains
+ -- one would produce an unparseable ID -- StrainSegmentAttributes.Coords
+ -- would split it at the embedded colon and then abort the ENTIRE attribute
+ -- query (every row on the page, not just the offender) casting
+ -- 'ncRNA'::integer.
+ --
+ -- What this gate guarantees is SEQUENCE-SIDE ONLY. It is not a
+ -- by-construction property of the whole ID: st.strain is concatenated in
+ -- ahead of the first colon and is never colon-checked here. What keeps
+ -- the strain field safe is the flatVocab it comes from, all of whose
+ -- values are colon-free today (0 of 452 on unidb_shu_a) -- a property of
+ -- the data, not of this SQL.
+ --
+ -- Neither side is an enforced invariant, which is exactly why the gate is
+ -- worth its keystrokes. Provenance of the numbers, because two databases
+ -- get confused here:
+ -- unidb_shu_a (the LIVE appDb, measured 2026-07-31): 0 of 160,581
+ -- dots.ExternalNaSequence source_ids contain ':'; 120 sequences carry
+ -- indel rows (1,855,449 rows over 1,356 protocolappnode rows).
+ -- genomicsdb_rebuild01 (a DIFFERENT, larger database): 10,704
+ -- colon-bearing source_ids, e.g. bld68_Tb927.1.05:mRNA, a
+ -- transcript-level feature; 20,823 indel-bearing sequences, none of
+ -- them colon-bearing.
+ -- So on the live DB the gate is currently unreachable, and on rebuild01 it
+ -- was unreachable only by an accident of which features get indel-called.
+ AND seg.ref_seq NOT LIKE '%:%'
+ -- The suffix is appended to the unnested strain (CONCAT(st.strain, '_Indel'))
+ -- rather than stripped from the column (regexp_replace(pan.name, ...)),
+ -- so pan_named_ix (name, protocol_app_node_id, ...) can still be used as
+ -- an Index Only Scan; applying a function to pan.name instead would force
+ -- a scan of protocolappnode per row. This is why the multiPick change
+ -- unnests the strain list rather than turning the gate into
+ -- regexp_replace(pan.name, '_Indel$', '') IN ($$strain$$): keeping pan.name
+ -- on the bare side of an equality is what preserves the Index Only Scan, and
+ -- it stays an equality once per strain (verified below: "Index Searches: 5"
+ -- for five strains).
+ --
+ -- GATE 3 (spec 5.1 numbering).
+ -- The gate is on ORGANISM (ens2.taxon_id = seg.taxon_id), not on the exact
+ -- sequence (i.na_sequence_id = seg.na_sequence_id). What it proves is the
+ -- only thing this query needs: THIS STRAIN WAS SEQUENCED AGAINST THIS
+ -- ORGANISM, so the strain name and the reference sequence are mutually
+ -- consistent and the minted primary key is meaningful. What it
+ -- deliberately no longer proves is that the strain has indel data on this
+ -- exact contig -- because that is not a precondition for a valid request.
+ -- A strain with ZERO indels on a contig is the ordinary case of a contig
+ -- that simply matches the reference: the strain coordinates equal the
+ -- reference coordinates, the identity mapping is exactly what
+ -- StrainSegmentAttributes.Coords already returns via its LEFT JOIN +
+ -- COALESCE(..., 0), and the contig IS present in that strain's consensus
+ -- FASTA. Gating on the exact sequence refused 689 of the 6,068 valid
+ -- strain/sequence pairs on unidb_shu_a (11%), e.g. A17-10A-1 +
+ -- mito_A_fumigatus_Af293, whose defline >A17-10A-1_mito_A_fumigatus_Af293
+ -- is right there in A17-10A-1_consensus.fa.gz.
+ --
+ -- Organism-level scoping is safe to substitute because, on unidb_shu_a
+ -- (measured 2026-07-31), (strain name, taxon) resolves to exactly one
+ -- protocol_app_node in all 452 pairs present, and no protocol_app_node_id
+ -- spans more than one organism. The residual assumption, and the FASTA
+ -- coverage evidence for it across three organisms, are in spec 5.1.1 and
+ -- spec 10; in one line, this gate is sound only while a strain set covers
+ -- every contig of its organism's reference.
+ --
+ -- Cost, both paths, measured on unidb_shu_a 2026-07-31 (EXPLAIN ANALYZE):
+ -- ACCEPT ~0.33 ms for one strain. The EXISTS early-exits on the first
+ -- matching indel row via indel_ix1.
+ -- REJECT is where this gate costs something, and it is unavoidable:
+ -- a false EXISTS must exhaust every indel row the strain owns. For
+ -- the largest strain here, A17-48H-7 (108,091 indel rows), a
+ -- cross-organism reject runs ~101 ms versus ~53 ms for the old
+ -- exact-sequence gate, i.e. about 2x. It scales with the strain's
+ -- row count, not the DB's: the smallest strain, C044 (4 rows),
+ -- rejects in ~0.33 ms. On a 43.5M-row database the worst case would
+ -- be substantially larger.
+ --
+ -- Cost of the multiPick change itself, same DB and date, Chr1 396000-399000:
+ -- ACCEPT 1 strain est cost 238.24, ~0.35 ms
+ -- ACCEPT 5 strains est cost 939.65, ~0.61 ms (5 rows)
+ -- ACCEPT all 452 ~1.9 s (232 rows; 220 strains belong to other organisms
+ -- and are gated out)
+ -- REJECT 1 strain ~4.9 ms | REJECT 5 strains ~22.5 ms
+ -- So wall time on the accept path is roughly flat in strain count while the
+ -- reject path is linear in it, which is the same asymmetry the single-strain
+ -- query already had, just multiplied. The plan stays a Nested Loop Semi Join
+ -- with an Index Only Scan on pan_named_ix, one index search per strain; the
+ -- unnest is a Function Scan costing 0.05. Five strains in one search is
+ -- cheaper than five separate searches, which would repeat the sequence lookup
+ -- and the WDK query-instance overhead five times. The unbounded worst case is
+ -- capped by maxSelectedCount on the param (see strainSegmentParams.xml).
+ -- Do NOT "optimise" this into a LIMIT 1 scalar subquery; that rewrite
+ -- was considered and rejected on purpose (spec 5.1.1) because it
+ -- would silently return a wrong answer if the "no node spans >1
+ -- organism" invariant ever broke, whereas EXISTS degrades to slow.
+ AND EXISTS (
+ SELECT 1
+ FROM apidb.indel i
+ , study.protocolappnode pan
+ , dots.ExternalNaSequence ens2
+ WHERE i.protocol_app_node_id = pan.protocol_app_node_id
+ AND pan.name = CONCAT(st.strain, '_Indel')
+ AND ens2.na_sequence_id = i.na_sequence_id
+ AND ens2.taxon_id = seg.taxon_id
+ )
+ ]]>
+
+
+
+
+
+
diff --git a/Model/lib/wdk/model/questions/strainSegmentQuestions.xml b/Model/lib/wdk/model/questions/strainSegmentQuestions.xml
new file mode 100644
index 0000000000..a73eae116a
--- /dev/null
+++ b/Model/lib/wdk/model/questions/strainSegmentQuestions.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+ Given a genomic location in reference coordinates and one or more strains, return
+ the equivalent segment in each strain's consensus sequence coordinates.
+
+
+
+ bed reporter
+ for BED/FASTA download; it has no record page and no category-tree placement.
+ ]]>
+
+
+
+
+
+
+
diff --git a/Model/lib/wdk/model/records/strainSegmentAttributeQueries.xml b/Model/lib/wdk/model/records/strainSegmentAttributeQueries.xml
new file mode 100644
index 0000000000..775ad3856c
--- /dev/null
+++ b/Model/lib/wdk/model/records/strainSegmentAttributeQueries.xml
@@ -0,0 +1,181 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ _,
+ -- written by dnaseq-nextflow bin/makeConsensusFastaFromVcfAndBed.py:233
+ -- (spec 2; that script is not in this checkout), and must NEVER be
+ -- split back apart. The '_' is not a usable
+ -- delimiter: measured on genomicsdb_rebuild01 (a DIFFERENT, larger
+ -- database) over the 6,119 strain names carrying indel data, 1,494
+ -- (24%) themselves contain '_' (1_01_01, Af293_resequence2,
+ -- China_LZCH-36, USGS_28834_1_NV), so "the first '_' ends the strain name"
+ -- is false for roughly a quarter of strains. A consumer needing the
+ -- strain or the sequence takes it from the primary key, where ':'
+ -- delimits unambiguously: 0 strain names contain ':' (0 of 6,119 on
+ -- rebuild01, 0 of 452 on the live unidb_shu_a), and the ID query
+ -- refuses to mint an ID for a reference sequence containing one
+ -- (10,704 dots.ExternalNaSequence source_ids do on rebuild01, though
+ -- none of its 20,823 indel-bearing ones; on unidb_shu_a it is 0 of
+ -- 160,581 -- see that query's gate 4 on ref_seq).
+ , ids.strain || '_' || ids.ref_seq AS strain_seq_id
+ , ids.ref_start + COALESCE(off.offset_start, 0) AS strain_start
+ , ids.ref_end + COALESCE(off.offset_end, 0) AS strain_end
+ , tn.name AS organism
+ FROM ids
+ -- Second silent row-drop point (the ids CTE is the other): an inner join,
+ -- so a sequence whose taxon has no 'scientific name' row disappears with
+ -- no record and no error. 0 affected today (0 sequences with a NULL
+ -- taxon, 0 taxa missing a scientific name). Note the cross-query
+ -- inconsistency this leaves: the ID query decides "does this sequence
+ -- have an organism" via apidb.organism, this one via sres.TaxonName.
+ -- They agree on all current data; nothing enforces that they keep
+ -- agreeing, so a divergence would show up as records that pass the ID
+ -- query and then vanish from the page.
+ JOIN sres.TaxonName tn ON tn.taxon_id = ids.taxon_id
+ AND tn.name_class = 'scientific name'
+ LEFT JOIN offsets off ON off.source_id = ids.source_id
+ ) coords
+ ]]>
+
+
+
+
+
+
diff --git a/Model/lib/wdk/model/records/strainSegmentRecord.xml b/Model/lib/wdk/model/records/strainSegmentRecord.xml
new file mode 100644
index 0000000000..cdd36e4e31
--- /dev/null
+++ b/Model/lib/wdk/model/records/strainSegmentRecord.xml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+ source_id
+ project_id
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/IsolatesHTS.java b/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/IsolatesHTS.java
index ccec9aff87..a925988aba 100644
--- a/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/IsolatesHTS.java
+++ b/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/IsolatesHTS.java
@@ -3,10 +3,30 @@
import org.apidb.apicommon.datasetPresenter.DatasetInjector;
import java.util.List;
+/**
+ * DNASeq dataset injector.
+ *
+ * Both method bodies are commented out for the dnaseq-merge-experiments work: DNASeq
+ * datasets are processed differently now, so neither what this injected nor what it
+ * referenced describes them any more. Concretely:
+ *
+ * - injectTemplates() derived per-sample gbrowse/jbrowse databases from getSampleList(),
+ * which keys samples on organismAbbrev + datasetClassCategory + experimentName. The new
+ * dnaseqExperiment class carries an empty category while its samples span two
+ * ("Genetic variation" for SNPs, "Structural variation" for CNVs), so that key cannot
+ * match and getSampleList() throws.
+ * - addModelReferences() registered SnpQuestions / SnpRecordClasses references. That XML
+ * is not in the compiled model and is to be superseded by the variation record.
+ *
+ * The class stays in place as a no-op so presenters may keep naming it while the new
+ * processing lands; getPropertiesDeclaration() is left alone so the hasCNVData prop that
+ * presenters still supply remains declared.
+ */
public class IsolatesHTS extends DatasetInjector {
@Override
public void injectTemplates() {
+ /* commented out for dnaseq-merge-experiments; see the class comment above
String datasetName = getDatasetName();
setOrganismAbbrevFromDatasetName();
@@ -65,10 +85,12 @@ public void injectTemplates() {
}
//System.err.println("short attribution" + getPropValue("shortAttribution"));
+ */
}
@Override
public void addModelReferences() {
+ /* commented out for dnaseq-merge-experiments; see the class comment above
// NGS SNPs
addWdkReference("SnpRecordClasses.SnpRecordClass", "question", "SnpQuestions.NgsSnpBySourceId");
addWdkReference("SnpRecordClasses.SnpRecordClass", "question", "SnpQuestions.NgsSnpsByIsolateGroup");
@@ -107,7 +129,7 @@ public void addModelReferences() {
addWdkReference("SampleRecordClasses.SampleRecordClass", "table", "Datasets");
addWdkReference("SampleRecordClasses.SampleRecordClass", "table", "Characteristics");
addWdkReference("SampleRecordClasses.SampleRecordClass", "table", "ProcessedSample");
-
+ */
}
diff --git a/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/custom/PlasmoDB/MicroarrayDeRisiTimeSeries.java b/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/custom/PlasmoDB/MicroarrayDeRisiTimeSeries.java
index 55370b7bb7..4bf89b4b76 100644
--- a/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/custom/PlasmoDB/MicroarrayDeRisiTimeSeries.java
+++ b/Model/src/main/java/org/apidb/apicommon/model/datasetInjector/custom/PlasmoDB/MicroarrayDeRisiTimeSeries.java
@@ -8,13 +8,22 @@ public class MicroarrayDeRisiTimeSeries extends MicroarrayTwoChannelReferenceDes
public void injectTemplates() {
super.injectTemplates();
- // Questions are hard coded in the model w/ the same name that would have been injected
-
- // we are setting hasPercentile to false so must inject these
+ // we are setting hasPercentile to false so must inject these
setPropValue("graphTextAttrName", "pctGraphAttr" + getDatasetName() + "_pct_graph");
injectTemplate("expressionGraphAttributesPercentile");
injectTemplate("graphTextAttributeCategory");
+ // These two questions are curated rather than generic — they use the PFTimeSeries
+ // vocabulary queries and an extra comparison-samples param — so hasMultipleSamples /
+ // hasPercentileData / hasPageData stay false and the questions come from
+ // microarrayDeRisiTimeSeries.dst instead of the generic expression templates.
+ //
+ // They used to be hardcoded in geneQuestions.xml, which meant they existed even on an
+ // instance where this dataset is not loaded, referencing expr/pct graph attributes that
+ // only exist when this presenter runs. Injecting them makes question and attribute
+ // appear and disappear together, so the model stays dataset driven.
+ injectTemplate("microarrayDeRisiTimeSeriesFoldChangeQuestion");
+ injectTemplate("microarrayDeRisiTimeSeriesPercentileQuestion");
}
@Override
@@ -22,8 +31,11 @@ public void addModelReferences() {
super.addModelReferences();
addWdkReference("TranscriptRecordClasses.TranscriptRecordClass", "question", "GeneQuestions.GenesByProfileSimilarity");
- addWdkReference("TranscriptRecordClasses.TranscriptRecordClass", "question", "GeneQuestions.GenesByMicroarraypfal3D7_microarrayExpression_Derisi_TimeSeries_RSRC");
- addWdkReference("TranscriptRecordClasses.TranscriptRecordClass", "question", "GeneQuestions.GenesByMicroarraypfal3D7_microarrayExpression_Derisi_TimeSeries_RSRCPercentile");
+ // derived from the dataset rather than spelled out, so these track the injected
+ // question names above instead of drifting from them
+ String questionName = "GeneQuestions.GenesByMicroarray" + getDatasetName();
+ addWdkReference("TranscriptRecordClasses.TranscriptRecordClass", "question", questionName);
+ addWdkReference("TranscriptRecordClasses.TranscriptRecordClass", "question", questionName + "Percentile");
}
diff --git a/docs/superpowers/2026-07-31-strain-segment-e2e-report.md b/docs/superpowers/2026-07-31-strain-segment-e2e-report.md
new file mode 100644
index 0000000000..5b2a809a65
--- /dev/null
+++ b/docs/superpowers/2026-07-31-strain-segment-e2e-report.md
@@ -0,0 +1,129 @@
+# End-to-end testing: strain genomic segment search
+
+**Instance:** FungiDB dev, `jbrestel.fungidb.org` · **appDb:** `unidb_shu_a` · **Date:** 2026-07-31
+
+## What it does
+
+A new internal search takes **one or more strain names** and a **reference-coordinate
+location** (sequence ID, start, end, strand) as input, and returns that segment expressed in
+each strain's own consensus-sequence coordinates as a BED feature — one BED line per strain
+from a single search. Strain names come from a controlled vocabulary; the coordinate
+conversion prefix-sums the per-event shifts recorded for that strain.
+
+Testing exercised the whole path against the running site — search, coordinate conversion,
+BED download — and then checked the result against the actual strain consensus FASTA files
+the BED is meant to index into.
+
+The search returns an empty result rather than an error for invalid input: a strain paired
+with another organism's sequence, or a range beyond the sequence length, both come back
+empty. In a mixed selection this applies per strain — an irrelevant strain drops out while
+the rest still return. A successful download leaves every error log silent.
+
+## BED output
+
+Input: five strains + `Chr1_A_fumigatus_Af293`, reference `396000-399000`, forward, as a
+**single search**. Column 1 is the strain FASTA key, columns 2–3 the strain coordinates (BED
+start is 0-based), column 4 the record ID carrying the reference coordinates requested.
+
+Note for callers: the strain list is passed as a **comma-delimited string**
+(`"A17-10A-1,A17-3C-11,…"`), not a JSON array. A real array is rejected with
+`400 … "strain" is not a string`.
+
+```
+A17-10A-1_Chr1_A_fumigatus_Af293 395992 398982 A17-10A-1:Chr1_A_fumigatus_Af293:396000-399000:f 0 +
+A17-3C-11_Chr1_A_fumigatus_Af293 396006 398996 A17-3C-11:Chr1_A_fumigatus_Af293:396000-399000:f 0 +
+A17-58A-3_Chr1_A_fumigatus_Af293 395999 399000 A17-58A-3:Chr1_A_fumigatus_Af293:396000-399000:f 0 +
+B-1-71L-1_Chr1_A_fumigatus_Af293 396035 399036 B-1-71L-1:Chr1_A_fumigatus_Af293:396000-399000:f 0 +
+E-1-75s-2_Chr1_A_fumigatus_Af293 395987 398984 E-1-75s-2:Chr1_A_fumigatus_Af293:396000-399000:f 0 +
+```
+
+One reference interval, five different strain intervals — different offsets and different
+lengths (reference 3001 bp; strains 2990, 2990, 3001, 3001, 2997). Every column-1 value
+matched a defline in the corresponding `_consensus.fa.gz` byte-for-byte.
+
+Strain count is bounded only by the vocabulary: selecting all 452 strains returns 232 BED
+lines in ~2.3 s (the other 220 belong to different organisms and are correctly dropped),
+with no duplicates. One strain returns exactly the line shown above.
+
+## Alignment
+
+Reference substrings came from the database and strain substrings from the consensus FASTAs,
+extracted with `samtools faidx` at exactly the coordinates the search returned, then aligned
+with `clustalo`.
+
+Identity figures below are over the **whole** alignment; the sequence blocks are short
+windows chosen to show a specific feature, so a strain can differ overall while looking
+identical in the window.
+
+### Chr1, reference 396000–399000
+
+Alignment length 3001, the same as the reference.
+
+| sequence | gaps | identity vs reference |
+|---|---:|---:|
+| REFERENCE | 0 | 100.000% |
+| A17-10A-1 | 11 | 99.799% |
+| A17-3C-11 | 11 | 99.799% |
+| A17-58A-3 | 0 | 99.867% |
+| B-1-71L-1 | 0 | 99.667% |
+| E-1-75s-2 | 4 | 99.766% |
+
+Columns 1196–1240, an 11 bp deletion in two strains:
+
+```
+REFERENCE GTGGTATTGCTTCTTCCATCAAACTTCTACAGAGCACAGCGGCTA
+A17-10A-1 GTGGTATTGCTTCTT-----------CTACAGAGCACAGCGGCTA
+A17-3C-11 GTGGTATTGCTTCTT-----------CTACAGAGCACAGCGGCTA
+A17-58A-3 GTGGTATTGCTTCTTCCATCAAACTTCTACAGAGCACAGCGGCTA
+B-1-71L-1 GTGGTATTGCTTCTTCCATCAAACTTCTACAGAGCACMGCGGCTA
+E-1-75s-2 GTGGTATTGCTTCTTCCATCAAACTTCTACAGAGCACCGCGGCTA
+```
+
+Gap sizes match the recorded indels exactly: 11 bp in two strains, 4 bp in a third, and no
+gap in the two with no recorded event.
+
+### mito, reference 500–2000
+
+No strain has an indel before position 2000 here, so all five map to the identical interval
+and the alignment is the expected degenerate case — length 1501, no gaps anywhere, four of
+five strains identical to the reference.
+
+| sequence | gaps | identity vs reference |
+|---|---:|---:|
+| REFERENCE | 0 | 100.000% |
+| A17-10A-1 | 0 | 100.000% |
+| A17-3C-11 | 0 | 100.000% |
+| A17-58A-3 | 0 | 100.000% |
+| B-1-71L-1 | 0 | 100.000% |
+| E-1-75s-2 | 0 | 99.933% |
+
+The only difference anywhere in this 1501-column alignment is a single SNP at column
+700, in `E-1-75s-2` — which is what its 99.933% (1500/1501) reflects. Columns 670–714:
+
+```
+REFERENCE AATTCTTATATATATAACCTATAATTTACATATTTTATATATACA
+A17-10A-1 AATTCTTATATATATAACCTATAATTTACATATTTTATATATACA
+A17-3C-11 AATTCTTATATATATAACCTATAATTTACATATTTTATATATACA
+A17-58A-3 AATTCTTATATATATAACCTATAATTTACATATTTTATATATACA
+B-1-71L-1 AATTCTTATATATATAACCTATAATTTACATATTTTATATATACA
+E-1-75s-2 AATTCTTATATATATAACCTATAATTTACAAATTTTATATATACA
+ ^ T -> A
+```
+
+mito is worth including because it was **broken until this round**. The search originally
+required a strain to have indel data on the exact sequence requested, so a contig a strain
+simply matches returned nothing at all — even though the contig is present in that strain's
+FASTA. That refused **689 of 6,068** valid strain/sequence combinations (11%). The check is
+now scoped to the organism, which still confirms the strain and sequence go together while
+allowing the identity mapping.
+
+## Control and limitation
+
+To confirm the conversion does real work, the strain sequence was also pulled at the
+**unshifted** reference coordinates. Against the reference, the converted coordinates give
+**99.92%** identity; the unshifted control gives **25.0%**, which is chance.
+
+One limitation, a property of the data rather than the implementation: if a requested
+boundary falls inside a deletion, that reference base does not exist in the strain, so there
+is no exact strain coordinate for it. Boundaries outside any deletion — the ordinary case,
+and every case above — convert exactly.
diff --git a/docs/superpowers/plans/2026-07-31-strain-segment-record.md b/docs/superpowers/plans/2026-07-31-strain-segment-record.md
new file mode 100644
index 0000000000..106593f36f
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-31-strain-segment-record.md
@@ -0,0 +1,1555 @@
+# Strain Genomic Segment Record Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add an internal `strain-genomic-segment` WDK record class that takes a single reference-coordinate genomic location plus a strain, and emits a BED feature in that strain's consensus-sequence coordinates.
+
+**Architecture:** The record's primary key carries *reference* coordinates plus the strain (`::-:`). One ID query validates strain, reference sequence, and range, returning zero rows if any gate fails. One attribute query converts reference to strain coordinates by prefix-summing `apidb.indel.shift`. A `BedFeatureProvider` reads those attributes and writes a BED line whose `chrom` is `_` — the key into the strain consensus FASTA. The pipeline deliberately stops at BED; seqret wiring is out of scope.
+
+**Tech Stack:** WDK model XML (`ApiCommonModel`), Java 11 + JUnit 4 (`ApiCommonWebsite/Model`), PostgreSQL 18, Maven, `wb` build wrapper.
+
+**Design spec:** `docs/superpowers/specs/2026-07-31-strain-segment-record-design.md` (this repo). Read it first — it records the measured data facts and the reasoning behind each decision.
+
+---
+
+## Orientation for someone new to this codebase
+
+**Two repos, one branch name.** Model XML lives in `ApiCommonModel`; Java lives in `ApiCommonWebsite`. Both have a `strain-segment-record` branch off `master` in `~/workspaces/fungidb/`. Commit in whichever repo you touched.
+
+**Nothing builds locally.** `~/workspaces/fungidb` is synced by mutagen to `cedar:/var/www/jbrestel.fungidb.org/project_home`, and all builds and tests run on `cedar`. Edit locally, save, and mutagen carries the bytes within a second or two. Never build in the local checkout.
+
+**After any commit or branch switch, run `bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb`.** Mutagen ignores `.git`, so the remote's refs go stale and its `git status` fills with modifications that are not real. This script reconciles refs only and never touches file contents. A local commit you have not pushed reports `UNPUSHED` and is skipped — that is expected and harmless here, since the build reads files, not git.
+
+**An XML file that no one imports is inert.** `Model/lib/wdk/apiCommonModel.xml` lists every model file. Until you add an ``, a new file is not parsed and cannot break the build. Tasks 2-4 exploit this: they land XML that is not yet imported, so each can be committed safely without a working model. Task 5 wires it in and is the first task that can fail a build.
+
+**Useful commands:**
+
+```bash
+# build the model on the remote (also reloads the webapp)
+bash ~/workspaces/agentic-veupath-dev/bin/veup-build.sh fungidb wb model
+
+# render a query's SQL exactly as WDK will run it, without executing
+ssh cedar 'bash -lc "source /var/www/jbrestel.fungidb.org/etc/setenv && \
+ wdkQuery -model FungiDB -query -showQuery"'
+
+# read-only SQL against the app database
+psql -h localhost -p 5439 -d genomicsdb_rebuild01 -c ""
+
+# log delta around a page load
+bash ~/workspaces/agentic-veupath-dev/bin/veup-logs.sh fungidb mark t1
+bash ~/workspaces/agentic-veupath-dev/bin/veup-logs.sh fungidb since t1
+```
+
+`wb ontology` is **not** needed: this work adds no ontology node (spec §6.1). `wb model` suffices.
+
+> **`wb model` does NOT compile `ApiCommonWebsite/Model`.** Verified in `gus_home/bin/wb`: the
+> `model` target runs `bld EbrcModelCommon/Model; bld ApiCommonModel/Model` and nothing else;
+> `site` covers `Website/Site` and `ontology` adds `Presenters/Model`. No `wb` target builds
+> `ApiCommonWebsite/Model`. So after editing Java there (Tasks 1 and 6), run:
+>
+> ```bash
+> ssh cedar 'bash -lc "source /var/www/jbrestel.fungidb.org/etc/setenv && bld ApiCommonWebsite/Model"'
+> ```
+>
+> **Running more than one test class:** this module is on surefire 2.12.4, where `-Dtest=A+B`
+> matches **zero** tests and still reports `BUILD SUCCESS` — a green run that tested nothing.
+> Use a comma: `-Dtest=StrainSegmentIdTest,StrainSegmentFeatureProviderTest`. Always check the
+> `Tests run:` counts rather than trusting the exit status.
+>
+> **The failure mode is misleading, which is why this is worth knowing:** `mvn test` compiles
+> to `target/classes`, which is not on `wdkXml`'s classpath — the jar must be installed into
+> `gus_home/lib/java`. Skip the `bld` and `wb model` fails with `Implementation class for
+> reporter 'bed' … cannot be found` / `ClassNotFoundException`, which reads as "the XML
+> registration is wrong" when in fact the XML is fine and the class simply was never deployed.
+> Do not respond to that error by reverting the `` element.
+
+---
+
+## File structure
+
+**`ApiCommonModel`** — all paths under `Model/lib/wdk/`:
+
+| File | Responsibility |
+|---|---|
+| `model/questions/params/strainSegmentParams.xml` | *new* — the `strain` flat-vocab param and its **global** vocabulary query (not organism-dependent; there is no organism param — spec §5.1.1) |
+| `model/questions/queries/strainSegmentQueries.xml` | *new* — the ID query; all four validation gates live here |
+| `model/records/strainSegmentAttributeQueries.xml` | *new* — the reference-to-strain coordinate conversion |
+| `model/records/strainSegmentRecord.xml` | *new* — record class and attributes. The BED `` element is added in **Task 6**, not Task 5: WDK's `ReporterRef.resolveReferences` does `Class.forName` at model-load time, so registering it before the Java class exists hard-fails the build |
+| `model/questions/strainSegmentQuestions.xml` | *new* — the question that binds query to record class |
+| `apiCommonModel.xml` | *modify* — five `` lines near the existing span imports at 419-423 |
+
+**`ApiCommonWebsite`** — all paths under `Model/src/`:
+
+| File | Responsibility |
+|---|---|
+| `main/java/org/apidb/apicommon/model/report/bed/util/StrainSegmentId.java` | *new* — the single parse/format authority for the PK grammar |
+| `test/java/org/apidb/apicommon/model/report/bed/util/StrainSegmentIdTest.java` | *new* — unit tests for the grammar |
+| `main/java/org/apidb/apicommon/model/report/bed/feature/StrainSegmentFeatureProvider.java` | *new* — record to BED fields |
+| `main/java/org/apidb/apicommon/model/report/bed/BedStrainSegmentReporter.java` | *new* — three-line reporter wiring |
+
+One responsibility each. The grammar class is the only piece with no WDK dependencies, which is exactly why it is the only piece that gets real unit tests — everything else needs a live `RecordInstance` and is verified against the running site.
+
+---
+
+## Task 1: The PK grammar class
+
+> **COMPLETE — amended twice after code review.** Commits `19defeba2` (as written below),
+> `3ead3adac` (review fixes), `65126443a` (bug fix). The code block in Step 3 is what was
+> *planned*; the shipped version differs, all worth knowing if you touch this class:
+> the regex is `^([^:]+):([^:]+):(\d+)-(\d+):(f|r)$` — every field excludes only `':'`,
+> the sole delimiter; validation lives in the private constructor rather than `parse()`,
+> and adds a `refStart < 1` check; and both `parseInt` calls rethrow with the full ID in
+> the message. 20 tests, not 12. See spec §3.1 for the reasoning.
+>
+> **The strain group was briefly `[^:_]+` and that was a live bug**, rejecting the 1,494 of
+> 6,119 strain names (24%) that contain an underscore. It came from a false "no strain name
+> contains an underscore" measurement asserted in Task 2's preamble. Do not reintroduce it:
+> narrowing the grammar cannot make `_` reversible anyway, because reference
+> sequence IDs contain underscores too. The key is opaque; parse the PK instead.
+
+The `ApiCommonWebsite/Model` module has JUnit 4 on its classpath but no `src/test/java` tree yet. You are creating it. Standard Maven layout means Surefire picks it up with no POM change.
+
+**Files:**
+- Create: `ApiCommonWebsite/Model/src/test/java/org/apidb/apicommon/model/report/bed/util/StrainSegmentIdTest.java`
+- Create: `ApiCommonWebsite/Model/src/main/java/org/apidb/apicommon/model/report/bed/util/StrainSegmentId.java`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `StrainSegmentIdTest.java`:
+
+```java
+package org.apidb.apicommon.model.report.bed.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+
+import org.junit.Test;
+
+public class StrainSegmentIdTest {
+
+ @Test
+ public void parsesForwardStrandId() {
+ StrainSegmentId id = StrainSegmentId.parse("A0003:AACB03000001:100-200:f");
+ assertEquals("A0003", id.getStrain());
+ assertEquals("AACB03000001", id.getRefSeq());
+ assertEquals(100, id.getRefStart());
+ assertEquals(200, id.getRefEnd());
+ assertEquals(StrandDirection.forward, id.getStrand());
+ }
+
+ @Test
+ public void parsesReverseStrandId() {
+ StrainSegmentId id = StrainSegmentId.parse("S7:AACB03000001:1-50:r");
+ assertEquals("S7", id.getStrain());
+ assertEquals(StrandDirection.reverse, id.getStrand());
+ }
+
+ // The BED chrom column must equal the dnaseq consensus FASTA defline,
+ // which makeConsensusFastaFromVcfAndBed.py writes as _.
+ @Test
+ public void strainSeqIdMatchesFastaKey() {
+ assertEquals("A0003_AACB03000001",
+ StrainSegmentId.parse("A0003:AACB03000001:100-200:f").getStrainSeqId());
+ }
+
+ // Real strain names contain hyphens; the range is a separate colon field so this is safe.
+ @Test
+ public void strainNameWithHyphensSurvives() {
+ StrainSegmentId id = StrainSegmentId.parse("X10462-P1C9:AACB03000001:1-50:r");
+ assertEquals("X10462-P1C9", id.getStrain());
+ assertEquals(1, id.getRefStart());
+ assertEquals(50, id.getRefEnd());
+ }
+
+ @Test
+ public void formatRoundTripsBothStrands() {
+ String forward = "A17-48H-7:AACB03000001:5-9:f";
+ String reverse = "A17-48H-7:AACB03000001:5-9:r";
+ assertEquals(forward, StrainSegmentId.parse(forward).format());
+ assertEquals(reverse, StrainSegmentId.parse(reverse).format());
+ }
+
+ // DynSpan's greedy "^(.*):" regex silently mis-parses these. We reject instead.
+ @Test
+ public void rejectsColonInSequenceIdRatherThanMisparsing() {
+ assertRejected("A0003:AAC:B03:100-200:f");
+ }
+
+ @Test
+ public void rejectsMissingStrand() {
+ assertRejected("A0003:AACB03000001:100-200");
+ }
+
+ @Test
+ public void rejectsMissingStrain() {
+ assertRejected("AACB03000001:100-200:f");
+ }
+
+ @Test
+ public void rejectsStartGreaterThanEnd() {
+ assertRejected("A0003:AACB03000001:200-100:f");
+ }
+
+ @Test
+ public void rejectsNonNumericCoordinates() {
+ assertRejected("A0003:AACB03000001:abc-200:f");
+ }
+
+ @Test
+ public void rejectsBadStrandLetter() {
+ assertRejected("A0003:AACB03000001:100-200:x");
+ }
+
+ @Test
+ public void rejectsNull() {
+ assertRejected(null);
+ }
+
+ private static void assertRejected(String sourceId) {
+ try {
+ StrainSegmentId.parse(sourceId);
+ fail("expected IllegalArgumentException for: " + sourceId);
+ }
+ catch (IllegalArgumentException expected) {
+ // expected
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+```bash
+ssh cedar 'bash -lc "cd /var/www/jbrestel.fungidb.org/project_home/ApiCommonWebsite/Model && \
+ mvn -Dtest=StrainSegmentIdTest -DfailIfNoTests=true test"'
+```
+
+Expected: **compilation failure** — `cannot find symbol: class StrainSegmentId`.
+
+If instead you get "No tests were executed", the `src/test/java` tree is not being picked up; confirm the file path matches the package exactly.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `StrainSegmentId.java`:
+
+```java
+package org.apidb.apicommon.model.report.bed.util;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * The single parse/format authority for the strain genomic segment primary key.
+ *
+ * Grammar: {@code ::-:}
+ * Example: {@code A0003:AACB03000001:100-200:f}
+ *
+ * Coordinates are 1-based, inclusive, and expressed in REFERENCE coordinates.
+ * Strain coordinates are derived by the attribute query, not stored here.
+ *
+ * Note the field patterns are {@code [^:]+} rather than DynSpan's greedy {@code (.*)}.
+ * DynSpan's SQL and Java parsers disagree on IDs containing extra colons; this one
+ * rejects them instead of guessing.
+ */
+public class StrainSegmentId {
+
+ private static final Pattern PATTERN =
+ Pattern.compile("^([^:]+):([^:]+):(\\d+)-(\\d+):(f|r)$");
+
+ private final String _strain;
+ private final String _refSeq;
+ private final int _refStart;
+ private final int _refEnd;
+ private final StrandDirection _strand;
+
+ private StrainSegmentId(String strain, String refSeq, int refStart, int refEnd,
+ StrandDirection strand) {
+ _strain = strain;
+ _refSeq = refSeq;
+ _refStart = refStart;
+ _refEnd = refEnd;
+ _strand = strand;
+ }
+
+ public static StrainSegmentId parse(String sourceId) {
+ if (sourceId == null) {
+ throw new IllegalArgumentException("Strain segment ID may not be null");
+ }
+ Matcher m = PATTERN.matcher(sourceId);
+ if (!m.matches()) {
+ throw new IllegalArgumentException(String.format(
+ "Strain segment ID '%s' does not match required pattern %s",
+ sourceId, PATTERN.pattern()));
+ }
+ int start = Integer.parseInt(m.group(3));
+ int end = Integer.parseInt(m.group(4));
+ if (start > end) {
+ throw new IllegalArgumentException(String.format(
+ "Strain segment ID '%s' has start %d greater than end %d", sourceId, start, end));
+ }
+ return new StrainSegmentId(m.group(1), m.group(2), start, end,
+ StrandDirection.fromEfOrEr(m.group(5)));
+ }
+
+ public String format() {
+ return String.format("%s:%s:%d-%d:%s", _strain, _refSeq, _refStart, _refEnd,
+ StrandDirection.reverse.equals(_strand) ? "r" : "f");
+ }
+
+ /**
+ * The key into the strain consensus FASTA, and therefore the BED chrom column.
+ * Written by dnaseq-nextflow as {@code _}.
+ */
+ public String getStrainSeqId() {
+ return _strain + "_" + _refSeq;
+ }
+
+ public String getStrain() { return _strain; }
+ public String getRefSeq() { return _refSeq; }
+ public int getRefStart() { return _refStart; }
+ public int getRefEnd() { return _refEnd; }
+ public StrandDirection getStrand() { return _strand; }
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+```bash
+ssh cedar 'bash -lc "cd /var/www/jbrestel.fungidb.org/project_home/ApiCommonWebsite/Model && \
+ mvn -Dtest=StrainSegmentIdTest -DfailIfNoTests=true test"'
+```
+
+Expected: `Tests run: 12, Failures: 0, Errors: 0, Skipped: 0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd ~/workspaces/fungidb/ApiCommonWebsite
+git add Model/src/main/java/org/apidb/apicommon/model/report/bed/util/StrainSegmentId.java \
+ Model/src/test/java/org/apidb/apicommon/model/report/bed/util/StrainSegmentIdTest.java
+git commit -m "Add StrainSegmentId, the parse/format authority for strain segment PKs"
+bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb
+```
+
+---
+
+## Task 2: Strain vocabulary param
+
+The strain list must come from `apidb.indel` (the requirement). There is no strain column on `apidb.indel`; strain is `study.protocolappnode.name` minus a `_Indel` suffix. All 6,119 names carry that suffix and **none contains a colon**, which is what makes the primary key parseable — `':'` is its only delimiter.
+
+> **CORRECTION — an earlier draft of this line claimed "none contains any other underscore".
+> That is FALSE: 1,494 of the 6,119 names (24%) contain an underscore** — `1_01_01`,
+> `Af293_resequence2`, `China_LZCH-36`, `USGS_28834_1_NV`. The `_Indel` strip is still
+> unambiguous (it is anchored to the end), but the `_` concatenation is
+> **not reversible** and must be treated as an opaque key. This bad fact propagated into
+> Task 1's regex as `[^:_]+` for the strain group, which silently rejected ~24% of
+> legitimate primary keys until it was caught in review. Consumers needing the strain parse
+> it from the primary key, where `':'` delimits unambiguously.
+
+> **REVISED after review — there is no organism param.** An earlier draft made this
+> vocabulary depend on an organism param and scope itself with `org_abbrev`. That was
+> redundant: the search is handed a sequence ID, which already determines its organism.
+> The vocabulary is now **global** (6,119 strains, 2.9s, cached once rather than once per
+> organism), and relevance is enforced by the ID query's `EXISTS` gate instead. See spec
+> §5.1.1 for why, including the tree-vocabulary trap that made the organism-scoped version
+> silently return nothing. The Step 1 content below is the revised version.
+
+**Files:**
+- Create: `ApiCommonModel/Model/lib/wdk/model/questions/params/strainSegmentParams.xml`
+
+- [ ] **Step 1: Create the param file**
+
+```xml
+
+
+
+
+
+
+ Strain or isolate whose coordinate system the returned segment is expressed in.
+ The list is restricted to strains that have indel data loaded for the selected
+ organism.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Verify the vocabulary SQL directly**
+
+The file is not imported yet, so `wdkQuery` cannot see it. Run the inner SQL by hand for the QA organism (*Aspergillus fumigatus* Af293, `org_abbrev` = `afumAf293`):
+
+```bash
+psql -h localhost -p 5439 -d genomicsdb_rebuild01 -c "
+SELECT count(*) AS strains FROM (
+ SELECT DISTINCT regexp_replace(pan.name, '_Indel\$', '') AS strain
+ FROM apidb.indel i, study.protocolappnode pan, webready.GenomicSeqAttributes_p gsa
+ WHERE pan.protocol_app_node_id = i.protocol_app_node_id
+ AND gsa.na_sequence_id = i.na_sequence_id
+ AND gsa.org_abbrev IN ('afumAf293')
+) t;"
+```
+
+Expected: `1117`. If you get `0`, confirm the `org_abbrev` spelling with
+`SELECT DISTINCT org_abbrev FROM webready.organismabbreviation_p WHERE organism LIKE 'Aspergillus fumigatus%';`
+
+Note the cost: this touches roughly 5.1M indel rows for Af293. It is declared
+`isCacheable="true"` so it runs once per organism. Record the wall-clock time. If it
+exceeds ~30 seconds, note it for follow-up — the fix is a small tuning table keyed on
+`(org_abbrev, strain)`, but do not build that speculatively.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd ~/workspaces/fungidb/ApiCommonModel
+git add Model/lib/wdk/model/questions/params/strainSegmentParams.xml
+git commit -m "Add organism-dependent strain vocabulary param for strain segments"
+bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb
+```
+
+---
+
+## Task 3: ID query with all four validation gates
+
+Input is a **single** reference location, not a set. Every gate is a filter, so invalid
+input yields zero records rather than a broken download — the failure mode
+`DynSpansBySegIds` gets wrong by doing no validation at all.
+
+`sequence_strand` has internal values `f` and `r` (`spanParams.xml:347-363`), so it drops
+straight into the PK with no `CASE`. `end_point_segment` documents `0 = end`, which the
+`CASE` below honours.
+
+**Do not add `queryRef="organismVQ.withStrainsChromosome"` to the organism `paramRef`**,
+even though `DynSpansBySourceId` does. That query is declared for only 10 projects, so
+referencing it forces an `includeProjects` guard onto everything that depends on it — and
+this record is meant to work on all projects. Worse, its SQL selects from
+`apidbtuning.GenomicSeqAttributes`, which **does not exist in this database** (verified
+2026-07-31; the same absence that stops DynSpan's `Bfmv` query from running here). Let
+`organismParams.organismSinglePick` use its own default vocab query, `organismVQ.withGenes`,
+which carries no `includeProjects`.
+
+Note `organismSinglePick` is declared `multiPick="true" maxSelectedCount="1"`
+(`organismParams.xml:256-262`), so WDK substitutes a quoted comma-separated list even
+though only one value can be chosen. `IN (...)` is therefore required and `=` would be a
+bug.
+
+**Param quoting — write params UNQUOTED in the SQL.** This codebase quotes enum and vocab
+params by default; `spanQueries.xml:303` has to say `quote="false"` explicitly to turn it
+off. So `$$sequence_strand$$` and `$$strain$$` arrive already wrapped in single quotes, and
+adding your own would produce `''f''`. Set `quote="true"` on the `strain` paramRef to make
+the intent explicit, then reference both bare.
+
+The codebase is genuinely inconsistent here (`spanQueries.xml:400` uses bare
+`$$liberal_conservative$$` while `:417` writes `'$$any_or_all_DynSeg$$'`), so this cannot be
+settled by reading alone. **Task 5 Step 5 is where it gets confirmed**: `wdkQuery -showQuery`
+renders the assembled SQL, and doubled quotes will be visible there. If they appear, remove
+the `quote="true"` rather than adding literal quotes.
+
+Also prefer `pan.name = CONCAT($$strain$$, '_Indel')` over `regexp_replace(pan.name, ...)
+= $$strain$$` in the EXISTS gate. Same lesson as Task 2: comparing a plain string lets the
+database use an index on `name`, whereas applying `regexp_replace` to every candidate row
+forces a scan and computes a function per row to reach the same answer.
+
+**One `` block, no project variants.** Do not add `includeProjects` or
+`excludeProjects` to anything in this task. `project_id` is selected from
+`webready.GenomicSeqAttributes_p`, not from `@PROJECT_ID@`, so it is correct on every
+project including UniDB. DynSpan duplicates its queries for UniDB precisely because it
+uses `@PROJECT_ID@`, which is meaningless on the portal — that is a problem this design
+avoids rather than a pattern to copy.
+
+> **CORRECTION — gate 4 (the `EXISTS`) is scoped by ORGANISM, not by exact sequence.**
+> Found in end-to-end QA and fixed 2026-07-31. As written below it matched
+> `i.na_sequence_id = seg.na_sequence_id`, which made the gate do two jobs: prove strain
+> and sequence are mutually consistent (**required** — it is what lets this search drop the
+> organism param, spec §5.1.1) and prove indel data exists on that exact contig (**not
+> required, and wrong**). A strain with **zero** indels on a contig is a perfectly valid
+> request: it means the strain matches the reference there, so the strain coordinates
+> simply equal the reference coordinates and the contig is present in that strain's
+> consensus FASTA. Such requests returned `### The result is empty ###`.
+>
+> That refused **689 of the 6,068 valid strain/sequence pairs (11%)**. The case that
+> surfaced it: `A17-10A-1` + `mito_A_fumigatus_Af293` returned empty even though
+> `>A17-10A-1_mito_A_fumigatus_Af293` is present in `A17-10A-1_consensus.fa.gz`.
+>
+> The fix adds `ens.taxon_id` to the inner derived table and joins the `EXISTS` back
+> through `dots.ExternalNaSequence ens2` on `ens2.taxon_id = seg.taxon_id`. Gate 4 now
+> proves **"this strain was sequenced against this organism"**; it no longer proves "this
+> strain has indel data on this exact contig". The substitution is exact rather than
+> approximate: `(strain name, taxon)` resolves to exactly one protocol app node in 452/452
+> pairs, and **0** `protocol_app_node_id` values span more than one organism. Cross-organism
+> requests still return zero rows. **The attribute query needs no change** — its `LEFT JOIN`
+> + `COALESCE(..., 0)` already yields the identity mapping for a zero-indel segment.
+>
+> Assumption to re-check if the consensus pipeline changes: a strain set is assumed to
+> cover **every** contig of its organism's reference (Af293: 9 sequences in the DB, 9
+> deflines in the FASTA). If that ever fails, this gate would mint an ID whose `chrom` has
+> no FASTA entry. Full rationale in spec §5.1, "Gate 3 is organism-level, and that is
+> deliberate" (the spec numbers this gate 3; the numbering differs, the gate is the same).
+>
+> Note also that the Step 1 snapshot below predates §5.1.1: the shipped query takes **no**
+> `organismSinglePick` param and resolves sequences through unpartitioned
+> `dots.ExternalNaSequence` + `apidb.organism` rather than `webready.GenomicSeqAttributes_p`.
+> Read the committed `strainSegmentQueries.xml` as the source of truth, not this block.
+
+**Files:**
+- Create: `ApiCommonModel/Model/lib/wdk/model/questions/queries/strainSegmentQueries.xml`
+
+- [ ] **Step 1: Create the ID query file**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = 1
+ AND seg.ref_end >= seg.ref_start
+ AND seg.ref_end <= seg.seq_length
+ AND EXISTS (
+ SELECT 1
+ FROM apidb.indel i
+ , study.protocolappnode pan
+ WHERE i.protocol_app_node_id = pan.protocol_app_node_id
+ AND i.na_sequence_id = seg.na_sequence_id
+ AND pan.name = CONCAT($$strain$$, '_Indel')
+ )
+ ]]>
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Verify each gate by hand**
+
+Still not imported, so run the assembled SQL directly. Substitute a real strain and
+contig for Af293 first:
+
+```bash
+psql -h localhost -p 5439 -d genomicsdb_rebuild01 -c "
+SELECT gsa.source_id, gsa.na_sequence_id, gsa.length,
+ regexp_replace(pan.name,'_Indel\$','') AS strain
+FROM webready.GenomicSeqAttributes_p gsa
+ , apidb.indel i, study.protocolappnode pan
+WHERE gsa.org_abbrev = 'afumAf293'
+ AND i.na_sequence_id = gsa.na_sequence_id
+ AND pan.protocol_app_node_id = i.protocol_app_node_id
+ AND i.location BETWEEN 100 AND 200
+LIMIT 1;"
+```
+
+**Record all four values now — Tasks 4 and 7 substitute them.** Write them down:
+
+| placeholder used later | value from this query |
+|---|---|
+| `` | `source_id` |
+| `` | `na_sequence_id` |
+| `` | `strain` |
+| sequence length | `length` |
+
+The `location BETWEEN 100 AND 200` clause is there on purpose: it picks a contig/strain
+pair that actually has indels in the 100-200 window, so the Task 7 shift check has a
+non-zero offset to prove and does not silently pass on a segment with no indels at all.
+
+Then run the query body four more times, each with one gate deliberately broken, and
+confirm **zero rows** every time:
+
+| Break | Expect |
+|---|---|
+| `source_id` set to `'NO_SUCH_CONTIG'` | 0 rows |
+| `strain` set to `'NO_SUCH_STRAIN'` | 0 rows |
+| `ref_end` set beyond `seq_length` | 0 rows |
+| `ref_start` set to `0` | 0 rows |
+
+A gate that still returns a row is a bug — fix it before moving on. This is the whole
+point of the task.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd ~/workspaces/fungidb/ApiCommonModel
+git add Model/lib/wdk/model/questions/queries/strainSegmentQueries.xml
+git commit -m "Add validating ID query for strain genomic segments"
+bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb
+```
+
+---
+
+## Task 4: Attribute query — reference to strain coordinates
+
+`shift` is a signed per-event delta (never zero, range −101..+80), so the strain offset at
+a reference position is a prefix sum partitioned by `(protocol_app_node_id, na_sequence_id)`.
+
+Two things make this query's shape non-obvious:
+
+1. **It cannot use `webready.*_p`.** Attribute queries receive only PK columns, so there
+ is no `org_abbrev` to prune partitions with, and every index on those tables is
+ `org_abbrev`-prefixed. There is also no unpartitioned `GenomicSeqAttributes` in this
+ database — `apidbtuning` exists but does not contain it, which is why DynSpan's `Bfmv`
+ query cannot run here at all. Instead use the unpartitioned GUS core path:
+ `dots.externalnasequence` (view; `source_id_uniq` index on `source_id`; carries
+ `na_sequence_id`, `taxon_id`, `length`) joined to `apidb.organism` and `sres.taxonname`.
+2. **`GROUP BY` the resolved node, not just the segment.** If a strain name ever resolves
+ to two nodes on one sequence, this produces two rows per PK and WDK errors. Joining on
+ name alone would instead sum two strains' shifts into one plausible-looking wrong
+ answer with nothing to detect it.
+
+**Boundary rule:** an indel exactly at `ref_start` lies inside the segment, so it shifts
+the end but not the start — `<` for start, `<=` for end. This is the deferred QA item;
+implement as written and verify in Task 7.
+
+**Files:**
+- Create: `ApiCommonModel/Model/lib/wdk/model/records/strainSegmentAttributeQueries.xml`
+
+- [ ] **Step 1: Create the attribute query file**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Note `i.protocol_app_node_id` in the `GROUP BY` but not the `SELECT`: that is deliberate.
+It is what turns a future name-to-two-nodes collision into duplicate rows and a WDK error
+instead of a silently summed wrong offset.
+
+- [ ] **Step 2: Verify the prefix sum against a hand calculation**
+
+Pick a strain and contig with indels, then compare. Substitute the values you found in
+Task 3 Step 2:
+
+```bash
+psql -h localhost -p 5439 -d genomicsdb_rebuild01 -c "
+WITH seg AS (SELECT ''::text AS strain, ::numeric AS na_seq, 100 AS s, 200 AS e)
+SELECT SUM(CASE WHEN i.location < seg.s THEN i.shift ELSE 0 END) AS offset_start
+ , SUM(CASE WHEN i.location <= seg.e THEN i.shift ELSE 0 END) AS offset_end
+FROM apidb.indel i, study.protocolappnode pan, seg
+WHERE pan.protocol_app_node_id = i.protocol_app_node_id
+ AND i.na_sequence_id = seg.na_seq
+ AND regexp_replace(pan.name,'_Indel\$','') = seg.strain
+ AND i.location <= seg.e;"
+```
+
+Then verify independently that the offsets are just running sums:
+
+```bash
+psql -h localhost -p 5439 -d genomicsdb_rebuild01 -c "
+SELECT i.location, i.shift
+FROM apidb.indel i, study.protocolappnode pan
+WHERE pan.protocol_app_node_id = i.protocol_app_node_id
+ AND i.na_sequence_id =
+ AND regexp_replace(pan.name,'_Indel\$','') = ''
+ AND i.location <= 200
+ORDER BY i.location;"
+```
+
+Add the `shift` values by hand: those with `location < 100` must equal `offset_start`,
+those with `location <= 200` must equal `offset_end`. They must match exactly.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd ~/workspaces/fungidb/ApiCommonModel
+git add Model/lib/wdk/model/records/strainSegmentAttributeQueries.xml
+git commit -m "Add strain coordinate conversion attribute query"
+bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb
+```
+
+---
+
+## Task 5: Record class, question, and imports — first build
+
+This is the first task that can break the build, because it is the task that makes the
+model load the new files.
+
+The record class is deliberately bare: no tables and no reporters beyond `bed` (added in
+Task 6). `useBasket="false"` is required — the default is `true`
+(`RecordClass.java:303`) and would inject basket questions and client affordances (spec
+§6). `doNotTest="true"` keeps it out of the WDK sanity tests, matching DynSpan.
+
+"No record page" is an **intent, not a guarantee**: WDK injects a `_default` summary view,
+a `_default` "Overview" record view, and `DefaultJsonReporter` unconditionally (spec §6),
+and the nine `columnAttribute`s *are* display attributes — they must stay that way because
+the BED reporter reads them. A record-page request is not a supported operation and is not
+verified to do anything graceful.
+
+**No `individuals.txt` entry.** Omitting the row is what keeps the search out of the
+category tree while leaving it addressable by name through the service API — the same
+mechanism `DynSpansBySegIds` relies on (spec §6.1). Note `DynSpansByLocation` is **not** a
+second precedent: it is only an `` (`spanQueries.xml:266`) with no ``
+and no references anywhere, i.e. a dead id query (spec §5.1).
+
+**Files:**
+- Create: `ApiCommonModel/Model/lib/wdk/model/records/strainSegmentRecord.xml`
+- Create: `ApiCommonModel/Model/lib/wdk/model/questions/strainSegmentQuestions.xml`
+- Modify: `ApiCommonModel/Model/lib/wdk/apiCommonModel.xml` (near lines 419-423)
+
+- [ ] **Step 1: Create the record class**
+
+```xml
+
+
+
+
+
+
+
+
+ source_id
+ project_id
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Create the question**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+ Given a genomic location in reference coordinates and a strain, return the
+ equivalent segment in that strain's consensus sequence coordinates.
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 3: Register all five files**
+
+In `Model/lib/wdk/apiCommonModel.xml`, immediately after the existing span imports
+(currently lines 419-423), add:
+
+```xml
+
+
+
+
+
+```
+
+Order matters only in that WDK resolves references after parsing all files, so any order
+within this block is fine — but keep it grouped and adjacent to the span imports so the
+next person finds it.
+
+- [ ] **Step 4: Build the model**
+
+```bash
+bash ~/workspaces/agentic-veupath-dev/bin/veup-build.sh fungidb wb model
+```
+
+Expected: build completes and the webapp reloads.
+
+If it fails, read the error for the specific unresolved reference — a mistyped
+`queryRef`, `paramRef`, or column name is the usual cause. A column declared in
+`` but absent from the attribute query's `` list will fail here.
+
+- [ ] **Step 5: Confirm the SQL assembles as WDK will run it**
+
+```bash
+ssh cedar 'bash -lc "source /var/www/jbrestel.fungidb.org/etc/setenv && \
+ wdkQuery -model FungiDB -query StrainSegmentAttributes.Coords -showQuery"'
+ssh cedar 'bash -lc "source /var/www/jbrestel.fungidb.org/etc/setenv && \
+ wdkQuery -model FungiDB -query StrainSegmentId.StrainSegmentsByRefSegment -showParams"'
+```
+
+Expected: the ID query lists **five** params — `strain`, `sequenceId`, `start_point`,
+`end_point_segment`, `sequence_strand`. There is **no** organism param (spec §5.1.1); if
+you see one, something is wrong.
+
+The attribute query prints wrapped as `SELECT o.* FROM (…) o` with `##WDK_ID_SQL##`
+**still literal, NOT expanded** — and that is correct, not a failure. Run standalone
+through `QueryTester` there is no answer or step to supply the ID SQL, so nothing
+substitutes the macro (the log also reports `params: [ ]`). Substitution happens in the
+answer-value layer at request time. What this step proves is that the query is
+registered, parses, and resolves — *executing* it against real IDs is Task 7's job.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd ~/workspaces/fungidb/ApiCommonModel
+git add Model/lib/wdk/model/records/strainSegmentRecord.xml \
+ Model/lib/wdk/model/questions/strainSegmentQuestions.xml \
+ Model/lib/wdk/apiCommonModel.xml
+git commit -m "Add strain segment record class and question, and import the model files"
+bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb
+```
+
+---
+
+## Task 6: BED feature provider and reporter
+
+`BedReporter` streams the attributes a provider declares and calls
+`getRecordAsBedFields` per record. `GenomicSequenceFeatureProvider` is the precedent for
+a provider that computes coordinates from attributes rather than from the PK.
+
+Two BED columns, two very different constraints:
+
+- **`chrom` is hard.** `BedLine.bed6`'s first argument *is* the chrom column, and it must
+ equal `_` or the FASTA index lookup fails. `bed6` also converts start
+ to 0-based for you — pass 1-based coordinates.
+- **`name` is free.** Under `deflineFormat=QUERYONLY`, seqret emits `'>' + name`
+ verbatim, so this becomes the eventual FASTA defline. `RequestedDeflineFields` is
+ populated only when the caller passes `deflineType=full`, so the default stays bare.
+
+**Files:**
+- Create: `ApiCommonWebsite/Model/src/main/java/org/apidb/apicommon/model/report/bed/feature/StrainSegmentFeatureProvider.java`
+- Create: `ApiCommonWebsite/Model/src/main/java/org/apidb/apicommon/model/report/bed/BedStrainSegmentReporter.java`
+- Modify: `ApiCommonModel/Model/lib/wdk/model/records/strainSegmentRecord.xml` — register the
+ reporter (Task 5 deliberately left this out; see Step 4 below). **Two repos, one task.**
+
+> **The registration in Step 4 is not optional bookkeeping — without it the BED download
+> simply is not offered, with no error anywhere.** Task 7's download call would fail with an
+> unknown-format error and the cause would not be obvious. Do not consider this task done
+> with only the Java side written.
+
+**This task must also reject the inverted-interval case.** `StrainSegmentAttributes.Coords`
+deliberately leaves `strain_start`/`strain_end` unclamped so that a segment lying inside a
+deletion reports `strain_end < strain_start` (only `strain_length` is clamped to 0 — spec
+§5.3.2). Nothing upstream rejects it, so the provider **must**: an inverted interval that
+reaches a FASTA lookup is a silent wrong answer. Verified live example to test against:
+PK `366.1:Pf3D7_10_v3:331757-331757:f` yields `strain_start 331658`, `strain_end 331604`,
+`strain_length 0`.
+
+- [ ] **Step 1: Create the feature provider**
+
+```java
+package org.apidb.apicommon.model.report.bed.feature;
+
+import java.util.List;
+
+import org.apidb.apicommon.model.report.bed.util.BedLine;
+import org.apidb.apicommon.model.report.bed.util.DeflineBuilder;
+import org.apidb.apicommon.model.report.bed.util.RequestedDeflineFields;
+import org.apidb.apicommon.model.report.bed.util.StrainSegmentId;
+import org.gusdb.wdk.model.WdkModelException;
+import org.gusdb.wdk.model.record.RecordInstance;
+import org.json.JSONObject;
+
+/**
+ * Emits one BED line per strain genomic segment.
+ *
+ * The chrom column must be the strain consensus FASTA key (_); the name
+ * column becomes the FASTA defline downstream and carries both coordinate systems.
+ */
+public class StrainSegmentFeatureProvider implements BedFeatureProvider {
+
+ private static final String ATTR_STRAIN_SEQ_ID = "strain_seq_id";
+ private static final String ATTR_STRAIN_START = "strain_start";
+ private static final String ATTR_STRAIN_END = "strain_end";
+ private static final String ATTR_ORGANISM = "organism";
+
+ private final RequestedDeflineFields _requestedDeflineFields;
+
+ public StrainSegmentFeatureProvider(JSONObject config) {
+ _requestedDeflineFields = new RequestedDeflineFields(config);
+ }
+
+ @Override
+ public String getRequiredRecordClassFullName() {
+ return "StrainSegmentRecordClasses.StrainSegmentRecordClass";
+ }
+
+ @Override
+ public String[] getRequiredAttributeNames() {
+ return new String[] {
+ ATTR_STRAIN_SEQ_ID,
+ ATTR_STRAIN_START,
+ ATTR_STRAIN_END,
+ ATTR_ORGANISM
+ };
+ }
+
+ @Override
+ public String[] getRequiredTableNames() {
+ return new String[0];
+ }
+
+ @Override
+ public List> getRecordAsBedFields(RecordInstance record) throws WdkModelException {
+ String featureId = getSourceId(record);
+
+ StrainSegmentId id;
+ try {
+ id = StrainSegmentId.parse(featureId);
+ }
+ catch (IllegalArgumentException e) {
+ throw new WdkModelException(e.getMessage(), e);
+ }
+
+ // chrom must be the FASTA key; take it from the attribute rather than recomputing,
+ // so the model stays the single source of truth for what was looked up.
+ String strainSeqId = stringValue(record, ATTR_STRAIN_SEQ_ID);
+ Integer strainStart = integerValueWithZeroForEmpty(record, ATTR_STRAIN_START);
+ Integer strainEnd = integerValueWithZeroForEmpty(record, ATTR_STRAIN_END);
+
+ if (strainStart < 1 || strainEnd < strainStart) {
+ throw new WdkModelException(String.format(
+ "Strain segment %s produced invalid strain coordinates %d-%d",
+ featureId, strainStart, strainEnd));
+ }
+
+ DeflineBuilder defline = new DeflineBuilder(featureId);
+
+ if (_requestedDeflineFields.contains("organism")) {
+ defline.appendRecordAttribute(record, ATTR_ORGANISM);
+ }
+ if (_requestedDeflineFields.contains("strain")) {
+ defline.appendValue(id.getStrain());
+ }
+ if (_requestedDeflineFields.contains("description")) {
+ defline.appendValue("segment of strain genomic sequence");
+ }
+ if (_requestedDeflineFields.contains("reference_position")) {
+ defline.appendPosition(id.getRefSeq(), id.getRefStart(), id.getRefEnd(), id.getStrand());
+ }
+ if (_requestedDeflineFields.contains("position")) {
+ defline.appendPosition(strainSeqId, strainStart, strainEnd, id.getStrand());
+ }
+ if (_requestedDeflineFields.contains("segment_length")) {
+ defline.appendSegmentLength(strainStart, strainEnd);
+ }
+
+ return List.of(BedLine.bed6(strainSeqId, strainStart, strainEnd, defline, id.getStrand()));
+ }
+}
+```
+
+- [ ] **Step 2: Create the reporter**
+
+```java
+package org.apidb.apicommon.model.report.bed;
+
+import org.apidb.apicommon.model.report.bed.feature.StrainSegmentFeatureProvider;
+import org.gusdb.wdk.model.WdkModelException;
+import org.gusdb.wdk.model.report.Reporter;
+import org.gusdb.wdk.model.report.ReporterConfigException;
+import org.json.JSONObject;
+
+public class BedStrainSegmentReporter extends BedReporter {
+
+ @Override
+ public Reporter configure(JSONObject config) throws ReporterConfigException, WdkModelException {
+ return configure(() -> new StrainSegmentFeatureProvider(config), getContentDisposition(config));
+ }
+
+}
+```
+
+- [ ] **Step 3: Verify it compiles and the grammar tests still pass**
+
+```bash
+ssh cedar 'bash -lc "cd /var/www/jbrestel.fungidb.org/project_home/ApiCommonWebsite/Model && \
+ mvn -Dtest=StrainSegmentIdTest,StrainSegmentFeatureProviderTest -DfailIfNoTests=true test"'
+```
+
+Expected, as actually observed from this exact invocation: `StrainSegmentIdTest` **20**,
+`StrainSegmentFeatureProviderTest` **10**, `Tests run: 30, Failures: 0`, no compilation
+errors. (An earlier revision of this step said 12 — that predated both the underscore
+regression tests and this task's provider tests.)
+
+Read the `Tests run:` counts; do not trust the exit status alone. See the surefire warning
+in "Useful commands" above — `-Dtest=A+B` matches nothing on this module and, combined with
+`-DfailIfNoTests=false`, reports `BUILD SUCCESS` having run no tests at all.
+
+`BedReporter.configure` validates at runtime that the record class matches
+`getRequiredRecordClassFullName()` and that every declared attribute exists on it, so a
+typo in an attribute name surfaces on first use, not at compile time. That is what Task 7
+Step 2 checks.
+
+- [ ] **Step 4: Register the reporter on the record class**
+
+Task 5 could not do this: WDK's `ReporterRef.resolveReferences`
+(`WDK/Model/src/main/java/org/gusdb/wdk/model/report/ReporterRef.java:213-224`) calls
+`Class.forName(getImplementation())` at model-load time and throws
+`WdkModelException("… cannot be found.")` on `ClassNotFoundException`. Registering a
+reporter whose class did not yet exist would have hard-failed the build. Now that
+`BedStrainSegmentReporter` exists, add it.
+
+In `ApiCommonModel/Model/lib/wdk/model/records/strainSegmentRecord.xml`, replace the
+Task-6 placeholder comment inside `` with:
+
+```xml
+
+```
+
+`name="bed"` is the string Task 7 passes as `reportName`, so it must match exactly.
+
+- [ ] **Step 5: Rebuild so the model picks up the registration**
+
+```bash
+bash ~/workspaces/agentic-veupath-dev/bin/veup-build.sh fungidb wb model
+```
+
+Expected: build succeeds. If it fails with "Implementation class for reporter 'bed' …
+cannot be found", the Java class did not compile into the deployed webapp — fix that
+before continuing, since Task 7 cannot pass without it.
+
+- [ ] **Step 6: Commit — both repos**
+
+```bash
+cd ~/workspaces/fungidb/ApiCommonWebsite
+git add Model/src/main/java/org/apidb/apicommon/model/report/bed/feature/StrainSegmentFeatureProvider.java \
+ Model/src/main/java/org/apidb/apicommon/model/report/bed/BedStrainSegmentReporter.java
+git commit -m "Add BED reporter and feature provider for strain genomic segments"
+
+cd ~/workspaces/fungidb/ApiCommonModel
+git add Model/lib/wdk/model/records/strainSegmentRecord.xml
+git commit -m "Register the BED reporter on the strain segment record class"
+
+bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb
+```
+
+---
+
+## Task 7: End-to-end verification
+
+> ## RESOLVED 2026-07-31 — instance repointed at an appDb that has indels
+>
+> John updated `etc/conifer_site_vars.yml` to swap the appDb from LDAP `genomicsdb_devn`
+> (`genomicsdb_070n`, **no `apidb.indel` at all**) to `appDb_ldapCommonName: UniDB_shu_a` /
+> `jdbc:postgresql://ares12.penn.apidb.org:5432/unidb_shu_a`.
+>
+> **The first gated rebuild still failed, and that turned out to be a checkout problem, not a
+> gate problem.** `rebuilder --gusjvmopts -Dpresenter.dataset.gate=on` died at WDK cache
+> creation:
+>
+> ```
+> PSQLException: relation "eda.attributegraph_sd9c28df5a4_gnphntyd" does not exist
+> for query TranscriptAttributes.MetaPhenotypeVariablesNumericFungiDB_VEuPathDB_curated_phenotype_Phenotype_RSRC
+> FATAL: I was unable to recreate the WDK cache.
+> ```
+>
+> That query name embeds a dataset (`VEuPathDB_curated_phenotype_Phenotype_RSRC`) that has **no
+> row in `apidb.datasource`** here, i.e. exactly what the gate is supposed to skip. It was not
+> skipped, and `$GUS_HOME/lib/wdk/presentersNotLoaded.txt` did not exist — the gate never ran.
+> Cause: **the gate implementation is not on `master`.** It lives on the
+> `dnaseq-merge-experiments` branch that plasmodb uses, so this checkout had the flag but not
+> the code that honours it. Fixed by putting `EbrcModelCommon`, `ApiCommonDatasets`, and
+> `ApiCommonPresenters` on that branch in this workspace.
+>
+> The gate is required here because this appDb holds a subset of the loaded data while
+> `datasetPresenters` is a superset by design.
+>
+> **The new appDb is reachable locally on port 5432** (`psql -h localhost -p 5432 -d unidb_shu_a`).
+> It is a **third** database, so the §2 figures — measured on `genomicsdb_rebuild01` — do not
+> describe it. Measured on `unidb_shu_a`:
+>
+> | | `rebuild01` (spec §2) | `unidb_shu_a` (what the site now queries) |
+> |---|---|---|
+> | `apidb.indel` rows | 43,585,584 | 1,855,449 |
+> | strains (`_Indel` nodes) | 6,119 | **452** |
+> | sequences with indels | 20,823 | 120 |
+> | `apidb.organism` | 967 | 13 |
+>
+> Indel-bearing projects here: TriTrypDB (95 sequences), PlasmoDB (16), **FungiDB (9 sequences,
+> 232 strains)** — so this FungiDB site does have usable data. Expect the strain vocabulary to
+> offer **452** options, not 6,119, and note the §5.2 vocabulary timing (57 ms) was measured
+> against the larger database.
+>
+> ### Verified test cases for Steps 3-6 — use these
+>
+> Both on strain `A17-48H-7`, sequence `Chr1_A_fumigatus_Af293` (length 4,918,979), offsets
+> computed read-only against `unidb_shu_a`:
+>
+> | Case | `start_point` | `end_point_segment` | offset_start | offset_end | expected `strain_start` | expected `strain_end` |
+> |---|---|---|---|---|---|---|
+> | A | 20000 | 25000 | +71 | +75 | 20071 | 25075 |
+> | B | 1 | 1000 | **0** | +3 | 1 | 1003 |
+>
+> Case B is the plan's required "`offset_start` is 0 but `offset_end` is not" check — it proves
+> the two offsets are computed independently rather than one being copied to the other.
+> Case A's offsets differ from each other (71 vs 75), which is a second, weaker form of the
+> same check.
+>
+> For case A the BED line must be:
+> `A17-48H-7_Chr1_A_fumigatus_Af293` / `20070` (0-based, so `strain_start - 1`) / `25075` /
+> `A17-48H-7:Chr1_A_fumigatus_Af293:20000-25000:f` / `.` / `+`
+>
+> ---
+>
+> **Original blocker, kept for the record.** **Discovered 2026-07-31, while starting this task.** Steps 3-6 cannot run on
+> `jbrestel.fungidb.org` as it is currently configured, and this invalidates one premise of
+> the whole plan.
+>
+> Every SQL fact in this spec and plan was measured against **`genomicsdb_rebuild01`**
+> (`localhost:5439`). But the site's `appDb` resolves through LDAP:
+>
+> ```
+> gus_home/config/FungiDB/model-config.xml →
+> ldapsearch cn=genomicsdb_devn → dbname=genomicsdb_070n, host=ares13.penn.apidb.org:5432
+> ```
+>
+> i.e. the running site queries **`genomicsdb_070n`** (reachable locally on port **5433**),
+> a different database. Measured there:
+>
+> | | `genomicsdb_rebuild01` (verified against) | `genomicsdb_070n` (what the site queries) |
+> |---|---|---|
+> | `apidb.indel` | 43,585,584 rows | **relation does not exist** |
+> | `study.protocolappnode` `%_Indel` | 6,119 | **0** |
+> | `dots.externalnasequence` | 10,331,542 | 10,168,874 |
+> | `apidb.organism` | 967 | 957 |
+>
+> So the ID query's `EXISTS` over `apidb.indel` will not return zero rows — it will raise
+> `relation "apidb.indel" does not exist`. The search errors rather than coming back empty.
+>
+> **What this does and does not invalidate.** The SQL correctness work stands: the queries were
+> verified against a database that really does hold this data, and `rebuild01` is where the
+> indel load exists. What is *not* established is that any deployed site can run them.
+> Note also that the earlier decision to move development from giardiadb to fungidb ("giardiadb
+> has zero indel rows") was itself made by querying `rebuild01`, so it chose the right *data*
+> but told us nothing about either instance's actual appDb.
+>
+> **Steps 1-2 are unaffected** — they read model metadata, not the appDb.
+>
+> **Resolving it is a decision for John, not a workaround to pick unilaterally.** The options:
+> point this instance's `appDb` at `genomicsdb_rebuild01` (a `model-config.xml` change, which
+> conifer generates from a template, so it is not a one-line edit); stand up or find an
+> instance whose appDb already has the indel load; or wait for `apidb.indel` to reach the
+> current workflow database. Until then Task 7 Steps 3-6, and the deferred `refStart`-boundary
+> QA question, cannot be answered on this instance.
+
+- [ ] **Step 1: Rebuild and confirm registration**
+
+```bash
+bash ~/workspaces/agentic-veupath-dev/bin/veup-build.sh fungidb wb model
+```
+
+Then, from an already-loaded page on `https://jbrestel.fungidb.org` (a raw curl
+307-redirects to autologin), run in the browser console:
+
+```javascript
+await (await fetch('/a/service/record-types/strain-genomic-segment')).json()
+```
+
+Expected: the record type resolves, `searches` contains `StrainSegmentsByRefSegment`, and
+`attributes` lists the nine columns from Task 5.
+
+- [ ] **Step 2: Confirm it is NOT in the category tree**
+
+```javascript
+const t = await (await fetch('/a/service/ontologies/Categories')).json();
+JSON.stringify(t).includes('StrainSegmentsByRefSegment')
+```
+
+Expected: **`false`**. If `true`, something added an ontology node — remove it.
+
+- [ ] **Step 3: Run the search and download BED**
+
+```javascript
+const r = await fetch('/a/service/answer', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ searchName: 'StrainSegmentsByRefSegment',
+ searchConfig: {parameters: {
+ // NO organism param -- the search does not declare one (spec §5.1.1) and WDK
+ // rejects unknown parameters. Five params exactly.
+ strain: '', // from Task 3 Step 2
+ sequenceId: '', // from Task 3 Step 2
+ start_point: '100',
+ end_point_segment: '200',
+ sequence_strand: 'f'
+ }},
+ reportName: 'bed',
+ reportConfig: {attachmentType: 'plain'}
+ })
+});
+console.log(await r.text());
+```
+
+Expected: one tab-delimited BED line where
+
+- column 1 (`chrom`) is `_` — **must** match the FASTA defline exactly;
+- column 2 is `strain_start - 1` (bed6 converts to 0-based);
+- column 3 is `strain_end`;
+- column 4 is the source_id (bare, since `deflineType` defaults to short);
+- column 6 is `+`.
+
+If you get `### The result is empty ###`, one of the four gates rejected the input —
+re-run the Task 3 Step 2 query to find which.
+
+- [ ] **Step 4: Prove the coordinates actually shifted**
+
+Compare the BED output against the reference coordinates you requested:
+
+```bash
+psql -h localhost -p 5439 -d genomicsdb_rebuild01 -c "
+SELECT SUM(CASE WHEN i.location < 100 THEN i.shift ELSE 0 END) AS offset_start
+ , SUM(CASE WHEN i.location <= 200 THEN i.shift ELSE 0 END) AS offset_end
+FROM apidb.indel i, study.protocolappnode pan
+WHERE pan.protocol_app_node_id = i.protocol_app_node_id
+ AND i.na_sequence_id =
+ AND regexp_replace(pan.name,'_Indel\$','') = ''
+ AND i.location <= 200;"
+```
+
+BED column 3 must equal `200 + offset_end`, and column 2 must equal
+`100 + offset_start - 1`. If `offset_start` and `offset_end` are both non-zero and the
+BED coordinates equal the unshifted reference values, the `LEFT JOIN` in Task 4 is not
+matching — that is the bug to chase.
+
+Pick a second case where `offset_start` is `0` but `offset_end` is not (a segment whose
+only indels fall inside it) to confirm the two offsets are computed independently.
+
+- [ ] **Step 5: Check the request logged clean**
+
+```bash
+bash ~/workspaces/agentic-veupath-dev/bin/veup-logs.sh fungidb mark bed1
+# re-run the Step 3 fetch
+bash ~/workspaces/agentic-veupath-dev/bin/veup-logs.sh fungidb since bed1 --quiet
+```
+
+Expected: error logs report `silent:`. Anything in `wdk` or the error logs is a real
+problem even if the BED output looked right.
+
+- [ ] **Step 6: Verify the defline fields work**
+
+Re-run Step 3 with `reportConfig` set to:
+
+```javascript
+{attachmentType: 'plain', deflineType: 'full',
+ deflineFields: ['organism','strain','reference_position','position','segment_length']}
+```
+
+Expected: BED column 4 becomes a pipe-delimited defline carrying organism, strain, both
+coordinate systems, and the length — confirming both `ref_*` and `strain_*` values are
+present and different.
+
+- [ ] **Step 7: Commit any fixes**
+
+```bash
+cd ~/workspaces/fungidb/ApiCommonModel # or ApiCommonWebsite
+git add -u && git commit -m "Fix found in end-to-end verification"
+bash ~/workspaces/agentic-veupath-dev/bin/veup-git-sync.sh fungidb
+```
+
+---
+
+## Task 8: Include UniDB on the existing eQTL genomic-segment search
+
+Independent of everything above, and safe to do first or last. The spec requires UniDB be
+included for **existing** genomic segments as well as the new record.
+
+Background you need: the portal site deploys as tomcat project `EuPathDB` but its **WDK
+model name is `UniDB`**, and `includeProjects` matches the *model* name. So
+`includeProjects="EuPathDB"` never fires on that site. Repo-wide there are 42 files still
+carrying legacy `EuPathDB` in `includeProjects` versus 760 `UniDB` occurrences — **do not
+try to fix them all**, that is a separate cleanup. Only the genomic-segment files are in
+scope, and in those there is exactly one live defect.
+
+Audit of `EuPathDB` in the span model files, so you can see why only one line changes:
+
+| Location | Verdict |
+|---|---|
+| `spanQuestions.xml:346` | inside a commented-out `IsolatesBySpanLogic` block — dead, leave it |
+| `spanQuestions.xml:478` | `includeProjects="PlasmoDB,EuPathDB,UniDB"` — already has UniDB, harmless |
+| `spanQuestions.xml:489` | **live defect** — `attributesList` scoped to `EuPathDB` only, so UniDB gets no summary list |
+| `spanQuestions.xml:634` | an `excludeProjects` that does not name UniDB, so UniDB stays included — which is what we want |
+
+**Files:**
+- Modify: `ApiCommonModel/Model/lib/wdk/model/questions/spanQuestions.xml:489`
+
+- [ ] **Step 1: Confirm the defect before changing it**
+
+```bash
+sed -n '478,495p' ~/workspaces/fungidb/ApiCommonModel/Model/lib/wdk/model/questions/spanQuestions.xml
+```
+
+Expected: question `DynSpansByEQTLtoGenes` includes `UniDB`, but the second
+`` is scoped `includeProjects="EuPathDB"`. Confirm the two
+`` bodies differ only in `sorting` whitespace — if they differ
+substantively, stop and ask, because then the intent was not a simple rename.
+
+- [ ] **Step 2: Add UniDB to the attributesList scope**
+
+Change line 489 from:
+
+```xml
+ **Which database these numbers describe matters, and it is not the one the dev site queries.**
+> `jbrestel.fungidb.org` resolves its `appDb` through LDAP `genomicsdb_devn` →
+> `genomicsdb_070n` (ares13, local port 5433), where **`apidb.indel` does not exist at all**
+> and there are **0** `_Indel` protocol app nodes. Every figure below is from
+> `genomicsdb_rebuild01`, which does hold the indel load. The queries in §5 are therefore
+> verified correct against real data, but have never been executed by a deployed site — see
+> the BLOCKED banner on plan Task 7.
+
+### `apidb.indel` — 43,585,584 rows
+
+```
+indel_id, protocol_app_node_id, na_sequence_id, location, shift (+ GUS housekeeping)
+PK indel_id; ix0 (na_sequence_id, indel_id); ix1 (protocol_app_node_id, indel_id)
+FK -> dots.nasequenceimp, study.protocolappnode
+```
+
+6,249 distinct `protocol_app_node_id`, 20,823 distinct `na_sequence_id`.
+
+**There is no strain column.** Upstream there is — `makeGenomicIndelDb` in
+`dnaseq-nextflow/modules/mergeExperiments.nf` builds
+`genomic_indels(strain, sequence_id, position, shift)` — but the GUS load normalizes
+strain into `study.protocolappnode`, so on the website side strain identity is one join
+away. This is the single most misleading thing about the table and the reason §4.1 exists.
+
+**`location` is the VCF anchor base, not the first affected base.** Established 2026-07-31 by
+reconstructing strain sequence from reference: for each event, the only deletion offsets that
+reproduce the observed consensus start at `location + 1`, never at `location` itself.
+
+| strain | recorded offset in segment | offsets that reproduce the strain |
+|---|---|---|
+| A17-10A-1 | 1207 | **1208**-1212 |
+| E-1-75s-2 | 2492 | **2493**-2495 |
+
+So a deletion covers `location+1 … location+|shift|` and `location` is the last *unaffected*
+base. This **confirms** the `<` / `<=` asymmetry in §5.3: an event whose `location` equals
+`refStart` deletes bases starting at `refStart+1`, i.e. genuinely inside the segment, so it
+must shift the end and not the start — which is what the query does.
+
+Two caveats it also exposes:
+
+1. **Event position is ambiguous within repeat context.** Several offsets reproduce the same
+ consensus (1208-1212 above, a 5-wide window). VCF left-normalizes; aligners tend to place
+ gaps rightmost. Nothing is wrong, but a segment boundary landing inside that window inherits
+ the ambiguity, and no convention we control resolves it. This is the honest answer to the
+ deferred "refStart sits inside the segment" question: for events fully outside or fully
+ inside a segment the arithmetic is exact; for an event *straddling* a boundary the reference
+ base may not exist in the strain at all, so no exact strain coordinate exists to return.
+2. **Only the net shift is meaningful, not the decomposition.** Verified on an insertion region
+ (A17-58A-3, ref 491000-493500): the table records `-1, -6, +31, -1` while a clustalo
+ alignment of the real consensus resolves the same region as `-1, +25, -1`. Both sum to
+ **+23**, and the recorded `-6/+31` pair 40 bp apart is one compound variant the aligner
+ renders as a single `+25`. The prefix sum consumes only the sum, so this is harmless -- but
+ do not expect a one-to-one correspondence between rows here and gaps in an alignment.
+
+`shift` is a **signed per-event delta**, never zero: range −101..+80, with 21,945,521
+negative and 21,640,063 positive rows. The strain offset at a reference position is
+therefore a *prefix sum*, partitioned by `(protocol_app_node_id, na_sequence_id)`.
+
+### `study.protocolappnode` — strain vocabulary
+
+Strain name is `name` with the suffix `_Indel` removed: `A0003_Indel`, `S7_Indel`,
+`A17-48H-7_Indel`, `X10462-P1C9_Indel`.
+
+Verified properties of the 6,119 distinct names appearing in `apidb.indel`.
+
+> **Provenance — this whole table was measured on `genomicsdb_rebuild01`, which is a
+> different and much larger database than the live appDb.** On `unidb_shu_a` (measured
+> 2026-07-31) there are **452** `_Indel` protocol app nodes, **452** distinct names, **0**
+> names mapping to more than one organism, **0** nodes spanning more than one organism, and
+> `(strain, taxon)` resolving to exactly one node in **452 / 452** cases — i.e. none of the
+> ambiguity below is live here yet. Everything downstream is written to be correct under the
+> rebuild01 shape, because that shape is what this data grows into; do not "simplify" a
+> query by citing the live zeroes.
+
+| Property (on `genomicsdb_rebuild01`) | Result | Consequence |
+|---|---|---|
+| contain `:` | **0** | the colon-delimited PK grammar (§3) is unambiguous |
+| end in `_Indel` | **6,119 / 6,119** | the suffix strip is uniform; no special cases |
+| contain any other `_` | **1,494** (24%) | `_` is **not** reversible — see §3.1 |
+| map to >1 organism | **126** (256 nodes) | strain name is **not** globally unique — see §5.2 |
+| `(name, na_sequence_id)` -> >1 node | **0** | sequence-scoped resolution is unambiguous *today* |
+
+**Correction (re-measured 2026-07-31).** An earlier revision of this spec recorded "contain
+any other `_`: 0" as verified fact. It is wrong: 1,494 of the 6,119 names contain an
+underscore (`1_01_01`, `Af293_resequence2`, `China_LZCH-36`, `USGS_28834_1_NV`). The
+`_` FASTA key is therefore a **one-way, opaque** key — correct to build,
+never to split. Anything needing the strain or the sequence back takes it from the primary
+key, where `:` delimits unambiguously (0 names contain `:`). See §3.1 for the consequence
+for `StrainSegmentId`'s pattern, which relied on the false claim.
+
+The last row is a **data-dependent invariant with no constraint enforcing it**, and it is
+the narrow one that matters: all 126 duplicated names *already* resolve to two or more
+protocol app nodes (122 to two, 4 to three) that all carry indel rows today, so the ambiguity
+is live in the data — what keeps it harmless is only that no `(name, na_sequence_id)` pair is
+served by more than one node. §5.2/§5.3
+specify a query shape that turns a future violation of *that* into an error rather than
+silent corruption.
+
+### `webready.genomicseqattributes_p` — reference sequences
+
+`Partition key: LIST (org_abbrev)`. Every unique index is `org_abbrev`-prefixed
+(`seqattr_source_id`, `seqattr_naseqid`, `pk_seqattr_`, `seqattr_taxsrc_id`).
+
+> **Every query against a `webready.*_p` table must constrain `org_abbrev`.** The column
+> is `org_abbrev`, not `organism_abbrev`. Omitting it scans all partitions (831 on
+> `organismabbreviation_p`) and uses no index.
+
+**As it turns out, this design touches no `webready.*_p` table at all** — see §5.1.1. Both
+the ID query and the attribute query resolve sequences through unpartitioned
+`dots.ExternalNaSequence`, so the partition-key rule above never binds. It is recorded
+because it drove an earlier draft and because anyone extending this record needs to know it
+applies the moment they reach for a `_p` table.
+
+`organism -> org_abbrev` comes from `webready.organismabbreviation_p`
+(`organism`, `org_abbrev`, `project_id`, `sanitized_org_abbrev`, `name_for_filenames`).
+
+Note: `apidbtuning.organismattributes` and `apidbtuning.GenomicSeqAttributes` — both
+joined by DynSpan's `Bfmv` query — **do not exist in this database**. Only the
+`webready.*_p` forms do. New queries target `webready.*_p` exclusively.
+
+### Strain consensus FASTA (for §7 context only)
+
+`makeConsensusFastaFromVcfAndBed.py:233` writes deflines as `_`, i.e.
+**`_`**. **Verified against real artifacts 2026-07-31** (`A17-10A-1_consensus.fa.gz`):
+deflines are exactly `>A17-10A-1_Chr1_A_fumigatus_Af293`, and the `chrom` this record emits
+byte-matches them.
+
+**Correction:** an earlier revision inferred from `checkUniqueIds.sh` that the pipeline emits
+**one merged multi-strain FASTA**. The delivered artifacts are **one gzipped file per strain**
+(`_consensus.fa.gz`, 9 deflines each for Af293). The uniqueness check is still
+consistent with that, but the packaging inference was wrong, and §8 should not assume a single
+merged file when seqret wiring is picked up.
+
+## 3. Identity
+
+```
+source_id = ::-:
+ A0003:AACB03000001:100-200:f
+```
+
+Primary key columns: `source_id` and `project_id`, uniformly.
+
+**No `includeProjects` or `excludeProjects` anywhere** — not on the record class, the
+question, the queries, or the PK columns. The record works on every project, UniDB
+included.
+
+This is a deliberate departure from DynSpan, which carries an `excludeProjects="UniDB"` PK
+column *and* duplicated UniDB `` variants (`dynSpanAttributeQueries.xml:16-33`). That
+fork exists because DynSpan derives `project_id` from `@PROJECT_ID@`, which is meaningless
+on the portal. This record instead takes `project_id` from the data — `apidb.organism.project_name`,
+joined on `taxon_id` (an earlier draft of this line named `webready.GenomicSeqAttributes_p.project_id`;
+§5.1.1 explains why the implementation uses the unpartitioned `apidb.organism` instead) — so it is
+populated correctly on every project including UniDB and needs no fork. One query, one PK, no variants.
+
+Note this is separate from the requirement to add UniDB to the **existing**
+genomic-segment `includeProjects` lists where absent — that concerns pre-existing
+searches, not this record.
+
+Coordinates in the PK are **reference** coordinates — the input, not the output. Strain
+coordinates are derived in the attribute query (§5.2). Rationale:
+
+- Provenance: the defline can report both what was requested and what was returned.
+- A reload of the dnaseq indel data is picked up automatically; nothing recomputes stale.
+- `(strain, refSeq)` resolves to exactly one `protocol_app_node_id` (§2), so
+ strain-in-the-PK is sufficient to validate strain against the indel vocabulary, which
+ is the stated requirement.
+
+### 3.1 Parse the grammar once
+
+DynSpan implements its ID grammar **five** times — SQL `regexp_substr` at
+`dynSpanAttributeQueries.xml:66-71` and `spanQueries.xml:51,62`, SQL `CONCAT`
+construction at `spanQueries.xml:120,197,275,396`, a Java regex at
+`DynSpanFeatureProvider.java:18`, and construction at `GffSpanDatasetParser.java:66`.
+The SQL and Java parsers **disagree**: SQL takes the first colon token as the sequence
+ID, the Java regex is greedy (`^(.*):(\d+)-(\d+):(f|r)$`) and takes the last two tokens
+as range and strand. A sequence ID containing a colon parses differently on each side.
+
+This spec adds **one** Java class, `StrainSegmentId`, with `parse`/`format`, used by the
+feature provider. On the SQL side use `split_part(source_id, ':', N)`, not positional
+`regexp_substr` — it does not silently renumber fields when one is empty. Strain names
+contain `-` (`A17-48H-7`), which is safe only because the range is its own colon field;
+the range must never be parsed out of the whole string positionally.
+
+**As implemented** (commits `19defeba2`, `3ead3adac`, corrected by `65126443a`):
+
+```
+^([^:]+):([^:]+):(\d+)-(\d+):(f|r)$
+```
+
+Every field is `[^:]+`, not DynSpan's greedy `(.*)`, so an ID carrying an extra colon is
+**rejected** rather than mis-parsed into a different segment. `':'` is the only excluded
+character because it is the only delimiter.
+
+**Defect found and fixed in review — the strain group was originally `[^:_]+`.** The `_`
+exclusion was added on the belief that no strain name contains an underscore ("0 of
+6,119"), as inert insurance against an ambiguous `_` FASTA key. That belief
+was **false**: 1,494 of the 6,119 strain names contain an underscore (§2, re-measured
+2026-07-31), so `parse()` was silently rejecting roughly a quarter of all legitimate strain
+segment IDs — e.g. `Af293_resequence2:Chr1_A_fumigatus_Af293:1-100:f`. Fixed in
+`65126443a`, with regression tests over the real underscore-bearing names; those five tests
+fail against the old pattern and pass against the new one.
+
+The lesson is worth keeping, because the exclusion could not have worked anyway: narrowing
+the grammar cannot make `_` reversible, since reference sequence IDs contain
+underscores too (`Chr1_A_fumigatus_Af293`), so `A_B`+`C` is indistinguishable from
+`A`+`B_C`. The real defense is never reversing the key — see §3.1.
+
+ The ambiguity the exclusion was meant to prevent is real (strain `A_B` + sequence `C` and
+ strain `A` + sequence `B_C` both yield the key `A_B_C`) but cannot be prevented by
+ narrowing the grammar, because both halves genuinely contain `_`. It is instead avoided by
+ **never reversing the key**: `strain_seq_id` is opaque output only, and every consumer that
+ needs the strain or the sequence reads them from the primary key's `:`-delimited fields
+ (§2, §5.3). Verified 0 actual key collisions in today's data; if one ever appears it is a
+ data problem to detect, not a grammar to tighten.
+
+Validation lives in the private constructor, not in `parse()`, so that any later
+from-parts factory cannot bypass it. It rejects `refStart < 1` (a 0 start would reach
+`BedLine.locationToZeroBased()` and emit `chromStart = -1`, a malformed BED line),
+`refEnd < refStart`, and any strand other than forward or reverse.
+
+## 4. Where DynSpan gets validation wrong
+
+Recorded because the new search must not repeat it.
+
+| Location | Behaviour |
+|---|---|
+| `spanQueries.xml:10-22` (`DynSpansBySegIds`) | webservice-only; **zero** validation. Any string becomes a record. |
+| `spanQueries.xml:48-67` (`DynSpansBySourceId`) | only existence check anywhere; **silently drops** bad rows. Does not check organism membership or sequence length. Its organism/chromosome params appear in no `WHERE` clause — they are form scaffolding. |
+| `dynSpanAttributeQueries.xml:74-75` (`Bfmv`) | `LEFT JOIN` seq attrs then `INNER JOIN` organism attrs, so an unknown sequence yields a null organism that the inner join deletes. Attributes vanish; no error. |
+| `DynSpanFeatureProvider.java:46-48` | the **only** place that throws — at download time, after the search succeeded. |
+
+## 5. Model changes (`ApiCommonModel`)
+
+### 5.1 Search: `StrainSegmentId.StrainSegmentsByRefSegment`
+
+Input is a **single reference location**, modeled on `DynSpansByLocation`
+(`spanQueries.xml:266`), not a `datasetParam` — so `SpanParams.span_id` and its
+`recordClassRef` to DynSpan are not involved.
+
+**Caveat on that precedent, verified 2026-07-31:** `DynSpansByLocation` is *only* an
+``. No `` wraps it, and nothing *consumes* it across
+ApiCommonModel, ApiCommonWebsite, ApiCommonWebService, EbrcModelCommon or
+EbrcWebsiteCommon — it is a dead id query. (It is not literally unmentioned: the
+`Model/vp2TuningTablesEffort` inventories name it twice, and `tableUsageMap.json` records
+`"SpanId.DynSpansByLocation": []` — an empty usage list, which independently corroborates
+that nothing uses it.) So "modeled on `DynSpansByLocation`" means
+modeled on a query **no question has ever exercised**: the param shape below has no live
+precedent and inherits no operational confidence. Worth knowing when the params
+misbehave — there is no working sibling to diff against.
+
+Params: `strain`, `sequenceId`, `start`, `end`, `strand`. **There is no organism param.**
+
+#### `strain` is multi-pick: one search, N records
+
+`strain` is a `flatVocabParam` with `multiPick="true"` and `maxSelectedCount="500"`, so a
+single search takes **one or more** strains and returns **one record per strain** — the same
+reference interval expressed in each strain's coordinates. Five strains is one search
+producing five BED lines, not five searches producing one each.
+
+The change is confined to the param and this ID query. Everything downstream was already
+written against a *set* of primary keys: §5.3's `Coords` keys on `source_id` and parses the
+strain out of each PK, and §7's `BedReporter` streams one line per record.
+
+The ID query crosses the (single-row) sequence side with the unnested strain list:
+
+```sql
+FROM ( ...sequence derived table... ) seg
+ , unnest(ARRAY[$$strain$$]) AS st(strain)
+```
+
+and builds the PK from `st.strain`. Every gate below is then evaluated **once per strain**,
+unchanged in meaning.
+
+Three things make this shape the right one rather than the obvious alternatives:
+
+- **`quote="true"` is what makes `ARRAY[...]` work, and is not optional.**
+ `EnumParamHandler.toInternalValue` quotes each internal value and joins them with
+ `DBPlatform.prepareExpressionList`, which on PostgreSQL is a plain comma join. So
+ `$$strain$$` renders as `'A17-10A-1','A17-3C-11',…` — exactly an `ARRAY` element list.
+ Verified against the running service, not assumed.
+- **The gate stays an equality, so `pan_named_ix` stays an Index Only Scan.** Rewriting
+ gate 3 as `regexp_replace(pan.name, '_Indel$', '') IN ($$strain$$)` would put a function on
+ `pan.name` and force a scan — the same reason the single-strain version appended the suffix
+ rather than stripping it. `EXPLAIN ANALYZE` on `unidb_shu_a` confirms an Index Only Scan on
+ `pan_named_ix` with one index search per strain.
+- **A failing strain is dropped, not fatal.** Because the gates are per (sequence, strain)
+ pair, a strain belonging to another organism is simply absent from the result while its
+ companions still return — the existing "bad input yields zero rows" contract applied per
+ row. Verified: `C044` (a *P. falciparum* strain, in the vocabulary) plus two *A. fumigatus*
+ strains against `Chr1_A_fumigatus_Af293` returns 2 records, no error. A strain that is not
+ in the vocabulary at all never reaches the SQL: WDK rejects it at param validation with a
+ 422, exactly as it did when the param was single-pick.
+
+**`maxSelectedCount="500"` is a correctness rail, not a performance one.** It sits above the
+entire vocabulary as it stands (452 strains on `unidb_shu_a`, live, measured 2026-07-31), so
+it refuses nothing a user can ask for today — selecting every strain still works. It sits
+*below* Oracle's `EXPRESSION_LIMIT` of 999 (FgpUtil `Oracle.java`), past which
+`prepareExpressionList` stops emitting a comma list and starts emitting
+`SELECT * FROM table(SYS.DBMS_DEBUG_VC2COLL(...)) UNION …`, which `ARRAY[...]` cannot
+consume. UniDB is PostgreSQL and so is not exposed today; the cap makes that cliff
+unreachable by construction rather than by luck of how large the vocabulary happens to be.
+It also bounds the reject path, whose cost is linear in (strains × that strain's indel rows).
+`allowEmpty` stays at its default `false`, so WDK rejects an empty selection during
+validation and the untypeable `ARRAY[]` is never generated.
+
+Cost on `unidb_shu_a`, 2026-07-31, `Chr1_A_fumigatus_Af293` 396000–399000:
+
+| selection | planner cost | wall | rows |
+|---|---:|---:|---:|
+| 1 strain, accept | 238.24 | ~0.35 ms | 1 |
+| 5 strains, accept | 939.65 | ~0.61 ms | 5 |
+| all 452, accept | — | ~1.9 s | 232 |
+| 1 strain, reject | — | ~4.9 ms | 0 |
+| 5 strains, reject | — | ~22.5 ms | 0 |
+
+The accept path is roughly flat in strain count; the reject path is linear in it. That is
+the same asymmetry the single-strain query already had (§5.1.1), just multiplied. Five
+strains in one search is cheaper than five separate searches, which would repeat the
+sequence lookup and the WDK query-instance overhead five times.
+
+### 5.1.1 Why no organism input
+
+An earlier draft took an organism as a first param, used it to prune the `org_abbrev`
+partition key and to check that the reference sequence belonged to it. That was redundant
+input: **a reference sequence ID already determines its organism.** Being handed an
+organism as well only creates a consistency question the query then has to answer.
+
+Dropping it removes three problems at once:
+
+- **No partition-key concern.** Resolve the sequence through unpartitioned
+ `dots.ExternalNaSequence` (carries `na_sequence_id`, `taxon_id`, `length`; `source_id`
+ uniqueness is enforced by `source_id_uniq` on the **base table** `dots.nasequenceimp` —
+ `ExternalNaSequence` is a view, so it holds no index of its own. The guarantee is
+ therefore stronger than "unique among external sequences": it is unique across all of
+ `nasequenceimp`, which is what makes "one input location yields one record" structural
+ rather than a data accident. Measured: 10,331,542 rows, 0 duplicate non-null `source_id`,
+ the only gap being 17 NULLs that can never match `= $$sequenceId$$`.) rather than `webready.GenomicSeqAttributes_p`. Same table path §5.3 already
+ uses, so the two queries become consistent instead of divergent.
+- **No `@PROJECT_ID@`.** `project_id` for the PK comes from `apidb.organism.project_name`
+ via `taxon_id` — unpartitioned, correct on every project, still one query.
+- **No vocabulary-shape trap.** See the note below; this is the defect that prompted the
+ redesign.
+
+> **The trap, recorded because it cost a round of rework.** With an organism param, the
+> obvious `org_abbrev IN ($$organismSinglePick$$)` is wrong. That param defaults to
+> `organismVQ.withGenes` (`organismParams.xml:677`), a *tree* vocabulary projecting
+> `string_agg(fq.abbrev, ', ') AS internal` grouped by `term, parentTerm` — so a leaf term
+> gives one abbrev but any grouping node gives `'afumAf293, afumA1163'`. Quoted, that is a
+> single literal matching **nothing**: verified, `org_abbrev IN ('afumAf293, afumA1163')`
+> returns 0 rows, with no error and no log line. Most of the model dodges this by overriding
+> `queryRef` to a flat abbrev vocabulary, but every such vocabulary carries
+> `includeProjects`/`excludeProjects`, which this record may not have. Not taking an
+> organism at all sidesteps the whole question.
+
+**Five** gates, all as filters so bad input yields **zero records** rather than a broken
+download. This numbering is mirrored verbatim in the header comment of
+`strainSegmentQueries.xml`; renumber both or neither.
+
+1. the reference sequence exists — `dots.ExternalNaSequence.source_id = $$sequenceId$$`;
+2. `1 <= refStart <= refEnd <= ens.length`;
+3. the strain has indel data somewhere on **this sequence's organism** — an `EXISTS` over
+ `apidb.indel` joined to `study.protocolappnode` and back to `dots.ExternalNaSequence`,
+ matched on `taxon_id` (**not** on `na_sequence_id`; see "Gate 3 is organism-level" below);
+4. the reference sequence ID contains no `':'` — `seg.ref_seq NOT LIKE '%:%'`;
+5. the sequence's `taxon_id` has an `apidb.organism` row — the *inner* join that also
+ supplies `project_id`. It belongs in this list because it filters: a sequence whose
+ `taxon_id` has no `apidb.organism` row yields no record at all. That is intended — such
+ a sequence sits outside every project and so has no `project_id` to key a record on —
+ and it must not become a `LEFT JOIN`, since a NULL `project_id` would mint a malformed
+ primary key.
+
+Gate 4 exists because `':'` is the only delimiter in the minted primary key, so a
+colon-bearing `source_id` yields an ID that §5.3 mis-parses and then dies on
+(`'ncRNA'::integer` aborts the *entire* attribute query, every row on the page). On
+`genomicsdb_rebuild01` — a **different, larger database**, not the live appDb — 10,704
+`dots.ExternalNaSequence` source_ids contain a colon (`bld68_Tb927.1.05:mRNA` and similar
+transcript-level features) while **0** of its 20,823 indel-bearing sequences do. On
+`unidb_shu_a` (live, measured 2026-07-31) it is **0 of 160,581** source_ids, with 120
+indel-bearing sequences. Either way the gate is unreachable today, which is exactly why it
+is worth having: the grammar's soundness should not rest on which features happen to get
+indel-called. Verified the gate costs nothing real — it refuses 0 indel-bearing sequences
+and all **9** Af293 sequences (8 chromosomes plus `mito_A_fumigatus_Af293`) still pass.
+
+Note what gate 4 does **not** cover: it is sequence-side only. `st.strain` is concatenated
+into the PK ahead of the first colon and is never colon-checked. That field is safe because
+the `flatVocab` it comes from holds no colon-bearing value (0 of 452 on `unidb_shu_a`,
+0 of 6,119 on rebuild01) — a property of the data, not of the SQL.
+
+Gate 3 subsumes the organism check the earlier draft did explicitly: if the strain has indel
+rows against that sequence's organism, strain and sequence are consistent **by
+construction**. That is a stronger guarantee than validating against a user-supplied
+organism, and it is what the original requirement ("validate the reference sequence against
+the reference organism") actually wanted.
+
+#### Gate 3 is organism-level, and that is deliberate
+
+**Corrected 2026-07-31 after end-to-end QA.** Gate 3 originally matched on
+`na_sequence_id`, so it did double duty: it proved strain/sequence consistency (its job,
+and the reason §5.1.1 can drop the organism param) *and* it required indel data on that
+exact contig (not its job, and wrong).
+
+The second requirement is wrong because **zero indels on a contig is a valid, ordinary
+state**, not a missing-data state. It means the strain matches the reference there: the
+strain coordinates equal the reference coordinates, `StrainSegmentAttributes.Coords`
+already returns exactly that identity mapping via its `LEFT JOIN` + `COALESCE(..., 0)`
+(no change needed there — verified against a zero-indel PK, which yields `100-200 ->
+100-200` with the correct `strain_seq_id`), and the contig **is** present in that strain's
+consensus FASTA. Refusing the request produced `### The result is empty ###` for a request
+the data fully supports.
+
+Scale of the defect: **689 of the 6,068 valid strain/sequence pairs (11%)** in `unidb_shu_a`
+were refused this way. The case that surfaced it: `A17-10A-1` + `mito_A_fumigatus_Af293`
+returned empty, yet `>A17-10A-1_mito_A_fumigatus_Af293` is present in
+`A17-10A-1_consensus.fa.gz`.
+
+So the gate now matches on `ens2.taxon_id = seg.taxon_id`. Stated plainly:
+
+| | |
+|---|---|
+| What gate 3 **now proves** | this strain was sequenced against this organism, so the strain name and the reference sequence are mutually consistent and the minted PK is meaningful |
+| What it **no longer proves** | this strain has indel data on this exact contig — which was never a precondition for a valid request |
+
+Weakening it is safe because the substitution is exact, not approximate (re-verified
+against `unidb_shu_a`):
+
+- `(strain name, taxon)` resolves to **exactly one** `protocol_app_node` — 452 / 452 pairs,
+ zero exceptions. Organism scoping is therefore no less specific than node scoping.
+- **0** `protocol_app_node_id` values span more than one organism.
+- It remains scoped by organism, never by strain name alone. On `unidb_shu_a` that scope is
+ currently free — all 452 `_Indel` nodes carry 452 distinct names and **0** names span more
+ than one organism — so the "126 strain names map to more than one organism" figure quoted
+ in §2 is a `genomicsdb_rebuild01` measurement, **not** a live one. The taxon scope is what
+ keeps this query correct whenever that situation recurs here, and the cross-organism case
+ still returns zero rows today (`A17-10A-1` + `Pf3D7_01_v3`).
+
+**Cost, both paths** (`EXPLAIN ANALYZE` on `unidb_shu_a`, 2026-07-31). The earlier claim
+"0.35 ms, no sequential scan" measured only the accept path and was wrong in detail:
+
+| Path | Old (exact-sequence gate) | New (organism gate) |
+|---|---|---|
+| **Accept** (`A17-48H-7` + `mito_A_fumigatus_Af293`) | — | **~0.33 ms**, `EXISTS` early-exits on the first matching indel row via `indel_ix1` |
+| **Reject**, largest strain (`A17-48H-7`, 108,091 indel rows, vs `Pf3D7_01_v3`) | ~53 ms | **~101 ms** (≈2×) |
+| **Reject**, smallest strain (`C044`, 4 indel rows) | — | **~0.33 ms** |
+
+Two corrections to the old claim. (i) "No sequential scan" is false in detail: the plan
+contains a `Seq Scan on organism o` (5 rows) — harmless, but it is there. (ii) The reject
+path is where this change costs something, and it is unavoidable: a *false* `EXISTS` must
+exhaust every indel row the strain owns, so the cost scales with **the strain's row count**,
+not the database's. Reviewers measured the same reject at 79–99 ms new vs 8.9–56 ms old
+depending on the plan chosen (2–9×). On a 43.5M-row database the worst case would be
+substantially larger.
+
+> **Rejected optimisation: do not rewrite the `EXISTS` as a `LIMIT 1` scalar subquery.**
+> Resolving the strain to a single `protocol_app_node` and comparing its organism directly
+> would make the reject path O(1). It was considered and **deliberately rejected**: it is
+> correct only while "no `protocol_app_node_id` spans more than one organism" holds, and if
+> that ever breaks it silently picks an arbitrary organism and admits or refuses the wrong
+> pairs, with no error anywhere. `EXISTS` degrades to *slow* rather than to *wrong*, which
+> is the trade this record wants. Revisit only with a schema constraint enforcing the
+> one-node-per-(strain, organism) invariant.
+
+> **The one assumption a future reader must re-check** (also listed in §10). Organism-level
+> gating admits every contig of the organism, so the minted `chrom` is guaranteed to exist
+> in the strain's consensus FASTA only while **a strain set covers every contig of its
+> organism's reference**.
+>
+> **Frame it as set equality, not as a one-way pipeline omission.** The invariant is that
+> the taxon's `dots.ExternalNaSequence` rows and the consensus deflines are the *same set*.
+> It can drift from either side:
+>
+> - *FASTA side* — the consensus pipeline omits a reference contig. The gate then mints an
+> ID whose `chrom` has no FASTA entry.
+> - *DB side* — a **non-genomic** sequence gains a row under a strain-bearing taxon. It is
+> then a contig of the organism as far as gate 3 is concerned, but was never a consensus
+> target. The gate-4 paragraph above supplies the live example of the shape:
+> `bld68_Tb927.1.05:mRNA`, a *T. brucei* transcript-level `source_id` — 10,704 such rows
+> exist on `genomicsdb_rebuild01`. **Gate 4's colon filter is currently the only thing
+> stopping those**, and it stops them by an accident of naming (transcript-level IDs
+> happen to carry a colon), not by any check on what kind of sequence it is. A
+> non-genomic row without a colon in its `source_id` would pass every gate.
+>
+> **Verification (2026-07-31), done on the organism where the risk actually lives.** Af293
+> alone would have been the easy case; the check was run on three organisms, chosen so that
+> one of them has contigs no strain has ever indeled:
+>
+> | Organism | Pairs admitted | Newly admitted | …of those, on contigs **no strain ever indeled** |
+> |---|---|---|---|
+> | *P. falciparum* 3D7 | 3,456 | 381 | **0** |
+> | *A. fumigatus* Af293 | 2,088 | 65 | **0** |
+> | *T. brucei* TREU927 | 524 | 243 | **144** |
+>
+> TREU927 is the case that could have broken: it has **131** sequences in the DB, **36** of
+> them never indeled by any strain, and organism scoping newly admits **144** pairs sitting
+> on exactly those 36 contigs — pairs the old gate refused and for which there is no indel
+> evidence at all that the contig was sequenced. So the FASTAs were checked directly.
+> `STIB247_consensus.fa.gz`, `gambiense_consensus.fa.gz` and
+> `TREU927_resequence1_consensus.fa.gz` each carry **131** deflines, and set-comparing each
+> one's `` fields against the DB's 131 `source_id`s gives **0 missing in either
+> direction** — in particular **0 of the 36 never-indeled contigs is absent from any of the
+> three FASTAs**. The invariant holds on the organism that would have exposed it, not merely
+> on the one that could not.
+>
+> If a future pipeline ever dropped a contig from the consensus, or a non-genomic row landed
+> under a strain-bearing taxon, this gate *would* mint an ID whose `chrom` is absent from the
+> FASTA, and gate 3 would need a per-strain contig manifest rather than a taxon match.
+
+Gate 4 becomes slightly more load-bearing under organism scoping, since it can no longer
+rely on colon-free-ness being a property of the *indel-bearing* subset. It still refuses
+nothing real: in `unidb_shu_a` **0** sequences reachable through gate 1+2 contain a colon
+(the 10,704 figure quoted *above*, in the gate-4 paragraph, was measured on
+`genomicsdb_rebuild01`, a different database), and all 9 Af293 contigs pass.
+
+Strain membership itself is enforced by WDK, which validates a `flatVocabParam` value
+against its vocabulary — that is what makes strain names a controlled vocabulary sourced
+from `apidb.indel`, as required.
+
+### 5.2 Strain vocabulary (global, not organism-dependent)
+
+With no organism param there is nothing to depend on, so the vocabulary is global:
+
+```sql
+SELECT strain AS internal, strain AS term, strain AS display
+FROM (
+ SELECT DISTINCT regexp_replace(pan.name, '_Indel$', '') AS strain
+ FROM (SELECT DISTINCT protocol_app_node_id FROM apidb.indel) x
+ , study.protocolappnode pan
+ WHERE pan.protocol_app_node_id = x.protocol_app_node_id
+ AND pan.name LIKE '%\_Indel'
+) t
+ORDER BY strain
+```
+
+**6,119 strains in 2.9s**, measured 2026-07-31. Cached once globally rather than once per
+organism, so cheaper in aggregate than the per-organism version it replaces (2.66s each).
+
+Two shape details carried over from review, both load-bearing:
+
+- Dedupe on the numeric `protocol_app_node_id` (6,249 values) **before** computing
+ `regexp_replace`, not after. Deduping on the regexp'd string instead runs the function
+ across all 43.6M indel rows and hash-aggregates text keys (timings and row counts here are
+ `genomicsdb_rebuild01`; the live `unidb_shu_a` holds 1,855,449 indel rows over 1,356
+ `protocolappnode` rows, so the same shape is simply cheaper) — measured 12,085ms versus
+ 2,681ms for identical output.
+- `pan.name LIKE '%\_Indel'` makes the suffix invariant executable rather than merely
+ documented; `regexp_replace` silently passes a non-conforming name through, which would
+ become a bogus strain option. The backslash escape matters — `_` is a single-character
+ wildcard in `LIKE`.
+
+This is the controlled vocabulary required by the brief: strain names come from
+`apidb.indel` (via `study.protocolappnode`, which is where the name actually lives). No
+tuning table exists for it. Scoping to a *relevant* strain is not the vocabulary's job —
+gate 3 of §5.1 rejects a strain with no indel data on the requested sequence's **organism**.
+(It does *not* require data on that exact sequence; see §5.1.1.) Because the param is
+multi-pick, that rejection is per strain: irrelevant strains drop out of a mixed selection
+rather than failing the search.
+
+### 5.3 Attribute query: `StrainSegmentAttributes.Coords`
+
+Emits eleven columns. Derived: `strain_seq_id`, `strain_start`, `strain_end`,
+`strain_length`, `organism`. Passed through from the primary key so that consumers never
+re-parse it: `source_id`, `project_id`, `strain`, `ref_seq`, `ref_start`, `ref_end` — §7's
+defline needs the strain and the reference range, and decomposing the PK in exactly one
+place is the whole point of §3.1.
+
+`strain_seq_id` is `strain || '_' || refSeq` — pure concatenation, matching the FASTA
+key (§2), and **opaque**: build it, never split it (§3.1). Offsets in one index-assisted
+pass per `(strain, sequence)`, bounded by `location <= ref_end`:
+
+```sql
+SUM(CASE WHEN i.location < seg.ref_start THEN i.shift ELSE 0 END) AS offset_start,
+SUM(i.shift) AS offset_end
+```
+
+Only the *start* offset needs conditional aggregation. The end bound
+`i.location <= seg.ref_end` is in the `WHERE`, so every surviving row is in scope for the
+end sum; an earlier revision wrote a mirrored
+`CASE WHEN i.location <= seg.ref_end` whose `ELSE` branch was unreachable (verified 0 rows
+disagreeing) and which made the boundary asymmetry appear to live in two places instead of
+one.
+
+**Resolve `protocol_app_node_id` via `(name, na_sequence_id)` and `GROUP BY` the
+resolved node — never join on name alone.** All 126 duplicated strain names (§2) *already*
+have two or more protocol app nodes (122 to two, 4 to three), all carrying indel rows
+today, so the collision is live in the
+data; what keeps it harmless is the narrower invariant the `i.na_sequence_id =
+ens.na_sequence_id` join depends on — **no `(name, na_sequence_id)` pair is served by two
+nodes (0 today)**. Grouping by node makes a violation of that produce two rows per primary
+key, which WDK rejects with an error. Joining on name alone would sum two strains' shifts
+into one plausible-looking wrong answer with nothing to detect it.
+
+**Boundary rule (to be QA'd, per §9):** an indel exactly at `refStart` lies inside the
+segment, so it shifts the end but not the start — hence `<` for start and `<=` (as the
+`WHERE` bound) for end. An off-by-one here is silent and yields sequence that looks correct.
+
+### 5.3.1 Group and join on the primary key, not on coordinates
+
+An earlier implementation grouped the offsets subquery on `(strain, ref_start, ref_end)`
+and joined on the same triple. **That silently corrupted every multi-record answer set.**
+One `protocol_app_node_id` spans *all* of a strain's sequences (one Af293 node carries
+indels on all 8 chromosomes), so two segments of the same strain on different sequences
+collapsed into a single group whose `SUM` spanned both, and the join handed that conflated
+row to both records. Measured on two 1-100000 segments of `NRZ-2016-071`, whose true
+offsets are 12 (Chr1) and 14 (Chr2): both reported `strain_end = 100026`, i.e. 100000 + 26.
+No error, no duplicate row, and `strain_seq_id` stayed correct — so the BED line pointed at
+the right contig with another chromosome's indel budget applied to its coordinates. A
+single-record test cannot see this; a reporter over a multi-segment result set is the
+normal case.
+
+Adding `ref_seq` to the grouping would fix the symptom. Instead **group and join on
+`source_id`** — the whole primary key — so "one row per input PK" holds by construction
+rather than by an argument about which coordinate fields happen to discriminate. Verified
+after the change: 100012 and 100014 respectively, and a third id differing only in strand
+yields 3 rows rather than 6 (strand correctly does not discriminate, since offsets do not
+depend on it).
+
+`i.protocol_app_node_id` stays in the `GROUP BY` alongside `source_id`, still absent from
+the `SELECT` — that is the collision detector described above, and it is now the only
+grouping column that is not the PK.
+
+`##WDK_ID_SQL##` is expanded **once**, into a `WITH parsed AS (...)` CTE; `ids` adds the
+resolved `dots.ExternalNaSequence` row to it and is referenced twice. Precedent:
+`transcriptAttributeQueries.xml:209`. Postgres materializes `ids` (confirmed: a single
+`CTE ids` node with two `CTE Scan` references), so the id set is scanned once and the four
+`split_part` expressions exist in one place.
+
+Three further structural rules, each guarding a silent-wrong-answer mode:
+
+- **`ids` resolves the reference sequence, and is the *only* place that does.** It therefore
+ *filters* as well as projects — an unknown `ref_seq` drops the row here, which is exactly
+ the "unknown sequence yields no record" rule. The offsets subquery originally repeated the
+ same `dots.ExternalNaSequence` lookup (a second index scan per PK, and the same rule
+ enforced in two places); it now consumes `ids.na_sequence_id`.
+- **The offsets are summed over a `DISTINCT` id set (`segs`), not the raw one.** Scanning the
+ raw set makes the sums proportional to input multiplicity: a repeated `source_id` in
+ `##WDK_ID_SQL##` joins each indel row once per duplicate and `GROUP BY source_id` collapses
+ them into one group whose `SUM` is multiplied. Demonstrated before the fix — PK
+ `366.1:Pf3D7_10_v3:331757-331757:f` supplied twice yielded offsets −198/−306 instead of
+ −99/−153, silently. Not reachable through today's ID query, but keying on the PK is
+ supposed to make one-row-per-PK true *by construction*, not by an argument about the input.
+ Regression test: the same PK twice must yield 2 rows with the single-copy offsets.
+- **`strain_length` is computed in an outer `SELECT` from the named `strain_start` /
+ `strain_end` columns**, because a `SELECT` cannot reference its own aliases and the offset
+ arithmetic would otherwise be written twice. A partial edit to one copy would yield a
+ length that disagrees with its own start and end.
+
+### 5.3.2 A segment inside a deletion inverts, deliberately
+
+The boundary asymmetry means a deletion at `refStart` shifts the end but not the start, so
+a short segment lying inside a deletion produces `strain_end < strain_start`. This is
+reachable with ordinary input, not a torture case: `apidb.indel` has 180,998 rows with
+`shift <= -20` and the ID query permits `start = end`. Real example — PK
+`366.1:Pf3D7_10_v3:331757-331757:f`, a 1-bp segment on a `shift = -54` event, gives
+`strain_start = 331658`, `strain_end = 331604`.
+
+That arithmetic is *correct*: the reference base does not exist in that strain, so the
+range maps to nothing. So:
+
+- `strain_start` and `strain_end` report the true, possibly inverted values — **not**
+ clamped, because the reporter needs to see the inversion;
+- `strain_length` is `GREATEST(..., 0)`, since a negative length is not a defensible
+ attribute value;
+- `StrainSegmentFeatureProvider` (§7) **MUST** reject `strain_end < strain_start` with an
+ explicit error. That is the right place for it — a malformed BED interval must fail loudly
+ rather than reach a FASTA lookup. **Implemented** in `ApiCommonWebsite` `a3c062e03`, hardened
+ in `e5346e9c4`. The guard is now three conditions, and leaving `strain_start`/`strain_end`
+ unclamped depends on all three:
+ 1. `strain_end < strain_start` — the deletion inversion this section is about;
+ 2. `strain_start < 1` — would emit `chromStart = -1` via `BedLine.locationToZeroBased`;
+ 3. the `strain_seq_id` attribute must equal the key `StrainSegmentId` computes from the
+ primary key — a tripwire for any future rewrite of that column, since a divergence would
+ otherwise be a BED line naming the wrong contig with no error.
+
+ All three throw `WdkModelException` naming the primary key and the offending values, before
+ `DeflineBuilder` or `BedLine.bed6` sees them; `BedReporter.write` does not swallow it, so the
+ download aborts. Covered by `StrainSegmentFeatureProviderTest` (10 tests), including the live
+ case `366.1:Pf3D7_10_v3:331757-331757:f`. **If that guard is ever removed, these columns must
+ be clamped instead.**
+
+## 6. Record class (`ApiCommonModel`)
+
+`StrainSegmentRecordClasses.StrainSegmentRecordClass`,
+`urlName="strain-genomic-segment"`, `doNotTest="true"`, **`useBasket="false"`**.
+
+`useBasket="false"` is required, not cosmetic: `RecordClass.useBasket` defaults to `true`
+(`RecordClass.java:303`), and `WdkModel.addBasketReferences` (`WdkModel.java:646-652`)
+then injects `..._RealtimeBasket` / `..._SnapshotBasket` questions and their `user_baskets`
+id queries, while `RecordClassFormatter.java:75` reports `useBasket` to the client so the
+results table offers basket affordances. Matches every other internal record class here
+(`fileRecord.xml:4`, `userfileRecords.xml:4`, `ajaxRecords.xml:16,35`); DynSpan leaving the
+default alone is not a counterexample, since DynSpan is user-facing.
+
+Reporters: **`bed` only** (plus WDK's unavoidable default JSON reporter — see below).
+Attributes: a `columnAttribute` for each of the nine non-primary-key columns §5.3 emits —
+the passed-through `strain`, `ref_seq`, `ref_start`, `ref_end` included, since §7's defline
+consumes them — plus the `idAttribute` over the `source_id`/`project_id` primary key. No
+tables.
+
+**What "no record page" does and does not mean.** It is an intent, not something WDK
+enforces. WDK unconditionally injects a `_default` summary view
+(`RecordClass.java:1306` → `SummaryView.createSupportedSummaryViews`), a `_default` record
+view displayed as "Overview" (`RecordClass.java:1346` →
+`RecordView.createSupportedRecordViews`), and `DefaultJsonReporter`
+(`RecordClass.java:1137-1139`). The nine `columnAttribute`s are ordinary *display*
+attributes — they carry `displayName` and are not `internal="true"`, and they must stay
+that way because the BED reporter reads them. The real basis for "non-user-facing" is
+narrower and sufficient: no tables, no reporters beyond `bed`, no ontology rows, so nothing
+wires up a record page. A record-page request is not a supported operation and is not
+verified to degrade gracefully.
+
+Register the new files in `Model/lib/wdk/apiCommonModel.xml`, alongside the existing
+span imports at lines 419-423.
+
+### 6.1 Keeping it non-user-facing
+
+`Model/lib/wdk/ontology/individuals.txt` is a 14-column TSV:
+
+| col | meaning |
+|---|---|
+| 1 | full node name |
+| 4 | `recordClassName` |
+| 5 | `targetType` (`search` / `table` / `attribute` / `dataset`) |
+| 6 | `name` (e.g. `SpanQuestions.DynSpansBySourceId`) |
+| 12-14 | three `scope` columns |
+
+Scope combinations across the 202 `targetType=search` rows, measured 2026-07-31:
+
+| scopes | count |
+|---|---|
+| `menu` `webservice` | 95 |
+| `internal` `webservice` | 42 |
+| *(none)* | 27 |
+| `webservice` | 17 |
+| `menu` | 15 |
+| `internal` | 6 |
+
+**Decision: add no row at all for the new search.** Two mechanisms could hide it, and
+omission is the right one here:
+
+- **Omission** — `DynSpansBySegIds` (`spanQuestions.xml:15`, commented "SegIds only
+ WEBSERVICES" at `spanQueries.xml:7`) is absent from `individuals.txt` and the model
+ builds. A question stays addressable by name through the service API regardless of
+ ontology presence. This is *the* precedent for a hand-written webservice-only span
+ search — exactly our case, and it is the **only** one. An earlier draft of this spec
+ also cited `DynSpansByLocation`; that was wrong and is corrected in §5.1 —
+ `DynSpansByLocation` is only an `` (`spanQueries.xml:266`) with no
+ `` and no references anywhere, so it demonstrates nothing about service-API
+ reachability.
+- **`internal` + `webservice`** — all 42 users are *injected per-dataset* searches, which
+ need ontology presence for categorization to work. Not our situation.
+
+Consequence for verification: the search must be **absent** from
+`/service/ontologies/Categories` (§9.4). Also, since no ontology node is added, this work
+does **not** touch categorization — so `wb model` suffices and `wb ontology` is not
+required. (It would be required if a row were ever added.)
+
+## 7. BED reporter (`ApiCommonWebsite` **and one model-side element**)
+
+Three deliverables, not two. The third is easy to lose because it lives in a different
+repo from the other two:
+
+1. `BedStrainSegmentReporter extends BedReporter` (`ApiCommonWebsite`)
+2. `StrainSegmentFeatureProvider implements BedFeatureProvider` (`ApiCommonWebsite`)
+3. the `` element on the record class (`ApiCommonModel`,
+ `Model/lib/wdk/model/records/strainSegmentRecord.xml`) — **exact shape**:
+
+```xml
+
+```
+
+`scopes="results"`, *not* `"results,record"`. The precedents `dynSpanRecord.xml:35` and
+`genomicRecords.xml:121` use `"results,record"`, but this class must not claim a `record`
+scope: §6 gives it no record page to download from.
+
+`name="bed"` is the literal string §9's verification passes as `reportName`, so it must
+match exactly.
+
+The element cannot land before the Java class: `ReporterRef.resolveReferences` does
+`Class.forName` on the implementation at model-load time and hard-fails the build if it is
+absent. Hence it ships in Task 6 alongside the reporter, not in Task 5 — which is exactly
+why it is at risk of being forgotten (see §10): **omit it and there is no BED download and
+no error anywhere**, because nothing in the model or the Java code references the reporter
+by name at build time.
+
+`BedStrainSegmentReporter` / `StrainSegmentFeatureProvider` follow
+`BedGenomicSequenceReporter` / `GenomicSequenceFeatureProvider` — the existing precedent
+for a provider that computes coordinates from **attributes** rather than from the PK.
+
+`getRequiredAttributeNames()` = `{strain_seq_id, strain_start, strain_end, organism}`.
+Strain is parsed from the PK, so it needs no attribute.
+
+The two BED columns have very different constraints:
+
+| Column | Constraint |
+|---|---|
+| `chrom` | **Hard.** Must equal `_` — the strain FASTA index key. Lookup fails otherwise. |
+| `name` | **Free.** Under `deflineFormat=QUERYONLY`, `Deflines.deflineForFeature` emits `'>' + feature.getName()` verbatim, so this becomes the output defline. Ours to design. |
+
+So `chrom = strain_seq_id`, and the `name` column carries provenance via
+`DeflineBuilder`, honouring `RequestedDeflineFields` as `DynSpanFeatureProvider` does
+(`DynSpanFeatureProvider.java:57-72`):
+
+The shape `DeflineBuilder.appendPosition` actually produces, with all fields requested
+(`deflineType=full`):
+
+```
+>A0003:AACB03000001:100-200:f | Aspergillus fumigatus Af293 | A0003 | AACB03000001, forward strand, 100 to 200 | A0003_AACB03000001, forward strand, 143 to 241 | segment_length=99
+```
+
+Carrying both coordinate systems is the payoff for choosing reference coordinates in
+the PK (§3). The two ranges stay distinguishable because the first names the reference
+sequence and the second carries the strain prefix.
+
+**An earlier revision illustrated the two ranges as `ref 100 to 200 | strain 143 to 241`.**
+Nothing ever emitted that. Matching it would have meant adding a `DeflineBuilder` method or
+hand-rolling strings — duplicating the builder for cosmetics — and it silently dropped the
+strand, which `appendPosition` includes. The `name` column is declared **Free** above and
+the normative requirement is only that it carry provenance through `DeflineBuilder` honouring
+`RequestedDeflineFields`, which the implementation satisfies literally. Recorded so nobody
+"fixes" working code to match a retired example, and so §9 verifies against the real string.
+
+## 8. Deferred: seqret wiring
+
+Sequence types in `service-sequence-retrieval` are pure configuration —
+`ReferenceDAOFactory.init()` reads `ALL_REFERENCE_SEQUENCE_NAMES` plus per-name
+`_FASTA_FILE`, `_INDEX_FILE`, `_IS_STRANDED`, `_MAX_SEQUENCES_PER_REQUEST`,
+`_MAX_TOTAL_BASES_PER_REQUEST`. **No service code change is needed.**
+
+When picked up, the work is: add a `SequenceType` enum value and one switch arm in
+`SequenceReporter.getSequenceTypeByRecordClassFullName` (`SequenceReporter.java:94-113`)
+— the single hard-coded record-class-to-FASTA seam — register the `sequence` reporter on
+the record class, and add the env vars. `SequenceType.name()` goes straight into the
+request URL and `ReferenceDAOFactory.get()` lowercases, so the enum name must equal the
+configured sequence name.
+
+## 9. Verification
+
+**Development target is fungidb** (`~/workspaces/fungidb`, branch
+`strain-segment-record`, sync up). **Not giardiadb:** it has 6,902 Giardia sequences
+across 14 organisms and **zero** `apidb.indel` rows, so nothing there exercises this code.
+
+QA organism: *Aspergillus fumigatus* Af293 — 1,117 strains, 5,085,377 indels. Other
+dense options: *Cryptococcus neoformans* H99 (875), *Candida auris* B8441 (502),
+*Cryptococcus deuterogattii* R265 (293).
+
+1. **SQL as WDK runs it** — `wdkQuery -model FungiDB -query
+ StrainSegmentAttributes.Coords -showQuery` (and `-showParams`). Renders post-injection
+ SQL without executing. Requires a prior `wb model`.
+2. **Execute** the rendered SQL read-only in psql against the tunnelled genomicsdb.
+ `%%PARTITION_KEYS%%` does not apply — this is not a partitioned gene-table query.
+3. **Registration** — `/service/record-types/strain-genomic-segment` lists the class and
+ its searches. Fetch from an authenticated app page (`javascript_tool`), since a raw
+ curl 307s to autologin.
+4. **Invisibility** — the search must be **absent** from `/service/ontologies/Categories`.
+ That endpoint is not project-filtered, so absence there is the meaningful check.
+5. **Shift correctness** — the boundary rule in §5.3. Pick a strain and sequence with
+ known indels, compute the expected prefix sum directly in psql, compare against
+ `strain_start`/`strain_end`.
+6. **Free signal for §5.3 once §8 lands:** seqret validates the feature against the
+ strain contig's indexed length and *fails open*, appending
+ `" | error_code=NOT_REQUESTED_LENGTH"` to the defline when `end > indexLength`
+ (`Deflines.java`). A correct implementation never produces it. It is silent by
+ design — nothing errors — so it only helps if deflines are actually read.
+
+## 10. Risks and open items
+
+| Item | Status |
+|---|---|
+| `<`/`<=` boundary at `refStart` | **open** — ships as specified, QA per §9.5 |
+| Ontology absence hides the search | **verified** 2026-07-31 — see §6.1 |
+| `(name, na_sequence_id)` uniqueness | true today, unconstrained; §5.3 grouping converts a violation to an error |
+| **A strain set covers every contig of its organism's reference** | true today, unconstrained — the residual assumption introduced by organism-level gate 3 (§5.1.1). Really *set equality* between a taxon's `dots.ExternalNaSequence` rows and the consensus deflines, and it can drift from either side (a pipeline dropping a contig; a non-genomic sequence gaining a row under a strain-bearing taxon, which only gate 4's colon filter incidentally catches). Verified on three organisms including *T. brucei* TREU927, where 144 newly-admitted pairs sit on 36 never-indeled contigs and all 131 deflines match the DB set exactly. Nothing enforces it; a breach mints an ID whose `chrom` has no FASTA entry, with **no error anywhere**. Fix would be a per-strain contig manifest. |
+| Reject-path cost of gate 3 | **accepted, measured** — a false `EXISTS` scans all of a strain's indel rows: ~101 ms vs ~53 ms for the largest strain here (§5.1.1). The `LIMIT 1` rewrite that would fix it is rejected on purpose; it trades slow for silently wrong. |
+| Prefix-sum cost | heaviest strain/sequence pair carries ~115k events; the `location <= ref_end` bound plus `ix0`/`ix1` should hold, but measure on Af293 |
+| Categorization rebuild | not applicable — §6.1 adds no ontology node, so `wb model` suffices. If a row is ever added, it becomes `wb ontology` (a superset of `wb model`). |
+| **Model-side `` element silently missing** | **open until Task 6** — it ships in a different repo from the two Java classes (§7.3), and nothing references it at build time. Omit it and there is no BED download and **no error anywhere**: `/service/record-types/strain-genomic-segment` simply lists no `bed` format. Verification: that endpoint's `formats` must contain `bed` with `scopes` = `results` only. |
+| Reporter `scopes` over-claimed | **open until Task 6** — copying `scopes="results,record"` from `dynSpanRecord.xml:35` / `genomicRecords.xml:121` would advertise a record-scope download on a class with no record page. Must be `scopes="results"`. |