Key Florilegium Caricis TJID3 Research  ·  Jones 2026  ·  Born Twice

Born Twice: Specimen and Record at the Moment of Collection

Abstract

Premise. Record it in the field, where the knowledge is. Like a tattoo, the record should go everywhere you do and outlast whatever held it first.

Methods and Results. The Independent File (IF) is a single HTML document that starts the voucher record at collection. A UUID and QR code tie specimen, photographs, collector, locality, and date together before the plant goes in the press. Fields, validation, Darwin Core mappings, and export rules live in one place: semantics by construction. IF runs in a browser with no installation, account, server, or network. The demonstration produced a documented voucher in under five minutes. IF prints labels; exports HTML, JSON, CSV, and XLSX; writes Symbiota, Specify, and DiSSCo packages; and pushes to GitHub. A take-home bundle reads on its own and imports back into IF. Downstream systems can receive the record without becoming conditions for making or keeping it. Nothing leaves the device unless the collector sends it.

Conclusions. A voucher record does not need a permanent digital home to keep its identity. Open formats, inspectable source, and no single host point toward decades of use, though no service life has been measured. The aim is a field book that follows a collecting career and still makes sense to the next person who opens it. The specimen goes to a collection; the record can stay with the collector.


1.Introduction

The broken chain

Herbarium specimens underpin systematics, floristics, conservation biology, and climate research only as far as the attached record can be trusted (Liu et al., 2022; Chen et al., 2022). Trust starts with provenance: who collected it, where, when, and under what name. Strip those and a mounted specimen proves that a plant once existed somewhere. That is not nothing, but it is close.

Yet voucher information is still assembled piecemeal as the specimen moves through pressing, drying, freezing, imaging, identification, and data entry. That chain takes months or years to close (Bebber et al., 2010; pers. obs.). Each handoff is a chance to omit, mistype, or diverge. We spent decades improving the databases at the end of the chain and almost nothing on the collecting event, the one moment when everything is known.

The system starts the record at that moment and keeps identifiers, fields, Darwin Core mappings (Wieczorek et al., 2012), images, validation, and export rules in one file. The design principle is semantics by construction: the record's structure and behavior carry its meaning, so nobody has to rebuild it later across several systems.

A phone can already photograph a specimen, record where it stood, store structured data, and move the record along. The herbarium then receives a record instead of rebuilding one. The remaining job is accessioning, not data rescue.

The record begins too late

Digitization has never lacked ambition. Large programs have spent decades databasing, imaging, georeferencing, and mobilizing collections (Bebber et al., 2010; Barkworth and Murrell, 2012; Brilhante et al., 2026). The Muséum national d'Histoire naturelle has imaged all 5.4 million of its vascular-plant sheets, yet holds field-collection information for only 16% (Le Bras et al., 2017). About 2.5% of the 5.3 million records it serves through GBIF carry coordinates (MNHN and Chagnoux, 2025). Smaller herbaria run short of money, labor, training, and hardware (Harris and Marsico, 2017; Powell et al., 2021). Effort is not the problem. Timing is: where, when, and by whom a specimen first becomes a computational record.

Recent tools attack the backlog with real ingenuity. VARP recovers barcodes from specimen images (Powell and Shaw, 2021). pyzbar decodes barcodes and QR in Python (Hudson, 2022). mvh builds virtual herbaria from open images in R (Vasconcelos and Boyko, 2025). Hespi reads sheet labels with OCR, HTR, and multimodal models (Turnbull et al., 2025). iNaturalist tools print labels from observations (Rockefeller, 2026). Unvouchered observations now outnumber specimens and misrepresent global patterns (Daru and Rodriguez, 2023). Each solves a real problem. Nearly all begin after collection, a handoff or two too late.

The gap follows specimens into the molecular repositories. Of 1.36 million flowering-plant records in GenBank, 52.9% carried a specimen-voucher identifier (Nakazato and Jinbo, 2022). In Dendrobium, 31.6% of records gave a country of origin and 4.8% named a collector or assessor (Wu et al., 2021). An author audit of COI records across six animal genera found the same holes: voucher identity, collection date, collector, coordinates, and identifier (Table 4; procedure in Appendix D; see also Carné et al., 2025). A sequence is digital from birth. Its provenance is not.

None of this argues for rebuilding historical collections. Existing barcodes can stay. Sheets need not be relabeled, and institutional databases need not be replaced (Heil et al., 2026). That horse has left the barn. The useful question is what happens to the next specimen collected.

The answer here is to assign Darwin Core terms when the record is made. Exports then present that record in whatever form a destination needs, photographs embedded or linked. The QR code can point to the fuller record or carry a minimal Darwin Core record itself. Either way, identity, taxon, place, datum, and date outlast any server or link. In that narrow sense the record resembles a digital paratype (Jones, 2025), kin to the sequence-based types proposed by Thiele et al. (2023), minus the four-letter code and its IUPAC embellishments. Here it is plain JSON.

Symbiota (Gilbert et al., 2026; Gries et al., 2014), Specify (Specify Collections Consortium, 2026), GBIF, and many other systems remain the destinations. The aim is to feed them from the field and plug the holes in the data dike before information leaks out.

 

2.Materials & Methods

2.1The Zero-Dependency Primitive

The result is an Independent File (IF): a document that computes. One HTML file of about 750 KB holds the interface, code, styles, and record rules. No external libraries, framework, packages, build step, or required service. TiddlyWiki has shipped as a single HTML file since 2004 (Ruston, 2026). The median webpage in 2025 weighed 2.9 MB (Barret et al., 2026). The reference deployment ships inside the Carex key (Section 9), though the voucher needs no taxon data. Appendix A lists every job the file does.

Table 1. Primary data store, intake path, and output format for five biodiversity data systems and IF. All converge on Darwin Core Archives or JSON at the boundary. Compiled from public repositories and documentation (including iDigBio, 2026), September 2026.
SystemPrimary storeIntake pathFormat outReads JSON
Specify 7MySQL / MariaDBData entry and batch import by collection staffDwC-A✓︎
SymbiotaMySQL / MariaDBData entry, CSV or DwC-A importDwC-A; JSON API✓︎
iDigBioPostgreSQL (JSONB)Harvest of DwC-A, usually via IPTJSON API✓︎
GBIFHadoop (Avro / Parquet)Crawl of DwC-A, usually via IPTJSON API; DwC-A and CSV downloads✓︎
DiSSCoPostgreSQL (JSONB)Mapping from source systems and aggregatorsopenDS JSON✓︎
IFNone; single HTML fileCollector, at time of collectionJSON, CSV, XLSX; Specify, Symbiota, and openDS exports✓︎

2-1aTwo Workflows: Preprint Vs Cowboy Methods

Pre-printing prevents poor performance. Bring labels when you can.

  • Print as many QR labels as you expect to need with IF's print function. Keep them in the press, a folder, wherever is handy.
  • Unused labels stay usable. Discard a damaged, soaked, or torn one; you lose only paper and ink.
  • Hit Scan and frame that label's QR code with your phone or tablet. IF adopts the identifier. Fill out your fields.
  • Put the specimen and its preprinted label together in the press.
 

No labels? Go Cowboy. Possible, but not recommended.

  • Use the QR code IF generates. After imaging, a pop-up gives the occurrence identifier's last four characters. Write them down with the specimen.
  • Back at the shop, QR tags · Reconcile Cowboy prints labels for those records.
  • Each label shows the last four characters and the binomial. Use them to rebuild the day and reunite the digital orphans with their specimens.

This study treats the QR record as a field-to-voucher workflow. Testing turned up other uses (specimen reminders, prepublication sharing, provisional identification, trip planning, record recovery), none evaluated here. The system is left open for workflows this paper did not think of.

IF nags once, and about the record, not the user: after three days of work living only in the browser, or ten unbundled vouchers, it asks to be copied out. One refusal and it shuts up.

2.2Quick Start

Quick start

How to use the field voucher

IF
  1. Open the IF file. To work offline, save a local copy first. It runs in the browser and stores records locally. Your device may ask permission for the camera and GPS. Annoying, and necessary for those features.
  2. The file generates a unique QR code and voucher identifier when it opens.
  3. Photograph the specimen in situ, with habit and habitat when useful. Existing images and photographed field notes can be added from other folders.
  4. Select Use Current Location (GPS). Coordinates can be corrected later.
  5. Add the useful photographs; the collector decides what earns its keep. Email attachments usually cap around 25 MB, so one canonical image may do for a day trip; plan storage for a long one. A no-photo option exists.
  6. Select Save Voucher. The record is stored in this browser. Email or export it when you are ready to move it.

Cost

$40 monthly in subscriptions over 90 days, no API billing or coupons used.

IF: build at a glance

Build 1008 · figures checked 29 September 2026 (freeze)

Build
ironfist1008
One name, stamped on every record it saves and every file it exports.
Self test
73/ 73
Checks that pass when the file is opened with ?selftest.
Named functions
573
Plus 93 in the vendored QR library. Every one is in the function manifest.
Call wires
1,401
Function to function, read from the code. The manifest shows each one both ways.
One file
762KB
12,376 lines. No server, no framework, no build step.
Unprompted network requests
0
The only request IF can make is to re-read its own file, on command.

IF is built to be taken apart. Every named function carries a comment stating what it does, and each is listed in the function manifest. A reader can trace any behavior to its source without running the file. Comments make up ~30% of the JavaScript by character count.

The interactive wiring diagram (Figure 1) follows build 1008 from printed identifiers and field entry through validation, local storage, and export. It is an explanatory map, not a live execution trace. For the trace, and for something to fly, go to Appendix F. The Happy Path atlas plays recorded calls through the application. The small-world flight deck lets you fly the architecture.

IF: interactive wiring diagram

Select a node to inspect its functions and connections, or choose Light the fuse to follow a voucher. Search for a function, drag to pan, and use Fit to see the whole map. Expand opens a larger view; Save SVG exports the figure.

Figure 1. Architecture map for build 1008. Self-test coverage and disclosed limits are available inside the diagram.

2.3Development and Testing Environment

Development proceeded by iterative triage across several tools, operating systems, browsers, and devices (Table 2). The workflow was tool-assisted but tied to no single environment.

Table 2.

Tools, environments, and their role in development

Category Tools / environment Role in development
Paper Notebooks Prep / Dailies /Versioning Design decisions, workflow diagrams, data relationships, failure states, and sanity checks, kept by hand as a parallel analog record.
AI-assisted development Six LLMs (Anthropic, OpenAI, DeepSeek); two agentic systems (Claude Cowork, Claude Code) Iterative code generation, debugging, review, restructuring, and prose assistance.
Data handling Microsoft Excel Inspection, organization, checking, and manipulation of tabular data.
Writing and editing LibreOffice Writer & Microsoft Word Manuscript preparation and editing.
Web browsers Spidermonkey, V8, Nitro engines Development, compatibility testing, and functional validation. Safari remains a problem-child.
Code editors Sublime Text; Visual Studio Code Direct inspection and editing of HTML, CSS, JavaScript, Python, and related files.
Programming and analysis Python; Biopython Data retrieval, processing, auditing, and analysis.
Operating systems MX Linux; Windows 10 Development and cross-platform testing.
Documentation and Figures Blender, Graph-viz Documentation, comparison, troubleshooting, and figure preparation.
Hardware testing Mobile and desktop Cross-device testing of layout, storage, input, export, and browser behavior.

2.4Repository Audit Procedure

Each audit froze its sampling frame, then audited and scored the frozen records from preserved local evidence. Procedures are in Appendices B.4 and D; checks that they match the reported results are still outstanding. The author ran the NCBI audits interactively with ChatGPT, inspecting outputs step by step, and that work set the procedure for SERNEC. For SERNEC, the author set the design and constraints, ChatGPT turned them into a frozen manifest and agent instructions, and Claude Cowork ran the retrieval (Appendix B.4).

2.5Options

2.6Export and Archiving

Export does two jobs: it hands a record on, and it gives the collector a copy to bring home. Formats differ in what they carry and how they handle photographs (Section 5.4). The take-home HTML record opens on its own and loads back into IF. Browser storage is no place to leave anything; keep exports and photographs elsewhere, plus a second copy on another device or service. A receiving institution may apply its own mapping and accession checks. The collector keeps the source record either way.

2.7Failure Modes Addressed

The failure addressed is a specimen separated from its documentation anywhere between collection and databasing. It happens the ordinary ways: notes never transferred; notes lost, wet, or illegible; the label written weeks later when the locality has gone soft; sheet and field report parted; the collector dead. Happens.

The shared identifier reconnects a separated sheet and record through the label, standalone HTML voucher, or bulk export table. If that sounds like a storage burden, all of GBIF's specimen records, imagery excluded, fit on a jump drive in this format. Failing everything, copy the UUID by hand with a pencil.

3.Results

The results address two questions: can the file produce a field voucher in minutes, and how often do existing repository records keep the provenance fields a voucher is meant to preserve?

3.1Field Demonstration

Minutes
Elapsed time, voucher tab opened to record saved. Cold device, first session. Two field photographs and one specimen-sheet photograph attached; coordinates from EXIF GPS; full Darwin Core field set completed by hand. Documented as Carex folliculata L., with a houseplant as physical proxy to remove taxonomic decision time. Not rushed, not optimized.

The demonstration shows a working capture-and-save workflow, not downstream adoption. Completion time was not measured under controlled conditions. To watch one save move through the code, play the Save chapter of the Happy Path atlas (Appendix F).

The author has run the file end to end more than 200 times, about half on real plants and half on synthetic records stuffed with placeholder text and autocomplete to push it toward failure. Those are stress tests, not a benchmark. With practice a record takes the author about 90 seconds, an anecdote, not a result. Outside collectors invited to test the precursor build, tmjv8 (Jones, 2026a), sent no data back. Completion time across collectors, devices, and conditions remains untested.

Builds named in this paper.

Repository provenance audits

The audits ask how often repository records carry the provenance fields a voucher should guarantee. Table 3A: the SERNEC random survey, 21,110 herbarium records (SERNEC Data Portal, 2026). Table 3B: the plant barcode stress test, identified by the author as NCBI matK and rbcL records. Table 4: 600 GenBank COI records across six animal genera. All count field presence, not accuracy.

3.2Herbarium and plant barcode records

Table 3A, summary.

SERNEC herbarium records: random survey

Pooled presence across 21,110 records in ten genera drawn at random from the BONAP top-100 list (Kartesz, 2015; Appendix I). The lowest genus is given under each value. Full table in Appendix H.

Coordinates present
25.0%
Lowest: Chorizanthe 11.1% (N = 9); Micranthes 17.4% (N = 442).
Collector present
81.3%
Lowest: Micranthes 70.1% (N = 442).
Collection date present
77.8%
Lowest: Micranthes 66.1% (N = 442).
Identified by present
38.3%
Lowest: Ribes 32.1% (N = 417).
Table 3B.

NCBI plant barcode records: matK and rbcL stress test

Values are percentages of records with each field present. N is the number of records. Collection date, collector, and coordinates are emphasized.

TaxonNCollection dateCollectorCoordinatesIdentified by
Carex19,83484.3%90.8%32.3%43.6%
Cyperus5,18584.2%88.7%34.2%45.0%
Panicum2,05487.3%91.3%34.7%37.3%
Solidago7,10574.4%77.8%24.3%37.0%
Total34,17882.4%87.8%31.1%42.1%

The total row gives pooled percentages across the displayed records. Appendix E: matK and rbcL audit BioPython.

3.3Animal COI records

Table 4.

COI voucher and provenance completeness across six animal genera

Presence of voucher and provenance fields in 100 GenBank cytochrome c oxidase subunit I (COI) records per genus: fish, beetles, birds, selected as super-genera. Values are percentages of each sample. Presence measures documentation, not accuracy. Cards give pooled presence across all 600 records, with the lowest genus under each value.

Coordinates present
43.2%
Lowest: Rhinogobius 19% (N = 100).
Collector present
37.2%
Lowest: Rhinogobius 19% (N = 100).
Collection date present
54.3%
Lowest: Oreochromis 42% (N = 100).
Identified by present
27.8%
Lowest: Zosterops 6% (N = 100).
Group Taxon N Voucher INST:number Collection date Collector Coordinates Identified by
FishLabeo COI10058%5%46%45%29%21%
FishOreochromis COI10045%7%42%28%41%19%
FishRhinogobius COI10060%9%43%19%19%11%
BeetleBembidion COI10094%34%62%56%87%74%
BeetleAgrilus COI10076%0%63%48%48%36%
BirdZosterops COI10038%24%70%27%35%6%

The tables show uneven retention of collection date, collector, coordinates, and identification. A record can keep one part of its provenance and lose another. The audits establish the documentation problem in the examined records; they do not test whether populated fields are true, or measure any improvement from IF. They identify what is worth securing at collection, before later workflows have to recover it.

The Protein Data Bank has a related gap. Its PDBx/mmCIF dictionary separates natural-tissue from genetically manipulated sources, and gene-source organism from expression host (wwPDB, n.d.-a, n.d.-b). It also offers a free-text field for natural-source details (wwPDB, n.d.-c), so there is room for a fuller account. But a taxon name, tissue, or supplier does not establish which organism supplied the material, where and when it was collected, or whether a specimen survives to be re-examined. We did not audit how often PDB records make those links. The point is narrower: precision about a structure is not provenance for its source.

4.The Inertia Straitjacket

The backlog is not rhetorical. As of 31 December 2025, Index Herbariorum listed 4,035 active herbaria holding a reported 406,426,591 specimens (Thiers, 2026). Every one needs a name, a place, a date, a collector, and a durable route into a data system. A collection that size will not be rescued by meetings or the next platform. It needs small tools that cut friction at the moment the record is born.

406M+
Global herbarium specimens
Index Herbariorum estimate, active herbaria, 31 Dec 2025.
4,035
Active herbaria
A distributed global collection, not one database or workflow.
22M+
German specimens
An advanced nation, most specimens still undigitized (GBIF Germany, 2025).
26%
Non-standard formats
Boxes, envelopes, slides, liquids, other labor-intensive formats.

Pressed plants are the obvious beachhead: flat, large, label-driven, tied to locality, already a sheet plus a text record. Not the only target, just the cleanest first one. Fish, birds, and arthropods share the core problem: a physical object must stay bound to a record through time.

  • Human-readable ID. The survival layer. Read, copied, photographed, typed, or recovered after the machines are gone.
  • QR code. The field and phone layer. Binds plant, photograph, bag, label, and record before the institutional workflow begins.
  • One-dimensional barcode. The legacy accession layer, scanned at the workstation once the specimen enters a collection.
  • RFID. The cabinet-scale audit layer for types and high-value specimens: thousands read without line-of-sight handling.

The layered view prevents identifier warfare. The identifier is the specimen; the machine-readable forms are doors into the same record. None needs to kill the others.

Table 5 compares the carriers. The paper label is the only one with a demonstrated multi-century record, and nothing here replaces it. Code 39 adds machine reading at the cost of a dedicated scanner. QR adds it on hardware the collector already carries (Statista, 2026), and resolves without a network when the data travel in the code (up to about 4,000 characters). NFC is convenient but mortal. Visual search is powerful but infrastructural. GS1 standardizes meaning without guaranteeing permanence.

Table 5.

Comparison of specimen-identification technologies

Cost is the marginal cost per object. Program-level costs appear in note c.

Technology Read requirement Resolves offline Practical lifespan Information capacity Cost per object
Human-readable labelPrinted or written text Unaided eye. Yes. Centuries on archival stock. The demonstrated incumbent. a Whatever fits legibly. Machine parsing requires transcription. None beyond the label already produced for the specimen.
1-D barcode, Code 39Linear symbology Laser or imaging scanner. Yes. Life of the label. a Low capacity for its size. More characters need a wider barcode. Incremental printing only.
2-D QR CodeMatrix symbology Smartphone camera or imaging scanner. Yes when the data are encoded directly in the symbol. No when the code contains only a URI. Life of the label. a Error correction can recover a partially damaged symbol. b Up to 7,089 numeric or 4,296 alphanumeric characters at error-correction level L. b Specimen codes should be far shorter so modules stay large and readable. Incremental printing only.
NFC tagRadio-frequency tag NFC-capable device and operating-system permission. Yes. Rated data retention 10 to 60 years or more, depending on the chip. Antenna, adhesive, or encapsulation may fail first. a Common NXP NTAG chips provide 144, 504, or 888 bytes. Other tags reach several kilobytes. Approximately USD 0.10 to 1.00 in quantity. Ruggedized or archival assemblies cost more. c
Visual searchRecognition method Camera, trained model, and reference collection. Only when the model and reference collection are stored locally. No physical limit. Lasts only while its models, references, and infrastructure are maintained. No encoded limit because the specimen itself is the query. Accuracy depends on image quality, reference coverage, and the taxonomic resolution requested. No physical tag cost. Program-level costs can be substantial. c
GS1 Digital Link and GS1 DataMatrixIdentifier syntax and carrierd Determined by the carrier in which the identifier is encoded. A DataMatrix symbol carrying data can resolve offline. A Digital Link requires access to its domain and resolver. Life of the label, a together with continued maintenance of the domain and resolver. GS1 DataMatrix holds approximately 3,114 numeric or 2,334 alphanumeric characters after inclusion of the GS1 function character. A Digital Link carried in a QR Code follows QR capacity limits, less URI overhead. Printing cost is negligible. Governance and hosting are not. c

Notes

  1. No symbology has a lifespan rating; longevity belongs to substrate, ink, and storage. Fused toner on archival stock persists; dye inkjet fades. Deep-freezing at accession, routine for pests, stresses adhesives and encapsulation; chip-retention figures do not cover it.
  2. Capacity maxima are quoted at error-correction level L, the weakest. Raising error correction improves damage recovery and lowers capacity; the two maxima cannot be had at once.
  3. Program costs are excluded from the per-object comparison: for NFC, encoding and tag verification; for visual search, cameras, image storage, annotation, model development, inference hardware, validation, and maintenance; for GS1, licensing, identifier governance, resolver hosting, database integration, and scanner upgrades.
  4. GS1 Digital Link is an identifier syntax carried by a QR Code or DataMatrix, not a physical carrier. It is included because institutions may evaluate it, not because it is a peer of the symbologies above.

This is why the system is deliberately primitive. The advanced solution is the institutional stack: server, database, grant, account, scanner, support queue, policy meeting, migration plan. Here the collector finishes the documentation while specimen and context are still at hand, and the institutional workflow can arrive later without making the field event wait. The competition is paper, and paper lasts hundreds of years.

5.System Description

5.1Technical Architecture

The file contains HTML, CSS, and vanilla JavaScript, roughly 12K lines in the version documented in Appendix B. All fx's are labeled for searchability. QR generation uses Kazuhiko Arase's QR Code Generator for JavaScript, MIT licensed and embedded (Arase, 2009). A pure-JavaScript parser reads GPS coordinates straight from image bytes (Section 5.5).

Records, addenda, and interface state live in LocalStorage; photographs in IndexedDB (Appendix B.1). Both survive restarts within the browser's storage limits, unless the browser clears them (Section 8). To see what a refused storage write does downstream, run Failure in the small-world flight deck (Appendix F). The file follows the 20-Year File / Digital Paratype architecture (Jones, 2025): installing it means copying it. Twenty years is a design goal, not a measured lifespan. Exports follow Lots of Copies Keep Stuff Safe (Reich and Rosenthal, 2001): more copies in more places. Copying alone does not check or repair copies the way a managed preservation system does.

5.2The Voucher Record

Each saved voucher record in IF is built from the form fields in Table 6. CSV and XLSX exports use the same saved keys through FIELD_DEFS; JSON carries the same record plus a photo manifest when photographs are present.

Table 6. Voucher record fields, their Darwin Core role, and storage and export behavior.
Field Darwin Core / role IF saved key and behavior
Voucher ID / QRsystem identifierid; generated in the browser or reassigned from a preprinted/scanned label.
Occurrence UUIDoccurrenceIDoccurrenceID; stable GBIF-ready UUID, generated at draft start and not overwritten by QR reassignment.
Genus + speciesscientificNameIF uses separate form inputs for genus and species, then saves and exports the joined value as scientificName.
QualifieridentificationQualifierqualifier; determined, cf., aff., near, or uncertain ID.
Identified by, firstidentifiedByidentifiedByFirst; exported separately as identified_by_first.
Identified by, lastidentifiedByidentifiedByLast; exported separately as identified_by_last.
Date identifieddateIdentifieddateIdentified; HTML date input, YYYY-MM-DD.
Kingdomkingdomkingdom; selectable cross-kingdom value.
Basis of recordbasisOfRecordbasisOfRecord; PreservedSpecimen is the default.
Familyfamilyfamily; free text.
Collection dateeventDatedate; HTML date input, defaults to the day of save.
Collector, firstrecordedBycollectorFirst; persists across consecutive saves.
Collector, lastrecordedBycollectorLast; persists across consecutive saves.
Collector ORCID iDrecordedByIDcollectorOrcid; optional identifier for the collector.
Collector numberrecordNumbercollectorNumber; manual entry, not auto-incremented.
InstitutioninstitutionCode / institutionIDinstitution; accepts herbarium code, organization, or institution text.
Countrycountrycountry; free text.
State / ProvincestateProvincestateProvince.
County / Parishcountycounty.
LocalityverbatimLocalitylocalityText; collector-supplied locality narrative.
LatitudedecimalLatitudelat; EXIF GPS, device GPS, or manual entry.
LongitudedecimalLongitudelon; EXIF GPS, device GPS, or manual entry.
Geodetic datumgeodeticDatumgeodeticDatum; WGS84 is the default.
AccuracycoordinateUncertaintyInMetersaccuracy; EXIF-reported, device-reported, or manual uncertainty.
Coordinate sourcegeoreferenceProtocolcoordinateSource; exported as georeference_protocol, e.g., EXIF, device GPS, or manual.
ID confidenceidentificationVerificationStatusconfidence; low, medium, high, or expert-confirmed.
Habitathabitathabitat; free text.
Associate speciesassociatedTaxaassociates; nearby taxa observed by the collector.
Description / notesoccurrenceRemarksdescription; morphology, abundance, population notes, or field description.
Additional notesidentificationRemarks / occurrenceRemarksnotes; follow-up, uncertainty flags, field conditions, or other remarks.
Created atdcterms:created / system metadatacreatedAt; timestamp created at save.
Voucher startedsystem metadatastartedAt; start time for the local timer subsystem.
Durationsystem metadatadurationSeconds; elapsed seconds from blank draft to save.
PhotographsassociatedMedia, by export linkageStored in IndexedDB under the voucher ID; JSON carries a photo manifest, and image files export separately as ZIP.

5.3QR Code Implementation

Each record gets a unique identifier when the form opens, before save, shown as a QR code and carried through every export. HTML voucher exports embed the QR with a generous white margin; the bulk HTML table puts one in the first column of each row; printed labels place it flush right with a 0.18-inch border. That border is the quiet zone scanners need; without it labels scan poorly in low light and on low-resolution phone cameras.

5.4Export Formats

  • DwC JSON A complete Simple Darwin Core occurrence export, fields mapped per Table 6, importable to most herbarium databases.
  • Take Home A portable, independently readable record that can be imported back into IF.
  • HTML Table All vouchers in one landscape HTML file, a QR image per row. Opens in any browser; drags into most spreadsheets.
  • CSV / XLSX One row per voucher, all Table 6 fields, as plain CSV or a formatted XLSX workbook, for institutional import or bulk editing before re-import.
  • Label Print Labels open in a browser print window; a blocked window is reported. No separate application needed. Two across at 4 × 3 inches on legal paper, for adhesive stock cut to size or card stock cut by hand. Matches no pre-perforated Avery template; test-print on plain paper first.

Optional connections

IF can work alone, sit inside an identification key, or feed a receiving workflow through an adapter. The same record serves all three. Git and GitHub give copies a place to land and keep version history (GitHub, Inc., 2026). Specify and Symbiota are the institutional collection workflows (Specify Collections Consortium, 2026; Gries et al., 2014). All of these connections are optional.

DiSSCo is one more destination. It ingests registered institutional source systems, fed by a Darwin Core Archive or a BioCASe endpoint, and turns their data into openDS Digital Specimens (DiSSCo, n.d.; DiSSCo, 2026a). IF writes the first, and writes openDS directly. I checked that export against the openDS 0.4.0 JSON Schema: everything IF writes passed. Seven required keys were missing, all assigned by DiSSCo at ingest, such as the specimen DOI and source-system ID. A Meise Botanic Garden record from the DiSSCo Sandbox passed the same check cleanly (DiSSCo, 2026b). The shapes match. Acceptance is a separate step; no IF record has been through DiSSCo ingestion yet.

5.5EXIF Extraction

When a photograph is attached, IF parses its EXIF in the browser for GPS coordinates and reported error. Empty coordinate fields are filled, with EXIF as source; existing coordinates are kept, and the distance between the two is reported. A separate writer embeds the voucher ID in the resized JPEG IF stores, beside its visible ID stamp (Appendix A); the source photograph is untouched. Capture date is not extracted; eventDate is typed or defaults to the day of save. A photo with no GPS tag (older camera, scan, screenshot) falls back to device geolocation or manual entry, and the coordinate source field records which path was used (limits in Section 8). One caveat: hand-entered GPS is GPS, and phone-assigned GPS is GPS plus, usually, Wi-Fi positioning, maybe A-GPS, cell-tower triangulation, and other stuff as well. For the sake of sanity it is not examined here.

6.Unresolved Records as Positive Data

The architecture buys resolution, not information.

The same system yields a second data type that usually becomes a ghost: the observation made, documented, and given up on. Databases treat a missing determination as silence, and silence reads as absence. That error compounds across decades of range modeling and conservation assessment. A collector who meets twenty Carex of section Ovales on a Rocky Mountain summit at 3000 m, measures perigynia, notes scale color and beak length, and writes "unresolved; complex difficult here; characters noted" has produced a difficulty signal: place, date, character states, and a flag that the taxonomy hit morphological compression right here. A useful fail.

Aggregated across collectors and years, such records show where a complex's hard boundaries run, which records range modelers should handle with kid gloves, and where the next specialist should look. A database that demands a name gets one, and a forced determination that goes quietly wrong corrupts everything downstream until someone catches it. Expert bypass is the limit case: when Tony Reznicek says Carex frankii in Ohio, the assertion is the chain. The system takes that as readily as the first-year student at Cedar Bog who loses the determination on a perigynium and says so. Neither is nothing.

My two cents. Messy biological reality → conceptual model → RDF ontology → mappings from legacy systems → mappings between ontology versions → application interpretation. That is not a service to anyone. It formalizes an unstable ontology without stabilizing the knowledge under it; it mummifies today's reading of that knowledge. And the mummy always comes back in the movies.

7.The Fifty Cabinets

The backlog includes collectors now infirm or dead, their localities incomplete or gone. The fifty-cabinet figure is informal: herbaria of every size, nearly three decades of field, curatorial, and computational work in Cyperaceae.

These specimens are not recoverable. The collectors are gone. The mental index, the memory of which road, which bog, which August, died with them. They survive in drawers as pressed plant material with no coordinates in space or time.

No tool recovers a broken chain, and this paper does not claim to. What the system does is prevent the next fifty cabinets. The chain breaks at a predictable moment, the end of a hard field day, when documentation gets deferred one more time. Five minutes on a phone the collector already carries removes the friction behind the deferral.

The author's own cabinets, several hundred specimens, are the same failure, recoverable only because the author is still alive and the mental index persists, barely. This system exists in part because those cabinets do.

8.Advantages and Limitations

Keeping the field record on the device assigns custody and backup to the collector. The costs are listed here rather than left in the source.

  • Custody. The collector holds the only copy until export. Clearing browser data, or losing the device, loses unexported vouchers. No server-side redundancy, by design; backup is the user's job.
  • EXIF coverage. HEIC originals from some iOS capture flows are not parsed for GPS, and capture date is not extracted; eventDate is entered by hand or defaults to the day of save. Storage persistence is requested through navigator.storage.persist() where available; this is a request, not a guarantee. The separate Safari advisory is described in Appendix B.3 and remains unverified on Apple hardware.
  • Georeferencing. Precision depends on the device, the canopy, and the conditions at collection. The recorded uncertainty is whatever the device reports.
  • Label stock. No pre-perforated template matches the 4 × 3 inch layout (Section 5.4). Test-print first.
  • Browser dependency. The file uses ES2025 and IndexedDB. Browsers predating those features will not run it.

Designing for decades

I cannot give IF an expiry date, and I don't try. The argument is about recovery. The Library of Congress weighs disclosure, adoption, transparency, self-documentation, and external dependencies in judging whether a format lasts (Library of Congress, n.d.). IF meets each plainly: open source, common formats, documented fields, no remote service. That is my reading, not a preservation certification. The W3C Technical Architecture Group puts compatibility with existing content first when web features change (W3C TAG, 2026), which favors IF. Three things have to survive: the bytes, the meaning, and the software. Backups and integrity checks cover the bytes. Identifiers, field definitions, and exports carry the meaning. Browser compatibility decides the software. If the interface breaks someday, readable exports and the source still leave a way back. Keeping the tool running is a harder bar than keeping the evidence readable. The defensible claim is a tool designed for decades of use, with no service life proven yet.

9.Origins and Scope

The voucher began with the matrix of the Carex Interactive Identification Key of North America (CIIK), built in DELTA, moved to Lucid, and developed over twenty years. That matrix went into Florilegium Caricis (Jones, 2026b; DOI: 10.5281/zenodo.19138828), a single-file key to 466 North American Carex taxa built on Flora of North America Volume 23. There the voucher has its own tab and talks to the key: a record can carry the current filter path, so the character data behind an identification travel with the locality. Florilegium Caricis tested function. A second work, Mule (Jones, in prep.), went in reverse: UI first, matrix parachuted in afterward. The voucher is described separately because it works alone, and its scope runs past Carex and past keys.

I built IF as one field botanist with a lot of help from AI systems, through round after round of iterative triage (Section 2.3). What it gets wrong is mine to answer for.

A whole career is the hope, kept together well enough to hand on. A generational scrapbook with identifiers: places revisited, names corrected, observations somebody else can still use. If it lets someone leave a fuller account than I can piece together from my own cabinets, it has done its bit.

10.Conclusion

Semantics by construction starts the record in the field, while plant, collector, place, and circumstances are still in one spot. Required fields, identifiers, corrections, and export mappings are built in, so whatever handles the record next has something to work from. The record can leave the field, enter another system, and come back to the collector. No single host has to last forever.

IF is a file with no home, so it lives everywhere. A copy on a phone, laptop, thumb drive, GitHub repository, or email attachment is the whole system, not a pointer to one. Nothing has to stay running for it to open. A page from the web's first year still opens in any browser (Noyes, 2013; W3C, 1992), about as good a longevity guarantee as software gets. The whole program is under a megabyte, smaller than one photograph from the phone that carries it. No computational liturgy required.

A herbarium keeps the specimens. A portable field record can keep the years spent finding them. The fifty cabinets are a sunk cost of past practice. The next fifty are preventable.

Data and Code Availability

The complete HTML file is available at tjid3.org/if/if.html. The voucher component is also preserved within the archived Florilegium Caricis release at Zenodo (DOI 10.5281/zenodo.19138828). Summary voucher-chain audit results are in Table 3A, Table 3B, and Table 4; the SERNEC retrieval doctrine and sampling script/.md are in Appendix B.4; the reconstructed COI audit script is in Appendix D; the field demonstration is in Section 3.1.

Author Contributions

T.M.J. conceived the system and directed its development throughout. He built and reviewed the software with AI assistance, ran the demonstration, interpreted the results, and wrote and revised the manuscript. AI systems helped with code generation, debugging, review, and prose. The author checked and accepted every design decision, every line that shipped, and every claim in this paper, and takes full responsibility for them (Section 2.3).

Funding

No external funding. The work was carried out independently under TJID3 Research.

Conflict of Interest

The author wrote the software described here and maintains its deployment. No commercial interest, license, subscription, or revenue is attached to the tool, which is released CC BY 4.0.

References

Appendices

Appendix A What the file does, item by item

Every job the file performs, in plain language, for a reader who uses a phone and a spreadsheet and has never looked at code. Items are numbered so they can be cited.

The file itself

1. One file, and the file is the whole program. Open it in any browser and it runs: no install, account, server, or internet once it is open. Even the QR codes are made on your device.

2. Everything stays on your device. Records live in browser storage; photographs in a larger browser store built for files. Nothing leaves unless you export or share it. Save the file and it is yours.

3. It remembers how you left it: light or dark, open sections, and field values you reuse.

4. It carries its own version number. Every record and export is stamped with the format version, so a future reader can tell which generation of the file made it.

Identity: how a specimen gets a name that never changes

5. Every specimen gets a permanent identifier when a draft begins: the occurrence UUID, a long random string never reused or changed. Photos, corrections, and exports all point back to it.

6. Every specimen also gets a voucher ID, shown as a QR code. This is the number for the label and sticker. Each record gets a fresh one.

7. Preprinted QR stickers work too. Scan one with the camera (Chrome or Edge) or type its ID, and the record adopts it. Attached photos move over.

8. It refuses bad IDs. An ID that looks like a web address is blocked, because a QR that points to a website dies with the website. An ID already used in this file is blocked too.

9. It cleans up its own past mistakes. Earlier versions allowed web-address IDs. On first run, after backing up the whole record set, any such record gets a proper ID and keeps the old one as legacy voucher ID.

10. It knows which device made each record. Each browser install gets a session UUID, stamped on everything it saves and shown in the corner badge (tap to copy). Merged exports from several people or machines still say where each record came from.

11. You can download the QR code as an image for a notebook page or another document.

Filling in a record

12. The fields follow Darwin Core, the shared vocabulary of GBIF and herbarium databases. Every field exports under its Darwin Core name, so the file goes to a collection database without re-entry.

13. You can add Darwin Core fields the form does not show, from a searchable picker grouped the way the official reference groups them. They save, export, and re-import like any other field.

14. Required fields are marked; the record will not save without them. A box lists what is missing and highlights it. The gate checks presence, not truth: it knows a field is blank, not that it is wrong.

15. It checks formats. Dates must be real year-month-day dates and are corrected as you type. Latitude and longitude must be in range.

16. One button fills coordinates from the device GPS, with reported accuracy, and notes GPS as the source.

17. It records where every coordinate came from (GPS button, photo metadata, or typed) under the Darwin Core georeference protocol term.

18. It handles several collectors. Add, reorder, remove. The first is primary; the rest export as additional collectors.

19. The collector is assumed to be the identifier until you say otherwise. The primary collector's name is copied into Identified by until you type a different one.

20. It suggests recent values, so a field day with one collector and one institution does not mean typing them fifty times.

21. Some fields carry over on purpose. ORCID, institution, country, and kingdom belong to the sitting, not the specimen, and stay filled after a save. Kingdom survives a reload, so a morning of insects is not filed under Plantae.

22. Duplicate for this site. Several specimens from one stop is the normal case. This starts a new record with a new ID, keeping locality, coordinates, habitat, and collectors.

23. A one-line trip summary sits above the form: collectors, institution, country.

24. It times each record, silently, from the first keystroke, selection, GPS fix, or photo to save. Export, print, and delete clicks do not count.

25. It saves the draft as you type. If the browser closes mid-record, a banner offers to restore it, IDs included, so it rejoins its photos. A restored draft is the same specimen, not a new one wearing the old words.

26. Deleting is reversible for a moment. Deleting a record shows an undo prompt. Clearing everything asks first.

Photographs

27. Photos attach from the camera or camera roll, are stored beside the record, and show in a small gallery on the saved card.

28. Photos are shrunk to 1920 pixels on the longest edge and re-saved as JPEG. Plenty for identification, and fifty vouchers do not fill the device.

29. The voucher ID is written onto the photo twice. Once visibly in a corner, once invisibly in the JPEG's EXIF block in three places. A photo separated from its record still carries its ID.

30. It reads location from a photo. Empty coordinate fields are filled, with the photo as source. If coordinates exist, it reports the distance between the two, so a photo taken at the truck and a specimen up the slope do not silently disagree.

31. "No photo" and "photo attached" cannot both be true. Ticking the no-photo box on a record with photos is refused. Adding a photo unticks the box. The save gate checks again.

32. Photos follow the record. If the ID changes, photos are re-keyed and re-stamped to match. Deleting a record deletes its photos rather than leaving orphans.

Corrections after the fact

33. The original record is never overwritten. A redetermination or coordinate correction is saved as a separate addendum pointing to the occurrence UUID. The original stays as first written.

34. Two kinds of addenda exist: redetermination and georeference correction, each with its own short form.

35. Exports show original and current state: the original fields, matching "current" columns from the latest addendum, and a count of addenda.

36. Correction history survives a spreadsheet round trip. JSON nests the chain under each record; CSV, XLSX, and HTML carry it in one extra column, and re-import rebuilds it instead of quietly reverting a redetermination.

37. It watches for broken chains. An addendum whose parent record is missing is reported, on the main page and in the compiler.

The saved list

38. Search, sort, and select. Every export and print action works on the selection, or on everything if nothing is selected.

39. Each card shows its addenda as badges and a panel.

Getting data out

40. CSV. Darwin Core column names. Opens in any spreadsheet.

41. JSON. Keeps numbers as numbers and true/false as true/false, nests the addenda, and lists each record's photos by name and size without embedding them.

42. XLSX. A real Excel workbook written by code inside this file. Opens in Excel, LibreOffice, Numbers, and Google Sheets.

43. HTML table. A self-contained page of the records, readable in any browser, printable in landscape.

44. Printed labels. 4 × 3 inches, two across on legal paper: QR, name, family, locality, coordinates, habitat, associates, collector, number, date. A redetermined specimen prints the current name with "Orig. det." underneath, redeterminer and date included, which is what an annotation slip would say anyway.

45. Photos as a ZIP, built by code inside this file, each photo still carrying its stamped ID.

46. Email or share. On a phone, the share sheet with the JSON attached. On a desktop, a download plus a blank email with instructions to attach it.

47. Exports are spreadsheet-safe. A cell a spreadsheet would read as a formula is neutralized, so a pasted locality note cannot run as a command. Real numbers, negative longitudes included, are left alone.

Getting data in

48. Import CSV or JSON produced by this tool, or by another copy of it on another device.

49. It refuses to overwrite by accident. An incoming record whose voucher ID matches a saved one is blocked and the clash reported.

50. It flags what it cannot trust. Malformed or incomplete records are imported anyway and marked in an import review column, so nothing is lost and nothing passes as clean.

51. Typed fields are restored on import. Spreadsheets turn everything into text; numbers and true/false values come back per the schema, and anything unrecognized stays text for review, not guessed at.

The compiler drawer

52. It merges many exports into one set: JSON, CSV, HTML, XLSX, and photo ZIPs from any number of devices or seasons. A specimen in several files is matched by occurrence UUID, kept once, and its addenda merged.

53. It counts what it did: records, source files, collectors, photo references, photo files, duplicates skipped, addenda merged, problem children.

54. It exports the merged set in every format, every row stamped with a compile batch UUID naming this particular merge.

55. It is deliberately temporary. The compiler empties on reload: a workbench, not a second database. "Add this report" pulls in this device's own records alongside the loaded files.

Self-checks and honesty about failure

56. It tells you when the browser refused to save. Browsers refuse writes when storage is full or the window is private. A banner says the record is on screen but not on disk, export now, and stays until a save succeeds. A fading message is how people come to believe a problem went away.

57. It asks the browser to keep the data, where supported, so storage is spared from automatic cleanup. The result shows as a status line.

58. It warns Safari users specifically as the problem child. Safari deletes a site's data after seven days without a visit; the file advises adding the page to the Home Screen, Apple's documented exemption. Tested on Apple hardware only at cellphone kiosks; appears functional; shims are built in.

59. It migrates its own old storage layouts, once, on first run.

60. Dates are local. The date on a record is the calendar date where you are standing, not the date in Greenwich, so an evening collection in Louisiana is not filed under tomorrow.

Appendix B Technical reference

Line numbers refer to ironfist911.html as distributed (11,943 lines). Function names are as they appear in the source. A leading underscore marks a helper not meant to be called from markup. The section numbers (1.1, 5.11, and so on) are the file's own; its module map at line 1182 lists them, and searching the source for a number jumps to that section.

B.1 Architecture

File layout. One HTML document, no external requests. Lines 1 to 23 are document metadata (Dublin Core, citation, canonical DOI). Lines 24 to 26 are a JSON-LD block typing the file as SoftwareSourceCode, WebApplication, and ScholarlyArticle. Lines 27 to 634 are CSS, light and dark themes as CSS custom properties. Lines 636 to 1132 are markup: the three panes (field form, saved-record list, office tools) and the compiler drawer. Lines 1133 to 1173 hold the QR generator as its own plain script. Lines 1174 to 10706 are the main program, Parts 1 to 8. Lines 10707 to 10936 are the dialogs. Lines 10937 to 11689 are the compiler drawer (Part 9), a second script in its own closure. Lines 11691 to 11938 are the pane shell (Part 10), a third.

Runtime layers. Four things carry the state; everything else reads through them.

FIELD_DEFS (line 1312) is the schema. Each entry maps an internal key to a CSV/Darwin Core column name and a human label, with an optional type of boolean or number. Every exporter, importer, form reader, and the HTML table iterate this one array. _coerceFieldValue() (line 1461) is the only place typed values are converted on the way in from flat formats.

Store (line 1599) is the persistence kernel: a single localStorage key (fieldVoucherRecords_v1, line 1573) holding an object of typed lists (voucher, addendum, uiState, list, bundle). Operations are all, get, put (upsert by id), remove, replaceAll, mutate, filter, lastWriteOk, and invalidateCache. Every write re-reads disk first, so two tabs on one workspace do not overwrite each other; mutate (line 1715) is the read-modify-write form. A refused write sets a flag and routes to the storage failure reporter. Upsert-by-id is the property the merge model rests on: two devices generating different UUIDs can union their exports without collision, and re-importing the same file is idempotent.

Bus (line 1797) is a minimal publish/subscribe with on and emit. The list, gallery, problems note, and workspace bar redraw on vouchers:changed; addenda:changed and draft:restore work the same way. Listeners are wrapped so one failing listener cannot stop the rest.

Draft (line 5074) is a plain object mirroring the form. Delegated input and change listeners update it as the user types; bulk writers (reset, restore, duplicate, GPS, EXIF) call _draftSyncFromDOM() (line 5076) immediately after setting values, because programmatic .value assignment fires no input event. voucherFromForm() (line 5384), the save gate, and the autosave all read Draft rather than the DOM.

Workspaces. One URL parameter, ?ws=name (line 1296), turns the file into separate data sets. Every storage key gets the workspace name appended through _wsKey(); the default workspace keeps the bare keys, so data from earlier builds is where it always was. The list of known workspaces and the install's device tag are shared on purpose.

Identifiers. Three UUIDs with three lifespans, all minted by uid() (line 1873, RFC 4122 v4 via crypto.randomUUID, falling back to getRandomValues, then to Math.random). occurrenceID is one per specimen, immutable, minted at draft start by voucherOccurrenceUUID() (line 2777), and is the foreign key for addenda and the dedupe key for merges. The Voucher ID (id) is the label and QR number; it is generated per draft but may be replaced by a preprinted sheet, and photos are re-keyed when it changes. sessionId() (line 2016) is one per browser install, stored under the legacy name deviceTag for backward compatibility, and stamped on every voucher and addendum at save. compileBatchId is one per compiler clear, stamped on compiled exports only.

Storage keys. Records: fieldVoucherRecords_v1 (line 1573). Draft autosave: fieldVoucherDraftAutosave_v1 (line 5115). Pre-migration backup of URL-shaped IDs: fieldVoucherreport_redirect_backup_v1 (line 2896). Photos: IndexedDB database fieldVoucherPhotos_v1, store photos (lines 3299 to 3300), holding Blobs keyed by photo id with a voucherId index. All four take the workspace suffix. Shared across workspaces: fieldVoucherWorkspaces_v1 (line 1303) and fieldVoucherDeviceTag_v1 (line 2006). Legacy keys read once by migration and then ignored: fieldVoucherreport_v1 (line 1304), fieldVoucherCollectors_v1 (line 4159), fieldVoucherDwcExtraFields_v1 (line 4318). Record schema version constant: RECORD_SCHEMA_VERSION = "v2" (line 1579).

Addenda model. Corrections are their own Store type (addendum), each with its own UUID, a parentOccurrenceID, a parentVoucherId, a type of georeference or redetermination, a payload, an author, a device tag, and a timestamp. The voucher row is never rewritten. computeCurrentStateFields() (line 2159) derives the current_* columns: the standing redetermination, and the standing georeference by supersession rather than array order (_geoHeads(), line 2259). Decoration happens at export and render time, never at write time. DERIVED_STATE_KEYS (line 2219) is computed from the same function so the two cannot drift.

One door in. canonicalRecord() (line 2522) is the single entry for every saved and imported record: FIELD_DEFS keys plus four named extras, so an unrecognized column cannot ride into the store. addRecords() (line 2631) is the single import path for JSON, CSV, and take-home bundles. The compiler drawer uses the same canonicalRecord().

Export and import symmetry. JSON nests the addenda array. CSV, XLSX, and HTML serialize the same array into the addenda_json column. parseAddendaField() (line 2137) accepts either shape, so a flat round trip is chain-safe.

Ways out. Files: CSV, JSON, XLSX, photo ZIP, HTML table, email, labels, QR sheets, and QR tags (Part 5). Take it home (section 5.5b, line 7131) writes one ZIP per part holding a viewer page, the JSON and CSV exports, the photos, a manifest with a SHA-256 for every file, and a README; a GitHub route cuts parts under GitHub's browser limits (line 7171). Import bundle (section 5.5c, line 7713) brings a bundle back and checks every photo against its manifest hash before reattaching it. Collection exports for Symbiota, Specify, and DiSSCo share one reader, _csPrepare() (line 8820).

Init sequence (lines 10625 to 10705). Tracer install, kernel migration from legacy keys, theme, dates, kingdom, pristine snapshot, form listeners, Voucher ID and occurrenceID, Darwin Core fields, collectors, autofill guards, section state, autosave check, trip strip, timer arming listeners (click, input, change, capture phase), legacy URL-ID migration followed by vouchers:changed, store reconciliation and the snapshot offer, storage resilience check, the self test when asked for, session badge, top stamp, workspace bar, two-tab presence, and the associated-voucher readout.

Third-party code. One item: Kazuhiko Arase's QR Code Generator for JavaScript, MIT, vendored at lines 1133 to 1173. It runs as its own script and sets window.QR; _ensureQR() (line 2747) only confirms it is there. Everything else, ZIP reader and writer, XLSX writer, EXIF reader and writer, JPEG re-encoder, SHA-256 through the browser's crypto.subtle, is in-file.

B.2 Function reference

All 488 functions in the file, in source order, grouped by the file's own sections. Each description is the one-line comment above the function in the source.

1.1 Workspaces (lines 1278 to 1306)

_wsSanitize(v), line 1295. Folds any workspace name to lowercase letters, digits and hyphens, 24 characters max.

_wsKey(k), line 1300. Storage key for this workspace; the default workspace keeps the bare key.

_wsFileName(n), line 1302. Download filename for this workspace: pests_field_vouchers_2026-09-19.csv.

1.2 Field definitions (lines 1307 to 1526)

_coerceFieldValue(def, value), line 1461. Turns a raw cell value into the type its FIELD_DEFS entry declares (boolean, number, text).

_boolTrue(v), line 1481. True for true, "true", 1 or "1"; everything else is false.

_mergeAddendumCopies(kept, incoming), line 1493. Two copies of the SAME addendum (same id) that disagree on isCurrentIdentification.

1.3 Kernel: Store and Bus (lines 1527 to 1756)

_load(fresh), line 1621. Reads the whole record store from localStorage, using the cache when nothing has changed.

_save(), line 1648. Writes the cache to localStorage and reports a refused write instead of swallowing it.

all(type), line 1664. Every record of one type (a copy, safe to change).

get(type, id), line 1666. One record by type and id, or null.

put(type, id, data), line 1671. Adds or replaces one record, then saves.

remove(type, id), line 1683. Removes one record by type and id, then saves.

replaceAll(type, records), line 1704. Swaps out every record of one type in a single write. 801: this re-reads disk and then throws the re-read away.

mutate(type, fn), line 1715. Read, modify, write against what is on disk NOW. fn receives the current list (a copy, safe to mutate) and returns the list to store; returning nothing keeps the copy it was handed.

filter(pred, type), line 1726. Every record that passes a test, across one type or all of them.

lastWriteOk(), line 1737. Did the last write to localStorage succeed?

invalidateCache(), line 1743. Drops the parsed copy so the next read goes back to disk.

1.4 Storage failure reporting (lines 1757 to 1811)

storageWriteFailed(), line 1768. True while localStorage is refusing writes.

reportStorageWriteFailure(err, what), line 1770. Records a failed write and warns the user once.

noteStorageWriteRecovered(), line 1789. Clears the storage warning once writes succeed again.

on(evt, fn), line 1800. Subscribes a function to a named event.

emit(evt), line 1802. Calls every function subscribed to an event; one failing listener never stops the rest.

1.5 Util (lines 1812 to 1889)

esc(s), line 1816. Escapes text for safe use inside HTML.

_forceLowercase(el), line 1822. Species epithets are never capitalized in botanical nomenclature, but autocapitalize="off" alone is not honoured by every mobile keyboard, so this forces the value itself lowercase as it's typed rather than trusting the attribute.

todayISO(), line 1841. Today's date from local calendar parts, not UTC, so an evening record is not dated tomorrow.

fullName(first, last), line 1849. First and last name joined into one display name.

_isoDateInputFilter(el), line 1857. Keeps a typed date in YYYY-MM-DD form as the user types digits.

uid(), line 1873. A random v4 UUID, with fallbacks for older browsers.

1.6 Legacy to Store migration (lines 1890 to 1948)

_kernelMigrateLegacyIfNeeded(), line 1897. One-time move of old per-feature storage keys into the Store.

2.1 Addenda (lines 1949 to 2320)

_sharedDeviceTag(), line 2008. The install-wide copy of the tag, or "" if none or unreadable.

_rememberSharedDeviceTag(tag), line 2012. Writes the install-wide copy of the tag, only if it is still empty.

sessionId(), line 2016. The per-install session tag: reads it, borrows the shared copy, or mints one on first call.

createAddendum(parentOccurrenceID, parentVoucherId, addendumType, payload, authorFirst, authorLast), line 2038. Files a new addendum (redetermination, georeference, note) against a saved voucher.

getAddendaFor(occurrenceID), line 2081. Every addendum for one occurrenceID, oldest first.

deleteAddendaForOccurrences(occurrenceIDs), line 2094. Deletes the addenda of vouchers being deleted, so none are left pointing at a missing occurrenceID; returns the count.

deleteAllAddenda(), line 2103. Removes every addendum in this workspace; returns how many went.

countAddendaForOccurrences(occurrenceIDs), line 2110. Counts the addenda that point at any of these occurrenceIDs.

deleteAddendum(id), line 2116. Deletes one addendum after a confirm; the voucher itself is untouched.

_normalizeAddendum(a), line 2130. Pulls the addenda chain off an incoming record whatever format carried it in: JSON nests the real array under .addenda, the flat formats carry the same array serialized in the addenda_json column.

parseAddendaField(src), line 2137. Reads the addenda chain from an imported record, whether nested JSON or an addenda_json cell.

computeCurrentStateFields(rec, addenda), line 2159. Works out the current name, determiner and coordinates from a voucher and its addenda.

decorateWithCurrentState(rec), line 2223. A saved voucher plus its live addenda and current-state fields.

decorateAllWithCurrentState(recs), line 2228. decorateWithCurrentState() over a list.

decorateWithNestedAddenda(rec), line 2234. Current-state fields from addenda already on the record (no Store lookup).

decorateAllWithNestedAddenda(recs), line 2239. decorateWithNestedAddenda() over a list.

_geoHeads(addenda), line 2259. The standing georeferences for one specimen, the ones nothing supersedes. One head is current; more than one is a conflict.

_geoLineage(head, addenda), line 2278. The standing georeference followed by everything it was made on top of, newest first, using the same supersession rule as _geoHeads().

findAddendumProblems(vouchers, addenda), line 2295. Finds orphaned addenda and specimens with zero or several current determinations.

findLocalAddendumProblems(), line 2317. findAddendumProblems() over this workspace's own saved data.

2.2 Saved list and import checks (lines 2321 to 2486)

load(), line 2325. Every saved voucher in this workspace.

persist(arr), line 2327. Replaces the saved voucher list.

persistUpdate(fn), line 2332. The read-modify-write form of persist(). Use this for anything that edits the current set (add one, delete one, delete these); persist() stays for a genuine wholesale replacement. fn gets the list as it is on disk at this instant, not as it was when the screen last drew.

_uniqueList(arr), line 2334. Distinct non-empty values, sorted.

_shortIdList(arr, max), line 2336. A short comma list of IDs for messages, with "+N more" when long.

_existingVoucherIdSet(records), line 2341. The set of Voucher IDs already in a record list.

_voucherIdInUse(id, records), line 2347. Is this Voucher ID already taken?

_freshUniqueId(used), line 2349. A new UUID not in the given set; adds it to the set.

_findIncomingIdClashes(items, existingRecords), line 2356. Lists incoming Voucher IDs that clash with saved ones or repeat inside the import.

_blockDuplicateImportIfNeeded(items, existingRecords), line 2369. Stops an import with a message if any Voucher ID would clash.

ingestAddendaFor(item, rec), line 2400. The import half of the addenda fix, and the actual bug: export nested the chain correctly, import ran every record through FIELD_DEFS and threw the nested array on the floor.

_malformedAddendaCell(src), line 2424. True when an addenda_json cell is present but not a readable list.

_addendaIntegrityIssues(src), line 2436. The dialog runs _addendumFormatProblems() on every addendum typed here; an addendum arriving in a file never did, so a georeference with latitude 999 imported clean and became current.

_recordIntegrityIssues(src), line 2448. Format problems in one record (bad coordinates, bad dates) for the import flag.

_joinImportIssues(existing, added), line 2480. Merges two lists of import issues into one "; " string without repeats.

2.3 Canonical record (lines 2487 to 2742)

canonicalRecord(raw, opts), line 2522. Builds one clean record from any input, field by field through FIELD_DEFS.

_fingerprintSource(rec), line 2591. The exact string every fingerprint is taken over.

_recordFingerprint(rec), line 2602. A content fingerprint of one record, used to spot exact duplicates on import.

_recordFingerprintLegacy(rec), line 2615. The pre-801 low-byte hash, kept so old stamps still verify.

_fingerprintMatches(rec, stamp), line 2625. True when a stored fingerprint still matches, old format or new.

addRecords(items), line 2631. Adds imported records to the saved list, merging, de-duplicating and ingesting addenda.

2.4 Voucher identity and QR engine (lines 2743 to 3135)

_ensureQR(cb), line 2747. The QR library runs as its own plain script at page load and sets window.QR; this just confirms it is there, then runs the callback.

voucherId(), line 2756. The draft's current Voucher ID (the QR value).

generateVoucherQR(optId), line 2758. Sets the draft's Voucher ID and draws its QR code.

voucherOccurrenceUUID(), line 2777. The draft's occurrenceID, created on first use.

_draftIdentityKey(), line 2784. Which draft is on screen. The occurrenceID is minted fresh for every new draft and restored with a restored one, and a relabel (new Voucher ID) leaves it alone, so it changes exactly when the specimen changes.

newDraftOccurrenceUUID(), line 2786. Starts a fresh occurrenceID for a new draft and shows it.

queuePhotoIdMigration(oldId, newId), line 2808. Moves the draft's photos to a new Voucher ID, one move at a time.

refreshVoucherQR(), line 2824. Mints a fresh Voucher ID for the draft and redraws its QR.

downloadVoucherQR(), line 2833. Saves the draft's QR code as a PNG.

makeCardQR(id, px), line 2845. A QR code as a PNG data URL, for cards and printouts.

_voucherQrMode(mode), line 2854. Switches the QR panel between a generated ID and a pre-printed sheet ID.

_cleanVoucherId(id), line 2868. Strips control characters and spaces from a Voucher ID.

_voucherIdIsUrl(id), line 2891. True when a Voucher ID is a web address or other resolvable link (not allowed).

async migrateLegacyRedirectVoucherIds(), line 2898. One-time repair: replaces URL-style Voucher IDs from old builds with UUIDs, keeping a backup.

_assignPreprintId(id), line 2966. Takes a scanned or typed pre-printed ID as the draft's Voucher ID.

async startVoucherScan(), line 2997. Opens the camera and starts looking for a QR code.

_doScanFrame(), line 3019. Checks one video frame for a QR code, then schedules the next.

_stopScanStream(), line 3036. Stops the camera and frame loop.

_qrModalSetTarget(t), line 3054. Sets the scan dialog's wording for its job: pre-printed sheet or associated voucher.

openQRModal(mode, target), line 3063. Opens the scan / type ID dialog.

closeQRModal(), line 3074. Closes the scan / type ID dialog and stops the camera.

_qrModalKeydown(e), line 3090. Escape closes the scan dialog.

_qrModalBackdropClick(e), line 3092. A click outside the box closes the scan dialog.

qrModalTab(mode), line 3094. Switches the scan dialog between Scan and Type tabs.

voucherManualIdPreview(), line 3114. Previews a typed ID as a QR code while typing.

voucherAssignManualId(), line 3121. Accepts the typed ID, for the draft or for an associated voucher link.

3.1 Coordinates (lines 3136 to 3260)

_setNoFixFromGPS(reason, displayReason), line 3140. Records a failed GPS attempt without wiping coordinates already entered.

useCurrentLocation(), line 3180. GPS button: the location prompt appears here, on the press, and nothing else prompts.

markManualCoordEntry(), line 3246. Marks coordinates as typed by hand and notes the source.

_coordNoFixClean(), line 3256. True when GPS failed and the coordinate boxes are still empty.

3.2 Photo store (lines 3261 to 3924)

_openPhotoDB(), line 3305. Opens (or creates) the photo database.

_photoId(), line 3335. A unique id for one stored photo.

async resizeImageFile(file, maxDim, quality, stampId), line 3349. Shrinks and re-encodes a photo to JPEG, stamping the Voucher ID into it.

_stampPhotoId(ctx, w, h, id, coverId), line 3400. Burns the Voucher ID into the photo's pixels, sized so a long ID fits on a small photo.

async _restampPhotoBlob(blob, oldId, newId), line 3431. Re-draws a photo's stamped ID after the voucher's ID changes.

sanitizeFilename(s), line 3461. Makes text safe to use as a file name.

_asciiBytes(s), line 3475. Text as null-terminated ASCII bytes, for EXIF fields.

_buildVoucherExifSegment(id), line 3483. Builds an EXIF block that carries the Voucher ID.

entry(tag, type, count, value), line 3507. Writes one EXIF directory entry.

_isExifApp1(bytes, off), line 3533. True when these bytes start an EXIF APP1 segment.

_stripExifApp1(bytes), line 3538. Removes any existing EXIF segments from a JPEG.

async _jpegWithVoucherExif(blob, id), line 3558. Puts the Voucher ID EXIF block into a JPEG.

async _jpegHasVoucherExif(blob, id), line 3569. Does this JPEG already carry this Voucher ID in its EXIF?

_photoExt(p), line 3600. The file extension that matches what is actually stored.

photoFilename(idVal, index, p), line 3611. <voucherId>_NN.<ext> for one photo in an export.

stablePhotoNames(voucherIdVal, photos, rec), line 3625. Photo id to filename for a voucher's photos. A photo keeps the first name it went out under; a new photo takes the lowest unused number.

async addPhotosToVoucher(voucherIdVal, files), line 3647. Saves photos to the database under one Voucher ID.

async getPhotosForVoucher(voucherIdVal), line 3687. Every stored photo for one Voucher ID.

async photoVoucherIdCounts(), line 3745. Every voucherId that has at least one photo row against it, with a count.

async deletePhoto(photoId), line 3766. Deletes one stored photo.

async deletePhotosForVoucher(voucherIdVal), line 3775. Deletes every photo for one Voucher ID.

async clearAllPhotos(), line 3787. Deletes every stored photo in this workspace.

async migratePhotosVoucherId(oldId, newId), line 3799. Moves photos from an old Voucher ID to a new one, re-stamping each.

async renderPhotoGallery(), line 3829. Draws the thumbnail strip under the Photos field.

async deletePhotoUI(photoId), line 3853. Removes one photo after a confirm.

onPhotoSkipToggle(), line 3859. Shows or hides the "no photo" reason box.

openPhotoInput(which), line 3888. Opens the camera or the picker from a real user press.

async handlePhotoInput(inputEl), line 3897. Handles photos picked in the form: store, stamp, check GPS.

3.3 EXIF GPS (lines 3925 to 4078)

async extractExifGPS(file), line 3944. Reads GPS coordinates from a JPEG's EXIF, or null.

_parseExifGPS(view, tiffBase), line 3967. Walks the EXIF directories to the GPS tags and returns decimal lat/lon.

_haversineMeters(lat1, lon1, lat2, lon2), line 4027. Distance in meters between two lat/lon points.

async reconcileExifGPS(files, draftKey), line 4034. Compares photo GPS with the form's coordinates; fills or flags as needed.

3.4 Voucher timer (lines 4079 to 4146)

beginVoucherTimer(), line 4113. Starts the draft's timer.

disarmVoucherTimer(), line 4117. Clears the draft's timer.

_isDraftSurface(el), line 4126. True when an element is part of the draft (form or QR block).

_armVoucherTimerOnFirstAction(ev), line 4131. Starts the timer on the first real edit to the draft.

3.5 Collectors (lines 4147 to 4305)

_collectorsLoad(), line 4165. The saved collector list.

_collectorsPersist(), line 4170. Saves the collector list.

_collectorsSyncFromDOM(), line 4175. Copies typed collector names from the form into memory.

_collectorsSyncAndSave(), line 4183. Reads, saves and passes collector changes on to the trip strip and Identified-by.

_syncDetFromCollector(), line 4196. Mirrors the primary collector's name into the Identified-by (det) fields: the collector is the default determiner at time of discovery.

renderCollectorRows(), line 4209. Draws the collector rows.

addCollectorRow(), line 4241. Adds an empty collector row.

removeCollectorRow(id), line 4250. Removes a collector row, with Undo.

moveCollectorRow(id, dir), line 4268. Moves a collector up or down the list.

getCollectorNamesList(), line 4281. Every collector's full name, primary first.

getPrimaryCollectorFirst(), line 4286. The primary collector's first name.

getPrimaryCollectorLast(), line 4288. The primary collector's last name.

getAdditionalCollectorsFromForm(), line 4290. The other collectors, joined with " | ".

_restoreExtraCollectorValues(), line 4294. Redraws the collector rows from memory.

initCollectors(), line 4298. Loads saved collectors and draws their rows at startup.

3.6 Additional Darwin Core fields (lines 4306 to 4572)

_dwcLoadSchema(), line 4321. The saved list of added Darwin Core terms.

_dwcSaveSchema(), line 4326. Saves the list of added Darwin Core terms.

_dwcSafeId(term), line 4330. A Darwin Core term made safe for use in an element id.

_dwcCategoryFor(term), line 4332. The picklist category a Darwin Core term belongs to.

_dwcAllTermsFlat(), line 4337. Every Darwin Core term in the picklist, with its category.

filterDwcTerms(), line 4344. Filters the Darwin Core picklist as the user types.

hideDwcTermDropdown(), line 4365. Hides the Darwin Core picklist.

selectDwcTerm(term), line 4367. Adds the chosen Darwin Core term to the form.

_dwcAddRow(term, value), line 4379. Draws one added Darwin Core row, with an optional value.

removeDwcExtraField(term), line 4395. Removes an added Darwin Core row, with Undo.

getDwcExtraObjectFromForm(), line 4412. The added Darwin Core values as {term: value}.

packDwc(obj), line 4422. {term: value} packed as "term: value | term: value".

getDwcExtraFieldsFromForm(), line 4426. The added Darwin Core values, packed.

_parsePackedDwc(str), line 4453. The packed extraDwcFields string as a term/value object.

dwcShapeConflicts(rec), line 4462. Terms where a record's dwc object and its packed string disagree.

parseExtraDwc(rec), line 4473. A record's added Darwin Core terms, from either shape or both.

dwcColumnsFor(recs, opts), line 4499. The added Darwin Core columns for an export, in a stable order: this workspace's configured terms first, then any others the records carry.

exportFieldDefs(recs, opts), line 4520. The export column list: FIELD_DEFS with added Darwin Core columns in place.

flattenDwc(rec), line 4528. One record with its added Darwin Core values spread into their own columns.

flattenAllDwc(recs), line 4535. flattenDwc() over a list.

dwcTermForHeader(h), line 4541. Matches an import column header to a Darwin Core term, any case.

dwcFromColumns(headers, cells, taken), line 4549. Collects Darwin Core values from import columns nothing else claimed.

attachDwc(rec, src, fromCols), line 4561. Puts the Darwin Core values on a record, both as an object and packed.

initDwcExtraFields(), line 4568. Restores the added Darwin Core rows at startup.

3.7 Associated voucher (lines 4573 to 4773)

_dwcEnsureTerm(term), line 4600. Adds a Darwin Core row to the form if it isn't there; returns its input.

_assocParts(v), line 4605. Splits a " | " list into parts.

_assocSet(el, parts), line 4607. Writes a DwC row's value the way typing would, so Draft and the autosave see it.

invalidateWorkspaceIndex(), line 4636. Drops the cross-workspace index so the next lookup rebuilds it.

_workspaceIndex(), line 4638. The parsed index of every other workspace's vouchers, built once and reused.

unreadableWorkspaces(), line 4667. The workspaces whose record store could not be read just now.

findVoucherAnywhere(idOrOcc), line 4669. Finds a voucher by Voucher ID or occurrenceID in any workspace here.

_assocRel(), line 4677. The relationship, tidied: no parentheses or bars, which would break the DwC string.

linkAssociatedVoucher(raw), line 4683. The link itself: look the scanned ID up, write the DwC rows, show it.

unlinkAssociatedVoucher(i), line 4709. Removes one link, and its associatedTaxa entry when there is one.

renderAssocReadout(), line 4727. One line per link: relationship, name, and where it lives.

toggleOtherTaxa(force), line 4756. The Other taxa bar under Additional Darwin Core field: opens and closes the associated-voucher block.

initAssociatedVoucher(), line 4765. Restores the associated-voucher controls at startup.

3.8 Suggestions (lines 4774 to 4936)

renderOptionDropdown(box, items, rowHtml), line 4784. Fills a suggestion box with rows, or hides it when empty.

hideDropdown(boxId), line 4791. Hides a suggestion box.

_recentValuesFor(key, max), line 4804. Recent distinct values of one field across saved vouchers.

showRecentSuggestions(fieldId, boxId, key), line 4813. Shows recent values under a field.

hideRecentSuggestions(boxId), line 4826. Hides recent values.

_recentPick(fieldId, idx), line 4828. Puts a picked recent value into its field.

_familyCandidates(), line 4862. Family names to suggest: this workspace's own first, then the seed list.

showFamilySuggestions(), line 4875. Shows family suggestions as the user types.

_familyPick(i), line 4889. Puts the picked family into the field.

_familyKey(e), line 4899. Arrow keys walk the list, Enter or Tab takes the highlighted row (Tab takes the top row when none is highlighted), Escape closes it.

updateRecentCollectorNames(), line 4919. Refreshes the collector name suggestions from saved vouchers.

3.9 Trip strip (lines 4937 to 4955)

renderTripStrip(), line 4943. Draws the trip strip: collectors and institution for this sitting.

3.10 Duplicate for this site (lines 4956 to 5059)

_legacyAdminKeys(o), line 4982. Maps the old State/Province and County/Parish draft keys onto their current names.

_siteFromForm(), line 4992. The site fields (place, coordinates, habitat) from the form.

_siteHasContent(site), line 5004. True when a site has a locality or coordinates.

rememberSite(site), line 5009. Remembers the last site for Duplicate for this site.

lastRememberedSite(), line 5013. The last remembered site, or null.

_applySite(site), line 5018. Writes a site back into the form.

_draftHasSpecimenContent(), line 5030. True when the draft has any specimen-level content.

async duplicateForThisSite(), line 5034. Starts a new draft at the same site as the last one.

3.11 Draft state (lines 5060 to 5086)

_draftSyncFromDOM(), line 5076. Copies every form field into Draft.

draftVal(id), line 5085. One draft value: from Draft, else from the form.

3.12 Draft autosave (lines 5087 to 5294)

_draftIdentitySnapshot(), line 5121. The draft's IDs, photo choice and added fields, for the autosave.

_draftAutosaveNow(), line 5142. Writes the draft autosave now.

scheduleDraftAutosave(), line 5161. Autosaves the draft shortly after typing stops.

clearDraftAutosave(), line 5166. Deletes the draft autosave.

capturePristineDraft(), line 5184. Notes what a blank form looks like, so the autosave can tell real edits.

_draftHasContent(data), line 5189. True when a draft differs from a blank form.

formHasUnsavedContent(), line 5215. True when the form holds unsaved work.

checkForDraftAutosave(), line 5230. Offers to restore a saved draft at startup.

_showDraftRestoreBanner(data, photoCount), line 5249. Shows the restore banner for a saved draft; photoCount > 0 says photos are the reason.

restoreDraftAutosave(), line 5278. OWNERSHIP, stated once so the next person reading this doesn't have to infer it from call sites: Store owns record state.

discardDraftAutosave(), line 5285. Throws away the saved draft and hides the banner.

dismissDraftRestoreBanner(), line 5290. Hides the restore-draft banner.

3.13 Apply a restored draft (lines 5295 to 5381)

applyDraftSnapshot(data), line 5301. Writes a restored draft back into the form.

3.14 Form (lines 5382 to 5441)

voucherFromForm(), line 5384. Builds a voucher record from the form.

3.15 Save gate (lines 5442 to 5844)

isNotRecorded(v), line 5487. True when a value is the "Not recorded" placeholder.

recordedOrBlank(v), line 5489. A value, or blank if it is "Not recorded".

fillNotRecorded(v), line 5491. Fills empty optional fields with "Not recorded"; returns which.

clearFieldHighlights(), line 5500. Removes red highlights from form fields.

_isRealISODate(s), line 5514. The gate tested non-empty and stopped there, so latitude=banana, longitude=x and 2026-99-99 all sailed through and landed in the record, the export, and GBIF's lap.

_coordProblem(raw, limit, name), line 5524. A message if a coordinate is not a valid decimal number in range, else "".

validateFieldFormats(), line 5533. Checks coordinate and date formats before a save.

validateRequiredFields(), line 5559. Lists required fields that are empty.

async validatePhotoRequirement(voucherIdVal), line 5599. The photo rule at save: a photo, or No photo ticked, never both.

showIncompleteVoucherModal(missing), line 5615. Shows the incomplete-voucher dialog and opens the sections at fault.

async saveVoucher(ev), line 5660. The form submit handler. Does one thing of its own, which is to refuse a second save while the first is still running, then hands off to _saveVoucherBody.

async _saveVoucherBody(), line 5671. The save itself, run one at a time by saveVoucher above.

rememberKingdom(v), line 5784. Kingdom belongs to the sitting, not to the specimen.

lastKingdom(), line 5788. The last kingdom used, or Plantae.

resetVoucherForm(), line 5793. Clears the form for the next voucher, keeping sitting-level values.

async clearVoucherDraft(skipConfirm), line 5830. Discards the draft and its photos, after a confirm if needed.

3.16 Autofill spill guard (lines 5845 to 5982)

_suppressBrowserAutofill(root), line 5860. Stops the browser's own address autofill from cross-wiring the form.

spillGuardedIds(), line 5897. The field ids the autofill spill guard watches, as a live list.

_spillSnapshot(), line 5912. The guarded fields' values before autofill can touch them.

_installAutofillSpillGuard(), line 5918. Installs the guard that undoes browser autofill spilling into other fields.

4.1 Render list, search, sort, bulk select (lines 5983 to 6189)

onVoucherSearchInput(), line 5990. Search box typed: filter the saved list.

onVoucherSortChange(), line 5996. Sort menu changed: re-sort the saved list.

toggleSelectAllVouchers(checked), line 6002. Ticks or clears every voucher currently shown.

toggleVoucherSelected(id, checked), line 6009. Ticks or clears one voucher.

updateBulkControls(), line 6014. Enables the bulk buttons and shows how many are ticked.

bulkDeleteSelected(), line 6029. Deletes every ticked voucher, with their photos and addenda.

renderVouchers(), line 6047. Schedules a list redraw 80 ms out, so a burst of changes draws once.

_renderVouchersNow(), line 6052. Draws the saved-voucher list now: filter, sort, cards.

async renderCardPhotos(voucherIdVal, slot), line 6159. Fills one card's photo strip.

4.2 Addendum UI (lines 6190 to 6438)

_addendaBadgeHTML(a), line 6197. The badge that says which device filed an addendum.

_addendumSummaryHTML(a), line 6206. One line describing an addendum's content.

renderAddendaPanelHTML(occurrenceID), line 6225. The addenda panel shown on a saved voucher's card.

_addendumPersonDefault(voucherId), line 6256. Who an addendum should be attributed to before anyone edits it, in the order of preference set out above.

_rememberAddendumPerson(first, last, orcid), line 6268. Remembers who filed the last addendum, for next time.

showAddAddendumModal(voucherId, occurrenceID, type), line 6274. Opens the add-addendum dialog for one voucher.

closeAddendumModal(), line 6298. Closes the add-addendum dialog.

_addendumFormatProblems(type, payload), line 6310. The save gate learned to check coordinates and dates in 108; this door never did, and it's the worse of the two.

submitAddendumModal(), line 6325. Checks and saves the addendum from the dialog.

deleteVoucher(id), line 6387. Deletes one voucher, its photos and addenda, with Undo.

openClearAllDialog(), line 6400. Opens the Clear all dialog.

closeClearAllDialog(), line 6402. Closes the Clear all dialog.

async confirmClearAll(), line 6412. Clears every voucher, addendum and photo, and redraws only after the photo sweep has finished.

4.3 Addendum problems note (lines 6439 to 6471)

renderAddendumProblems(), line 6450. findLocalAddendumProblems() has existed since the addenda went in and nothing ever called it outside the Compiler Drawer, so the local report could hold an orphan or two disagreeing redeterminations and show no sign of either.

5.1 Download helpers (lines 6472 to 6496)

downloadBlob(name, content, mime), line 6474. Saves content to a file with the workspace-prefixed name.

downloadCSV(name, csv), line 6484. Saves CSV text (with a BOM so Excel reads UTF-8).

downloadJSON(name, obj), line 6486. Saves an object as pretty-printed JSON.

downloadHTMLFile(name, html), line 6488. Saves an HTML string as a file.

recordsFor(ids), line 6491. The saved vouchers with these IDs, or all of them.

5.2 CSV export and import (lines 6497 to 6598)

_csvFormulaSafe(v), line 6515. A cell that starts with = + @ or a tab is a FORMULA to Excel, LibreOffice and Sheets, not text: they evaluate it on open, and =cmd|'/c ...'!A1 is a live command.

csvUnescapeFormulaGuard(v), line 6522. Removes the formula guard quote added on export.

csvEscape(v), line 6527. One CSV cell: formula-guarded and quoted when needed.

toCSV(records), line 6533. Records as CSV text.

exportCSV(ids), line 6540. Exports vouchers as CSV.

parseCSV(text), line 6549. Parses CSV text into rows of cells.

importCSV(ev), line 6569. Imports vouchers from a CSV file.

5.3 JSON export and import (lines 6599 to 6710)

async recordsWithPhotoManifest(recs), line 6606. Photo files travel separately via Export All Photos (ZIP); this JSON carries a manifest only.

async exportJSON(ids), line 6692. Exports vouchers as JSON, with a photo manifest.

importJSON(ev), line 6699. Imports vouchers from a JSON file.

5.4 ZIP and XLSX export (lines 6711 to 6936)

crc32(bytes), line 6717. CRC-32 checksum of bytes, for ZIP entries.

_u16(n), line 6727. A 16-bit number as two little-endian bytes.

_u32(n), line 6729. A 32-bit number as four little-endian bytes.

strToBytes(s), line 6731. Text as UTF-8 bytes.

base64ToBytes(b64), line 6733. Base64 text as bytes.

colLetter(n), line 6740. A column number as a spreadsheet letter (1 = A, 27 = AA).

xmlEsc(s), line 6773. Escapes text for XML, keeping line breaks.

makeZip(files), line 6782. Packs files into an uncompressed ZIP (used for XLSX and photo exports).

buildXLSX(records), line 6824. Builds an XLSX workbook (with QR images) from records.

exportXLSX(ids), line 6923. Exports vouchers as XLSX.

5.5 Photo ZIP export (lines 6937 to 7009)

async exportAllPhotosZip(ids), line 6953. Exports every photo for these vouchers as one ZIP, with a manifest.

5.5b Keep a copy of this file (lines 7010 to 7130)

_downloadFolderHint(), line 7041. Names this device's usual download folder, for the notice.

saveThisFileToDevice(), line 7059. Saves a copy of this report file itself to the device.

async _finishSaveToDevice(bytes, picked, name), line 7085. Waits for the bytes and the folder choice, then writes the copy.

5.5b Take it home (lines 7131 to 7712)

async _sha256Hex(bytes), line 7177. SHA-256 of some bytes as lowercase hex, or "" where the browser offers no crypto.subtle.

makeZipBlob(entries), line 7189. A stored (uncompressed) ZIP as a Blob. Same layout and UTF-8 name flag as makeZip(), but each entry's data may be a Blob that is never copied: the photo's own Blob goes into the archive by reference, so a 200 MB part never needs 200 MB of bytes in memory at once.

async _bundleTextEntry(name, text), line 7210. One text file as a ZIP entry, with its crc, size and SHA-256.

_bundledPhotoMap(), line 7216. Photo id to the name of a saved or shared bundle that holds it.

async _bundlePlan(recs, limits, opts), line 7223. Reads which photos each voucher has and cuts the vouchers into parts.

_bundleCard(rec, photoPaths), line 7253. One voucher as the viewer page shows it. Names come through _csPrepare() and _odsNameHTML(), the same reader every other export uses, so the page and the files beside it cannot disagree on the current name.

_bundleViewerHTML(meta, cards), line 7276. The open-me.html page. Everything it shows is embedded, so it opens from a folder with no server and no network.

_bundleReadme(meta, fileCount), line 7339. README.txt for one part: how to open it and how to check it.

async _bundleBuildPart(plan, k, bundle), line 7375. Builds one part: reads each photo once (for its crc and SHA-256), then hands the photo's own Blob to the ZIP.

_takeHomeSettings(), line 7442. Where bundles are going, remembered per workspace: route ("device" or "github"), and for GitHub the owner/repo and branch.

_githubRepoClean(v), line 7447. Owner/repo as GitHub allows it, or "" when it is not one.

_githubUploadURL(repo, branch), line 7452. The repo's upload page, pointed at the take-home folder on that branch.

async openTakeHomeModal(ids, opts), line 7457. Opens the Take it home dialog for these vouchers, or all of them.

async _takeHomeSetNewOnly(on), line 7470. The "only photos not taken home yet" box.

async _takeHomeReplan(), line 7477. Cuts the parts for the chosen route and redraws.

async _takeHomeSetting(key, value), line 7485. Saves a change to the route, repo or branch and replans.

_takeHomeGithubNote(), line 7495. The GitHub line: the upload link once the repo is valid, and what to do there.

_takeHomeRender(), line 7507. Draws the dialog from _takeHome.

closeTakeHomeModal(), line 7555. Closes the Take it home dialog and lets go of any built ZIPs.

async _takeHomePrepare(k), line 7562. Builds one part, logs what went into it, then offers Share and Save.

_takeHomeHandedOff(k, how), line 7589. Marks a part as having left the device, in the log the Remove step reads.

async _takeHomeShare(k), line 7595. Sends a prepared part through the device's share sheet.

_takeHomeSave(k), line 7610. Downloads a prepared part.

async clearBundledPhotos(), line 7629. The Remove step. Walks every saved voucher; a voucher's photos go only if every one of them is in a bundle that was saved or shared and still hashes to what that bundle recorded.

5.5c Bring it back, and what is not safe yet (lines 7713 to 7960)

async _putPhotoRecord(rec), line 7727. Writes one photo row straight into the photo store, keeping its original id.

async importBundleFiles(ev), line 7736. The Import bundle button: every picked ZIP, in name order.

async _importOneBundle(file, total), line 7758. One take-home ZIP: records in through addRecords(), then each photo checked and re-attached to the voucher as saved here.

scheduleTakeHomeNote(), line 7849. Schedules a refresh of the not-yet-taken-home line.

async renderTakeHomeNote(), line 7854. Counts vouchers with photos in no handed-off bundle, and draws the line.

_anyDialogOpen(), line 7908. True when some other dialog is already up, so the offer waits its turn.

async maybeOfferSnapshot(), line 7913. Decides whether to offer a snapshot now, and offers it.

_snapshotSnooze(hours), line 7947. Closes the offer and holds off for this many hours.

_snapshotYes(), line 7953. Yes: close the offer, open the snapshot. The short snooze keeps the offer from coming straight back if the snapshot is then abandoned.

5.6 Email (lines 7961 to 7997)

async emailreport(ids), line 7969. Shares or emails the JSON export.

5.7 HTML table export (lines 7998 to 8030)

plainDoc(title, inner, extraCSS), line 8000. A plain printable HTML page around some content.

exportHTMLTable(ids), line 8018. Exports vouchers as a printable HTML table.

5.8 Print labels (lines 8031 to 8178)

_taxonHTML(name, qualifier), line 8041. A determination as label HTML: name words italic, qualifier and rank connectors roman.

labelInner(v), line 8058. The inside of one herbarium label.

labelDoc(records), line 8107. A page of herbarium labels, four by three inches.

printHTMLNow(html), line 8139. Opens HTML in a new window and prints it.

printLabels(ids), line 8173. Opens herbarium labels for printing: the current determination on top, the original kept as Orig. det.

5.9 QR sheets (preprint) (lines 8179 to 8371)

_voucherIdTail(id), line 8197. Last four of a Voucher ID, lowercase; "" if too short.

_setVoucherIdLabel(el, id), line 8202. Writes a Voucher ID into the draft QR panel with its last four in bold.

_qrSheetDefaultRows(), line 8209. The sheet's lines as [{label, value}], remembered in uiState.

_qrSheetRows(), line 8217. The saved QR sheet lines, or the defaults.

_qrSheetRowsFromDOM(), line 8225. Reads the row editor back out of the dialog; empty rows drop.

_qrSheetSave(), line 8232. Saves as you type, so Cancel still remembers.

_qrSheetRenderRows(rows), line 8234. Draws the row editor: label, value, remove.

_qrSheetAddRow(), line 8244. Adds an empty line and puts the cursor in its label.

_mintSheetIds(n), line 8255. Mints n lowercase v4 UUIDs whose last four are unique within the batch and don't repeat any last four already on a voucher in this browser (or the current draft).

qrSheetDoc(ids, fields), line 8273. Builds the print window: one letter page per UUID.

openQrSheetModal(), line 8310. Opens the QR sheets dialog, building it on first use.

closeQrSheetModal(), line 8344. Closes the QR sheets dialog.

_qrSheetKeydown(e), line 8351. Escape closes the dialog.

_qrSheetSetCount(n), line 8353. Picks 10/20/50/100 and lights the matching button.

_qrSheetResetFields(), line 8358. Back to the default six lines, names from the primary collector.

printQrSheets(), line 8363. Saves the lines, mints the batch, opens the print window.

5.10 QR tags (reconciliation print) (lines 8372 to 8624)

_qrTagPerPage(), line 8412. The saved tags-per-page choice, or 12.

_qrTagSetPerPage(n), line 8418. Picks 20/12/6 per page, lights the button, remembers it.

_qrTagNameHTML(v), line 8425. The name line for a tag: qualifier upright, binomial in italics.

_qrTagTime(v), line 8434. 24-hour local time the draft was started (falls back to save time).

_qrTagDates(), line 8440. Distinct collection dates with counts, newest first.

_qrTagRecords(scope), line 8446. The records a scope names, oldest saved first (field order).

_qrTagRenderScopes(), line 8453. Fills the scope picker: selected (if any), each day, all.

_qrTagRenderList(), line 8464. Draws the checklist for the current scope; sheet-ID vouchers start unticked.

_qrTagToggle(cb), line 8480. One checkbox in or out of the print.

_qrTagSetAll(on), line 8485. All / None for the checklist.

_qrTagCount(), line 8489. Keeps the Print button honest about how many tags it makes.

_qrTagSetScope(v), line 8495. Scope picker changed.

qrTagDoc(recs, perPage), line 8498. Builds the print window: a letter-page grid of cut-apart tags, grouped under a collector and date heading.

openQrTagModal(), line 8560. Opens the QR tags dialog, building it on first use.

closeQrTagModal(), line 8598. Closes the QR tags dialog.

_qrTagKeydown(e), line 8605. Escape closes the dialog.

printQrTags(), line 8608. Prints the ticked vouchers in save order. The print window is opened inside the click so a pop-up blocker doesn't eat it.

5.11 Collection systems: Symbiota, Specify and DiSSCo (lines 8625 to 9518)

_csTermURI(name), line 8703. The term URI for a column name: Dublin Core, Symbiota, or Darwin Core.

_csCell(v), line 8709. One CSV cell for a machine file: control characters dropped, RFC 4180 quoting, nothing else.

_csCSV(headers, rows), line 8715. A header and rows of objects as CSV text, LF line ends, trailing newline.

_csTidy(v), line 8721. Trims and collapses runs of whitespace.

_csFull(first, last), line 8723. "First Last" from two parts, skipping blanks.

_csParseName(raw), line 8729. Splits a scientific name into genus, epithet, rank, infraspecific epithet and authorship.

_csMeters(raw), line 8764. Reads "±4365 m", "10m", "0.5 km", "30 ft", "0.5 mi" as whole metres.

_csQualifier(raw), line 8778. Ironfist's qualifier as a collection system writes it. determined is no qualifier.

_csUSState(raw), line 8788. A U.S. state or territory by postal code or full name. { value, matched }.

_csOrcid(raw), line 8800. A bare ORCID iD as its https://orcid.org/ URI; anything else passes through.

_csSplitCollectors(raw), line 8807. additionalCollectors ("A B | C D") as [{ first, last, guess }].

_csPrepare(recIn), line 8820. The one reader. Every derived value both exports use, worked out once from a decorated record (addenda and current_* fields on it), plus the review flags.

_csReviewRows(P, system), line 8970. review.csv rows for one prepared record, only the flags that apply to this system.

_csReview(rows), line 8976. review.csv text, sorted fix, then check, then info; with the counts.

_csSymbiotaOcc(P), line 8988. The occurrence core row for one prepared record, plus any added-term conflicts as flags.

_csSymbiotaDets(P), line 9021. Identification extension rows for one prepared record, oldest first.

_csMetaXml(occCols, detCols), line 9030. meta.xml for the archive: core and extension, one field element per column after the key.

buildSymbiotaArchive(recs), line 9045. The Symbiota ZIP entries ([{ name, text }]) for these decorated records, and the review counts.

_csSpecifyTaxonCols(n, h, family), line 9096. One determination's WorkBench columns as [header, value] pairs. n is 1 for the current one (no prefix, and Family), 2 and up for earlier ones ("Det 2 ...").

_csSpecifyRow(P, nColl, nDet, addedTerms), line 9117. One WorkBench row as [header, value] pairs, sized to the most collectors and determinations any exported record has, so every row has the same columns.

_csSpecifyTarget(header), line 9145. Where a WorkBench column goes in Specify's stock schema: { table, field, note }.

buildSpecifyWorkbench(recs), line 9208. The Specify ZIP entries ([{ name, text }]) for these decorated records, and the review counts.

_odsClean(v), line 9301. Drops empty strings, nulls, empty arrays and objects left holding nothing but their @type, so the file carries no hollow keys.

_odsNum(s), line 9313. A decimal string as a number, or undefined when it isn't one.

_odsAgent(first, last, orcid, role, position), line 9315. One person as an openDS agent with a single role.

_odsNameHTML(n), line 9325. ods:scientificNameHTMLLabel: genus and epithets italic, the × outside the italics, rank marker and authorship roman.

_odsSpecimen(P), line 9340. One prepared record as an ods:DigitalSpecimen.

buildOpenDS(recs), line 9430. The openDS ZIP entries ([{ name, text }]) for these decorated records, and the review counts.

_csDownload(built, base, label), line 9488. Zips a builder's entries and downloads them, then reports the review counts.

async exportSymbiota(ids), line 9496. Export for Symbiota: a Darwin Core Archive ZIP of these vouchers, or all of them.

async exportOpenDS(ids), line 9502. Export for DiSSCo: an openDS Digital Specimen ZIP of these vouchers, or all of them.

async exportSpecify(ids), line 9508. Export for Specify: a WorkBench data set ZIP of these vouchers, or all of them.

6.1 Toasts (lines 9519 to 9563)

showUndoToast(message, undoFn), line 9531. Shows a message with an Undo button for a few seconds.

hideUndoToast(), line 9541. Hides the Undo message.

runUndo(), line 9548. Runs the pending undo, then hides the message.

showNotice(message, isError), line 9554. Shows a short message; errors stay up longer.

6.2 Section state (lines 9564 to 9577)

initSectionPersistence(), line 9568. Remembers which form sections are open or closed.

6.3 Theme (lines 9578 to 9593)

toggleTheme(), line 9580. Switches light and dark theme and remembers it.

initTheme(), line 9587. Applies the saved theme, or the system's.

6.4 Build name (lines 9594 to 9608)

buildLabel(), line 9600. The short form the header chip wears, I.F.<n>.

applyBuildName(), line 9602. Puts the build name in the tab title and the header stamp.

6.5 Workspace bar (lines 9609 to 9728)

_wsHue(name), line 9619. A stable hue from the workspace name. I just know this is gonna bite me in the arse.

_wsColor(name), line 9625. The workspace's color, or none for the default.

_wsListLoad(), line 9627. The shared list of workspaces this browser has opened.

_wsListRemember(name), line 9632. Adds a workspace to the shared list.

_wsHref(name), line 9641. URL for a workspace: this same file, ?ws=name (none for default).

_wsFavicon(), line 9643. Tab icon: the workspace's first letter on its color.

renderWorkspaceBar(), line 9657. Paints the bar, the stripe and the icon for this workspace.

_wsRenderList(), line 9673. The switcher list: default plus every named workspace, as plain links.

toggleWorkspacePanel(force), line 9683. Opens or closes the workspace switcher.

confirmWorkspaceSwitch(name), line 9693. New workspace: tidy the name, remember it, go there in this tab.

guardWorkspaceLink(e), line 9705. Intercepts a click on a workspace link so the draft is flushed before the navigation takes the page away.

openNewWorkspace(), line 9715. Reads the name box, normalises it to the lowercase-hyphen shape workspace names are limited to, and opens that workspace.

6.6 Same workspace, two tabs (lines 9729 to 9775)

_wsBeat(), line 9744. Tells other tabs this workspace is open here.

_wsWarnTwice(), line 9746. Warns that this workspace is also open in another tab.

initWorkspacePresence(), line 9753. Starts the two-tabs check.

6.7 Session badge and top stamp (lines 9776 to 9823)

renderSessionBadge(), line 9785. Shows the schema version and short device tag.

renderTopStamp(), line 9793. Shows when this page was opened and the short device tag.

async copySessionIdToClipboard(elId), line 9801. Copies the full device tag to the clipboard.

7.1 Storage resilience (lines 9824 to 9991)

_persistAsked(), line 9884. Has this browser already been asked for persistent storage?

_markPersistAsked(), line 9890. Notes that this browser has now actually answered the ask.

requestPersistNow(), line 9895. The storage strip's button: asks this browser to keep the data.

_persistWithTimeout(), line 9910. Resolves to null if the browser prompt goes unanswered, so nothing here hangs on it.

async checkStorageResilience(opts), line 9917. Checks how safe local storage is here and shows a note if it is at risk.

7.2 Store reconciliation (lines 9992 to 10185)

async reconcileStores(), line 10024. Compares saved vouchers against stored photos and reports orphans either way.

async purgeOrphanPhotos(), line 10127. Deletes every photo with no voucher behind it, after confirming.

async exportOrphanPhotos(), line 10151. Saves the orphaned photos to a ZIP before anyone deletes them.

7.3 Runtime tracer (?trace) (lines 10186 to 10300)

_traceOn(), line 10220. True when the URL asks for ?trace.

_traceWrap(name, fn), line 10225. Wraps a function so the tracer counts its calls and callers.

_traceModule(moduleName, fns), line 10244. Wraps every function in a module object for the tracer.

_traceInstall(), line 10251. Turns the tracer on for every global function (only with ?trace).

_traceReport(), line 10283. What the tracer has seen so far.

_traceDump(), line 10293. Prints the tracer report to the console.

7.4 Self test (?selftest) (lines 10301 to 10612)

async runSelfTest(), line 10318. The ?selftest round trip: one fixture voucher through store, exports and back.

mapTableForSelfTest(headers, rows), line 10595. Maps a parsed table's columns to FIELD_DEFS, for the self test's CSV re-import.

9.2 Compiler drawer: merge rules (lines 10959 to 11082)

withCompileBatchId(recs), line 10967. Stamps this compile's batch id on each record.

mergeAddendaInto(existing, incoming), line 10979. Folds a second copy's addenda into the kept record, deduplicated by addendum id, so a duplicate occurrenceID no longer loses its corrections.

mergePhotoLists(target, winnerPhotos, otherPhotos), line 11005. Union of two photo manifests for one specimen.

_stampMs(v), line 11026. A timestamp as ms, or null.

mergeFieldsInto(existing, incoming, incomingStamp), line 11039. The field half of a same-occurrenceID merge: the newer copy's values win, photo lists are unioned, and the copy set aside is recorded as a conflict.

9.3 Compiler drawer: compile and display (lines 11083 to 11241)

el(id), line 11085. Shorthand for document.getElementById inside the drawer.

recordKey(r), line 11095. The merge key for a record: its occurrenceID, or its Voucher ID when it has none.

mergeRecords(items, sourceLabel), line 11101. Merges one source's records into the compiled set, counting what changed.

cleanRecords(), line 11146. The compiled records without the drawer's own bookkeeping fields.

collectorsCount(), line 11154. How many distinct collectors are in the compiled set.

photoRefNames(), line 11163. Every photo filename the compiled records point at.

fingerprintDrift(), line 11178. The provenance question a reviewer actually asks about a drawer that holds its own private copy of everything: can what it exports disagree with the vouchers it read?

render(), line 11189. Redraws the drawer's counts, messages and conflict list.

9.4 Compiler drawer: readers (lines 11242 to 11466)

mapTable(headers, rows), line 11244. Maps table columns to FIELD_DEFS and returns records.

parseJsonText(text), line 11264. Voucher records from JSON text.

parseCsvText(text), line 11271. Voucher records from CSV text.

parseHtmlText(text), line 11277. Voucher records from an exported HTML table.

async _inflateRaw(bytes, expected), line 11300. Inflates one deflated ZIP entry, refusing a runaway one.

async readStoredZip(buffer), line 11321. Reads an uncompressed ZIP into {path: bytes}.

colIndex(ref), line 11364. A spreadsheet cell reference's column as a 0-based index.

_xmlDoc(bytes, what), line 11370. Parses XML bytes, with a clear error if broken.

_firstSheetPath(entries), line 11383. The path of the first worksheet in workbook order.

_excelSerialToISO(v), line 11404. An Excel date serial as YYYY-MM-DD; other values pass through.

async parseXlsxBuffer(buffer), line 11411. Voucher records from an XLSX file.

async parsePhotoZip(buffer), line 11447. Loads photos and their manifest from a photo ZIP.

9.5 Compiler drawer: file intake (lines 11467 to 11532)

async processFile(file), line 11469. Reads one dropped file by type: JSON, CSV, HTML, XLSX or photo ZIP.

async loadFiles(files), line 11493. Reads every dropped file in turn, reporting failures without stopping.

async addThisreport(), line 11509. THE SEAM. This function is the only place in this closure that reads anything belonging to the rest of the file: load() for the saved vouchers, getPhotosForVoucher() for their images.

9.6 Compiler drawer: exports (lines 11533 to 11612)

saveBlob(blob, filename), line 11535. Saves a Blob with the workspace-prefixed name.

exportCompilerJSON(), line 11542. Exports the compiled set as JSON.

exportCompilerCSV(), line 11553. Exports the compiled set as CSV.

exportCompilerHTML(), line 11558. Exports the compiled set as an HTML table.

exportCompilerXLSX(), line 11569. Exports the compiled set as XLSX.

exportCompilerPhotos(), line 11578. Exports the compiled set's photos as one ZIP.

clearCompiler(), line 11607. Empties the drawer and starts a new batch id.

10 Pane shell (lines 11692 to 11937)

isNarrow(), line 11712. True while the layout shows one pane at a time.

currentPane(), line 11715. The pane showing on a phone: "1", "2" or "3".

showPane(n), line 11718. Shows one pane on a phone. On a wide screen it just makes sure that pane is open.

toggleSide(side), line 11729. The wedges. Narrow: swap between that pane and pane 2.

paint(), line 11742. Keeps the tab states and the wedge arrows honest.

revealElement(el), line 11767. Brings an element into view wherever it is parked: switches to its pane on a phone, reopens a collapsed pane on a wide screen, opens every <details> above it, then scrolls its own pane.

syncIdentificationChoices(), line 11858. Pushes the current <select> values onto the card-style choice triggers that stand in front of them.

close(), line 11882. Shuts the choice dialog. Named so the three ways out, the Close button, a click on the backdrop and Escape, all go through one path.

B.3 Notable engineering decisions, with location

Each item is a change made after real use exposed a failure, unless it says otherwise. The line is where the rationale sits in the source.

Line 1633. Storage writes fail loudly. A refused localStorage write used to be swallowed and the cache reported the record as saved. The cache is still kept, since the record is real and exportable, but a persistent banner says the disk did not take it.

Line 1709. Two tabs, one store. Every write re-reads disk first, and mutate() puts each read-modify-write between one fresh read and one write, so a second tab's save is no longer overwritten by a stale copy.

Line 1769. One failure reporter for two stores. Record store and draft autosave write different keys but fail the same way; one function owns the message and the banner.

Line 1829. Local calendar date. todayISO() uses local date parts, not toISOString(), so evening records are not dated tomorrow.

Line 1949. Addenda instead of overwrites. Corrections get their own rows keyed to the immutable occurrenceID.

Line 2086. Addenda die with their voucher. Deleting a record used to orphan its corrections, which rode into every export and surfaced months later in a compile with nothing to attach to.

Line 2136. One addenda parser for every format. Nested array or serialized cell, same function, mangled cells tolerated.

Line 2259. Georeferences by supersession, not position. After a compile, array order is load order, so latest-wins let one of two independent corrections win silently. Each georeference now records what it supersedes; more than one standing head is reported as a conflict.

Line 2316. Local addendum problems are reported. The check existed only in the compiler; it now runs on the local set and reports without deciding.

Line 2382. Addenda survive import. Export nested the chain; import ran rows through FIELD_DEFS and discarded the nested array. Now every incoming addendum is upserted, and parent pointers are rewritten to the record as saved locally.

Line 2630. Local import dedupes on occurrenceID. A colleague's copy with a different Voucher ID and the same occurrenceID used to import as a second specimen. It now folds into the existing one.

Line 2776. occurrenceID survives relabeling. Scanning a preprint sheet used to clobber the only UUID the record had. The specimen UUID is now minted at draft start and never touched by ID reassignment.

Line 2897. URL-shaped IDs migrated with backup. A separate backup key is written once before any record is altered.

Line 3206, 4056. Draft resync after programmatic writes. GPS and EXIF write .value directly, which fires no input event; without an explicit resync the save gate saw stale blanks.

Line 3288. Safari IndexedDB Blob caveat, stated and left. The known WebKit Blob bug is documented with the ArrayBuffer workaround named, and not applied blind without hardware to test on.

Line 3304. A failed photo store open is not cached. One refusal used to fail every later photo call until a reload, even after space was freed. The rejection now clears the cache, so the next call tries again.

Line 3482. Voucher ID in EXIF. Hand-built APP1 segment; no library.

Line 3614. A photo keeps its first filename. Filenames used to be positions in the export list, so a second take-home bundle could reuse a name and confuse two photos on import. A photo now keeps the first name it went out under.

Line 4130. Timer arms on typing, scoped to the draft. Button-only arming logged hand-typed records as zero seconds; page-wide arming let export clicks start the clock on an empty draft.

Line 4190. Det mirrors collector until hand-edited. Tracks the last auto-filled value to distinguish an edit from a default.

Line 5008. Site banked at save. Duplicate-for-this-site read the live form, which was blank in the one sequence everyone uses (save, then duplicate).

Line 5120. Autosave carries identity. Restoring prose under freshly minted IDs separated the draft from its photos and its preprinted sheet. The whole draft, IDs included, is now saved and restored.

Line 5183. Pristine snapshot instead of empty test. A reset form is never empty (date, kingdom, datum, institution carry over), so "has content" compares against a snapshot of the reset state.

Line 5532. Format gate on dates and coordinates. Non-empty is not a coordinate. Shape is validated; plausibility is not claimed.

Line 5588. Photo and no-photo are mutually exclusive. The checkbox used to short-circuit the whole check, so a voucher could carry an image and a "no photo" flag at once. The gate now refuses the contradiction.

Line 5787. Kingdom belongs to the sitting. Persisted across reset and reload; defaults to Plantae only on first run.

Line 6057. List decorated with current state. Search, sort, and card headline agree with export and label.

Line 6302. Addendum dialog gets the same format gate. A bad corrected latitude would become current_latitude, the column downstream readers trust most.

Line 6498. Spreadsheet formula guard. Apostrophe prefix on cells starting with = + @ or tab; numeric values exempt so longitudes survive; reversed on import.

Line 6691. JSON carries a photo manifest, not bytes. Keeps the export small and diffable; the ZIP and the take-home bundle carry the images under matching names.

Line 7131. Records and photos leave together. They used to leave as two files joined only by a naming convention. Take it home writes one ZIP per part with both, a page that shows them together, and a SHA-256 for every file. A voucher is never split across two parts.

Line 7167. Parts cut to GitHub's limits. GitHub's browser upload takes no file over 25 MiB and no more than 100 files at once, so the GitHub route cuts parts at 24 MiB and 90 photos. The file builds the part and the link; the commit is the collector's.

Line 7625. Photos are removed only when provably safe. Freeing space deletes a voucher's photos only if every one is in a saved or shared bundle and still hashes to what that bundle recorded.

Line 7713. Bundles come back checked. Take it home was one-way. Import bundle puts records back through the single import door and checks each photo against its manifest hash; a photo that fails is named, not attached.

Line 7912. A snapshot offered, never forced. Some browsers clear site data without warning. When unbundled work builds up, the file asks once whether to make a copy. Nothing is saved without a tap.

Line 8145. Labels print the current determination. A decision rather than a repair. The original determination stays on the label as "Orig. det." with redeterminer and date.

Line 8817. One reader for three collection exports. Symbiota, Specify, and DiSSCo read each record through _csPrepare(), so they agree on the current name, collectors, coordinates, and review flags.

Line 9268. DiSSCo keys left out, not faked. Seven required openDS keys are minted by DiSSCo at ingest. The export omits them and its README says so; the file is a pre-ingest Digital Specimen.

Line 9838. WebKit eviction, both mechanisms. Storage-pressure eviction (mitigated by persist()) and ITP's 7-day rule (mitigated only by Home Screen install) are distinguished, and the advisory is labeled untested.

Line 10971. Compiler merges addenda on duplicate occurrenceID. The second copy's corrections were previously discarded with the copy.

Line 11028. Compiler merges fields, newer wins. The first copy loaded used to win outright, so a later, corrected export lost to an older one. The newer copy's values now win, photo lists are unioned, and the copy set aside is named.

Line 11086. Compiler uses the single entry door. The drawer used to carry its own near-copy of canonicalRecord(); flat formats compiled to an empty chain, and a CSV round trip reverted redeterminations.

Line 11609. Fresh compile batch id on clear. A batch identity ends when the drawer is cleared.

B.4 SERNEC retrieval doctrine and sampling script

SERNEC agent retrieval doctrine, v2 candidate freeze

Purpose

This is a bounded specimen-data retrieval study. The agent is a retrieval worker, not a taxonomist, data cleaner, analyst, or study designer.

THE AGENT'S JOB IS RETRIEVAL, NOT JUDGMENT.

Failure is data. Substitution is contamination.

Source boundary
  • Use SERNEC only for specimen retrieval: https://sernecportal.org/portal/collections/search/index.php
  • Search the exact supplied collection/institution code and genus.
  • Do not rescue a failed SERNEC search with GBIF, iDigBio, Google, an institutional portal, NCBI, or another source.
  • The SERNEC interface searches a shared SEINet database, so selecting the exact frozen collection code is mandatory.
Frozen study arms
A. RANDOM_SURVEY

Micranthes; Potentilla; Euphorbia; Ribes; Ranunculus; Lupinus; Polygonum; Solidago; Chorizanthe; Eragrostis.

B. STRESS_TEST, a separate deliberate study

Carex; Cyperus; Solidago; Panicum.

The stress-test genera were deliberately selected and are not part of the random survey inference.

Solidago occurs in both arms by design. RANDOM_SURVEY/Solidago and STRESS_TEST/Solidago are separate experimental cells. Do not merge them.

Frozen institution panel

The source list contains 486 collection entries and was already randomized with seed 20260827. Restrict that frozen list to rows tagged SERNEC - Southeastern Herbaria, preserve its existing random order, and take the first 20. The resulting 20 are fixed in 01_FROZEN_sernec_institutions_20.csv.

Do not redraw, substitute, or replace an institution because it is sparse, empty, broken, awkward, or unexpectedly large.

Retrieval unit

The retrieval unit is:

Study_Arm × Institution × Genus

There are 280 frozen retrieval cells: 20 institutions × (10 random genera + 4 stress-test genera).

Species are NOT preselected in this phase. A previous draft introduced an unsupported target of 10 species per genus; that rule is withdrawn. Species names are retained exactly as they occur in the specimen records returned by SERNEC and may be analyzed later as a separate, explicitly designed step.

Record sampling rule

Target = up to 100 specimen records per retrieval cell.

For each Study_Arm × Institution × Genus:

  1. Search SERNEC for the exact frozen institution/collection and genus.
  2. Record the result count reported by SERNEC when available.
  3. Export the complete matching result set whenever SERNEC permits it.
  4. Preserve the raw export unchanged.
  5. If the complete result contains 0 records: retain/report 0.
  6. If it contains 1–100 records: retain all records.
  7. If it contains >100 records: run the supplied Python sampler on the complete raw CSV and retain a reproducible random sample of 100 rows without replacement.
  8. Never use the first 100 displayed records as a substitute for random sampling.
  9. If SERNEC reports more matches than it allows the agent to export, mark the cell CAPPED; do not pretend a random sample from a truncated export represents the full cell.
Species handling
  • Do not choose, balance, replace, normalize, or stratify species during retrieval.
  • Do not force equal numbers of species.
  • Preserve whatever species determinations occur in the sampled specimen records.
  • Blank, sp., cf., aff., infraspecific, synonymized, or odd taxon strings remain untouched.
  • Any later species-level sampling is a separate study decision and must be frozen before it is performed.
Preservation rules

Do not clean, normalize, correct, infer, georeference, deduplicate, merge, enrich, or taxonomically reconcile raw SERNEC records.

Never overwrite a raw export. Derived 100-row samples must use a distinct filename.

Suggested raw filename: <StudyArm>__<InstitutionCode>__<Genus>__RAW.csv

Suggested sampled filename: <StudyArm>__<InstitutionCode>__<Genus>__SAMPLE100.csv

Stop / failure rules

Stop and report rather than improvise if:

  • no matching SERNEC records;
  • portal result/download cap prevents complete export;
  • timeout/hang;
  • institution/collection filter appears wrong;
  • genus query is ambiguous;
  • CSV download fails;
  • supplied URL is unavailable;
  • requested collection cannot be found.

Do not substitute another genus, institution, portal, query interpretation, or source.

Required return for every cell
  • Job_ID
  • Study_Arm
  • Institution_Code
  • Institution_Name
  • Genus
  • Record_Target
  • SERNEC_Result_Count (if displayed)
  • Exported_Row_Count
  • Retrieval_Status: COMPLETE / SPARSE / ZERO / FAILED / CAPPED
  • Query_or_Result_URL
  • Retrieval_Date
  • Raw_Filename
  • Failure_or_Note
One-sentence operating rule

Search exactly what you were given, export exactly what SERNEC returns, preserve it exactly, and report every failure instead of fixing it.

Sampling script

Caveat: the loop was reversed on the fly during the run. That change is not represented in the listing below.

#!/usr/bin/env python3
"""Reproducibly sample up to 100 specimen rows from one COMPLETE raw SERNEC CSV.

Usage:
    python 04_sample_records.py RAW.csv SAMPLE.csv STUDY_ARM INSTITUTION_CODE GENUS

Rules:
- RAW.csv is never modified.
- 0..100 data rows -> retain all.
- >100 data rows -> sample 100 without replacement.
- Sampling seed is deterministically derived from MASTER_SEED + cell identity.
- Output preserves the original column set. Selected rows are written in original
  raw-file order after row indices are sampled.
"""

import csv
import hashlib
import random
import sys
from pathlib import Path

MASTER_SEED = 20260828
TARGET = 100

if len(sys.argv) != 6:
    raise SystemExit(
        "Usage: python 04_sample_records.py RAW.csv SAMPLE.csv STUDY_ARM INSTITUTION_CODE GENUS"
    )

raw_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
arm = sys.argv[3].strip()
institution = sys.argv[4].strip()
genus = sys.argv[5].strip()

raw_bytes = raw_path.read_bytes()
raw_sha256 = hashlib.sha256(raw_bytes).hexdigest()

with raw_path.open("r", newline="", encoding="utf-8-sig") as f:
    reader = csv.reader(f)
    try:
        header = next(reader)
    except StopIteration:
        raise SystemExit("RAW CSV is empty: no header row")
    rows = list(reader)

n_available = len(rows)
seed_text = f"{MASTER_SEED}|{arm}|{institution}|{genus}|{raw_sha256}"
seed_int = int(hashlib.sha256(seed_text.encode("utf-8")).hexdigest(), 16)

if n_available <= TARGET:
    selected_indices = list(range(n_available))
    rule = "ALL_AVAILABLE"
else:
    rng = random.Random(seed_int)
    selected_indices = sorted(rng.sample(range(n_available), TARGET))
    rule = "RANDOM_100_WITHOUT_REPLACEMENT"

out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    writer.writerow(header)
    for i in selected_indices:
        writer.writerow(rows[i])

print(f"RAW_SHA256={raw_sha256}")
print(f"CELL={arm}|{institution}|{genus}")
print(f"AVAILABLE_ROWS={n_available}")
print(f"SELECTED_ROWS={len(selected_indices)}")
print(f"RULE={rule}")
print(f"MASTER_SEED={MASTER_SEED}")
print(f"OUTPUT={out_path}")
Appendix C SERNEC stress-test institutions

Table C1. Institution-level completeness, SERNEC stress-test arm. Values are percentages of N.

Institution N INST:number Collection date Collector Coordinates Identified by
USCH8,38193.1%87.9%99.2%25.1%75.9%
WILLI5,997100.0%95.6%99.5%61.6%72.2%
GMUF-Plants3,771100.0%92.2%91.6%16.2%13.6%
ETSU3,47190.1%95.0%99.9%21.9%33.2%
UTC-UCHT3,06196.6%41.9%43.0%10.4%2.4%
MISSA2,15592.4%76.7%93.8%68.8%2.8%
WVW1,191100.0%97.6%100.0%4.7%0.3%
USMS1,13997.2%87.4%93.4%57.8%14.4%
PIHG1,050100.0%98.6%99.2%0.0%68.1%
STAR1,017100.0%92.0%92.0%46.2%45.4%

Stress-test institutions returning at least 1,000 records. Voucher presence, institution naming, and locality were nearly invariant under this sampling design and are omitted.

Appendix D COI audit procedure and reconstructed Python script

Purpose and scope

This script reconstructs the saved NCBI audit workflow used in 2026 for the six fields in Table 4: voucher, structured voucher identifier (INST:number), collection date, collector, coordinates, and identified by. It computes no composite completeness score.

Workflow

  1. Read a frozen CSV manifest containing an accession column.
  2. Retrieve the listed GenBank records and inspect their source qualifiers.
  3. Append one scored row per successful retrieval and skip completed accessions when resuming.
  4. Calculate field percentages locally from the saved audit rows.

Requirements and use

Python 3 and Biopython are required. Supply an NCBI contact email through NCBI_EMAIL or the script configuration, and optionally an API key through NCBI_API_KEY.

python coi_voucher_audit_reconstructed.py labeo_manifest.csv

Output

  • <manifest-stem>_voucher_audit.csv: one scored row per completed record.
  • <manifest-stem>_voucher_summary.csv: field counts and percentages among completed records.

Python listing

#!/usr/bin/env python3
"""
COI voucher / provenance audit — reconstructed production version
=================================================================

Reconstructed from the saved NCBI audit workflow used in 2026.

Protocol:
    frozen accession manifest
        -> retrieve exact accession.version records
        -> audit provenance fields
        -> append one evidence row at a time
        -> resume safely after interruption
        -> summarize locally from preserved evidence

This script intentionally reports only the fields retained in the cleaned
COI table:

    Voucher
    INST:number
    Collection date
    Collector
    Coordinates
    Identified by

It does NOT calculate a composite "6/6" score.

Requirements
------------
Python 3.x
Biopython

Example
-------
python coi_voucher_audit_reconstructed.py labeo_manifest.csv

Input CSV must contain a column named:
    accession

Output
------
<manifest-stem>_voucher_audit.csv
<manifest-stem>_voucher_summary.csv

NCBI requests require an email address. Set NCBI_EMAIL below, or export
NCBI_EMAIL in your shell environment.

Optional:
    export NCBI_API_KEY="your-key"
"""

import csv
import os
import re
import sys
import time
from pathlib import Path

from Bio import Entrez, SeqIO


# --------------------------------------------------
# CONFIGURATION
# --------------------------------------------------

NCBI_EMAIL = os.environ.get("NCBI_EMAIL", "REPLACE_WITH_YOUR_EMAIL@example.com")
NCBI_API_KEY = os.environ.get("NCBI_API_KEY", "").strip()

Entrez.email = NCBI_EMAIL
if NCBI_API_KEY:
    Entrez.api_key = NCBI_API_KEY

# Be polite to NCBI.
REQUEST_DELAY = 0.12 if NCBI_API_KEY else 0.36
MAX_RETRIES = 4


# --------------------------------------------------
# VOUCHER CLASSIFIER
# --------------------------------------------------

# Conservative structured-voucher rule:
# institution code + colon + non-empty specimen/catalogue number
#
# Examples:
#   MO:1234567
#   US:01234567
#   LSU:Jones123
#
# Deliberately strict. Free-form strings such as
# "Abbott 24851 (FLAS)" count as Voucher but NOT INST:number.
STRUCTURED_VOUCHER_RE = re.compile(
    r"^\s*[A-Za-z][A-Za-z0-9._-]{1,15}\s*:\s*\S+\s*$"
)


def is_structured_voucher(value: str) -> int:
    if not value:
        return 0
    return int(bool(STRUCTURED_VOUCHER_RE.match(value.strip())))


# --------------------------------------------------
# RECORD AUDIT
# --------------------------------------------------

def source_qualifiers(record):
    """Return qualifiers from the first GenBank source feature."""
    for feature in record.features:
        if feature.type == "source":
            return feature.qualifiers
    return {}


def first_value(qualifiers, key):
    values = qualifiers.get(key, [])
    if not values:
        return ""
    return str(values[0]).strip()


def audit_record(record):
    q = source_qualifiers(record)
    voucher_text = first_value(q, "specimen_voucher")

    return {
        "accession": record.id,
        "voucher": int(bool(voucher_text)),
        "voucher_struct": is_structured_voucher(voucher_text),
        "collection_date": int(bool(first_value(q, "collection_date"))),
        "collector": int(bool(first_value(q, "collected_by"))),
        "coordinates": int(bool(first_value(q, "lat_lon"))),
        "identified_by": int(bool(first_value(q, "identified_by"))),
    }


# --------------------------------------------------
# RETRIEVAL
# --------------------------------------------------

def fetch_record(accession):
    last_error = None

    for attempt in range(1, MAX_RETRIES + 1):
        try:
            with Entrez.efetch(
                db="nuccore",
                id=accession,
                rettype="gb",
                retmode="text",
            ) as handle:
                record = SeqIO.read(handle, "genbank")

            time.sleep(REQUEST_DELAY)
            return record

        except Exception as exc:
            last_error = exc
            if attempt == MAX_RETRIES:
                break

            wait = attempt * 2
            print(
                f"Retry {attempt}/{MAX_RETRIES - 1} for {accession}: "
                f"{exc} — sleeping {wait}s",
                file=sys.stderr,
            )
            time.sleep(wait)

    raise RuntimeError(
        f"Failed to retrieve {accession} after {MAX_RETRIES} attempts: "
        f"{last_error}"
    )


# --------------------------------------------------
# MANIFEST / CHECKPOINT HANDLING
# --------------------------------------------------

AUDIT_FIELDS = [
    "accession",
    "voucher",
    "voucher_struct",
    "collection_date",
    "collector",
    "coordinates",
    "identified_by",
]


def read_manifest(path):
    with path.open("r", encoding="utf-8-sig", newline="") as handle:
        reader = csv.DictReader(handle)

        if not reader.fieldnames or "accession" not in reader.fieldnames:
            raise ValueError(
                f"{path.name} must contain a column named 'accession'."
            )

        rows = []
        for row in reader:
            accession = (row.get("accession") or "").strip()
            if accession:
                rows.append(accession)

    if not rows:
        raise ValueError(f"No accessions found in {path.name}.")

    return rows


def completed_accessions(output_path):
    if not output_path.exists():
        return set()

    done = set()

    with output_path.open("r", encoding="utf-8", newline="") as handle:
        reader = csv.DictReader(handle)
        for row in reader:
            accession = (row.get("accession") or "").strip()
            if accession:
                done.add(accession)

    return done


def run_audit(manifest_path, audit_path):
    accessions = read_manifest(manifest_path)
    done = completed_accessions(audit_path)

    new_file = not audit_path.exists() or audit_path.stat().st_size == 0

    print(f"Manifest: {manifest_path}")
    print(f"Frozen accessions: {len(accessions)}")
    print(f"Already audited: {len(done)}")
    print(f"Audit file: {audit_path}")
    print()

    with audit_path.open("a", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(handle, fieldnames=AUDIT_FIELDS)

        if new_file:
            writer.writeheader()
            handle.flush()

        for i, accession in enumerate(accessions, start=1):
            if accession in done:
                continue

            print(f"[{i}/{len(accessions)}] {accession}", flush=True)

            try:
                record = fetch_record(accession)
                row = audit_record(record)

                # Preserve the accession as frozen in the manifest, even if
                # GenBank returns an equivalent normalized identifier.
                row["accession"] = accession

                writer.writerow(row)
                handle.flush()

            except Exception as exc:
                print(
                    f"ERROR: {accession}: {exc}",
                    file=sys.stderr,
                    flush=True,
                )
                # Do not write a fake zero row. A failed retrieval remains
                # visibly incomplete and can be resumed later.

    print()
    print("Audit pass finished.")


# --------------------------------------------------
# LOCAL SUMMARY
# --------------------------------------------------

SUMMARY_FIELDS = [
    ("voucher", "Voucher"),
    ("voucher_struct", "INST:number"),
    ("collection_date", "Collection date"),
    ("collector", "Collector"),
    ("coordinates", "Coordinates"),
    ("identified_by", "Identified by"),
]


def summarize(audit_path, summary_path):
    with audit_path.open("r", encoding="utf-8", newline="") as handle:
        rows = list(csv.DictReader(handle))

    n = len(rows)
    if n == 0:
        raise ValueError("Audit file contains no completed records.")

    summary_rows = []

    print()
    print(f"Completed audit records: {n}")
    print("-" * 48)

    for field, label in SUMMARY_FIELDS:
        k = sum(int(row[field]) for row in rows)
        pct = (100.0 * k / n) if n else 0.0

        summary_rows.append(
            {
                "field": field,
                "label": label,
                "n": n,
                "k": k,
                "percent": f"{pct:.1f}",
            }
        )

        print(f"{label:18s} {k:4d}/{n:<4d}  {pct:5.1f}%")

    with summary_path.open("w", encoding="utf-8", newline="") as handle:
        writer = csv.DictWriter(
            handle,
            fieldnames=["field", "label", "n", "k", "percent"],
        )
        writer.writeheader()
        writer.writerows(summary_rows)

    print()
    print(f"Summary written: {summary_path}")


# --------------------------------------------------
# MAIN
# --------------------------------------------------

def main():
    if len(sys.argv) != 2:
        print(
            "USAGE:\n"
            "  python coi_voucher_audit_reconstructed.py MANIFEST.csv\n\n"
            "MANIFEST.csv must contain a column named 'accession'."
        )
        sys.exit(1)

    manifest_path = Path(sys.argv[1]).expanduser().resolve()

    if not manifest_path.exists():
        print(f"ERROR: file not found: {manifest_path}", file=sys.stderr)
        sys.exit(1)

    stem = manifest_path.stem

    audit_path = manifest_path.with_name(
        f"{stem}_voucher_audit.csv"
    )

    summary_path = manifest_path.with_name(
        f"{stem}_voucher_summary.csv"
    )

    if "REPLACE_WITH_YOUR_EMAIL" in Entrez.email:
        print(
            "WARNING: set NCBI_EMAIL before production use.\n"
            "Example:\n"
            "  export NCBI_EMAIL='you@example.com'\n",
            file=sys.stderr,
        )

    run_audit(manifest_path, audit_path)
    summarize(audit_path, summary_path)


if __name__ == "__main__":
    main()
Appendix ENCBI matK and rbcL audit Python

Purpose and scope

The retained audit script. It reads a scored audit CSV and lists the accessions that carry all five core provenance fields (place, collection date, collector, coordinates, identified by) together with a voucher, at three levels of voucher strictness: any voucher, institution named, and strict INST:number. Results are summarized in Table 3B.

Requirements and use

Python 3, standard library only. The input file is set by INPUT at the top of the script and must carry 0/1 columns place, collection_date, collected_by, lat_lon, identified_by, voucher, voucher_named, and voucher_struct, plus accession and specimen_voucher.

Output

  • Three console lists, one per voucher level: accession and specimen-voucher string for each qualifying record. No files are written.

Python listing

import csv

INPUT = "labeo_manifest_n100_voucher_audit.csv"

rows = []

with open(INPUT, newline="", encoding="utf-8") as f:
    reader = csv.DictReader(f)

    for row in reader:
        rows.append(row)


def core_five(row):
    return all(
        int(row[field]) == 1
        for field in [
            "place",
            "collection_date",
            "collected_by",
            "lat_lon",
            "identified_by"
        ]
    )


print("Labeo COI — full-chain voucher sanity check")
print("=" * 72)

print("\nT6 ANY VOUCHER")
print("-" * 72)

for row in rows:
    if core_five(row) and int(row["voucher"]) == 1:
        print(
            row["accession"],
            " | ",
            row["specimen_voucher"]
        )

print("\nT6 INSTITUTION NAMED")
print("-" * 72)

for row in rows:
    if core_five(row) and int(row["voucher_named"]) == 1:
        print(
            row["accession"],
            " | ",
            row["specimen_voucher"]
        )

print("\nT6 STRICT INST:number")
print("-" * 72)

for row in rows:
    if core_five(row) and int(row["voucher_struct"]) == 1:
        print(
            row["accession"],
            " | ",
            row["specimen_voucher"]
        )
Appendix FSupplementary architecture views

These earlier architecture views preserve a flyable small-world model with Blender export and the recorded Happy Path atlas. They document their respective builds and complement the 704 wiring diagram in Section 5.1.

Small world · Blender flight deck

Getting my pedagogical groove on, here testing Blender for uses in this environment. Fly it, run it, found it oddly useful for design.

Hold W to fly; arrow keys or a mouse drag turn the view. Success and Failure run the illustrated routes. Blender downloads a scene-builder script generated from the same data.

Ironfist Happy Path · animated call atlas

Watch the recorded calls move through the application. Open the atlas, choose a chapter such as Save, then press Play chapter. Drag to orbit; pinch or scroll to zoom. On a small screen, tap Controls to choose a chapter.

Return to Section 5.1: Technical Architecture

Appendix GIF fields across Specify, Symbiota and openDS

Source: one record from ironfist1008, exported 2026-09-29 through all three collection-system builders. Every cell was read from what those builders wrote, not from memory. Specify column: WorkBench header → table.field. Symbiota: the Darwin Core Archive, occurrence.csv unless noted. openDS: the 0.4.0 Digital Specimen, every key checked against its schema.

direct one field to one field reshape maps, but needs splitting, joining, parsing or a lookup varies depends on the institution's schema, or has no single home IF only carried as JSON; no native field in any of the three theirs left blank for the receiving system to assign
IF fieldSample valueSpecify 6/7 WorkBenchSymbiota / DwC-AopenDS 0.4.0FitNote
Identifiers
id (Voucher ID)4f3c9a2e-…Alt Cat Number → CollectionObject.altCatalogNumberotherCatalogNumbersods:hasIdentifiers, "Ironfist Voucher ID", Alternative, on the labeldirectThe QR on the sheet. Not the catalog number.
occurrenceIDb8e2d4c1-…GUID → CollectionObject.guidoccurrenceID (also the row id)ods:physicalSpecimenID; identifier, PreferreddirectEach system mints its own if this is not carried in.
legacyVoucherId, sourceVoucherId(empty)not writtenotherCatalogNumbers, joined with "; "ods:hasIdentifiers, Superseded / AlternativereshapeSeveral IDs in one delimited string on the Symbiota side.
catalog number(blank on purpose)Catalog Number, blankcatalogNumber, blankregistry fields left for DiSSCo: dcterms:identifier, ods:midsLevel, …theirsThe receiving system assigns these. A placeholder would collide or be imported as real.
voucherIdSourcesheetIronfist Provenance (JSON, unmapped)dynamicPropertiesdwc:dynamicPropertiesIF onlyCarried, with no native field.
institutionLSUInstitution (unmapped; set by the collection)institutionCodeods:organisationName, or ods:organisationID when it is a ROR or Wikidata IDreshape
basisOfRecordPreservedSpecimennot stored; implied by the collectionbasisOfRecorddwc:basisOfRecordreshape
Determination
currentScientificNameCarex crinitaGenus, Species → taxon tree; Full Name for checkingscientificName, genus, specificEpithet, taxonRankods:TaxonIdentification: name, rank, HTML labelsreshapeSpecify needs the name in its taxon tree; free text is refused.
scientificName (original)Carex crinitaDet 2 … columns when it differsidentification.csv row, identificationIsCurrent 0a second ods:Identification, not verifiedreshapeA separate row only when a redetermination exists.
qualifierdeterminedQualifier, blank for determinedidentificationQualifier, blankdwc:identificationQualifier, blankreshapeDarwin Core keeps this field for doubt: cf., aff., nr., ?. "determined" is a status and goes out as the next row.
verification statusdeterminedVerification Status → Determination.confidence (varies)identificationVerificationStatus, occurrence and identification rowsdwc:identificationRemarks: "Verification status: determined"variesNever blank, so a blank qualifier never travels alone. openDS 0.4.0 has no key for it and refuses unknown keys.
identifiedByFirst, identifiedByLastTimothy / JonesDeterminer First/Last Name → AgentidentifiedBy, joinedagent, role "identifier"reshapeSpecify keeps the split; the others join.
identifiedByOrcid(empty)Determiner ORCID (version dependent)identifiedByIDagent identifiervaries
dateIdentified2026-09-29Determined DatedateIdentifieddwc:dateIdentifieddirect
identificationRemarks (current)(empty)Determination RemarksidentificationRemarksdwc:identificationRemarksdirect
family, kingdomCyperaceae / PlantaeFamily; kingdom from the treefamily, kingdomon the ods:TaxonIdentificationreshapeSpecify derives these from the tree.
confidenceHighIronfist ProvenancedynamicProperties idConfidencedwc:dynamicPropertiesIF onlyHow sure the determiner was. No native field anywhere.
Collecting event
date2026-09-29Start DateeventDatedwc:eventDate + year, month, daydirect
collectorFirst, collectorLastTimothy / JonesCollector 1 First/Last Name, primaryrecordedByevent agent, role "collector", position 1reshape
collectorOrcid(empty)Collector 1 ORCID (version dependent)recordedByIDagent identifiervaries
collectorNumber4127Field Number → CollectionObject.fieldNumberrecordNumberidentifier "dwc:recordNumber", on the labelvariesHerbaria differ on fieldNumber versus stationFieldNumber.
additionalCollectorsMary SmithCollector 2 … columns, name splitassociatedCollectorsevent agents, positions 2 onreshapeSpecify needs each name split; guessed splits are flagged for review.
Locality and georeference
country, stateProvince, countyUSA / Louisiana / TangipahoaCountry, State, County → geography treecountry, stateProvince, countyods:Locationreshape"USA" goes out as "United States"; a misspelled state is flagged, never passed through.
localityTextRoadside ditch 1.2 km N of Tickfaw…Locality Namelocalitydwc:localitydirect
lat, lon30.574810 / -90.482130Latitude1, Longitude1 + verbatimdecimalLatitude, decimalLongitude, verbatimCoordinatesods:Georeference, as numbers, + verbatimdirectCurrent coordinates go out as decimals; the original field reading rides as verbatim.
geodeticDatumWGS84DatumgeodeticDatumdwc:geodeticDatumdirect
accuracy±8 mMax Uncertainty Est 8 + unit mcoordinateUncertaintyInMeters 8dwc:coordinateUncertaintyInMeters 8reshape"±" and the unit stripped; km converted. Free text is refused and flagged.
coordinateSourceGPS (browser geolocation)Georef ProtocolgeoreferenceProtocoldwc:georeferenceProtocoldirect
georeference fix (addendum)(none)Georef … columns: sources, remarks, determiner, date, statusgeoreference… columnsods:Georeference sources and remarksdirectCurrent values after any correction; the original stays as verbatim.
Specimen description
habitatWet roadside ditch, full sun…Habitat (varies)habitatdwc:habitat on the eventvariesSpecify: often collecting-event remarks or an attribute field.
associatesJuncus effusus, Scirpus cyperinusAssociated Taxa (varies)associatedTaxaevent assertion, dwc:associatedTaxavaries
descriptionFruiting; perigynia matureDescription → CollectionObject.descriptionverbatimAttributesdwc:organismRemarksdirect
notesCommon along the ditch for 50 mRemarks → CollectionObject.remarksoccurrenceRemarksdwc:fieldNotes on the eventdirect
added Darwin Core termsverbatimElevation: 5 mDwC: term columns, unmappedthe named column, else dynamicPropertiesevent assertionsvariesA term that disagrees with an IF field is flagged; the IF field wins.
Media
photos[] filenames(none on this voucher)Photo Files (unmapped)dynamicProperties photoFilesods:isKnownToContainMedia; filenames in dwc:dynamicPropertiesreshapeImages travel in the take-home bundle and the photo ZIP, not in these files. The names match.
photos[].idInImage, photoSkipped, photoSkipReasonfalse / (none)Ironfist ProvenancedynamicPropertiesdwc:dynamicPropertiesIF only
History and provenance
addenda[] redeterminations(none)Det 2 … columnsidentification.csv rowsfurther ods:Identification objectsreshapeEach status is "redetermined". Exactly one is current; a conflict is flagged, never guessed.
createdAt, updatedAt2026-09-29T15:05:43ZIronfist Provenancemodified (updatedAt); both in dynamicPropertiesdcterms:created, dcterms:modifiedreshapeSpecify and Symbiota stamp their own import time unless mapped.
startedAt, durationSeconds, sessionId, workspace, createdWith, exportedWith, compileBatchId, ingestFingerprint212 s, ironfist1008, …Ironfist ProvenancedynamicPropertiesdwc:dynamicPropertiesIF onlyCapture provenance. It travels with every record, and no system has a field for it.

Specify table names are from the stock schema. Each institution can relabel fields and choose where habitat, associates and collector number live, so check against the target herbarium's schema before building an importer. openDS registry fields (the specimen DOI, version, MIDS level, source system, organisation ID) are assigned by DiSSCo on ingestion and are left out on purpose.

Appendix HSERNEC herbarium records: full random-survey table
Table 3A.

SERNEC herbarium records: random survey

Ten genera drawn at random from the BONAP top-100 list (Kartesz, 2015; Appendix I). Records were retained as returned by SERNEC, including synonymized and infraspecific names; no taxonomic normalization or correction was applied after retrieval. Values are percentages of records with each field present. N is the number of records. Collection date, collector, and coordinates are emphasized.

TaxonNCollection dateCollectorCoordinatesIdentified by
Micranthes44266.1%70.1%17.4%37.8%
Potentilla1,82774.7%77.4%24.5%34.6%
Euphorbia3,32981.0%84.4%28.6%42.8%
Ribes41777.7%82.0%21.1%32.1%
Ranunculus4,12677.5%80.4%20.8%34.8%
Lupinus70485.1%89.1%35.7%39.3%
Polygonum1,03180.9%85.7%23.3%36.6%
Solidago7,10574.4%77.8%24.3%37.0%
Chorizanthe9100.0%100.0%11.1%44.4%
Eragrostis2,12086.5%90.8%30.0%46.9%
Total21,11077.8%81.3%25.0%38.3%

The total row gives pooled percentages across the displayed records. Appendix B.4: SERNEC Python sampling script.

Appendix IBONAP random genus draw for the SERNEC survey

The ten genera of the SERNEC random survey (Table 3A; Appendix H) were drawn without replacement from a list of the top 100 North American genera compiled from the BONAP North American Plant Atlas (Kartesz, 2015), using seed 2026082702. Only the drawn genera are shown. The full list is BONAP data and is not reproduced here.

Table I1.

BONAP genera drawn for the random survey

Draw order is the order returned by the seeded draw. Rank is the genus's position in the BONAP top-100 list. Max spp. is the largest number of species in the genus recorded for a single county, with the county or counties where that peak occurs. Source: Kartesz (2015).

DrawGenusRankMax spp.Peak county
1Micranthes6213Siskiyou County, CA; Clackamas County, OR; Jackson County, OR
2Potentilla2622Park County, WY
3Euphorbia939Cochise County, AZ; Pima County, AZ
4Ribes5717Los Angeles County, CA
5Ranunculus3523Park County, WY
6Lupinus840Inyo County, CA
7Polygonum6713Plumas County, CA
8Solidago2123Macon County, NC; Yancey County, NC
9Chorizanthe9620San Luis Obispo County, CA
10Eragrostis9816Brazos County, TX

Seed 2026082702. Rows reproduced with attribution to Kartesz, J. T., and the Biota of North America Program (BONAP), North American Plant Atlas (2015).