You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Redesign the authoring SDK as an executable data standard: archive = osa.Archive() is the composition root; the author writes what a standards document would say — record shapes with semantic field types and declarative constraints, plus a small set of functions for what tables can't express — and the platform generates everything else (storage, indexes, submission pipeline, docs, catalog, MCP). Four decorators total: record, source, curate, derive.
Builds on #180 — its server-side halves (Convention removal, ingesters/ingester_releases provenance symmetry, docs-on-schema, File as field type, one type map) stand unchanged; its §5 SDK surface is superseded by this design. Delivers the metadata-transformation capability #164 frames as tracked silver transforms. Spans two repos (SDK: osa-py; server: this repo), managed from here.
The organizing analogy
FastAPI works because its constructs map one-to-one onto the sections of a document its users were already mentally writing — the API contract. OSA's authors were already writing a document too: the data standard (MIAME, PDB deposition guidelines, a consortium's submission spec, a biotech's CRO-acceptance SOP). The SDK's design test, applied to every construct: which sentence of the standards document does this express? A construct that expresses no such sentence is machinery leaking.
The dimensions an archive author controls are the standard's section headings: identity (what things exist), semantics (what fields mean: types, units, vocabularies), admissibility (what is accepted, and with what reservations), origin (how data enters), derived knowledge (what the archive computes), audience (what consumers can ask — generated, like FastAPI's /docs).
Declarative-first
Real standards are overwhelmingly tables, rarely prose algorithms. The SDK mirrors that: most of a standard compiles to zero functions.
importosaarchive=osa.Archive("pockets")
@archive.recordclassProtein(osa.Record):
"""Crystal structures with computed binding pockets."""organism: osa.Term[NCBITaxon] # vocabulary binding IS the rule:# unresolvable -> rejected, with candidatesresolution: osa.Quantity["angstrom"] =osa.field(
range=("0.5 angstrom", "10 angstrom"),
violation=osa.caveat, # severity is a declaration
)
isolated_from: str|None=osa.field(missing=osa.caveat)
structure: osa.File(format="pdb")
classDocs: # #151 gate unchanged; docstring = purposeexample_questions= [...]
Field declarations carry constraints (range=, pattern=, missing=) and their severities. Functions exist only where the standard has prose.
different trigger (clock/upstream, not a submission), different JTBD, often a different person
curate
admissibility + semantic resolution — everything done to claims
runs pre-accession; receives Submission[T] / field values
derive
derived knowledge — everything computed from accepted facts
runs post-accession; receives T; records stay immutable while derivations re-run
Division of labor: decorators declare role + config (schedule=, runs=); signatures declare binding (Protein -> list[Pocket] attaches the feature; Protein.organism FieldRef scopes a curate). Binding is strict with loud import-time cross-checks: a @archive.curate function taking Protein (a fact) is an error whose message points at @archive.derive, and vice versa — the claim/fact type split makes "which decorator" mechanically answerable, never a taxonomy judgement.
Curate merges transformation and judgement deliberately (the Pydantic lesson: a validator may fix or complain; users never pick between two kinds). Two channels, both always available: the return value is canonicalisation (field-scoped functions return the possibly-corrected value; provenance records before/after + the producing release); the injected findings collector is commentary at four severities (below). Mixed acts — "fuzzy-matched, accept with caveat" — are first-class. Rejection has exactly one home: a finding.
No fail-fast: all curate functions run; findings accumulate; the submitter gets the complete report in one round trip (decisive when the submitter is an agent).
Findings
Severity
Effect
reject
Refused; findings returned to submitter
hold
Routed to the curation queue; a curator releases or rejects (gives the curation domain its real model)
caveat
Accessioned, but the caveat travels with the record permanently: rendered in catalog/manifest, filterable in queries
note
Accessioned; informational; kept in provenance
Stable machine-readable codes (organism/unresolvable); codes render into generated docs ("submissions are rejected when…") and the #151 docs gate covers them; #203's example-validation applies. Declarative constraint violations produce findings through the same machinery — one severity model end to end.
Submissions: bronze derived from silver
The author never models raw data. Submission[Protein] is generated as the widened shadow of the Record: every field accepts the un-harmonised form (organism: str | Term; resolution: str | Quantity — "2.4 Å" parses), files accept paths. A Submission[T] and a T are different types on purpose: a claim vs a curated fact — and the type split is also the pipeline position, which is what makes the curate/derive cross-check possible.
Raw payloads preserved verbatim forever (bronze = the current records.metadata JSONB, demoted to raw-as-received; typed metadata tables become canonical, written only by the pipeline).
Lifecycle, provenance, semantics types
Lifecycle: received → curating → { rejected | held | accessioned } → deriving → current, then superseded (.N+1) / retracted (tombstone; access closes, accession + reasons stay citable — DELETE is not in the vocabulary). Re-curation with a new release mints a new record version through the same pipeline; replayable because raw is preserved.
Metadata is not a feature table (decision 2026-08-14, supersedes the "metadata-as-feature" framing in feat(ingest): ingester identity + versioned releases (raw-ingestion provenance) #208's scope-correction note): canonical typed metadata keeps its own store family — the existing metadata.<slug>_v<major> tables (own PG schema, keyed per major so additive minors extend in place) — and gains run-provenance columns analogous tofeatures.*.run_id, not residency in the features namespace. This also dissolves feat(ingest): ingester identity + versioned releases (raw-ingestion provenance) #208's PgName identifier-budget blocker, which only arose from prefixing metadata_ into feature-table names.
Value objects: osa.Quantity (dimension-constrained; any unit of the dimension accepted, canonical unit stored, original in provenance; comparisons dimension-checked), osa.Term (CURIE + label + owning vocabulary; cross-vocabulary comparison is an error), osa.File (content-addressed, lazy handles). Unit-aware filters now; ontology-transitive .within() as roadmap with semantics-domain closure tables. Client query builder compiles to the /data FilterExpr API (not SQL).
Preflight (design constraint now, feature later): curate functions whose dependencies are pure must be client-runnable — same findings, same codes, before bytes upload. SDK domain layer stays zero-I/O.
Precedents (why this shape)
FastAPI: constructs map to the contract document's sections; variation lives in signatures; docs generated. Adopted wholesale as the analogy.
Pydantic: transformation + judgement are one validator kind. Source of the curate merge.
pandera: parsers (transform) ordered before checks (judge) — validates the transform/judge acts being distinct phases without needing distinct registration kinds.
Great Expectations: validation phrased as expectation; complete reports, not fail-fast. Source of the findings-accumulation contract.
sqlmesh audits: pre-promotion gating with blocking/non-blocking modes — the severity gradation, independently invented.
dbt/Dagster: source as the consensus origin word; Dagster's asset-centric refactor as the data-centric cautionary/corrective tale.
Rails callbacks: the anti-pattern — naming functions by pipeline position (before_save) instead of domain role.
Design history (rejected shapes, so the reasoning survives)
No decorators, pure signature inference: silent-failure registration; nothing greppable.
Verb-per-kind (source/harmonise/check/derive): harmonise-vs-derive is a provenance-taxonomy question authors can't answer (JTBD: biotech QC, consortium, FRO, academic — none arrive with that distinction).
One @archive.curate for everything: lost the "when does this run" information.
Moment names (intake/derive): better, but "intake" is our pipeline's word, not the standard's.
Dimension-per-decorator (resolve/expect split): reintroduced two rejection channels and made fix-plus-remark inexpressible → merged back into curate with the findings collector.
Growth rule: a new decorator requires a new position relative to the claim/fact boundary or a new trigger class — not a new use case. Use cases go into the existing four. Integration events (Slack, Nextflow, DOI minting — the outside world subscribing) are a separate seam, trigger-objects-as-data on the existing outbox events, not named lifecycle decorators.
Deliberately not adopted (from the reference design reviewed)
Collection-scoped registries — declarations attach to the one Archive.
Derived scalar fields on the Record — one derivation mechanism (Features); serving may project single-row features as record columns (merged in consumption, never in declaration: records are closed, the set of computations open).
Same-process SQL query builder — OSA is client-server.
Ordering among curate functions (field-scoped vs record-scoped; declared constraint findings vs function findings) — pandera's parsers-then-checks ordering is a candidate: field canonicalisation → declarative constraints → record-scoped functions.
Multi-module archives: how declarations across packages attach to the single Archive (import side-effects vs explicit archive.include(module)) without reintroducing a Collection concept.
Quantity storage: canonical-unit numeric column (resolution__angstrom) vs value+unit pair; unit change = conversion migration, not retype.
Findings storage/API shape; hold integration with the curation domain.
Summary
Redesign the authoring SDK as an executable data standard:
archive = osa.Archive()is the composition root; the author writes what a standards document would say — record shapes with semantic field types and declarative constraints, plus a small set of functions for what tables can't express — and the platform generates everything else (storage, indexes, submission pipeline, docs, catalog, MCP). Four decorators total:record,source,curate,derive.Builds on #180 — its server-side halves (Convention removal,
ingesters/ingester_releasesprovenance symmetry, docs-on-schema,Fileas field type, one type map) stand unchanged; its §5 SDK surface is superseded by this design. Delivers the metadata-transformation capability #164 frames as tracked silver transforms. Spans two repos (SDK:osa-py; server: this repo), managed from here.The organizing analogy
FastAPI works because its constructs map one-to-one onto the sections of a document its users were already mentally writing — the API contract. OSA's authors were already writing a document too: the data standard (MIAME, PDB deposition guidelines, a consortium's submission spec, a biotech's CRO-acceptance SOP). The SDK's design test, applied to every construct: which sentence of the standards document does this express? A construct that expresses no such sentence is machinery leaking.
The dimensions an archive author controls are the standard's section headings: identity (what things exist), semantics (what fields mean: types, units, vocabularies), admissibility (what is accepted, and with what reservations), origin (how data enters), derived knowledge (what the archive computes), audience (what consumers can ask — generated, like FastAPI's /docs).
Declarative-first
Real standards are overwhelmingly tables, rarely prose algorithms. The SDK mirrors that: most of a standard compiles to zero functions.
Field declarations carry constraints (
range=,pattern=,missing=) and their severities. Functions exist only where the standard has prose.The four decorators
recordsourcecurateSubmission[T]/ field valuesderiveT; records stay immutable while derivations re-runDivision of labor: decorators declare role + config (
schedule=,runs=); signatures declare binding (Protein -> list[Pocket]attaches the feature;Protein.organismFieldRef scopes a curate). Binding is strict with loud import-time cross-checks: a@archive.curatefunction takingProtein(a fact) is an error whose message points at@archive.derive, and vice versa — the claim/fact type split makes "which decorator" mechanically answerable, never a taxonomy judgement.Curate merges transformation and judgement deliberately (the Pydantic lesson: a validator may fix or complain; users never pick between two kinds). Two channels, both always available: the return value is canonicalisation (field-scoped functions return the possibly-corrected value; provenance records before/after + the producing release); the injected findings collector is commentary at four severities (below). Mixed acts — "fuzzy-matched, accept with caveat" — are first-class. Rejection has exactly one home: a finding.
No fail-fast: all curate functions run; findings accumulate; the submitter gets the complete report in one round trip (decisive when the submitter is an agent).
Findings
rejectholdcaveatnoteStable machine-readable codes (
organism/unresolvable); codes render into generated docs ("submissions are rejected when…") and the #151 docs gate covers them; #203's example-validation applies. Declarative constraint violations produce findings through the same machinery — one severity model end to end.Submissions: bronze derived from silver
The author never models raw data.
Submission[Protein]is generated as the widened shadow of the Record: every field accepts the un-harmonised form (organism: str | Term;resolution: str | Quantity—"2.4 Å"parses), files accept paths. ASubmission[T]and aTare different types on purpose: a claim vs a curated fact — and the type split is also the pipeline position, which is what makes the curate/derive cross-check possible.Protein.submission(...)— structural parse client-side, semantics server-side.Submission[Protein]; after that, one pipeline for every entry path. (Server-side registry keeps the feat: remove Convention — Archive SDK (Schema/Feature/derive/ingest) + ingester provenance #180ingestersnaming.)records.metadataJSONB, demoted to raw-as-received; typed metadata tables become canonical, written only by the pipeline).Lifecycle, provenance, semantics types
received → curating → { rejected | held | accessioned } → deriving → current, thensuperseded (.N+1)/retracted(tombstone; access closes, accession + reasons stay citable — DELETE is not in the vocabulary). Re-curation with a new release mints a new record version through the same pipeline; replayable because raw is preserved.osa recompute --stale.metadata.<slug>_v<major>tables (own PG schema, keyed per major so additive minors extend in place) — and gains run-provenance columns analogous tofeatures.*.run_id, not residency in the features namespace. This also dissolves feat(ingest): ingester identity + versioned releases (raw-ingestion provenance) #208's PgName identifier-budget blocker, which only arose from prefixingmetadata_into feature-table names.osa.Quantity(dimension-constrained; any unit of the dimension accepted, canonical unit stored, original in provenance; comparisons dimension-checked),osa.Term(CURIE + label + owning vocabulary; cross-vocabulary comparison is an error),osa.File(content-addressed, lazy handles). Unit-aware filters now; ontology-transitive.within()as roadmap with semantics-domain closure tables. Client query builder compiles to the /data FilterExpr API (not SQL).Precedents (why this shape)
sourceas the consensus origin word; Dagster's asset-centric refactor as the data-centric cautionary/corrective tale.before_save) instead of domain role.Design history (rejected shapes, so the reasoning survives)
@archive.ingest/transform/derive(feat: remove Convention — Archive SDK (Schema/Feature/derive/ingest) + ingester provenance #180 §5): stage names leak execution vocabulary;__name__binding is stringly.source/harmonise/check/derive): harmonise-vs-derive is a provenance-taxonomy question authors can't answer (JTBD: biotech QC, consortium, FRO, academic — none arrive with that distinction).@archive.curatefor everything: lost the "when does this run" information.intake/derive): better, but "intake" is our pipeline's word, not the standard's.resolve/expectsplit): reintroduced two rejection channels and made fix-plus-remark inexpressible → merged back intocuratewith the findings collector.Growth rule: a new decorator requires a new position relative to the claim/fact boundary or a new trigger class — not a new use case. Use cases go into the existing four. Integration events (Slack, Nextflow, DOI minting — the outside world subscribing) are a separate seam, trigger-objects-as-data on the existing outbox events, not named lifecycle decorators.
Deliberately not adopted (from the reference design reviewed)
Archive.Relationship to existing issues
list[Pocket]→pocket).Submissionidentity — coordinate.osa.File(...)declarations carry size constraints (e.g.max_size=) with a sane default, so a missing limit can never surface as a late server-side 422 after image builds.rejectfinding, not a skipped row), andosa test/preflight must exercise the pipeline through accession semantics so fix: datetime schema fields can never publish (SDK maps them to date, publish rejects the timestamp) #195-class bugs are caught client-side.Open design questions
Archive(import side-effects vs explicitarchive.include(module)) without reintroducing a Collection concept.Quantitystorage: canonical-unit numeric column (resolution__angstrom) vs value+unit pair; unit change = conversion migration, not retype.holdintegration with the curation domain.@archive.recordregistration survives or a metaclass auto-registers against the archive (explicitness vs ceremony).Phasing
Submission[T]generation (SDK-only beyond feat: remove Convention — Archive SDK (Schema/Feature/derive/ingest) + ingester provenance #180).hold), retraction,recompute --stale, preflight.