13 Ingesting a dataset
For the data team, and for anyone who will bring a dataset into the integrated database. The work is a loop of five steps with a person at every hand-off; each step is a Claude Code skill in CalCOFI/workflows (.claude/skills/, with the runbook beside them) that scaffolds the artifacts and writes its stage into the shared trackers, so the loop documents itself. The provider-facing side of the same loop — what a provider sees and edits — is Metadata & the ingest loop.
/explore-dataset → /generate-metadata → /ingest-new → run the notebook → /validate-ingest → release_database.qmd
13.2 The five steps
- Explore —
/explore-dataset {path|url}profiles the source: structure, coverage, the columns that look like keys or standard fields (matched against the dictionary), the provider, whether to ingest or only to publish. It seedsquestions.csvfrom what the profile cannot answer. - Metadata —
/generate-metadata {provider} {dataset}scaffolds the two crosswalk files, pre-filling the standard names from the dictionary (the dictionary wins:lat_dec→latitude), flags the columns it cannot place as new standard fields or new measurement types, and registers the dataset’s key, category and descriptive sidecar. - Ingest —
/ingest-new {provider} {dataset}scaffoldsingest_{provider}_{dataset}.qmdfrom the template, with the questions section at the end. The notebook reads the source, applies the crosswalk, keys every event to its cruise by date span (resolve_cruise_key(), never by event month), keeps the provider’s own identifiers as columns, and projects its tables into the core family —sample,obs,obs_attribute,sample_measurement, and the taxa references — with the genericappend_*()helpers. The projection SQL lives in the notebook, not in the package, so a new dataset never edits shared code. Every notebook ends the same way:write_parquet_outputs(),build_metadata_json(),sync_to_gcs(). - Validate — after the notebook runs,
/validate-ingest {provider} {dataset}checks keys, nulls, ranges, duplicates, the summary tables, the schema against the dictionary, the declared measurement bounds, and that noblockerquestion is open. Every finding is either fixed or becomes aproposedquestion with the finding as its context; an undeclared bound is a finding, not a pass. - Release —
release_database.qmddiscovers the new dataset’s sidecars on its own, merges its relationships, assembles the core in memory, runs the gates (Releases), and freezes.test_release.qmdthen runs the consumer contract against the uploaded release and promoteslatest.txtonly when it passes.
13.3 Staging without releasing
A dataset with open blocker questions sets in_release: false in its notebook’s calcofi: block. It still runs in the pipeline and writes its full outputs, but every release-side step skips it — the table registry, the core union, the dataset table, the diagram, the merged sidecars — until the flag flips. Two things do not follow automatically: a new measurement type it introduces is staged in its own measurement_type_new.csv rather than the shared registry, and any upload to the public bucket is gated behind a local flag if publication itself is unsettled.
13.4 Rules that fell out of doing it sixteen times
- Key events to cruises by date span, never by calendar month. A cruise straddles a month boundary routinely and the neighbouring month is usually a real cruise of the same ship; the month rule moved thousands of casts onto the wrong cruise with no key ever failing.
- The provider’s identifiers are columns. A UUID, a cast counter, a station id: keep it, typed, beside the natural key. Never drop a source key as “derivable” — the bottle ingest did, and that was the bug.
- Declare what you publish, and only the core. No per-dataset source table reaches the release; where a source column matters, give it a home in the core rather than shipping the source table to carry it.
- Bounds are physical, not observed. Declare what is possible, never the range you saw; enforcement is a separate, explicit deletion.
- Coverage is measured, never asserted. Do not type a dataset’s years or extent into its metadata; the release measures both.
- Every question is pre-answered where the repo can answer it. A
proposedrow carries the answer we built; the provider confirms a solution rather than being handed a problem. - A shared registry is written with
na = "". The default writes the string"NA", which is invisible from R and reaches the release as a value.
13.5 How a question reaches its provider
Each provider organization has one Google Sheet, generated from git and pulled back into it: a tab per dataset for its questions, a metadata tab for the descriptive sidecar, and — on the CalCOFI Sheet — the holdings triage board for datasets not yet ingested. The data team runs push to regenerate the tabs and pull to bring answers back; both are dry runs until told otherwise, and pull writes only the four answer columns. When a batch is ready, one email is drafted from the same rows. Metadata & the ingest loop says what the provider sees.