Changelog
Every release of PlasticDB V2, newest first (305 releases). Back to the recent releases.
Version 1.59.0
Added
- Standalone binding assays and typed endpoints with explicit units, censored and qualitative results, provenance and preparation links, separate from degradation.
- Public binding API and browse page, dedicated dataset/release tables, and revision/withdrawal handling that preserves existing degradation labels.
- Binding vocabulary and adsorption-isotherm, TIRF and FRAP methods.
Version 1.58.0
Added
- Explicit reviewed parent-to-variant reclassification, with pinned source evidence, actor definitions, complete preparation coverage and exact assay conservation.
- Typed, durable reclassification audits and direct public redirects to the variant, including visibility checks and subsequent variant merges. Historical endpoints survive orphan cleanup and source-paper deletion.
Version 1.57.2
Fixed
- Curator-reviewed parent consolidation can preserve explicitly mapped terminal constructs without broadening automatic sequence matching. Sequence hashes, source evidence and variant definitions are checked again before applying.
- Identical parent reference coordinates can retain existing fusion variants without inventing sequences or treating fusions as point mutants. Conflicted structures can remain on archived records with an explicit audit trail.
- Reviewed variant folds can compare tagged sequences in a common core frame, checking mutation coordinates while retaining each original sequence and offset.
- Paper revisions retain historical sequence-frame records rehomed from merged accessions, while still retracting dropped assertions about current records.
Version 1.57.1
Fixed
- Parent protein merges leave previously retired variants on their original parents, preserving their redirects, sequences and merge audits. Retired variants no longer create active fold collisions or move to the survivor.
- A retired variant already on the survivor still reserves its name; incoming live collisions remain explicitly refused under the existing unique index.
Version 1.57.0
Added
- Separate source-scoped protein constructs and enzyme preparations, with explicit reference/expressed/assayed sequence roles, coordinate frames, separate chains, tags/linkers/fusions, modifications and paper evidence. Missing sequences and unknown tag status remain explicit.
- Experiment-to-preparation associations preserve roles and amounts for multiple preparations, including standalone enzyme characterisations. Curator editors and extraction contracts retain the complete material descriptions.
- Public construct/preparation views, actor-page material panels, and four additive downloadable/snapshot tables, filtered by published actor/evidence.
- Exact legacy variant sequence snapshots before approved variant folds, with source attribution and explicit structure-reference associations.
Fixed
- Promotion, revision, withdrawal, orphan cleanup and merges preserve or retract material evidence with its source. Revising one ingestion no longer removes a shared interaction still owning another ingestion's experiments.
All new tables ship through the existing schema-init release step. No automatic corpus-wide reinterpretation or merge is performed. See docs/construct_preparation_layer.md for compatibility and migration policy.
Version 1.55.0
Identity sources, sequence frames and curator-only correspondence. **Migration 0032_identity_sources** (three new tables; python -m app.db.init_schema then alembic upgrade head). Deploy before the producer starts sending the fields; the producer strips them until /openapi.json advertises them. See DECISIONS.md §9.1.
Added
identity_sourcesonprotein,protein_variantandprotein_variant.parent_proteinin the extraction contract: where an identity value (sequence, accession, parent, mutations) came from when it was not the paper —cited_paper,author_correspondence,reference_record,genome_recovery,database_lookup(pluspaper/supplement), with who/when/reference,verified_againstand an optional evidence file + sha256. Absent means "from the paper"; every existing payload stays valid. Vocabulary atGET /api/v1/vocabularies(identity_source) and the vocabulary catalog.sequence_frame(precursor/mature/construct/pdb_chain),construct_spanandconstruct_tagson the same three objects. Recorded for curators; not used for dedup.- Promotion records both per (entity, asserting ingestion) in
identity_sources/sequence_frames, with the entity's accession and the asserted values frozen on the row. A replace-mode revision retires the superseded runs' rows; protein merges and variant folds re-home them onto the survivor; orphan cleanup removes them with their entity. - Correspondence upload (
correspondenceonPOST /ingestions/{id}/files) intoingestion_correspondence_filesunder a private prefix — never a supplement. Curator-only list and download (GET /ingestions/{id}/correspondence[/{file_id}], attachment,private, no-store). Linked from an entry by name and hash at approval. - Audit:
GET /api/v1/admin/identity-sources(curator; filter bysource, accession — current or as asserted) andGET /api/v1/admin/sequence-frames. - Curation form controls for every new leaf (121 → 157), and a small "Not from the paper: supplied by the corresponding author (sequence)" marker under the sequence on protein and variant pages — the source type only.
Security
- Identity provenance is redacted for non-curators.
POST /ingestions,PATCH /ingestions/{id}andPOST /ingestions/{id}/upload-markdownaccept any signed-in account and echoextracted_data; every such response now goes through one helper (IngestionResponse.for_viewer→redact_identity_provenance), which cuts eachidentity_sourcesentry to{source, fields}. Curators still see everything, and the stored record is untouched. A structural test fails if a non-curator route builds an unredacted ingestion response. Follow-up, out of scope here: those producer routes still accept anyCurrentUser; they should require an ingest-capable role.
Fixed (review)
- Linking and replacing evidence are serialised. Approval's evidence lookup, the correspondence replace and the delete all lock the file row (
FOR UPDATE), so a replace can no longer swap bytes under a link being made. Replaced objects are deleted only after the request commits, so a failed multi-file upload never destroys an existing file. Every write gets its own object key (content hash plus a per-write version), cleanup re-checks after the commit that no row references a key before deleting it, and one request naming the same correspondence file twice is refused (422). A request that fails before committing (a later part's 413/409/422, a storage error) deletes what it wrote. A commit that raises may still have committed, so then nothing is deleted: the keys are logged and left for reconciliation.POST /ingestions/{id}/filessets 30 sSET LOCALstatement/lock/idle-in-transaction timeouts as its transaction opens and accepts at most 10 correspondence parts, so the window from the first correspondence write to the commit is at most (10 × 4 + 2) × 30 s = 21 min — under the reconcile script's 1 h minimum age.python -m scripts.reconcile_correspondence_objects(dry run by default,--apply,--older-than-hours≥ 1, default 24, each key re-checked in a fresh query right before deletion) lists and removes any object underprivate/correspondence/that no row references, e.g. after a crash. Key versions are a full uuid4 — so neither a duplicate in one request nor two interleaved replacements can delete the object the final row points to. - Distinct assertions stay distinct. The per-payload dedup key includes the normalised asserted values, so two different accessions asserted for one sequence-folded protein are two audit rows.
- Linked evidence is immutable. Re-uploading a correspondence file under the same name with different bytes is a 409 once an approved entry links it; the same bytes again are a no-op. An unlinked file is replaced and its old object removed.
DELETE /ingestions/{id}/correspondence/{file_id}(curator): removes an unlinked file and its object; a linked one is refused (409).- Non-Latin-1 filenames download.
Content-Dispositioncarries an RFC 5987filename*and an ASCII fallback; an em dash in an email subject was a 500. - Clearing a construct span in the form (
{start: null, end: null}or{}) means "no span" instead of a 422; spans are bounded (≤ 100 000) so an overflow is a 422, not a database error. - Approve reports evidence that did not link (
identity_evidence_unlinked, plus a log line). - A revision that drops an entry clears the public marker: the superseded rows' proteins are invalidated in the cache.
- 0032's downgrade documents and logs that it drops the provenance rows.
Version 1.54.0
Security: the public chatbot's SQL runs as a least-privilege database role that can read only public-rows-only views, so the regex SQL guard is no longer the security boundary. Migration 0031_chat_public_views (creates schema chat_public and its views; touches no table). Needs a deploy (API + migrate job), then a one-time role provisioning and a new secret — until then the chat keeps today's path and warns loudly. See DECISIONS.md §11, "The chatbot reads views, as its own role".
Security
- Chat SQL runs as
plasticdb_chat, a dedicated login role whose only privileges areUSAGEon schemachat_publicandSELECTon its views (via the NOLOGINplasticdb_chat_reader). It has no privilege on any table inpublic, so tables added later — including the identity-provenance tables of #605 — are unreachable by default. The role is read-only by default, statement-time-limited, and pinned tosearch_path = chat_public. - The guard had been bypassed through PostgreSQL lexical features (escape-string literals, unicode-escaped identifiers, schema-qualified relation names, and SQL-looking text inside string literals); one class allowed an anonymous chat request to read non-public rows. With the role in place a guard bypass reaches nothing private. The guard also now refuses those lexical forms outright, refuses quoted identifiers and the XML table functions, and scans with string-literal contents blanked.
Added
app/db/chat_public.py: thechat_publicviews (security barrier; public rows only, mirroringapp.services.query; only the columns the chat needs). Built by migration 0031, bycreate_schemaon a fresh database, and rebuilt bypython -m app.db.init_schemaon every deploy.app/services/chat_db.py: a separateNullPoolengine fromCHATBOT_DATABASE_URL, read-only transaction per query. Chat SQL never uses the application session when it is set.CHATBOT_REQUIRE_RESTRICTED_ROLE(defaultfalse): whentrue, chat SQL is refused unlessCHATBOT_DATABASE_URLis set. Flip it after the role is deployed.CHATBOT_STATEMENT_TIMEOUT_MS(default 15000).scripts/chat_restricted_role.sql(idempotent; superuser; after the release is deployed) andpython -m scripts.provision_chat_role(dry run unless--apply; password from--password-stdinorCHATBOT_DB_PASSWORD, sent as a SCRAM verifier; connection errors are reported without the URL or exception text). Provisioning repairs unexpected memberships, PUBLIC/chat table, column and sequence grants, CREATE on schemas (revoked from PUBLIC), default ACLs, PUBLICEXECUTEon application and extension functions, and the implicit PUBLICEXECUTEdefault for new functions; grants SELECT on the approved views by name; and then verifies the boundary — memberships (direct and transitive), ownership, the exactchat_publicinventory and each view's definition againstapp.db.chat_public, column and sequence privileges, catalog objects by provenance (stock or allowed extension only) plus an explicit sensitive-catalog deny list, executable functions, effective function defaults, large objects and default ACLs — failing the whole run on anything unexpected. Its output never includes anything derived from the connection URL.
Changed
- The chatbot's schema hint describes the
chat_publicviews (same names and columns as the tables they front). - With no
CHATBOT_DATABASE_URL, chat SQL keeps the previous path and logs aSECURITY:warning at startup and on every query.
Removed
scripts/chatbot_readonly_role.sql, which was never wired in, grantedSELECTon base tables and carried a placeholder password. The new script disables itsplasticdb_chatbotrole if it exists.
Version 1.53.1
Review fixes to the containment detector (1.53.0). Report-only, no migration, no write path. Needs a deploy (API).
Fixed
- Per-request all-pairs cost. The containment report is now cached, keyed by a digest of the report inputs (sequences, tags, stored cleavage sites), and the all-pairs search runs in a worker thread, off the event loop.
- TEV scar (
ENLYFQ[GS]) is stripped only alongside a His run, so a real C-terminus is no longer trimmed. - Leading Met is kept on post-SignalP chains;
core_sequenceagrees withframe_rowsafter a cut. - Stored cleavage sites are used as residue coordinates on the normalised chain; offsets are computed on the normalised string.
- Containment plan nulls the survivor, classification and move fields it cannot honestly fill.
Version 1.53.0
Containment detector for proteins stored in different sequence frames. Report-only: no migration, no write path, no change to identity, ingestion or merging. Needs a deploy (API) to show on the Duplicates page.
Added
frame=containmentonGET /api/v1/curation/duplicatesand a "Containment (frames)" tab on/curation/duplicates: clusters of live proteins whose core chain (mature chain minus observed C-terminal tags and the leading Met) is a proper substring of another row's stored chain, each edge classifiedmature_equivalent(±3 aa of the SignalP site),tag_trim,construct_in_precursorortruncation, with the merge planner's refusals shown for context. Every cluster carriesframe_unresolvedand none is ever mergeable. Near-identical pairs (1–2 substitutions, ≥98%) are listed separately as never-duplicates.app/services/sequence_core.py: the pinned, evidenced tag list andstrip_terminal_tags()/core_sequence()— pure and stdlib-only, so step (C) can move it intoplasticdb_ml.frames. Strips(LE|GS)?H{6,12}only with ≥6 consecutive His, then a C-terminalENLYFQ[GS]TEV scar, then one leading Met; capped; never writes back. Real C-termini such as#1264 …CPFALEand#665 …CPFALare untouched.app/services/protein_containment.py(detector) andpython -m scripts.report_protein_containment(--format json,--dumpfor an offline JSON export). Over the 2026-10-30 dump: 39 clusters / 89 rows, 84 edges, 59 near-identical pairs; all 67 edges of the frames investigation reproduced (differences explained in DECISIONS.md).
Changed
protein_dedup.entity_keyraises on the newFRAME_CONTAINMENT, so the containment frame cannot reachSequenceIndex,group_by_sequenceor a merge planner.
Version 1.52.1
CI only. No migration, no runtime change.
Fixed
- Deploy gate logs live.
.github/wait-for-release.pyprinted through a block-buffered stdout on the Actions runner, so the v1.51.1 deploy (the first run that actually waited: a test release row held it for five polls) showed every "is running ... checking again in 60s" line at once, when the gate finished. Each status line is now flushed as it is written, so a held deploy shows that it is waiting, and on what, while it waits.
Version 1.52.0
Calibration status on HMM screen results. No migration. Needs a deploy (API) and a release-worker image; hits show no badge until a release has published a calibration.json.
Added
- The release publishes
calibrated/calibration.jsonbesidePlasticDB.hmm(hmm_library.OPTIONAL_ARTEFACTS), in the same atomic loop and before the fingerprint, so a search never sees a library and a calibration record from different builds.hmm_publish.verify_emissionchecks it against the manifest checksum when present; a build without one still publishes and removes the previous library's copy.scripts/publish_hmm_librarygoes through the samepublish()and reports it. The PlasticDB-ML bundle's own (stale) calibration.json is never used. app/services/hmm_calibration_status.py: loadscalibration_status/calibration_status_reasonper profile from the published file, cached per file version. An absent or unreadable file, rows without the fields (pre-0.11 format) or an unknown status all mean "unknown": no badge.- HMM screen results:
GET /api/v1/jobs/{id}hits carrycalibration_statusandcalibration_status_reason(null when unknown); the job page shows a neutral "untestable — no negative set" label (reason as tooltip) and a warning "not separable" badge; separable shows nothing. Hits fromnot_separableprofiles sort below the rest of the same query (API, page and TSV; otherwise the order is unchanged). The HMM TSV gains a trailingcalibration_statuscolumn. There is no per-family or per-profile page in the app, so the hit table is the only surface.
Version 1.51.1
Dependency pin. No migration. Needs a release-worker image for the next release to pick it up.
Fixed
plasticdb-mlpinned to v0.11.1 inrequirements.txtandrequirements-notebooks.txt(PlasticDB-ML #35). Notebook 01's Matrix B example located its variant and parent by internal ids (VARIANT_ID, PARENT_ID = 329, 658), the same failure mode that killed release 2026.44.7 when a duplicate merge tombstoned protein 21. It now follows NylB'-SCY (PBDAPN) and NylB' (PB95X3) by accession and skips the example, with a note, if either is absent. The notebook-id lint now catches tuple, chained, annotated and walrus assignment.
Version 1.51.0
Variant merge: "one sequence = one enzyme" (#576) for protein variants. Migration 0030_variant_tombstone (schema only): adds is_tombstoned (NOT NULL DEFAULT false) and merged_into_accession (indexed) to protein_variants; no row changes. Run python -m app.db.init_schema before alembic upgrade head, as usual (it creates the new variant_merges table). Nothing is merged by the release itself: run python -m scripts.merge_duplicate_variants (dry run) and read the plan first.
Added
app/services/variant_merge.py. Groups live variants of one parent with one canonical sequence in one frame (protein_dedup.entity_key:precursor= the stored record,mature= after the variant's own SignalP cut). Identical sequences under different parents are reported separately and never merged. Built from the protein merge's parts rather than beside them: refusal vocabulary,classify_verdicts, the decisions file, the variant-to-variant fold (protein_merge.fold_variant_rows, extracted from_fold_variant), structure-link relinking (relink_folded_variant_structures, extracted from_relink_parked_structures) andEvidenceLostError.- Survivor: never a tombstone, then an accessioned row, then most evidence (interactions, experiments, observations, kinetics + properties, structures), then most complete (filled columns, mutation list, synonyms), then lowest id. A decision (or the admin endpoint) may name another.
- Moves every row with a foreign key to the retired variant onto the survivor: interactions (with their experiments and observations), kinetics, properties, job hits, structures, enrichments, mutations. A structure or enrichment whose unique slot the survivor already fills, and a mutation list when the survivor has one, stay on the tombstone; nothing is deleted. The survivor fills its NULLs from the retired row and gains its name and synonyms as
synonyms. The census over every referring table and the (interaction, paper, polymer, result) ledger must be identical before and after, or the group rolls back. - Tombstone: the retired row keeps its name, mutations and its
(parent, name)slot;is_tombstoned+merged_into_accession, registry entrymerged, onevariant_mergesaudit row. - Refusals:
verdict_conflict/verdict_disagreement(lifted by a decision'saccept_verdict_disagreement),variant_type_conflict,mutation_conflict(mutation lists compared on the residue they name: position +numbering_offset− the frame's signal-peptide cut when both offsets are known, the paper's positions otherwise),numbering_conflict(the survivor has no list and moving the retired row's list would re-number it),sequence_mismatch/parent_mismatch(admin endpoint only),tombstoned,no_accession,excluded,decision_mismatch/decision_invalid(a variant decision takes onlysurvivor,retire,accept_verdict_disagreement,note).
scripts/merge_duplicate_variants.py: plan by default,--apply,--frame,--only,--exclude,--decisions,--limit; one group per transaction throughcommit_with_deadlock_retry; invalidates the parents' protein cache.variant_mergesaudit table (new table, ORM). Both variant columns keep their row alive in the orphan sweep./api/v1/curation/duplicates→variants: the variant groups (V01…) with plan, survivor, refusals and moves;cross_parent(one sequence under several parents); recent variant merges. The/curation/duplicatespage renders all three.
Changed
POST /api/v1/admin/accessions/mergeaccepts variant accessions. Two variants of one parent run the variant merge; one variant and anything else, the same variant twice, or variants of different parents are 422; a refused plan is 409 with the plan. New body fields:dry_run(returns the plan, writes nothing; for proteins it reports and writes nothing),accept_verdict_disagreementandnote(variants). Protein and organism merges are otherwise unchanged.- A merged variant's accession redirects.
GET /api/v1/variants/{accession|id}(and/fasta) answers 301 to the survivor;/api/v1/proteins/{accession}and/proteins/accession/{accession}301 a merged variant's accession to/api/v1/variants/{survivor}; the/variants/{id}and/proteins/{accession}pages 301 to the survivor's page. A tombstone with no survivor is 410. - Tombstoned variants are hidden everywhere public.
query.variant_parent_public_filterandis_public_variantdrop them (also underinclude_sequenceless), and so do the protein page's variant list, the engineering view and both search variant arms. - Promotion resolves a tombstoned variant to its survivor: a paper naming the retired variant attaches its evidence to the survivor instead of the redirect.
- Paper deletion detaches a merged tombstone it owns (
paper_id→ NULL) instead of cascading it away (orphan_cleanup.hand_over_shared_variants). 0028_variant_unique_name's unknown-foreign-key guard listsvariant_mergesas an audit reference (it only matters when 0028 is replayed on acreate_schema()database).- A revision never deletes a merged variant's tombstone.
delete_prior_ingestion_rowsdetaches a tombstoned variant from the retired ingestion (paper_ingestion_id→ NULL) instead of deleting it with its parked mutations, structures and enrichments; before this, re-approving the retired variant's paper turned its accession's 301 into a 404. - A protein merge never folds a live variant onto a tombstone. A same-name clash where either row is a merged variant's tombstone is a
variant_conflictrefusal (arenamedecision resolves it). A tombstone whose survivor a protein merge folds away is re-pointed at the kept row. - Variant merges re-plan under lock.
apply_variant_plantakesSELECT … FOR UPDATEon the member variants and their interactions, plans again, and raisesPlanChangedError(endpoint: 409; script: the group fails, nothing written) if any member's evidence, verdicts, mutations or state moved since the plan. /api/v1/changesreports a merged variant asmergedwithmerged_into(a withdrawn one asremoved), as for proteins; never for a staged parent.- A revision of the survivor's own paper keeps the merged names: the retired variants' names and synonyms are re-unioned into
synonymsafter the payload rewrites them. - The protein/organism
dry_runreports the entities it would mark and 404s an unknown retired accession. The protein page no longer lists a structure parked on a merged variant's tombstone (it showed the survivor's PDB twice). Approving a payload that names a withdrawn variant tombstone is a 422 (RetiredVariantError), not a 500.
Version 1.50.1
Dependency pin. No migration. Needs a release-worker image for the next release to pick it up.
Changed
plasticdb-mlpinned to v0.11.0 inrequirements.txtandrequirements-notebooks.txt(PlasticDB-ML #33). Notebook 05 reports metagenome hits above both the published GA and a per-profile background threshold fitted on the pristine control runs (leave-one-out on the controls); the published search library and its GA cutoffs are unchanged. Notebook 01 prints what the analysis population excludes and why.calibration.jsonrows carrycalibration_status(separable/not_separable/untestable) and a reason, for a later UI label.
Version 1.50.0
Build and CI, plus one small public endpoint. No migration. Needs a deploy (API) for the endpoint; the deploy gate treats its absence as "unknown" and proceeds, so the release that ships it is not held.
Added
GET /api/v1/releases/status→{"running": bool, "version": str|null, "started_at": datetime|null}, unauthenticated, nothing else exposed. "Running" is what the release task itself treats as in progress: arunningrow younger thanSTALE_RUN_HOURS(13 h); an older one is a dead worker's and is not running. The rule is now one function,dataset_releases.is_live_run, whichclaim_releaseuses too (behaviour unchanged).tests/test_release_status_endpoint.py.- Deploy gate in Build and Push. After every image is pushed and before the gitops bump, a release waits while
/api/v1/releases/statussays a dataset release is running (.github/wait-for-release.py: every 60 s, at most 7 h). A deploy restarts the release worker, which kills a release (~4.5 h) mid-way. Past 7 h it emits a::warning::and deploys anyway; a 404 (endpoint not deployed yet) or an API unreachable three times in a row is "unknown" and deploys with a warning, so the gate cannot wedge a deploy. Dry runs skip it. The job timeout is 540 min (was 90). - Weekly prune of CI tags (
.github/workflows/prune-ci-tags.yml,.github/prune-ci-tags.py): Sundays 03:17 UTC, deletes tags ofvmar559/plasticdbmatching^ci-(PR imagesci-<sha>-<role>, dry runsci-test-<sha>*) pushed more than 14 days ago, through the Docker Hub API with the logindockerhub-private.pyuses. Release (<sha>,<sha>-api,<sha>-release,<sha>-chart),base-*andbuildcache-*tags never match.workflow_dispatchdefaults to a dry run, and a pull request that touches the pruner runs it dry against the real registry. A token without Delete scope fails the run with a message saying so.tests/test_prune_ci_tags.pycovers the selection rule and the gate.
Changed
- Monthly base refresh. The UTC year-month is now an input of the base-image tag (
.github/base-image-tag.sh, alongsideBASE_REFRESH), so the first build of each month — PR checks or Build and Push, both throughensure-base.sh— rebuilds the base images. A new tag alone only re-tagged cached layers (measured: 15 s, no apt), soensure-base.shalso passes the month as theBASE_REFRESH_MONTHbuild arg, which thetoolchainanddeepsigstages declare (withBASE_REFRESH) before their firstRUN: apt and the installs re-run against the currentpython:3.12-slim(Debian and Python security updates). Before this, bumpingBASE_REFRESHhad the same flaw. This changes the base tag and its cache keys now, so this pull request builds the base cold once.BASE_REFRESHstill forces a rebuild in between. - The chart executor is
vmar559/plasticdb:<sha>-chart, a tag in the one private repository, instead of the publicvmar559/plasticdb-chart-executor:<sha>(CHART_EXECUTOR_REPOis gone; its layer cache keeps its own ref). The gitops chart selects it withchartExecutor.image.variant: chart(plasticdb-gitops), which must merge only after a release that publishes-charthas deployed. tests/test_deploy_contract.pycovers the gate (release-only, last step before the bump, limits, timeouts), the monthly refresh (same month same tag, next month a new one, both workflows computing it throughensure-base.sh), the sidecar tag and the prune workflow's dry-run default.
Version 1.49.1
Build only. No application behaviour changes, no migration.
Changed
- The API and release-worker images are tags in
vmar559/plasticdb, not repositories of their own. Docker Hub's free plan allows one private repository andvmar559/plasticdbis it, soplasticdb-apiandplasticdb-release(which 1.49.0 waited for) can never be created private on this account. Build and Push now publishes, under one release SHA:vmar559/plasticdb:<sha>(worker, unchanged),:<sha>-api(API, beat, migrate) and:<sha>-release(release worker), and no longer skips the two role builds. The private-repository guard is a singleensureon the one repository. The dry run measures the pushed-api/-releasetags instead of re-building the API under a scratch tag. The gitops chart selects them with<role>.image.variant(plasticdb-gitops); until a variant is set, every role keeps running<sha>.
Version 1.49.0
Build and CI: images are built from cached, content-addressed base images, one image per role, and pull requests build each image once. No application behaviour changes, no migration. Measured: the API image is 335 MiB compressed / 857 MiB unpacked (was the 1,024 / 3,223 MiB worker image); the worker is unchanged in content (1,021 MiB); a release re-ships ~4 MiB of code layers instead of ~65 MiB. Build and Push's build steps take 38 s warm and 150 s with no layer cache (after a ~2.5 min checkout), down from 12–20 min. PR checks take ~14 min warm and ~19 min when the base must be rebuilt (was 21–36 min); the HMM smoke (~8 min) is now the critical path. The base images are already published for this commit's inputs.
Changed
- Base images (
Dockerfile.base). The bio toolchain (HMMER, T-Coffee, MAFFT, MMseqs2, foldseek, DIAMOND) and every Python dependency moved out ofDockerfileintobase-api(toolchain +requirements.txtwithout torch) andbase-worker(+ torch, the notebook toolchain, transformers, DeepSig). They are published asdocker.io/vmar559/plasticdb:base-<hash>-api|worker, where<hash>(.github/base-image-tag.sh) coversDockerfile.baseand every file it COPYs, and are rebuilt only when that hash changes (.github/ensure-base.sh).Dockerfilebuildsapi,workerandreleaseFROM them and adds only the code, so a code change no longer re-downloads torch, TensorFlow or the toolchain. Each base records what it resolved to in/usr/local/share/plasticdb/base-*.freeze.txt.BASE_REFRESHinDockerfile.baserebuilds a base on purpose (OS security updates are no longer picked up by every release). - The API image no longer carries torch, transformers, jupyter or DeepSig. torch is excluded from its resolution (
uv pip install --excludes); nothingapp.mainimports needs it, and theapibuild now fails if importingapp.mainloads torch, transformers,plasticdb_ml.ml, jupyter or nbconvert. - Python installs use uv (bind-mounted, never in an image) with
--index-strategy unsafe-best-match(pip's--extra-index-urlsemantics), bytecode compilation, and a BuildKit cache mount; apt and npm use cache mounts too.docker/pip-install.shkeeps the credential handling unchanged and turns uv's cache off if it is not on a cache mount. - Registry layer cache. Every build reads and writes
docker.io/vmar559/plasticdb:buildcache-<target>(mode=max). The runners are ephemeral ARC pods with a fresh docker-in-docker daemon per job, so no local builder can persist; the builder is still created by name, only if absent. Code COPYs are--linkand come after the dependency layers. - PR checks build once. A new
imagejob builds the test, browser-test,api, HMM-smoke andworkerimages in parallel and pushes them asdocker.io/vmar559/plasticdb:ci-<sha>-<role>;build(HMM smoke),tests,pgande2epull them and no longer check out or build. They run on!cancelled()and fail first unlessimagesucceeded, so a failed build cannot pass the required checks by skipping them. Pull requests that touch onlydocs/and top-level*.mdfiles build nothing and report all four checks green. A superseded run is cancelled. - Build and Push publishes one image per role, all under the release SHA:
docker.io/vmar559/plasticdb:<sha>(the worker, unchanged name, still everything every role needs),docker.io/vmar559/plasticdb-api:<sha>anddocker.io/vmar559/plasticdb-release:<sha>, plus the chart executor as before. The gitops bump is the same singleimage.tagsed and runs only for av*tag. tests/test_deploy_contract.pycovers the role → repository mapping, the shared tag, the bump, the base and its hash inputs, the credential reaching only the base build, the cache, the private-repository guard and the PR-check structure. The pin tests intest_bio_tools.py,test_signal_peptides.pyandtest_release_notebooks.pyreadDockerfile.base.
Added
workflow_dispatchon Build and Push: a dry run that pushes onlyci-test-<sha>tags, never bumps gitops, and reports image sizes against the current release, the dependency diff and a scan of every image and cache blob for the git credential (.github/image-report.sh,.github/scan-registry-for-secret.py)..github/dockerhub-private.py, run before the first push: a push to a Docker Hub repository that does not exist yet creates it public, and these images carry private code.plasticdb-apiandplasticdb-releasedo not exist yet and the CI token cannot create them (403), so until they are created private the release skips those two pushes with a warning and every role keeps runningvmar559/plasticdb.make basebuilds the base images locally;docker composebuilds FROMplasticdb-base:local(orPLASTICDB_BASE_IMAGE).
Version 1.48.2
Dependency pin. No migration. Needs a worker image (release worker) for the next release to pick it up.
Fixed
- Release notebooks no longer hard-code IsPETase's database id.
plasticdb-mlpinned to v0.10.1 inrequirements.txtandrequirements-notebooks.txt. Release 2026.44.7 failed in notebook 01 (ISPETASE = 21→build_corpus[21], KeyError 21; then NameErrorpet_cell) because the 2026-10-28 duplicate cleanup (1.46–1.48) tombstoned protein 21 /PBG5RNinto protein 3 /PBFCTW. v0.10.1 looks IsPETase up by accession throughplasticdb_ml.ids.resolve_accession, which followsmerged_into_accessionwhen a bundle carries it (this exporter does not emit it yet), falls back to the former accession, and reports absence instead of raising. A notebook lint now refuses hard-coded integer protein ids. Verified on a bundle cut live from the 1.48.0 database: notebooks 01–04 exit 0.
Version 1.48.1
Performance: the public protein page no longer downloads the whole record. For IsPETase (PBFCTW: 1,018 interactions, 865 variants) the page fetched 10.7 MB and took about 14 s cold. It now loads ?view=summary and pages the evidence in. No migration, no change to the full-record response. Needs a deploy (API only).
Changed
/proteins/{accession}page requests?view=summary(carryinginclude_sequencelessas before), renders at once, then loadsinteractions,variants,kineticsandproperties50 rows at a time, with a "Load more (n of N)" control and a retry on failure. Section headers show the totals from the summary'scountsbefore any row arrives. The evidence card takes optionalcounts/moreHtmloptions (evidence_table.js); other pages are unaffected.?view=summaryis built lean. It used to build the whole record and strip it, so a cold summary cost nearly as much as the full record. It now never loads the lists (noload), reads the interactions with only plastic, paper and method, and takes the counts fromCOUNTqueries. It has its own cache key (proteins:summary:{id}), cleared wherever the detail key is. The answer is identical to the old stripped record (a test compares them).
Added
GET /api/v1/proteins/{accession}/variants,/interactions(kind=direct|indirect|all, indirect rows labelled with their variant),/kineticsand/properties:{total, items}withlimit(default 25, max 200) andoffsetorpage, same item shapes as the full record. Totals equal the summary'scounts. They apply the v1.48.0 hide rule throughvariant_parent_public_filterand honourinclude_sequenceless; a hidden accession answers 404, a merged one 301.tests/test_pg_protein_page_lazy.py: pages add up to the full record, pagination bounds, hidden rows and hidden accessions, the opt-in, the full record unchanged, the lean summary equal to the stripped full record, and the page requesting the summary.
Version 1.48.0
Feature (#576 decision 10): proteins and protein variants without a sequence are hidden from every public surface, and so are the interactions they are the actor of. No migration, no stored flag: it is a computed predicate, so a row reappears by itself once it gets a sequence. Needs a deploy (API and worker); the hourly DIAMOND rebuild then drops the two sequenced variants whose parent has no sequence. Measured on live V2 before the change: 424 of 1,511 live proteins, 331 of 2,948 variants (329 without a sequence of their own, 2 under a sequence-less parent) and 1,133 of 15,689 interactions (765 protein, 368 variant) are hidden. Organism-only interactions (8,782) are unaffected.
Changed
- The public gates carry the predicate.
public_visibility_filter(Protein)now also requires a non-blank sequence (NULL and whitespace both count as none);variant_parent_public_filter()requires the variant's own sequence and a visible, sequenced parent ("a variant whose parent is hidden is hidden");public_actor_interaction_filter()andmeasurement_actor_public_filter()route their protein and variant slots through those two. Their Python twins (visible_source_proteins,visible_step_protein, newis_public_protein/is_public_variant/is_public_interaction,has_sequence) follow. Every public query that was already gated for staged and tombstoned rows is gated for this by the same call: counts and the landing headline, the protein, organism, plastic, paper and engineering pages, global search and autocomplete, every bulk download and export, release snapshots, sitemaps, the change feed and the chat agent's SQL guard. A sequence-less accession answers 404 like a staged one. - The DIAMOND/BLAST reference build uses the same gates.
reference_db.protein_public_clauses/variant_public_clauseswere a hand-written copy that checked a variant's parent for visibility but not for a sequence. - The protein row of the CSV/JSON exports no longer counts a hidden variant's kinetic measurement in
n_kinetic_measurements/best_kcat_km. - Admins still see everything: the admin lists and the record preview never used the public gates. The preview now labels such a row "Public, but hidden — no sequence" and links its opt-in view.
Added
- "Show proteins without a sequence" checkbox in the protein search (off by default). It sends
include_sequenceless=truetoGET /api/v1/proteins, which lifts the predicate for proteins, variants and their evidence in that search, and the result links carry it on to/proteins/{accession}and/variants/{id}(and their API reads), so a listed row opens instead of 404ing. The opt-in detail is not cached. Every other route ignores it. tests/test_pg_sequenceless_hidden.pycalls every registered GET route anonymously against a fixture world (a sequence-less protein, a blank-sequence protein, a sequence-less variant, a sequenced variant of a hidden parent, each with interactions, assays, kinetics and a pathway step) and fails on any response that carries one of them. On the previous release it finds 39 leaking route templates. It also checks the snapshot tarball and the DIAMOND build clauses, the opt-in, the admin view and that filling a sequence un-hides the row.tests/test_sequenceless_hidden_everywhere.pypins the gates and adds a ratchet: no module outside a reviewed list may comparevisibilityto"public"by hand.
Version 1.47.0
Duplicate prevention (#576 step 5). No migration, no new table. Needs a deploy (API, worker and beat). Rule: one identical mature chain is one enzyme.
Added
- Auto-merge after SignalP. When a SignalP result writes a cleavage site, the protein's mature chain can become identical to another live protein's. The groups such a protein now belongs to are found with the detector
report_duplicate_proteins --frame matureuses (protein_dedup.duplicate_groups_sync) and merged through the existing merge service (protein_merge.plan_group→apply_plan), one group per transaction with the deadlock retry. Hooked into the signal-peptide worker (predict_for_proteins: the on-promotion dispatch and the daily sweep) and the enrichment ladder (enrich_protein_sync,enrich_all_proteins_sync), after the result is committed. A merge problem is logged and never fails the SignalP task.- Survivor:
protein_merge.survivor_sort_key, the merge script's rule: never a tombstone, then the most restrictive visibility, then the richest evidence, then the most complete accessions, then the lowest id. The protein whose prediction triggered the merge gets no preference. - Overridden refusals (#576):
verdict_conflictandverdict_disagreementmerge, and each paper's interactions move with their own verdict.organism_splitmerges, the survivor keeps its organism, and a curator entry offers the other one. - Still blocks:
visibility_collapse(a staged row must not demote a public one),variant_conflict(needs a curator decision per variant),adjudicated_elsewhereandtombstoned. A blocked group gets a curator entry instead. - Audit: the
protein_mergesrowsapply_planwrites, with; auto-merged after SignalP (#576 step 5); overrode …appended tonotes. One Slack line per batch lists what merged and what was blocked. - Setting
DEDUP_AUTO_MERGE_AFTER_SIGNALP(default on) switches it off.
- Survivor:
- Curator entries reuse the flags queue. Each entry is a pending
suggestionannotation on the protein, so it appears in the curator inbox as "Flag or suggestion" and is worked at/dashboard/review. An organism entry carriesfield_name = source_microorganism_idwith the other organism's id: accepting it switches the organism through the existing suggestion path, and rejecting it keeps the row's organism. Other entries carry no field, so accepting them only acknowledges. Entries are filed under the service account (the earliest active superuser if there is none) and are never filed twice for one event. With neither account, the entry is skipped with a warning and the merge or approval goes ahead. - Weekly duplicate report to Slack. The beat job
report-duplicate-proteins(Mondays 07:15 UTC, taskreport_duplicate_proteins_weekly) runs the mature-frame detector read-only. It posts only when at least one group exists: the group count, the row count and the first 10 groups (accessions, names, length, organism and visibility splits).
Changed
- An organism conflict at approval attaches instead of minting a duplicate.
_get_or_create_proteinused to refuse a sequence match whose source organism (compared by tax_id) differed, and create a second row. It now attaches the evidence to the existing row, which keeps its organism. The name is recorded as a synonym as usual, theorganism_mismatchdedup note is still recorded, and a curator entry reads "Organism conflict on PB…: paper <doi> says X (tax_id N), row says Y (tax_id M) …". The entry is a one-click switch when an organism row for the paper's tax_id already exists. Otherwise it only acknowledges: promotion does not create an organism row with no evidence of its own. It is filed in a savepoint, so a failure cannot abort the approval.
Version 1.46.2
Bug fix: approving a paper or a revision could attach evidence to a tombstoned (merged) protein. No migration. Needs a deploy (API and worker). Existing rows are repaired by a new script, run separately (scripts/repair_tombstone_evidence.py --apply, in the worker pod).
Fixed
- Promotion follows a merge to the survivor. A merged protein keeps its row as a tombstone (
merged_into_accessionset,is_tombstonedtrue), and that row keeps itsuniprot_accession,genbank_idand name._get_or_create_protein's accession match and its paper-scoped name match (_protein_named_in_this_paper) could both return it, so new interactions and variants were hung off a row every read path forwards past. On 2026-10-29 production held 24 interactions from 17 ingestions on 11 tombstones, plus 7 variants. For example, a revision of Cut190 (C7MVE8) landed onPBDUDQ, merged intoPBRE2Bthe day before. Both matches now followmerged_into_accessionto the live row at the end of the chain (_live_protein), so the evidence lands where the enzyme now lives. The accession match prefers a live holder of the accession over a tombstone, then the lowest id (it used to take whateverLIMIT 1returned). A chain that ends at a withdrawn record, a missing accession or a loop counts as no match. The sequence match already skipped tombstones. The variant lookup is keyed on the parent this returns, so a variant actor lands on the survivor's variant, never on one parked on the tombstone.
Added
scripts/repair_tombstone_evidence.py— re-homes what already points at a tombstone onto its live survivor, using the merge service's own move (protein_merge._rehome), so the unique-key rules match a merge's: enrichments, structures and variant names the survivor already holds stay parked on the tombstone. A variant whose name clashes with one of the survivor's and that carries evidence is refused and reported, never folded or overwritten. A tombstone with no live survivor that still carries evidence is refused too. Dry run by default (read-only session),--applyto write,--onlyto limit it to some tombstones. Each tombstone is one transaction, checked with the merge's evidence census before and after. It is idempotent. Live dry run on 2026-10-29: it would move 24 interactions, 7 variants, 10 properties, 1 kinetics row and 1 structure from 11 tombstones, with no refusals.
Version 1.46.1
Curator decisions for the duplicate-protein merge pass (#576, #575 §2). No migration. The tool is scripts/merge_duplicate_proteins.py, run in the worker pod. Nothing changes for a run without --decisions, except that the variant auto-equivalence below now folds same-chain clashes that it used to refuse.
Added
--decisions FILE. A JSON file of curator rulings, one entry per group (format inapp/services/merge_decisions.py). Entries are matched by member accessions (survivor+retire), never by G-number. If the planned group's membership differs, the group is refused asdecision_mismatchand nothing is lifted. A malformed file stops the run before anything is planned. Per group:accept_verdict_disagreement: trueliftsverdict_conflict,verdict_disagreementandadjudicated_elsewhere. Evidence still moves per paper:apply_plannow asserts that every interaction of the group keeps its paper, polymer andresultacross the merge (evidence_ledger), and rolls back otherwise.organism: {tax_id, name, reason}sets the survivor'ssource_microorganism_idto the stored organism with that tax id, whose name or a synonym must match, and liftsorganism_split. The organisms it replaces go into theprotein_mergesnotes andcross_references["organism_decisions"]. It never creates an organism row: an unknown tax id is refused asdecision_invalid.variant_decisionsresolve same-name variant clashes.foldmoves the dropped row's evidence onto the kept row, which keeps its own sequence, frame, type and mutations. When the kept row is on a retired protein, it moves onto the survivor and the survivor's own row is parked on that tombstone.renamerenames a row, which then moves as a separate variant. Every clash must be covered, or the group stays refused.- The survivor may be named by the decision when that doesn't change the resolved visibility.
visibility_collapseis never lifted.
- Variant auto-equivalence. Two same-named variants that conflict only on
sequence/numbering_offsetand hold one chain once each offset is applied are folded without a decision. "One chain" means the longer sequence minus exactly the N-terminal residues the shorter starts after, as in LCC's 293-aa precursor at offset 0 against the 258-aa mature chain at −35. Identical stored sequences also count. The fuller row is kept, and frame columns are never mixed between the two rows.
Fixed
- Protein merge moves
protein_structure_paperslinks onto the survivor (#575 §1). A retired protein's structure row that parks because the survivor already holds that PDB id used to keep its paper links, so the survivor's copy under-reported which papers asserted it. Links now follow onto the survivor's row (or the kept variant's, for a folded variant). A link whose ingestion the target already records stays put, and none is deleted. The link count is part of the merge's conservation check.
Version 1.46.0
#575 follow-ups. No migration. Needs a deploy (API and worker).
Fixed
- A duplicate variant name is a 409, not a 500.
POSTandPATCH /admin/protein-variantshituq_protein_variants_parent_name(migration 0028) as an unhandledIntegrityError. The shared admin create/update now turns a violation of that index into409with "A variant with this name already exists on this protein". Any other integrity error is re-raised unchanged. GET /proteins/{accession}no longer re-validates its own cached payload. The cached detail is alreadyjsonable_encoder(ProteinDetail), but returning the dict made FastAPI validate and re-encode it. ForPBFCTW(10.7 MB, 1,018 indirect interactions, 865 variants) that was about 1.3 s of event-loop CPU against 0.2 s to dump. Measured against production before the change: 5.5 s warm uncompressed, 1.7-2.2 s gzip, 14 s on the cold request. The cold build is unchanged (see the issue for the open question).- An approval now queues a lineage fill for the organisms it created. An organism is minted with a
tax_idand no lineage, and only the Sunday 03:17 refresh filled one, so it sat in the curation report'sno_lineagelist (ingestion 925, Halomonas sp. G11, row 4115,tax_id1684425, stilllineageNULL two days after approval). The newfill_missing_lineagestask runs themissingscope under the usual taxonomy-run lock; a broker failure is swallowed and the weekly pass remains the net.
Added
report_duplicate_proteinsreports live proteins sharing a v1 id (proteins.legacy_id, the verified PlasticDB v1 number;plasticdb_idis the V2 serial and already unique). It needs no sequence, so it finds an import-time split where one row's sequence is NULL. Tombstoned rows are ignored.
Version 1.45.1
Bug fix (#576): the gaps that left proteins and protein variants without a sequence. No migration. Needs a deploy (API and worker). Existing rows are not backfilled here: the three known cases (TfH PBERS5, TfCut2 PB78JG, FsC PBDPN7) carry no accession, so neither the new rung nor a re-approval reaches them, and PB78JG would become a byte-identical duplicate of PB5QQN. That is a merge decision for a curator.
Fixed
- Promotion fills a missing sequence on a matched protein.
_reconcile_protein_metadataleftsequence"exactly as stored", on the grounds that a match means the two agree. That is false when the stored sequence is NULL. A sequence-bearing actor matched by accession onto a row minted without a sequence now writes it. A stored, non-empty sequence is never replaced. The write goes throughapp.services.sequence_fill.fill_missing_sequence, which also setssequence_provenanceback tounverifiedand clearshas_signal_peptide/signal_peptide_cleavage_site, so the daily provenance and signal-peptide sweeps pick the row up. It refuses a value with nosequence_key, such as a note. The fill is recorded as asequence_filleddedup note. The approval dispatches signal-peptide and provenance jobs for these rows as it does for new ones, and queues a protein-cache invalidation. Today only an accession match reaches this path: the paper-scoped name match runs only for an actor with no sequence. - A reused variant fills a NULL
sequenceandnumbering_offset. Promotion matches a variant on (parent,variant_name). On a first promotion the reused row took only synonyms and an unclaimed origin from the payload. A revision of a paper that does not own the row recorded the stated sequence as a conflict with NULL. So a variant created sequenceless by one paper stayed sequenceless whatever later papers stated: 28 variant entries in approved extractions carried a sequence and offset 0 against live rows holding NULL for both._fill_variant_nullsnow runs on both paths.sequenceis written only when the stored one is NULL or blank, and the variant's signal-peptide columns are cleared with it.numbering_offsetis written only when the stored one is NULL and the stored sequence is the payload's chain, because an offset stated against a different sequence would misdescribe it. Neither is ever overwritten, a fill is not reported as a revision conflict, and the row's ownership stamp does not move. A revision of the owning paper still rewrites its own row, as before. - Enrichment retries a failed sequence fetch. Promotion fetches a sequence from the accession once and, on failure, logged "enrichment will retry later". Nothing did:
_step_uniprotmarks a row with nogenbank_idand no sequencepermanent: no_lookup_path. The ladder has a new first rung,sequence. It runs only for a protein with a UniProt or GenBank accession and no sequence, and uses the same helper promotion uses (fetch_sequence_for_accessions, overlookup_uniprot_sequence/lookup_genbank_sequence). Found:ok, the sequence is filled as above, and verdicts recorded while the row had no sequence (no_sequence,no_lookup_path) are dropped for that pass so those rungs run at once. Lookup failed or empty:transient, retried on the weekly passTHROTTLE_DAYSapart and given up atRETRY_CAP(5). The admin rescue can lift the cap (source: "sequence"). A row that has a sequence, or has no accession, gets nosequencerow at all. The admin coverage table lists the new source. - A nucleotide accession resolves through its CDS, and only when it links exactly one protein.
U58990.1,AB038647.1andFR727681are nucleotide records. NCBI's protein database answers 400 for them, so the sequence was never fetched.accession_format.is_nucleotide_accessionrecognises the shape (one letter + 5 digits, two letters + 6 or 8, RefSeqNM_/NC_/…). It excludes a value that also fits the UniProt pattern, such asP12345.lookup_nucleotide_cds_proteinsreads the record's CDSprotein_ids (efetch nuccore rettype=fasta_cds_aa). With exactly one, that protein record's sequence is used, and enrichment records it incross_references.cds_protein_id. The storedgenbank_idis not replaced. With several CDS proteins, or none, nothing is filled: enrichment recordspermanent: nucleotide_ambiguous_cds: …ornucleotide_no_cds: …. An NCBI outage staystransient. Promotion and the curator form's GenBank lookup (POST /lookup-sequence) use the same path. On the form, an ambiguous record returns the reason as the error. One effect to note: R-1's load-time check now also applies to an actor that names a single-CDS nucleotide accession and supplies its own sequence. The CDS protein's record wins, as it already does for protein accessions.
Version 1.45.0
Structure provenance and a sequence guard on PDB entries. Migration 0029_structure_provenance (schema + backfill): adds three nullable columns to protein_structures and backfills the new protein_structure_papers table. It deletes no rows. Run python -m app.db.init_schema before alembic upgrade head, as usual (it creates the table).
Added
protein_structure_papersrecords which paper asserted each structure row. One row per (structure, asserting ingestion), with the ingestion's paper. Promotion writes it whenever a payload names a PDB id for a protein or variant, including when the row already exists (5LUI on protein 511 is asserted by two papers).protein_structures.created_byrecords which writer created a row:paper,enrichment,predictionorimport; NULL means it predates the column.- Enrichment checks that a PDB entry is the owner's protein. It reads the entry's polymer sequences from RCSB and requires one to align to the protein's (or variant's) sequence at ≥ 95% identity over the aligned region, covering ≥ 50% of the shorter sequence (local alignment, BLOSUM62). A variant without a sequence is checked against its parent. The verdict is stored on the row (
sequence_check=match/mismatch,sequence_identity) and returned by the structure API. Amismatchrow is kept for a curator, shown with a "sequence mismatch" badge on the protein page, and is never the primary structure: it does not count as "already has a measured structure", it does not stop AlphaFold, andstructure_fileis never pointed at it (one already pointing at it is cleared). Nothing is deleted on this basis. When RCSB cannot be reached or the owner has no sequence there is no verdict and nothing changes; during discovery that is recorded astransientand retried. - The check runs on every way a PDB id arrives. Existing rows are checked once (paper-asserted, v1 import, earlier discoveries). Discovery checks its candidates: the id in
structure_file, every entry that cross-references the UniProt accession (up to 20; it used to take RCSB's first, unranked hit), and the RCSB sequence search (which already requires identity 1.0). It adopts the best match, or nothing; the rejected candidates are listed in the enrichment detail.
Fixed
- A corrected paper's retracted PDB id is removed. Paper 10.1002/anie.202525215 asserted 6KYS on DuraPETase (variant 488), a typo for 6KY5; 6KYS is M. tuberculosis MazF-mt1. Revision 1632 dropped it and was approved in replace mode, but the structure row stayed. When an approval retires earlier runs, their structure links are now dropped. A row the new payload does not assert is deleted if no other ingestion links it and a paper created it. The owner's
structure_fileis cleared if it names that entry (otherwise enrichment would recreate the row), and the next best row becomes primary. Rows from the v1 import, enrichment, predictions, admin edits or of unknown origin only lose the link. Thepaper_supersededevent lists each structure removed or kept (structures_retracted). - NylB no longer has 2E8I as its primary structure. All 16 PDB entries that cross-reference P07061 are Hyb-24 NylB/NylB' hybrid mutants at 88.3–90.1% identity; enrichment took the first one. The guard flags all 16, and AlphaFold's model (AF-P07061, pLDDT 97) wins.
upsert_protein_structurenever re-points a flagged row at a new PDB id.
Migration
0029_structure_provenanceaddscreated_by,sequence_checkandsequence_identitytoprotein_structures(nullable; declared on the model). It backfillsprotein_structure_papersfrom approved (live) ingestions. An ingestion's paper is the one its interactions are stamped onto, or the paper matchingreference.doi. Protein-level ids (protein.pdb_id, a variant'sparent_protein.pdb_id) link the protein-owned rows with that id on the proteins the paper's interactions name. Variant-level ids link the row with that id on the paper's variant of that name. A linkedexperimentalrow whose owner is not a v1 record (legacy_id IS NULL) getscreated_by = 'paper'; predicted rows getprediction; everything else stays NULL and is never removed automatically. Superseded ingestions are not read, so rows only a retired run asserted (6KYS, row 512) stay unlinked. Those are removed by hand. Idempotent. The downgrade drops the table and the three columns.
Version 1.44.0
Every weekly release now cuts all of its ML bundle's record-derived inputs from the live database: Matrix B, identity.npz, features.json and mature.faa join the two corpora. Only the seed families and the Phase 1 partition stay frozen, on purpose. The release task gets its own 12 h time limit. No migration. Needs a deploy (worker image: plasticdb-ml v0.10.0) and the gitops branch release-time-limits (RELEASE_NOTEBOOK_TIMEOUT).
Changed
- Matrix B is cut live.
export_ml_bundle --from-databasenow runsscripts/export_mutation_effects' ownfetchandexport --tier strictstages, with the frozen Phase 1 seeds, into its scratch directory. Itsmatrix-b/variants.json,mutation_effects.tsvandsummary.jsonshadow the cachedstep2-2026-09-04copy. Until now every bundle carried that copy: 745 variants, 332 triples over 42 parents, 282 variant rows. So T4 and every other variant-using cell in notebooks 01 and 03 ran on September data. On the 2026-10-28 database the live cut is 2,952 variants, 7,318 strict effect rows and 1,577 triples over 121 parents, giving 1,397 variant rows. Measured: re-runningexporton the September run's ownvariants.json, with the Phase 1 run it used, reproduces itsmutation_effects.tsvandsummary.jsonbyte for byte. - The positional artefacts are rebuilt over the live records and Matrix-B variant rows, every release. The release passes
--refresh-positional always --identity-cache <ML_BUNDLE_IDENTITY_CACHE>. The identity matrix comes fromplasticdb_ml.ml.identity, a pair cache keyed by both mature chains' SHA-256 and the aligner's Biopython version. Its result equals the oldidentity_matrix_parallel+extend_identityelement for element: measured on the September set against the shippedidentity.npz, and on the 2026-10-28 live set, which is 2,430 ids and 1.87M distinct pairs. A cold build of the live set is ~23 min on the pod's four CPUs. A warm one takes 0.4 s. The cache is pre-seeded on the shared volume (<bioinformatics_dir>/identity-cache/identity_pairs.npz). mature.faais rebuilt too, in the cached run's own naming (>p<id>, variantsp-<id>, the mature chain). Measured: this reproduces the September file byte for byte from that run's corpus and Matrix B. A protein curated this week is now in notebook 02's search set.- Coverage counts the variant rows, and the export refuses a gap.
positional_coveragenow resolves ids withfalsify.extended_records(records and Matrix-B variant rows) wherever it is given a Matrix B. Both the exporter andassert_bundle_covers_corpusgive it one. With--apply, an export whose identity matrix or feature table misses any id, or whose coverage cannot be measured, now writes nothing and exits 1, naming the ids and the reason the rebuild could not run. It used to write the bundle with a named gap inmissing_parts. The ESM entry was already refused on a missing or stale vector. weekly_dataset_releasehas its own limits: soft 12 h, hard 12 h 15 min. Every other task keeps 4 h / 4 h 15 min. The brokervisibility_timeoutgoes from 5 h to 13 h. It is one number for the whole Redis broker (kombu's single unacked index), so it must clear the longest task. The cost: a message whose worker died waits up to 13 h, not 5 h, before it is redelivered, on every queue.STALE_RUN_HOURSgoes from 8 to 13, so a live release younger than its ceiling is never "resumed" by a second run.docs/dataset-releases.mdhas the measured runtime.
Added
--identity-cache FILEonexport_ml_bundle(needs--from-database).ML_BUNDLE_IDENTITY_CACHEsetting;release_notebooks.identity_cache_path().
Version 1.43.2
Bug fix. Migration 0028_variant_unique_name (data): collapses duplicate protein variants, then adds a unique index. It refuses to run, and changes nothing, if two same-named variants on one parent disagree about the variant (see below); run the read-only query in the PR first.
Fixed
- Approving a paper that names a variant no longer 500s when its parent holds two variants of that name. Promotion found "variant X of protein P" by
(parent_protein_id, variant_name)withscalar_one_or_none(), and nothing kept that pair unique. The lookup now takes the lowest id (ORDER BY id LIMIT 1). The interaction lookup that follows it (paper, polymer, actor) had the same shape and now does the same: a protein merge legitimately leaves two interactions of one paper on one actor and polymer, and the paper's next approval used to raise on them. - Protein merge no longer duplicates variants. It moved every variant from the retired protein to the survivor, even when the survivor already had one of the same name; after a merge, the next approval naming that variant 500'd. A retired protein's variant whose name the survivor already holds now stays on the tombstone (counted in
parked), and its evidence is folded onto the survivor's variant: interactions (with their experiments and observations), kinetics, properties, job hits, and the structure and enrichment rows the survivor's variant does not already hold. Its mutation list stays with it; the survivor's variant keeps its own values, fills its NULLs from it, and gains its synonyms. A fold is planned only when the two rows agree about what the variant is — sequence (whitespace and case ignored),variant_type,numbering_offsetand the mutation list, compared wherever both state them. Otherwise the group is refused asvariant_conflict, for a curator.apply_plannow also checks a variant-evidence census (variant-actor interactions carry noprotein_id, so the protein census could not see them), and the audit row records each fold. The dry run lists the folds.
Migration
0028_variant_unique_namegroups the variants of one parent with one exactvariant_name(exact, as promotion compares;S238Fands238fstay two variants). If any group's rows disagree on sequence, type, numbering offset or mutation list, it raises before writing anything and lists the groups — folding them would keep one sequence and drop the other. Otherwise each group collapses onto its lowest id (the longest-citable accession). Before the duplicates are deleted, every foreign key onto them is re-pointed:interactions,enzyme_kinetics,enzyme_properties,job_resultsall move; structures move unless the survivor holds that PDB id (the copy then fills the survivor's row's NULL metadata, as 0027 does); enrichments move unless the survivor has that source; the survivor takes a mutation list only if it had none (the others are identical). It refuses to run if the live database has a foreign key ontoprotein_variantsit does not know. The survivor fills its NULL columns from the duplicates and unions their synonyms; any other differing value a duplicate carried is written to the log. A removed accession is markedmergedinaccession_registryand pointed at the survivor (or moves to a survivor that had none). Referring rows are counted before and after and must match. It then createsuq_protein_variants_parent_nameon(parent_protein_id, variant_name), declared on the ORM model for the same reason as 0027's indexes. The downgrade drops the index; it does not restore removed duplicates.
Version 1.43.1
Bug fix. Migration 0027_structure_unique_pdb (data): collapses duplicate structure rows, then adds two unique indexes.
Fixed
- Approving a paper that names a protein's PDB id no longer 500s. Promotion asked "does this protein already have structure X?" with
scalar_one_or_none(). Protein 511 (Thc_Cut1,PBHHTE) had four rows for 5LUI (two5lui, two5LUI), so approving ingestions 785 and 892 raisedMultipleResultsFoundand the whole approval failed. Both lookups (protein and variant) now ask an existence question (LIMIT 1), compare withupper(), and the protein lookup ignores variant-owned rows. A variant-owned row also carries its parent'sprotein_id, so a variant and its parent naming one PDB id in a single payload used to hit the same error. - PDB ids are stored upper-case. Enrichment lower-cased the id it took from a
structure_filename; extraction sends upper case.normalize_pdb_id(strip, upper, blank → NULL) is now applied by promotion,upsert_protein_structureandscripts/import_degraders.py. - Enrichment no longer overwrites the wrong structure row.
upsert_protein_structureupdates the row that already holds the PDB id, if there is one, instead of re-pointing the firstexperimentalrow at it. For a protein it now looks only at the protein's own rows, so it can no longer overwrite a variant's structure with the parent's id. - Protein merge no longer duplicates structures. It moved every structure row from the retired protein to the survivor, even ones the survivor already had; that is how protein 511 got its second pair. A protein-owned row whose PDB id the survivor already holds now stays on the tombstone, as enrichment rows do (reported as
parked). Variant-owned rows still move.
Migration
0027_structure_unique_pdbsets blankpdb_idvalues to NULL, and collapses each group of rows with the same owner (the variant ifprotein_variant_idis set, otherwise the protein) and the sameupper(trim(pdb_id))to one row. The survivor is theis_primaryrow, then the row with the most of method/resolution/deposition date/source/confidence/model URL filled, then the lowest id. Its NULL columns are filled from the removed rows. No table referencesprotein_structures.id. Each collapse is logged. It then upper-cases everypdb_idand createsuq_protein_structures_protein_pdb(protein_id, upper(pdb_id)whereprotein_variant_id IS NULL) anduq_protein_structures_variant_pdb(protein_variant_id, upper(pdb_id)where it is not NULL). A protein and its variant may still hold the same id. The indexes are declared on the ORM model, not ininstall_pg_extras, because the deploy runscreate_schema()before the migration and would build them on the un-collapsed table. The downgrade drops the indexes; it does not restore removed duplicates or the original case.
Version 1.43.0
Every weekly release now fits notebook 03 with ESM-2 embeddings and recomputes T4 and T5 from its own bundle. No migration. Needs a deploy (worker image gains transformers).
Added
export_ml_bundle --with-esmbuilds the embeddings instead of copying them.build_esm_entryresolves the same record set the positional rebuild uses (records plus Matrix-B variant rows,falsify.extended_records), reuses every vector a persistent cache (--esm-cache) holds for the same mature sequence (SHA-256 checked), computes the rest withplasticdb_ml.ml.esm(ESM-2 650M, revision pinned), and writes exactly that id set with its digests. The manifest gains anesmblock (ids, reused, computed, model, revision). The cached step-2 file is no longer read: it covered 879 ofbundle-2026.44's 1,212 ids and carried no digests.--esm-threadssets torch's threads. A dry run plans and computes nothing; with--applya failure to build stops the export, as a missing--with-esminput always did.- The release passes
--with-esm. The cache isML_BUNDLE_ESM_CACHE, default<bioinformatics_dir>/esm-cache/esm_esm2_t33_650M_UR50D.json; the exporter child getsHF_HOME=<that dir>/huggingfaceunless one is set, so the 2.5 GB checkpoint is downloaded once onto the shared volume, and only when something needs computing. Measured on the research VM pinned to 4 cores: 2.31 s per sequence at the median length, ~20 s to load the model, 3.3 GB peak RSS; 8 new sequences cost 46 s end to end. release_notebooks.assert_bundle_carries_esm, before the first cell beside the coverage gate: refuses a bundle with no ESM entry, an id without a vector, or a vector whose digest does not match the record's sequence, because notebook 03 (plasticdb-ml 0.9.0) now refuses the same. A bundle pinned withML_BUNDLE_PATHfrom before this release is therefore refused.RUN.json'sbundle.esmrecords what was checked.
Changed
- plasticdb-ml v0.8.0 → v0.9.0, with the
[esm]extra inrequirements-notebooks.txt. Notebook 03 recomputes T4 and T5 from the bundle (falsify.t4_from_bundle/t5_from_bundle, the CLI protocol) instead of displaying the frozen 2026-09-04t4.json/t5.json, which it now shows beside them; fits with ESM by default; and reports every head and T4/T5 with and without it. Onbundle-2026.44(930 records, VM, profile block live): T4 improved-direction 0.285 [0.124, 0.486] over 128 triples / 24 parents (frozen: 0.212, 120 / 21); T5 PET AUROC 0.668 [0.608, 0.778] (frozen: 0.685); PET forest AUROC atcentroid250.660 with ESM, 0.531 without.
Version 1.42.3
scripts/label_matrix_api.py reads what the API now publishes. No migration, no API change.
Changed
- Actor ids are read, not inferred.
GET /papers/{id}/recordshas carriedprotein_id/protein_variant_idsince 1.42.1 (#491). The script uses them directly and drops the public-page ground truth, the extraction matcher for identity, thegroup_permutedbijection, the self-audit andmax_disagreements/max_impossible. Checked against the live API: all 3,141 protein-page foreign keys and all 2,555 variant ones agree with the published ids, and every one of the 3,162 protein-actor and 2,559 variant-actor interactions is placed (the 1.21.6-era run left 333 of 1,898 protein-actor rows placeable only inside a group). is_tombstonedis read, not probed. It is the admin protein list'sstate == "merged"(which covers proteins in no paper), cross-checked againstFootprintProteinRead.is_tombstoned; the one-HTTP-status-per-protein resolver probe is gone.- A missing field is an error. An API that does not publish
protein_id,protein_variant_id,is_tombstonedor the liststateraisesApiErrornaming the field; there is no fallback to the old inference. - Still inferred:
enzyme_preparation/enzyme_production_contextare on no read schema, so/ingestions/{id}is still fetched for those two columns and matched on the now-known actor, plastic, verdict and note. Publishing them on a read schema would let that fetch go.
Version 1.42.2
Vocabulary note only. No migration.
Fixed
PECvocabulary note now says what the row holds: poly(ester carbonate), v1's Mitsubishi Gas Chemical polyester-carbonate (Calabia & Tokiwa 2004). It used to say PEC "most likely denotes poly(ethylene carbonate)", which contradicted the row's own data and, since 1.41.1, a separatePoly(ethylene carbonate)row.
Version 1.42.1
Dependency pin. No migration.
Changed
- plasticdb-ml v0.5.3 → v0.8.0. It brings the fresh-ID Phase 1 tooling:
--first-family-id/--first-merge-id, thepartition_idstamp, the crosswalk and stability checks. It also fixesparse_domtbloutso a catalytic envelope that HMMER prints in two pieces of one model is completed, whichscripts/export_ml_bundle.pypicks up through its import. Order-unstable families are now split by a majority vote over 100 seeded orderings. Notebook 05 andplasticdb_ml.metagenomescome along too, but are excluded from release runs. The new Phase 1 partition is67e0b2a6f4918232: 150 families, 11 profiles. It is staged at/app/bioinformatics/ml-work-2026-10-27, and the release reads it onceML_BUNDLE_WORK_DIRpoints there (plasticdb-gitops).
Version 1.42.0
Publish safety ahead of the family-partition switch. No migration.
Added
- The family partition is part of the library fingerprint.
hmm.fingerprint.jsonnow carriespartition: {partition_id, digest, source}, read from the build'sfamilies.json(also looked for asphase1/families.json) or Phase 2'sprofile_manifest.json(beside the calibration, in../profiles/).partition_idis the stamp plasticdb-ml will write; until it does, the digest of the file is the fallback. A partition swap in a week where the corpus did not move now republishes, and the HMM screen TSV header gains# profile_library_partition:so a download says which partition produced it. - Keeping old fingerprints comparable. The partition is its own key, not folded into
digest, sodigestis unchanged and still agrees withscripts/export_ml_bundle.corpus_fingerprint. A caller that cannot see a partition (the hourly check with no build directory) ignores it. One-time change: the library published before this release has nopartition, so the first publish after it counts as stale once, republishes and records it. scripts/publish_hmm_library --allow-profile-set-changeand--families(name thefamilies.jsonwhen it is not inside the build directory).
Changed
- A changed profile set is never published automatically.
hmm_publish.publish_from(the release) andhmm_build.check_hmm_library(the hourly beat) refuse when the new library's profile names differ from the published one's. The reason is logged and returned (so it lands on the release'snotebooks["published_library"]and in the Slack line), withrefused: "profile_set_changed"; the old library keeps serving.forcedoes not override it. A person publishes a changed set withpython -m scripts.publish_hmm_library --build-dir <dir> --apply --allow-profile-set-change; the script otherwise refuses with exit status 3.POST /admin/releases/publish-nowpublishes staged records, not the profile library, so it is unaffected and gains no flag.
Version 1.41.4
Bug fix. No migration.
Fixed
- A failed notebook stage no longer loses its cause to a cleanup error. On release 2026.42 notebook 03 hit the 3600s timeout, and while the exception unwound
TemporaryDirectorycould not remove the scratch tree (OSError: [Errno 39] Directory not empty), so the release row recorded theOSErrorinstead ofNotebookRunError: ... did not finish within 3600s. The scratch directory is now removed best-effort (retried, then a warning naming the directory) and cleanup can never raise. - A notebook timeout now kills the kernel, not just nbconvert.
subprocess.run(timeout=)kills only the direct child; the kernel is a grandchild and kept writing into the directory being deleted, which is what made the cleanup fail. nbconvert now runs in its own session and the whole process group is killed on timeout or interruption.
Version 1.41.3
Build fix. No migration, no runtime change.
Fixed
- A flaky runner link to github.com no longer fails the image build.
docker/pip-install.shchecks that the private PlasticDB-ML repo is readable before pip starts, with a singlegit ls-remote. When the CI runner's connection to github.com dropped out, that check hung ~150s, failed, and blamed the token — the v1.41.0 build failed this way twice with a working token. The check now retries network failures (4 attempts, 10/20/30s back-off, each capped at 60s;PROBE_ATTEMPTS/PROBE_TIMEOUToverride), stops at once on an authentication refusal, and prints what git actually said so a timeout is no longer reported as an expired token.
Version 1.41.2
Vocabulary only. No migration.
Added
- Plastics:
PBST— poly(butylene succinate-co-terephthalate) with no grade stated (copolymer, unparented likePBAT/PBS/PBSA). The legacyPBST55row is one grade and stays the term for it; filing an unstated grade there would assert a composition. Unblocks10.1016/j.jhazmat.2024.135895. - Plastics:
PET oligomer— PET oligomers (n ~ 3-8, e.g. a glycolysis by-product), parented toPETon theNylon oligomer/Polyisoprene oligomer/PCL diolprecedent. Not BHET or MHET, which are compounds. Unblocks10.1016/j.wasman.2023.08.048. - Compounds:
Glutaric acid(C5H8O4; unblocks10.1016/j.biortech.2025.133186) andN-methyldiethanolamine(C5H13NO2, pH-responsive PU chain extender; unblocks10.1016/j.msec.2018.08.063).
Version 1.41.1
Vocabulary only. No migration.
Added
- Plastics:
Nylon 4— polyamide 4, poly(4-aminobutyrate). A sibling ofNylon 6andNylon 66(parentNylon,polyamide). Unblocks10.1016/j.polymdegradstab.2022.109868and10.1016/j.polymdegradstab.2023.110446. - Plastics:
Poly(ethylene carbonate)— the alternating ethylene oxide/CO2 aliphatic polycarbonate, declared asPPCis. It takes no abbreviation:PECis already the legacy poly(ester carbonate) row (Tokiwa's PBS/C), so reusing it would merge two polymers. - Plastics:
Aliphatic polycarbonate (other)— a family anchor on theAliphatic polyester (other)pattern (kind NULL, unparented, no abbreviation) for one-off epoxide/CO2 copolymers and terpolymers; comonomers go insubstrate_composition. Not bisphenol-APC. With the row above and the epoxides below, unblocks10.1007/s002890050484. - Plastics:
PHA Copolymer— the covalent counterpart ofPHA Blend, declared asPCL Copolymeris (parentPHA): PHA joined to a non-PHA partner by graft, block or end-cap, e.g. PEG-capped PHBV. Unblocks10.1016/j.jbiotec.2007.03.012. - Compounds:
Trimethylolpropane(unblocks10.1016/s0922-338x(97)83000-0),Ethylene oxide,Propylene oxideandCyclohexene oxide(the epoxide comonomers of the epoxide/CO2 polycarbonates).
Version 1.40.4
Bug fix. No migration.
Fixed
- Bulk approve/reject no longer 500s every revision after the first failure. A failed decision (e.g. an organism NCBI cannot resolve) rolls the session back, which expires every object in it — including the curator's
User. The next revision's approve readcurrent_user.id, a lazy load on an async session, and raisedMissingGreenlet; onsweep-2026-10one unresolved name turned the rest of each 10-revision chunk into500s. The user is now re-loaded after each rollback. The failing revision still fails alone, as designed. Covered by a real-session test; the existing mocked test could not see it because a mock never expires.
Version 1.40.3
Bug fix. No migration.
Fixed
/curation/revisionsno longer takes the site down on a large batch. With no batch chosen the page opens the busiest open sweep and asked for up to 500 revisions, loading every revision's and every parent's fullextracted_datain one query to diff them. On the 437-revisionsweep-2026-10batch that exhausted the single uvicorn worker's memory: the page answered502 Bad Gatewayand so did every other page,/healthincluded, until the container restarted. The page now shows 50 revisions at a time with previous/next links, andbatch_revisionsreads each page without its payloads and diffs them 20 at a time (DIFF_CHUNK), so peak memory follows the chunk rather than the page size — which also protectsGET /api/v1/ingestion-revisions/batches/{batch}?limit=500. A paper patched in place now loads only the one earlier attempt it is compared with, not every attempt it ever had.
Version 1.40.2
Vocabulary only. No migration.
Added
- Plastics:
Dimethacrylate resin (other). A family anchor for cross-linked (di)methacrylate resin networks (dental composites, adhesives and coatings) with no row of their own; the resin's monomers go insubstrate_composition. It takes the two cured dental resins of10.1016/j.dental.2026.01.011(Bis-GMA/TEGDMA and FPUA/TEGDMA, hydrolysed by a Pseudomonas cholesterol esterase), which were filed on the legacyPlasticplaceholder (PlasticDB-AI #107). - Compounds: Bis-GMA, TEGDMA, HEMA and perfluoropolyether alcohol, so that anchor's composition can be stated. The paper's one-off FPUA monomer is recorded by its precursors (IPDI, PFPE alcohol, HEMA).
Version 1.40.1
Glossary corrections and two more confirmed settings. No migration.
Changed
- Admin settings: the Figshare API URL and "Run the ML notebooks" now need confirmation too, like the other four release switches. The API URL decides where the Figshare token is sent and where releases are published; the notebooks switch changes what goes into the next snapshot.
- CI: the build, tests and pg jobs get 30 minutes instead of 20. With a cold image build they were finishing at 19–20 minutes and timing out.
- Glossary: the GA / TC / NC entry now defines all three cut-offs and says the HMM screen applies GA by default (it still described the pre-1.39.0 behaviour). The Snapshot and Weekly release entries no longer say every snapshot gets a Figshare DOI.
Version 1.40.0
Shareable job results, safer admin settings, and EC numbers on the pathway. This release carries a migration, 0026_reaction_step_ec_number, which adds one nullable column (reaction_steps.ec_number) and backfills nothing. One new table, job_share_links, ships through create_all.
Added
- Read-only share links for jobs (B13). A job's owner can create a link (optionally expiring in 1–365 days) and revoke it. Anyone with the link sees the results, provenance card and downloads without signing in; the owner's identity, other jobs, the input file and all write actions stay hidden. Tokens are stored hashed and shown once; revoked and unknown links give 404, expired ones 410; shared pages are
noindexandno-store. - My Jobs shows runtime, key parameters, input file and how long result files are kept (90 days, from the existing sweep). Pathway, tree and taxa-table jobs show a real result summary ("1 of 4 steps present", "276 tips") instead of "Results 0".
- EC numbers on the PET pathway. Steps carry an EC number, shown under the enzyme on the diagram and in the step list, linked to ExPASy ENZYME.
scripts/backfill_pathway_ec.py(dry-run by default) fills them from the curated pathway file and the EC already on linked proteins, and reports disagreements instead of writing.
Changed
- Admin settings: irreversible switches need confirmation (A8). Figshare auto-publish, the Figshare article ID, the weekly dataset release and publishing the HMM library each have their own control with an in-page confirmation stating the consequence; the API refuses a change to them without
confirm(422, nothing written). Secrets are write-only: shown as a mask with the last 4 characters, a re-submitted mask is never stored, and a secret can be explicitly cleared. The settings page no longer has inline handlers, so it gets the strict CSP.
Fixed
- Admin delete of a job with hits failed on PostgreSQL (foreign-key violation from
job_results). The hit rows are removed first.
Version 1.39.0
The HMM screen applies the profile library's calibrated cut-offs. No migration. Behaviour change for API clients: an hmm_screen job that sends only evalue now gets gathering cut-offs; send threshold_mode=evalue for the old behaviour.
Changed
- HMM screen uses gathering (GA) cut-offs by default (
hmmsearch --cut_ga). Each profile's GA sits between its weakest true family member and the strongest near-miss from a confusable α/β-hydrolase family, so generic esterases and lipases that clear an E-value no longer count as hits. E-value only remains as an "exploratory" mode for distant homologues (threshold_mode=evalue). If any profile in the library lacks a GA line the job falls back to E-value and records why. Each hit shows its bit score next to the profile's GA, and the TSV download gainsprofile_gaandabove_ga. In gathering modejob.evalueis empty.
Fixed
- Approving a revision could delete other papers' interactions. When a revision stopped using a protein variant its original ingestion had created, the supersede deleted that variant even though other papers' interactions still named it, and the database cascade removed those interactions with their experiments and observations. On production, approving revision 1229 (paper 204) removed the LCC-ICCG × PET interactions of ingestions 212, 359 and 360. A variant still named by any surviving interaction, kinetics or properties row is now kept. The three ingestions are repaired by re-approval.
- Deleting or withdrawing a paper could do the same. A variant belongs to the paper that first created it, so deleting that paper cascaded into every other paper's interactions using the variant (38 variants are shared like this on production). Before a paper is deleted, each variant other papers still use is handed over to the earliest of them.
- News item 5 restored. V2 had rewritten the announcement of the 2024 Environmental Microbiome paper into claims the paper does not make, dated it 2022 and dropped its DOI. It now carries the original text, quotes and full citation (doi:10.1186/s40793-024-00575-4).
Version 1.38.0
Accessibility and findability. No migration.
Security
- Password reset accepted a client-chosen minimum length. The reset request carried a
min_lengthfield the server trusted, so a one-character password could be set. The field is gone and the server enforces 8 characters, the same rule as registration.
Added
- What's new (
/whats-new, the 8 latest releases) and the full changelog (/changelog), rendered from CHANGELOG.md and linked from the footer and the landing page. - Glossary (
/documentation/glossary): 62 terms drawn from the existing documentation, marked as draft wording pending review. - News archive (
/news). The landing page shows the latest software and dataset releases instead of 2021–22 news; items under a year old still appear there. - Structured data (JSON-LD) for search engines and FAIR indexes:
Dataseton the landing and download pages (with the latest Figshare DOI), BioschemasProteinandTaxon,ScholarlyArticlewith DOI and PMID,ChemicalSubstancewith verified ChEBI/PubChem only. robots.txtand sitemaps (/sitemap.xml, paged per entity type) listing public, non-tombstoned records withlastmod.- PMIDs on paper pages (View on PubMed), in the papers list and in the papers API (
pmid). - External resources box on protein, organism, plastic and paper pages: PAZy (by accession), ESTHER (α/β-hydrolases only), BacDive (bacteria and archaea), and a cross-references line of stored identifiers as CURIEs.
Fixed
- Extraction drift scans failed on almost every ingestion (1.36.0). The new pairing score counted experiment keys, and a key carries
additional_conditions, a JSON dict, so 575 of 584 payloads failed withunhashable type: 'dict'and the scan showed no drift. Keys are now frozen before counting; equality is unchanged.
Changed
- Headings: one
h1per page and no skipped levels across public, documentation, auth and tool pages. - Text alternatives for the taxonomy-group charts and the 3D structure viewers.
- Visible labels on login, register and password forms, with the password rule shown up front; every form field has an accessible name.
- Page titles follow "Page - PlasticDB" everywhere.
- Card headers on public pages are plain; colour is kept only where it means a result.
- Chat renders headings, lists, code and links instead of showing
##literally (escape first; links limited to http(s) and site paths).
Version 1.37.0
Strain details are shown and exported, and the public API is easier to call from other sites and tools. No migration. Breaking for API clients: /api/v1/jobs, /pathways, /releases and /snapshots now return {total, items} instead of a bare list.
Added
- Strain details on organism and paper pages (C9). Each strain's culture-collection IDs (linked to BacDive), isolation source, genome accession (linked to NCBI) and papers, plus a strain × plastic matrix on organism pages counting each strain's own whole-cell results. Expression hosts and knockouts are labelled and not counted.
- Strain fields in exports.
microorganisms.csv/.jsongainstrain_designations,culture_collection_idsandgenome_assembly_accessions;dataset.zip,dataset.jsonand release snapshots gain astrain_occurrencestable (public, non-tombstoned organisms only). The changed export means the next weekly release gets a new DOI. scripts/report_duplicate_strains.py: read-only report of strains that look the same (shared deposit number or normalised designation, e.g. PAO1 and ATCC 15692) with their DOIs and ingestion ids, for corrections upstream.- CORS for anonymous reads (E9).
GET/HEADon/api/v1/*from any origin getsAccess-Control-Allow-Origin: *, never with credentials; preflights asking forAuthorizationor a write method are refused. - Rate-limit headers. Limited routes return
X-RateLimit-Limit,-Remainingand-Reset; a 429 also carriesRetry-After. Limits are unchanged. - Public API description at
/api/v1/openapi.public.jsonwith Swagger UI at/api/v1/docs: only public read endpoints, derived from the routes' own dependencies. - Typed list items in the OpenAPI description for every paged list, and a typed
/stats.
Changed
/api/v1/jobs,/pathways,/releasesand/snapshotsuse the standard{total, items}envelope. The site's own pages were updated; external scripts reading these lists need.items.
Version 1.36.0
HMM screen and taxonomy tree jobs now show where their results came from, and upload tools submit once per click. No migration.
Added
- Provenance cards for HMM screen and taxonomy tree jobs. HMM screen jobs record and show the HMMER version, the command, the profile library (file, build time, profile count, fingerprint, corpus and toolchain), the E-value threshold and whether GA/TC/NC cut-offs were applied (they are not: the screen runs
hmmsearch -Eonly). Tree jobs record and show the lineage source, the last completed NCBI lineage refresh, the tree method, how plastic columns are credited and whether the topology came from the cache. Older jobs show "not recorded" per row. Parameters shown on the card are no longer repeated under Job details.
Fixed
- Upload tools needed several clicks to submit, and each extra click posted another job (B14). The Submit button was reset in a
finallyblock while the browser was already navigating to the job page. It now stays on "Opening job…" after a successful submit and comes back only when a submit fails. Affects Annotate list, gene and genome, Pathway analysis, HMM screen and Compare genomes. - Organism search results showed the raw NCBI lineage string. They now show a short trail (domain › phylum › … › genus).
- Footer links were too small to tap (R02-10). Every footer link is at least 24px tall; Quick Links and Contact become 44px rows on touch screens and below 992px.
- Extraction drift report paired same-protein interactions by result. Two payload interactions sharing an accession could be matched crosswise, hiding real differences (paper 377). Pairs are now ranked by identity, actor match strength, experiment overlap, then result and notes. The remaining missing-experiment rows on 411 and 629 are genuine pre-1.35.0 shared experiments and now say so.
- Test isolation: the cached job store leaked between tests, so five HMM screen tests failed on machines without
/app. Import order is pinned foralembicsoruff checkagrees across checkouts.
Version 1.35.0
V2 can now receive the fields PlasticDB-AI will extract, shows extracted text without editing it, ranks kinetics in common units, and carries its own chemistry reference data. This release carries a migration, 0025_declare_extraction_fields, which adds nullable columns only (protein_variants, enzyme_kinetics, enzyme_properties, observations, experiments) and two CHECK constraints; it backfills nothing. Two new tables, chemical_references and chemical_reference_monomers, ship through create_all.
Fixed
- Every paper exported the same PMID (1.34.0). The PMID lookup's evidence subquery was not correlated to the paper, so papers.csv/json and the BibTeX/RIS exports gave all 584 papers PMID 10103271. Each paper now gets the PMID of its own promoted ingestion, or none.
- Promotion lost properties when payload entries collapsed. Payload interactions with the same paper, plastic and actor merge into one live interaction; each entry's experiments, observations, kinetics and properties are now kept instead of overwriting one another (paper 377: LCC-G's half-life and Tm had replaced LCC-NG's). Existing rows are repaired only by a reopen-and-approve of ingestions 377, 204, 82, 219, 226, 254, 309, 411, 516, 607, 614, 615, 618, 629 and 1030.
- Re-approve now updates owned interactions. A revision updates notes, result and enzyme context on interactions its supersede chain owns; other rows keep their values and record a
revision_conflict. - The extraction drift scan no longer runs in an API pod. In 1.34.0 it ran inside the request and could exceed the API pod's 512 Mi limit. Rescan now queues
run_extraction_drift_scanon the release worker; results are stored inextraction_drift_scans/extraction_drift_rows(create_all) and the page and API read the latest completed scan. The scan streams 50 ingestions at a time. - Drift report accuracy. New
collapsedandunstampedclasses (not drift); ingestions replaced by an approved revision are skipped; pre-existing paper title/authors/journal count asnormalised.
Added
- New extraction payload fields, end to end (payload → column → promotion and revision → drift report → API → pages → exports →
/api/v1/vocabulary):protein_variant:synonyms(searchable),numbering_scheme_code(mature | precursor | reference_homolog | pdb_structure | other),numbering_reference(required forreference_homolog),is_originating_paper,originating_doi. The free-textnumbering_schemeis kept as extracted; a proposed code is computed on read (numbering_scheme_code_proposed,python -m scripts.report_numbering_schemes), never stored.experiments[]:initial_mass,initial_mass_unit.observations[]:time_point,time_point_unit(h | d | week),sd,n,control_value.interactions[].kineticsandinteractions[].properties(single objects):substrate,substrate_type(plastic | compound).- The curation form has controls for every new field, so approving never wipes them.
- Chemistry reference (V2-owned).
chemical_referencesholds PubChem CID, ChEBI id, InChIKey, backbone, polymer family and monomers; a draftapp/reference/chemistry/core_plastics.jsoncovers the core plastics and their monomers (small molecules verified against PubChem/ChEBI; polymer identifiers null or marked unverified).python -m scripts.load_chemistry_referenceis dry-run by default; nothing is loaded by the deploy. Plastic and compound pages and the API show the fields when present. - PU subtypes.
PU-ester,PU-etherandPU-carbonateunder PU; the legacyImpranilrow moves underPU-ester. Existing PU interactions stay on PU. New payloads naming a subtype ("polyester polyurethane", "polyether-based PU") resolve to the child. Reverses the 2026-08-07 curator refusal, at the maintainer's decision. - Notes on ambiguous abbreviations. PES (poly(ethylene succinate)), PEF, PEC and Impranil carry a clarifying note on their plastic pages.
- Normalised kinetics.
kcat_normalised(s⁻¹),km_normalised(mM) andkcat_km_normalised(M⁻¹·s⁻¹) beside the stored values in the API and variant export; the engineering comparison table sorts on them and marks units it cannot convert (mass-based Km).
Changed
- Extracted text is filtered on display, never rewritten. Public pages hide figure paths and curator-addressed notes through one rule set shared by
app/services/text_display.pyandapp/static/js/text_display.js. The API and exports return stored text unchanged (DECISIONS.md §10).scripts/clean_public_text_artefacts.py --applynow refuses and points to the PlasticDB-AI correction route; its dry-run report remains. - The legacy "Plastic" placeholder is left out of the plastics list, pickers, search, statistics and the taxonomy tree. Its evidence still shows on its own page (with a note), on organism, protein and paper pages, and in exports. Nothing was reassigned.
Version 1.34.0
Groundwork for keeping V2 in step with the PlasticDB-AI extraction JSONs, plus incremental sync (E5) and richer exports (E10). This release carries a migration, 0024_record_updated_at, which adds an indexed updated_at to proteins, protein_variants, microorganisms, plastics and papers; the PreSync hook applies it. No existing value changes.
Fixed
- Approved revisions now land on variants and strains. A revision sent through
/ingestions/{id}/reviseand approved updates the payload-sourced fields of variants and strain occurrences its own paper created (and clears fields the revision drops). Rows created by another paper keep their values; each disagreement is listed as arevision_conflictin the approve response, the bulk revision report and the review page. First-time promotions are unchanged. Rows from the v1 import carry no ingestion stamp and are not corrected;genetic_backgroundchanges only when the payload states it. - Glycolic acid formula is C2H4O3 (was seeded as C2H4O2, acetic acid's). The vocab seed corrects the live row only if it still holds the old value. All 97 seeded compound formulas were checked.
Added
- Extraction drift report.
python -m scripts.report_extraction_drift(CSV/JSON;--paper,--table,--class) and the admin page/curation/extraction-drift(withGET /api/v1/curation/extraction-drift) compare every payload-sourced live field with the approved ingestion's stored JSON, classed astext_edited,value_changed,missing_live_row,extra_live_rowornormalised. Read-only; the scan is batched and cached, with a Rescan button.apply()'s field copies are now named lists the report shares. GET /api/v1/vocabularyand/vocabulary/{name}: every controlled list the ingestion schema enforces (17, including measurement types with default units, environment types, strain types), plus plastics (kind, parent, aliases) and compounds, built from the validators' own sources, with per-list and overall versions and ETags.POST /api/v1/ingestions/validate(producer auth): runs the payload schema and vocabulary checks and returnserrors,unrecognised_fieldsandunmatched_plasticswithout storing anything.- Incremental sync (E5).
?updated_since=on the protein, variant, organism, plastic and paper lists;updated_atin responses;GET /api/v1/changes?since=lists updated, removed and merged records (staged records never appear); ETag/304 on JSON responses; gzip;?view=summaryon protein detail drops the evidence lists (IsPETase was 995 kB). - Exports (E10). papers.csv/json gain
titleandpmid;/papers/export?format=bibtex|ris(same filters as the list) and/papers/{id}/export; the variants CSV gains numbering scheme, numbering offset, paper id/DOI/year andtm_celsius.
Version 1.33.0
Hit tables keep every hit and filter on the page; search understands everyday names and typos (D1). No migration.
Changed
- Every hit is stored; subject coverage ≥ 70 % is the default view. Annotate Gene/Genome and Compare Genomes no longer drop hits before storage. The form's coverage/bitscore fields now set the job's starting view (
result_metadata.view_defaults; scov 70 when left out, 0 = off). The job page shows "N of M hits shown · K hidden · Show all", keeps the threshold in the URL (?scov=&qcov=&bitscore=), and recomputes per-plastic counts, the best-hit view and the Compare Genomes matrix for the active threshold. 1.31.0 jobs'hit_thresholdsare read as their default view; jobs from before 1.31.0 have no coverage and show every hit with a note. - Downloads match the screen.
/jobs/{id}/downloadand/jobs/{id}/comparisonacceptscov,qcov,bitscoreandall, and write# default_view/# view_filterlines. The comparison TSV/CSV now start with#lines. The compare-genomes zip holds the filtered matrix pluscomparison_matrix_all_hits.tsv. A "Download all hits" link applies no filter. GET /jobs/{id}stays unfiltered and now also returnsview_defaults./jobs/{id}/comparisongainsformat=json.- Search (D1). Plastic names and abbreviations outrank fuzzy hits; words under 4 characters match whole ("PE" no longer finds Perez, "PS" no longer Pseudomonas); "polyether" is no longer stemmed into polyethylene. Raw "rank" scores are replaced by "exact match", "common name" or "similar spelling". Accession matches still rank first.
Added
- Everyday names for plastics (D1). styrofoam/EPS → PS, PET bottle → PET, plastic bag/polythene → LDPE and PE, PVC pipe → PVC, bioplastic → PLA and PHA, plexiglass → PMMA (
app/services/search_synonyms.py). - Typo tolerance (D1). Trigram similarity ≥ 0.5 on queries of 5+ characters against plastic names and synonyms, with a "Did you mean …?" suggestion; a reviewable list keeps real polymer words (polyether, polyamine) from being treated as typos.
- Plastic top card (D1). A query that names a plastic shows "Go to PS — Polystyrene" above the results;
/search/groupedreturns these and the suggestion.
Version 1.32.0
Bulk review for producer corrections ("sweeps"). This release carries a migration, 0023_ingestion_revision_batch, which adds nullable revision_batch/revision_reason columns (and an index on the first) to paper_ingestions; the PreSync hook applies it. Existing rows read NULL.
Added
POST /api/v1/ingestions/{id}/revise(producer). Delivers a corrected extraction for a paper already in V2, tagged with arevision_batch(e.g.sweep-2026-10) and arevision_reason.- On an approved paper it reopens and sets the new payload in one transaction, under reopen's row lock and through reopen's own code (
app/services/ingestion_revision.py), so every reopen guard holds: one open revision per paper, approved papers only. Returns 201revision_opened. An identical payload opens nothing (200unchanged). - On a pending_review paper it replaces the payload in place (200
patched), unless a curator holds a live review claim. - A resent request is a retry: it updates the revision the same batch opened (200
revision_updated). - Callable by the account that delivered the paper (an
autorow whose chain it submitted) or by a curator with the publication right.
- On an approved paper it reopens and sets the new payload in one transaction, under reopen's row lock and through reopen's own code (
/curation/revisionsand/api/v1/ingestion-revisions/batches[/{batch}[/approve|/reject]](curators). Lists each batch with a server-computed diff per revision against the record it replaces: interactions added, removed and changed (matched by actor + plastic, not position), with result, strain type, observation count and values, notes and other fields. Approve or reject a selection, or the whole batch.- Each revision is approved by the existing approve code (replace mode for a revision of an approved paper), in its own transaction. A failure (e.g. an unresolvable organism) is reported on its row and the rest go ahead.
- Retrying is safe: decided revisions report
already_approved/already_rejected. Approve handles at mostlimit(default 20, max 50) per call and pages withafter_id.
app.services.extraction_diff, the pure diff function behind the page.
Changed
reopennow calls the sharedopen_revisionservice. Behaviour is unchanged.
Version 1.31.1
Follow-ups from verifying 1.31.0 on the live site. No migration.
Fixed
- Variant pages under /proteins/.
/proteins/<variant accession>(e.g. PBRMK6) returned the 404 page; it now redirects (301) to the variant page, as the API already did. - Accession search. Exact UniProt, GenBank (with or without the version suffix), PDB, PlasticDB accession and PlasticDB id now find the protein, case-insensitively and ranked first, in the /proteins list,
/api/v1/search, grouped search and autocomplete. Visibility filtering is unchanged. - Job pages show only the parameters a job type uses. Taxa Table jobs no longer list E-value or % identity; the taxonomy tree no longer lists "Wild-type only"; HMM screen no longer lists % identity; pathway jobs name the pathway.
- "Mode" shown twice on hit-table job pages; it, and the search thresholds, now live only in the provenance card when one exists.
Added
- Taxa Table provenance. A "Methods & provenance" card with data release, input file, matching method, synonym table and the column layout detected at run time; the taxa TSV gains the same
#header lines (the column header row is kept; a leading#OTU IDloses its#). - /jobs list (R07-7). Input-file column, real result counts and run times.
Version 1.31.0
Tool output researchers can cite (B4, B5, B6), plus keyboard (G2) and mobile (G3) fixes. This release carries a migration, 0022_job_result_coverage, which adds nullable qcov/scov columns to job_results; the PreSync hook applies it. Hits stored before it show "–" for coverage.
Added
- Job provenance (B6). Every new job records the data release, reference DB build time and sequence count, DIAMOND version, mode, parameters and input filename in
result_metadata.provenance. It appears in a "Methods & provenance" card on the job page and as#lines at the top of the annotate TSV. Older jobs show "not recorded". - Hit-table coverage and thresholds (B5). Query and subject coverage columns; optional
min_qcov,min_scovandmin_bitscoreon Annotate Gene/Genome and Compare Genomes (off by default); a best-hit-per-query view; live coverage/bitscore filters on the job page. - Annotate Taxa Table output (B4). Every input column is kept (ASV/feature id included), followed by the match, linked NCBI taxid and PlasticDB URL, positive and negative plastics, paper count, up to 3 DOIs, and a genus-level summary with a caveat for genus-only matches.
- Genus synonyms (B4). Ideonella/Piscinibacter/Pseudideonella, Pseudomonas and its recent splits, and Rhodococcus/Rhodococcoides are searched together when a genus finds nothing under its own name;
Synonym_Notesays when this happened.
Changed
- Taxa table input (B4). QIIME 2, SILVA and GTDB
;lineage strings are parsed. An empty, header-only or unreadable table now fails with a message saying what was found, instead of completing with no rows. - Annotate TSV (B5). Header row, plain accessions (no
protein:prefix) and the extra columns; built on request, so older jobs get it too. - Sortable tables (G2). Headers are buttons with
aria-sortand a visible focus ring, through one shared script. - /engineering (G2). Enzyme names are links, the pager is a labelled
<nav>, the search box is labelled, and the page has one<h1>. Its inline handlers are gone. - Mobile layout (G3). Search chips wrap below 992 px (navbar is sticky there); protein and other detail tables wrap at 390 px; the /microorganisms filters collapse below 768 px behind a "Filters (n)" button and open when filters are active; the chat box fits on a phone screen.
Fixed
- Hit order (B5). Hits are sorted by query (input order), e-value, bitscore and accession everywhere;
Job.resultshad noORDER BY. - Accessible names (G2). Docs cards have no empty links; the chat widget, scroll-to-top button and profile image have names.
Version 1.30.0
Trustworthy tool results and counts: Compare Genomes (B7), Pathway Analysis (B8), Taxonomy Tree (B9) and honest organism counts (C4). This release carries a migration, 0021_strain_genetic_background, which the PreSync hook applies. Every existing strain starts as unknown, so the migration itself changes no count.
Added
- Strain type (C4).
strain_occurrences.genetic_backgroundtakes the values wild type, expression host, knockout, engineered strain or not classified.- Expression hosts, knockouts and engineered strains are never credited to their species. That covers badges, counts, "First Reported", the plastic page, stats, exports and snapshot, the Taxonomy Tree and Annotate Taxa Table.
- Their results stay on the paper and protein pages, labelled (e.g. "expressed in E. coli BL21(DE3)").
interactions.csvgains agenetic_backgroundcolumn. - Curators set the type on the ingestion review form ("Strain type") or at the new
/curation/evidence-checks.
/curation/evidence-checks(andGET /api/v1/curation/evidence-checks). It shows strain-type proposals, and read-only lists of positives with no experiment, values copied across isolates, positives showing mass gain, and catch-all "bacterium"/"fungus" taxa where a genus is named.python -m scripts.propose_genetic_background. Dry run by default;--applywrites only the high-confidence class.- Compare Genomes matrix (B7). A genome × plastic matrix counts the query sequences that hit an enzyme with its own positive result for that plastic. Hits with only negative or inconclusive evidence are listed separately.
- Counts are normalised per 1,000 proteins (BLASTP) or per Mb (BLASTX).
- The matrix is shown as a heatmap, a per-genome bar chart with a text alternative and PNG/SVG export, and a table.
- It is also served by
GET /api/v1/jobs/{id}/comparison?format=tsv|csv, thecomparisonfield, andcomparison_matrix.tsvin the zip.
- Pathway Analysis results (B8). A per-step table shows substrate → product, EC, best hit, identity, coverage, and present/absent with the reason. There is also a missing-steps list and an "N of M steps present" summary. The results TSV has a header and per-hit step columns, and the tool page lists references per step and flags single-organism steps.
- Taxonomy Tree downloads (B9). Newick (
plasticdb_taxonomy_tree_<job>.nwk), standalone SVG with the plastic key, and PNG at 2x. Clades collapse and expand on click, and there is "Expand all" and a label-size slider.
Changed
- Evidence badges count distinct papers (C4). Per-plastic results gain
positive_papers,inconclusive_papers,negative_papersandpaper_count. - Pathway Analysis references (B8). Each step is judged against the curated enzyme plus every PlasticDB protein with positive evidence and the same EC number (or a curator link), not P. sakaiensis alone.
- Pathway step presence (B8). A step counts as present only when a hit has at least 40 % identity, an e-value of at most 1e-10, at least 70 % coverage of the reference, and at least 50 % coverage of the query. The thresholds are adjustable and stated on the page.
- "Build Phylogenetic Tree" is now "Taxonomy Tree" (B9). Each page says it is an NCBI-taxonomy cladogram: ranks only, no sequence comparison, no branch lengths. URLs and the
build_treejob type are unchanged. - Taxonomy Tree leaves out placeholders (B9). Synthetic constructs and uncultured, unidentified, unclassified or metagenome names, plus bare "bacterium"/"fungus", are left out and named in the footer. The PET tree goes from 273 to 267 leaves. Annotate Taxa Table also treats a bare "fungus" as a placeholder.
- Job API hit proteins (B7). The
proteinobject on job hits carriessource_microorganism(public only), its owndegradation_linksandpaper_count. The always-emptypapersandpaperfields are removed. - d3 7.9.0 is vendored instead of loaded from the CDN.
Fixed
- "Tested whole only" (C4).
evidence_scope=whole_organismon the organism list still showed via-enzyme rows in its badges and papers. - Pathway Analysis false positives (B8). An E. coli K-12 proteome lit pathway steps from 42–64-residue partial alignments. The page also said "ten curated enzymes" while naming six.
- Base64 Compare Genomes endpoint (B7).
POST /api/v1/bioinformatics/compare-genomessaved one file the worker never read. It now takes onefasta_b64per genome. - Taxonomy Tree viewer (B9). It drew no plastic dots for names containing brackets or punctuation, and the "Taxa" button never toggled the labels.
Version 1.29.0
Small P1 fixes from the pre-release sweep: correct sequence-type handling and tool docs (B10, B11), release stamps on downloads (E6), stricter and more consistent API behaviour (E4), accession lookups (E7), API docs (E8), PlasticDB v1 ids (A7), and removal of the iTOL embed (D2, deployed from #539). No migration. After deploy, run python -m scripts.backfill_v1_legacy_ids (dry run, then --apply).
Added
- Accession lookups (E7).
/variants/{ref},/variants/{ref}/fasta(new),/proteins/{ref}/fasta,/proteins/{ref}/structureand/microorganisms/{ref}accept a PlasticDB accession as well as the numeric id or tax-id. Accession lookups ignore case. A variant accession on the protein routes answers 301 to/variants/{accession}. - PlasticDB v1 ids (A7). The protein detail API has a
v1_idfield, set only when the id is verified against the v1 FASTA by accession or identical sequence./proteins/<v1 id>(e.g./proteins/00025) redirects to the enzyme v1 gave that number to.scripts/backfill_v1_legacy_ids.py(dry run by default;--apply,--clear-only) recomputeslegacy_id. - Release stamp on downloads (E6). Every bulk download names its data release in the file name (
plasticdb_proteins_2026.39.12.csv,plasticdb_dataset_2026.39.12.zip) and in anX-PlasticDB-Data-Versionheader. - API documentation (E8). Python (requests) and R (httr2) examples on
/documentation/apiand/profile/api-keys. The docs gain sections on error format, sorting, identifiers and unknown parameters. Tests check every documented example against the OpenAPI schema, and run the anonymous ones against a seeded PostgreSQL. - Search settings stated (B11). The tool pages and documentation state DIAMOND 2.0.6 at default sensitivity, up to 25 matches per query, no coverage cutoff, the TSV columns, and HMMER 3.4.
Changed
- Unknown filter values are a 422 instead of a silent fallback (E4). This covers
evidence_method,substrate_form,evidence_scope(on detail,/statsand/plastics:organism_or_enzymeorwhole_organism), the plastic-detail sort, order and polarity, andNaN/infkinetic thresholds. - One error format (E4). Every 422 from a read endpoint is
{"detail": [{type, loc, msg}]}and names the parameter. 429 bodies use{"detail": …}. - Sorting (E4). NULLs sort last in both directions, and text sorts ignore case.
- 404 instead of 200 (E4).
/snapshots/latestis a 404 until a snapshot exists, and/pathways/by-plastic/{id}is a 404 for an unknown plastic. - Anonymous routes in OpenAPI (E8). Routes with optional auth, such as
POST /tree/build, are marked as allowing anonymous calls. - E-value defaults agree (B11). The tool pages default to the API's E-value, 1e-5, and every DIAMOND tool offers 1e-3 to 1e-50.
- "Legacy ID" means v1 only (A7). The label appears only for verified v1 enzymes, as "Legacy ID (PlasticDB v1)". Paper promotion no longer copies the V2 serial (
plasticdb_id) intolegacy_id; the serial itself is still issued and unchanged.
Removed
- The iTOL embed on
/interactive-tree(D2). The bare URL redirects to Build Tree, and?job=…still shows a built tree. The menu links, the iTOL line on /privacy anditol.embl.dein the CSPframe-srcare gone.
Fixed
- Wrong sequence type (B10). Nucleotide sequences in BLASTP mode or HMM Screen, and protein sequences in BLASTX mode, are refused with a message naming the records ("Choose BLASTX, or translate/call genes first (e.g. Prodigal)"). The page checks before uploading, the API returns 422, and the worker re-checks the whole file. Such jobs used to "complete" with no hits.
- LIKE wildcards (E4).
%and_in search and filter text acted as SQL wildcards (lineage=%matched every organism). - v1 ids (A7). Every protein showed its V2 serial as its "Legacy ID", so v1 citations landed on the wrong records. v1-format DIAMOND hits (
00025|…) resolved through that serial too, and could dedupe against the wrong protein. - Tool docs (B11). They no longer say "paste" or promise "summary statistics" on Annotate Genome.
- /download (E6). It no longer says both "live" and "weekly": downloads are live, and only new organisms and proteins wait for the weekly release.
- API docs (E8). The API docs and API-keys page had wrong filter semantics, stale counts, a placeholder host, contradictory rate limits and incorrect auth labels.
Version 1.28.3
A fourth plastic kind, formulation, for one polymer plus non-polymer additives. Migration 0020 adds the label to plastic_kind_enum. The deploy's vocabulary seed then relabels the rows, after the migration has committed.
Added
formulationas a value ofPlastic.kind, for one polymer plus non-polymer additives. Migration0020_plastic_kind_formulationrunsALTER TYPE plastic_kind_enum ADD VALUE IF NOT EXISTS 'formulation', inside the migration transaction as 0009 and 0017 do (allowed since PostgreSQL 12, because nothing uses the label in that transaction). The downgrade movesformulationrows back toblendand leaves the unused label on the type, because PostgreSQL cannot drop an enum value.- The plastic page labels a formulation sub-type "(formulation)", as it labels a blend "(blend)". The label warns that evidence reached through "Include sub-types" is not evidence on the pure polymer.
Changed
OXO-PEandpPVCareformulation, notblend(ruling 2026-10-01). They are PE plus a pro-oxidant and PVC plus a plasticiser, not blends of polymers. Both the seed terms andPLASTIC_KIND_CORRECTIONS(blend→formulation) are updated.P34HBiscopolymer. It is a spelling of P(3HB-co-4HB), and was unclassified.PlasticKindCorrection.from_kindacceptsNone, meaning "only while the row is still NULL". Like the other corrections, it never overwrites a kind a curator has set.- The vocabulary seed skips a kind the live
plastic_kind_enumdoes not have yet, instead of failing the whole seed. The row keeps its kind until the next seed after the migration. This coversscripts/seed.pyagainst an un-migrated database;init_schemaalways migrates first. scripts/check_migration_deploy_safety.py preflightreports theplastic_kind_enumlabels and the kind of the three relabelled rows.
Version 1.28.2
Two more compounds. No migration; they ship through the deploy's vocabulary seed.
Added
HydroxyapatiteandFlax fiberas filler compounds.
Version 1.28.1
Two vocabulary additions. No migration; they ship through the deploy's vocabulary seed.
Added
ENR(epoxidised natural rubber) as a plastic, a sub-type ofNRin the polydiene class.1,4-Phenylene diisocyanate(PPDI) as a compound.
Version 1.28.0
Signal peptides are predicted locally with DeepSig. The privacy and terms pages are final. Blend and PHA plastics are grouped under their parent polymers. No migration; the plastic hierarchy changes ship through the deploy's vocabulary seed.
Added
- DeepSig signal-peptide prediction in the worker image. It uses DeepSig 0.9 (GPL-3.0) in an isolated Python 3.11 with CPU-only TensorFlow 2.15.1. The model weights are baked in and checked by checksum, and the build runs a smoke test that IsPETase is cut at residue 27 and thioredoxin-1 has no signal peptide. Signal peptides are now predicted on PlasticDB's servers instead of by EBI Phobius, which remains a fallback only when DeepSig is missing. Each job makes one DeepSig run instead of one per sequence. Archaea jobs state that the Gram-positive model was used. The image grows by about 1.1 GB.
scripts/erase_user_account.pyerases an account on request. It is a dry run unless given--apply. The account is scrubbed in place: the name becomes "Former contributor" and the email a dead address, the credentials are cleared, and chats and jobs are deleted. Published records stay under CC BY 4.0, credited to "Former contributor". It refuses superusers.- Blend sub-types are labelled "(blend)" on the plastic page, and the plastics API returns
kindon each child plastic.
Changed
- /privacy and /terms are final. They state that the site is run by the PlasticDB team under New Zealand's Privacy Act 2020, with requests to contact.plasticdb@gmail.com, and that New Zealand law and courts govern the terms.
- The AI assistant section says the question, up to 20 earlier messages and PlasticDB's own query results are sent to DeepSeek, a third-party service, and links DeepSeek's terms and privacy policy.
- The server-log, signal-peptide, access/correction and deletion sections describe what the service actually does. Inactive accounts are not deleted.
- Single-base blends are sub-types of their base polymer: LDPE, LLDPE, PE, PBAT, PBS, PBSA, PCL, PHA, PHB, PLA, PS, PU, PVA and PVC Blend. ABS/SAN blend, Mater-Bi, Naturecycle and PA-EA stay top-level.
- PHB and the other legacy polyhydroxyalkanoates sit directly under PHA: PHV/P3HV, P3HP, P4HB, PHBV and its duplicate spellings, P(3HB-co-4HB)/P34HB, P(3HV-co-4HB), PHBH, PHBVH, PHO, PHN and PHPV.
include_subtypesreaches PHB-Blend through PHB. Headline counts still count direct evidence only. - The tools documentation describes signal peptides as DeepSig, not SignalP 5.0.
Fixed
- PHBV was classified as a blend. It is a copolymer, like its duplicate spellings. The fix comes from a new kind-correction step in the seed that changes a row only while it still has the known-wrong value, so a curator's edit is never overwritten.
- The Annotate Taxa Table docs claimed names are resolved against NCBI. They are matched against PlasticDB's own organism records.
- A failed DeepSig run no longer leaves scratch files in the worker's working directory.
Version 1.27.2
LLDPE and O-PE become sub-types of PE. No migration.
Changed
- LLDPE and O-PE are sub-types of PE (second maintainer ruling, 2026-09-30).
plastic=PE&include_subtypes=truenow also includes their evidence, and PE'schildrenlists them. As with LDPE and HDPE in 1.27.1, the change ships through the vocabulary seed at deploy and only fills an empty parent. Headline counts are unchanged. O-PE (oxidised polyethylene) and OXO-PE (the pro-oxidant formulation) stay two separate rows. The* Blendanchors (e.g.LLDPE Blend,PE Blend) are still undecided and keep no parent.
Version 1.27.1
Hotfix: dataset.zip no longer runs the API pod out of memory, and the text-cleanup script no longer leaves broken sentences. Also: LDPE and HDPE become sub-types of PE, and an E-value of 0 displays correctly. No migration.
Fixed
GET /api/v1/downloads/dataset.zipOOM-killed the API pods (exit 137 at the 512Mi limit), and one anonymous request was enough. The archive was built in memory, and the evidence-spine CSVs added in 1.27.0 made it too large. It is now streamed from the database in batches of 250 rows into a ZIP file on disk, and sent from that file with aContent-Length. Peak memory no longer grows with the data: on a test database with 30,000 observations the build added 29 MiB of RSS, against 280 MiB before (27 MiB against 251 MiB traced). The archive still containsREADME.md,LICENSE.txtand every entity and spine CSV, and staged and tombstoned records are still left out.scripts/clean_public_text_artefacts.pyleft sentences ending on a dangling word ("Read from.", "…annotated spectrum in."), about 132 of them in the production dry run. When a figure path was the object of a preposition, the preposition is now removed with it. If that leaves only a stub, the stub clause or sentence is dropped:Read from Fig. 1b (figures/…png).becomesRead from Fig. 1b., andRead from (figures/x.png).is removed entirely. The script is still dry-run unless given--apply.GET /api/v1/plasticsreturnedchildren: []for every plastic. It now lists each row's sub-types, and children on the plastic detail are sorted by name.- An E-value of exactly 0 showed as "–" in tool job results (
/jobs/{id}). It now shows "0", small values use scientific notation (e.g. 1.2e-45), and "–" means the value is missing.
Changed
- LDPE and HDPE are sub-types of PE (maintainer ruling 2026-09-30).
plastic=PE&include_subtypes=trueon organisms, proteins, papers, downloads and the tree now includes their evidence. The change ships through the vocabulary seed at deploy and only fills an empty parent. Headline counts are unchanged, because they count only direct evidence. dataset.zipis cached on the pod's disk. It is built at most once at a time per pod, behind a lock, and kept forDATASET_ZIP_CACHE_TTL_SECONDS(default 600). The cache key includes the release and each table's row count and max id, and the public-row counts for proteins and organisms. So new rows, a release, or a record being staged or tombstoned all trigger a rebuild straight away. Edits to existing rows show up within one TTL. Repeat requests are served from the file (X-PlasticDB-Cache: hit) without touching the entity tables. If there is less thanDATASET_ZIP_MIN_FREE_MB(default 256) of free disk, or another build is still running after 5 minutes, the endpoint returns503withRetry-After.DATASET_ZIP_CACHE_DIRdefaults to<tempdir>/plasticdb-dataset-zip.DATASET_ZIP_CACHE_TTL_SECONDS=0turns the cache off: each request builds its own temporary file, which is deleted after it is sent.- Inside the ZIP,
README.mdnow comes after the CSVs, because it lists their final row counts.
Version 1.27.0
Pre-release sweep fixes: tool correctness (B1–B3), exports and API (E1–E3, A4), licence (A1), HMM Screen access (B12), public-text markup (C10) and contrast (G1). No migration.
Added
- Data licence: CC BY 4.0 (A1). The licence and the citation are in the footer of every page. There are new
/license(with a/licenceredirect),/termsand/privacypages; facts the maintainer has not confirmed are marked DRAFT. OpenAPI now carriesinfo.licenseandtermsOfService. API responses carry aLink: rel="license"header, andGET /api/v1/releases/currentreturns the licence fields. - Submission consent (A1). Submit Data requires agreeing to publication under CC BY 4.0:
POST /api/v1/submissionsneeds"license_accepted": true, and the consent is recorded with the submission. - Evidence spine in downloads (E1).
dataset.zip,dataset.jsonand release snapshots includeinteractions,experimentsandobservationstables that join to organisms, proteins, plastics and papers. They also carryREADME.mdandLICENSE.txtand ametadatablock with the licence, citation and a table guide. New columns:n_papers,paper_ids,paper_dois,evidence_by_plastic,source_protein_accessionsandcredited_microorganism_id. - Evidence on tool hits (B1). Job hit results carry
evidence/evidence_polarity, and the job page has an Evidence column. Annotate Taxa Table addsEvidenceandPositive_plasticscolumns and a summary (B3). PlasticDB_registry.dmnd(every public record), used only for submission deduplication.include_subtypeson/microorganisms,/proteins, their exports and tree building. Apageparameter on public list endpoints.sort_by=ideverywhere, andtitle/journalfor papers. A Papers entity and a sub-types option in the custom export form.- Superuser-only
GET /api/v1/admin/releases. variant_degradation_linksonGET /api/v1/proteins/{accession}.- Scripts, both dry-run unless given
--apply:scripts/fix_scaffold_and_synthetic_proteins.py: checks the six records behind B2.scripts/clean_public_text_artefacts.py: removes figure paths and curator-only notes from public text, and reports truncated titles (C10).
- Automated WCAG AA contrast checks:
tests/test_theme_contrast.pyandtests/e2e/contrast.spec.js.
Changed
- Tool reference databases contain only records with a positive result of their own (B1). The wild-type database also excludes synthetic-construct sources. The rebuild fingerprint carries a policy version, so this release rebuilds the databases automatically.
plastic=is an exact match (E2). On microorganism and protein lists and exports it matches name, abbreviation or full name exactly, ignoring case, soPEno longer matches PET or LDPE.- HMM Screen is open to every signed-in user (B12). The
hmm_screen_publicsetting is removed. - Public
/releasesreturns only version, date, counts and DOI (A4). - A protein's own evidence is shown separately from its variants'.
degradation_linksandplasticson the protein detail cover only the protein's own evidence; its variants' evidence is listed separately. - Organism export rows credit evidence the way the organism page does, and their per-plastic columns line up slot by slot.
- Annotate Taxa Table labels genus-level matches
genus. Fuzzy matching stays within the query's genus and never matches placeholder names. - Theme colours are defined once as tokens in
plasticdb.css. Yellow fills carry dark text. - Google Analytics on dashboard pages loads in production only.
- The snapshot licence changes from MIT to CC BY 4.0.
Fixed
- An E. coli K-12 negative control reported unmodified scaffold proteins and negative or inconclusive records as 100 % "PET" hits (B1).
- Scaffold proteins whose only result belongs to an engineered variant (E. coli OmpA/EfeO/FliD/CysP) read as PET-positive on their protein pages.
- Annotate Taxa Table crossed genera (Sphingomonas → Sinomonas), matched placeholder names, and presented negative-only organisms as degraders (B3).
- Paging skipped and repeated records; every sort now ends with a unique tiebreaker. An unknown
sort_byorsort_orderreturns 422, andpageis honoured (E3). - Exports dropped the evidence, credited one paper per organism and misaligned the per-plastic lists (E1).
- The releases API exposed ML-bundle paths, withheld counts and fingerprints (A4).
<i>,<sub>and<sup>in titles, citations and notes render as formatting through an allow-list, and several pages that inserted citations unescaped now escape them (C10)./openapi.jsondropped the licence, contact and terms fields.- Colour contrast meets WCAG AA across the site, including the register, forgot-password and reset-password pages, which never loaded the theme (G1).
Version 1.26.1
Added
- Plastic
Poly(methyl acrylate)(curator ruling 2026-09-30), needed by10.1016/j.ijbiomac.2020.07.261. Vinyl homopolymer, keyed by name with no abbreviation on thePoly(ethyl acrylate)precedent, becausePMAis also poly(malic acid). - Compound
Birch tar(curator ruling 2026-09-30), the 1–10 wt% additive of the PLA/PEG films in10.1016/j.ijbiomac.2023.124226. No formula (a pyrolysis tar);is_additive=True.
Version 1.26.0
Embargoes, deposits and visibility holds are removed; all curated data is public after the weekly release. Maintainer decision, recorded in DECISIONS.md §8. Breaking: the deposits API is gone. This release carries a migration, 0019_remove_embargo, which changes data; the PreSync hook applies it.
Removed
- Deposits and embargoes. The
/api/v1/depositsAPI (create,/mine,/queue,/review/{reviewer_token}, get,/release,/notes,/withhold, patch), theDepositmodel and its member tables,deposit_idon proteins and organisms, the dailyrelease_due_depositsCelery task and beat entry, the/curation/depositspage, the curator-inbox "deposit" kind, the embargo / deposit card on Submit Data (and its JS), the deposit badges on the review pages, and the contributing guide's#embargosection (replaced by#release, "When your records become public"). - Operator visibility holds (the negative-results hold).
app/services/visibility_holds.py, the hold columns and their session guards (VisibilityHoldMixin, the before-flush visibility deflection and the two deletion guards,HeldRecordDeletionErrorand its 409 handler), the/api/v1/admin/protein-visibility/*and/api/v1/admin/microorganism-visibility/*routes, and the admin panel's Visibility tab. Hold-awareness is gone from orphan cleanup, supersede (SupersedeRefused), dataset releases (the held-and-public invariant), protein merge, protein dedup, the duplicates report and the annotation protected-field list. privateandembargoedas privacy states in the admin state filter, state badges and admin preview.privateremains only as the parking value of a tombstoned/merged row;embargoedstays in the database enum as an unused label.- The "Who can see held records" HMM disclosure on Submit Data.
POST /api/v1/submissionsno longer accepts adepositblock. The schema forbids unknown fields, so a client still sending one gets a 422.
Changed
- Migration
0019_remove_embargo(data): held, non-tombstoned proteins and organisms becomepublic; held tombstones have their hold cleared and stayprivate; any non-tombstonedprivateorembargoedrow becomespublic;deposit_idis cleared (staged deposit rows staystagedfor the next release). It then drops the hold columns,deposit_id, the deposit tables and thevisibility_hold_reason_enum/deposit_visibility_enumtypes.accession_registryrows minted for deposits are kept (accessions are never reused). The downgrade restores the schema, not the data. - Weekly release, hardened against a silent no-op.
releasable_models()is an explicit(Microorganism, Protein)instead of being derived from the removed hold mixin.releasable_filterisvisibility = 'staged' AND NOT is_tombstoned, and raises for a model it cannot gate instead of returning "publish nothing". The withheld report is noweligible/staged_but_tombstoned, andGET /api/v1/admin/releases/stagingdropsheld_and_public. - Protein merge: the survivor ranking is
staged < public, and a tombstoned member can never be the survivor (a newtombstonedrefusal covers an all-tombstoned group). Thedeposit_splitrefusal and deposit-link re-homing are gone. - The accession merge/tombstone routes moved from
deposits.pytoapp/api/v1/accessions.py. The URLs are unchanged:/api/v1/admin/accessions/mergeand/tombstone. scripts/label_matrix_api.pyenumerates proteins throughGET /api/v1/admin/proteins, 200 per page, because the protein-visibility list it used is gone.- The submission-envelope guard in
apply()stays. Its stated purpose is now what it still protects: acceptance, contributor credit and structure folds. Submission approval stays superuser-only, with the rationale reworded. - The HMM-screen rationale text (config, admin settings, tools page,
hmm_access) no longer cites held rows.hmm_screen_publicand its default are unchanged; opening the screen is a separate decision. - Ingestion withdrawal no longer refuses a paper for belonging to a deposit.
- CLAUDE.md's required pg-tier test is now
test_generate_snapshot_excludes_staged_and_tombstoned_records.
Version 1.25.0
Pre-release sweep fixes F1–F10 (accounts, submission, curation tooling). This release carries a migration, 0018_ingestion_assignment (adds paper_ingestions.assigned_to_id / assigned_at); the PreSync hook applies it.
Added
- Signed-in pages keep their API token fresh (F1). New page route
POST /session/tokenexchanges the session cookie for a short-lived bearer token (same-origin, header-gated,no-store). The API itself still refuses the cookie.static/js/auth_session.jsfetches a fresh token when it is missing or stale, retries a 401 once, and shows a "Sign in again" banner when the session itself has expired, instead of chat, dashboard and admin pages failing silently. - Curator Inbox (F5) at
/curation/inbox: every pending queue in one list, with whole-backlog counts, age, sort, filters and assign-to-me. Assignment is a durable to-do mark; only superusers can assign others. - Read-only curator reports (F5): duplicate proteins with the merge tool's verdict, unresolved organism names, and the curation activity (audit) log with old → new values.
- Synonyms through the admin API (F5). Protein and microorganism synonyms can be edited; edits are audited and searchable.
- Curator Guide (F6) at
/documentation/curator-guide(alsodocs/curator-guide.md): review policy and the R-16…R-22 extraction rules. Judgement calls the maintainer has not ratified are marked DRAFT. - Admin preview (F7) of staged, held, private and merged proteins and organisms at
/admin/preview/{proteins|microorganisms}/{id}, andGET /api/v1/admin/proteins, which lists every protein in every state, paged and searchable. - Ingestion review, one record at a time (F8). Cards collapse to a summary with Previous/Next; an opt-in source pane beside the form shows the paper text (with find) or the PDF.
- Flag queue links (F7). Flags on interactions, experiments, observations and variants link to their paper, or say the record no longer exists. A "Waiting" column shows age.
- Honest DOI lookup (F3). It now reports found / not found / unavailable / invalid, fills in the journal and title, and says when the paper is already in PlasticDB. There is a Paper title field.
- Submission checks (F3). Values are checked against physically plausible bounds (
app/schemas/submission_bounds.py, enforced by the API and mirrored on the page), and a citation is required. Drafts are saved in the browser with an unsaved-changes prompt. The review step shows every value entered. - Embargo and deposit guidance (F4). The visibility choices are explained, it is disclosed that accepted held sequences feed the HMM profile library, a "What happens after you submit" panel is added, and the contributing guide has an embargo section.
- Readable picker labels (F10) (
term_labels,plastics[].full_nameon/api/v1/vocabularies). /contributorsdirectory (F9) andGET /api/v1/curation/contributors: name, ORCID, suggestions written, suggestions accepted and data submissions made. No email is shown.- The login page explains itself (F9): why an area needs an account, and what signing in unlocks.
Changed
/curation/statscounts the real backlog; the paper review list shows age and assignee; the sidebar leads with the Curator Inbox;/curator-guideopens the Curator Guide.- Admin Visibility tab shows the real state (merged, held, staged, embargoed, private, public) instead of lumping them under "private".
- Ingestion review: upload, remove and re-run extraction are fenced in a "Manage source files" section, and Approve and Reject sit apart.
- Deposit visibility has no preselected default. The release date must be between tomorrow and 3 years out.
- The submit form hides fields irrelevant to the record type, and the Oxygen-requirement help text is corrected. DOIs are stored without the
https://doi.org/prefix. /forumtells non-members that it is for signed-in members only, instead of redirecting. Unverified users are no longer looped to/login.- Contributor profiles explain how each counter is earned. The unused "Evidence Claims" counter is removed.
Fixed
/curation/reviewnever loaded (F2). It read the wrong localStorage key. It now shows a real error on failure, and opening a data submission works.- A submission requesting an embargo with a release date failed with a 500.
- A non-existent DOI was reported as "Found."; the citation was sent as the paper title.
- Admin: Engineered tab names showed "undefined", creating a variant always failed, and edits saved nothing. The Proteins and Engineered tabs were capped at 500 rows and hid held and staged records.
- Contributor counters credited the curator who accepted a suggestion instead of its author, and counted reviews as data submissions. They are now derived from the source records, so no backfill is needed.
- The dashboard told signed-in users to "Login" while their jobs loaded.
- Open redirect through
/login?next=.
Version 1.24.1
Changed
/pathways/{id}no longer redirects old plastic-keyed links. 1.24.0 sent/pathways/16(PET's old address) to/plastics/16/pathwayswhenever no pathway 16 existed. Pathway and plastic ids share one number space, so that link would have silently started showing an unrelated pathway once a 16th pathway was curated. The id is now only ever a pathway id; anything else is a 404. Use/plastics/{id}/pathwaysfor a plastic's pathways.
Fixed
test_curation_form_roundtripgenerated payloads the schema rejects. Its free-text pool ignored column length limits (e.g.temperature_unit≤ 8 characters), so seed-dependent runs failed with a validation error. It only runs where Node is installed, which thetestsCI job is not, so it was red locally only.
Version 1.24.0
Pre-release sweep fixes D3–D8 (browse, search, statistics). Merged from #521–#526.
Added
- Filter papers by evidence.
/papersand/api/v1/papersnow filter byplastic(exact abbreviation or full name, wherePEnever matchesPET, with optionalinclude_subtypes),microorganism(tax ID, accession or name, including enzymes purified from it),protein(accession),evidence_method,resultandhas_quantitative. All of them apply to one reported result, so "PE + weight loss" means a weight-loss assay on PE. Filtered views are linkable, and unknown method or result values return 422. - Papers on the plastic page. Each plastic page shows how many papers report evidence on it, with links to the filtered paper list. The API exposes the count as
paper_count. - Assay-condition filters on Microorganisms. Method and substrate form (which the API already accepted) are now labelled selects, and there are new sole carbon source (yes / no / not stated) and oxygen (aerobic / anaerobic / microaerophilic / not reported) filters. All conditions must hold in the same experiment, on evidence credited under the chosen evidence scope.
- Protein design filters. Experimental structure (PDB) yes/no, sequence yes/no, EC-number prefix (
3.1.1matches whole levels), and a Tm range. Each entity is matched on its own values, and variants inherit the parent's EC number. The proteins CSV/JSON/FASTA exports accept the same filters. - Plastics can be sorted by evidence. The Microorganisms and Proteins count columns sort on click (largest first), and an Evidence filter hides or shows the plastics with no recorded degrader (
has_evidence, also on the plastics exports). /plastics/{id}/pathwayslists every degradation pathway for a plastic, with a selector and permalinks.- Text alternative for pathway diagrams. A collapsible, ordered list of steps (substrate → enzyme (EC) → product) sits under the diagram on the pathway and plastic pages, and the drawn diagram is labelled for screen readers.
- Strain on collapsed evidence rows. Species pages show the strain designation without expanding the row.
- Easier navigation on long detail pages. Evidence conditions tables carry a screen-reader caption (plastic, outcome, strain/paper). Organism, protein and paper pages get an "On this page" section nav.
Changed
- "Top isolation environments" counts degrading microorganisms. It used to count every isolation record, whatever the test result, so one 1,000-strain fungal screen with mostly negative results made plant material the top environment. It now counts distinct public organisms with at least one positive result, like the tiles and the plastics chart; soil (158) now leads. Each number matches the microorganism list filtered to that environment.
/api/v1/stats/chartsenvironments carrycodeand a humanlabel, plus a top-levelcurrent_year.labelpreviously held the raw code.- Genus pages mark organisms with no degradation evidence. Taxonomy stats gain
evidenced_microbe_totaland a per-memberevidence_count. Members with no evidence are labelled "No degradation evidence recorded" and listed after those that have evidence. - "Temperature class" is now "Growth temp. class" on the Microorganisms filter and the genus page, with a note that it comes from the recorded optimum growth temperature, not the assay temperature.
- The Proteins substrate-form filter lists every form in the vocabulary, not 5 of 9.
Fixed
- Pathway pages are addressed by pathway id.
/pathways/{id}now shows that pathway; it used to treat the number as a plastic id, so/pathways/1showed "No Pathway Found" with HTTP 200. Old plastic-keyed links 301 to the plastic's pathways, and unknown ids return a real 404. The breadcrumb and title name the plastic ("PET", not "Plastic #16"), and a plastic with no pathway shows a neutral message with no graph controls. - Evidence badges count inconclusive results. Organism, protein and variant detail pages now show the
?tally (e.g.PET 30+ 3? 1−), as /documentation/reading-results describes. The detail API had droppedinconclusive_count, so inconclusive + negative was labelled plain "negative", and the organism API disagreed with the plastic page ("bacterium" LDPE 3/0/0 vs 3+ 20?). - Paper pages say who each interaction is about: the organism (with strain), the protein with its accession, or the variant with its parent accession, instead of just "microorganism".
- Renamed organisms are findable by their familiar names. The Pseudideonella sakaiensis page says "Also known as / formerly Ideonella sakaiensis, Piscinibacter sakaiensis", including in its title and description; the API adds an authority-free
also_known_aslist. Annotate-list resolvesIdeonella sakaiensis,Piscinibacter sakaiensisand bareIdeonella/Piscinibacterto taxon 1547922 (labelledsynonym), and/taxonomy/genus/Ideonella(and its API twin) 302s to Pseudideonella instead of 404. - Statistics charts say what they count. Readable environment names, a subtitle naming the unit on each chart, axis titles, "Plastics with the most degrading microorganisms" instead of "Most-studied plastics", the current year dashed and labelled "(to date)", and "1 Pathway" in the singular.
- The isolation-environment chart no longer counts hidden organisms (private, staged, embargoed, held or tombstoned).
- Paper export ignored the plastic filter.
/downloads/papers.csv|jsonnow accept the same evidence filters as/papers. - Kinetic filters missed values written as
min⁻¹ors^-1, so FAST-PETase (43.42 min⁻¹) was absent frommin_kcat=0.5. Unit spellings are now normalised (kcat to s⁻¹); rows with an unrecognised unit are excluded. A wild-type protein no longer matches on its variants' kinetics. - Unknown
temp_class,sole_carbon_sourceandaerobicityvalues are a 422 (temp_class=thermophilicsilently returned every organism). - Strict CSP on /papers. The last inline handlers on the page were removed.
Version 1.23.14
Added
- Compound
Bisphenol A(curator ruling 2026-09-28): the PU-C chain extender of10.1002/jbm.a.34777, restored to itssubstrate_compositionafter decision D had moved it to notes as a one-off. A commodity monomer (bisphenol-A polycarbonate, epoxies) that will recur.
Version 1.23.13
Changed
PEAbackfilled as poly(ethylene adipate) (full_name,polymer_class; NULL-fill only). Held out of 1.23.12 until the materials mis-filed on it — the Bayer Tir 1874 poly(ester amide) of ingestions 149 and 289 and the poly(diethylene glycol adipate) of 79 — were moved by revisions 1033–1035, approved in replace mode. The only approved records still namingPEAare ingestion 207's (Kasuya et al. 1999), which are poly(ethylene adipate).
Version 1.23.12
Added
- **33 plastics terms, 1 legacy backfill and 2 compounds, all on one curator
ruling (2026-09-27).** Each unblocks an extraction that already exists and
fails vocabulary validation. Every row carries its reasoning and DOI(s) in
app/services/materials_vocab.py. - PHA copolymers —
P(3HB-co-6HH),P(3HB-co-3H4MV),P(3HB-co-3HP),P(3HB-co-3HV-co-4HB),P(3HB-co-mcl-3HA),PHACOSand the homopolymerPHH(poly(3-hydroxyheptanoate), the missing C7 ofPHHx/PHO/PHN/PHD), all parented toPHA.P(3HB-co-6HH)is notPHBH(3- vs 6-hydroxyhexanoate) and notPHB-b-PCL(random vs block);P(3HB-co-3H4MV)is notPHBV. Compounds6-Hydroxyhexanoateand3-Hydroxy-4-methylvaleratemake their ratios writable. - Polycarbonates —
PTMCandPPC, and a newpolycarbonateclass. - Other common plastics —
PLCL,P(BS-co-ES), `poly(trimethylene adipate),PMMA,Poly(ethyl acrylate),PVP,P(S-co-DVB),SAN,ABS`,PPS(classpolyarylene sulfide) andNBR(declared asSBRis). - PCL/PLA–PEG block and graft copolymers, one canonical name each —
PCL-b-PEG,PCL-b-PEG-b-PCL,(PCL)2-b-PEG-b-(PCL)2,PEG-b-(PCL)2,PCL-g-PEG,PLA-b-PEG-b-PLA,PCL-b-PLLA,PVP-b-PCL. All unparented, on thePHB-b-PCLrule. - Blends and commercial materials —
NR Blend(parentNR),PA-EA,ABS/SAN blend, and the trade-named mulch filmsNaturecycleandOrganix(composition proprietary and unstated, the Mater-Bi rule). - Anchors for covalent one-offs (rulings C and E).
PCL Copolymer(parentPCL) is the covalent counterpart ofPCL Blend: a graft, network, block or random copolymer of PCL, with the partner insubstrate_composition. Recurring copolymers keep rows of their own. It covers 12 papers. The class anchorsAliphatic polyester (other),Aromatic polyester (other)andPoly(ester amide)hold one-off polyesters, with their monomers in the composition.Poly(ester amide)also receives the poly(ester amide)s now mis-filed under legacyPEA(ingestions 149 and 289), by revision after this release deploys. - Bis-urea elastomers (ruling D) —
PC-BU,PC(e)-BUandPCL-BU(10.1016/j.actbio.2019.05.037; PCL-BU is also the PCL-U4U of10.1089/ten.TEA.2013.0517). Each is classed by its soft block. The(e)means ester, not ether. These are not polyurethanes: the paper says they contain no urethane group. They are not a subdivision ofPU. PBLG(ruling G), a synthetic polypeptide in a newpolypeptideclass (a protease substrate, not a nylon), and judgement-call rows the curator is asked to confirm:PTMC-b-PBLG,PPDO,Cellulose butyrate(noCBabbreviation, because that is carbon black) andPCL diol(parentPCL).- 45 compounds (rulings B-E). Fillers and plasticisers (
Montmorillonite,Tricalcium phosphate,Calcium carbonate,Carbon black,Multi-walled carbon nanotube,Boron nitride,Triacetin,Cyclic polyphenylene sulfide). Biopolymer partners withis_additiveNULL (Zein,Gelatin,Collagen,Agarose). Polyurethane/urea hard- and soft-segment monomers (HDI, IPDI, BDI, H12MDI, ethylenediamine, putrescine,Poly(tetramethylene oxide)). The partners of the PCL copolymers, and the monomers of the one-off polyesters and poly(ester amide)s, plus5-Hydroxyhexanoate(the delta-hexalactone unit of poly(CL-co-HL)). Names match the remapped PlasticDB-AI extractions exactly.
Changed
PTSmerged intoPBS(curator ruling H, 2026-09-27).PTSis poly(tetramethylene succinate), the Tokiwa lab's name for PBS (v1: "PTS (BIONOLLE-1020)", a PBS grade). NewMERGED_PLASTICS/merge_plastics, run by the PreSync seeder after the upsert: every foreign key onto the old row (discovered from the model metadata) is re-pointed at the survivor, duplicate monomer/blend-component links are folded, the old row is deleted, and a per-table census must balance or the transaction rolls back (PlasticMergeInvariantError). No interaction, experiment, observation, kinetics row or pathway is ever deleted; a (paper, actor) pair with evidence on both rows keeps both interactions and is counted in the log line. Idempotent, and a no-op on a fresh database. Measured in production before writing it (read-only): row 67 carries 33 interactions (4 organisms positive, 18 negative), all from10.1128/aem.64.12.5008-5011.1998via approved ingestion 202; nothing else references it, and no actor is on both rows. The PreSync log now reportsplastics merged.PBS Blendmerged intoPBS-Blend(curator ruling, 2026-09-27) by the same mechanism: one anchor spelt twice. The space-spelt row 53 was empty in production; row 54 carries the data, so nothing moves.- A merged-away name still resolves on read-back.
resolve_plasticand the composition lookup read a merged name as its survivor (merged_plastic_name), so re-promoting ingestion 202 lands onPBSinstead of minting aneeds_reviewPTSrow. The live vocabulary no longer servesPTS, so a producer validating against it is refused (new REJECTED_TERMS entry) rather than aliased. PTCbackfilled (full_name,polymer_class; NULL-fill only, the row carries no records): poly(tetramethylene carbonate), i.e. poly(butylene carbonate). v1 attaches it to Roseateles depolymerans 61A, whose description (Suyama, Hosoya & Tokiwa 1998,10.1111/j.1574-6968.1998.tb12956.x) names that polymer. Not renamed toPTMC, which is the C3 homologue.PHCbackfilled on the same evidence and with the same NULL-fill: poly(hexamethylene carbonate),polycarbonate(v1 "PHC (PCD-2000)"). Its only production records (ingestion 202) use it in exactly that sense. It is now the written form of a poly(hexamethylene carbonate) soft block, so no compound twin (PHMC) is added.- Comments that guessed at the legacy
PHC,PTSandPTCrows are corrected from the v1 data:PHCis poly(hexamethylene carbonate) ("PHC (PCD-2000)"),PTSis poly(tetramethylene succinate), i.e. PBS ("PTS (BIONOLLE-1020)"). None is a PHA or a polythioester.
Not in this release
PEAbackfill to poly(ethylene adipate): held until V2 records mis-filed underPEAare corrected.PEAmust never be used for poly(ethyl acrylate) (new REJECTED_TERMS entry).PCL-b-PBLGhas no row. It is filed onPCL Copolymer.BI-OPL: stays refused.
Version 1.23.11
Fixed
- A task still running after an hour is no longer run a second time.
task_acks_late=Trueacknowledges a task only when it finishes. Nothing setbroker_transport_options.visibility_timeout, so the Redis transport's default of 3600 s applied, whiletask_time_limitallows 4 h 15 min. Any task past the hour was redelivered while still running and executed again as soon as the first copy finished. Measured in production on 2026-09-23: release 2026.39.9 ran 3,672 s, and its message (taskd5ed60ea) came back as 2026.39.10 at 13:22:05, the same second 2026.39.9 finished. That run passed the hour too, and 2026.39.11 followed. One message was running as a self-perpetuating chain of forced releases, stopped by hand with a revoke. The same applied to analysis jobs on the users' queue: any job over an hour ran twice.BROKER_VISIBILITY_TIMEOUTis now 5 h, which exceeds the hard limit. Three tests pin it. Removing the setting fails one; reverting to 3600 fails two.
Version 1.23.10
Fixed
- **Pins plasticdb-ml v0.5.3: calibration assigns each family's own Pfam
against the complete catalytic panel.** Release 2026.39.9 was the first to
calibrate end to end in production, and the library it built was wrong. Five
of six families were NOT SEPARABLE, because notebook 04 assigned
own_pfamagainst Phase 1's 11-model panel, which has no PETase. The release did not publish it:RELEASE_PUBLISH_LIBRARY_ENABLEDwas off for exactly this check. v0.5.3 assigns against all 29 models and reproduces the recorded assignment, 391 of 391 seeds.
Version 1.23.9
Fixed
- **Pins plasticdb-ml v0.5.2: notebook 04 reads its calibration inputs from
the bundle.** Release 2026.39.8 was the first to run all four notebooks to
completion. It covered 751 live records, built 143 profile HMMs in notebook 03,
and executed notebook 04. Notebook 04 then declined to calibrate: `staged
inputs incomplete: (no directory staged)`. The bundle held every calibration
input (
optional_parts.calibration_inputs: true,missing_partsempty). PlasticDB-ML #19, which teaches notebook 04 to read them from there, had merged into its stacked base branch rather thanmain, so v0.5.0 and v0.5.1 shipped without it. v0.5.2 carries it.
Version 1.23.8
Fixed
- **Pins plasticdb-ml v0.5.1: notebook 03 no longer assumes exactly four
model heads.** Release 2026.39.6 cut its bundle from the live database,
rebuilt the identity matrix, ran notebooks 01 and 02, and executed notebook 03
with the HMM profile block built for the first time in production (143
profile HMMs). Only one cell failed:
KeyError: 'PBS'. The live corpus now has PBS as a fifth head, and the notebook's colour palette listed four. The frozen week-38 corpus had hidden this. v0.5.1 derives the palette from the heads the data produces and keeps the existing four colours.
Version 1.23.7
Fixed
- Notebook 03 can find the build tools in the release pod. Release
2026.39.4 got further than any release before it. It cut its bundle from the
live database, rebuilt the identity matrix over all 751 records (1,033 ids,
about 6 minutes on 4 workers), and executed notebooks 01 and 02. Notebook 03
then refused with `BlockUnavailable: feature block 'hmm' was requested and
could not be computed: missing tool(s): mafft=False hmmbuild=False
hmmsearch=False`. That was on a pod where all three are installed and pinned,
and where notebook 02 had just used them.
PlasticDB-ML's
rescore.probe_available()decides "am I in PlasticDBV2?" by importingapp.services.bio_tools. The notebook child runs in its scratch directory with noPYTHONPATH, soappwas never importable and the answer was always no. Two of this week's fixes each worked as written and contradicted each other. PlasticDB-ML v0.5.0 made the notebook probe for the tools instead of assuming they were absent, and made a wanted block that cannot be built stop the run. V2 already knew the child could not import its code (that is whystage_fetchcannot run in a notebook). The strict flag was right to refuse, because the premise it was handed was wrong._child_envnow puts the repository root on the child'sPYTHONPATH. Measured on the pod in that exact environment, with no credentials: the probe imports, and_tools()resolves and verifies mafft, hmmbuild, hmmsearch and diamond. This adds importable code only. No credential moves, andENV_WITHHELDstill refuses one.
Three new tests. One checks the path points at a tree holding app/services/bio_tools.py. One runs a real interpreter with exactly the child's environment, from outside the repository, and imports the module. One checks the child still carries no credential. Removing the line fails two of them.
Version 1.23.6
Fixed
- **The release's bundle export runs as a subprocess, because Celery forbids
the process pool it needs.** The first live release, 2026.39.3, cut its
bundle from the live database. That worked: 751 records, against the 597 in
the frozen week-38 bundle, so 154 proteins curated since then reached the
pipeline for the first time. The positional rebuild that should have covered
them then failed with `AssertionError: daemonic processes are not allowed to
have children
.export_bundlecalledexport_ml_bundle.main(argv)` in-process. Every Celery task runs in a prefork child, which is daemonic, and the rebuild (--identity-workers) is amultiprocessingpool.assert_bundle_covers_corpusdid its job and refused the run 18 seconds in, before any alignment, naming the 154 ids and the recorded reason. No test could have caught it. Every test called the exporter from pytest or the CLI, and neither is daemonic.export_bundlenow runspython -m scripts.export_ml_bundlethroughrun_exporter. A process started withexecis a new interpreter, not amultiprocessingchild, so its pool is allowed. The child inherits the worker's stdout and stderr, so the exporter's own messages still land in the release log. - The database URL no longer travels in argv. In a subprocess, argv is
readable from
/proc/<pid>/cmdline, and the URL carries the password. It now goes into the child's environment asSYNC_DATABASE_URL, which is the exporter's own default.
Three new tests. One pins the fresh interpreter, the environment-borne URL and the repository-root cwd. One runs the real -m scripts.export_ml_bundle --help as a subprocess. One asserts the URL never appears in argv. Reverting to the in-process call fails three tests.
Version 1.23.5
Carries #506 and #508 onto main. Both merged into their stacked base branches rather than main: gh pr edit --base failed silently on this repository (a Projects-classic GraphQL deprecation error) so neither retarget took, and v1.23.4 was tagged without them. No rework — the same commits.
Added
- The bundle carries the frozen Phase 3 inputs, so a release can calibrate.
A weekly release runs PlasticDB-ML's notebooks 01–04, and notebook 04 is the
only thing in either repository that produces
hmm_library.PUBLISHED_ARTEFACTS—PlasticDB.calibrated.hmmandpolymer_policy.json, the two files the deployed sequence search reads. It could not run in the release pod, because a calibration needs three inputs that are in none of the places a release already has: the negative-tier cache, a 35-modelsuperfamily.hmm, and Phase 1'snegatives.faa. Somebody had to copy them into the pod by hand and point$PLASTICDB_ML_CALIBRATION_INPUTSat them — the last manual step in a release that has no others, and one nobody can perform from outside the pod. So the release rebuilt the profile library every week while the deployed screen went on searching a library published by hand. Re-fetching at release time is not the alternative it looks like.calibrate.stage_fetchdoes `from scripts.export_seed_fasta import concat_hmms, ensure_pfam, andrelease_notebooks._child_env` builds the notebook environment from an allow-list with noPYTHONPATH, so the import raises in the child. It is also independently undesirable: *"a build that re-downloaded its negatives would silently recalibrate every threshold the day UniProt shipped a release."* They ship undercalibration-inputs/, laid out as the relative pathsplasticdb_ml.hmm.calibrate.CALIBRATION_INPUT_PARTSdeclares, so the subtree is a staged inputs directory: the ML side hands it tostage_inputsuntranslated and$PLASTICDB_ML_CALIBRATION_INPUTSsurvives as an override of the same shape. No new flag — the cache, the panel andcache_manifest.jsoncome from--phase3-dir(wherestage_fetchwrites them) andnegatives.faa/nr90_cluster.tsvfrom--phase1-dir, both of whichrelease_notebooks.cached_input_dirsalready passes, so the weekly--from-databaseexport picks them up with no release-stage change. The operator's one-off is a singlecalibrate … fetchinto$ML_BUNDLE_WORK_DIR/phase3;docs/dataset-releases.mdhas it. Measured, not estimated. The real cache (SWISSPROT_POOL20,000, the eightN2_ORGANISMS, a 6,069-record N3 pool) and the real 35-model panel, then a re-export of2026.38from the same sources: **24,523,428 bytes raw, 11,638,895 compressed, taking the bundle from 6,193,919 to 17,836,131 bytes** (corpus digest72b0e37f…either side, so nothing but the new part moved). Nearly three times the artefact, and the module docstring carries the per-file table rather than burying it. Neither half is a lever:cache/swissprot.faais 8.0 MB of the 11.6 andstage_negativesdraws N1 and half of N0's 200,000 shuffles from that pool, so a smaller one moves the tier digests and with themcalibration_fingerprint; andsuperfamily.hmmduplicates 29 of the 35 models already inpfam/, but shipping only the six absent accessions saves 0.7 MB of 11.6 and buys a second place where *the superfamily panel* is constructed, when the point of freezing it is that "same superfamily, different family" has to be a statement about the same set twice. Optional, on the line--phase3-dirand--phase2-dirare on: a bundle without them cannot calibrate and can still do everything notebooks 01–03 do. Requiring them would refuse every export that worked yesterday. The absence is a namedmissing_parts["calibration-inputs/"]entry with its consequence and the command that closes it, and a partial set is reported part by part — an operator who ranfetchinto the wrong work directory has seven files and no eighth, and "absent" would send them to repeat the whole UniProt pass.test_the_exporter_looks_for_every_part_a_calibration_requiresis the cross-repo ratchet: a required part added toCALIBRATION_INPUT_PARTSthat this exporter did not learn about would keep producing bundles marked complete and refuse in the pod. It skips against a pinnedplasticdb-mlthat predates that declaration —requirements.txtinstalls a tag, deliberately, and CI proved the point by failing here first. That is not a hole: a required part can only be added by a version that has the constant, so the skip fires only where there is nothing yet to be out of step with. Verified end to end: a bundle re-exported from2026.38's sources ran PlasticDB-ML's wholenegatives → fit → tiers → report → publishchain offline in 194 s and produced both published artefacts, reproducing five of the six recorded profiles'GA/TC/NCexactly.
Fixed
- **The weekly release would have failed in notebook 03, fifteen minutes in,
the first week it ran on a corpus that had moved.** 1.23.0 gave the release a
fresh corpus every week and left
identity.npzandfeatures.jsonwhere they were: cached step-2 artefacts keyed positionally by protein id. Notebook 03 selects its identity rows withrow_of[rec.id]— a raw dict lookup, not a join — so the first record the cached matrix does not cover is aKeyErrorafter notebooks 01 and 02 have already succeeded. Measured on the shippedbundle-2026.38:rescore.build_recordsresolves 597 records, the matrix holds 879 ids, 0 uncovered — because the matrix was built over that same step-2 run. Any protein curated after it was uncovered by construction. 1.23.0 recorded the shortfall in the manifest; recording is not preventing. The features half was quieter and worse: notebook 03 reads it withfeature_table.get(str(rec.id), {}), so an uncovered record reached the model as a row of zeros and nothing said so. That is the failure mode PlasticDB-ML's handover names — *"a join that degrades is worse than one that fails"* — and the one V2 #499 measured, where a patched-out key-space check wrote a complete JSON, exited 0, warned about nothing, and every number in it was zero. Tolerating the shortfall was therefore rejected: it would have made the loud half quiet to match the quiet half. Two changes, one closing the gap and one making it impossible to ship through:- **
--refresh-positional(defaultauto) rebuilds both artefacts over the corpus the bundle actually carries**, using the same functions the step-2 driver runs —rescore.identity_matrix_parallel,falsify.extend_identityover the Matrix-B variant rows, andfeatures.compose_features. Measured on the 2026.38 record set (597 records + 282 variant rows = 879 ids, 346,260 pairwise alignments): **250 s and 328 s** across two runs on twelve workers, of which the feature block is 12-13 s. It needshmmsearchand the cached Pfam panel and **no other binary** — no MAFFT, no MMseqs2, no DIAMOND, no network. The per-pair cost is flat (7,140 pairs: 26.37 s on one worker, 8.59 on four, 5.41 on twelve), so a single-core pod pays about 21 minutes and a four-core pod about 7. Against the notebooks stage's existing ~15-21 minutes that is between a quarter more and twice as long, and it is the number to plan a release on;ML_BUNDLE_IDENTITY_WORKERSpins the worker count, becauseos.cpu_count()reads the host's cores inside a container and not the cgroup's quota.autorather thanalwaysbecause the rebuild is exactly reproducible: re-run against the corpus the cached artefacts were built from,ids,pidandcovall come backarray_equalwith the shippedidentity.npzand all 879 feature rows match atmax |Δ| = 0.0. Skipping a rebuild that would change nothing therefore cannot change what a release measures. A rebuild that cannot run — nohmmsearch, an incomplete Pfam panel, nomatrix-b/— refuses rather than degrading (compose_featuresanswers a missinghmmsearchby marking every sequence in-family and zeroing every Pfam column) and the export still writes, with the reason in the manifest's newpositional_refreshblock and the shortfall still named inmissing_parts. - **
release_notebooks.assert_bundle_covers_corpusrefuses before the first cell.** Measured onbundle-2026.38: 0.36 s cold, 0.17 s warm, over the three members it reads out of the tarball — and it reports that bundle's 597 records against 879 matrix ids with 0 uncovered, reproducing the figure the defect was diagnosed from. Run straight after the bundle is resolved and before any notebook is executed. It measures rather than reads a manifest field, becausebundle-2026.38— the kind of bundleML_BUNDLE_PATHpins on purpose — was cut before the exporter computed one, and a gate that trusted the field would have to decide whether to refuse on a question it could not read. Here alone an unanswerable check is fatal, and the asymmetry is deliberate: an unreadableidentity.npzis not a bundle notebook 03 can run on either. The coverage it measured is recorded on the release row and inRUN.json, so the population the model ran on is named and counted rather than logged.
- **
POSITIONAL_COVERAGE_RECIPEnamed the wrong module. It saidpython -m plasticdb_ml.ml.features fetch/features, which writes a PET-only corpus and an identity matrix over proteins alone. The producer ofwork/step2-*isplasticdb_ml.ml.falsify, whose matrix carries the 282 Matrix-B variant rows as negated ids — 282 of the 879. An operator following the printed recipe would have rebuilt a narrower matrix thatverify_identity_idsthen refuses.
Known and named, not fixed
mature.faais still the cached run's, so a refreshed bundle's search FASTA names a subset of its own matrix's rows: a protein curated this week is in the model's rows and is not in the HMM screen's search set. It is left alone on purpose — nothing indexes it by row, andplasticdb_ml.ids.record_seq_idnow spells its namesprotein:<id>where the cached run wrotep<id>, which is the spelling notebook 02 joins its hits back on, so a rebuild would be an identifier change wearing a freshness fix's clothes. The manifest'spopulationsblock no longer calls it "the sequences identity.npz is indexed over" when that has stopped being true.t4.jsonandt5.jsonare still the cached run's results, including its statement about its own corpus — so a bundle whose corpus has grown carries a t4 corpus block counting the population t4 was run on. Notebook 03 displays them as results rather than joining them to records by id, so this is a stale number on a page and not a wrong join. Named inpositional_refreshfor the same reason everything else here is: an absence a manifest names is a gap, and an absence nothing records is a wrong number with a provenance line on it.
Version 1.23.4
Added
- Five plastics terms, each blocking a paper that is already extracted and
cannot be delivered. 11 interactions and 70 observations across 3 papers are
unblocked. All five came out of one overnight extraction batch, and in every
case the extractor had already checked the near neighbours and refused to
substitute — the reasoning is quoted in
reviewer_guidance.data_gapson each extraction. PPDL— poly(omega-pentadecalactone), the C15 macrolactone polyester.10.3390/polym17192676ranks it head to head against PLA, PLLA and PCL in one substrate-preference series, so folding it into any of them destroys the comparison that is the paper's result. The nearest-looking terms are different polymers:PPLis poly(beta-propiolactone) (C3),PMCLis poly(4-methylcaprolactone) (C7, branched),PESis poly(ethylene succinate), not a lactone at all.PE-2,18andPE-12,12— long-chain aliphatic polyesters, named in their own literature for the carbon counts of the diol and the diacid.10.3389/fmicb.2025.1671731assays one cutinase against both alongside PCL, so the three are a single comparison. ThePE-prefix is not polyethylene: this vocabulary already spendsPEon two senses (PE/PE Blendare polyethylene,PEA/PES/PET/PEFare poly(ethylene …) esters) and inPE-12,12it is neither — the 12 is the diol, soPEthere means polyester. The published names are kept because they are what both the literature and the extraction use; thefull_nameis what removes the ambiguity.PHB-b-PCLandPHB-b-PLA— synthesised diblock copolymers, not blends. The blocks are covalently joined in one chain, so no homopolymer was ever tested and filing either underPHB,PCLorPLAwould assert an experiment10.3390/polym17111558did not do. Its PHB depolymerase shaves the PHB block away while the PCL crystallite edges splinter — a result that exists only because the two are one molecule. Both unparented, on thePLGA/CAP/SBR/PVAcrule: a diblock of two families has no single parent, and hanging them offPHBwould pull them into every PHB query as though a PHB homopolymer had been assayed.
Not added
BI-OPLwas requested by a fourth paper (10.1371/journal.pone.0300503) and is refused, as it already was inREJECTED_TERMS: a commercial material earns a row only when its composition is proprietary and unstated. It is describable asPBAT-Blendplussubstrate_composition. That paper needs its extraction corrected, not a vocabulary row.PE-18,18appears in the same paper as the other two LCAPs but carries no interaction. This file adds a term when a paper proves the vocabulary could not express it, never in anticipation.PDLLAwas suggested by the extractor and stays refused on the existing ruling: racemic poly(DL-lactide) is the default sense ofPLA.
Version 1.23.3
Added
- **
scripts/experiments/reversal_censoring.py— how much of the 22.0% pair-reversal rate is a censoring artefact.**docs/phase5-censored-refit.mdmeasures the ceiling on any condition-free within-study ranking head: 4,417 of 20,088 informative pairs reverse between two cells of one paper, so no condition-free score can get both right and the ceiling is about 0.78. The obvious follow-up had never been asked — a published0usually means "below this laboratory's detection limit", so a reversal could be two enzymes swapping places purely because one fell under the floor in one cell. It is not. Measured against the step-11 pull: **3,167 of the 4,417 flips (71.7%) are two measured magnitudes disagreeing**, and 1,250 (28.3%) need a censored comparison to exist at all. Restricting to comparisons of two real numbers makes the rate worse — 3,167 of 8,462 informative pairs is 37.4% — because censored comparisons contribute far more consistent pairs than flipped ones. The 0.78 ceiling is not an artefact of censoring. Also worth naming, because the question invites the conflation: a reversal is a within-paper event, one laboratory's two assay cells, so between-laboratory detection-limit variation cannot explain one by construction. The two are separate defects. The module decomposes rather than re-derives: the pairs come fromcensored_model.t3_pairson a design built by the same functions from the same pull, and it reproduces 24,097 / 20,088 / 4,417 before splitting anything. The split is a restriction, so it can only remove flips and never create one — asserted at runtime, and pinned by a synthetic fixture built to make a flip out of censoring and nothing else. Recorded there and not fixed:t3_pairsreads a right-censored row ("at least the ceiling") as lower than any observed value, so 857 of 196,288 cell-level comparisons are scored against an inverted label. Correcting it would move a published figure, so it is the owner's call. And the Erickson powder-ceiling defect the pull still carries (83 observations, 20 design rows wrongly right-censored) moves the flip count by 2 — it matters for a magnitude fit, not for ranking.
Version 1.23.2
Fixed
- A reading off the top of the scale is a detection.
make_designbuilt gate 1's binary target asrow_detected = 1 if r.kind == "obs" else 0.row_kindis three-valued —0observed,1left-censored (*at most the detection floor*),2right-censored (at least the quantitation ceiling) — so this is the same two-way test applied to a three-way code thatt3_pairswas carrying one layer up (C3), and it fails in the same direction: **a right-censored row, the strongest evidence of activity an instrument can return, went into a detection model as evidence of no activity.** |row_kind| published | corrected | |---|---:|---:| |0observed — a value inside the calibrated window | 1 | 1 | |1left — at most the detection floor | 0 | 0 | |2right — at least the quantitation ceiling | 0 | 1 | The left branch was measured before it was left alone, because trading one wrong sign for another is the obvious way to get this wrong: 2,944 of the 3,013 left-censored rows are a literalvalue <= 0and the other 69 are a positive number below the paper's own declared detection limit, which that paper is declaring indistinguishable from zero. It also matches the probability the conditional arm actually scores,Phi((f − theta − floor) / sigma)= P(the true value is above the floor). Population on the published step-11 corpus: **95 of 6,253 rows, 1.52% of the target vector**, prevalence 0.5030 → 0.5182, all 95 from one paper of 79 (Erickson) across 49 of 902 cells. **Zero of the unit-clean variant's 510 rows**, which has no right-censored row at all — so not one unit-clean number moves, structurally. This was a re-measurement, not a sign flip.fit_censored_modelnever readsrow_detected, so one set of fits scores both rules and the two arms differ by the label rule alone. Hyperparameter selection was re-run under both rules (360 nested refits) because its inner cross-validation selects on a likelihood over these labels — the choice is identical in all ten outer folds — and so were all three permutation nulls (600 draws, 3,000 further fits). Every published-rule figure reproduces the published artefact to three decimals. | | published | corrected | |---|---:|---:| | detection AUROC, paper folds | 0.603 ± 0.139 | 0.609 ± 0.141 | | detection AUROC, 60% cluster folds | 0.610 ± 0.052 | 0.620 ± 0.065 | | conditional AUROC (severity known) | 0.750 ± 0.051 | 0.754 ± 0.056 | | detection AUROC vs its cluster-level null, paper folds | 0.598 vs 0.524 ± 0.052, p = 0.080 | 0.604 vs 0.524 ± 0.053, p = 0.080 | | conditional AUROC vs its null, 60% cluster folds | 0.756 vs 0.735 ± 0.012, p = 0.033 | 0.760 vs 0.736 ± 0.012, p = 0.020 | **Ranking improves everywhere because the 95 mislabelled rows are the ones the sequence head already scored highest.** On fold 0's held-out scores the mean score percentile is 0.418 for left-censored rows, 0.548 for observed and 0.754 for right-censored: the model was being marked wrong for ranking them at the top. Only fold 0 owns them, so only fold 0's AUROC moves (0.608 → 0.640, folds 1-4 identical in the fourth decimal).
Changed — one published verdict is withdrawn
- **"Significantly better calibrated than a condition-blind logistic" does not
survive the correction.**
docs/phase5-censored-refit.mdpublished that contrast at Δ = +0.020 nats, 95% CI [+0.003, +0.035], p = 0.0015. It is now +0.023, [−0.002, +0.041], p = 0.13. The point estimate moves the wrong way for the obvious story — the advantage grows and the interval widens, because each per-fold difference moves away from zero in whichever direction it already pointed, and a cluster bootstrap over 79 papers holding 3.5 effective studies cannot separate a larger mean from a larger spread. The exact sign-flip permutation over folds was p = 0.50 before and after, and it was in the published artefact all along. "Better than a constant" survives (p = 0.010, and p = 0.042 on ΔAUROC) and *"indistinguishable from the binary model"* survives.docs/corrections.md#c4is the full account. C3's two owed T3 p-values are paid by the same re-run — T3's everything else at 0.581 reads p = 0.025 against a cluster-level null and p = 0.040 against a sequence-level one, and the 60%-cluster-fold row's observed moves 0.627 → 0.651 at p = 0.0066. **Under the published label rule the re-run reproduces all three published nulls to four decimals**, and `T3, dominant screen` does not move on any of them — exactly as C3 said it could not. Documents corrected in place, each with a dated pointer to C4:docs/phase5-censored-refit.md(gate 1's two summary tables, the per-fold table, both paired-arm tables, the prospective split and all three null tables),docs/discrimination-programme-results.md(the conditional arm against its null, and the T3 p-values),docs/corrections.mdC1 (the 2026-09-07 refit's raw detection rates — C1's finding is unaffected, the film still detects far less often than the powder) and C2 (the conditional arm and its null — C2's finding is unaffected and slightly strengthened). Four new tests intests/test_censored_model.py, none torch-gated, so all four run in the CI image: the three-way rule as a unit, a right-censored row detected through the realbuild_rows→make_designpath, a left-censored row not detected through both routes intoleft, and the wholerow_detectedvector against the wholerow_kindvector — which catches the call site rather than the rule. Each verified to fail against a mutation of the thing it tests: the defect restored, a mirror-image inversion, the call site reverted to the inline expression, and "everything is a detection".
Version 1.23.1
Fixed
- An off-the-scale reading is not the smallest possible value.
scripts/experiments/censored_model.py'st3_pairsdecided which of two enzymes measured higher inside an assay cell, and it testedrow_kind != 0— "censored in either direction".row_kindis three-valued:0observed,1left-censored (at most the detection floor),2right-censored (*at least the quantitation ceiling*). Those are opposite facts, and the test treated both as lower than any observed value, **so the strongest result an instrument can return was ranked as the weakest**. Measured on the published step-11 corpus: **857 of 196,288 cell-level comparisons carried an inverted label** — every observed-versus-right-censored comparison in the corpus, no exceptions. The published pair-reversal count moves from 4,417 to 4,325 of 20,088 informative pairs, 22.0% to 21.5%, every one of the 92 removed reversals from outside the dominant screen (669 → 577; the screen's 3,748 does not move, because all 95 right-censored rows are Erickson's and the screen is Norton-Baker's). **The ceiling those numbers exist to state does not move at the precision it is quoted to** — 1 − 0.2199 = 0.780 against 1 − 0.2153 = 0.785, both "about 0.78" — so C2's "ship neither head" and every conclusion resting on the ceiling stand unchanged. The T3 accuracies are averages over the labels this fixes, so they were re-measured on one fit rather than adjusted by arithmetic (the fit does not readt3_pairs, so a single fit scores both rules and only the sign rule differs between the two columns). Experiment 2 moves 0.593 → 0.621 at the cell grain and 0.568 → 0.581 at the pair grain. **Experiment 1 does not move at all** — every count and both accuracies identical to every digit, because all 95 right-censored rows are Erickson's and the dominant screen is Norton-Baker's. The re-run reproduces the published fit: same per-fold hyperparameters, and experiment 2's old pair-grain accuracy to every digit,0.5677147930893753. One remark inphase5-censored-refit.mdis withdrawn: *"the two experiments agree more closely than they did (0.598 against 0.593 at the cell grain)"*. They do not — 0.598 against 0.621. The convergence was the defect.docs/corrections.md#c3is the full account, including what is still owed: the two T3 p-values, because each permuted draw is scored against the same corrected labels, so the null moves with the observed and 200 nested refits are needed to restate them. Two further consequences of the same!= 0test are fixed with it. **Left against right was discarded as unorderable** (1,231 comparisons) althoughleft ≤ floor ≤ ceiling ≤ rightorders it — now ordered, guarded on the declared window actually satisfyingfloor ≤ ceiling, because a cell that cannot say must still say nothing. And the regression test that was supposed to cover this,test_t3_calls_two_proteins_tied_at_the_floor_uninformative, asserted nothing: its fixture's design isrow_kind == [0, 0, 0, 1], one censored row, so the loop body never executed — and its condition was written as the same!= 0conflation. It now counts what it checked and runs on a fixture with 109 left-censored rows. Why the rest of the module was right and this was not:censored_logliktakes separate left and right masks, andmake_designdrops a right-censored row with no declared ceiling to be censored at.t3_pairswas the only place that collapsed the two, and it was invisible because a censored row'srow_yis the placeholder0.0— an inverted ordering and a correct one are the same array.
Known defects
make_designlabels a right-censored row "not detected" (row_detected = 1 if r.kind == "obs" else 0) — the same conflation in gate 1's binary target, 95 of 6,253 rows. Reported indocs/corrections.md#c3and not fixed: it moves gate 1's detection AUROC and every permutation null attached to it, which is a re-measurement rather than a sign correction, and the two should not be entangled in one change.
Version 1.23.0
Changed
- **The weekly release cuts its own ML corpus instead of re-reading a frozen
one.** The
notebooksstage resolved its bundle fromML_BUNDLE_PATH, and the production ConfigMap set that to a literal/app/bioinformatics/ml-bundles/bundle-2026.38.tar.gz. So every release after 2026.39.2 would have re-analysed the week-38 corpus: newly curated proteins reached the public dataset and the DIAMOND index and never reached the profiles or the model, and each run reportedcompleted. The failure signal was absent by construction. WithML_BUNDLE_PATHunset — now the normal case — the release exports a bundle from the live database at its own release label, intoML_BUNDLE_DIR(default<bioinformatics_dir>/ml-bundles), from the cached phase runs inML_BUNDLE_WORK_DIR. Set, the path still wins and is recorded and reported as pinned: reproducing a past release is a real need, and a release that re-analyses a month-old corpus should not read like one that did not. Idempotent by label, so the notebook stage stays resumable: a release that failed in notebook 03 re-executes it against the corpus notebook 01 read, not against one that moved in between.
Added
scripts/export_ml_bundle.py --from-database, the mode that stage runs, implementing the.. todo::that module has carried. It re-derivescorpus.json(plasticdb_ml.ml.falsify.fetch_corpus) andlabel_matrix_corpus.json(scripts.export_label_matrix.fetch_corpus) into a scratch directory that shadows--step2-dirfor those two entries. The assembly, the manifest and the checksums do not fork. SELECT-only on a read-only session; dry-run by default, and the dry run fetches, because one that did not could not report what would be written. It refreshes the two corpora and nothing else, which the manifest now states rather than leaving to be inferred: the seed families and the phase-1 partition are a Phase 1 re-run by hand, andidentity.npz/features.jsonstay cached. Notebook 03 indexes the identity matrix withrow_of[rec.id]— a dict lookup, not a join — so the exporter measures that coverage with notebook 03's ownbuild_recordsand records it underpositional_coverage, naming any shortfall inmissing_partswith the command that closes it. Measured onbundle-2026.38: 597 records, 879 ids, 0 uncovered. No row filter.PLAN.md's Risks note put the public-bundle filter in this mode; that was superseded on 2026-09-19 — reviewers may see the whole dataset — and the docstring now records the ruling instead of carrying the todo.
Version 1.22.1
Fixed
- The notebooks stage could never write its output in production. Every
executed notebook reaches its destination through
os.replace, andos.replacecannot cross a filesystem boundary./tmpis the container's overlay and<snapshots_dir>is an NFS mount, so the defaultTemporaryDirectorylocation sat on the wrong side of a mount point and every capture raisedOSError: [Errno 18] Invalid cross-device link. Release 2026.39 found it:promoteandreferenceboth succeeded — 37 proteins and 9 microorganisms promoted, DIAMOND rebuilt for 559 proteins and 628 variants — notebook 01 executed, and the result was then thrown away. The scratch directory now sits beside the destination, so both ends of the rename are on one filesystem.shutil.movewas rejected deliberately: it would hide the symptom by falling back to copy-then-delete, and a copy is not atomic, so a worker killed mid-write would leave a truncated notebook where a release record should be. The suite could not have caught this —tmp_pathputs both ends in the same directory, so the rename never crossed anything. The regression test asserts the property that made production differ, not the behaviour the fixture already guarantees.
Version 1.22.0
The identifier release. A PlasticDB accession is now what names a protein in every artefact this repository exports — the DIAMOND reference FASTA, the seed/HMM stack, the structure manifests, the ML bundle — in place of the four competing formats an audit found in use and the internal integer id they all degraded to. The pipeline core that consumes those artefacts moved out to PlasticDB-ML, and the weekly release gained a stage that executes its notebooks, because the notebooks are producers now rather than a report about the pipeline.
Minor rather than patch, on the precedent 1.19.0 set when it explained itself. Three things in that entry's sense: scripts/backfill_accessions.py gains a required argument and refuses an invocation that worked before; seed ids change value everywhere, so library_fingerprint and corpus_fingerprint move and every published profile library is stale; and the release gains a stage that can stop a release. The additive half — 1.21.7's read-schema fields, the exporter's new parts — would have been a patch on its own, and that is the line this repository draws.
This release carries a migration. 0017_release_notebooks, the first since 0016_experiment_window, which production is still stamped at. The deploy record is docs/release-1.22.0.md, and the tag carries this section and 1.21.7 below it — that section was a VERSION bump that no tag ever shipped, exactly as 1.21.2 rode out on v1.21.3.
Changed
- A seed is named by its PlasticDB accession (
protein:PB24UZ, orprotein:PlasticDB_<id>for a row with no accession minted yet). This was the last identifier in the seed/HMM stack still keyed by the internal integer id, and it is the join key forseeds.faa,seeds.tsv,families.json,no_profile.tsv, the tail arm's subjects, the provenance sidecar — and the structural arm. library_fingerprintandcorpus_fingerprintmove with it, deliberately. They digest the seed id as the library is keyed by it, so **every profile library published before this is stale and will rebuild.** That is the point: every artefact in such a library spells its seeds the old way. A re-minted or backfilled accession also moves the digest now, which is correct for the same reason.- The DIAMOND reference FASTA reads
protein:PB6JCU, and three things had to move with it.reference_ids.parse_subject_id'sisdigit()gate was silently routing an accession to the legacy branch, where it was looked up againstplasticdb_idand matched nothing — issue #330's failure mode a second time, and widened deliberately.resolve_subjectnow takes accession / pk / fallback and follows a merged accession to its survivor, which a bare primary key cannot do: a merged-away pk is simply gone, while a merged accession still resolves and a tombstone answers 410. And the accession is inreference_build.current_fingerprint, so a re-mint triggers a rebuild rather than serving a stale database. - One definition of the identifier policy, not two.
app/services/export.pydelegatesprotein_seq_id/variant_seq_idtoplasticdb_ml.idsinstead of holding a second copy, andreference_ids.parse_subject_idroutes through the same module. Newaccession.is_accessionis checked against[A-Z0-9]rather than the narrower mintingALPHABET, because it is a classifier and its two errors are not symmetric. - The pipeline core is PlasticDB-ML's now — about 16,000 lines of labels,
sequence frames, the HMM stack and its drivers, features and the rescorer, with
fifteen re-export shims here so every existing import keeps working. The
shims bind the module object (
sys.modules[__name__] = _moved) rather than star-importing: four moved modules declare__all__and six callers here import names outside it, six of them private, so a star import would have dropped them silently. The eight driver shims keep a__main__dispatch ahead of the alias, because underpython -mthe module's own name is__main__. What stayed is everyfetch_corpusand all database access.requirements.txtpinsplasticdb-mlat v0.4.0, which is the first tag to ship the notebooks as package data. - A backfill write must name its entity.
scripts/backfill_accessions.pyread entirely as being aboutProtein.accession— *"run this before applying the NOT NULL constraint"* — whileTARGETSsilently also mutatedMicroorganism, whose accession isnullable=Trueby design. Measured on production on 2026-09-20: a bare--applywould have written **197 rows nobody asked for**, caught by a human reading the dry run, which is the wrong place for that check to live.--applynow requires at least one--entity, enforced inmain()before a session exists, so a refused run cannot reach a database at all. A dry run still covers every entity and keeps its exit code, so anything gating on it is untouched.Microorganismwas not dropped from the targets — backfilling it is sometimes exactly what is wanted, and was here.
Added
- The weekly release executes PlasticDB-ML's notebooks. They are not a
report about the pipeline any more — notebook 02 builds the profile library
and notebook 03 calls the gates — so running them is how the week's artefacts
come into existence, and the executed notebook is the release's record of how.
A new
notebooksstage sits betweenreferenceandsnapshot: after the DIAMOND rebuild because it is the long, externally-dependent one and nothing user-visible should wait behind it; before the snapshot because the executed notebooks ship inside the tarball, undernotebooks/with aRUN.jsonnaming the bundle, the pinned tool versions and theplasticdb-mlrevision. Resumable within the stage as well as across it — a notebook with a clean executed copy for this version is not run twice. - A dedicated Celery queue for the release (
RELEASE_QUEUE, with its ownrelease-worker).app/workers/hmm_build.pyrefuses to auto-run this chain because it "must not start by surprise on a worker that is also running users' jobs"; that objection is answered by separating the workers, not by dropping the inversion. Its comment is extended rather than deleted, recording what changed and which of its three conditions each mechanism meets. - A toolchain gate that refuses a version that drifted.
bio_tools.assert_tool_usabletolerates a mismatch on purpose — a developer's host is not the image — but a release cannot: two toolchains produce two different profiles. Every pinned binary the build chain needs is probed before the first cell, and notebook 02'sBUILD PATH: FALLBACKpath is now unreachable from a release. requirements-notebooks.txt, installed in the worker stage only, askingplasticdb-mlfor its[notebooks]extra by name at the same pinned tag. The rule it does not break is that a git-URL install must not quietly acquire a second HMMER; the release path stayshmmbuildagainst the pinned binary, and the gate above is what makes that structural.scripts/stage_structures.py— the missing producer for the directory of structure filesscripts/structural_tier.pyandscripts/experiments/structural_features.pyread. It stages the object store on disk named by accession and records theplasticdb_id → accessioncrosswalk inmanifest.json. Object store keys are not renamed;Protein.structure_filepoints at them, and renaming them is a data migration. Previously that directory was staged by hand asprotein_<internal id>.pdb— a convention nothing in the repository produced.- Key-space guards on the structural tier's four inputs
(
plasticdb_ml.ml.joins.reconcile_key_space). Three of the routesstage_yieldcompares are a set intersection against one universe of seed ids, and an intersection cannot tell "outside the restricted universe" from "spelled in another key space" — the latter produced a complete run reporting zero for every arm, which is also a publishable Phase 5 finding. scripts/label_matrix_api.pyandfetch --source api— a second acquisition path for the label-matrix corpus, over the REST API instead of PostgreSQL, becausefetchis aSELECTand production is not reachable from a developer machine. The SQL path is the default and is unmodified; it is the one to use wherever a database is reachable, because it reads the columns and owes nobody an inference. Everything the API path had to substitute is recorded in the corpus underprovenance, and both self-checks run before anything is written: the derivation re-scored with ground truth withheld (1,417 checkable, 1,417 agreed, a disagreement aborts the run) and a possibility constraint that fired 9 times. What survives is stated rather than smoothed over — 333 of 1,898 protein-actor rows can only be placed inside a group sharing one polymer and one verdict, and **195 of 1,317 variant-actor rows** are dropped and counted rather than guessed. 1.21.7's actor ids are what make that residue removable; this release does not change the consumer.- Four recorded gaps closed in the ML bundle (
scripts/export_ml_bundle.py). The Pfam panel shipped 16 models against 36 declared, which made it systematically permissive in a direction invisible from the output — a hit a missing model would have contested was reported uncontested, and the worst absentee was PF12740 (PETase), in a plastics panel. All 20 fetch from InterPro today;--pfam-dirpoints the panel at its own directory and the manifest always carries a census, with an incomplete panel naming its absentees and the direction of the error. The 35 profiles were established to befalsify.ProfileCache's per-head T4 arms rather than family profiles — 35 of 35 stems recompute from the shipped FASTAs — andprofiles/index.jsonnow records the verified stem rule.MANIFEST.json.populationsgives each artefact its population and what its count came from; an uncountable artefact recordsuncountedand never fails the export. Phase 2's and Phase 3's artefacts exist on no machine here and were not invented — they are named inmissing_partswith why, consequence and how to produce them.BUNDLE_FORMAT_VERSIONstays 1: every change is additive.
Fixed
- Three places where an accession we already held was dropped. All three
confirmed real by stashing the fix and watching the new tests fail.
bioinformatics.pyhard-coded a variant's accession toNone— the variant branch of_resolve_search_hit_entitiesnever selected the column, so the API reported that a variant had no accession when it had one. This one was user-visible.proteins.pybuilt its FASTA header and filename by hand, a character-for-character duplicate ofexport.protein_fasta_header().structural_tier.pydefinedseed_name()and never called it, so both published FASTAs carried the raw filename stem instead of the typed identifier — precisely what that helper exists to prevent. - The settings middleware was rewriting settings mid-test (#492), and the
issue's leading hypothesis was wrong: there is no second
Settingsinstance, andid()is identical at the fixture, the mutation and the route._refresh_settings_middlewarecallsmaybe_refresh_async()on every HTTP request; throttled to one reload per 30 s, it opens a connection to whatever PostgreSQL is listening on localhost and_apply_lockedthen rewrites every editable field on the process-wide singleton in place — the same object the fixture patched. **So the suite's results were silently depending on the contents of a developer's dev database.** A new autouse_no_live_settings_refreshintests/conftest.pypatches the name bound inapp.mainat import time; the regression test forces both throttles open, so it fails every time the guard is removed rather than half the time. No retry, sleep, marker, skip or reorder. Honest caveat: this is proved for the local failures and not for the CI instance, wherelocalhost:5432is unreachable and the fail-open path cannot mutate anything.
Note for operators
Three things in this release need a decision before they do anything, and one happens whether or not you decide. All four are in docs/release-1.22.0.md with what to expect.
- Every published profile library is stale. A consequence, not an action. The hourly DIAMOND reference rebuild fixes itself; the HMM profile library does not rebuild on its own and reports its staleness hourly until an operator or a notebooks-enabled release builds it.
- The notebooks stage ships disabled (
release_notebooks_enabled=False) and therelease-workerinplasticdb-gitopsships disabled too. They are turned on together; a worker with nothing routed to it is just a pod, and the stage without a worker is a queue nobody drains. - **
ml_bundle_pathis empty andexport_ml_bundle.pyhas no--from-databasemode.** A release runs against a bundle an operator produced and pointed it at. Enabling the stage without one fails the release atnotebooks. - **A release promotes
visibility='staged'topublicand cannot be undone from the outside. Measured on production 2026-09-21: 37 proteins and 9 microorganisms**, plus 1 variant hanging off a staged parent.
Version 1.21.7
Added
- The actor ids on
FootprintInteractionRead—protein_id,protein_variant_id,strain_occurrence_idand the derivedmicroorganism_id, served byGET /papers/{id}/recordsandGET /papers/{id}/footprint. The schema publishedactor_typeand no actor id, so it could say *"this paper produced a negative protein-actor interaction on PET under these conditions"* and not which protein — whileinteractions.protein_idis the column the whole evidence spine is keyed on (docs/label-matrix.md: a label lives on a(protein, polymer)pair). This is exposure of columns that were already there, not new data. is_tombstonedonFootprintProteinReadand on the/admin/protein-visibility/proteinsenumeration. It is half ofplasticdb_ml.labels.drop_unsequenced()'s predicate and was obtainable only by resolving every accession one at a time and reading 301/410/200 back off the redirect — one HTTP request per protein for a boolean already loaded in the row.- What it is for.
scripts/label_matrix_api.py(PR #490) rebuilds Matrix A's corpus over HTTP because production is not reachable from a developer machine, and had to infer both. The inference is scored on every run (1,417 checkable, 1,417 agreed) but **333 of 1,898 protein-actor rows can only be placed inside a group sharing one polymer and one verdict** — a 97-enzyme panel written up in one sentence — and 195 of 1,317 variant-actor rows are dropped outright. With the ids published, that path becomes a transcription. This release does not change that consumer; it only makes the change possible.
Visibility — deliberately unchanged
- No caller sees anything it could not see before. An id is not exempt from
the "something published what it should not have" class
(
app/services/dataset_releases.pyrecords seven):protein_idon an interaction whose protein is withheld would confirm the record exists and pair it with a paper, a plastic and a result — which for the negative-results embargo is the withheld asset itself. It cannot happen here, because the interaction carrying the id is dropped first:public_actor_interaction_filteralready excludes any interaction whose protein, whose variant's parent, or whose strain occurrence's microorganism is non-public or tombstoned. The curator/recordsroute is ungated by design and already published those actors by name and accession. - No second gate was written. The ids are reported and never read as a
permission;
public_actor_interaction_filterstays the only clause deciding what a public caller sees, because a second code path re-deriving a visibility rule is the shape every one of those seven leaks had. The reasoning is onFootprintInteractionReaditself, where the next reader will meet it. is_tombstonedis constantfalseon the public/footprintroute and that is not a defect:public_visibility_filterexcludes tombstoned rows, so a tombstoned protein is never in the public list to report itself. The admin enumeration is superuser-gated and already unfiltered by visibility.- Three tests in
tests/test_protein_visibility_holds.pypin it — a held protein's id never appears on the public footprint, the curator view still names all of them, and a tombstoned protein reaches neither the public protein list nor a public interaction. Two more intests/test_papers.pypin the three actor slots, the derivedmicroorganism_id, and that the other slots staynull.
No migration: every field is an existing column or a join over one.
Version 1.21.6
Added
- Three monomer compounds, closing the sentence 1.21.5 left open. That
release added
P(MDO-co-CEVE)andP(MDO-co-TEGVE)and noted in the same comment that "its three constituents are not incompoundseither". They are now:2-Methylene-1,3-dioxepane(MDO),2-Chloroethyl vinyl ether(CEVE) andTetra(ethylene glycol) methyl vinyl ether(TEGVE), into theNEW_COMPOUNDSliteral thatseed_materials_vocabulary()upserts idempotently.10.1021/acs.biomac.8b01500is the last paper in the review queue: its polymer names have resolved since 1.21.5, and all ten of itssubstrate_compositioncomponents — five experiments, two each — still failed validation on these three names. - Mapping them onto an existing compound was refused, on the same ground the
PCLsubstitution was. The MDO molar fraction is the paper's finding — degradation tracks F_MDO across both the C1–C3 and T1–T2 series — and composition is the whole reason those two copolymers are single rows rather than five. Nothing in the list is these monomers:Tetraethylene glycolis the diol, not the methyl/vinyl diether built from it, and the vocabulary held no cyclic ketene acetal and no vinyl ether of any kind. Droppingsubstrate_compositioninstead would delete the variable the paper is about. is_additive=Falseon all three: MDO ring-opens during radical polymerisation to insert the backbone ester the Candida antarctica lipase cleaves, and the two vinyl ethers contribute the carbon-carbon units. None is dispersed in the matrix.- **Every formula is verified against the paper's own stoichiometry rather than
asserted** —
C6H10O2,C4H7ClOandC11H22O5reproduce all five stated initial molar fractions (0.1/0.2/0.3 for CEVE, 0.2/0.25 for TEGVE) to four decimal places. That arithmetic also settles tetra(ethylene glycol) against tri- independently of the prose:C9H18O4would give 0.235 and 0.291 where the paper says 0.20 and 0.25. - One test in
tests/test_materials_vocab.pypins the three rows, theiris_additive, the verified formulas, the separateness of the `Tetraethylene glycoldiol, and that none of them is aplastics` row.
Version 1.21.5
Added
- Seven plastics terms, each one blocking a paper already extracted:
PBCn,poly(trans-hydroxynopol dicarboxylate),P(MDO-co-CEVE),P(MDO-co-TEGVE),PBATGA,poly(trans-1,4-isoprene)(abbreviationTPI) andPET Blend. 43 interactions across 8 papers are unblocked by them (24 + 12 + 2 + 1 + 2 + 2, in that order). They go into theNEW_PLASTICSliteral, whichseed_materials_vocabulary()upserts idempotently — the reviewed, git-tracked path — and not throughPOST /api/v1/admin/plastics, which records no audit of who created what. - Two of the seven are family rows, and that is the ruling worth reading.
PBCncovers the six BASF polyesters of10.1021/acs.est.6b06060and10.1021/acs.est.7b01330, andpoly(trans-hydroxynopol dicarboxylate)the four terpene polyesters of10.1002/cbic.202400456. Six and four rows were refused because the member names do not exist: the Zumstein papers define their series once ("PBCn with n = number of carbon atoms in diacid") and name no member, and the ChemBioChem codesHN_FDCA/HN_DMS/HN_DMA/HN_DMScname the dimethyl-ester reagent rather than the polymer. Mintingpoly(butylene suberate)for PBC8 would be our chemistry entered as the authors' — a confident term nobody can check against the paper. The member — the diacid carbon count, or theHN_code — travels per record inspecimen_description, themcl-PHAand ionic-phthalic trade. - Boundaries stated where a producer reads them rather than left to be
rediscovered:
PBCn's C6 member isPBAand stays there (andPBAis not re-parented onto the family row);PBATGAhangs offPBATon theApexaprecedent, against thePBAone;poly(trans-1,4-isoprene)is a sibling ofNR/IRand never a child, because collapsing the isomers puts one Lcp's cis result and its far slower trans result on one row. Five newREJECTED_TERMSentries carry the refusals, including whyTPIis the abbreviation and not the name — ISO 1629 has no elastomer code for gutta-percha, so the siblings' naming convention cannot be followed here. - Six tests in
tests/test_materials_vocab.pypin the rulings, andPBATjoins the legacy set intest_declared_parents_existsoPBATGA's parent is checked at declaration time.
Version 1.21.4
Documentation only. No runtime behaviour changes.
Added
- The 1.21.3 deploy record (
docs/release-1.21.3.md): what shipped, the[SAFE]preflight, the deploy timeline, and every post-deploy check with its result. The screening-gate probe is the same one the 1.21.0 record used, run before and after, so the two releases are comparable line for line — the gate refuses a signed-in non-superuser with 403 and an anonymous caller with 401 on both job-creation paths, exactly as it did on 1.21.0. - Recorded two things worth knowing next time. The PreSync hook is a no-op only in
the second sense of the word: it applies no DDL, but it does re-run the
reference-vocabulary upsert, which reports
0 added, 0 renamedonce the rows exist. And a 422 from a malformed gate probe is indistinguishable at a glance from a 422 from an admitted caller —POST /api/v1/jobsis multipart andhmm-screentakes query parameters, so a JSON body never reaches the gate at all. The superuser control row exists to catch exactly that.
Version 1.21.3
Housekeeping only. No runtime behaviour changes.
Fixed
- Lint is at zero. The fifteen repo-wide ruff errors that every agent has
had to reason around ("pre-existing, in files I did not touch") are cleared.
Thirteen were
I001import ordering, fixed withruff check --fixscoped to the offending files;ruff formatwas deliberately not run across the tree, because about ten files onmainare unformatted and formatting them would have buried the diff. Import order in a migration is not always cosmetic, so it was verified: all 16 migration modules import, and the migration suites are green against a real PostgreSQL 16 — which exercisesenv.pythrough an actualalembic upgrade head, the only way it can be exercised at all. - Two errors that were not import ordering, both checked before being
touched.
app/api/v1/compounds.pyhad a deadorderlocal — sorting goes throughpaginated_list, so the endpoint was never relying on it. The nestedifinapp/api/v1/ingestions.pyis merged, with the comment explaining the inner test moved above the merged statement rather than stranded inside it. - The local mock tier and CI now agree on the source tree. Twenty-seven
stale agent worktrees were checked out inside the working directory under
.claude/worktrees/, andtests/test_secret_redaction.pyandtests/test_deploy_contract.pywalk the tree and read every copy — so a clean branch failed locally and passed in CI, for 9.1 GB of reasons that had nothing to do with the code under test. All twenty-seven were clean, held by no process, and every branch preserved (25 removed withgit worktree remove; two whose root-owned__pycache__blocked deletion were moved out of the repository intact rather than half-deleted). Both tests now pass on the host.
Changed
docs/discrimination-programme-results.mdis current again. It described 2026-09-05, when "nothing in this programme wrote to the database"; production is now at 1.21.0 with the panel data ingested and the HMM screening tool gated. All six of its "open decisions" are settled and marked as such, the one outstanding write is named, and a known issues section records five things found and deliberately not acted on — the most consequential being that Hu & Howe's PET-nanoparticle assays are filedsubstrate_form = 'model_substrate', so the modelling is discarding genuine PET measurements as surrogate readings. A new where the evidence lives section records the archive path for the programme's working artefacts, which live under the git-ignoredwork/.
Version 1.21.2
Fixed
- **Erickson 2022's 89 powder experiments no longer declare a quantitation
ceiling they never had.**
scripts/backfill_detection_limitsapplied a blanket 500 mg/L ceiling to all 202 of that paper's pre-existing rows, but the paper declares the ceiling for the film assay only — Supplementary Table D6 says "quantitative product release values are reported for amorphous PET powder and crystalline PET powder even above 500 mg/L" — and **30 of the 89 carried a stored value above the ceiling declared on them, to 10,403.99 mg/L**. Left in place it would right-censor real measurements out of every fit that reads the window. The 89 now match the 1,599 powder rows the panel insert already stores without a ceiling. The 93 film rows keep theirs, and the 20 rows of unrecorded morphology are left alone rather than guessed at.
Added
scripts/experiments/provenance_and_conflicts.py— derive, census, plan, apply, restore, verify for the above, dry-run by default, with the rollback captured before the write and round-tripped on live rows. The apply and the restore share one body run withbefore/afterswapped, so the undo is literally the write backwards.docs/phase5-provenance-and-conflicts.md— the adjudication of the fourteen sequence-provenance reviews, the three self-contradicting interactions, and the panel ingest's three loose ends.
Changed
- **The fourteen sequence-provenance reviews are adjudicated and change no
label.** In all fourteen the stored chain is byte-identical to the NCBI record
for the accession the paper states, and the supplement's chain translates
exactly from the gene the paper synthesised — so the disagreement is the
paper's own accession against its own construct, not a corpus ingestion error.
Measured on an alignment rather than on k-mers the fourteen run **0.901–1.000
identical**, not the 0.33–0.86 previously recorded;
ESM136is not a discrepancy at all, making it thirteen. Every stored verdict already agrees with the workbook's own measurement, so none of them is a verdict question. No chain was rewritten and no row was split: both remedies need something outside the authorisation (asequence_provenancevocabulary term, or a curator's identity decision on seven embargoed rows). - **The three interactions "whose verdict contradicts their own observations"
are correct as stored, and were not flipped.**
PET46releases 1,624 µM aromatics from semi-crystalline PET powder;TrmAandTrmHrelease TPA from PET nanoparticles at rates their paper reports as significant. The recorded premise that "the pNP-ester surrogate set the verdict" is falsified by the paper's own within-panel control:AroCretains 124 % pNPB activity and is stored negative, while its nanoparticle rate is not significant. All three are genuine conflicting condition cells, whichapp/services/label_matrix.pysays to keep. - **Enzyme
713's 30 deferred panel rows are resolved to the 168-residue interaction** on the construct geometry — the assayed 129-mer isM+ 120 +LEHHHHHH, and that 120-mer is the 168-aa chain minus a 48-residue N-terminal leader, C-terminally flush, while inThh_Estit is an internal fragment with 141 residues left over. The insert itself is a separate authorisation.
Version 1.21.1
Added
- The record of the 1.21.0 release (
docs/phase5-gate-release.md): the measured before/after on both job-creation paths, the merge order, the CI results, the deploy timeline, and the rollback procedure. Two things are worth reading even if the rest is routine — the exposure was **measured on the live 1.19.0 deployment** rather than inferred from the diff (a non-superuser was admitted to both paths), and the polymer-summary observation in §3 of the verification, which is not a regression but should be understood beforehmm_screen_publicis opened to the public.
Version 1.21.0
Added
- The HMM profile library is built, calibrated and published.
/tools/hmm-screenand the whole Phase 4 cascade shipped in 1.19.0, but<bioinformatics_dir>/PlasticDB.hmmhad never existed in a deployment, so every screen failed at thehmmsearchcall with an honest message saying nothing had been searched (#341). Phases 1–3 were run against the live corpus in the production worker pod and the calibrated library installed on the shared volume by the same code path the hourly check uses. The build record — family count, profiles built, families that ship no profile and why, and the pinned calibration source — is indocs/phase5-profile-library.md.- The calibration negatives are the pinned 2026-08-30 UniProt pull, reused verbatim rather than re-fetched. An unpinned pull silently recalibrates every threshold the day UniProt ships a release.
- Nothing new is served.
PUBLISHED_ARTEFACTSis still exactly the calibrated library and the polymer policy; the per-protein provenance sidecar, the only build artefact that names accessions and papers, stays on the build volume.
- An admin-controlled release switch for HMM screening
(
hmm_screen_public, Admin → Settings → "HMM profile screening"). **Default closed:** only superusers may create anhmm_screenjob. Flipping it open admits every signed-in user. It is a row in the existingapp_settingsoverride table, so the change reaches every API worker and the Celery worker without a redeploy or a restart.- The switch exists because the profiles are derived from 291 records under an embargo that lifts when the owner's paper publishes. A profile is a statistical summary and names no record, but when it is safe to release them is his judgement, not a deployment constant.
- Enforced at the API, not in the template.
require_hmm_screen_accessguards both job-creation paths —POST /api/v1/jobswithjob_type=hmm_screenandPOST /api/v1/bioinformatics/hmm-screen— before a byte of the upload is written. The page guard and the hidden sidebar link are cosmetics on top: a hidden page in front of a live public endpoint is not a gate, which this project has now learned twice. - Anonymous callers are refused in both states. Opening the switch widens the tool to signed-in users, never to the world: an ownerless job is readable by anyone holding its id.
tests/test_hmm_screen_gate.pypins all four states against both endpoints, plus the page, the sidebar, and the fact that the published set carries no provenance.
Fixed
- The published polymer vocabulary read the wrong shape, which the first real
publish is what exposed.
published_polymer_names()assumedpolymer_policy.jsonwas a flat{polymer: record}mapping; the build writes an envelope with apolymerslist, so the reader returned the envelope's own keys — the tool page offeredunit,ruleandtiersas polymer options and the endpoint answered 422 toPET. The envelope is now read properly, the real artefact's shape is pinned in a test, and an unrecognised shape degrades to "vocabulary unknown" (falling back to the hardcoded list) rather than to a confident wrong answer.
Changed
publish()'s atomicity claims are now asserted rather than documented: the fingerprint is proved to be written last, and an incomplete build directory is proved to leave no fingerprint behind to be trusted.
Version 1.20.2
Added
docs/corrections.md— a citable register of falsified claims. Two entries, each stating what was believed, what is now known, the evidence that overturned it, and which conclusions elsewhere are affected. Documents indocs/now cite it by anchor (docs/corrections.md#c1) instead of repeating the correction, anddocs/discrimination-programme-results.mdlinks it from the top.- C1 — the accessibility axis had the sign backwards. The stringent negative rule counted a null on amorphous film as stronger evidence of a true negative, on the prior that amorphous chains are mobile so film must be the easy substrate. Film is the harder substrate. Matched-enzyme detection in two independent laboratories (0.171 vs 0.471 over 5,478 comparisons; 0.598 vs 0.804 over 826), a severity parameter fitted without ever being shown a condition column (film θ = +0.653, powder θ = −0.455), the confound isolated at matched particle size, and a refit on the enriched corpus (41,879 matched enzyme comparisons, 0.579 vs 0.755) all agree. Surface geometry dominates crystallinity in a whole-particle assay. Correcting the axis takes the stringent negative tier from 23 to 0, because all 23 qualified on the falsified clause and nothing else.
- **C2 — "AUROC 0.759 when the assay cell is named" is mostly assay ordering.** A permuted-sequence null reaches 0.735 against the real model's 0.756. C2 explicitly does not apply to step 4's 0.740, measured inside a single fixed assay cell where between-cell severity cannot be the explanation, and which already carries its own cluster-level null (p = 0.025).
Changed
- **Every document that rested on the falsified accessibility axis now says so,
in place.** Dated research records keep the original claim legible beside a
marked correction; documents that state a current rule or instruction are
corrected outright.
docs/phase0-pet-discrimination.md— a correction banner at the head, the falsified clause marked in the rule table, the tier census and the AUROC 0.864 / z = +3.49 stringent arm flagged as the output of a rule that no longer has a tier, and both wrong recommendations in "what would settle it" corrected: a second screen does not make the paper confound testable (it fails on cluster geometry), and the re-assay panel should not be run on film.docs/discrimination-fix-plan.md— step 6's gate-2 worked example ("amorphous film at 70 °C should come out easy") corrected; step 7's instruction to filePET46as *"a documented exception for one archaeal family"* withdrawn — it is the general case; the "91 % → 51 %, makingCpapconstructible" claim superseded by the measured 91 % → 54 % on an empty tier; and §4's bare-AUROC success criteria flagged, since a pooled conditional arm reaches 0.735 without reading a residue.docs/discrimination-diagnosis.md— H2's citation of the stringent arm and ofCpap, and the "more curation makesCpapconstructible" item.docs/phase2-panel-extraction.md— worklist item B1 is worth 13 under the old rule and 0 under the corrected one, not 15.docs/pet-negative-curation-worklist.md— the four remaining lines that still calledPET46a "documented exception", still graded amorphous film as accessible, or still prescribed a re-assay on film.docs/label-matrix.md— cross-linked to C1 and updated with the 41,879-comparison confirmation.
- **The conditional-arm correction is applied with its scope stated, so it does
not swallow a result it does not cover.**
docs/phase3-censored-model.md's "worth keeping: the conditional head at 0.759" is withdrawn and its equation of 0.759 with step 4's 0.740 corrected;docs/phase5-censored-refit.mdno longer describes the two as the same number reproduced by a third route;docs/phase2-benchmark-reproduction.mdgains a section stating why its 0.740 is structurally out of scope (one assay cell, one severity, its own null already run); anddocs/discrimination-programme-results.mdwithdraws "ship the condition-named head" in favour of shipping neither.
Fixed
docs/phase5-censored-refit.mdcarried a stale upstream to-do saying the accessibility axis "still has the sign backwards" and thatnegative_stringency.pyshould be re-derived. Both were already done on 2026-09-05. Its pointer to the fix plan's success criteria said §6; they are in §4.- **
docs/discrimination-programme-results.mdattributed the 0.740 benchmark to a 60 % identity holdout. It is measured at 70 %**; 60 % gives 0.694. Its within-study ranking figures (0.589 / 0.615, ceiling 0.79) are replaced by the refit's (0.572 / 0.568 against nulls, ceiling 0.78).
No code changed in this release. The accessibility axis was corrected in scripts/experiments/negative_stringency.py in 1.19.0 and is pinned by tests/test_negative_stringency.py; this release makes the written record agree with the shipped rule.
Version 1.20.1
Added
docs/phase5-censored-refit.md— the censored, condition-aware model refitted on the data step 5 wrote, and the three measurements step 6 recorded as not done: a cluster-level permutation null (600 refit draws), a paired test of one evaluation arm against another rather than two overlapping intervals, and a prospective split (train through 2024, test 2025 onward). Ship recommendation: do not ship either head, and the null is why — a model with its sequences shuffled reaches conditional AUROC 0.735 against the real model's 0.756, so almost all of step 6's headline number is the model ordering assays rather than enzymes.scripts/experiments/censored_model.py: four new capabilities.paired_armstests the difference between two evaluation arms on the identical out-of-fold rows (per-row Δ log-likelihood under a paper cluster bootstrap; Δ AUROC computed inside each fold and never pooled; an exact sign-flip permutation over folds).permutation_nullrefits the whole cross-validated evaluation under whole-cluster feature permutations.prospective_splittrains on papers through a cutoff year and tests on the rest.matrix_b_leak_checkproves Matrix B never entered the fit, by sequence content rather than by id, andrunraises rather than reports if it ever does. New subcommandsnullanddigest, andrun --only-doifits a single paper out of the database.
Changed
- **The model reads the measurable window from the database instead of from a
constant keyed by DOI.**
SQL_OBSERVATIONSnow selectsdetection_limit,detection_limit_unit,quantitation_ceiling,quantitation_ceiling_unitandbuffer;attach_windowsderives the floor regime per row. 278 cells now carry a declared floor where 4 did, 1,410 censored rows sit on a published threshold where none did, and the ceiling branch of the likelihood meets real data for the first time (95 right-censored rows, previously zero).bufferis part of the assay cell's protocol signature, which keeps 470 cells apart that would otherwise collapse.
Fixed
- **One identity matrix was being served to two variants with different protein
sets.** Indexing a 400-long cluster-label list with a 118-protein design's row
indices does not raise — it silently clusters the wrong proteins.
identity.npznow carries itsprotein_idsandrunrefuses a mismatch;clusterstakes--identity-outso each variant gets its own. - A right-censored row was being dropped for having no floor. Floor groups are built from left-censoring only, and a right-censored row is dropped if and only if its cell declares no ceiling.
- **A bootstrap p-value could be exactly zero for a difference that is
identically zero.** Both tails now use the
(1 + count) / (1 + draws)convention.
Version 1.20.0
Added
- **
scripts/ingest_erickson_panel.py— the panel write step 3 planned and deliberately shipped without.** Four verbs, and only two of them write: a SELECT-onlycensus(read-only session,transaction_read_onlyasserted, engine disposed in afinally, novisibilitycolumn and no accession named), an offlineplan, anapplythat inserts in one transaction and writes a rollback file naming every row it created, and arollbackthat deletes exactly those rows after re-checking each one still belongs to the paper. It never touchesinteractions,proteinsorprotein_variants, asserts the 291 embargo holds inside the write transaction either side, and refuses any target interaction that is not PET. - A
rollbackverb onscripts/backfill_detection_limits.py, sharing one body withapplyrun withbefore/afterswapped — a rollback that is not literally the apply run backwards is not a rollback. Both verbs now assert the 291 holds before and after. scripts/rollbacks/— the committed restore path for both writes: the id lists, the pre-write watermark, and a plain-SQL copy of each undo for a machine that cannot run the scripts.docs/phase5-panel-ingest.md— what was written, what re-derivation found that the plan did not, the verification, and four things found and left alone.
Changed
- Production data (two owner-approved writes). 2,329 experiments gained the
measurable window their paper declares — the four
0016_experiment_windowcolumns, empty since the migration landed. Erickson 2022's cell-level panel was inserted: 3,539 experiments and 3,704 observations, from 4,493 of the workbook's 4,680 rows, carrying per-cell temperature, buffer, pH, duration, substrate morphology and crystallinity, and the assay's 1 mg/L floor on every row. All 1,430 of that paper's zero observations now carry the threshold that produced them; none did before. No verdict moved, no interaction was created, and the effective study count for PET negatives is 3.9165 either side.
Fixed
- Three enzyme ids in Erickson's FASTA contain a space (
LCC,LCC ICCG,LCC WCCG), and the usual "identifier up to the first space" rule folds all three onto one key with the last record winning — handing wild-type LCC the engineered WCCG chain rather than merely losing a sequence. The reader takes the whole header and treats a genuine duplicate as an error. - A panel cell is keyed by its assay series. Table D6 reprints D3 cell for cell, but D5 and D7 are 168 h time courses whose 96 h sample is a different reaction from D3's 96 h endpoint — up to an order of magnitude apart. Without the series in the key, 74 distinct measurements merge away. D5 and D7 disagree on 63 cells and both values are kept, per the label contract.
Version 1.19.1
Added
docs/phase4-release.md— the record of the 1.19.0 release. What shipped and in what order, the amendment made to the curation worklist before #478 merged, CI per required check, the deploy timeline, and every post-deploy verification with its result:/healthat 1.19.0,alembic_versionat0016_experiment_window, all five newexperimentscolumns present, nullable and empty, 291 visibility holds unchanged, and row counts unchanged at 6,526 / 867 / 17,323 / 27,763. Also the rollback procedure and its two rules, and the one check that could not be completed — the signed-in journey, for which no local browser profile holds av2.plasticdb.orgsession and no autofill exists to click.
Version 1.19.0
The PET discrimination programme, landed. Nine steps asked whether PET-degrading and non-degrading enzymes are separable by sequence. The answer that had been recorded — that they are not, and that the difficulty is biological — turned out to rest in part on two measurement defects and on a substrate rule with its sign inverted. This release ships the corrected instrument, the documents, and the one schema change the corrected programme needs. It writes no data. Every curation and backfill the programme identified is staged as a dry run and still waiting on an owner decision.
Minor rather than patch: scripts/experiments/pet_discrimination.py gains a CLI flag (--allow-incomplete-blocks), a new exception in its public surface (BlockUnavailable), and a changed default — a feature block that cannot be computed now stops the run instead of degrading silently.
Added
0016_experiment_window— five nullable columns onexperiments:buffer,detection_limit,detection_limit_unit,quantitation_ceiling,quantitation_ceiling_unit. They make an experiment's measurable window explicit, so a reported zero can say what it was below instead of being read as an absence. Applied by the ArgoCD PreSync hook on deploy; the columns land empty, because the 4,680-row insert that would populate them is not approved. Upgrade and downgrade both proven on a populated table.- A condition-aware target. The same 212 proteins, the same features and the same models score 0.740 when the target names an assay condition and 0.534 when it is a cross-study protein-level bit. The target is worth 0.21 AUROC — more than any feature block in the programme.
scripts/check_migration_deploy_safety.py—heads,preflightandprove-unknown-revision. Stops a schema stamp from taking the whole deploy pipeline down: the PreSync hook runsalembic upgrade headagainst the migration directory baked into the image, so a database stamped ahead of the image fails the hook and aborts the sync of every unrelated change.- The programme's evidence, as documents, indexed by
docs/discrimination-programme-results.md: the diagnosis, the fix plan, and one file per step (docs/phase1-*throughdocs/phase4-*), plusdocs/phase4-premerge-resolution.md, which records hunk by hunk how this branch and #479 were merged without either side silently deleting the other's fix.
Fixed
- Two published nulls were measurement defects, not results. The profile-HMM block read below chance because training rows seeded the profile that then scored them, and because one out-of-fold vector was pooled across five models with different score scales. Corrected — leave-one-seed-out rescoring, per-fold AUROCs, and a cluster-level permutation null — the block reads 0.617 (fold sd 0.084, p = 0.32): uninformative and imprecisely measured, not anti-predictive. The old path is kept as a harness check and still reproduces the published figures exactly.
- A feature block that cannot be computed now stops the experiment. MAFFT's
portable tarball invoked without
MAFFT_BINARIESprintsv0.000, exits 0, and fails every alignment; the block returned seven zero columns and a"ran"status, which scores AUROC exactly 0.500 — indistinguishable from a real null, in the one experiment whose entire claim was a null. It now raisesBlockUnavailable.--allow-incomplete-blocksrestores the old behaviour deliberately and keeps the dropped block's columns out of the composite rather than diluting it. - The accessibility axis had the sign backwards. Amorphous film was treated as the accessible substrate. It is the harder one, in both independent panels (0.171 vs 0.471 detection over 5,478 matched comparisons; 0.598 vs 0.804 over 826) and in an assay-severity parameter fitted without ever seeing a condition column: surface geometry dominates crystallinity in a whole-particle assay. Correcting it **empties the stringent negative tier, 23 → 0** — all 23 members had qualified on the falsified clause alone. Every stringent-tier claim in the record needs restating before it is quoted again.
- Four vocabulary bugs in
negative_stringency.py: a detection method the database can never hold (uhplcis not inMETHOD_VOCAB), no temperature-unit normalisation, a NULLsubstrate_formclearing the polymer axis, and noyearsduration conversion. Each moves zero rows today; a rule member that can never match looks exactly like one that never fires, socheck_vocabulary()now fails loudly at start-up if the rule names a method the schema cannot store.
Changed
docs/pet-negative-curation-worklist.mdis corrected in place. Its central claim — that curating five already-held papers makes the same-paper controlCpapconstructible — is false. The 13-of-15Cpapfailure was reproduced exactly by rebuilding the fold geometry rather than fitting a model, and its cause is cluster geometry, not sample size: the donor paper's 96 negatives occupy two 25 %-identity clusters,GroupKFoldcannot split a cluster, and a draw fits only when it catches the lone outlier. Curating other papers grows the draw without adding clusters. Also corrected: B1 delivers 13 not 15 (two are conflicting cells) and zero under the corrected axis, and B2's negatives are not unextracted but mis-signed. The corrections are marked in place beside the original text; the conditions tables, the ~40-paper rejected-lead registry and the honest assessment stand.- The model recognises families, not activity. It ranks an improved variant above its parent 21 % of the time, and the failure is dose-dependent in substitution count (0.407 at one substitution, 0.045 at five or more), while prospective discrimination on the same corpus reaches 0.685. Both are true at once, and together they are the signature of homology recognition. T4 and T5 are wired and belong on every model card.
- Structural features are closed on measurement rather than on cost. They move discrimination by −0.068, upper bound +0.010; separately, none of the 19 candidate ORFs could be folded at all.
- The HMM screen became a feature and the whitelist stopped drifting (phase 7 integration): #318 closed, #341 in part, #459 fixed.
Not done, deliberately
None of the following was applied; each is staged, dry-run and diffed, and each is an owner decision:
- 13 verdict reclassifications forced by the source paper's own declared threshold — the only lever that moves the effective study count (3.50 → 3.92);
- the 4,680-row Erickson observation insert that would populate
0016's columns; - 14 sequence-provenance reviews, 8 under a current PET negative;
- 3 interactions whose
positiveverdict contradicts their own stored observations (TrmA, TrmH, PET46), where a surrogate assay set the verdict.
No database write, no visibility change, and no Figshare publication is part of this release. The 291 protein visibility holds are unchanged.
Version 1.18.13
Added
- **
scripts/check_migration_deploy_safety.py— the check that stops a stamp from taking the deploy pipeline down with it.** The deployment applies schema changes from an ArgoCD PreSync hook Job (plasticdb-migrate) whose command ispython -m app.db.init_schemaand nothing else, and that module runs `alembic upgrade head` against the migration directory baked into the image. Advancing production'salembic_versionto a revision the deployed image does not carry therefore makes the next hook fail — and a failed PreSync hook aborts the ArgoCD sync of every change in that release, not just the schema one. Proved against a throwaway PostgreSQL with the migration directory extracted from the deployed commit:alembic upgrade headraisesCommandError: Can't locate revision identified by …, and so does the obvious repair,alembic stamp <older>, because stamp resolves the current revision first. Onlyalembic stamp --purgeor a rawUPDATE alembic_versionwalks it back. Three verbs:heads(compare the repo's head with the head baked into the image the hook Job is configured with, read live from the cluster; exits non-zero when the repo is ahead),preflight(read-only census — revision, target columns, row counts, visibility holds, locks and idle-in-transaction backends; assertstransaction_read_only = onand disposes the engine in afinally), andprove-unknown-revision(reproduce the failure on a scratch database). Covered bytests/test_migration_deploy_safety.py. - **
docs/phase4-migration-landing.md— the landing decision for0016_experiment_window, and the sequence that makes it safe.** The migration was not applied: production runs 1.16.0 from commit7e34e4b, whose baked alembic head is0015_protein_provenance, so stamping0016now would break the next deploy of anything. Nobody needs to run alembic by hand in the first place — the PreSync hook already does, so the merge is the apply. The document records the evidence, the two reproduced merge conflicts on the way in (VERSION/CHANGELOG.mdon the second merge; 11 hunks inscripts/experiments/pet_discrimination.py'sevaluateandfold_profile_hmm_features, which #479 hardens and this branch rewrote), the rollback command, and the rule that an image rolled back past0016needs the database downgraded first. Production is unchanged:alembic_versionstill0015_protein_provenance, 0 of 5 columns onexperiments, 6,526 interactions / 867 proteins / 17,323 experiments / 27,763 observations, and **291 visibility holds before and after**.
Version 1.18.12
Changed
- **The thirteen Norton-Baker PET verdict corrections, applied — the first
database write of the discrimination programme.** Thirteen enzymes were stored
as PET negatives of
10.1021/acscatal.5c03460while that paper's own supplementary workbook records them releasing aromatic product above the threshold the paper itself declares ("activity was defined as an aromatic absorbance reading at 260 nm of 0.05 above the blank in at least 1" condition). All thirteen were re-derived from the published workbook rather than inherited from step 3's list, and all thirteen survived: each has one to three cells at A260 0.17–0.28, against a workbook floor of 0.17. Identity was resolved on the sequence and never on the name — 3 exact, 10 after trimming the initiator methionine and the C-terminal His tag. One transaction,interactions.resulton thirteen rows,negative→positive. The paper's PET negatives go 97 → 84, the corpus's 192 → 179, and the effective study count 3.4995 → 3.9165, matching the prediction to four decimal places. The 291 visibility holds were asserted inside the transaction either side and are unchanged;visibilitywas never written or filtered on. Seedocs/phase4-verdict-corrections.md.
Added
scripts/experiments/verdict_corrections.py— the apply, and the way back. Five subcommands:derive(pure, re-reads the workbook),census(read-only, assertstransaction_read_only = on),plan(pure, emits the rollback),apply/restore(dry runs unless--apply, one transaction, every target re-readFOR UPDATEand refused unless it is still the exact row the plan described) andverify. The rollback is committed alongside it —verdict_corrections_rollback.jsonandverdict_corrections_restore.sql— redacted so it restores exactly without naming an embargoed accession: the workbook is published, so asample_idbeside aprotein_idwould be the leak. Proven by a full round trip on the live rows, not by inspection. Covered bytests/test_verdict_corrections.py.
Version 1.18.11
Added
docs/discrimination-programme-results.md— one page for the nine steps. The diagnosis document argues why, the fix plan says how, and this records what happened when all nine ran: every gate's outcome, the five findings that change what we believe, the six staged decisions waiting on the owner, and the merge order. Three published conclusions did not survive: the profile-HMM block was never anti-predictive, the accessibility axis has the sign backwards (which empties the stringent negative tier), and the paper-confound control fails on cluster geometry rather than sample size. Nothing in the programme wrote to the database; every write is dry-run and diffed.
Version 1.18.10
Added
/tools/hmm-screen— the HMM screen finally has a way in (#318). A tool page like the other five, gated behind an account, posting a multipart FASTA toPOST /api/v1/jobswith a bearer token (the API rejects the session cookie on purpose, so a cookie-only fetch 403s on every job that has an owner). The polymer options are rendered server-side from the same vocabulary the endpoint validates against, so the form cannot offer an option the endpoint will reject. The landing page's "HMM Discovery" capability card — the one card #288 could not link, because there was no honest destination — now links to it, and both/documentation/toolsand/documentation/apidescribe the feature, where before neither contained the string "hmm".- **
app/services/hmm_library.py+scripts/publish_hmm_library.py+ an hourlycheck-hmm-librarybeat: the profile library is now publishable, and its absence is no longer silent (#341).**PlasticDB.hmmhas never existed in a deployment — only the DIAMOND databases are on the shared volume — so every HMM-screen job failed insidehmmsearchon a path the user could not see. Publishing installs a completed Phase 3 build directory atomically (os.replace, fingerprint written last, an incomplete build refused), and the scheduled check republishes a missing or stale library within the hour, exactly as the pathway databases do. The operator path and the automatic path run the same code, so they cannot diverge. The publish script's database session is read-only and verified, and disposed in afinally. - The library fingerprint is widened, which is the substantive half. The
DIAMOND digest hashes
(id, has_sp, cleavage, sequence)— enough for a database whose only input is sequences, and provably not enough for one whose profiles are grouped by family and labelled by polymer. An interaction flipping positive → negative changes the library while leaving every sequence byte-identical; the sequence-only digest would not move and the library would silently never rebuild, which is the exact shape of #330. Polymer associations and their verdicts, the Phase 1/2/3 recipe constants (TCS_MIN,MAX_TRIM_FRACTION,SPLIT_IDENTITY_LADDER,MIN_SEEDS_FOR_PROFILE,DEFAULT_FAMILY_IDENTITY,RANDOM_SEED) and the pinned toolchain versions all enter the digest. Everything is ordered; nothing iterates a set (#470). HMM_BUILD_DIRsetting — a completed build directory for the hourly check to publish from. Empty by default, which reports staleness and publishes nothing.
Fixed
- **
POST /jobsaccepted a job type that could not work and rejected one that could (#459).**JobTypeEnumofferedbuild_tree, whose handler never readsinput_file_path— so a client following the enum uploaded a FASTA, got a201, and the job built something unrelated to what it sent; that request belongs atPOST /tree/build. It rejectedhmm_screenwith a422, so the one path that can carry a real-sized file could not reach the HMM screen at all, which is what blocked #318.build_treeis out,hmm_screenis in, withtarget_polymersas a repeated form field validated before the upload is written.tests/test_job_type_registry.pynow ties the enum, the worker's_PIPELINESregistry and thejob_type_enumcolumn together so they cannot drift silently again. - **An
hmm_screenjob submitted throughPOST /jobswould have been handed the DIAMOND engine.**dispatchresolves the backend fromjob.engine, which defaults todiamond; the endpoint now setshmmerfor that job type, asPOST /bioinformatics/hmm-screenalready did.pidentis stored asNULLthere too — hmmsearch reports no percent identity, so recording the form's default would put "30%" on a job that never applied it. - An absent profile library no longer reads as an empty result.
run_hmm_screenchecks for the library before an engine is resolved or a job directory created, and fails with a sentence a user can act on. "Nothing was searched" and "nothing was found" are different answers. - The job page rendered an HMM screen as though it were a DIAMOND search.
jobs/status.htmlbranches on job type in five places and knew nothing abouthmm_screen, so the results table headed a profile as "Subject", showed a% Identitycolumn reading0.0%on every row because hmmsearch produces none, and claimed a sequence database had been searched. It now labels the column Profile, drops the identity column, and renders the per-polymer tally fromresult_metadata.
Changed
VALID_POLYMER_NAMESis a fallback, not the contract.valid_polymer_names()reads the publishedpolymer_policy.json— Phase 3.4's computed evidence-tier vocabulary — and falls back to the hardcoded frozenset only while nothing is published, because rejecting every polymer would be worse than a stale list.
Documentation
docs/phase3-integration.md— what was integrated, where the job-type whitelist had drifted (the ORM column had not; the job status template and the API documentation had), the signed-in journey traced step by step, the part of #341 that is deliberately not automated and why, the embargo question left for the owner, and a twelve-step post-deploy verification checklist. Nothing here has been deployed and no HMM job has ever run end to end.
Version 1.18.9
Changed
- **The structural feature question is reopened, measured, and closed again — on
a different reason than the one on file.** (
scripts/experiments/structural_features.py,tests/test_structural_features.py,docs/phase3-structural-features.md,docs/ml_engine_design.md§4.)ml_engine_design.mdexcluded *anything structural* on the strength of Phase 5's net −1 query. Phase 5 measured structure as a retrieval arm competing with DIAMOND, and said in its own words that *"this phase measured retrieval, not ranking, and those are different questions"* — so §4 cited Phase 5 as having answered a question Phase 5 explicitly left open. Step 8 answers it. - **Gate: no. Δ AUROC = −0.068, 95% CI [−0.168, +0.010] over 71 test identity clusters, cluster-level permutation p = 0.16.** Pre-registered estimator, with source-organism growth temperature inside both arms, under Step 2's prospective split (train ≤2024, test 2025+; 64 train / 149 test, predicted models only). A better-powered 70 %-identity cluster-CV arm (n = 214, fold sizes 43/43/43/43/42) gives −0.026 ± 0.027 over folds, p = 0.64. **n is small and is not the reason for the answer:** the interval's upper bound is +0.010, so a gain above one AUROC point is excluded; detecting a true +0.05 would need 944 test proteins and +0.02 would need 5 895.
- **The growth-temperature control did not change the picture, because there was nothing to control.** Source-organism optimal growth temperature (TEMPURA, species then genus, 138 of 213 matched) is uncorrelated with the PET label: Spearman −0.015, 30.9 °C mean for positives against 30.1 °C for negatives. Dropping the control moves the structural difference from −0.068 to −0.073.
- A separate and sufficient reason: the block is not computable at inference. All 19 Phase-4 candidate ORFs failed to fold — the public ESMFold endpoint returns 413 above 400 residues (13 of 19) and 504 below it (6 of 19), while folding a 33-aa control in 1.5 s. §4's requirement that every feature be computable for a novel ORF is violated at the first ORF.
- The cost argument was also about a different object. The 1 500-CPU-year figure was for folding an nr-sized catalogue. 402 models already existed in the structure store, and extracting every geometric feature from all of them — 126 990 residues — took 15.6 wall-minutes on one core.
- **A predicted Tm does not inherit the ascertainment bias that excluded
measured Tm.** Measured Tm scored 0.731 in Phase 0 and was refused because labs
characterise the enzymes they believe are active. An ESM-2 + ridge model
trained on FLIP's Meltome Atlas (held-out Spearman 0.530, RMSE 7.4 °C) runs the
other way — AUROC 0.327 overall, and 0.359 / 0.358 / 0.364 in three
disjoint slices — and is uncorrelated with growth temperature (ρ = −0.009). The
rule that carries forward is about the label set a predictor was trained on,
not about the word "predicted". It is still not shipped: it duplicates the
thermo_proxy (0.64 flipped against 0.628) and did not rescue the ablation. - **Structure provenance is itself an ascertainment variable, and is now measured.** Among the split's proteins, deposited experimental structures are 89.5 % PET-positive against 61.0 % (AlphaFold) and 64.9 % (ESMFold). The ablation therefore admits predicted models only; once it does, having a model is unassociated with the label (63.6 % vs 62.4 %).
Added
scripts/experiments/structural_features.py— the folding and feature-extraction pipeline, re-runnable, with the tool versions recorded: stagesmodels,fold,features,ogt,tm,ablate,clustercv,power. Cleft detection is LIGSITE-style with the PSP threshold at 4 rather than the published 5, calibrated on two known active sites (IsPETase 6EQE and Fusarium cutinase 1CEX) before any label was scored, and the cleft measured is the one the catalytic serine sits in — on 6EQE the largest enclosed pocket is 24.4 Å from it. SASA, pocket geometry and triad detection are written out in-process;freesasa,fpocket,dsspandfoldseekare on no host in this stack.tests/test_structural_features.py— 14 Tier-1 cases pinning the geometry against shapes whose answers are known, plus the pre-registration invariant that the two compared arms differ by the structural block alone.
Version 1.18.8
Changed
- **The negative-stringency rule's accessibility axis had the sign backwards, and
is corrected.** (
scripts/experiments/negative_stringency.py,docs/label-matrix.md,tests/test_negative_stringency.py.) The axis read *"crystallinity ≤ 15 %, or anamorphous_filmwith no recorded crystallinity"*. The second clause applied a statement about crystallinity to a variable that is geometry. On matched enzymes — only the enzymes both cells actually ran — an amorphous film detects less often than a crystalline powder in two independent panels (0.171 vs 0.471 over 5,478 comparisons; 0.598 vs 0.804 over 826), and a severity parameter fitted without ever seeing a condition column agrees (θ +0.653 film vs −0.455 powder). Hold particle size constant and the crystallinity effect returns, small and in the expected direction. **A film is the hardest substrate measured, so a film null cannot be what certifies an assay was easy.** NewGEOMETRY_LIMITED_FORMS; crystallinity remains the axis.PET46— null on amorphous foil, 1,624 µM aromatic products from semi-crystalline powder — is therefore not filed as an exception: it is the general ordering, and it is the case that falsified the clause. - **Measured cost of the correction on the production corpus: the N-stringent PET negative tier goes 23 → 0 and N-permissive 119 → 91.** All 23 stringent negatives reached the tier through the falsified clause and through nothing else. Exactly one negative experiment in the database clears the corrected axis — SfCut, a 4.8 %-crystallinity pellet at 70 °C — and its protein has no stored sequence. Phase 0's stringent-tier result (AUROC 0.864, z = +3.49) is the true output of the rule as it stood and can no longer be read as evidence that hard conditions separate.
Fixed
- **Four vocabulary bugs in the stringency rule, each costed separately and all
four moving zero rows today** — which is the finding, because a rule member
that can never match looks exactly like one that never fires.
SENSITIVE_METHODScarrieduhplc, which is not inevidence_vocab.METHOD_VOCAB, soexperiments.method_idcould never point at it;gc_msandgcare in the vocabulary and were missing, so a GC-quantified null would have failed the detection axis silently. A newcheck_vocabulary()runs at start-up and fails loudly if the rule ever names a method or substrate form the database cannot hold. temperature_unitis aString(8)with no enum; the rule testedunit in ("C", ""), socelsius,°C,ForKwould each have emptied the thermal axis. Production is uniformlyC— the fix is the guard, not a repair."real_pet": form not in SURROGATE_FORMSreturned **True for a NULLsubstrate_form**: the one axis that cleared on missing data, contradicting the docstring directly above it._HOURS_PERhad noyearsentry, so the four production experiments recorded in years returnedNoneand failed the duration axis.
Added
- Step 7's curation re-verification and dry-run payloads.
(
scripts/experiments/curation_worklist.py,tests/test_curation_worklist.py, written up indocs/phase3-curation-and-stringency.md.) Read-only; **there is deliberately no--applyflag.** 20 reviewable curation operations, embargoed accessions masked. - **The gate answer:
Cpapis still not constructible — 2 of 15 draws fit today, 5 of 15 after every worklist payload, and the tier is empty under the corrected rule.** Reproduced without fitting a model, by rebuilding the exactGroupKFoldgeometryevaluateuses. The cause is not sample size: the donor screen's 96 negatives occupy two identity clusters, 95 in one, andGroupKFoldcannot split a cluster — so a draw fits only when it happens to include the single outlier. Curating other papers grows the draw, not the donor's cluster count. The paper confound remains untested. - Three corrections to
docs/pet-negative-curation-worklist.md(PR #478). B1 delivers 13, not 15 — two of Erickson's fifteen are already conflicting cells. B2's TrmA and TrmH are not unextracted: both PET-film nulls are already stored as observations under interactions whoseresultsayspositive, contradicting the paper and the observation text. And the worklist's headline — 91 % → 51 % single-study concentration makingCpapconstructible — measures 91 % → 54 %, and moves the effective study count from 3.44 to 3.51 against a gate of ≥ 5.
Version 1.18.7
Added
- **The censored, condition-aware model — the replacement target, fitted and
gated.** (
scripts/experiments/censored_model.py,tests/test_censored_model.py, written up indocs/phase3-censored-model.md.) Step 6 ofdocs/discrimination-fix-plan.md, and the largest step in the programme.a_ij = f(sequence_i) − theta_j + noise, observed inside each assay cell's measurable window and left- or right-censored outside it.fis sequence-only — it takes an ORF and returns a number, with no pH, no substrate form and no paper id, which is the answer todocs/ml_engine_design.md§4.theta_jis the severity of one (paper × protocol) cell under a hierarchical prior, **marginalised out in closed form at prediction time**. The censored likelihood is written directly in torch —log_ndtr, thirteen lines, no survival-analysis dependency — and checked against the closed forms. - Gate 2 passes: the fitted severity recovers physics it was never told.
Nothing about temperature, pH, substrate form or duration is a feature; they
are grouping keys. Fitted on Erickson 2022's panel,
thetaorders the eight reaction times strictly monotonically with the zero-hour control as the single hardest cell in the study (Spearman −1.000), puts 60-70 °C easier than 30-40 °C on 12 of 13 paired contrasts, and puts higher pH easier. **The plan's own worked example fails, and the raw data says the example is wrong**: on the enzymes both cells actually ran, the amorphous film detects less often than the crystalline powder in both independent panels (0.171 vs 0.471; 0.598 vs 0.804). Isolate crystallinity at matched particle size, as Erickson's cryomilled powder does, and the effect is small and in the expected direction. Surface geometry dominates crystallinity in a whole-particle assay, andnegative_stringency.py's accessibility axis has the sign backwards. - Gate 1 fails, and the reason is stated rather than smoothed. Scored on the one event a protein-level bit can also predict — "would this assay have detected this enzyme?" — the censored model loses on held-out likelihood (−1.048 ± 0.326 against the binary model's −0.641 ± 0.275 over five paper folds) while winning on ranking (detection AUROC 0.573 ± 0.090 against 0.431 ± 0.171, i.e. the binary model is below chance). The likelihood arm is dominated by a laboratory's own detection rate, which this model deliberately refuses to predict. **When the assay cell is named, the censored model reaches AUROC 0.759 ± 0.153 at a 60% identity holdout** — the same region as step 4's condition-specific 0.740 on someone else's benchmark.
- T4 moves from 0.202 to 0.360 on the identical 114 PET triples, and the dose-response that was step 2's sharpest finding disappears: the old model scored 0.407 / 0.132 / 0.045 across one, two-to-four and five-or-more substitutions; the new target scores 0.366 / 0.385 / 0.286. Still below a coin flip and far below the ≥ 0.70 criterion — but the interval now contains 0.5, so "it fails T4" becomes "the test cannot tell".
- T3 reported as the two experiments it is. The plan's 23,072 within-study
pairs reproduce exactly, and 96.3% of them are one paper. Split: the
dominant screen alone is 22,155 pairs at 0.589 accuracy, everything else is
847 pairs over 36 papers at 0.615. Both grains are reported, because the same
table holds 150,435 cell-level comparisons and only 23,002 distinct
protein pairs. **20.5% of informative pairs reverse their ordering between two
conditions of the same paper**, which puts a ceiling of about 0.79 on any
condition-free ranking head — a property of the question that no gate in
ml_engine_design.mdcurrently states. - **A named holdout stringency, chosen by a rule fixed before any AUROC existed:** 60% amino-acid identity, centroid linkage, 50% coverage gate. Step 1's pathology reproduces on the larger corpus — one 25%-identity cluster holds 83.8% of the 400 modelled proteins — so a 25-30% split does not exist here either, and every number in the write-up carries the threshold it was measured at.
Fixed
- A leak that gave the binary baseline its entire apparent advantage. Selecting every protein whose PET verdict came from a training paper hands the baseline the held-out protein's own label whenever a paper straddles the split — which under a sequence-cluster holdout is almost always. Restricting it to proteins that appear in the training rows moved its cluster-fold AUROC from 0.745 to 0.589.
- Three defects in the fit itself, each of which produced plausible output.
torch.whereevaluates both branches, so a sentinel floor on an uncensored row becamelog_ndtr(-inf)and its NaN cotangent poisoned every gradient — the first fit returnedtheta = nanfor all 432 cells and reported success. A ceiling declared at log₁₀ = 0.0 is falsy, soceiling or 1e9silently threw it away. And the hierarchical variance is degenerate at its own joint mode (tau_within→ 1e-9, every cell collapsing onto its paper's mean), so the two variance parameters are fixed hyperparameters selected by nested cross-validation rather than fitted. All three are regression tests.
Version 1.18.6
Added
- The assay's measurable window is a column, not prose. (Migration
0016_experiment_window,scripts/backfill_detection_limits.py, written up indocs/phase2-censoring-schema.md.) Step 5 ofdocs/discrimination-fix-plan.md.experimentsgainsdetection_limit/detection_limit_unitandquantitation_ceiling/quantitation_ceiling_unit. 73% of the PET-negative interactions carry an explicitvalue = 0and 92% of those zeros say "below detection limit", "ND" or "no bar above the baseline" in free text — so the number and the meaning of the number lived in two different places, and every consumer read a zero without being able to tell "nothing happened" from "nothing this instrument could see". Both panels are interval-censored, not merely left-censored: Norton-Baker 2025 bottoms out at A260 ≥ 0.05 over blank, Erickson 2022 tops out at 500 mg/L, and a ceiling that flattens the best enzymes is as damaging as a floor that ties the worst. All four are nullable: "the paper did not say" has to stay distinguishable from "the paper said zero". experiments.buffer— the buffering agent the pH was held at. Erickson ran sodium phosphate pH 7.5 and HEPES pH 7.5 over every enzyme in the panel; stored without the agent, **545 of its condition pairs were indistinguishable**. It is part of_EXPERIMENT_CONTENT_FIELDS, the identity a promotion uses to decide whether an experiment already exists, because a condition column outside that tuple would let promotion merge the very pairs the column exists to keep apart. Curator-editable end to end: schema, curation form,curation_form.js, and the contributing docs (ExtractedDatagrows from 103 exportable leaves to 104).- The backfill's dry run, and what it found.
census/plan/apply, so only the verb namedapplycan write; the census is read-only, disposes its engine in afinally, and selects novisibilitycolumn and no accession. The plan covers **2,326 panel experiments — 100% of both papers — with 0 conflicts, and 86.7% of the zeros behind the PET negative class** now carry the threshold that produced them. The prose the plan hoped to mine turned out to be almost empty: 146 experiments declare a floor and state no number, and only 3 in the whole corpus state a floor with a number and a unit. The prose pass's first draft matched 12 rows and was wrong on 10 —LODinside "cyclodextrin", a keyword paired with the next sentence's concentration, dissolved-oxygen "saturation", and "0.1 um" read as 0.1 µM — each now a regression test.
Version 1.18.5
Added
- The published benchmark reproduced, both arms, on someone else's yardstick.
(
scripts/experiments/benchmark_nb2025.py, tests intests/test_benchmark_nb2025.py, written up indocs/phase2-benchmark-reproduction.md.) Step 4 ofdocs/discrimination-fix-plan.md— the step the plan calls "the one that settles the argument". Norton-Baker 2025's balanced slice (crystalline PET powder / 40 °C / pH 5.5, **212 library candidates, 100 active against 112 zero) is run through our own feature blocks two ways: condition-specific**, held out by sequence cluster inside the slice, and cross-study, trained on our literature corpus with this paper's evidence removed — the direct analogue ofPETML'sD1-Scraped-513, which the same authors report at AUROC 0.470-0.474 on this condition. The contrast is the result. Same features, same two models, same 212 test proteins; the only difference is whether the training target names a condition. **Condition-specific: 0.740, fold sd 0.164, 95% CI [0.536, 0.944], p = 0.025 against a 200-draw cluster-level permutation null. Cross-study: 0.534, cluster bootstrap [0.449, 0.668], p = 0.81 against the same kind of null** — and two of its fifteen block × model pairs land on 0.442 and 0.450, the published number almost exactly, as does the strongest trivial baseline. AUROC is reported as a mean and spread over folds, never a pooled point estimate, and the nulls are cluster-level throughout (the sequence-level ones are reported beside them and give p = 0.005, which is the gap step 1 documented). The clustering is reported before any AUROC, as the plan requires. Norton-Baker's library is one homologous family — median pairwise identity 0.488, 99.9% of pairs at or above 25% identity — so at 25% and 30% identity the whole slice is a single cluster and **no held-out split exists at all**; at 40% and 50% one cluster holds 96.7% and 86.8% of it, which is step 1's fold-geometry pathology recurring. The identity threshold is therefore derived by a rule fixed in the source before the run (the most stringent threshold whose fold geometry is sound), which lands on 0.70, and the AUROC is reported across the whole 0.50-0.90 grid because it rises monotonically with how much homology the split lets through. Nothing was written to the database and no connection was opened at all — the benchmark's input is step 3's file and the corpus is step 2's cached read-only dump.visibilitywas neither read as a filter nor modified; the 291 holds are intact and no non-public accession appears in the write-up.
Fixed
- **A pre-registered estimator naming a composite block silently lost its
permutation null.** (
scripts/experiments/pet_discrimination.py.)evaluate's null-target selection testedp_block in blocks, andblocksholds only the atomic feature blocks — so pre-registeringjointorjoint_plus_esmproduced a report whose primary estimator carriedp_vs_cluster_null: null, which reads as "no association" rather than "never measured". Composite blocks now get their matrices, an unbuildable one gets an explicitnot_runwith a reason, and the full-pipeline null says why it cannot address a composite instead of returning nothing.evaluatealso takes an optionalprimary_estimatorso a different benchmark can pre-register its own pair without touching step 1'sPRIMARY_ESTIMATOR.
Version 1.18.4
Added
- **Both PET panel workbooks extracted quantitatively, and a dry-run ingestion
plan that says the ingest does not do what step 3 assumed it would.**
(
scripts/experiments/panel_workbooks.py,scripts/experiments/panel_ingestion_plan.py, tests intests/test_panel_workbooks.pyandtests/test_panel_ingestion_plan.py, written up indocs/phase2-panel-extraction.md.) Step 3 ofdocs/discrimination-fix-plan.md. Nothing was written to the database — every session was openedpostgresql_readonly=Trueand verifiedtransaction_read_only = on, every engine disposed in afinally, andpg_stat_activitychecked afterwards for leaks.visibilitywas neither filtered on nor modified; non-public accessions are maskedNONPUBLIC_nnnthroughout and the mask mapping was not stored. **The apply is documented as a single command and has not been run.** The gate, three ways. Effective study count for PET negatives, as inverse Simpson over per-paper protein counts: 3.4995 today (reproducing the diagnosis figure exactly), **3.4995 if the panels are ingested as a pure addition — unchanged to four decimal places — and 3.9165 if the verdict corrections the source data forces are applied with it**. Why addition cannot move it. All 65 of Erickson's screened enzymes resolve by sequence onto proteins already in the corpus (16 exact, 49 after trimming the expression construct's His tag and initiator methionine); Norton-Baker proposes two new rows, neither a negative. **Not one new PET-negative protein comes out of either panel.** And structurally it could not: Norton-Baker is already 97 of the 192 protein-paper negative pairs — 50.5% of the whole minority class — so more negatives from it make the class more concentrated. The only lever is subtraction, and there is one: **13 enzymes ingested as Norton-Baker PET negatives released product above the paper's own declared A260 ≥ 0.05 threshold**, taking that paper from 97 to 84. Norton-Baker is already ingested quantitatively — 2,124 experiments with per-condition temperature, pH, substrate form, crystallinity and aumol/mgvalue, 2,080 of them matching a workbook cell exactly. 3a is an enrichment of 78 cells, not a bulk insert. Erickson is where the information is: its 202 existing rows are best-condition summaries, and the apply would add 4,680 cell-level measurements (1,859 left-censored zeros, 310 right-censored at the 500 mg/L ceiling) across D3-D7. Sequence-level dedup, never name matching (the owner's standing instruction). Four tiers — exact, contained, near, new — and three rules that each prevented a wrong write: trimming the expression construct before comparing (without it, 32 candidates already in the corpus looked new), resolving a-nSP/-pelBconstruct to its base enzyme, and checkingprotein_variantsbefore minting an engineered benchmark as a wild type. A number never becomes a verdict. Erickson's enzyme410has one cell at 1.19 mg/L out of 30 and the paper's own Supplementary Table 11 calls it "No Activity", so it stays negative with the measurement recorded beside it. Four enzymes screened at 2-5 µg rather than the study's standard 10 µg are reported aslow_yield_artefact, not as hard negatives. **The balanced 216-enzyme slice is a standalone artefact with sequences attached** (work/step3-2026-09-05/nb2025_balanced_slice.tsv), which step 4 needs. Measured: 215 enzymes, 102 active against 113 zero — the plan's 216/102/114 counted the buffer-onlyblankwell as an enzyme. Two parsing faults that would each have been silent. Erickson spells the quantitation ceiling500+in 163 cells and500*in exactly one, so a+-only parser reports 1,949 D3 measurements where the sheet holds 1,950; and two time-course headers lost their degree sign (608C,30*C), which reads literally as a 608 °C reaction. Both are covered by tests. Two curation queues the run surfaced. Fourteen Norton-Baker enzymes have a corpus row whose sequence is not the chain the paper's SI publishes under thatsample_idalthough the NCBI accession agrees — eight of them under a current PET negative. Andexperimentshas no buffer column, so Erickson's NaH2PO4 pH 7.5 and HEPES pH 7.5 collapse into one condition for 545 cell pairs unless the agent is carried inadditional_conditions.
Version 1.18.3
Added
- **The two falsification tests nobody had run — and the current model fails one
of them, below chance.** (
scripts/experiments/falsification.py,tests/test_falsification.py, written up indocs/phase1-falsification-tests.md.) Step 2 ofdocs/discrimination-fix-plan.md. Read-only with respect to the database: oneSELECT-only dump on a session verifiedtransaction_read_only = on. T4 — variant direction. Gate: FAILED. Matrix B's 332 matched (parent, variant, polymer) triples are the only controlled contrasts the corpus holds — same scaffold, a few substitutions, same assay, same figure. **203 were scorable; on the 120 whose measurement says the variant is the better depolymerase, the rescorer's forest ranks the variant above its parent in 0.212 of cases (95% CI over parent scaffolds [0.079, 0.396]**, 21 parents, 9 exact ties). The success criterion was ≥ 0.70. The masked multi-label MLP reads 0.383 [0.214, 0.587] — an interval that includes chance, so for that head the honest statement is "the test cannot tell", not "it passes". A constant-score control lands at exactly 0.500, which is the harness's own check that the paired test is wired correctly. The mechanism, which is the useful part. The trivial nearest-characterised-positive baseline fails in the same direction and harder (0.246, and 14% excluding its ties); the median variant-minus-parent score gap is negative; and the failure is dose-dependent — 0.407 for single mutants against 0.107 for variants carrying two or more substitutions. **The score is a decreasing function of distance from the characterised set, and engineering a protein is a walk away from that set**, so on a variant series the model ranks backwards by construction. Phase 0 inferred homology recognition from a leakage gradient; T4 measures it directly, with family membership held constant. Matrix B is a test set only and never entered a training fold: variant rows carrylabels = {}, they sit after the record list every training index is drawn from, and two tests pin both facts. Two contaminations that would each have flattered the answer were removed first — **each parent is held out of its own contrast (the model is refitted 23 times) and removed from the profile arm it seeded**. T5 — the prospective split. Train on papers through 2024, test on 2025+. The plan's support table reproduces exactly, with no drift: PET positives 222 train / 181 test, negatives 41 / 151 — and it is at interaction grain, which the plan does not say; at (protein, polymer) cell grain it is 139/150 and 40/150. PET is the only head with a usable 2025 side. **The forest scores AUROC 0.685 [0.621, 0.776] against a trivial baseline at 0.521** — the largest honest gap the rescorer has produced. The caveat is quantified rather than gestured at: the 2025 side spans 15 papers but its effective study count is 1.68 and one paper supplies 76.8% of the test proteins, so the result is split on that paper (0.662 on it, 0.708 on the 63-protein remainder) and reported as one prospective trial. Read together, the tension is the finding: prospective discrimination without activity discrimination is what family recognition looks like. Phase 6.2'sENABLE_ML_ENGINE = Falseand PET's "polymer undetermined" verdict now rest on two independent measurements instead of one. Both tests are CLI entry points so they can go on every future model card, permanently, as the plan requires. - A cluster bootstrap that resamples the right unit. An AUROC is a mean over
pairs, and 272 test proteins make 18 471 of them; resampling pairs reports an
effective sample size in the thousands and an interval three times too narrow
([0.656, 0.715] against the honest [0.621, 0.776]).
cluster_bootstrap_aurocresamples identity clusters and recomputes the statistic. T4's interval resamples parent scaffolds, and reports the too-narrow Wilson interval beside it so the difference is visible.
Fixed
rescorer.fold_profile_featureswould have crashed on its first real run. Step 1 changedrun_hmmsearchto return(hits, error)so that "the profile matched nothing" and "hmmsearch did not run" stop being the same empty dict; this call site still unpacked one value and would have raisedAttributeError: 'tuple' object has no attribute 'get'the moment a fold profile was built — which is every run with MAFFT and HMMER present, and none of the mock tier. The error is now handled, recorded in the block's status asdegraded, and pinned bytests/test_rescorer.py::test_every_hmmsearch_call_site_unpacks_the_error.
Version 1.18.2
Fixed
- The profile-HMM block was never anti-predictive; the instrument was broken.
(
scripts/experiments/pet_discrimination.py, written up indocs/phase1-instrument-clearing.md.) Phase 0 reported the profile HMM — the feature the whole pipeline is built on — at AUROC 0.467 and 0.439, below chance, and concluded that it "performs worst". Two independent defects in the measurement produced that number, and removing either one removes the below-chance result:- Train/test covariate shift in the headline feature. The fold's positive and negative profiles were built from the training rows and then used to score every row, so training rows were seeds of the profile they were scored against and test rows were strangers to it. The feature had two different distributions and the classifier fitted a boundary on the wrong one. Scoring is now leave-one-seed-out: each seed's row is deleted from the training alignment, the profile is re-estimated, and the seed is scored against a profile that does not contain it — 1,628 rebuilds per run, about four minutes.
- One AUROC pooled across five differently-calibrated models. Folds here
range from 15 to 345 test sequences and 0.40 to 0.94 prevalence, and
concatenating their probabilities ranks incommensurable numbers against each
other. This is the same defect that produced the spurious 0.460 in
docs/discrimination-diagnosis.md§1.2. Every block now reports the **mean AUROC over folds with its fold standard deviation** and the per-fold values; the pooled figure is kept and demoted toauroc_pooled. Corrected, the block reads 0.617 with a fold sd of 0.084 over 5 folds (95% CI [0.513, 0.721]) and ranks fourth of eight rather than last. It is still not evidence of signal: a 200-draw cluster-level permutation null is centred on 0.499 with sd 0.112, giving p = 0.32. Phase 0's overall conclusion — nothing separates PET positives from negatives on this corpus — stands; the claim that profiles are worse than useless does not.docs/phase0-pet-discrimination.mdneeds five passages restated, listed in the new document.
- The permutation null was too tight and too small to carry a p-value. It
permuted labels over sequences, which destroys the within-cluster label
agreement the real data has, and it drew 20 samples — a grid that cannot
express a p below 0.048. The null is now cluster-level (each cluster
receives another cluster's label composition) at 200 draws by default, and
is computed for the pre-registered pair as well as the historical one. On the
profile-HMM block the two nulls disagree sixfold: p = 0.050 sequence-level
against p = 0.32 cluster-level.
--pipeline-permutationsadds the null nobody had run — the one that rebuilds MAFFT,hmmbuildand the leave-one-seed-out rescoring from the permuted labels, because the profile columns are built from the labels. At 12 draws it is centred on 0.525 and reaches 0.721. - The reported estimator is pre-registered rather than maximised.
PRIMARY_ESTIMATORfixes one block × model pair in the source. The historical maximum over every pair is kept for arm comparability but now carriesn_keys_examined(16) and a Šidák-corrected p-value. - The split is no longer described as 5-fold cross-validation when it is not.
One 25%-identity cluster holds 84.8% of the labelled set, so the run is a single
62 → 345 split plus four folds of fifteen. A
fold_geometryblock reports the per-fold sizes, the 23× spread, anis_balanced_k_foldflag and a plain-English description, and the same sentence is logged as a warning. - Four more silent failures in the same experiment.
run_hmmsearchreturned an empty dict for both "matched nothing" and "did not run", and callers zero-filled a feature column and reported success — it now returns(hits, error). A MAFFT orhmmbuildfailure inside a fold no longer yields{"status": "ran"}; the block takes the worst status of its folds and a degraded block can no longer supply an arm's headline. A NaN AUROC is reported asfailedwith a reason instead ofran. A missing ESM embedding raises instead of becoming a zero vector at the origin of the representation space.
Version 1.18.1
Added
- **A diagnosis of why PET discrimination fails, and a pipeline built on a
different target.** (
docs/discrimination-diagnosis.md.) Four measured nulls concluded that the difficulty is biological — that positives and negatives are genuinely not separable by sequence. Three new read-only measurements say the binding constraint is narrower and more fixable. The negative class is worth about 3.5 independent observations. 187 PET-negative proteins come from 22 papers with an inverse-Simpson effective count of 3.5. Every held-out-study estimate in the programme is roughly one draw from a distribution of three or four, which is exactly what Phase 6's fold-to-fold SD of 0.03–0.17 predicts. The binary label is a threshold applied to a number already stored. 163 of 224 PET-negative interactions (72.8 %) carry an explicit `observations.value = 0`, and 92.3 % of those zeros say "below detection limit", "ND" or "no bar above the baseline" in their text. They are left-censored observations, and each paper's limit is different. The programme has been binarising ~4 000 quantitative experiments down to 432 protein-level bits. The scale that would let studies be compared has no negative anchor. Only 23 of 411 PET proteins are measured by more than one paper and every one is positive-only, because nobody re-tests an enzyme that failed. 26 of 69 papers share no protein with any other. Measured inter-study disagreement is 17 %, which caps AUROC near 0.90 — so label noise costs about 0.10 and cannot explain a 0.30 gap. The document sets out six ranked hypotheses, six discriminating tests (three of which use only data already in the database and can each overturn a published conclusion), a five-stage pipeline built on a censored condition-aware target, and success criteria stated as prospective enrichment rather than retrospective AUROC. The remedy is already on disk and already validated elsewhere. Two supplementary workbooks in the local paper corpus hold ~267 enzymes and ~5,800 condition-indexed quantitative measurements with sequences attached, ~99 of them enzyme-level hard negatives verified as expressed and folded — including a **fully covered, balanced, single-condition slice of 216 enzymes, 102 active against 114 zero**. The authors of one of them report that models trained on a literature-scraped cross-study set score AUROC 0.47 on their assay, the same below-chance signature as our profile-HMM block, while **condition-specific models trained on the uniform assay reach about 0.8**. Nothing here retracts a null. It argues that no null so far was measured on a sample large enough, or against a target specific enough, to support the conclusion drawn from it. - The execution plan that follows from it.
(
docs/discrimination-fix-plan.md.) Nine steps, each with a measurable gate and a stated consequence for failing it, written to be picked up cold in a new session: clear the instrument, run the two falsification tests nobody has run, ingest the two panel workbooks, **reproduce the field's published ~0.8 on a balanced 216-enzyme benchmark slice**, add detection-limit and quantitation-ceiling columns, fit the censored condition-aware target, finish the curation worklist, reopen the structural feature question, then integrate. Carries the operational constraints verbatim (read-only production, the 291 embargoed proteins, connection hygiene), the worker-pod recipe, the two steps that write to the database and must dry-run first, the measured limits that bound the design (only 26.7 % of stored values normalise; cross-lab reproducibility at matched conditions is 24–64×), and the two ingestion traps: 35 negative interactions carrying an off-target positive value, and ten detection methods that produce zero negatives because they are confirmatory.
Version 1.18.0
Added
- **A curation worklist that makes the PET stringent-negative confound
testable.** (
docs/pet-negative-curation-worklist.md.)docs/phase0-pet-discrimination.mdreports that the N-stringent PET negative tier is 21 of 23 negatives from one deep-sea study across three genera — a paper filter wearing a condition filter's clothes, with no constructible same-paper control. This document scopes what it would take to break that, read-only, against the production corpus and a 1 465-paper local store. **The finding is that the fix is almost entirely re-curation, not new literature.** Roughly 40 papers were read in full; the open literature contributed zero new stringent negatives and one unresolved candidate. Meanwhile four already-curated papers hold 18 stringent negatives that the rule cannot currently see:10.1038/s41467-022-35237-x(Erickson/NREL) — 15 negatives whose assay is a 6 pH × 5 temperature grid "spanning … temperatures from 30–70 °C", 96 h, amorphous Goodfellow film, UHPLC, zero product at every cell. They fail only the thermal axis becauseexperiments.temperatureis NULL: the grid has no scalar to record.10.1002/cbic.202500065(Howe, Queen's) — TrmA (*Anaerolinea thermophila*, E8N6D9) and TrmH (Thermosporothrix hazakensis, A0A326UD21) were never extracted, though the paper names four PET-film negatives and PlasticDB holds two.10.1007/s00253-016-7992-8(Ribitsch/Guebitz, BOKU) — PpEst (Pseudomonas pseudoalcaligenes), whose protocol runs at *"50, 65 or 80 °C … for 72 h"* on amorphous Goodfellow film; the extraction kept 50 °C.10.1021/acssuschemeng.6c00030— a stringent negative (SfCut, Saccharopolyspora flava) already curated but outside the Phase 0 corpus because the protein has no sequence. Curating those moves the tier from 2 papers / 2 groups / 3 genera to 5 papers / 4 groups / 9 genera across 3 phyla, and drops the largest single study's share from 91 % to 51 % — which is what makesCpap, the control the experiment could not build, constructible. The document also records why the literature cannot help much: **PET discovery screens are run below the glass transition.** The largest negative set published — 954 bacterial cutinases, only ten PET-active, titled *"PET hydrolysis is a rare property"* — is measured at 40 °C on >50 %-crystallinity powder by UV-Vis, and clears no axis at all. Every rejected paper is listed with its disqualifying axis so the work is not repeated. No database writes, no verdicts changed. This is a worklist, not a curation.
Version 1.17.1
Fixed
- **A feature block that cannot be computed now stops the experiment instead of
reporting a fabricated null.** (
scripts/experiments/pet_discrimination.py,scripts/experiments/provenance_arms.py,scripts/experiments/negative_stringency.py,scripts/experiments/rescorer.py,tests/test_pet_discrimination.py.) MAFFT's portable tarball shipsmafft.bat, a wrapper that exportsMAFFT_BINARIESbefore callingmafftdir/bin/mafft. Invoke the inner script directly and it printsv0.000 != v7.526, exits 0, and then fails every alignment it is given.fold_profile_hmm_featurestreated that failure the way it treated every other: log a warning, leave the block's seven columns at 0.0, and return{"status": "ran"}. A block of zeros is not an absent row in a results table. It is scored, cross-validated and reported like any other feature, and it scores **AUROC exactly 0.500** — which is what a real null looks like, in the one experiment whose entire published claim is a null. The same silence covered a missing aligner, anhmmbuildfailure, and a fold that could seed neither profile. Now: a requested block that cannot be computed raisesBlockUnavailable.--allow-incomplete-blocksis required to degrade at all, and then the block isnot_run, listed under the report's top-levelincomplete_blocks, carried into every arm row ofsummary.json, and printed as a stderr banner — a missing block is easy to miss in a results table, so it is stated in every place a human looks rather than in JSON alone. The arm drivers refuse to start a 78-arm run with the aligner missing, instead of warning and spending two hours producing an incomparable arm set. - **The pinned-toolchain guard accepted a MAFFT that could not align two
sequences.** (
app/services/bio_tools.py,scripts/check_bio_tools.py,tests/test_bio_tools.py,docs/bioinformatics-tools.md.)check_bio_tools.pyassertedversion_marker in output. The broken banner isv0.000 != v7.526— the pinned marker is *inside the message reporting the fault* — so the substring matched and the check passed. There are three states, not two: absent, present at some version, and present and unable to work. Treating the third as the first is the actual defect; the first permits a caller to degrade, the third must not.BioToolgainsversion_disqualifiers(("v0.000",)for MAFFT), tested before the marker, and amisconfigured_hintnaming the fix. The probe moves toapp.services.bio_tools.probe_toolso the runtime can ask the same question of the binary it actually resolved:_tools()in every experiment harness now callsassert_tool_usable, and a--tool-dirpointing at the wrong MAFFT fails the run at once. A version mismatch still only warns — a host at another release is a host, not a defect. The image was never affected: its.debhardcodes the helper directory and/usr/bin/mafft --versionreportsv7.526withMAFFT_BINARIESunset. The affected runs were ad-hoc, on a host with an unpacked tarball, where a build-time-only guard never ran — which is why the runtime check exists as well as the fixed build-time one. Do not setMAFFT_BINARIESby hand: a packaged MAFFT requires it unset and breaks identically if it is set. - **A profile-HMM fold that could seed only one of its two profiles said
ran.** The per-fold status was also overwritten fold by fold, so one bad fold in five vanished behind the four that followed it. A fold now reportsdegradednaming the class and its seed count, and the block's status for the run is the worst fold's.
Changed
docs/phase0-pet-discrimination.mdgains a retroactive audit ("Was MAFFT working?") and corrects the environment-trap note, which claimed the harness marked the blocknot_runwhen it in fact marked itran. Both published runs had a working MAFFT and no number is retracted: no arm scores the degenerate 0.500, the control distributions have real spread (hmm/forest sd 0.047 across 30 draws), every fold directory holds non-empty alignments and profiles, the run scripts invoke MAFFT through themafft.batwrapper, and the whole-corpus reference reproduces the 2026-08-26 baseline block for block. The audit does record a real degradation it found: 41 folds of the negative-stringency run built no negative profile because the arm had fewer than 8 training negatives — confined toCpap/Cstrcontrol draws the document already declines to read as results, and touching no published number.
Version 1.17.0
Added
- **Negative stringency: the database can now say how hard a negative was
pushed.** (
docs/label-matrix.md→ "Negative stringency",scripts/experiments/negative_stringency.py,tests/test_negative_stringency.py.) "Did not degrade PET" is a measurement made under conditions, and two hand-adjudicated cases show what that costs. Ahmed 2025 (doi:10.1002/mbo3.70144) records PET negatives by bulk UV absorbance with no stated limit of detection, and disclaims its own null in the text. Herrero Acero 2011 (doi:10.1021/ma200949p) records a PET negative for TfCut1 on 37 %-crystallinity film at 50 °C — an enzyme Roth 2014 and Wei 2020 report is PET-active. A negative is now scoped by five axes evaluated on a single experiment: real polymer (not a BHET/pNP surrogate), temperature at or above PET's hydrated glass transition (≥ 60 °C), an accessible substrate (≤ 15 % crystallinity or an amorphous film), chromatographic/MS detection, and ≥ 24 h. N-permissive is the polymer plus one accessibility axis; N-stringent is all five at once. Missing data never clears a bar. The rule was frozen from the polymer physics and the two adjudicated cases before any model was fitted. For PET: **160 negatives, of which 119 are permissive (12 papers) and 23 are stringent (2 papers)**. Positives are unaffected — an observed activity is evidence of ability whatever the conditions were. The corpus dump now carries the assay conditions (pet_discrimination.CORPUS_SQL_EXPERIMENTS), so the tiers are recomputed at every build and never stored on a row.
Changed
- **Phase 0 re-run on condition-scoped negatives: the PET null is not explained
by what a negative means either.** (
docs/phase0-pet-discrimination.md, new dated section; baselines kept verbatim.) Condition coverage was measured first, as the gate: on PET negatives, temperature is populated on 90.7 %, substrate form 99.4 %, method 100 % and crystallinity 59.6 % — enough to scope by, on both classes. (The four fields at 0 % —culture_medium,aerobicity,sole_carbon_source,substrate_mw— are whole-organism fields and every PET protein verdict is a cell-free assay.) 78 arms:full/permissive/stringent, plus 30 + 30 size-matched random control draws and 15 single-paper draws, all throughpet_discrimination.evaluateunmodified. Thefullarm **reproduces the published whole-corpus reference exactly** (432 labelled, 272/160, best AUROC 0.641, every block to three decimals). For the first time in the programme both scoped arms clear their controls — permissive 0.709 against a control mean of 0.640 (sd 0.033, z = +2.10, 0/30 draws at or above); stringent 0.864 against 0.656 (sd 0.060, z = +3.49, 0/30). Neither survives its confound check. The permissive arm sits at the top of its control's leakage distribution (median train↔test identity 0.308 against a control range of 0.279–0.308, r = +0.31 between leakage and AUROC), and leakage-matched the effect falls to +0.028 AUROC, z = +0.92. The stringent arm is 21-of-23 one laboratory's deep-sea screen across three genera, and its negatives are **more distant from the positives than every one of 2 000 random size-matched subsets** (z = −3.21) — homology recognition, the artefact the 2026-08-26 run was built to expose. Its single-paper control could not be constructed: 13 of 15 same-paper draws have a training fold with no negative in it and cannot be fitted at all. Verdict unchanged: Layer C reports "polymer undetermined" for PET. What changed is the ranking of the open explanations — assay-condition noise is the only one of three that has produced any signal above its control, and the reason it cannot be credited is that this corpus cannot separate "measured hard" from "measured by one laboratory on one clade". Phases 1–3 were not re-run and the reason is recorded: Phase 1's seed set is the positives, Phase 2 consumes Phase 1, and Phase 3's N0–N3 tiers are Swiss-Prot and shuffles with corpus proteins explicitly excluded — not one measured negative among them.
Version 1.16.1
Changed
- Phase 0 re-run on provenance arms: the PET null is not label noise.
(
scripts/experiments/provenance_arms.py,scripts/experiments/pet_discrimination.py,docs/phase0-pet-discrimination.md.) Phase 0 found nothing separating measured PET-positives from measured PET-negatives, and Phase 6.2's rescorer then failed to beat nearest-neighbour. Both were read as "this is biologically hard". v1.15.0's provenance audit offered a competing explanation: for **277 of 620 sequenced non-tombstoned proteins** the stored sequence is unverified against the organism the record credits it to, so a polymer verdict may be attached to a sequence nobody measured. That is an upper bound on possible label noise, not a measurement of it — most of those rows are probably fine. Tested by re-running the identical pipeline on four arms: A the 343organism_consistentproteins; B thirty size-matched random draws (seeds 20260831–20260860); C the 277 unverified proteins; D ten clean draws size-matched to C. Same code, same split (centroid at 25% identity), same folds, same metric — only the corpus changes. **Arm A scores AUROC 0.663, the 70th percentile of its own random control (mean 0.644, sd 0.038); 9 of 30 draws score at or above it. Effect +0.019 AUROC, z = +0.51.** The sharper contrast is flatter still: the unverified arm scores 0.681 against a size-matched clean control mean of 0.679 — +0.002 AUROC, z = +0.08. Every arm sits at the same leakage (median train↔test identity 28%) and the same permutation floor. The whole-corpus reference run reproduces the 2026-08-26 verdict (best 0.641 against a 0.630 floor). One block moved and is recorded rather than buried: the profile HMM block is above all thirty control draws in the clean arm (0.660 vs a control mean of 0.548) and below the clean control in the unverified arm — consistent in direction, but one comparison of sixteen, with a bootstrap interval that includes chance and an MCC of 0.28. It licenses nothing. Layer C's "polymer undetermined" for PET stands. Curating the unverified rows is worth doing for correctness; it will not rescue PET discrimination.
Added
--restrict-provenance,--restrict-idsand--report-outonpet_discrimination evaluate, so the same evaluation can be run over a subset of the corpus without a second implementation.restrict()slices the records and both identity matrices together — an excluded protein cannot bridge two clusters it is not part of. The corpus dump now carriessequence_provenanceandaccession_status.- A staleness check on the ESM embedding cache. A cache written for an earlier, smaller corpus was previously a cache hit, and the proteins it did not cover were silently scored as zero vectors.
Version 1.16.0
Added
- A protein duplicate-merge tool: the backfill half of sequence-level dedup.
(
app/services/protein_merge.py,scripts/merge_duplicate_proteins.py, newprotein_mergesaudit table.) v1.14.0 closed the door going forward — a paper naming an enzyme whose residues are already stored folds onto that row at promotion. It deliberately did not touch what was already there: **29 groups of byte-identical mature chains covering 64 of 638 sequenced proteins, and 63 of those 64 rows carry interactions, experiments and observations no other member of their group carries**. Consolidating them is a union of evidence spines, which is this. Detection is not re-implemented:protein_dedup.duplicate_groups_sync— the same functionscripts/report_duplicate_proteins.pyreports from — finds the groups, and this plans and applies. One detector, two consumers. Union the evidence, never pick a survivor and drop the rest. Every row pointing at a retired record is re-homed onto the survivor: interactions (and with them the experiments and observations that hang off them by foreign key), variants, kinetics, properties, structures, enrichments, deposit membership, job hits, submission matches, reaction steps. All twelve tables are counted before and after and the group aborts if a single row went missing —EvidenceLostError, an assertion rather than a log line. A test reflects every foreign key toproteins.idout of the metadata and compares it against the hand-written referrer list, so a table added later fails the build rather than quietly not being re-homed. Tombstone, never delete.merged_into_accession+is_tombstoned, so aPB…from a published paper still resolves — 301 to the survivor, through theresolve_accessionstate machine that already existed for exactly this. The registry entry moves tomerged. Because it is anUPDATE, the two #463 deletion guards are never met, let alone worked around; the pg tier proves both halves by merging a group containing a held row and then watching the very same guard refuse an actual delete of it. The survivor rule is a safety property, not a taste. Most restrictive visibility first (private<embargoed<staged<public), then richest evidence, then most complete accessions, then lowest id — the same tie-breakSequenceIndex.matchuses, so ingestion and the backfill agree about which row is canonical. Ordering restrictiveness first means the survivor is the resolved visibility, so the tool never writesvisibilityon it at all. That matters: a held row is parked atprivate, and thebefore_flushparking guard turns any write to a held row'svisibility— including assigningprivateto a row that is alreadyprivate— into an overwrite ofvisibility_hold_prior. The hold would survive; its reversibility would not. Novisibility_hold_*column is written anywhere in the module. What it refuses, with the reason printed. Verdict conflicts (positive/negative on a shared polymer) and soft disagreements, both classified from the live spine rather than from an audit file; organism splits, whichprotein_dedupalready calls a taxonomy question; deposit splits; visibility collapse — the most restrictive value demoting a member, apublicrow leaving the public corpus or astagedone leaving the release queue, off by default and reachable with--allow-visibility-collapse; and G09/G21 on a hard list keyed by accession, because their adjudications are specific and are not "merge". Repo backfill conventions. Dry run by default and `SET TRANSACTION READ ONLYwhile it is one;--apply` to write; one group per transaction throughcommit_with_deadlock_retry(thebackfill_signal_peptideslesson: a long transaction deadlocked against the scheduledreindex_search_vectorsbulkUPDATEofproteinsand lost a production run at record 140 of 577); SIGINT/SIGTERM stop between groups; resumability is a property of the query — tombstoned rows drop out of the detector, so re-running finds nothing, which is also the idempotence test. protein_merges— the audit row #463 wished it had ("no tombstone, nomerged_into_accession, no audit row — the loss had to be reconstructed from row counts"). One row per retired record: which accession became which, in which sequence frame, and how many rows moved in each table. A new table, so it ships through the ORM models andpython -m app.db.init_schema; no Alembic revision, because a migration must neverop.create_table()something the models also declare.
Version 1.15.0
Added
- **The database now records what each stored sequence is, and whether the
accession it hangs on is still alive upstream.** Two new
proteinscolumns (accession_status,sequence_provenance), a classifier (app/services/provenance.py), a worker that keeps them current (app/workers/provenance.py), and a one-time catch-up (scripts/backfill_sequence_provenance.py). Migration0015. Two hand adjudications of contradictory PET verdicts both dissolved into provenance rather than biology.PBUU2H(WP_108898452.1) carries a PET-positive for Bacillus thermoamylovorans JQ3, but the accession came from the paper's own supplementary table under the headerNr_ID— an NCBInrbest hit. The authors deposited SRA reads and never JQ3's protein, so the stored sequence is an annotation transfer onto a different isolate, and that accession is also suppressed upstream.PB5BTTis named "Tfcut2" and carriesE5BBQ2, which is TfCut1 and is now deleted outright; the real TfCut2 isE5BBQ3, merged intoQ6A0I4. Nothing in the schema could tell an authoritative deposit from a homology guess, and everything downstream inherits the difference — the profile-HMM pipeline builds on these sequences and the label matrix attaches polymer verdicts to them. What is measured, and what is not. Full provenance is not reconstructible: nothing records whether a curator read an accession off a methods section or inferred it, and no query invents that. What is computable is consistency — what upstream says about the handle, and whether upstream's organism agrees with the row's. Both vocabularies therefore keep an explicitunverified, it is the default for all 867 existing rows, and a failed lookup leaves it rather than fabricating a verdict.accession_status:unverified·absent·active·secondary(merged upstream) ·deleted(deleted or suppressed) ·unresolved.sequence_provenance:unverified·no_sequence·unaccessioned·organism_consistent·species_projection(a RefSeqWP_— the non-redundant namespace, so it belongs to no single isolate whatever the species says) ·strain_mismatch·organism_mismatch·uncomparable. Two columns, not one, because they are independent facts and each named case proves it from a different side: a live, primary accession can still name a different organism than its row, a dead one may have named exactly the right one, andWP_108898452.1is defective on both axes at once. A single column would have to pick which half to report. Measured against production, read-only (867 live proteins):organism_consistent357,no_sequence229,unaccessioned121,species_projection100,uncomparable34,organism_mismatch26,strain_mismatch0; andactive454,absent350,deleted61,secondary2. - **Forward-looking capture, following the #469 pattern rather than inventing a
third.** Both approval routes dispatch
classify_protein_provenancefor the proteins the promotion created, after the commit — never inside the request, because a lookup is seconds per record and an approval can mint 46 at once. A dailysweep-protein-provenancebeat (05:50 UTC) is the net behind it. It is deliberately not a rung of the weekly enrichment ladder: that batch walks every protein inidorder under a four-hour limit, so the newest rows are the last it reaches — the same reason a NULLhas_signal_peptidesurvived months of weekly runs. app.services.external_api.eutils_get_sync— the sync twin ofeutils_get, so Celery and the backfill scripts pace, identify and retry against NCBI through the same one place the async callers do, instead of adding a second bareclient.get. Lookups are batched at 100 accessions per request against both UniProt's/uniprotkb/accessionsand NCBI esummary, and deduplicated across a batch.
Fixed
eutilsrefusals are no longer under-counted: esummary emits one"error"key per refused id in the same JSON object and every parser keeps only the last, so refusals are now read from the raw body. And an "Invalid uid" is no longer taken as "no such protein" — 31 of this corpus's accessions are MAG/WGS proteins that esummary and esearch both refuse whileefetchserves them in full.
Version 1.14.0
Changed
- **A protein's identity is now its accession, then its sequence, then — within
one paper only — its name. Global name matching is gone.**
(
app/services/ingestion_promotion.py, newapp/services/protein_dedup.py.) An audit of production found **29 groups of byte-identical mature chains covering 64 of 638 sequenced proteins (~10% of the corpus)** — the same enzyme stored two, three or four times, with its papers and evidence split between the copies. Accession matching was not broken, it was one-dimensional: UniProt legitimately holds several accessions for one chain (one group is four rows, one 261-aa chain, three UniProt accessions), so **15 of the 29 groups share no accession value in either column** and no widening of the accession lookup could ever have joined them. Promotion now compares the canonical sequence, reusing the normalisationapp/services/uniparc.pyalready defines (whitespace stripped, upper-cased, trailing*dropped,Nonefor anything that is not a protein sequence) and keying an in-memory index on its CRC64. A digest is never the verdict: the full canonical strings are compared before a match is returned. A second paper's interactions, experiments and observations now land on the protein that already holds those residues. What it catches and what it does not. It compares the stored precursor, which is all it has — SignalP runs after promotion commits — so it reaches 12 of the 15 accession-distinct groups. The other three (audit groups G09, G15, G22) are identical only after the signal-peptide cut and are invisible to it by construction.scripts/report_duplicate_proteins.py --frame bothfinds those, read-only, over rows SignalP has since annotated. - **The global name fallback is removed, and the measurement is the argument for
what replaced it.** It matched
Protein.name == namewithlimit(1)against the whole table with no regard for the organism:PHB depolymeraseappears in 8 approved production ingestions naming 8 different species, and all 8 resolved to whichever row came back first. But 229 of 867 production proteins carry neither an accession nor a sequence, so the name was genuinely their only handle, and dropping it outright is not free. Measured over the 1,722 protein actor slots in the stored payloads: | | slots | |---|---:| | accession-bearing (never reached the fallback) | 974 | | no accession, a sequence supplied (now covered by the sequence check) | 199 | | no accession and no sequence | 549 | Of the 510 bare slots in approved payloads, only 291 are distinct(ingestion, name)pairs — 219 are one paper naming one enzyme again. Dropping the fallback with nothing in its place would have split a single study's enzyme into several rows, which is worse than the cross-paper duplication being fixed. So two narrow replacements keep exactly that:- a payload-local actor cache, so one paper's repeated name is one actor; and
- a paper-scoped name check (
interactions.paper_id), so a re-approval finds its own earlier work — the only handle a bare actor has, and the reason re-promotion stays idempotent for the 291 pairs. Neither can reach another paper's row. **The cross-paper cost is 23 bare names used by more than one approved ingestion, 52 arrivals that previously folded onto an existing row and will now mint their own.** Many of those folds were wrong (thePHB depolymerasecase above), so this is not purely a loss — but it is a real change in behaviour and it is stated rather than buried.
- A sequence match with an organism mismatch is refused, not resolved.
Production holds one 355-aa nylon hydrolase stored under both Arthrobacter
sp. and Paenarthrobacter ureafaciens. Which name is right is a curator's
judgement, and settling it on approval would make the answer depend on which
paper was reviewed first. Promotion keeps both rows and records a
DedupNote(organism_mismatch)on thePromotionResult, also logged at WARNING. The comparison is ontax_id, so two spellings of one NCBI node are not a mismatch. - **Metadata on a reused row: the stored record wins, and nothing is thrown
away.** A null
uniprot_accession/genbank_idis filled from the incoming record; a different value is recorded undercross_references.alternate_uniprot_accessionsrather than substituted (structures and enrichment records are keyed on the column).sequenceis never touched — ruling R-1 makes it the deposited record verbatim, and a match means the two already agree.
Added
proteins.synonyms(Alembic0014_protein_synonyms), modelled onmicroorganisms.synonymsdown to the FTS weight 'B' and theto_jsonb(row)->>'synonyms'trigger idiom. Sequence dedup made it necessary rather than nice: one 261-aa Thermobifida fusca chain is published asTfH,TfCut2,TFHandTfC, and folding four spellings onto one row while keeping one of them would delete three terms a reader of those papers will type. Promotion appends and never renames the row. It is deliberately not an identity key — nothing resolves to a protein through it, so a wrong synonym costs a spurious search hit and never a merged record.scripts/report_duplicate_proteins.py— read-only detector for duplicate groups in either frame. There is no--apply: it has no write path, opens the sessionREAD ONLY, and disposes its engine in afinally.
Fixed
- Nothing about visibility. Stated because it is the constraint: promotion
writes neither
visibilitynor anyvisibility_hold_*column on a reused row, in any branch. Six of the 29 groups straddle the embargo, so a public paper's record folding onto one of the 291 held proteins is now an ordinary event —tests/test_pg_protein_dedup.pypins that the held row stays held, that its hold columns are byte-identical afterwards, and thatquery.public_visibility_filterstill excludes it.
Not done
- The existing 29 groups are not merged. Detecting them is in scope;
consolidating them is not — 63 of the 64 rows carry evidence no other member of
their group carries, so a merge has to union two evidence spines, re-home
plasticdb_ids that are citable, and decide six embargo straddles. That needs its own tool and its own review. This change is forward-looking only.
Version 1.13.2
Changed
- The release snapshot streams instead of materialising the database (#271).
generate_snapshotheld the whole corpus in memory roughly three times over — every ORM entity, a second copy as flat row dicts, and a third as onejson.dumpsstring — and protein and variantsequencecolumns were resident in all three. That was tolerable while the snapshot ran monthly; since v1.9.0 it runs weekly and unattended insideweekly_dataset_release, in a worker pod that also runs DIAMOND alignments, where an OOM takes the release with it. Every entity is now read through a server-side cursor in batches ofSTREAM_BATCH_SIZE(250, the same bound/api/v1/downloadsuses) and written straight into its file. One pass per entity feeds the CSV, thesnapshot.jsonfragment and — for proteins — the FASTA, so the tarball still costs the same six SELECTs.dataset_fingerprintstreams the same way. On a corpus the shape of production (867 proteins, 1308 microorganisms, 6526 interactions, 17323 experiments, 477 papers), peak Python allocation drops 56.5 MB → 15.7 MB and process RSS above baseline 117 MB → 37 MB. The point is the slope, not the number: at four times that corpus the buffered implementation peaks at 214.0 MB (RSS +450 MB) while the streaming one peaks at 17.2 MB (RSS +41 MB) — 3.8× versus 1.1×.dataset_fingerprintmoves the same way, 56.5 MB → 16.4 MB. The artefact is byte-identical. Every member of the tarball — all six CSVs,sequences.fasta,snapshot.json,README.md— matches the buffered implementation's output byte for byte on the same input, anddataset_fingerprintreturns the same digest, so this release does not mint a DOI for a dataset nobody edited.snapshot.jsonis reassembled to the exact shapejson.dumps(..., indent=2)produced, empty-list rendering included. The visibility filters are untouched.public_visibility_filter,variant_parent_public_filterandpublic_actor_interaction_filterare applied by the same statements, now collected in one_ENTITY_SPECStable that the CSVs,snapshot.jsonand the fingerprint all read from, so there is one place a row set can be defined. New pg tests pin this down by row set rather than by count:test_streamed_snapshot_is_byte_identical_to_the_buffered_oneruns a transcription of the old implementation alongside the new one and compares bytes, andtest_streamed_snapshot_excludes_staged_embargoed_and_private_togetherseeds embargoed, private, staged and tombstoned records in one corpus — the case where a predicate lost between batches leaks some rows rather than all. Note for anyone streaming ORM rows elsewhere.Result.unique()is not used: over a streamed psycopg2 result it silently drops rows — the ORM's uniquing key for a mapped entity isid(obj), and a streamed result lets CPython reuse the addresses of collected batches, so later rows collide with dead ones. Measured here it returned 724–1975 of 3468 proteins, differing run to run; the download routes' asyncpg path did not reproduce it, so it is a hazard to avoid rather than a defect to fix there. Uniquing is preserved by comparing against the previous primary key, which every statement orders by, at O(1) memory, andtest_every_streamed_statement_eager_loads_with_selectinfails the build if an eager load ever becomes ajoinedload, which is the one thing that could make a parent row arrive twice. Nor is the batch size passed as ayield_perexecution option, though it is the same mechanism. Doing so records it on the top-levelQueryContext, andorm_pre_session_execmerges that context's execution options into every post-load queryselectinloadissues — so the sub-load inheritsyield_perand then trips over the.unique()it calls on itself, `Can't use the ORM yield_per feature in conjunction with unique()`. That leak is only reachable when ado_orm_executelistener is registered onSession, because only then doesSession._execute_internalrunorm_pre_session_exectwice and feed the first pass's merged options into the second (SQLAlchemy #13301, fixed after the 2.0.35 pinned here). v1.13.0 added exactly such a listener —_refuse_bulk_deletion_of_held_records, the guard that stops a bulkDELETEdestroying an embargoed row — and it took everygenerate_snapshottest in the pg tier down the moment the two met. Narrowing the listener does not help: it already returns immediately for anything that is not a delete, and the registration alone is what changes the execution path, so the only way to fix it there would be to delete the guard.stream_resultsplusResult.yield_perbatches identically — measured side by side on one corpus the two shapes peak at the same figure to the decimal, at both scales — and never reaches a sub-load.TestStreamingSurvivesTheDeletionGuardpins it down in the mock tier, where a branch carrying only one of the two changes can still see it.
Version 1.13.1
Fixed
- A newly promoted protein no longer lands with a NULL signal peptide. The
2026-08-29 approval minted 61 proteins with usable sequences and every one of
them had
has_signal_peptide = NULL. That is not cosmetic: the profile-HMM pipeline clusters on the mature chain when a signal peptide is known, so those 61 were clustered as precursors against 577 incumbents clustered as mature chains — a representation mismatch nothing reported, which invalidated a Phase 1 run.scripts/backfill_signal_peptides.pyclosed the gap by hand (391 True / 247 False / 0 NULL); this closes it for good. Newapp/workers/signal_peptides.py, reached two ways:- On promotion. The curator approve route and the admin accept route each
dispatch
predict_signal_peptidesfor the proteins that promotion created, after the commit — the worker is a separate process and cannot see a flushed-but-uncommitted row. Not inline: the default predictor is EBI's hosted Phobius at ~12s per sequence, so a 61-protein approval would have been a twelve-minute HTTP request. - On a schedule. New daily
sweep-signal-peptidesbeat task (05:35 UTC) predicts for a bounded batch (SIGNALP_SWEEP_LIMIT, default 40) of rows that still have none, newest first — the opposite of the weeklyenrich-all-proteinsbatch, which walks every protein inidorder under a four-hour soft time limit and therefore reaches a freshly minted row last, if at all. That is part of why the gap persisted. Three rules the implementation holds, each with tests: - Both columns, or neither.
signal_peptide_cleavage_siteis whatsequence_framestrims on and what the reference FASTA is built from, so the pair is written in one place. A positive call whose cleavage site could not be parsed is booked as a legiblepermanentfailure rather than stored asTruewith a NULL site. - Failure is never written as
False. A failed prediction leaves both columns NULL and records why on the protein'sProteinEnrichment(source='signalp') row, which the admin enrichment coverage table already shows. Transient failures (the two EBI 500s the backfill hit) are retried by the task and then by the sweep; a row that reaches the retry cap or is terminally unpredictable is named in a Slack line, so a protein nothing will retry is visible rather than merely absent. - **One protein, one short transaction, and no transaction open across a
network call.** The batch form of this deadlocked in production against the
scheduled
reindex_search_vectorsbulk UPDATE and killed a run at record 140 of 577; writes go throughcommit_with_deadlock_retry. Filling a staged record does not invalidate the public DIAMOND databases:reference_build.current_fingerprinthashes onlyvisibility='public'rows and promotion createsstagedones, so approval triggers no rebuild. Pinned in the pg tier, with a control proving the fingerprint does move for a published row.
- On promotion. The curator approve route and the admin accept route each
dispatch
Version 1.13.0
Fixed
- **A record under a visibility hold can no longer be deleted — by the supersede
path or by anything else (#463).** On 2026-08-29 a bulk approval of the pending
review queue moved six ingestions to
superseded, and five proteins from a deliberate 296-row embargo (bulk_negative_results, batch3ace0ee3-…) went with them. The batch is 291. They were not tombstoned, not released, carry nomerged_into_accession, and left no audit row: the loss had to be reconstructed from row counts and id ranges. The mechanism was indirect, which is why no review caught it.ingestion_supersede.retiredeletes the evidence a retired revision stamped (delete_prior_ingestion_rows), then runs the global orphan sweep (delete_orphaned_organisms_and_proteins), whose entire test for "this row is rubbish" is referenced by nothing. For an embargoed protein whose interactions have just been retired, that test is satisfied and wrong. Nothing anywhere asked whether the row was held. Three layers now, each independently load-bearing:app/services/orphan_cleanup.pynever sweeps a held row. Held orphans are left standing and reported back underheld_proteins/held_microorganisms, so the superuser paper delete and the ingestion withdrawal keep working and simply leave the embargoed record in place. AProteinVariantwhose parent is held is spared with it — a variant carries no hold of its own and is gated on its parent's visibility, so deleting it would destroy part of the held record while leaving the hold looking honoured.ingestion_supersede.retirerefuses the whole correction (SupersedeRefused→ 409) when the sweep had to spare something. It aborts rather than reporting: the retire runs inside the approve route's single transaction and commits nothing, so the raise rolls the promotion, the deletes, the re-stamps and the status move back together and the paper returns to the queue. Proceeding would have spared the protein row while keeping the deletion of every interaction, experiment and observation behind it — an embargoed husk with nothing left to publish when the embargo lifts, which is a quieter loss rather than a smaller one. The 409 names the accessions so the curator can release the hold or re-assert the record.- A guard at the flush boundary, beside the existing parking guard in
app/models/models.pyand keyed onVisibilityHoldMixinthe same way, so a third holdable entity is covered the day it declares the mixin. Two listeners, because the two deletion idioms take different routes:before_flushforsession.delete(obj),do_orm_executefordb.execute(delete(Model)…)— which never enters the unit of work and is the statement that actually destroyed the five rows.HeldRecordDeletionErroranswers 409 through a new handler inapp/main.py, so any door nobody has found yet refuses loudly instead of deleting quietly. There is no opt-out flag: the sanctioned way through is to release the hold first, and an exemption keyword would be another door with a nicer name. Also closes the laundering variant noted in the issue — a superseding revision could re-create equivalent content as a freshstagedrow carrying no hold, which the next weekly release would publish. It did not happen in this incident (verified: nostagedrow shares a sequence or accession with any held protein), but nothing prevented it; the record can no longer be destroyed for a replacement to take its place.
Changed
- The supersede now records what it destroyed.
retirereturns an identifier for every protein and organism the orphan sweep deleted, andrecord_paper_supersededstores them in thepaper_supersededcontribution event underrows_removed.deleted_entities. A deleted row leaves nothing behind — that is what deletion is — and the absence of any such trail is why #463 had to be reconstructed from counts.ContributionEvent.metadata_is JSON, so this is additive: no new table, no migration. **One entry per deleted row, always: the accession where there is one, otherwise#<id>.**Microorganism.accessionis nullable (onlyProtein.accessionisNOT NULL), so an accession-keyed trail would have been complete only as long as the data happened to be tidy — the same shape of assumption that produced the bug above. The numeric id is also the better forensic handle: id ranges are what pinned down the original incident once the accessions were already gone. The 409 refusal uses the same fallback, so a curator blocked by an accession-less record is given something they can act on rather than an empty string.
Version 1.12.2
Fixed
- **
--cut_ncwas unsafe on M0001: the library shipped a-1000000.00noise cutoff for a value nothing measured.** M0001, the nylon-amidase merge, has no definable NC — not one of its 5 324 N3 hard negatives scores above the reporting floor, so there is no highest-scoring non-member to read a cutoff from.THRESHOLDS.mdprinted—for it; the.hmmprintedNC -1000000.00 -1000000.00;, becausegathering_linesclamped an undefined cutoff to a floor. HMMER does not treat that as "undefined". It reads it as a noise cutoff every sequence on earth clears, sohmmsearch --cut_ncagainst the shipped library returned hits--cut_garejected, silently, and the ambiguity was visible only inside the model file: | M0001 vs |--cut_ga|--cut_nc(before) |--cut_nc(after) | |---|---:|---:|---| | N1 background | 0 | 3 | exit 1 | | N2 ecological | 0 | 6 | exit 1 | | N3 hard | 2 | 684 | exit 1 | | the 150 curated negatives | 0 | 2 | exit 1 | 684 is exactly the PF01425 relatives N3 excludes by construction — the tier inversiondocs/phase3-calibration.mdalready documents, arriving through a threshold instead of through a report. A cutoff that was not measured is now written as nothing at all, for all three columns rather than NC alone. Measured against the pinned HMMER 3.4 before choosing it, forhmmsearchandhmmscanalike: with the line absent,hmmpresssucceeds,--cut_gaand--cut_tcare unaffected, and--cut_ncexits non-zero and returns nothing — on the worker pod with the message NC bit thresholds unavailable on model M0001, though the exit code is the contract and the sentence is not: the3.4+dfsg-2build in the CI image prints a bareError:with the detail missing. The cost is that HMMER aborts at the offending model, so--cut_ncover the library now fails for all six profiles rather than for M0001 alone — which is the right trade against a search that answers a question nobody could have answered.hmmfetchstill gets you any single profile.--cut_gaand--cut_tcare unchanged for every profile against every tier: 70 model × query-set cells (six profiles + the whole library, against the seeds, the 150 curated negatives, N1, N2 and N3) re-measured before and after, zero moved. Five of the six calibrated profiles come out byte-identical; onlyhmm/M0001.hmmmoves.insert_gathering_thresholdsnow checks its own output and refuses to return a model carrying a cutoff that was not measured, so this cannot be reintroduced by a formatter edit.make hmm-smokere-measures HMMER's real behaviour in CI, because omitting the line is only safe while the toolchain keeps refusing rather than defaulting. Republished at/app/bioinformatics/phase3-2026-08-30/(PlasticDB.calibrated.hmm, its.h3i/.h3m/.h3ppress files,hmm/M0001.hmm,calibration.json,THRESHOLDS.md) withSHA256SUMSregenerated and verified; every other file in the artefact is untouched.
Version 1.12.1
Changed
- **Phase 3 of the profile-HMM pipeline re-run against Phase 2's six profiles —
and the answer is no.** Consumes
/app/bioinformatics/phase2-2026-08-30/; artefacts published to/app/bioinformatics/phase3-2026-08-30/(40 MB, including the UniProt cache so the negatives cannot drift again). Full write-up indocs/phase3-calibration.md, which keeps every baseline number alongside the new one. **The 638-protein corpus did not improve discrimination. Of the four profiles that existed at the 2026-08-27 baseline, two are unchanged to the last decimal place and the other two got worse.** | the same biological family | baseline GA / TC / NC | now | NC→TC gap | |---|---|---|---| | the big polyesterase family | F0001137.00 / 220.60 / 53.40|111.30 / 163.20 / 59.40| 167.20 → 103.80 (−38%) | | the second polyesterase family | F000292.80 / 161.30 / 24.40| unchanged | 136.90 → 136.90 | | the PHB depolymerases | F0003174.90 / 270.40 / 79.50|173.60 / 264.80 / 82.40| 190.90 → 182.40 | | the nylon amidases | M0001389.10 / 389.60 / —| unchanged | still untestable | |Lip_C(PF24708) | — | F0004299.80 / 553.50 / 46.00| 507.50 (new) | | the carboxylesterases (PF00135) | — (rejected at 3 seeds) | M0002192.90 / 328.60 / 58.90| 269.70 (new) | All six profiles recover 100% of their own members at GA. The whole gain is the two new profiles; nothing existing improved on any metric. This is the third phase in a row to return a null on the grown corpus, and the first where the direction is negative rather than flat. - F0001's 63-bit loss is the partition, not the new proteins. Its TC is set
by
PBE66YAPET-5 at 163.2 bits — a protein that was in the 577-corpus already, in a family that shipped no profile. Of the 13 members F0001 gained, six came from baselineF0008, one from baselineF0006and six are new arrivals, and the three weakest members of today's family are all in the first two groups. The re-clustering widened the family boundary, which is exactly what costs a membership threshold its margin. Nothing was tuned to recover it. - **The per-profile N3 exclusion is measured, and it is the number Phase 4 needs.** "Same superfamily, different family" removes a family's own Pfam relatives from its hard tier — correctly — so a 0.00% FPR against N3 is a statement about N3 and not about what a screen returns. Own-Pfam relatives removed / how many clear the GA fitted without them: F0001 2/2, F0002 65/56, F0003 18/0, F0004 20/20, M0001 684/2, M0002 163/130. F0003 is the only profile whose GA sits above every excluded relative.
- **M0002 is the first profile in the project with a non-zero false-positive
rate**: N1 0.12% — twelve mammalian carboxylesterases, cholinesterases and
neuroligins, the best at 370.9 bits, above M0002's own TC of 328.60. All
are PF00135, all were removed from N3 by construction, and N1 is unfiltered.
It is also the first profile whose cross-validation disagreed with itself
(MCC 0.975 ± 0.035 over 3 folds, against 1.000 ± 0.000 everywhere else).
--cut_gaon M0002 is a correct membership call and needs a report-level guard before it becomes an activity claim. - **§3.4 recomputed on the grown label matrix: Tier 1 = 4 (unchanged), Tier 2 = 11, Tier 3 = 38, and no polymer earns a calibrated confidence.** PET grew from 264/151 to 284/164 pairs and stays where it was — tier cleared, discrimination gate failed. Nylon 6 still misses Tier 1 by exactly one negative pair (24 positive, 9 negative — the baseline's two numbers); 61 new proteins contributed none.
- F0004 was calibrated against N0–N3 and never against its PET labels. Its
N3 correctly excludes PF24708
Lip_Cand the merges resolve to their constituent families, both by the existing code. Its MCC is flagged as a resubstitution score: all 16 members fall into one identity group at 40%, so no folds exist and its leakage is not measurable. Reported as such rather than as a clean 1.000.
Fixed
- (reported, not fixed — no code changed) **
--cut_ncis unsafe on an untestable profile.** M0001's undefined NC is written into the model as the-1000000.00sentinel and HMMER reads it as a real threshold:hmmsearch --cut_nc M0001.hmmover the 3 487-sequence N2 tier returns **6 hits where--cut_gareturns 0**.THRESHOLDS.mdprints—so the human artefact is correct; the.hmma tool reads is not. Phase 4 must use--cut_gaonly until theNCline is omitted for such profiles. - (curation, reported only — nothing written) **29 groups of byte-identical
mature chains cover 64 of the 638 protein rows**, 7 of them disagreeing about
at least one polymer verdict and **2 carrying an outright positive/negative
conflict on PET**: Phase 2's
PBUU2HLip4_120 /PBTXZSCt_Est, and a pair Phase 2 did not see —PB8XKWTfCut1 /PB5BTTTfcut2, two rows named for enzymes the literature treats as different proteins holding the same 261-residue mature chain with opposite PET verdicts. That one reads as a data-entry fault. None of it contaminates a threshold: TC is a minimum over identical-scoring chains,_corpus_exclusionskeeps every corpus sequence out of every negative tier (606 sequences, 231 accessions), each conflicting pair collapses onto one 90%-dedup representative so none straddles a fold boundary, and none of the eight accessions is among the 155 curated all-negative proteins.
Notes
- Determinism checked rather than asserted: the whole phase was run twice into
separate directories from the same cache. Identical tier digests, identical
fingerprint, identical per-profile thresholds, and a byte-identical
PlasticDB.calibrated.hmm(sha256:ea8bb78acf35fed4…). -Z/--domZare not passed, in this run or the baseline, and should not be: every threshold here is a bit score, and bit scores do not depend on database size.-Zrescales E-values only and would change no number in the document.- Read-only throughout. One database connection, opened by
export_label_matrix fetchon areadonly=Truepsycopg2 session and closed in afinally. Verified afterwards: 291 private proteins, allvisibility_hold_reason = 'bulk_negative_results'— unchanged — 466 public, 110 staged, 638 sequenced wild types, 0 sessions idle in transaction.
Version 1.12.0
Changed
- **Phase 2 of the profile-HMM pipeline re-run on the corrected 638-protein
partition — six profiles, not four.** Consumes
/app/bioinformatics/phase1-2026-08-30/(the mature-chain artefacts, not the 08-29 precursors); artefacts published to/app/bioinformatics/phase2-2026-08-30/. Full write-up indocs/phase2-profile-construction.md, which keeps every baseline number alongside the new one. 200 of 350 deduplicated seeds are inside a profile — 57.1%, against the 2026-08-27 baseline's four profiles over 173 of 310 (55.8%): | profile | seeds | TCS | M | eff_nseq | trim | vs baseline | |---|---:|---:|---:|---:|---:|---| | F0001 | 137 | 90 | 258 | 1.57 | 0.0% | +7 seeds, same model length | | F0002 | 18 | 80 | 178 | 1.36 | 0.0% | unchanged in every number | | M0001 (Amidase merge) | 15 | 82 | 434 | 1.80 | 1.6% | unchanged in every number | | F0003 | 11 | 83 | 217 | 1.25 | 0.0% | +1 seed, one column shorter | | F0004 (PF24708Lip_C) | 10 | 93 | 382 | 0.73 | 0.0% | new | | M0002 (PF00135 COesterase merge) | 9 | 85 | 492 | 1.39 | 0.0% | new — the baseline rejected this group at 3 seeds | Every profile recovers 100% of its own seeds with itself best-scoring, no in-family seed is missed (317 of 317), and **106 curated proteins are still reachable only through the non-profilephmmer/DIAMOND route** — 105 in the baseline, unmoved by 61 new proteins. Half the corpus is still the tail. The honest summary: **an 11% larger corpus improved no existing profile and added two new ones**, and more of the coverage gain comes from a merge that crossed a threshold than from anything the corpus discovered. - **The §2.3 TCS gate fired on nothing again — and
F0004, the family it was most expected to catch, scored the highest of all six (93 against a gate of 50).**F0004is the first gate-clearing family carrying its own measured negatives: nine PET-negative members against two PET-positive ones. It is an excellent homology profile — zero columns trimmed, only two cross-family hits — and it supplies no discriminative signal whatsoever:- 0 of 408 alignment columns separate the nine negatives from the two positives (200 random 9/2 splits of the same sequences: median 0, 95th percentile 2, max 8);
- pairwise identity negative-to-negative and negative-to-positive have the same median, 47.4%;
- nine profiles built from eight negatives each score the PET-positive members at the top of the held-out-negative range, not below it;
- one PET-positive member,
PBUU2HLip4_120, is a **byte-identical 372-residue mature chain** to the PET-negativePBTXZSCt_Est — two curations of the same enzyme, from two papers, disagreeing about PET. The 90% dedup therefore collapses it onto the negative side, leaving a seed-level contrast of eight negatives against one positive. Phase 3's N3 tier and Phase 6's rescorer must not be pointed at this family. What it can honestly support is §3.1's specificity report, not a cutoff. (Two further identical pairs —PBKNV6/PB4VN7andPBMKXJ/PBFAGC— carry conflicting PET verdicts too. Recorded for the owner; nothing was written.)
--strip-signal-peptidere-tested end to end and kept as the default. Phase 1's A/B said the mature-chain rule was costing a profile-eligible family (5 gate passes without the strip, 4 with). Carried all the way through Phase 2, the two arms give the same six profiles and the same coverage — 200/350 (57.1%) stripped, 201/351 (57.3%) not — because §2.0's trim-fragmentation repair puts the nylon amidases back together as fifteen seeds, three more than the untrimmed arm ever had. Four of the six profiles are identical between the arms; the two that differ both favour the mature chain (+2 TCS each, less end-trimming). Phase 1 was counting gate passes one stage too early.- **The trim-fragmentation repair is now worth 6.8 points of seed coverage and two of the six profiles** (50.3% → 57.1%), against 4.8 points and one profile on the baseline corpus. It found four candidate groups and admitted two; both rejections are recorded with the number that failed them and their constituent families restored to the tail.
- Determinism re-verified rather than assumed. The whole build was run a
second time into a separate directory: all six profile digests, all six TCS
scores and the concatenated library are identical —
sha256:3ac9e874e7b35d802a30d6bd304e530661d8aa97a96bae0fcd2f081092953478. docs/hmm-pipeline-plan.md§2 and §2.0 carry the re-run's status block and the updated repair figure; the baseline's block is kept unedited above it.
No code changed. This release is measurement and documentation: the same scripts/build_family_profiles.py and app/services/hmm_profiles.py the baseline ran, byte-identical, against a larger corpus. **Nothing was written to the database** — Phase 2 reads no database at all. Verified after the run: 291 private proteins, all visibility_hold_reason = 'bulk_negative_results', unchanged; 466 public / 110 staged; 0 sessions left idle in transaction.
Version 1.11.3
Changed
- **Phase 1 re-run a second time, on the corrected corpus, and the 2026-08-29
partition turns out to have been right.** The 1.11.2 re-run flagged a defect in
its own inputs: all 61 newly approved proteins carried
has_signal_peptide = NULL, so they were clustered as precursors while the 577 incumbents were clustered as mature chains, and it could not say how much of its +14 families was that artefact. The SignalP backfill has since run against production — §1.1 acceptance goes 577/638 = 90.4% → 638/638 = 100%, 391 true / 247 false / 0 NULL, 0 true-with-no-cleavage-site — and Phase 1 was repeated with the same code, tools, panel and parameters. The two corpus dumps differ in exactly 61 rows and only in their signal-peptide columns: one variable. The corrected partition is the uncorrected partition. Compared as a set of member blocks the two are identical — 108 families, same membership, zero splits, zero merges, zero proteins changing family; the 90%-dedup grouping matches too. Seeds 483, post-dedup 350, gate passes 4, seed coverage 50.3%, largest family 137 (39.1%), 78 singletons (72%). **Of the +14 families the larger corpus produced, 0 were the precursor artefact and 14 were real**, and the fourth gate pass is real. Everything 1.11.2 rested on survives:F0004still clearsn ≥ 8at 10 seeds / 16 members on PF24708Lip_C, still with nine PBAT/PBSA-positive, PET-negative members — still the only gate-clearing family with a large internal negative set.F0012(RoxA/RoxB rubber oxygenases, 5 seeds) andF0017(Lcp, 4 seeds) both still exist at the same size and still miss the gate. - **Catalytic-domain detection re-measured on properly trimmed mature chains: no movement whatsoever.** 584 / 638 (91.5%), the same 54 undetected proteins protein for protein, and not one of the 584 changed its best-scoring panel profile. The arrivals still detect at 47/61 (77.0%) against the incumbents' unchanged 537/577 (93.1%), so the 1.6-point fall from the baseline is a panel-coverage gap in the new material and not a trimming artefact. The accessory FASTA is byte-identical to the 08-29 run's.
- Why the artefact was worth nothing, measured. The signal-peptide cut and
the domain-envelope cut are both N-terminal and the envelope is nearly always
deeper: for 552 of 638 chains it already begins after the cleavage site, so
stripping first is a no-op. 40 proteins gained a signal peptide but only **17
of 483 seed sequences changed**, 11 of them by 1–6 residues at an envelope
boundary. The six RoxA/RoxB chains lost 26–34 residues each — the panel finds
no domain in them, so the strip is their only trim — and
F0012did not move. A control export leaving all 391 signal peptides on does distort the partition (351 dedup seeds, 107 families, 5 gate passes, 54.7% coverage), so the mature-chain rule still earns its place; 61 untrimmed rows was simply too few to bend a 638-protein clustering. - Determinism control repeated: four consecutive
clusterruns on identical inputs give a byte-identicalfamilies.json, md565970c68aad56c1ac3d16da846e1f2ee, 108 families and 4 gate passes every time. Thecanonicalise_fastafix from v0.95.3 still holds on the corrected corpus. - Phase 2 artefacts published to
/app/bioinformatics/phase1-2026-08-30/andwork/phase1/(git-ignored; they contain embargoed sequences and are never a deliverable — the release filter is Phase 8). **Phase 2 must consume the 08-30 directory, not 08-29**: the partition is the same but 17 seed sequences are now correctly trimmed, and a profile is built from mature chains. docs/phase1-seed-families.md,docs/phase1-catalytic-panel.mdanddocs/hmm-pipeline-plan.mdupdated so the corrected numbers are operative; the 2026-08-29 sections are marked superseded and kept verbatim, as is the 2026-08-26 baseline. No earlier measurement is edited or deleted.- Read-only throughout: SELECT statements only, through a session the server held
readonly. Novisibilityorvisibility_hold_*column was read for filtering or written. Verified afterwards — 291 private proteins all stillbulk_negative_results, and 0 sessions left idle in transaction.
Version 1.11.2
Changed
- **Phase 1 of the profile-HMM pipeline re-run against the grown corpus (577 →
638 sequenced wild types), and the result is close to a null.** The owner's
approval round added 61 proteins, all
visibility = 'staged'; the 220 embargoed and 357 public rows are untouched. No code changed — samehmm_seeds.py/export_seed_fasta.py/build_seed_families.py(md5-checked against the deployed image), same mmseqs and HMMER 3.4, same 29-profile catalytic panel, same parameters. The only variable is the corpus. Seeds 427 → 483, N3 hard negatives 150 → 155, post-dedup seeds 310 → 350, families 94 → 108, families clearing the Phase 2n ≥ 8gate 3 → 4, and the fraction of the deduplicated corpus inside gate-clearing families 51.0% → 50.3% — i.e. down. Singletons stay at ~73% of the family table and one family still holds ~40% of the corpus. **An 11% larger corpus bought one additional profile-eligible family and moved nothing else.** What did change is worth having: the new family isF0004(baselineF0009, 5 seeds, joined by 11 new proteins → 10 seeds, PF24708Lip_C), and it is the first gate-clearing family carrying a large internal negative set — nine members are PBAT/PBSA-positive and PET-negative, which is exactly the within-family contrast Phase 3's N3 tier and Phase 6's rescorer need. Natural rubber also enters the seed corpus for the first time, as two families (RoxA/RoxB rubber oxygenases, 5 seeds; Lcp latex-clearing proteins, 4 seeds), neither of which reaches the gate. The incumbent partition was not re-shuffled: 2 of 94 baseline families have members in more than one family now, and one merged. A Phase 2 profile built on the baseline partition is still built from seeds that are still together. - Catalytic-panel coverage re-measured: 537/577 (93.1%) → 584/638 (91.5%).
The panel is unchanged at 29 profiles and PF12740
PETase— the baseline's largest omission — is still in it and still the best-scoring profile for 368 of the 584 proteins that find a domain. The drop is entirely cohort-driven: the 577 incumbents score identically to the baseline (93.1%) and the 61 new arrivals score 47/61 (77.0%). Panel usage shifted where the corpus did —Lip_C(PF24708) 5 → 16 andAbhydrolase_5(PF12695) 0 → 7, the latter having been the best hit for nothing at all until this round.
Fixed
- Nothing in code. One defect found and reported, not fixed here: all 61 new
proteins carry
has_signal_peptide = NULL, so the SignalP enrichment has not run on the approval batch.signal_peptide_strippedis therefore still 351 — not one of the 61 could be trimmed to a mature chain — and Phase 1.1's acceptance bar now reads 577/638 = 90.4%, one approval round from failing its own > 90% threshold.scripts/backfill_signalp.pyshould run on the batch and Phase 1 be repeated before Phase 2 consumes this partition.
Notes
- Read-only throughout: the corpus was read once through a session the server
held
readonly, SELECT statements only. Novisibilityorvisibility_hold_*column was read for filtering or written at all;pg_stat_activityshows no idle-in-transaction session left behind. Every artefact lives under the git-ignoredwork/(build host) and/app/bioinformatics/phase1-2026-08-29/(worker pod) — they hold embargoed sequences, are never deliverables, and the release filter remains Phase 8. - Documentation:
docs/phase1-seed-families.mdanddocs/phase1-catalytic-panel.mdeach carry a dated re-run section. **The 2026-08-26 baseline numbers are kept verbatim** — the comparison is the point.
Version 1.11.1
Fixed
- Approving an ingestion that mints a new protein returned HTTP 500.
Production carried two hold-reason enum types at once:
microorganisms.visibility_hold_reasonwas onvisibility_hold_reason_enum(what the ORM declares, and therefore what asyncpg casts every INSERT parameter to) whileproteins.visibility_hold_reasonwas still onprotein_visibility_hold_reason_enum, the name 0010 gave it. EveryINSERT INTO proteinsraised asyncpg.exceptions.DatatypeMismatchError: column "visibility_hold_reason" is of type protein_visibility_hold_reason_enum but expression is of type visibility_hold_reason_enum out ofingestion_promotion._get_or_create_protein'sdb.flush(). The discriminator is whether the approval creates a protein row, and nothing else. Measured against the live queue on 2026-08-29: of 92 ingestions inpending_review, 46 would mint a protein and 46 would not, and the 19 ingestions a curator had actually tried and had 500 on were all in the first set — while all 36 that returned 200 promoted only organisms or reused proteins. ASELECTneeds no cast, so every read path stayed healthy and the split was invisible from the outside. The same fault silently broke the operator visibility hold on proteins, whichUPDATEs that column. Migration0013_reconcile_hold_enummoves every column still on the old type across to the shared one and drops it. The two types held **identical labels**, soUSING col::text::newtypeis exact — the 296 held proteins keep their reason, prior visibility, batch and audit trail, and no value changes. - The deployment order that produced it, so it cannot recur. 0011 renamed
the type and guarded the rename with "does the new name already exist?".
Three starting states were considered and the one that happened was not among
them: the migrate job runs
create_schema()before alembic, andmetadata.create_all(checkfirst=True)creates every missing enum type in the metadata even when it creates no table — anEnumwith no explicitmetadata=registers abefore_createlistener ontable.metadata. So the new type was conjured out of the ORM declaration before 0011 ran, 0011 saw it and returned, andproteinswas left behind.tests/test_pg_migrations.pywas green throughout because its_strip_holdsdrops the enum before replaying 0010 → 0011 — the one starting state in which the rename does fire. It now also replays the deployment's order, and ends on the literalINSERT ... NULL::visibility_hold_reason_enumthe outage was made of. 0011 is left exactly as it shipped: it has already run, and rewriting an applied migration would not change what is in the database.
Version 1.11.0
Added
- **Two
measurement_typeterms for quantities the vocabulary could not hold:depolymerization_extentanddegradation_half_life.** The vocabulary had 21 terms and not one of them was a dimensionless extent or a duration, so both went to the nearest-sounding term and corrupted it. Measured across 549 extractions:depolymerization_ratecarries **1055 observations in 100 papers under 100 distinct unit strings, and only 839 of them are rates**. 140 are dimensionless extents (%,% of PAM,% of wild type,% of WT,% of theoretical,% of control,fold) in ~20 papers; 29 are durations inhord— a number whose dimension is the reciprocal of the term it is filed under. Any cross-paper aggregation on that field is already wrong, and nothing downstream can see it: a float with a unit string is a float with a unit string.depolymerization_extent(enzymatic, default%) — how much of the polymer came apart, as opposed to how fast. Method-agnostic on purpose: NaOH consumed in a pH-stat titration, monomer quantified by HPLC and converted to conversion, or a gravimetric conversion all answer one question. The pH-stat is a real measurement of how much polymer was broken down — the base neutralises released carboxylic acids, so it is proportional to ester bonds cleaved — and it had no honest home. It is notweight_loss, which means mass lost from the specimen on a balance:10.1002/cssc.202501789reports "% PET depolymerization calculated from total NaOH addition" and "% direct PET weight loss after recovery, washing, drying and weighing" for the same condition, 42 observations under onemeasurement_type, so a reader cannot tell the titration number from the balance number (45 corpus-wide, with10.1021/ma9005318).degradation_half_life(enzymatic, defaulth; days are equally legitimate) — the time for half the polymer substrate to be degraded.10.1016/j.envint.2018.12.029reports 16 explicit nanoparticle degradation half-lives (t½, 3–97 h, wild type against variant) asdepolymerization_rateinh, and10.1038/s41529-023-00342-9reports first-order removal constants inday⁻¹with the half-life it derives stranded in prose. It is deliberately not the enzyme's thermal half-life, which already lives inExtractedProperties.half_lifewithhalf_life_temperature: one is how long the protein survives at a temperature, the other is how long the plastic survives the protein. That is the one pair in this vocabulary a single word could silently collapse, so both term descriptions say it, the documentation page carries a warning callout, and a test pins it. Both sit in theenzymaticgroup besidedepolymerization_raterather than under the assay that produced them. The group is a dropdown bucket, and the defect being fixed is a curator reaching fordepolymerization_ratewhen they mean an extent or a duration — three terms in three different buckets would preserve exactly that. No migration. The vocabulary lives inMEASUREMENT_TYPE_VOCABand is served from it; themeasurement_typestable is a lookup that_resolve_measurement_type_idget-or-creates on promotion, so an existing database acquires the rows the first time either term is promoted (or on the nextmake seed), and no live table is altered.
METRIC_POLARITYlearns both terms, and one of them inverts.depolymerization_extentis higher-is-better;degradation_half_lifeis lower-is-better — an enzyme that halves the polymer in less time is the better enzyme — which is the opposite ofhalf_life, the protein's thermal half-life, three lines away in the same namespace. Km is the other entry in that registry whose direction cannot be assumed, and it is there for the same reason.
Fixed
weight_loss's term description now says what it is not. A conversion computed from pH-stat titration or from released monomer isdepolymerization_extent"even when the paper prints the two side by side" — which is the case that produced the 42 indistinguishable observations above. Same fordepolymerization_rate, which had no description at all while carrying three different quantities under 100 unit strings.
Notes
- Two gaps found by the same sweep were deliberately not decided and are
recorded as deferrals in
REJECTED_TERMSwith their counts, for a curator ruling rather than a recount:specific_activity(22 observations in 8 papers filed asdepolymerization_rateinU/mg,U/mg protein,nkat/mg,kU/mg— a rate per unit enzyme, not per unit time, so the existing term is not lying about what the number is, only about what it may be compared with), andtime_to_conversion(10 observations of "reached 90% PET conversion in 27.4 h" — a duration to a threshold the paper chose, which is not a half-life and must not be absorbed by one).
Version 1.10.0
Changed
- The REST API page is reorganised around what a researcher came to do.
/documentation/apiopened with Conventions and then a nine-row Authentication table — an integrator's reading order, for an audience that is not an integrator. A scientist arrives wanting a sequence, an organism, a filtered export or a job, and met the auth model first and a<protein_id>placeholder second. The order is now enzymes → engineered variants → microorganisms → plastics, papers and pathways → search → bulk downloads → dataset releases → jobs → then authentication. Auth is not deleted, because a job genuinely needs a credential; it is scoped honestly to the three things that need one and put where you reach for it. The cookie/Bearer split is stated rather than implied: the API refuses the browser session cookie on purpose, because a cookie the browser attaches by itself is a CSRF hole, so being logged in to the website authenticates nothing an API client does. Every example is a runnable command againstv2.plasticdb.orgwith real accessions from the live database — IsPETasePBG5RN(numeric id 21), Pseudideonella sakaiensis tax id 1547922, PET as plastic 16 — and the whole page was verified against production before being written down. - Weekly dataset releases are documented where users will look for them.
GET /api/v1/releases/currentreturns the version, the DOI and a ready-madecitationstring; the page now says to recordversionin a methods section and quotecitationverbatim, and explains why a quiet week's release carries an earlier release's DOI./releases,/releases/{version}and the three/snapshotsroutes go with it.
Fixed
- Documentation that described endpoints we removed.
README.mdstill carried the "Verified subset (WS07)" block:GET /api/v1/downloads/verified-set, arelease=parameter and anX-PlasticDB-DOIresponse header, all removed in #248, plusapp/services/verified.py, which no longer exists. The endpoint 404s in production. It was also the only place the README described the release/DOI surface, so a reader asking how to cite a version was sent to a dead endpoint — while the correct### Dataset releasessection sat directly beneath it. (#270) - Five wrong claims on the API page, each found by reading the routers
rather than the previous copy:
/searchwas said to search variants (it covers microorganisms, proteins, plastics and papers — there is no variant scope);GET /jobs/{id}was marked auth-required (it isOptionalUser, so an anonymous job is readable by anyone holding the id);POST /submissions/lookup-sequenceand/validate-sequencewere marked auth-required (both handlers take no user dependency); "list endpoints cap at 500" is wrong for/search/grouped(100) and/releases//snapshots(200), and/papersdefaults to 20 rather than 100; "entity detail routes are not rate-limited" is wrong for/variants/{id}and/engineering/proteins/{accession}, both 200/hour.
Added
- The public surface that existed but had never been written down. The
release and snapshot routes above;
GET /jobs/{id}/download;/search/grouped;/microorganisms/names;/microorganisms/accession/{accession}, which is the only address an organism with no NCBI tax id has;/microorganisms/taxonomy/{rank}/{name};/proteins/accession/{accession};/variants/{id}/structure; the four/pathwaysroutes; and all seven/bioinformatics/*job endpoints, which had never appeared on the page despite being the JSON path built for scripted access. The page now also links the generated schema at/docs,/redocand/openapi.json— it previously referred to "the OpenAPI schema below" without ever linking one. - A copy button on every example. Bound with
addEventListenerand delegated from a single container, on a nonce-carrying script with noon*=attribute anywhere, so the page keeps the strict CSP and the inline-handler ratchet does not move. The button carries anaria-labeland its own:focus-visibleoutline at specificity (0,2,0), which clears sb-admin-2'sbutton:focus{outline:0}.
Version 1.9.0
Added
- Weekly dataset releases — the database now has a version you can cite.
Curation used to publish on approval: the rows a curator's approval created
took the
visibilitycolumn default,public, and were in the DIAMOND reference databases and the next Figshare snapshot within the hour. That is a continuously moving corpus. A user who ran an alignment on Tuesday and again on Friday had no way to say what they had searched either time, and no way to get Tuesday back. The model is now inverted. Curation stages; a release publishes. Approval writesvisibility='staged'— a fourth rung on the entity visibility enum, and therefore invisible to every read path in the codebase on the day it was added, because they are all spelledvisibility == 'public'. One scheduled task per week, Thursdays 06:20 UTC, turns the staged set public, rebuilds the reference databases from it, and cuts an archived snapshot. **The release version isYYYY.WWand is deliberately not the application version.**1.9.0says which code is deployed;2026.35says which data you aligned against. It is monotonic, sorts as plain text, and is the same string as the snapshot'srelease_label— so the version a user quotes and the archive they cite cannot drift apart. It appears in the footer of every page, inGET /api/v1/releases/current(with a ready-made citation and the DOI), and inside the archive itself. A DOI is minted only when the data actually changed. A newdataset_fingerprinthashes exactly the rows a snapshot would emit, through the same visibility-filtered builders, so a quiet week completes with no snapshot and no second DOI for an identical dataset. This replaces the monthly snapshot beat, which minted one on the 1st regardless. The archive itself is still built by the existinggenerate_snapshot_task— a second copy of that pipeline would be one more thing that can publish. **The release is built to be structurally incapable of publishing a withheld record**, which is the requirement the whole design answers: it runs unattended, its entire purpose is making records visible, and 296 proteins are currently held ahead of unpublished lab work. There is one query for what may be published —query.releasable_filter, placed immediately belowpublic_visibility_filterso the gate on what the public sees and the gate on what a release makes public are read together — and four independent things have to fail before a held record reaches a DOI. A hold does not annotate a record, it moves it, so a held record is notstagedand no query the release runs returns one;publish_recordre-checks every condition on the loaded object, so a superuser's id list is a narrowing and never a widening; thebefore_flushguard stays the last line rather than the only one; and an invariant counting records that are both held and public runs before promotion, inside the promotion transaction, and once more immediately before the snapshot. The release never touches a deposit's records — those stay withdeposit_visibility.records_to_publishand the superuser doors #417/#428 narrowed to, because a seventh leak of this class is likeliest to be born as a well-meaning second door. Failure and re-runs. Promotion is one transaction, so an interrupted release has published nothing. Each of the three stages is idempotent and the release row records which one it reached, so a re-run resumes rather than starting over: a failed DIAMOND rebuild leaves records correctly public and is repaired by the hourly rebuild beat anyway; a failed snapshot reuses this version's row rather than cutting a second. The unique version column is the lock — two beats in one week compute the same string and one finds the other's row. Operator controls, both of which can only ever show less or the same.POST /api/v1/admin/releasescuts an out-of-cycle release now, with its own citable version, publishing exactly what the scheduled run would have.POST /api/v1/admin/releases/publish-nowpublishes named staged records for a correction that cannot wait a week, through the same filter and guard. A new Dataset releases setting holds the weekly release back entirely. Nothing in the release path lifts a visibility hold — not the schedule, not a force, not a superuser — and a record that was staged when it was held returns tostagedwhen the hold is lifted, rejoining the next release on the operator's timing. Every run posts to Slack with the half that matters: what it declined to publish and why ("withheld — proteins: 296 under an operator hold"), which is the weekly proof, without anyone going to look, that a withholding is still in force. New:docs/dataset-releases.md(cadence, version scheme, citation, and the operator's view), a Dataset releases panel on the Downloads page, migration0012adding thestagedlabel toentity_visibility_enum, andtests/test_pg_dataset_releases.py— which has to be the PostgreSQL tier, because every rule it proves is aWHEREclause and a filter that silently stopped filtering would leave the mock suite entirely green.
Changed
- Records created by ingestion promotion are
stagedrather thanpublic, so a newly curated record appears on the next Thursday rather than immediately. The latency is accepted deliberately: a stable, nameable corpus is worth more than same-day visibility. Contributor submissions are unaffected in outcome — their rows take the deposit's visibility a moment later, as before. - The monthly snapshot beat is retired;
monthly_snapshot_triggerremains as a manual way to cut a datedYYYY-MMarchive.
Version 1.8.0
Added
- A curator surface for reviewing and releasing deposits (#429). #417/#428
made publishing a deposit a superuser act and closed three doors into a
privilege-escalation bypass. What it left behind was a right with nowhere to
exercise it: there was no listing of deposits anywhere in the product, so the
only signal that one was waiting was a nightly Slack line, and the only way to
act on one was to already know its accession. A permission that exists only as
a 403 is not a workflow.
/curation/depositsis the queue. It shows every deposit, most urgent first, with its records, its depositor, how long it has waited, and — the part no column could previously answer — *whether anything is going to happen to it on its own*. Twoembargoeddeposits with the same past release date have opposite futures: the one carrying asubmission_idwas sanctioned by a superuser at acceptance and the nightly beat publishes it, while the one without is waiting on a person, indefinitely. That second row is Awaiting curator release, and it is the queue's headline. Each row expands in place to the records it contains, flagged with the two things that make a release do less than it looks like it will: members the deposit merely lists (reused from the corpus, not its to publish — #430) and members under an operator visibility hold (the flush guard parks the write, so a release leaves them exactly where they are). The row states up front how many records a release would actually publish. The curator/superuser split is drawn deliberately and does not widen #428. A curator sees the whole queue — reading unpublished material is review, and restricting the list to superusers would mean the only person who can see the queue is the only person who can clear it. A curator's action is a recommendation:POST /deposits/{acc}/notesrecordschecked/recommend_release/needs_changes/recommend_rejectand a note as aContributionEvent, and its payload has novisibilityfield at all. Release stays with the superuser and goes through the route that already existed — this change adds no second publish path, since a seventh leak of the "something publishes what it should not" class is likeliest to be born as a well-meaning new door.POST /deposits/{acc}/withholdis the reject half, superuser-only for the mirror reason: it clearsrelease_date, cancelling a publication the depositor arranged and the beat would have carried out. It refuses apublicdeposit (un-publishing is a retraction, with exports and possibly a Figshare DOI already carrying the records), it only ever lowers, and it skips held rows via the newdeposit_visibility.records_to_withhold— writing to a held record's visibility would overwrite thevisibility_hold_priorthat its eventual release restores, which is how the 296 deliberately embargoed proteins would have come backprivateinstead ofpublic. - **
GET /deposits/minesays what state a deposit is in, not just its visibility (#429).** A depositor readingembargoedon a deposit whose release date passed last month could not tell whether the database had forgotten them or was still deciding.state/state_labelnow say "Awaiting curator release", which is a queue position rather than an embargo that never ends.
Changed
- The deposit maturity predicates (
due_deposits_stmt,unsanctioned_due_deposits_stmt) moved fromapp/workers/tasks.pyto the newapp/services/deposit_queue.py, so the curator queue and the nightly beat classify a deposit by one predicate rather than two copies of it.tasks.pyimports them; there is no behaviour change and no second definition.
Version 1.7.0
Fixed
- **The Organism Type control on annotate-gene / annotate-genome does something
(#342).** It was submitted, stored on the job row and displayed — and no worker
ever read it:
grep -rn organism_type app/workers/returned nothing, so "Gram-positive" and "No signal peptide" produced byte-identical jobs. Until v0.58.0 the About This Tool panel had additionally claimed SignalP was applied to the selection, which made the control worse than absent: it implied the analysis had accounted for something it had not. The predictor that was missing when the issue was filed shipped in v1.4.0, so this is wiring. Picking an organism now runs a **signal-peptide prediction over the query sequences that hit the database** — the question the tool is actually asked, since a plastic-degrading hydrolase has to be secreted to reach the polymer.signal_peptides.predict()takes anorganismand translates the form's vocabulary into each predictor's own argument: DeepSig's-kkingdom (euk/gramp/gramn, with archaea mapped togramp— DeepSig has no archaeal model and archaea are monoderms) and SignalP 6's--organism(eukaryaorother, its two models). **Phobius, the default, has no organism argument at all**: there the selection decides whether to predict rather than which model to use, and the job page says exactly that rather than over-claiming. The result is visible where the job is read: the organism type on the status card, a per-query Signal peptide column in the results table (Yes, with the cleavage site, or No), and a summary above the branch split so the "no matches" and file-backed branches report it too. The step can never fail a job. An unconfigured predictor, a BLASTX job (whose queries are nucleotide, and the predictors take protein) or a per-sequence error each settle as a stated reason in the results — the DIAMOND hits are what the user came for. A whole proteome is capped at its best-scoring queries (SIGNALP_JOB_MAX_SEQUENCES, default 50) because the default predictor is a rate-limited hosted service taking one sequence per submission, and a truncated run says so rather than reading as a complete one.
Version 1.6.1
Fixed
- **Accepting a submission can no longer publish a pre-existing private record
(#430) — the fourth door into the embargo bypass #417 closed three of.**
submission_promotion._cover_reusedattaches aProteinorMicroorganismthat was already in the database to the deposit the submission asked for. Two cases were already guarded (already public, already owned by another deposit); the third assigned the deposit's visibility to a row that was **private and owned by nobody** — so a submitter asking forvisibility='public'published a record that had never been curated for publication, silently, as a side effect of somebody else's acceptance. 296 proteins are currently under a deliberate operator embargo. A reused row is now never made more visible than it already is: it is recorded as a deposit member (so the deposit still describes the whole finding, and a reviewer holding the token still sees it) and its ownvisibilityanddeposit_idare left exactly where its owner put them.private → embargoedcounts as an escalation too — an embargoed deposit publishes what it owns the day its release date passes, so adopting the row would have deferred the leak rather than closed it. Every refusal logs, matching the two branches that already did. - **A deposit release now publishes the records it owns, not the ones it
lists.** The same bug with a timer on it, found while fixing the first half:
both release doors —
POST /deposits/{accession}/releaseand the dailyrelease_due_depositsbeat — iterated deposit membership, which includes every reused row. So the record_cover_reusedwas not allowed to relabel got published at release time instead, and a member still embargoed under another deposit went with it. Both doors now call one function,deposit_visibility.records_to_publish, which filters ondeposit_id. - **A community field suggestion can no longer carry
visibility,deposit_idor an accession.** Found by auditing the same pattern elsewhere.POST /annotationsis open to any authenticated account and takes a free-textfield_name/new_value; accepting one is a curator right, one flag below the superuser right thatassert_may_publishreserves publication to. The applier ended in a baresetattr(entity, field_name, value), so{"field_name": "visibility", "new_value": "public"}on an embargoed protein published it, anddeposit_idre-homed a row out of its embargo container. A short governed-field denylist now refuses both, and afield_namethat is not a column of the target model is refused rather than written as a stray Python attribute that never reaches the row. PATCH /annotations/{id}/reviewreturned 500 for every review. The blanketdb.refresh()after the write expired the eagerly-loadeduserthat the response builder reads, and an async session cannot lazy-load it (MissingGreenlet). The endpoint had never been driven from a route-level test; the refresh is now scoped to the columns it can meaningfully update.
Changed
app/services/deposit_visibility.pyis the one place the rule lives: theprivate < embargoed < publicladder,escalates()for cover time, andrecords_to_publish()for release time.DECISIONS.md§8 states both halves.
Version 1.6.0
Added
- **Phase 6.1 —
docs/ml_engine_design.md, the design documentapp/workers/ml_engine.pyhad referenced since it was written and which did not exist.** Training data, head gating from the computed evidence tiers, the feature set and the three things deliberately absent from it, the gates a head must clear, the weight storage layout and its atomic swap, the model card that doubles as the cache key, and the inference contract. It settles a shape problem nobody had written down: **MlEnginewas stubbed as aSequenceSearchEngine, and a rescorer is not a retrieval backend.** ASearchHitcarriespident,evalueandbitscore— three numbers with agreed meanings — and has no field that could hold "probably degrades PCL" without becoming the composite score Phase 4 and §6.4 both ban. The engine therefore keeps the signature, delegates retrieval to DIAMOND unchanged, and attaches a per-polymer annotation block; it never invents, reorders or suppresses a hit, and Phase 4'sCLASS_ORDERremains the only quantity that crosses the arm boundary. - **Phase 6.2 — the masked multi-label rescorer. Trained on all 903 labelled
(protein, polymer) pairs, measured with its own leakage reported, and
recommended for deferral.** Result document:
docs/phase6-ml-rescorer.md. Code:scripts/experiments/rescorer.py(fetch → features → evaluate),tests/test_rescorer.py(32 Tier-1 cases). Labels come fromapp.services.label_matrix.build_cells, not a second implementation, so the training set is the objectdocs/label-matrix.mddescribes.ENABLE_ML_ENGINEstaysFalseand no weights are written. The measurement the phase exists for. The sharpest question this corpus can pose is to train on every other study and predict the one that supplies 35% of every negative pair in the database — 347 proteins in, 210 held out: | PET, dominant-paper holdout | AUROC | 95% CI | AUPRC (floor 0.543) | MCC | |---|---:|---|---:|---:| | masked multi-label MLP | 0.621 | [0.54, 0.77] | 0.635 | +0.243 | | random forest | 0.597 | [0.51, 0.76] | 0.624 | +0.240 | | baseline — nn identity, pos − neg | 0.587 | [0.48, 0.66] | 0.603 | −0.061 | | baseline — best single feature | 0.516 | [0.44, 0.65] | 0.553 | +0.000 | | label-permutation null, p95 | 0.568 | | | | 0.034 of AUROC over a trivial baseline. Phase 0's verdict survives contact with four times the label set, a 650M embedding model, a masked multi-label loss and a study-level split. - Five things the phase settled, none of them a modelling result.
1. Report the leakage, then report the noise beside it. Phase 0's gradient
reappears — PET per-fold AUROC 0.699 at 28% median train↔test identity,
0.770 at 63%, 0.808 at 82% — but the fold-to-fold SD is 0.03–0.17, so
across four or five folds nearly every model-minus-baseline gap in the
experiment is inside one standard error. The honest conclusion is not "the
model is bad", it is "407 PET pairs cannot tell".
2. Study leakage is a second axis and no identity split can see it. At the
same achieved identity — 0.633 against 0.645 — the PET forest scores 0.770
under an identity split and 0.597 when the held-out fold is one study's
screen.
3. **A third gate, discovered by the run: a head's positives and negatives must
overlap in family space.
contrast_overlap()measures it. PHB's share none** — 17 of its 18 negatives are enzymes that degrade something else — and PCL's share 2 of 10. Their strong-looking numbers are family recognition, and a single profile bit score gets 93–95% of the model's discrimination on them. PET is the only head whose contrast set genuinely overlaps (9 shared families), which is exactly why its question is the hard one. 4. ESM-2 650M — the size §6.2 names, deferred by Phase 0 — changes nothing. Across 34 head × split × model comparisons: mean +0.011, median −0.001, 16 improved against 18 worsened. On the decisive fold the block costs 0.11 AUROC. 5. Pooling out-of-fold scores is unsafe on folds this uneven. On the paper split PET pools to 0.532 while every individual fold sits at 0.60–0.86. The gap is calibration, not discrimination; both figures are published rather than the flattering one being chosen. docs/is now copied into.github/test.Dockerfile. The guard thatapp/workers/ml_engine.py's design-doc reference does not dangle could not itself run in the job that blocks the merge. That reference dangled for the whole life of the stub, which is the entire reason Phase 6.1 exists.
Changed
app/workers/ml_engine.pyandapp/workers/engines.pynow say why the engine is off — no polymer head clears the discrimination gate — and point at both documents, rather than at "no model weights have been trained yet". TheNotImplementedErroris a design output, not an unfinished edge.docs/hmm-pipeline-plan.mdrecords status for §6.1–§6.4. Two of §6.4's four ranking steps are not implementable as written: aTM-score >= 0.5hard gate no ProstT5 hit can clear (Phase 5), and a primary sort on a predicted Tm that DeepTM has never been able to supply. The ban on a composite score is untouched and is now load-bearing in a second place.
Version 1.5.0
Added
- **Phase 5 — the structural tier. Built, measured against the curated corpus,
and recommended for deferral: it finds almost nothing the sequence arms
missed.** No discovery campaign was run. Code:
app/services/hmm_structure.py(pure logic, 45 Tier-1 cases intests/test_hmm_structure.py),scripts/structural_tier.py(weights → refdb → predict → anchors → search → fidelity → yield → verify → bench). Result document:docs/phase5-structural-tier.md. The measurement the phase exists for. Scored over Phase 1's 94-family partition, in both the production configuration (ProstT5 3Di → our reference models) and an upper bound this project cannot have for a novel screening hit (real structures on both sides). Universe: the 227 deduplicated seeds that hold a model, 10 512 true same-family pairs, 177 queries with a relative. | route | precision | recall | F1 | |---|---:|---:|---:| | profile arm (hmmsearch, 4 profiles, leaked in its favour) | 0.664 | 0.993 | 0.796 | | tail arm (DIAMOND) | 0.965 | 0.899 | 0.931 | | structural arm — production | 0.675 | 0.837 | 0.747 | | structural arm — upper bound | 0.676 | 0.841 | 0.749 | The structural arm is worse than DIAMOND on both, in both configurations, and real coordinates buy it 0.004 of recall. Its exclusive contribution over both sequence arms is 12 true pairs against ~1 850 false ones (0.6% precision) and 3 queries out of 177 — against 4 the sequence arms reach and it does not, so in the production configuration the tier is net −1. In the upper bound the TM ≥ 0.5 gate rejects 409 of 67 539 rows (0.6%), which is the plan's own "every α/β-hydrolase shares the fold" prediction landing as a number; in the production configuration no hit can be gated at all and all 29 282 arestructural_analog_unverified. - **ProstT5 evaluated first, as the plan requires, and adopted — but the plan's
§5 gate does not survive it.** Folding a novel sequence is not available to
this project (ESM Atlas: cached 110-mer ~1.5 s, novel one 504s at ~29.7 s,
repeatably; 128 rows still retry-capped), so ProstT5 was the only route worth
evaluating. It runs offline, needs no GPU, is **deterministic across thread
counts** — byte-identical 3Di at 1, 4, 8 and 12 threads — and reproduces the
structure's own 3Di string at a median 0.597 per-position identity over 227
chains fed their own coordinates' amino-acid sequence. What it cannot do is
produce coordinates, so **a TM-score cannot be computed for a ProstT5 query and
an active-site geometry check is impossible** — and asking for one anyway makes
foldseek die in
convertalis, the last stage, after the prediction and the whole search have been paid for. Hence two pinned output formats and adetect_format()that identifies a table by width.fold_gate()therefore passes a row that has no TM-score rather than failing it — failing it would report "below 0.5" for a quantity nobody computed — and such a hit is classedstructural_analog_unverified. In place of geometry,anchor_transfer()carries the reference's catalytic anchors through the foldseek alignment and checks the residues, namedcatalytic_residues_conservedand never "active site intact". - A third arm, and still no composite score. Phase 4 keeps the profile arm
and the tail arm apart because their bit scores are log-odds against different
nulls; a foldseek E-value is a third such quantity and a TM-score a fourth.
class_order_with_structure()derives the combined ordinal order from Phase 4'sCLASS_ORDERrather than restating it, inserting the structural classes belowseed_homologon the plan's own reasoning, and a test asserts every Phase 4 class survives in its original relative order. Ranking is numeric within an arm only. - Compute, measured and then extrapolated, with the two labelled. ProstT5 runs at 6.30 residues per CPU-second (measured over 227 chains at four threads, uncontended; 3.25 at twelve — thread scaling is poor) against the profile arm's 2.48 × 10⁶, a factor of 394 000. Applied to hits, which is where the tier belongs, that is 16 CPU-minutes for the 19 survivors of Phase 4's real sample — four minutes of wall clock at four threads — and is not a problem. Applied to an nr-sized catalogue it is 1.32 × 10⁷ CPU-hours. **Compute is not what defers this tier — yield is** — but it permanently fixes where the tier could ever sit in the cascade.
- Structure coverage published rather than assumed. 406 proteins hold a
structure — 120 experimental, 138 AlphaFold, 148 ESMFold. Among the 227
deduplicated seeds in the experiment, **71% of the reference models are
predictions**, and
evidence_matrix()measures what that does to the result: only 15.2% of classified hits are experimental-against-experimental — and 0.0% in the production configuration, where the query's 3Di is always predicted.structural_hits()takes the query side's provenance separately so that a ProstT5 string is never reported as an experimental structure merely because the same protein happens to have one on file. Every hit therefore carries the 3Di provenance of both sides, and its evidence kind is the weaker of the two — an ESMFold model against a ProstT5 string is two predictions agreeing with each other, not a structural measurement. - Determinism, verified so that it can fail. Two credible candidates, both
checked: fp16 ProstT5 inference reducing over a thread-dependent partition
(byte-identical), and foldseek's output order, which is a function of thread
scheduling — one digest
156d868b…across 1, 3 and 8 threads over three different files (canonical_rows()sorts into one total order;structural_digest()hashes the parsed rows, so a printf change is not mistaken for a result change). The Tier-1 suite asserts the digest is invariant across three permutations and that perturbing one bit score by 1.0 moves it — the negative control Phase 4's-Ztolerance check did not have.
Changed
docs/hmm-pipeline-plan.md§5 carries the status block and is flagged for rewriting: its gate ("TM-score ≥ 0.5, then check active-site geometry explicitly") is not implementable for the only query route available to this project.
Version 1.4.0
Added
- **Phase 4 — the screening pipeline. The cascade is built, proved on the frozen
fixture and on a bounded real sample, and benchmarked.** No discovery campaign
was run. Code:
app/services/hmm_screen.py(pure logic, 41 Tier-1 cases intests/test_hmm_screen.py),scripts/screen_sequences.py(antifam → orfs → plan → screen → report → bench → verify). Result document:docs/phase4-screening.md. pyrodigal -p meta (in process) → orfs.faa, partial genes flagged and kept hmmsearch --cut_ga AntiFam.hmm → every match discarded hmmsearch --cut_ga --domtblout -Z N --domZ N PlasticDB.hmm → the profile arm coverage / bias / length / Δbits → the §4 post-filters, in order diamond vs no_profile.faa → the tail arm diamond vs seeds.faa → nearest characterised neighbour + paper - **The two arms are reconciled, never merged — the central design decision of
this phase. 55.8% of the corpus sits behind a profile and 105 curated
proteins, a quarter of it, are reachable only by searching
no_profile.faa**. A profile bit score is a log-odds against that profile's null; a DIAMOND score is a log-odds against one curated sequence. There is no rescaling that makes them one number, so the report does not pretend there is: both arms run on every query, both are reported wherever both fire, ranking is by an **ordinal class** across arms and numeric only within one, andarm_overlap()publishes the split on every run. This is §6.4's ban on laundering unreliable numbers into a confident-looking float, applied one phase earlier. On the real sample the tail arm reached more than the profile arm did: of 19 queries reached, 8 profile-only, 11 tail-only, 0 by both. That settles a product question —/tools/hmm-screenhas to lead with the non-profile route rather than footnote it. - **Competitive Δbits works in raw bits and survives a family with no measurable
NC.** M0001's 5 325 hard negatives all scored below the floor, so its NC, its
NC→TC gap and its MCC are undefined. Any margin normalised against them is a
NaN for that family, which silently drops every M0001 hit or admits every one
with nothing in the output saying which. Δbits is therefore a difference of two
full-sequence scores from the same search over the same database with the same
-Z, the margin is a declared 3.0 bits (one octave of odds), and a family's own Pfam is excluded from its own confusable panel exactly as Phase 3 excludes it from that family's N3. Separability enters the label —family_member_uncalibrated, "read--cut_gahere as near-exact members only" — and never the arithmetic. A library with nocalibration.jsonbeside it degrades every hit to that class rather than up tofamily_member. - **
-Zand--domZare computed once over the whole input and passed to every shard**, including the single-process run the shards are validated against — everyhmmsearchin this phase is built by one argv function, because a one-process path that took-Zfrom its input while the shards took theirs from the plan would make the acceptance test compare two different searches and pass. Measured on the real sample: **four shards reproduce the single process row for row and E-value for E-value**, and a 4-shard full screen produces the same hit-report digest as the unsharded one. Without-Z, the same four shards produce E-values whose **median ratio to the correct ones is 0.2515 against an expected 0.25** — the classic sharding bug, in a table that concatenates perfectly cleanly either way.assert_comparable_z()refuses to merge shard outputs whose recordedZdisagrees, and the KubernetesJobis rendered from the plan object so the two cannot drift. - A hit is homology plus provenance, and never activity. Phase 0 measured
profile-HMM AUROC at 0.44–0.47 for PET — below chance — and no polymer earns a
calibrated confidence on this build, so every artefact leads with that sentence
and every row carries
activity_claim: none. The polymer wording is Phase 3's verbatim frompolymer_policy.json, which preserves the distinction that is easy to lose in a report template: PET reports *tier cleared, discrimination did not*, while PCL, PLA and PHB report tier cleared, discrimination not run. An unknown polymer gets Tier 3's wording — the weakest claim available — rather than silence. - The provenance chain reaches the paper. A hit resolves to its nearest
characterised member by DIAMOND against the curated seed set, and from there to
the per-polymer verdicts and the paper DOI behind them. On the real sample the
chain resolved on 21 of 26 rows, and the best hit is IsPETase recovered from
its own genome at 100% identity to
protein:21with its paper attached and without a word claiming it degrades anything. Phase 1 gains one stage,python -m scripts.export_seed_fasta provenance, because the seed header carries a paper count and a count cannot be cited; without the sidecar the report sayspaper_provenance: unresolvedrather than inventing a citation. make hmm-smokenow runs Phases 1–4 on the frozen fixture: it screens it twice, compares the hit-report digests, checks a 3-shard screen against the unsharded one, and pins that an unpinned-Zwould have changed E-values, so the check cannot quietly stop detecting the bug it exists for.
Measured
- Throughput is ~40× what the plan was budgeting from. Two independent
measurements on a 1.87 M-residue real ORF set (HMMER 3.4,
--cpu 4, CPU-seconds = wall × threads): 2.73 × 10⁹ residue·profile per CPU-second against the 4-profile library (1 100 model positions) and 3.36 × 10⁹ against AntiFam (36 150), versus the ~7.0 × 10⁷ in the plan. Extrapolated — and labelled as extrapolated — an nr-sized protein catalogue (3 × 10¹¹ residues, a stated assumption) costs ≈ 34 CPU-hours against the calibrated library. The profile search is not the bottleneck. ORF calling is: pyrodigal ran at 0.51 Mbp/s, ≈ 545 CPU-hours per Tbp of contigs. - **AntiFam discarded nothing on real bacterial ORFs, and costs 33× the search it
protects.** 0 of 5 612 ORFs from a published draft assembly, and 0 of 8 001
after cutting the same assembly into 1.5 kb fragments (which manufactured 2 389
extra ORFs, 75% of them partial, from 1.9% fewer residues). The seam
demonstrably fires — fed AntiFam's own seed sequences it rejects 319 of 320 —
so the zero is a measurement. Because an AntiFam verdict is a property of one
sequence,
--antifam-mode postapplies it to the hits instead of to every ORF for an identical surviving hit set at 1/1000 the cost;prestays the default because it is the plan's order, and the one difference is documented (inpostthe spurious ORFs are still inside-Z, so E-values are smaller while bit scores are identical). - Determinism, verified rather than assumed. Two independent screens of the
same 5 612 ORFs produced a byte-identical
hits.tsv. Phase 4's own non-determinism candidate is shard completion order, so nothing is consumed in arrival order: shard rows are merged into one canonical sort, ORF names are a pure function of contig and gene index, and the report digest is taken over the parsed rows so formatting cannot masquerade as a result change.
Fixed
- **The determinism harness had the phase's one real bug, and it was in the
check.**
verifycompared E-values with an absolute tolerance of 1e-12, which every pair of significant hits satisfies at 1e-86 — so it reported "an unpinned-Zwould not change E-values" while printing two differing digests beside it. A tolerance that passes on every real corpus is not a check. It compares the ratio now, which is also the informative number, and the fixture pins the expectation so the regression cannot come back silently.
Version 1.3.0
Added
- **Phase 3 — calibration. All four profiles now carry
GA,TCandNC, sohmmsearch --cut_gainherits our thresholds.** This is the differentiator: no other plastizyme resource holds curated negatives, so none can say what its models do on the hard cases. Code:app/services/hmm_calibration.py(pure logic, 47 Tier-1 cases),scripts/calibrate_profiles.py(fetch → negatives → fit → tiers → report). Result document:docs/phase3-calibration.md; per-profile deliverable:THRESHOLDS.md. | profile | members | GA | TC | NC | gap | CV MCC (folds) | |---|---:|---:|---:|---:|---:|---| | F0001 | 221 | 137.00 | 220.60 | 53.40 | 167.20 | 1.000 ± 0.000 (10) | | F0002 | 26 | 92.80 | 161.30 | 24.40 | 136.90 | 1.000 ± 0.000 (5) | | F0003 | 11 | 174.90 | 270.40 | 79.50 | 190.90 | 1.000 ± 0.000 (4) | | M0001 | 17 | 389.10 | 389.60 | — | — | undefined | An MCC of 1.000 here is a statement about how easy the question is. The best hard negative scores 137–191 bits below the weakest true member, so every threshold in that gap gives the same confusion matrix on every tier and the data cannot choose one — GA's precision is bounded by the width of the gap, not by the cross-validation. Reported beside its achieved leakage, as the house rule requires: **every held-out member of every family has a training relative at ≥25% identity**, which is the corpus Phase 0 described rather than a fold-assignment bug. False-positive rate at GA is 0.00% in all four tiers for all four profiles. - Four negative tiers, built and cached. N0 = 200 000 Altschul–Erikson
di-residue shuffles (implemented in-process and unit-tested to preserve every
dipeptide count exactly —
esl-shuffleis not in the pinned toolchain); N1 = 10 000 length-matched reviewed Swiss-Prot; N2 = 3 487 proteins of eight obligate parasites and host-associated bacteria; N3 = a 6 009-protein pool of the catalytic panel's families, filtered per profile to exclude that family's own Pfam. Onlyfetchtouches the network and it only writes to a cache, so a UniProt release cannot silently recalibrate the library. - **§3.4's polymer evidence tiers are computed from the label matrix in pairs and
emitted as
polymer_policy.json.** Tier 1 = 4 (PET, PCL, PLA, PHB), Tier 2 = 8, Tier 3 = 39. No polymer earns a calibrated confidence, because clearing a tier is necessary and not sufficient: PET clears Tier 1 on the best contrast set in this field and still reports "polymer undetermined" (Phase 0: AUROC 0.628 against a null reaching 0.561), and PCL/PLA/PHB clear their tier and have never been asked — which the artefact distinguishes from having failed. Nylon 6 misses Tier 1 by one negative pair and is promoted automatically when it arrives; a test pins that.
Changed
canonicalise_hmmgained an explicitallow_thresholdsargument. Phase 2 still refuses aGA/TC/NCline by default — fitting a cutoff there from the curated activity negatives is the tempting, quiet mistake — and Phase 3 is the one caller that passes it. Re-canonicalising after writing the three lines is what keeps the calibrated library byte-reproducible too.make hmm-smokenow runs Phases 1, 2 and 3, calibrates the fixture twice and compares the digests, and checks the Phase 2 guard in both directions.
Reported honestly
- M0001 cannot be calibrated, and the reason is structural. Not one of its
5 325 N3 negatives scored above the reporting floor, so NC, the gap and MCC are
undefined; the artefact reports
separable: null, nottrue. The cause is visible in a tier inversion the pipeline now flags: N1 — the easy tier — reached 256.9 bits and N2 reached 309.4 while the hard tier reached nothing, because M0001's family is coextensive with one Pfam family (PF01425), N3 excludes that family by construction, and unfiltered Swiss-Prot still contains it. There is no hard tier to build for such a family without calling genuine members non-members. Its GA falls back to 389.1 against a TC of 389.6 — "at least as good as the weakest member" — which is a documented limit, not a calibration. - **The specificity report against the 150 curated all-negative proteins cuts both ways.** 106 are hit at GA against Phase 2's 130 at E ≤ 1e-5, and the difference is not a specificity win: 28 of them are genuine family members that score above the reporting floor and below GA, so a calibrated search now misses them. Both columns are published. The count never enters the fit.
- Determinism verified, not assumed. Two independent fits over the same
profiles and tiers produce an identical
PlasticDB.calibrated.hmm(sha256:327bd360f82eb324…).
Version 1.2.0
Changed
- The build plan absorbs Phase 2's six measured amendments
(
docs/hmm-pipeline-plan.md). They were written up indocs/phase2-profile-construction.mdand deliberately not applied at the time because the plan file was owned elsewhere; all six are now folded in and the Phase 2 document records that.- §2.2 — T-Coffee expresso is out of scope, permanently. It is pinned in the image but was never run and never will be: D3 fixes the aligner at MAFFT L-INS-i for every family, and expresso fetches structural templates over the network, which a build step must not do. The structural evidence it was wanted for is spent in Phase 5, where foldseek already runs locally.
- §2.3 — the TCS gate does not do family surgery. Phase 1 predicted it
would fire on the 130-seed family and budgeted for splitting it. It scored
91 against a gate of 50 — the highest of the four profiles, and the
lowest score anywhere on the corpus is 80. TCS measures whether an alignment
is internally consistent, not whether a family is biologically one thing, and
130 real polyesterases sharing one α/β-hydrolase core align extremely
consistently even when they degrade nineteen different polymers. The gate
stays at 50; the expectation is corrected, in §2.3 and in both Phase 1 status
notes that carried it. The column-creep check on
Mis the one that would have caught over-broad clustering, and it also comes back clean. - §2.0 — the trim-fragmentation repair is now a named step, with its three conditions. Envelope trimming fixed the large families and broke one real 15-seed family; the repair is worth 4.8 points of seed coverage.
- §2 deliverables — the tail artefacts are first-class.
no_profile.faa/no_profile.tsvcover more of the corpus than the profiles do, and they now appear in the Phase 8 release layout besidePlasticDB-A.hmm.gz. - §2.4 — the 25% trim hard-fail never came close to firing (worst case 1.6%). Kept, and noted as not the binding constraint on an envelope-trimmed corpus.
- **Phase 7's fingerprint must include the family partition, the Phase 2/3
recipe constants and the pinned toolchain versions.** A profile is a function
of its seeds and of MAFFT, ClipKIT, T-Coffee and HMMER; a toolchain bump
that changes every profile must not leave the digest unmoved. The
VALID_POLYMER_NAMESswap is pinned to Phase 7 for the same reason — it needs a build artefact that nothing writes until the family build is wired intoreference_build.py.
- Phase 2's result is recorded as a status block in the plan, alongside Phase 0's and Phase 1's.
Version 1.1.0
Added
- **Phase 2 — profile construction. Four profiles over 55.8% of the corpus, and
a declared tail that is bigger than the profiles.**
hmmbuildnow runs per homologous family and never per polymer (D1):app/services/hmm_profiles.py(the decision logic, Tier-1 tested) andscripts/build_family_profiles.py(align → TCS gate → split ladder → trim → build → press → search back). Result document:docs/phase2-profile-construction.md. | profile | seeds | TCS | M | trim | recovery | |---|---:|---:|---:|---:|---:| | F0001 | 130 | 91 | 258 | 1.5% | 130/130 | | F0002 | 18 | 80 | 178 | 0.0% | 18/18 | | M0001 | 15 | 82 | 434 | 1.6% | 15/15 | | F0003 | 10 | 83 | 218 | 0.0% | 10/10 | Every profile recovers every seed it was built from, and for every seed its own profile is the best-scoring one — no seed is better explained by another family's model. Across the whole seed set 322 of 427 curated seeds are reached; all 275 inside a profile-bearing family are found. t-coffee13.41.0.28bdc39 joins the pinned toolchain, for §2.3's TCS gate and nothing else.t_coffee -evaluaterebuilds its consistency library from the sequences, so the gate runs offline; §2.2's **expresso is deliberately not run**, because D3 fixes the aligner at MAFFT L-INS-i and a build step must not fetch PDB templates over the network.- The no-profile tail ships as a named artefact pair, not as an absence:
no_profile.faa(137 tail seeds, headers intact) andno_profile.tsv(family, size, the exact reason there is no profile, and every corpus protein each representative stands for after the 90% dedup). **105 curated proteins — a quarter of the corpus — are reachable only through thephmmer/DIAMOND route**, so/tools/hmm-screenhas to lead with it rather than footnote it.
Measured
- **The §2.3 TCS gate did not fire, and Phase 1's expectation that it would was
wrong. The 130-seed family scores TCS 91** — the highest of the four,
against a gate of 50. TCS measures whether an alignment is internally
consistent, not whether a family is one biological thing: 130 real
polyesterases sharing an α/β-hydrolase core align beautifully even when they
degrade nineteen different polymers. The plan's other check agrees — the model
is 258 columns against a median trimmed seed of 258 residues, so there is
no column creep either. Keep the gate; stop expecting it to do family surgery.
Splitting it was measured anyway (
split-probe), and it is expensive: at--min-seq-id0.40/0.50/0.60/0.70 the family becomes 3/11/31/56 subfamilies retaining 98%/86%/58%/28% of its seeds. At 0.60 — the first rung giving more than two profile-sized pieces — 42% of the family falls into the tail. - The nylon amidases are re-merged, on evidence, under the same gate.
Envelope trimming had cut one genuine 15-seed PF01425 family into 6, 6 and 3
(v0.98.0, reported against ourselves). A group of families is a merge
candidate only when every seed shares one best catalytic Pfam accession,
every family is below the seed gate, and **the same seeds were a single cluster
before trimming** at the same identity and coverage — that last condition is
the evidence the trim did it, and it is why
pretrimexists. A candidate then faces the same TCS gate, the same 25% trim hard-fail and the samen >= 8as anything else. On the real corpus the rule finds two groups and admits one: M0001 (TCS 82, M = 434 — the full Amidase envelope, not a fragment — 15/15 recovered, zero hits outside itself) and rejects M0002 as too small. Seed coverage 51.0% → 55.8%; that merge is the entire improvement. - Nothing else moved the other 44%. Sixty-nine of the 88 tail families are singletons, and Phase 1's panel widening — the intervention meant to dissolve them — left 64 of 76 in place. The tail is permanent at this corpus size.
Changed
scripts/build_hmm_profiles.pyis marked SUPERSEDED and warns on every run. It groups by thepolymer=header tag, which is the architecture of the tool this project is trying to beat: F0001, F0002 and F0003 carry 19, 19 and 21 distinct polymers, so a polymer-grouped build cuts each into that many fragments and reassembles them across non-homologous families by label. It is not deleted only becauseapp/workers/pipelines.pystill reads thePlasticDB.hmmit writes; it goes when Phase 7 wires the family build intoapp/workers/reference_build.py.make hmm-smokenow runs Phases 1 and 2 — twice. The fixture's profiles are built into two directories and their digests compared, which is the determinism claim checked rather than asserted. It is still thebuildjob in CI, somafft,t_coffee,clipkitandhmmbuildnow execute on every pull request.
Fixed
- Two ways a profile build silently fails to be reproducible, both live.
hmmbuildstamps a wall-clockDATEinto every model it writes and records the absolute path it was invoked with inCOM— through a temp directory, that differs on every single build. Either alone guarantees two builds of the same seeds under the same pinned toolchain produce different files, which makes "is this release reproducible?" unanswerable by comparing checksums.canonicalise_hmmdrops the date and rewritesCOMto the logical command. Verified: a second independent build of the whole corpus reproduces all four digests. - T-Coffee rewrites
protein:412toprotein_412inscore_ascii, so its per-sequence scores came back keyed on an identifier matching nothing else in the pipeline — silently, because the overall score is still correct. Those per-sequence numbers are the diagnostic that names which members drag a family below the gate.parse_tcsundoes it.
Deliberately not done
- No calibration. Profiles carry no
GA/TC/NCandcanonicalise_hmmraises if one appears. The tempting move is to fit a cutoff from the 150 curated negatives sitting in the Phase 1 output; it would encode "does not degrade PET" as "is not a member of the polyesterase family". Those are activity negatives and most are genuine family members — **130 of the 150 are hit by these profiles, which is correct behaviour**. Phase 3 fits the cutoffs against all four negative tiers. - No discovery campaign. The profiles were built from the curated corpus and searched back against it. Nothing was screened against nr or MGnify.
Version 1.0.0
Added
- Matrix B — the engineered-variant Δ dataset. The discovery label matrix
excludes engineered variants on purpose: a parent and its mutants are 99%+
identical, and Phase 0 measured AUROC climbing 0.63 → 0.825 on nothing but
train/test identity overlap. But variants are the only controlled
comparisons the corpus holds — same scaffold, a few substitutions, same assay,
same figure — so they now get their own dataset with their own unit:
(parent, variant, polymer) → Δ, fold change, direction, built by
scripts/export_mutation_effects.pyonapp/services/mutation_effects.pyand written up indocs/mutation-effects-dataset.md. Like the seed export,fetchis the only stage that touches the database, it opens the connectionreadonly=True, and it never reads, writes or filters onvisibility— these are build artefacts, the embargoed rows are a large share of the measured negatives, and the release filter is Phase 8. The exporter refuses to write into a snapshot directory. resolve_parent_frameverifies a coordinate frame instead of trusting one.ProteinVariant.numbering_offsetindexes the variant's stored sequence and is not guaranteed to index the parent's. Each candidate offset — declared, identity, ± the signal-peptide cleavage site — is applied to the parent sequence and accepted only if every mutation's wild-type residue is found where it claims to be; a variant that resolves on nothing is exported withframe=unresolvedand no coordinates at all.BbPETase-CDis why: its declared offset of −156 correctly indexes its own catalytic-domain construct and puts P207 on a cysteine of the parent. Trusting the column would have mis-placed nineteen mutations across five variants, each onto a real residue of the right protein, without raising anything.- **Stage 3 prototype: measured substitutions projected onto a family-reference
column**, so a novel profile hit can be told *"this position is known to have
been engineered, and you carry the wild-type residue."* Pairwise
Needleman-Wunsch with BLOSUM62 and affine gaps, written out rather than adding
biopython to the image for a 24×24 table. 697 family positions over 15
families; only the 116 with single-mutant evidence are labelled
attributed.
Measured
- **332 matched (parent, variant, polymer) triples — the feasibility question is
answered yes. 959 Δ rows over 300 variants, 42 parents, 43 papers**, of
which 156 variants are single mutants, from a corpus of 745 variants over
85 parents. Relaxing temperature, pH and duration from the condition key gives
1341 rows over 395 variants. Every one of the 2742
Mutationrows is fully structured — there is no free-text-only mutation problem. - The kinetics tables cannot carry this dataset and the observations can. Of
959 rows, 859 come from
observations(monomer_released491,activity_detected209), 83 fromenzyme_properties(61 of them Tm) and 17 fromenzyme_kinetics. A kcat/Km-based Matrix B would be a table of seventeen numbers. - Matrix B is a PET-engineering dataset with a fringe, and says so: PET 218 of 300 matched variants, then PHB 30, PBAT 13, PLLA 13, PCL 11. Directions run 537 improved / 358 reduced / 36 unchanged — the reduced rows are a label no public dataset carries.
- The highest-yield curation target is unit and duration normalisation. 308
variants carry a measurement but find no wild-type twin. The exporter
diagnoses each failure field-by-field into
summary.json: 1170 unmatched measurements come from papers that reported no wild type of that parent and metric at all (an unrecoverable floor), but **685 have one and disagree on a condition field — 369 of those on nothing butunitand/orduration**. That is a normalisation pass, not a re-extraction, and it would grow the dataset by roughly a third. - Slightly over half the variant corpus hangs off an embargoed scaffold. The label matrix established that the public subset carries zero curated negatives for any polymer; Matrix B is a different table, so it was measured rather than assumed. **397 variants over 36 held parents against 348 over 49 public parents** — the concentration runs the wrong way because the deeply engineered scaffolds are the ones under hold. A reconstruction from published data loses roughly half the (parent, variant, polymer) triples and all of the surrounding negative evidence. Recorded as what a competitor could rebuild, not as a justification for the embargo: the release filter is still Phase 8.
Version 0.99.0
Added
- **Matrix A — the label set is a (protein, polymer) matrix with three states,
not a list of positive and negative proteins.** The plan counted "577
positives / 283 negatives"; both are counts of proteins, and a label lives
on a pair. **47 proteins in this corpus are positive on one polymer and
negative on another**, so a protein-level label destroys one of those two facts
and does not say which. The evidence spine has always stored it correctly
(
interactions.protein_id×plastic_id×result) — only the ML framing collapsed it.scripts/export_label_matrix.py+app/services/label_matrix.pyun-collapse it; written up indocs/label-matrix.md. 903 labelled pairs: 620 positive, 273 negative, 10 conflicting, over 557 wild types and 51 polymers. The states are positive / negative / untested, and untested means absent or masked, never zero: 903 of 29 427 cells were ever measured, so imputing the rest as negatives would fabricate 28 524 labels and teach a model the literature's attention rather than any property of an enzyme.label_matrix.tsvcontains no untested rows; the wide grid leaves the cell empty and says so in its header. Four exclusions, each measured rather than argued. **Engineered variants are out entirely** — 745 variants of 85 parents at 99%+ identity, and Phase 0 measured AUROC climbing 0.63 → 0.73 → 0.825 purely as train↔test identity rose 28% → 63% → 81%, so admitting them makes any identity split meaningless. Variant-only evidence does not propagate: 41 cells where the wild type was never tested but a variant was stay untested, carrying a pointer. Organism-level evidence is out (3 476 of the spine's 5 735 interactions). Conflicts are kept as conflicts — all 10, with both arms and both sets of conditions; in none of them does a condition set recur on both arms, so a majority vote would have deleted a real distinction.
Measured
- **Every negative pair in the database is on an embargoed row — for every
polymer, not just PET.** Phase 0 established this for PET's 147; across all 51
polymers the public subset is 456 positives and zero negatives, against 273
negatives and 10 conflicts under a hold. A matrix built after a visibility
filter would not be a smaller matrix, it would be a matrix with one class and
no way to notice. The build reads the full corpus read-only; the release filter
stays at Phase 8 and the artefacts refuse to be written under
snapshots/orstatic/. - 73.9% of negative pairs carry substrate form, pH and temperature together
(92% / 89% / 82% individually), which is what makes a cross-paper negative
comparable at all — a negative is a statement about conditions, not about an
enzyme. It is uneven where it matters: **PET's negatives are 88% fully
specified, PCL's are 23%**. Deeper fields fall off as expected (duration 80%,
crystallinity 46%, molecular weight 16%).
aerobicity = 'not_reported'counts as absent, because counting it would report the schema being filled in rather than the paper having said anything. - Evidence tiers, counted in pairs: 4 polymers reach Tier 1 (PET, PCL, PLA, PHB), 8 reach Tier 2, 39 are Tier 3. **Nylon 6 misses Tier 1 by one negative pair** — the cheapest available increase in this pipeline's coverage is one published nylon negative. PBAT, Impranil and Nylon oligomer hold no negatives at all and cannot leave Tier 2 however many positives accumulate.
- Source concentration, re-measured on pairs. One paper supplies evidence for 211 of the 413 PET pairs (51.1%, and it is the sole source for 210 of them), 21% of all labelled pairs, and 34% of every negative pair in the database. 630 positive pairs come from 156 papers; 283 negative pairs come from 38. Leave-one-paper-out belongs beside the identity splits.
- 341 labelled pairs sit on 185 proteins with no stored sequence and are dropped
from the trainable set — counted in
report.json, never silently, and kept by--include-unsequenced.
Changed
docs/hmm-pipeline-plan.md— three errors our own measurements exposed. §3.1 no longer calibrates family thresholds against activity negatives. A GA/TC/NC cutoff answers "is this sequence in this family?", and a PET-negative protein is a genuine family member — that is why somebody tested it. The profile should hit it. Calibrating until the threshold rejects our curated negatives would teach it to reject true homologs and encode an activity claim inside a membership model. N3 is now "same-superfamily-but-different-family" (ESTHER non-polyesterases, confusable α/β-hydrolase families, top-BLAST-ratio non-members), and family calibration uses N0–N3 only. The 150 all-negative proteins became a per-profile specificity report ("this profile hits 7 of the 150 tested-inactive proteins"), published inTHRESHOLDS.mdand never an input to a cutoff. Their real home is Phase 6 / Layer C, where the unit is the pair and "PET negative, PCL positive" survives. §6.2's 25%-identity split is gone — Phase 0 measured that 99.5% of the labelled set falls in one connected component at 25%, so there is nothing to hold out and a plan mandating an impossible split gets an evaluation that quietly does something else. It is replaced by leakage-reported evaluation (every metric prints the achieved train↔test identity beside it, at three points on the leakage axis), leave-one-paper-out, per-polymer heads gated by the evidence tiers, and a masked multi-label loss so one protein contributesPCL = +1andPET = −1in the same pass and nothing at all for the polymers nobody tested it against. Phase 1 now records why exactly 150 proteins leave the seed set: they are negative for everything tested and so contribute no positive evidence for any polymer. The old "283" counted proteins carrying any negative — a different set, which would have deleted 51 plastizymes from the seeds over a negative on some other polymer. The label matrix reached the same 150 from the other direction, which is the same statement written in the unit that loses nothing.
Version 0.98.0
Changed
- **The catalytic Pfam panel is 29 profiles, not 11, and the largest thing it was
missing was the PETase family itself. Phase 1 measured that 46% of the
corpus (266 of 577) finds no catalytic domain** at
--cut_ga, keeps its full chain, and therefore cannot meet the family clustering's 80% coverage requirement against a neighbour that was trimmed to a domain — median 320 residues against 169. Rather than guess a wider list, all 577 mature chains were scanned against the whole of Pfam-A (30 134 profiles, same--cut_ga) and the families the missed proteins actually belong to were read off the result:scripts/experiments/catalytic_panel_scan.py, written up indocs/phase1-catalytic-panel.md. PF12740 (PETase) was the best-scoring hit for 153 of the 266 — and for a further 204 that the old panel caught with a weaker, shorter model. A PET-hydrolase database was screening its corpus for cutinases and not for PET hydrolases. Behind it: NylA amidases (PF01425), family VIII esterases (PF00144), NylC (PF03576), the PLA-degrading subtilisins (PF00082), PHB depolymerases (PF10503), the Cbotu_EstA/PlaM4 polyesterases (PF24708). A family is admitted only when it is the best-scoring Pfam-A hit for a protein the panel misses, Pfam's own description makes it the catalytic domain, and that catalytic unit is one domain — sofn3andHemolysinCabindwent toACCESSORY_PFAMinstead (in the catalytic panel, PueA would have been "trimmed" to a 35-residue RTX repeat), and the multi-domain oxidative enzymes (DyP peroxidase, laccase, PQQ β-propeller, ADH) are deliberately left whole. Domain detection 311/577 (54%) → 537/577 (93%); accessory modules excised 84 → 96; trimmed and untrimmed median lengths now 258 and 264 residues, so the corpus is finally compared like with like.
Measured
- **Widening the panel does not buy more profiles, and the singletons were not
the artefact.** Same corpus, same parameters, only the panel changed:
families 103 → 94, singletons 76 → 69, families clearing
n ≥ 86 → 3, seeds inside those 49.8% → 51.0%. **64 of the 76 singletons are still singletons** — the long tail is genuine sequence diversity, not a coverage artefact, and Phase 2 must plan for it permanently. What the length mismatch had been doing was splitting the large families: five of the six gate-clearing families were one family cut into pieces by the coverage requirement and are now a single **130-seed family holding 42% of the deduplicated corpus** (PET, PCL, PBAT, Impranil). It stays above 40% at every point of the identity × coverage sweep including--min-seq-id 0.40, so it is real homology rather than a threshold artefact. **Phase 2 should plan for three profiles, not six**, and expect §2.3's TCS quality gate to fire on that family first. Reported against ourselves: trimming also fragmented one genuine family. The 15 nylon amidases that cleared the gate as full chains split into 6, 6 and 3 once trimmed — three members have a ~300-residue Amidase envelope against the others' ~440 (0.67 coverage, below-c 0.80), and the rest separate at 30% identity once the flanking regions carrying the alignment are gone. Envelope trimming is not free. Determinism is unaffected: three consecutiveclusterruns give a byte-identicalfamilies.json, and two consecutiveexportruns give byte-identicalseeds.faa,negatives.faaandaccessory.faa. The run read the full 577-protein corpus through a session the server heldreadonly, SELECT only; novisibilityorvisibility_hold_*column was read for filtering or written at all.
Version 0.97.0
Added
- **A microorganism can now be withheld from public view and put back exactly as
it was — the hold mechanism proteins have had since 0.88, generalised.** An
operator with 296 enzymes under embargo could be certain no promotion path
would publish one of them, and could say nothing of the kind about the strain
the enzyme came from:
Microorganism.visibilityis written by the same paths (deposits.create_deposit/release_deposit, the dailyrelease_due_depositstask,submission_promotion._cover) and read by the same gates, but there were no hold columns onmicroorganismsand thebefore_flushparking guard readisinstance(obj, Protein)(#431). The five columns are now aVisibilityHoldMixincarried by both entities — same names, same reversibility (prior state stored, batch-tracked, manual beats bulk, both directions idempotent) — and the flush guard keys on the mixin, so the next holdable entity is covered the day it is declared rather than the day someone remembers to add it to a class list. The admin dashboard's Visibility tab grew the matching pair of switches, the bulk one selecting every strain whose evidence includes a negative result through either arm the rest of the codebase credits an organism by: its own whole-organism assays, and the cell-free assays of enzymes purified from it. **A hold does not cascade, deliberately, and the reasoning is written down inapp/services/visibility_holds.py.** Withholding a strain leaves its enzymes exactly where they were: an enzyme is an independently accessioned record with its own switch, and a cascade would need a second kind of "prior" — *held because of a parent* — which is a second idiom for the one concept these columns exist to express. The organism list reports how many source proteins stay public per row, so the choice is visible in the UI rather than discovered later. A held organism's name is likewise not scrubbed from the public enzyme rows that point at it: a species name is in NCBI, in the enzyme's GenBank entry and usually in the paper's title, and blanking a column of an otherwise-public record is less honest than either state. This ships the capability only. No record's visibility or hold state is read, moved or inferred by the release; whether any organism is ever held is the operator's decision, made from the dashboard.
Fixed
- **The microorganism detail route, its two page routes and the anonymous
autocomplete had no visibility gate at all.** The list filtered, the search
filtered, the exports filtered, the release snapshot filtered — and the detail
page every one of them links to served an embargoed, private or tombstoned
strain to anyone who guessed its tax-id or accession.
/microorganisms/namesreturned the whole table, held rows included, to unauthenticated callers, and/microorganisms/searchdid the same for anything matching the query. All four now 404 or omit, exactly as the protein and engineering routes do. Found by the first route-level test written against a held organism.
Changed
- Migration
0011_micro_visibility_holdsadds the five hold columns and their two indexes to the populatedmicroorganismstable, and renamesprotein_visibility_hold_reason_enumtovisibility_hold_reason_enum— metadata only, no table rewritten and no value changed. The type's two labels never said anything about proteins, and one shared type is what lets the mixin be a mixin rather than a per-table copy. Release order is unchanged (python -m app.db.init_schema, thenalembic upgrade head); the downgrade moves the type back so a rollback cannot strand a database between the two revisions. As in 0010, a downgrade forgets what a hold changed rather than releasing it — release from the dashboard first if anything is withheld.
Version 0.95.3
Fixed
- **
mmseqs easy-clusterwrites*_rep_seq.fastain a different record order on every run, and the family clustering that reads it is order-sensitive.** The set of representatives is identical — verified by hashing the sorted headers across runs — but greedy set cover (--cluster-mode 0) gives whichever sequence it sees first the chance to become a representative and claim its neighbours. On the full 577-protein corpus, three consecutive runs of identical inputs and identical parameters produced 102, 103 and 104 families, with 5, 5 and 6 of them clearing the Phase 2 admission gate. Nothing reported it.--threads 1does not fix this; the non-determinism is in the output order, not in the clustering arithmetic.canonicalise_fastanow rewrites the representative FASTA in a fixed order (by typed sequence id) before the family clustering, and three consecutive runs give 103 families and 6 gate passes every time. A profile-HMM release whose family partition changes between two builds of the same corpus is not reproducible in the sense the pipeline claims to be, and the Phase 8 manifest would have recorded a partition nobody could regenerate.
Changed
docs/phase1-seed-families.mdnow reports the full corpus. The previous figures came from the 345 public wild types, which was all the build host could reach; the run has been repeated read-only against all 577 sequenced wild types (357 public, 220 under a visibility hold). The public-subset table is kept alongside, clearly labelled, because the contrast is itself a finding. Authoritative numbers: 150 curated negatives separated, 427 seeds, 313 after 90% dedup, 103 families, largest 58 seeds (18.5%), **76 singletons (74% of families), and 6 families clearingn ≥ 8— holding only 49.8%** of the seeds. Half the deduplicated corpus falls outside any profile-eligible family, which is the number Phase 2 has to plan around. Against the public subset: the embargoed rows add breadth, not depth — 29 more families, 21 more singletons, and no additional profile-eligible family. What moves is the denominator: gate-clearing families cover 62% of the public subset and 50% of the real corpus, so anyone estimating coverage from our published data overstates it by a quarter. The public subset also yields **zero negatives of any kind** (456 positive, 6 inconclusive out of 462 interactions), confirming Phase 0's finding from the outside. D1 holds empirically, and more strongly than on the public subset. F0004 carries 19 distinct polymers, F0002 and F0007 carry 17, F0012 carries 14 — single homologous families whose members were each measured against a different substrate panel. A polymer-grouped build would cut each into a dozen fragments and reassemble them across families by label; the resulting "PET profile" would span eleven non-homologous groups. The converse also holds: F0005/F0011 are clean nylon-hydrolase families and F0006/F0008/F0013 clean PET ones. Signal-peptide stripping finally ran — the public feed carrieshas_signal_peptidebut not the cleavage site, so the earlier run could not apply D2's mature-chain rule at all. All 351 were stripped. A/B on the full corpus: four more sequences collapse at 90% dedup (313 vs 317), eight more proteins find a catalytic domain at--cut_ga(311 vs 303), and two families and one gate pass disappear — a shared secretion motif had been making unrelated secreted proteins look alike, and the gate should not be cleared on the strength of a targeting sequence. Two further measurements that change the plan: **46% of the corpus finds no catalytic domain** and keeps its full chain, so it cannot meet an 80% coverage bar against a trimmed domain — a share of the 76 singletons is that artefact, and widening the Pfam panel is the cheapest available improvement. And **Phase 3.1's "283 curated negatives" is not the measured number**: 150 negative-only proteins (the tier as exported), 201 carrying any negative, 455 negative interaction rows. The 51 proteins carrying both stay in the seed set, because they demonstrably degrade some polyester and putting them innegatives/would push the calibrated NC up and every profile's GA with it. The run wrote nothing: SELECT statements on a session the server heldreadonly, and novisibilityorvisibility_hold_*column touched. Verified afterwards — 296 private proteins, allbulk_negative_results, unchanged.
Version 0.95.2
Fixed
- **The release build had no retry on the three binary downloads, so a transient
connection reset from GitHub's release CDN failed the deploy.**
v0.95.0failed twice in a row, both times identically: `curl: (35) Recv failure: Connection reset by peer, 150 s into a 17 MBmmseqs-linux-avx2.tar.gz`. Since v0.94.0 the image pulls MAFFT, MMseqs2 and foldseek from the open internet at build time, andbuild-and-push.ymlis the only path to production — so two third-party hosts became a hard dependency of every deploy, with no retry between them and a failed release. Every download now carries `--retry 5 --retry-delay 5 --retry-all-errors --connect-timeout 30 --speed-limit 10240 --speed-time 30`.--retry-all-errorsis the load-bearing flag: plain--retrydoes not cover curl exit 35, which is the failure we actually hit. The speed floor aborts a stalled transfer after 30 s so it is retried, instead of hanging for 150 s until the far end resets it. The SHA-256 pins are untouched and cannot be weakened by a retry: a truncated or substituted file still failssha256sum -c. Closes #436.
Version 0.95.0
Added
- Phase 1 of the profile-HMM pipeline: seed export and family definition
(
docs/hmm-pipeline-plan.md§1.2–1.4). Result document:docs/phase1-seed-families.md. Three pieces —app/services/hmm_seeds.py(pure logic, Tier-1 testable),scripts/export_seed_fasta.py(corpus → seeds, negatives, accessory modules) andscripts/build_seed_families.py(90% dedup → families → structural cross-check → report) — plusmake hmm-smoke, which runs the whole thing on a frozen fixture and checks it against a golden partition. Families, not polymers (D1). Nothing groups on a polymer tag; polymer is counted within a family and reported as an annotation, with each verdict attached to its own polymer so that a PET-negative, PBAT-positive enzyme keeps both facts. The measured partition is the argument for that decision: the four largest families each carry three or four different polymers, and PET appears in at least eight separate families. A polymer-grouped build — which is whatscripts/build_hmm_profiles.pystill does, left in place and not extended — would have cut those families apart and then glued their PET fragments into one "PET profile" spanning four non-homologous groups. The empirical family table (345 public wild types, the full corpus was not reachable from the build host — see the result document): 250 seeds after 90% dedup, 74 families, largest 23.2%, 55 singletons, and only **6 clear the Phase 2n ≥ 8gate**, holding 62% of the seeds. The--min-seq-id×-csweep moves that between 5 and 7 across the whole grid, so the clustering threshold is not what limits this pipeline; corpus diversity is, which is the same conclusion Phase 0 reached from the other direction. No winner is selected — the plan selects by leave-one-out recovery and hard-negative separation, which are Phase 2/3 measurements. Three assumptions in the plan turned out to be false and are handled rather than worked around:- There are no InterPro/Pfam coordinates in the database. InterPro data is
stored in
proteins.cross_referencesas{id, name, type, db}; the enrichment step drops the per-entry locations, and there is noprotein_domainstable.--domain-envelopetherefore computes the envelope withhmmsearch --cut_ga --domtbloutagainst the catalytic Pfam panel, taking the envelope columns (20/21), not the alignment ones. - There is no per-residue pLDDT.
protein_structures.confidenceis the mean over the model.--plddt-trimreads the B-factor column of the stored structure instead, and leaves a protein untrimmed — and counted — when the model cannot be read, rather than trimming against an average. - **The seed export is a new script, not flags on
scripts/export_reference_fasta.py.** That one builds the public DIAMOND reference database and filtersvisibility == "public"; this one reads the full corpus on purpose, because every PET negative is embargoed and filtering at Phase 1 would delete a class rather than shrink the corpus. One wrong default in a shared script publishes 296 embargoed sequences, and this project has already leaked an embargo three times through three different promotion paths. The seed writer additionally refuses to write under asnapshots/orstatic/path.
- There are no InterPro/Pfam coordinates in the database. InterPro data is
stored in
make hmm-smoke, wired into thebuildCI job (.github/hmm-smoke.Dockerfile). The Tier-1 image carries pytest but no bioinformatics binaries and the application image carries the binaries but no pytest, so every test that callsmmseqswould otherwise skip in both and the clustering code would never run in CI at all. The smoke test runs against the image thebuildjob just built.
Fixed
mmseqssilently reuses an existing tmp directory, keeping alatestsymlink and skipping work it believes it has already done. Running theclusterandsweepstages into the same tmp produced two different answers for identical parameters (6 versus 7 profile-eligible families) because the sweep was reusing the previous run's prefilter. Both stages now wipe their tmp directory first. Left alone this would have published a family table that no parameter combination actually produces, with nothing reporting it.
Version 0.94.0
Added
- **The image carries the profile-HMM toolchain:
mafft,mmseqs2,foldseek,clipkit,pyrodigal** (docs/hmm-pipeline-plan.mdPhase 7). Every version is pinned exactly, and the three downloaded artefacts are additionally pinned by SHA-256, because a profile HMM is a function of the seed set and of the toolchain:mafft --autopicks a different strategy across releases andhmmbuild's default entropy weighting has moved between HMMER minor versions, so two builds of the same seeds under two toolchains produce two different profiles with nothing anywhere reporting it. HMMER is pinned to 3.4 and MAFFT is taken from the author's own.debat 7.526 rather than Debian's 7.505 — 7.526 is what the Phase 0 experiment measured on. MetaGeneMark is deliberately absent: it is licensed per registered user, so it cannot ship in a redistributable image, andpyrodigalis the substitute — a better one here, since it calls ORFs in-process rather than a subprocess per shard.tests/test_bio_tools.py::test_metagenemark_is_not_shippedkeeps it out. Licences were verified upstream, not assumed: MMseqs2 is MIT; **foldseek and pyrodigal are GPL-3.0**, as is the already-bundled DIAMOND. All three ship as unmodified upstream builds invoked as separate processes (or, for pyrodigal, as an independently-licensed library), so they do not relicense PlasticDB; the manifest'ssourcefield is the corresponding-source pointer GPL-3.0 asks for. One consequence worth knowing before a deploy: **from release 10 foldseek publishes only an AVX2 build for linux/amd64**, so the image now requires an x86-64 CPU with AVX2. On a CPU without it the binaries die with SIGILL, and the build reports exactly that rather than leaving it to surface inside a Celery task three phases later. Full detail, including how to roll foldseek back to the last SSE2 release:docs/bioinformatics-tools.md. - **
app/services/bio_tools.py— one manifest, three readers, so the toolchain cannot rot silently.**scripts/check_bio_tools.pyexecutes every binary and runs as aRUNstep in the image build, so an apt line, a download or a pip pin that only looked like it worked failsdocker build— thebuildjob in CI.tests/test_bio_tools.py(Tier 1, no Docker) asserts every pin in the manifest still appears verbatim in the file that installs it, catching the version edited in one place and not the other. Pipeline code resolves binaries throughtool_path(), which names the phase that wanted the tool instead of raising a bareFileNotFoundError.
Fixed
- **
docker-compose.ymlmountedbio_tools:roon the worker, which is the one service that has to write it.** The worker runsrebuild_reference_databases(app/workers/reference_build.py), which mkstemps into that directory andos.replaces the result into place — the atomic swap readers depend on. Read-only, that isEROFSinside a Celery task, so no worker-driven reference build could be tested locally at all, and the profile-HMM build joins the same_BUILDSlist in Phase 7. The stale comment claiming the API needed write access "only" formake bio-buildis gone with it. Nothing else is loosened: job scratch still lives injob_files, and it is the atomic-replace discipline that protects a concurrent reader, not the mount flag.
Changed
- The chart-executor sidecar pins
numpy==2.0.2instead of2.1.1, so one numpy serves the whole project. ClipKIT capsnumpy<2.1on Python 3.12 andrequirements-dev.txtpulls the sidecar's pins in sotest_chart_sidecar.pyruns against the versions the sidecar image ships; at 2.1.1 that resolution is impossible and.github/test.Dockerfilecould not build at all. Both are numpy 2.x and matplotlib 3.9.2 supports either. .github/test.Dockerfilenow copiesdocker-compose.yml, which is data to the new mount test — the same reason it already copiesDockerfile.
Version 0.93.1
Fixed
- **
/api/v1/searchreturned 500 for any query matching an organism with no NCBI tax-id (#408).** The microorganism arm of the search UNION selectedtax_idstraight intoGlobalSearchResult.route_key, which is non-optional. A null therefore failed Pydantic validation and took down the entire response — one such row anywhere in the result set, and the site-wide search box returned nothing. Fifteen production organisms were live triggers:?q=tamsuii,?q=circinatus,?q=japonicaand a dozen more each 500'd, through both/searchand/search/grouped. A nulltax_idhas been a sanctioned state since 0.83.0 — a validly published species NCBI has no node for — so the fix is not the one the protein arm uses (drop the row). A tax-id-less organism does have a page,/microorganisms/accession/{accession}, shipped in 0.82.0 and already linked from the microorganism list. Search now routes oncoalesce(tax_id, accession)and carries the accession page indetail_url, so it agrees with the list page instead of contradicting it. The 15 rows are findable rather than merely non-fatal — they are the ones a curator most needs to find, each carrying a recorded cause (absent_from_ncbi,misspelling,obsolete_name,not_validly_published) and an invitation to revisit it./search/autocompletenever 500'd because it already took the other option —.where(route_key_col.is_not(None))silently hid these rows from the dropdown. It now suggests them too, with the route the dropdown can follow. - **Six templates built a dead
/microorganisms/nulllink for the same organisms.**/microorganisms/{tax_id}takes an int, so an unguarded `/microorganisms/${row.tax_id}` 422s for a tax-id-less row — and three of the six guarded the displayed tax-id (${m.tax_id || '–'}) while leaving the link unguarded, rendering an en-dash inside a link to nowhere. The fallback the microorganism list had kept to itself is now shared asorganismHref/organismLinkinstatic/js/organism_name.js, and used by the protein detail and list, plastic detail, paper detail, taxonomy group and paper footprint pages. Two payloads gained theaccessionthey needed to build it:FootprintMicroorganismReadand the taxonomy group'stop_microorganisms. The dashboard search dropdown (base_dashboard.html) also ignored thedetail_urlthe API sends, so it linked engineered variants to/proteins/{id}as well; it now honours it.
Version 0.93.0
Fixed
- **Any authenticated user could publish arbitrary public proteins through
POST /api/v1/deposits(#417).**DepositCreate.visibilitywas a barestrwith no validator, and the route copied it verbatim onto theDepositand onto everyProteinandMicroorganismit created. So one call from any verified account put rows into the public corpus — the setworkers/reference_buildbuilds the DIAMOND databases from, thatservices/exportandservices/snapshotsread, and that a release snapshot is cut from under a Figshare DOI that cannot be unpublished. No curator saw them. It was also a hole in the operator embargo currently withholding 296 proteins. Two changes, and the second is the one that matters. The schema now validatesvisibilityagainst the enum (a typo is a 422 instead of reaching the database enum as a 500), sharing one validator withschemas/submissions.pyinschemas/common.check_visibilityrather than keeping a second copy that was never written. And **moving deposit content into the public set is now a superuser act**, the same rightdeps.may_publish_ingestionreserves for a contributor's submission — publishing somebody's own unpublished data under their name has always been the narrow right. Enforced at all three doors, because leaving any one open makes the others theatre:POST /depositswithvisibility='public'(403);POST /deposits/{accession}/release, previously open to the deposit's owner, so "create private, then release" was the same bypass in two calls (403); and the dailyrelease_due_depositsbeat, previously reachable by creating anembargoeddeposit dated yesterday, i.e. the same bypass with a 24-hour delay. The beat now releases only deposits carrying asubmission_id— the fact that they came through an acceptance a superuser had to make — and reports matured deposits it declines to publish to Slack rather than leaving a depositor waiting in silence. Depositors lose nothing they are owed: minting accessions, holding recordsprivateorembargoed, and the anonymous reviewer link all work exactly as before. Verified in the pg tier, where the assertion is made againstpublic_visibility_filteritself — the clause a snapshot is built from — not against a status code. Thebefore_flushguard inmodels.pywas confirmed empirically (not assumed) to park a held protein's publication through the release route as well, so the 296 embargoed rows cannot leave by this door either.
Version 0.92.0
Added
- **Phase 0 of the HMM pipeline: the within-family PET discrimination
experiment.** Before any profile-HMM code is written, one question decides what
Layer C is allowed to claim — inside a single homologous family, does anything
computable from sequence distinguish an enzyme measured to degrade PET from
one measured not to? We can ask it honestly, which almost nobody can: every
published plastizyme classifier trains its positives against homologues of
unknown activity, a near-circular label set. We hold real negatives.
scripts/experiments/pet_discrimination.py(fetch/features/evaluate,--helpon each) answers it, anddocs/phase0-pet-discrimination.mdrecords what it found. Labels come from the evidence spine —interactions.resultagainst the plastic row named exactlyPET. Never anilike:app/services/query.pyfilters plastics by substring, which is right for a search box and wrong for a label, because%PET%also matches PETG and%PE%matches LDPE and HDPE too. The gate resolved: nothing separates them. Layer C will report "polymer undetermined" for PET and will not ship a calibrated PET discriminator. Across six feature blocks — profile HMM bit score and envelope coverage, the M1→M5 active-site ladder, thermostability, accessory modules, nearest characterised neighbour, ESM-2 embeddings — and two model classes, the best out-of-fold result under the hardest split the corpus permits is **AUROC 0.628 / MCC 0.21 / AUPRC 0.740**, against a prevalence floor of 0.639 and a label-permutation null that reaches 0.561. The profile HMM, the feature the whole pipeline is built on, scores below chance. Two findings the plan did not anticipate. First, **the 25%-identity split the plan mandates does not exist on this corpus**: 68% of the 82,621 labelled pairs are ≥25% identical, and single-linkage clustering at 25% leaves **99.5% of the 407 labelled proteins in one connected component**. There is nothing to hold out. Phase 6.2 mandates the same split for the ML rescorer and cannot have one either, so that requirement is flagged for rewriting. Second, **apparent performance is a monotone function of train/test similarity** — the identical pipeline reports AUROC 0.63 at 28% median train↔test identity, 0.73 at 63%, and 0.825 at 81%, i.e. under the random split most published plastizyme classifiers use. The experiment therefore reports the achieved leakage next to every metric, which is the number that decides whether an AUROC means anything and which almost nobody publishes. Features that could not be run are implemented, markednot_runwith a reason, and reported as such — never silently dropped, and never given a fabricated number. DeepTM did not run (no PyPI package, no public weights endpoint); what runs in its place is a panel of published sequence-level thermostability correlates, prefixedthermo_so it can never be mistaken for a predicted Tm. ESM-2 is the 35M checkpoint, not the 650M named in Phase 6.1 — the larger model loads but needs about an hour per pass on a CPU-only host. - The corpus-scope rule, now written down. The build reads all 577 sequenced
wild types; the release filter runs at Phase 8, not Phase 1. Phase 0
measured why this is not a preference: **all 147 PET negatives in the database
are embargoed, and the public subset holds 249 positives and zero**
negatives. The hold was applied as a curation batch
(
visibility_hold_reason = 'bulk_negative_results'), so filtering onvisibilitybefore Phase 8 would not shrink the corpus evenly — it would delete one class outright, and every threshold, negative tier and calibration downstream would inherit that silently, with nothing failing loudly. A profile is residue frequencies, not sequences, so nothing is leaked by building over held rows. Recorded indocs/hmm-pipeline-plan.mdunder "Scope of this build", along with the rule that no build-side query filters on or writesvisibilityand that sequence-bearing intermediates are build artefacts, never deposits. The experiment reads production read-only — a session openedreadonly=True, SELECT statements only, pinned by a test that greps the four corpus queries for write verbs and for any mention of a visibility column.
Changed
docs/hmm-pipeline-plan.md. Phase 0 marked run, with the re-measured counts (260 positive / 147 negative, against the plan's pre-embargo estimate of 264/151). Phase 1.1's SignalP blocker marked cleared — measured against the live corpus, all 577 now carry a resolvedhas_signal_peptide(351 true / 226 false) and all 351 signal peptides have a stored cleavage site, so Phase 1.2's--strip-signal-peptidehas what it needs. Structural coverage has also moved to 406 of 577, from the 386 quoted in Phase 2.requirements-dev.txtgainsscikit-learn, so the experiment's threshold-selection and cluster-bootstrap tests execute in CI instead of skipping past themselves — the same reasoning already applied to the chart-executor pins.
Version 0.91.0
Added
- A sequence is now a lookup key. Every accession-keyed enrichment source —
UniProt metadata, RCSB, the EBI AlphaFold DB, InterPro — needs a
uniprot_accessionbefore it can do anything, and **365 of the 577 sequenced proteins in production have none**. 175 of those have no structure of any kind, and 101 of them are longer than 400 residues — past the public ESMFold endpoint's hard ceiling, which refuses a 450-residue sequence with an immediate413. For those, AlphaFold was not the preferred route to a structure; it was the only one, and it was closed. The newuniparcrung opens it, from the residues themselves: **CRC64 of the sequence → UniParc entry → active UniProtKB cross-reference**. UniParc is the archive UniProt indexes by sequence, so a protein with no accession still has a key. Once the accession lands in the column, the rest of the ladder works unchanged — and does so in the same pass, because the rung is placed after UniProt and before everything that reads the column (#424). Attribution is exact identity, checked twice. A CRC64 is only an index — 64 bits, and collisions exist — so the UniParc entry's sequence is compared to ours character for character before it is accepted at all. Then only an active cross-reference is used:activeis UniParc's way of saying "this accession currently holds this exact sequence", and an inactive one means the entry has since been deleted or re-sequenced. Nothing is ever attributed on a partial or near match. The CRC64 implementation is pinned in the test suite against four checksums UniProt publishes itself, because the wrong variant does not fail — it silently matches nothing, which reads exactly like "there is nothing to find". Every resolution carries its provenance: the enrichment row's detail reads `resolved A0A0K8P6T7 (UniProtKB/Swiss-Prot) via UPI0006A8DE61 on exact crc64 8620A766B69749D2`, and the UniParc id is stored as a cross-reference and linked from the protein page. A value already inuniprot_accessionis never overwritten — that came from a curator or an importer, and the rung reports what it found instead. scripts/backfill_uniparc_accessions.py— the one-time catch-up over the corpus that predates the rung. Resumable by construction (a protein stops being a candidate the moment itsuniparcrow settles, so an interrupted pass is continued by re-running it),--limitto bound a pass, one protein per commit withcommit_with_deadlock_retry, and--report-fingerprintto show that the reference-database fingerprint has not moved. It covers private records as well as public ones — a structure is internal enrichment, not publication — and writes to no visibility column in any branch.
Fixed
- UniProt now reads an accession that is already in the column. Its only
routes in were a GenBank id and a BLAST search that is off by default, so a
protein carrying nothing but a UniProt accession never got a name, an EC
number, GO terms or active sites. It now fetches the entry directly, and
ignores a
200whoseentryTypeisInactive— a deleted entry has no sequence and no names, and parsing one would blank the columns it was meant to fill.
Version 0.90.0
Added
- A way back from the retry cap. The enrichment state machine stops
attempting a source once its row is
transientwithretries >= RETRY_CAP. That rule is right — it is what stops the weekly batch spending its whole budget on a record that will never resolve — but it had no exit. So when v0.87.7 fixed the ESMFold rung (the freeapi.esmatlas.comendpoint answers504at its gateway's ~29.6s ceiling for about half of all requests; the fix retries within one attempt and took per-attempt success from ~50% to ~97%), it could not reach a single row it was written for: 231 rows — 66 proteins and 165 engineered variants — were already past the cap, burned through by the ~50% era, and permanently parked (#421). Admin → Enrichment → "Rescue retry-capped rows" re-attempts one source for exactly those rows. The panel says how many there are before you press it, per source, and reads zero when there is nothing left. What it deliberately does not do is what already existed. "Re-enrich all" isforce=Trueacross all 8 sources × 762 proteins × 745 variants; it re-attempts the 139 rows already settledpermanent: unfoldable— sequences over 400 residues, which the endpoint answers413to — and re-runs UniProt, PDB, InterPro and AlphaFold over the whole corpus to reach a few hundred rows. Onlytransientrows are ever selected here;ok,noneandpermanentare terminal and are never re-asked. Resumable by construction, not by a checkpoint file. A row's retry counter is reset immediately before its attempt, so a row that has been tried is no longer at the cap and a second pass does not select it again. Interrupt one and start another; it continues from the rows the first never reached. And a row that fails again comes back atretries=1rather than at the cap, so the weekly batch keeps trying rather than re-parking it — the same trap, one release later, which is the failure this feature exists to end. Newenrichment_rescue_runstable (ORM +python -m app.db.init_schema; no migration —create_allcreates a table that does not exist yet), reusing thetaxonomy_runspattern shipped in 0.89.0 rather than inventing a third mechanism: aUNIQUElock_keyso two passes cannot contend for the free upstream that is the reason the rows were parked, counters and a heartbeat committed after every row, an automatic release of a lock whose worker died, and a visible history of what each pass recovered. Reasoning in full in the module docstring ofapp/services/enrichment_runs.py.python -m scripts.rescue_enrichmentis the same thing without a browser — same lock, same run rows, same worker code — and reports, writing nothing, when given no--source. enrich_entity_sources— the ladder, restricted to named sources. A rescued rung still runs through_enrich_one/_enrich_one_variant, so it keeps the gates that make it correct (the "an experimental structure already exists" check, the AlphaFold-may-still-deliver wait, and thestructure_filereconciliation that is what actually makes a recovered fold render).
Fixed
- The dashboard was still reporting v0.87.7's improvement as 522 errors.
Busybuckets onendpoint_over_capacity, the tagEsmfoldOverCapacitywrites — and that exception did not exist when any of the stored rows were written. All 522esmfoldtransient rows in production carry the older shape, and every one of them is a 5xx from the free endpoint: ``` esmfold_failed: ESM Atlas returned 504: {"message": "Endpoint request …"} 416 esmfold_failed: ESM Atlas returned 504: Gateway Time-out 104 esmfold_failed: ESM Atlas returned 502: Bad Gateway 2 ``` SoErrorsread 522 andBusyread 0 — the exact crying wolf the new bucket was introduced to stop, still on the page. The coverage query now recognises both spellings. This is a reading rule, not a rewrite: rewriting the stored strings would either discard what the endpoint actually said or wrap it in a tag the row was never given, and neither is a thing that was observed. The match is narrow — the ESM Atlas rung by name, and only the status codes_ESM_ATLAS_RETRYABLEtreats as "ask again shortly" — soesmfold_failed: ESM Atlas returned no ATOM recordsis still an error and still counted as one. TheRetry-cappedcolumn's tooltip no longer advises "rescue with Re-enrich all", which was expensive and wrong. - **A backfill died at 140 of 577 because it held row locks for two minutes at a
time.**
scripts/backfill_signal_peptides.pydefaulted to--commit-every 10. A record is a ~12-second hosted EBI Phobius call, andupsert_enrichmentreadsentity.enrichments— a lazy relationship load, which triggers SQLAlchemy's autoflush, which takes the row locks onproteinsat the first record of the batch and holds them until the commit at the tenth. Two minutes of held locks for perhaps five milliseconds of writing, against a tablereindex_search_vectorsbulk-UPDATEs on a schedule. PostgreSQL resolved the deadlock by killing the backfill, and with no retry the run simply ended. Both halves are fixed.--commit-everynow defaults to 1, bounding what a deadlock can destroy to one prediction; and the newapp.workers.sync_db.commit_with_deadlock_retryre-applies and re-commits that record rather than losing it. The prediction itself is never repeated — it has already happened, outside the transaction, and only the in-session writes are redone. Retries are keyed on the SQLSTATE (40P01,40001), not on message text: a constraint violation retried four times is still a violation, and retrying it would hide the thing an operator has to see. scripts/backfill_plddt.pyhad the same shape, worse.row.proteininside its loop is a lazy load, so the autoflush took a lock onprotein_structuresat the first repair — and a single commit at the end of a one-second-per-row loop held it for the entire run. It now commits each repair through the same retry. (backfill_microorganism_synonyms.pywas checked and is clean: it accesses no relationship and issues no query inside its loop, so nothing flushes until the final commit.backfill_accessions.py,backfill_submission_ingestions.py,migrate_variant_enrichment.pyandenrich_dois.pymake no network calls inside a transaction at all.)
Version 0.89.0
Added
- Taxonomy enrichment has a button, and the button has a memory. Refreshing
NCBI taxonomy was a person:
kubectl execinto a worker pod, callrefresh_lineages()by hand, and read the pod's log for the outcome — until the pod was recycled and there was no outcome to read. Admin → Taxonomy now starts the same pass on a worker, shows it moving, records what it did, and refuses to start a second one. Four scopes, cheapest first, because the difference between them is 1 NCBI request and ~1300: | Scope | Rows | When you want it | |---|---|---| | Missing lineage | organisms with a tax-id and no lineage string | after an import — currently 0 of 1282 in production, which is the point of showing the number | | Unresolved names | organisms NCBI had no node for (25 in production) | after NCBI adds nodes; the same rules as the weekly retry job | | Single organism | one | a curator fixed one name and wants it re-asked | | Full refresh | every organism with a tax-id (1282) | a genuine re-sync; ~7 minutes at the anonymous 3 rps, ~2 with anNCBI_API_KEY| No overlapping runs, enforced by the database.taxonomy_runs.lock_keyisUNIQUEand non-NULLonly while a run is live. Two clicks 200 ms apart both pass any "is one running?" SELECT; only one survives the insert. This is not fussiness — NCBI blocks the shared institutional IP, not the tab that double-fired, so a duplicate crawl is everyone at the university losing E-utilities. The weekly beat tasks take the same lock, so Sunday's 03:17 refresh will not land on top of a run started at 03:16. Progress, not a spinner. Counters and a heartbeat are committed every 25 organisms, so the dashboard reads *340 of 1282 checked · 12 updated · 2 failed* while the crawl is still going — and so a run whose worker was killed is distinguishable from one that is merely slow. A run that stops reporting for twenty minutes has its lock released automatically; a Release the lock button cuts that wait to five, because recovering from a dead worker must not require a terminal either. It is refused while the run is still reporting: nothing in the API can stop a Celery task, so freeing a live run's lock would start a second crawl beside the first. Newtaxonomy_runstable (ORM +python -m app.db.init_schema; no migration —create_allcreates a table that does not exist yet). Deliberately notJob: that is a bioinformatics upload with fifteen DIAMOND-shaped columns, a results file, a user-facing status page, Slack notifications and a file-eviction sweeper, and itsjob_typeis a live PostgreSQLENUMwhose `ALTER TYPE … ADD VALUE` cannot be used in the transaction that adds it. Reasoning in full in the module docstring ofapp/services/taxonomy_runs.py. - The orphan sweep's reference map grew a third answer.
delete_orphaned_organisms_and_proteinsclassifies every foreign key pointing atmicroorganisms.idas either keeping the organism alive or dying with it, and a completeness test refuses a new one that is neither.taxonomy_runs.microorganism_idis genuinely neither: a run about an organism must not stop that organism being reclaimed, and must not be deleted when it is — the run happened. The new_MICROORGANISM_AUDIT_TRAILlist says so out loud, and the FK'sON DELETE SET NULLis what enforces it.
Fixed
- The lineage refresh was pacing itself, badly, in a second place.
app/workers/lineage.pybuilt its own efetch URL and slept 1.1s every third request. That copy sent notool/email/api_key— an unidentified caller is throttled harder and blocked without an email first — ignoredRetry-After, never retried a 429, and ignoredNCBI_API_KEYentirely, so the 10 rps allowance the key buys was left unused while the shared institutional IP wore the 3 rps one. Every request now goes througheutils_get, the one place that paces, identifies, retries and backs off. - A throttled NCBI used to read as a clean run. The same loop treated every
non-200 as
continue, which made "NCBI is rate-limiting us" and "NCBI has no such taxon" the same event: a pass against a throttling NCBI finished successfully having updated nothing, and nothing anywhere said so. The two are now counted apart —NCBIUnavailableis a recorded failure with the organism's name attached, an empty document is a note that the tax-id may have been merged or deleted.
Version 0.88.0
Added
- **Withhold protein records from public view, and put them back exactly as they
were. A paired switch on the admin dashboard (Admin → Visibility**):
one action withholds every protein carrying at least one negative
interaction — the unpublished asset a competitor is not generating — and one
action restores that same set. Plus a per-protein pair, and a searchable
management list, for the one-off cases.
**The enforcement is the existing
Protein.visibilitycolumn, not a new flag.** Every read path in this codebase already gates on it: the REST list/detail/FASTA/structure routes, the server-rendered pages, FTS and trigram search, the CSV/JSON/FASTA downloads,services/export.py,services/snapshots.py— whose output takes a Figshare DOI that can never be unpublished — andworkers/reference_build.py, which builds the public DIAMOND databases fromvisibility = 'public'. A parallelis_hiddenboolean would have needed every one of those patched by hand, and would have missed some. Four new columns onproteins(migration0010_protein_visibility_holds) do one job: make the action exactly reversible. They record, per row and durably, the visibility the record had immediately before the hold, who applied it, when, and under which batch. Restore puts that back — a record that was alreadyprivatefor an unrelated reason comes backprivate, neverpublic. The prior value is stored rather than recomputed, because release happens months later: by then curation will have moved which proteins carry a negative result, and recomputing would restore the wrong rows. Precedence, stated plainly in the UI: manual beats bulk. A protein you hide by hand carriesreason = 'manual'and the bulk restore leaves it hidden — release it individually. Unhiding one by hand during the embargo takes it out of the batch for good. Both switches are idempotent: restoring twice reportsrestored: 0and changes nothing. The fourth door is closed structurally. Embargo has leaked out of this database three times, each through a different promotion path that assignedvisibility = 'public'without knowing an embargo applied — and the lesson of those three is that the list of such paths is never complete. So the guard is not at the doors: abefore_flushlistener parks any visibility write to a held protein invisibility_hold_priorinstead of applying it. Nothing leaks, and nothing is lost — an embargo that matures during a hold takes effect the moment the hold is lifted.
Fixed
- **A record that is not public is no longer served by accession, by id, or from
the evidence spine.** Auditing the read paths for the switch above turned up
pre-existing leaks that would have defeated it, and that already applied to
every embargoed deposit:
protein_resolution.resolve_accessionreported a non-public protein asLIVE, soGET /api/v1/proteins/PB000123and the page behind it served its full detail — sequence, structures, kinetics, interactions, papers. NowMISSING(404, not 403 and not 410: the existence of the record is part of what is withheld)./proteins/{id}/fastaand/proteins/{id}/structuretook a raw id with no visibility or tombstone check at all./variants/{id},/variants/{id}/structure,/engineering/proteinsand/engineering/proteins/{accession}served a hidden parent's data through its engineered variants.- The paper detail page, the public paper footprint (its curator twin keeps
the full view), and the plastic page's protein buckets, counts and rows all
rendered hidden proteins straight off the evidence spine. The
interaction-actor rule that guarded snapshots now lives in
services/query.pyand is shared by all of them. /search/autocompleteapplied no visibility gate — the FTS index covers every row regardless — which made it the discovery mechanism for the rest.- Count-only disclosures:
/stats/by-country, the taxonomy-group protein totals, and the plastics table'sprotein_count. - The public contribution timeline republished the old and new values of every field an admin edited, including sequences, for any protein.
- Pathway steps, the pathway graph and the compound page named their linked enzyme regardless of its visibility.
- The chatbot can no longer read non-public rows.
POST /chatlets an anonymous visitor run arbitrary read-only SELECTs against an allowlist of tables; the allowlist governs which tables, never which rows, so "list every protein sequence" returned embargoed and private records. Each visibility-bearing relation is now rewritten into a filtered sub-select aliased back to its own name, so the hidden rows are not removed from the answer — they were never in the relation. (Sub-selects rather than database views on purpose: a view overSELECT *pins the base table's column types and would break the next migration that widens one.) FROM papers, usersis no longer allowed past the chatbot's allowlist. The guard's table scan was\b(from|join)\s+(\w+), which reads the first relation of a list and stops — so a comma-joined second table was never checked againstALLOWED_TABLESat all, andusers(hashed passwords, API key hashes) was reachable by writing a comma instead of a JOIN.
Version 0.87.7
Fixed
- Signal peptides: the source had never run, once, in production.
has_signal_peptidewas NULL on all 762 protein rows andsignal_peptide_cleavage_siteon all of them too.protein_enrichmentsheld zero rows forsource='signalp'— not stale ones, none. Two independent causes, either one fatal: 1.ENRICHMENT_SIGNALP_ENABLEDdefaults to False, and the rung sits behind it, so nothing was ever attempted. Worse, the disabled branch did not even setresult["signalp"] = skipped— the source vanished from the payload entirely, making "switched off" indistinguishable from "does not exist". 2. Flipping the flag could not have helped: the rung shells out tosignalp6, which is not in the image and cannot legally be put there. SignalP 6.0 is licensed by DTU per registered user with no redistribution right. Confirmed against the running worker:command -v signalp6→ not found. The fix is not to smuggle SignalP into the image but to stop depending on a tool we may not ship. Newapp.services.signal_peptidesmakes the predictor pluggable across three back-ends, chosen byENRICHMENT_SIGNALP_TOOL: | Tool | Licence | Role | |---|---|---| |deepsig| GPL-3.0 | Local, redistributable, no rate limit.requirements-bio.txt; kept out of the default image only because it pulls TensorFlow. | |ebi_phobius| n/a — we redistribute nothing | EBI's hosted job service. Needs no install, so it is the default and the feature works on a stock image. | |signalp6| DTU academic | Honoured when an operator has licensed and installed it. Never shipped by us. |ENRICHMENT_SIGNALP_ENABLEDnow defaults to True, because there is finally a predictor a default install can reach. Both columns are written together and never independently — a True boolean with a NULL cleavage site silently degrades the reference FASTA, every ESMFold model andmature_sequence()to the precursor. The invariant is enforced inSignalPeptideResultitself. All three parsers are pinned against real captured tool output, because the whole feature turns on one integer and the three tools each report it differently: SignalP 6CS_pos→ the first number; DeepSig GFF3 → the feature end; Phobiusn6-17c23/24o→ the number before the slash. Verified live end-to-end against EBI on four production sequences, e.g.PB264QMKANLILACASVVAGVSA│APLERR…— a textbook AxA cleavage at 18. - ESMFold: 522 "errors" were one free endpoint being busy.
Every one of the stored details was a 5xx from the keyless ESM Atlas endpoint —
416 ×
504 {"message": "Endpoint request timed out"}, 104 × `504 Gateway Time-out, 2 ×502`. The endpoint is not dead and has not moved: the EvolutionaryScale→Biohub acquisition is a red herring here, because no Forge key is configured, soesmfold_api_urlis never reached and the keyless path is the only one that runs. Measured against the live service (2026-08-23), which is what the fix is built on: a 78-residue fold returns 200 in 1.7 s; six identical 300-residue folds returned three 200s (1.4–2.6 s) and three 504s at exactly 29.6 s — an AWS API gateway ceiling over a cold backend. It is not length-dependent: a 180-residue sequence failed where a 300-residue one had just succeeded. A 504 therefore says nothing whatsoever about the sequence. The state machine's own retry isTHROTTLE_DAYS(6) away and gives up afterRETRY_CAP(5), so a ~50 % per-request failure rate produced rows that had burned up to 11 retries over months with no structure._fold_via_esm_atlasnow retries within the attempt (ESMFOLD_MAX_ATTEMPTS, default 5, linear backoff), which takes ~50 % to ~97 %. Sequence-level rejections (400/413/422) are still never retried — the answer cannot change. NewEsmfoldOverCapacity, distinct fromEsmfoldUnfoldableand from a plain error, so "a free service is popular" stops being reported as a defect. Separately confirmed thatESMFOLD_PUBLIC_MAX_RESIDUES=400is correct rather than arbitrary: 450 residues gets an immediate413, in under a second.
Changed
- The admin enrichment table stops crying wolf. It had one
Errorscolumn fed by everytransientrow, and it countedpendingnowhere at all — so esmfold read "522 errors" (520 of which were an upstream being busy) and alphafold's 498pendingrows were simply invisible. Every one of those 498 is a protein with no UniProt accession for AlphaFold to key on, and 183 proteins carry no sequence at all: curation gaps that no number of retries can close. Four columns now separate what needs a human from what does not — Errors (real, actionable), Busy (upstream at capacity), Blocked (waiting on data a curator must enter), Waiting (may still resolve itself) — plus Not run, which distinguishes "ran and found nothing" from "never ran", the precise ambiguity that let SignalP read as a tidy 0 % for years. Adormantbadge marks a source with no rows at all. Colour is now carried only by the columns that mean act on this. - The status/detail vocabulary moved to
app.services.enrichment_status, so the dashboard buckets on the same constants the workers write. It previously restatedretries >= 5as a literal, which would have desynced silently the momentRETRY_CAPmoved.
Added
scripts/backfill_signal_peptides.py— dry-run by default,--applyto write, resumable (candidacy is a property of the query, not a checkpoint file), batch-committed, and rate-limited on the hosted predictor only.--forcerescues rows whose last attempt failed;--report-fingerprintprints the reference-database digest before and after. ⚠ A successful run invalidates the public DIAMOND databases, by design.current_fingerprint()hashes(has_signal_peptide, cleavage_site, sequence)becauseexport_reference_fastawrites the mature chain — a signal peptide landing on an unchanged sequence really does change the FASTA. Three new tests intest_reference_rebuild.pypin that pairing, including one that asserts overmature_sequence()'s actual attribute reads, so adding a fourth input to the trimming rule fails the build rather than silently letting the databases on disk disagree with the digest that claims to describe them.requirements-bio.txtfor the optional local predictors, with the licensing rationale for what is in it and what can never be.
Version 0.87.6
Fixed
- The tree's leaf count is counted, not subtracted (#415, follow-up to #308).
v0.87.4 started reporting how many organisms actually became tips, and derived
it as
organism_count - skipped_no_lineage. Verified against production immediately after the deploy: the footer said 780 taxa over a tree with 776 rendered tips. The number whose entire job is to be honest about what was drawn was itself wrong by 4. Two things stop an organism becoming a tip and only one of them is countable up front._build_newickplaces a tip withnode.setdefault(m.name, {}), and_lineage_to_internal_pathdrops a lineage's last rank as the organism's own — so a genus-only organism hangs its tip one level above its congeners, under the label that is precisely the genus node they created there.setdefaultreturns that node and the organism silently becomes internal. Live, on the whole corpus: Micromonospora, Rhodococcus, Microbispora, Roseateles.count_newick_leaves()counts tips from the Newick string, which is also the only thing that works on a Redis cache hit (where_build_newicknever runs).result_metadatanow carriesmerged_into_ancestoralongsideskipped_no_lineage, and the invariant `leaf_count + skipped_no_lineage + merged_into_ancestor == organism_count` is asserted. The footer names each reason rather than lumping them: *"776 taxa (of 795 matched — 15 have no lineage, 4 share a name with an ancestor)"*, because the two need different answers from a curator. The loss itself is not fixed here — what a genus-level record should look like on a tree that also holds its species is a topology decision, and the robust form of it (key_build_newickby organism id, not by name) touches a label contract that the tree page's parser and the plastic matrix both depend on. Tracked in #415. Making the count honest is what surfaced it, and is the half that must not wait.
Version 0.87.5
Changed
- **The sulfonated copolyester row is now the family row: one plastic for the
twelve ionic phthalic polyesters, with the diol carried in
substrate_composition** (curator Ruling A, 2026-08-20). 0.87.3 addedpoly(ethylene terephthalate-co-5-sulfoisophthalate)— one of the twelve copolyesters10.1016/j.jbiotec.2017.01.012(Haernvall et al. 2017) assays — and recorded the open question inREJECTED_TERMS: eleven more rows, or one family row with the diol insubstrate_composition. **The curator chose the family row.** These copolyesters are too niche to grow a 108-row plastics vocabulary by ~10% for a single paper's independent variable, and none of the eleven names is used in any literature outside these two papers. Row 105 is renamed in place topoly(terephthalate-co-5-sulfoisophthalate)rather than joined by a family row: a specific member and the family that contains it are the near-duplicate this module exists to prevent, since a producer naming either would be right and the two would split every query that touches the material. The name elides the diol and keeps both diacids, which is what makes it a family without making it a bucket — nothing non-sulfonated and nothing built on ortho-phthalate can drift into it. The literature's own term, "ionic phthalic polyester" (the J Biotechnol title), leads thefull_nameso it is searchable without being a key. ThePETparent is detached, and that is the substantive half of the change. 0.87.3's parenting rested on "the backbone is still ethylene glycol and terephthalate" — true of exactly one of the twelve members, and of none of the three glycol-ether ones, so keeping it would make aPET-and-descendants filter return an enzyme acting on poly(tetraethylene glycol terephthalate-co-5- sulfoisophthalate).PENis the precedent for an aromatic polyester whose relation to PET is a resemblance rather than a derivation. **The rename was measured against production before it was written, because renaming a referenced row is a different and riskier operation than renaming an unused one.** Row 105 carried 0 microorganisms, 0 proteins, no degradation links, no children, no monomers, no components and no containing blends; all 395 ingestions were scanned and none names it inextracted_data— the one prose mention, ingestion 288 (10.1021/acs.est.7b00062), files its fourteen analogues under the placeholderPlastic. The only artifact naming it anywhere is the not-yet-submitted extraction of the paper this ruling is about. The seeders could not express a rename at all: they are name-keyed upserts, so changing the literal would have inserted the new name and left the old row standing beside it.RENAMED_PLASTICScloses that, runs before the upsert, and refuses three cases quietly rather than aborting a deploy over the state of the data — the old name absent (nothing to do), both names present (a merge only a curator can make), or the old row carrying references (a rename re-labels evidence filed under the old meaning; whether it survives the new one is a curatorial question). The reference check is discovered from the mapper metadata rather than from a list of tables, so a new foreign key ontoplastics.idcannot make it quietly incomplete. The PreSync job now prints the rename count, because a rename is the one thing that step does that is not purely additive.full_nameandabbreviationare cleared by the rename so the upsert refills them. This is the one point where "never clobber an existing value" is deliberately suspended: that rule protects a better description of the same material, and a re-purposing changes which material the row is, so a description of the old one is not a correction to keep but a statement that has become false.
Added
- **Nine diols, so the family row's distinguishing component can actually be
named.**
1,5-Pentanediol,1,6-Hexanediol,1,8-Octanediol,1,12-Dodecanediol,1,2-Propanediol,1,4-Cyclohexanedimethanol,Diethylene glycol,Triethylene glycolandTetraethylene glycol.Ethylene glycol(legacy v1 import),1,3-Propanedioland1,4-Butanediolwere already present. Collapsing twelve plastics onto one row only holds if every member stays distinguishable, andsubstrate_composition[].componentis validated against the compounds vocabulary — so a missing diol does not fail loudly, it just leaves nine of the twelve substrates unidentifiable while the composition still validates. That would collapse the paper's independent variable silently, which is worse than the vocabulary growth the ruling was avoiding. The convention is the one this corpus already runs on: the diol is a null-fraction component beside the diacid mole fractions, as1,4-Butanediolis in 9 papers and 75 occurrences.is_additive=Falsethroughout — a diol is half of a polyester backbone, never something dispersed in it. This is the field most likely to be got wrong from outside the paper, since several of these are also sold as solvents, humectants and antifreeze; the flag records the role in this material.1,2-Propanediolis a separate row from1,3-Propanedioldespite sharingC3H8O2, on theBis(2-ethylhexyl) phthalate/Dioctyl phthalatereasoning: the paper assays both and the difference is a finding — the branched C3(1,2) polyester released 5.46 mmol TA/mol against the linear C3's 12.7. PlasticTerm.no_abbreviation— a row that has no abbreviation and none to be derived. 0.87.3 used the column width as a proxy ("a 51-character name will not fitVARCHAR(50), so NULL"), and this release is where the proxy and the real question come apart: the family name is 42 characters and fits, so without the flag the rename would have quietly filled in an abbreviation column whose NULL was a deliberate decision — an "abbreviation" that abbreviates nothing, and a side effect of a rename rather than a decision anyone made.
Version 0.87.4
Fixed
- **The phylogenetic tree carries every organism matching the filter — the cap
was in the browser, not in the query (#308).** v0.87.3 removed the
.limit(500)from_resolve_ids()and the reported symptom did not move: the Microorganisms page still showed 795 organisms and its "View as Tree" button still produced a 500-leaf tree. Reproduced against production by driving the real button and capturing the requests: ``` GET /api/v1/microorganisms?polarity=positive&limit=500&offset=0 → 500 of 795 POST /api/v1/tree/build {"microorganism_ids": [ …500 ids… ]} ```limit's maximum on that endpoint is 500, deliberately (B2 — one request must not be able to materialise a table), andbuild_treeusesmicroorganism_idsverbatim when they are given. So the page had one page of ids, the tree had one page of leaves, and_resolve_ids()— the function that had just been fixed — never ran at all. **The root cause wasapp/templates/landing/microorganisms.html:562,qs.set('limit', '500')**, and no amount of server-side work could have reached it. The page now sends the filter and the server resolves it, which is the only arrangement with no number in it to outgrow. Three parts:TreeBuildRequestcarries the whole microorganism browse filter set —q,plastic, year range,temp_class,environment,location,polarity,include_negative,evidence_scope, every taxonomic rank. A shorter list would have re-created the bug quietly: the page can filter by all of those, so a request that could not express them would force the page back to resolving ids itself.microorganism_idsstays and still wins when present — it is the only way to ask for a set no organism filter describes (the proteins page's organisms-behind-these-proteins tree, and/tools/build-tree?ids=)._resolve_ids()answers withmicroorganism_list_filters()— the definition of "a microorganism matching these filters", already shared by the list endpoint,/api/v1/stats's headline and the exports. That is the part that cannot silently re-break: the tree and the table now answer one question, so a filter added to the list reaches the tree with it.app/static/js/microorganism_filters.jsreads the form once for both the table and the button, andapp/static/js/tree_from_filters.jsposts those filters. Shipped modules rather than inline template script, because the browser tier can only guard a file the page loads. One deliberate behaviour change: with no filters at all, a tree used to mean every row in the table and now means every row the browse list shows — 795 rather than 1,308 — because the shared definition reads an unqualified request as "has at least one positive result", the ruling that makes the list total and the home page headline one number.include_negative: truestill reaches every public record. And the signed-out path is gone rather than fixed. The button used to bounce a visitor with no token to/tools/build-treecarrying only name/lineage/rank, silently dropping plastic, polarity and evidence scope — this same defect in miniature.POST /api/v1/tree/buildtakes an optional user by design (building a tree ingests no user data), so there was nothing to gate. A failed build now reports the failure instead of navigating to a tree of a different question.
- The tree page counts the leaves it drew, not the rows it was handed.
_build_newickskips an organism with no lineage string — it has nowhere to hang — with a warning in a worker log, while the page's footer counted the plastic matrix, which has a row per organism whether or not it was placed. In the live corpus 16 of the 795 organisms have no lineage, so the honest leaf count is 779 and the footer would have said 795 over it.result_metadatanow carriesorganism_count,leaf_countandskipped_no_lineage, and the footer reads "779 taxa (of 795 matched — 16 have no lineage and cannot be placed)". Same failure family as the truncation above: a phylogeny missing organisms renders exactly like a complete one, so the number has to be carried out to the reader.
Testing
tests/e2e/tree_from_filters.spec.js— **the tier that would have caught this.** It clicks the real button against a fake backend that enforces the real 500-row list cap and resolves filters uncapped, then asserts the resulting tree's leaf count equals the number of matching organisms (795 > 500). Verified red first: against the v0.87.3 handler 4 of its 6 cases fail, the headline one reporting a 500-id POST body.tests/test_tree_build_from_filters.py— the server half and the contract between them: the request model is a **superset of the list endpoint's own filter parameters** (so the page can never again have a reason to resolve ids itself), the resolver returns 520 of 520 and every one becomes a leaf, polarity reaches it, and over PostgreSQL the tree's id count equals the total the list reports for the same filter. Verified red first against a reinstated.limit(500)(assert 500 == 520) and a reinstated client-side id fetch.tests/test_tree_no_leaf_cap.py's fixtures now seed real degraders, because the resolver answers the browse list's question and an organism with no interaction is in no list.
Version 0.87.3
Added
- **
PBA— poly(butylene adipate), the aliphatic endpoint of a series whose other members are already here.**10.1016/j.nbt.2015.11.004(Perz et al. 2016, *Substrate specificities of cutinases on aliphatic-aromatic polyesters and on their model substrates*) is blocked on this one name: two positive interactions, HiC and Thc_Cut1, each carrying the adipic acid released after 72 h at 50 °C. The paper synthesises a graded Ada:Ta series and this is its Ta0 endpoint (Ada100_Ta0, "PBAda" in Fig. 5) — adipic acid and 1,4-butanediol, no terephthalate in it at all. It must not be folded intoPBAT, the one tempting move because both come out of the same table: the Ada:Ta ratio is the experiment's independent variable, so filing the Ta0 sample under the aliphatic-aromatic copolyester collapses the axis the study was built on and asserts terephthalate in a substrate that contains none — thePEN-into-PETargument one family over, and it lands harder here because Thc_Cut1's largest product release on any substrate in the paper is on this sample. NorPBS/PBSA: same diol, different diacid, andPBSAis the dangerous near-miss since it does contain adipate and would additionally assert a succinate comonomer.kind='homopolymer',polymer_class='polyester', unparented —PBAT,PBSandPBSAare siblings on the diacid axis, not ancestors. The abbreviation is shared with poly(butyl acrylate) in the wider literature, which is why thefull_nameis load-bearing. - **
poly(ethylene terephthalate-co-5-sulfoisophthalate)— a sulfonated PET copolyester — and5-Sulfoisophthalic acidas a compound.**10.1016/j.jbiotec.2017.01.012(Haernvall et al. 2017, *A new arylesterase from Pseudomonas pseudoalcaligenes can hydrolyze ionic phthalic polyesters*) fails validation with 26 errors: 12 plastic names and 14substrate_compositioncomponents. Parented toPET: theApexashape, not thePENshape. The backbone is still ethylene glycol and terephthalate, with 9–30 mol% of the diacid replaced by sodium 5-sulfoisophthalate. PEN was left unparented because it swaps the diacid outright, and the stated objection was that aPET-and-descendants filter must never return an enzyme that has never released terephthalate — here PpEst releases terephthalic acid and MHET, and the paper reports no 5-sulfoisophthalic acid in any hydrolysate. Equally it must not be folded intoPET: PET is not hydrolysed under these conditions and the ionic comonomer being what opens the chain is the whole result.kind='copolymer', which the model expresses directly. The diacid is a compounds row and refused as a plastics one: nothing acts on it, and a plastics row would make a monomer selectable as an actor's substrate. Unlike2,6-Naphthalenedicarboxylic acid, refused last release becausePENis a homopolymer and nothing needed its diacid, this one has 14substrate_compositionentries waiting on it whose mole fractions are the paper's second independent variable. **The paper is not unblocked by this, and the shortfall is recorded rather than papered over. It assays twelve** copolyesters, one per diol; the curator ruled on the ethylene member. The compounds row clears all 14 composition errors and this row clears 1 of the 12 plastic errors, leaving 11. The other eleven diols are a deferral awaiting a ruling — including whether they want eleven rows or one family row with the diol carried insubstrate_composition— and are recorded inREJECTED_TERMSwith that question stated. - **Three aromatic medium-chain-length PHAs — and their published
abbreviations deliberately not used as keys.**
10.1074/jbc.m608119200gives poly(3-hydroxyphenylhexanoate), poly(3-hydroxyphenylheptanoate) and poly(3-hydroxyphenyloctanoate), all genuine bacterial polyesters assayed as polymers by a purified depolymerase. They are classified exactly asPHHxandPHDare — homopolymers onpolyhydroxyalkanoate, parented toPHA; the phenyl side chain is what stops them being filed underPHO/PHN/PHHx. **The paper's own abbreviations arePHPhHandPHPhh, which differ only by the case of the final letter, and they cannot be keys here.** Measured against this codebase rather than assumed: PostgreSQL's unique index onplastics.nameis case-sensitive, so both rows would store without complaint — while every lookup is case-insensitive.plastic_resolve.resolve_plasticcomparesfunc.lower(name)andfunc.lower(abbreviation)and returns.first(), an arbitrary pick between the two; its normalised fallback lower-cases as well; andingestion_promotionmatches withILIKE. Two distinct polymers would therefore resolve onto one row with no constraint violated and no error raised — an observation filed against the wrong material, silently. So the canonicalnameis the chemical name and the abbreviations are case-fold-safe:PHPhHx,PHPhHpandPHPhO, taking the hexanoate/heptanoate stemsPHHxalready uses. The cost is stated rather than hidden: neither literal from the paper is a key, so an extraction written against its abbreviation list resolves to nothing until re-extracted. A miss is visible and recoverable; a silent merge is neither. - **
Bis(2-ethylhexyl) phthalate(DEHP) as its own compound, by curator ruling.**compoundsalready holdsDioctyl phthalate, and the two share the formulaC24H38O4, which is exactly why folding one onto the other looks free. It is not: "dioctyl phthalate" strictly names the di-n-octyl ester (DnOP, CAS 117-84-0) while DEHP is the branched 2-ethylhexyl ester (CAS 117-81-7), and DEHP is the one actually in pPVC. Mapping a paper's DEHP onto the DOP row would put a compound the paper never used intosubstrate_composition— wrong, not merely coarse.is_additive=True, on theRhodamine 6Gprecedent. The existingDioctyl phthalatedescription read "Plasticiser (DOP / DEHP)", which the ruling overturns, and the literal is corrected here. **That correction reaches a fresh database only** —seed_compoundsfills NULLs and never overwrites, deliberately, so the deployed row keeps the old wording until a curator edits it. Flagged here rather than forced, because overwriting a description on every deploy is the curator-clobbering this seeder exists not to do.
Fixed
- **A vocabulary name too long to be an abbreviation is dropped rather than
copied.**
seed_plasticssetabbreviation = namefor every row, which was free while every name was an abbreviation. The sulfonated copolyester's name is 51 characters against aVARCHAR(50)column, so copying it would have aborted the PreSync seed job on PostgreSQL — a deploy-time failure invisible to every SQLite-backed test, since SQLite does not enforce the width. Truncating would be worse than NULL:resolve_plasticmatches onabbreviation, so a mangled prefix becomes a second resolvable identifier for the same material. The limit is read off the column so the two cannot drift, and NULL is what the promotion path already writes for a row with no abbreviation. An explicitabbreviationon a term now wins over the name, which is what lets the three aromatic PHAs above be keyed by chemical name and still carry a short handle.
Version 0.87.2
Fixed
- A filter-built phylogenetic tree carries every organism it matched (#308).
_resolve_ids()ended in a bare.limit(500)with noORDER BY, so a filter matching more organisms than that built the tree from an arbitrary 500 of them — and not even the same 500 twice — while nothing told the caller. The failure mode is not an error message: a clipped phylogeny renders exactly like a complete one, so someone filtering by a phylum drew conclusions from a tree that was quietly missing organisms. At 795 organisms in the live corpus the cap was reachable today, not hypothetically. The cap is gone rather than made visible. The issue proposed keeping it and reporting "showing 500 of N"; Victor overruled that — *"it should have no limit. we dont have that many and it generates bad side effects"* — and the measurement agrees. Seeded on PostgreSQL with 795 organisms, 40 plastics and 2,769 interactions: resolving the whole corpus takes 23 ms (adomain:filter over 531 of them, 23 ms; aplastic_idfilter, 7 ms), the worker'sbuild_taxonomy_treetakes 67 ms, and the poll payload is **593 KiB raw / 16 KiB gzipped** for an 18 KB Newick plus the 40 × 795 plastic matrix. Nothing there justifies discarding data. The 500 arrived with the original tree feature (36b431e, 2026-05-26) carrying no stated reason, so nothing was traded away that had been argued for. TheORDER BYstays on its own merits, and is on both queries. Rows are ordered by lineage, then name, then id — taxonomic grouping first, and a total order so two identical requests resolve to the same list. It also has to be on the worker's re-fetch (id IN (...)has no inherent order), because_build_newickinserts nodes as it walks the rows and therefore lets row order decide sibling order in the Newick.
Version 0.87.1
Fixed
- The four landing-page stat cards are the same height again (#316). They
carried no
h-100, so each card was only as tall as its own text — and the four no longer carry the same amount of text. #402 gave Microorganisms a545 tested whole · 171 via their enzymebreakdown and Proteins awild-type · engineeredone, while Plastic Types and Papers have none, so the pair the reporter photographed sat 17 px apart at desktop and the grid read as ragged. Measured across eight viewport widths, the cards sitting beside each other were unequal at seven of them; they are level at all eight now — including 390 px, where1,284wraps and Papers was 68 px taller than Plastic Types. The class is the one this page already uses: every.feature-cardbelow the hero carriesh-100, as do the dashboard's own.stat-cards. Where an anchor wraps a card it needsd-block h-100too —height: 100%does nothing to an inline box, so the card would stretch to the anchor rather than to the column. No CSS change and no new dependency. The two rows still differ from each other, and that is deliberate. A wrapped Bootstrap card grid sizes each flex line to its own content, and levelling the lines would take a fixedmin-height— measured, the right constant would be 314 px at 320 px wide and 126 px at 768 px, so any single number is wrong at most widths and shows as dead space under the short cards.
Version 0.87.0
Added
- **
accession_notesonExtractedProteinVariantActor— the caveats are more load-bearing on a variant than on a wild-type, and it was the one actor with nowhere to put them.** The occasion is a correction on10.1002/anie.202525215: ten sequence-bearing nodes held a His-tagged pET26 construct where ruling R-1 requires the deposited PDB 8OTA record verbatim, and six of the ten were protein variants. The note explaining the swap — including anumbering_offsetcorrected −32 → −34, silently wrong by two residues against the new frame — could not be written where it belonged.accession_noteswas declared onExtractedProteinActorand on nothing else, so a note on a variant went onto an undeclared key and was discarded between the200and the database. A variant's sequence is derived — the parent record plus this variant's substitutions, gated by a wild-type-residue checksum — and it carries anumbering_offset(R-7) that has to be reconciled against the paper's own numbering. Every one of those steps can go uncertain in a way a curator must be told about, and none of them exists for a wild-type. Writing the note on the parent instead is not a workaround: the parent record is shared by every sibling variant, so it would attribute a statement about one derived sequence to all of them. The producer had been sending it all along. Swept across the 397 extraction JSONs on the pipeline's disk, wire-shaped exactly as the submit stage sends them: 29 notes on 3 papers (10.1038/s41467-024-45523-5,10.1016/j.polymdegradstab.2017.01.006,10.1038/s41467-025-61599-z) landed on the undeclared key and were dropped without a word — *"the paper assays this variant but never lists its substitutions; the mutation set is taken from the originating study it cites"*, *"it differs from BAV72205.1 at exactly one position, precursor residue 111 = paper residue 77 + 34"*. That is exactly the prose R-1 and R-7 exist to preserve. It travels as far as the wild-type's does and no further, deliberately: declared in the contract (so it reachesGET /openapi.jsonand the producer's preflight), stored verbatim inextracted_data, and rendered on the curation form as its own control. **Promotion writes it into no column — for either actor.**proteinshas noaccession_notescolumn either; this is prose for the curator reading the ingestion, and giving the variant path a column the wild-type path does not have would be a new asymmetry in place of the one being removed. So no migration: the field lives inside a JSON column that already exists. A control was not optional.approve_ingestionoverwritesextracted_datawholesale, so a leaf the form does not render is deleted the first time a curator presses Approve — which is whytests/test_curation_form_coverage.pyderives the expectation from the schema in both directions. The exportable-leaf count goes 102 → 103, in the Python tier and in the Playwright pin.
Fixed
- **An unrecognised key in an extraction is now named, in the log and in the
response. It is still accepted.** Pydantic's default
extra="ignore"meant an undeclared key was dropped between the wire andmodel_dump()with no error, no log line and a200— which is why the variantaccession_notesabove went unnoticed for months, and why nothing complained when the producer emitted"accession_notes": nullon those six nodes.app.services.unknown_fields.unknown_field_pathswalks the raw request body againstExtractedData— the only moment the dropped keys still exist — andPOST /ingestionsandPATCH /ingestions/{id}report what it finds two ways: aWARNINGnaming the ingestion id, the DOI and every path, and anunrecognised_fieldslist on the response. Paths useapp.services.payload_diff's notation (interactions.0.protein.ec_number, a 422locminus the envelope), so one resolves through the curation form's existing error locator with no second mapping to keep in step.extra="forbid"was considered and rejected, and so was emittingadditionalProperties: falseinto the OpenAPI document, which looks like the cheaper version of the same idea and is worse. Forbidding 422s any payload carrying a stray key, including the stored extractions the submit stage replays. The OpenAPI route would not change what this server accepts — but the producer's own preflight validates againstGET /openapi.jsonand refuses to submit what fails there, so it would convert "we dropped a key" into "this paper is blocked", one repository away and with nobody watching. A blocked paper mid-batch is worse than a warning.tests/test_unknown_extraction_fields.pypins both decisions. **Noise is the failure mode this had to survive, so it was measured before it was built. Over those same 397 extractions: 288 carry a top-levelsupplements_required** — a producer control flag the extract stage consumes locally, never a claim about the paper — so reporting it would fire on 73% of submissions on one uninteresting key and the mechanism would be ignored inside a week. It is allow-listed by exact name at the top level only, withextraction_model(pipeline provenance, delivered as its own PATCH field); the same word on an interaction is not that flag and is still reported. After that allowance and the variant field above, 4 extractions in 397 trip the warning at all, on six keys, every one of them a real loss:observations[].observation_typeandinteractions[].confidence(both retired in Feature 2.5),microorganism.sample_source(the field lives on the interaction),microorganism.thermophilic,kinetics.notes, andprotein.cross_references(owned by V2 enrichment, never extracted). Four papers in four hundred is a signal; two hundred and eighty-eight would have been noise. A free-formdictfield is a leaf and is never walked — descending intoadditional_conditionsorculture_collection_idswould report every user-chosen key as unknown and destroy the signal on any paper that used them as intended. The walk is bounded at 200 paths for the reasonpayload_diff.MAX_PATHSis bounded. Scoped out deliberately:POST /ingestions/{id}/approveis not instrumented. Its payload comes from a form whose controls are derived from the schema and asserted exact in both directions bytests/test_curation_form_coverage.py, and the record it re-posts was itself produced bymodel_dump(), so there is no undeclared key for it to carry.
Note for operators
Nothing in this release requires a migration. accession_notes on a variant is a field of a Pydantic contract stored inside the existing extracted_data JSONB column; no table changes. Extractions already stored are unaffected — the key was never persisted, so there is nothing to backfill, and the three papers that sent it will deliver their notes on their next submission.
Version 0.86.3
Added
- **
PENin the plastics vocabulary — the fourth member of a series three quarters of which was already here.**10.1021/acscatal.0c05126(Cui et al. 2021, *Computational Redesign of a PETase for Plastic Biodegradation under Ambient Condition by the GRAPE Strategy* — the DuraPETase paper) is blocked inNEEDS_HUMAN_REVIEWagainst an extraction of 161 interactions on this one name.PET,PBTandPTTare all present; PEN's absence was arbitrary, which is the argumentPTTitself was added on. It must not be folded intoPET, and this paper is the one that proves it. It runs the three side by side and the products differ: DuraPETase hydrolyses PEN to 2,6-naphthalenedicarboxylic acid and its mono-/bis(2-hydroxyethyl) esters, where PET gives TPA/MHET/BHET and PBT gives TPA with its mono-/bis(4-hydroxybutyl) esters. Filing the PEN arm underPETwould put two arms of one specificity experiment on one row and assert that the enzyme released terephthalate from a substrate that contains none — and the rates separate them in the direction that makes the collapse worst, PEN degradation markedly enhanced by the engineered variant while PBT stays slow.kind='homopolymer',polymer_class='polyester', unparented. Unparented deliberately, and unlikeApexa: Apexa hangs offPETbecause it is modified PET (the same two monomers plus an undisclosed third), while PEN replaces the diacid outright — 2,6-naphthalenedicarboxylic acid for terephthalic acid.PBTandPTTare the same substitution on the diol side and both are unparented, so four siblings beats three siblings and one child distinguished by nothing. Thefull_namenames the 2,6 isomer rather than reading "polyethylene naphthalate", because the legacyPEFrow — poly(ethylene furanoate) — is the furan analogue on that same diacid axis and one letter away in the abbreviation. Rhodamine 6Gin the compounds vocabulary — a dopant, not a plastic. The occasion is an extraction of a fluorophore-doped PET / PA6 specimen: the polymer resolved, the dye had nowhere to go, sosubstrate_compositionfell back to the base polymer alone and the extraction logged a data gap saying so. Nothing is blocked by this and it carries no scope implication — it is a fidelity fix, and what it repairs is that a doped specimen and an undoped one were the same record.is_additive=True, unambiguously — a tracer dye is never a blend partner or a comonomer at any loading, so it does not get starch's NULL. Formula is the chloride salt (CAS 989-38-8), the form sold and the form a paper means when it writes "Rhodamine 6G". Refused as a plastics row, which is the one way this could have gone wrong: a dopant is not a material under study, nothing acts on it, and a plastics row would make it selectable as an actor's substrate.compoundsis reachable from an extraction only throughexperiment.substrate_composition, which is exactly and only what it claims. Two further refusals are recorded inREJECTED_TERMSwith their arguments: parentingPENontoPET, and2,6-Naphthalenedicarboxylic acidas a compound — the counterpart of the existingTerephthalic acidrow, refused on the3-Mercaptobutyraterule (PEN is a homopolymer, so nosubstrate_compositionneeds its diacid, and the PET-pathway compounds are there for the reaction-step subsystem rather than as a monomer set).
Version 0.86.2
Added
- **
P(3MP)andP(3MB)— the two homopolythioesters, and the half of a v1 paper that has been missing since v1.**10.1007/s00203-004-0715-z(Elbanna, Lütke-Eversloh, Jendrossek, Luftmann & Steinbüchel 2004) is blocked inNEEDS_HUMAN_REVIEWwith **32 validator errors, every one of them one of these two names, against an extraction of 125 interactions and 219 observations**. Ruled in scope by the curator, 2026-08-13. What they are. Polythioesters are the sulfur analogues of the polyhydroxyalkanoates: the backbone ester oxygen replaced by sulfur, giving a thioester linkage-S-C(=O)-where a PHA has-O-C(=O)-. The paper measures the substitution rather than asserting it — thioester C=O at 1,688 cm⁻¹ against the oxoester's 1,735 cm⁻¹ by IR, plus elemental sulfur, ¹H-NMR and MALDI-TOF. Both are biosynthesised, accumulated in vivo as intracellular granules by recombinant E. coli JM109 (pBPP1) —buk/ptbfrom *Clostridium acetobutylicum* feeding a Thiocapsa pfennigii PHA synthase — and purified from the cells. PHA synthases are unspecific about the linkage type; PHA depolymerases, which is this paper's finding, are not. R-16 was settled first, because if the actors had been assayed on a thioester monomer the right answer was to delete 32 interactions, not to mint two terms. All 32 are against the polymer: purified homopolythioester granules as a suspension (turbidity), as an agar-overlay emulsion (clear zone), or as sole carbon source. The paper's free-3MP and 3,3'-thiodipropionate growth tests are small-molecule carbon sources and correctly produce no interaction. This is the missing half of an existing entry, not a new class.P(3HB-co-3MP)has been a plastics row since v1 — and both of itsdegraders_list.tsvrows cite this paper. v1 recorded the copolymer positives and had nowhere to put the homopolymer negatives, so the study's title and conclusion were lost. Every result on the two new rows is negative, and that is the point: no PHA-degrading bacterium grew or cleared a plate, and none of the five purified depolymerases showed activity, while the same enzymes hydrolyse the copolymer in the same tables with activity falling as 3MP content rises (25,000 U/mg on PHB → 9,500 on the copolymer → zero). A negative on a named polymer head-to-head against a positive is substrate-specificity evidence — theBR/CRargument verbatim. Bothkind='homopolymer',polymer_class='polythioester', unparented. Also adds the compound3-Mercaptopropionate, without which the paper's three copolymer batches (12, 23 and 36 mol% 3MP) are one indistinguishableP(3HB-co-3MP). Five refusals are recorded inREJECTED_TERMSwith their arguments: a genericPTErow (the class belongs onpolymer_class, andPTS/PTCcarry nofull_nameso reading either as "polythioester" would be aPHC-style guess); parenting ontoPHB/P3HP/PHA(theIR-under-NRmistake, and an "all PHB" query would return a polymer no PHB depolymerase touches); merging the two into one row (different monomers, and Table 4 scores poly(3MB)-where poly(3MP) readsnd— a measured negative and an untested pair are not the same record); theP3MP/P3MBspelling (one interior character fromP3HP/P3HB, and that character is the finding); and3-Mercaptobutyrateas a compound (nothing to add it for — 3MB's polymer is a homopolymer, so nosubstrate_compositionneeds it). The legacyP(3HB-co-3MP)row is not reclassified: the seeder fills NULLs, legacy rows are a curator's (theNRprecedent), and that backbone is genuinely half oxoester, so neither class is honest for it.
Version 0.86.1
Housekeeping
scripts/repair_superseded_organism_litter.pypreserves before it deletes. Found by dumping the doomed rows for a rollback capture and reading what was actually in them — the script as merged in 0.86.0 violated **0.86.0's own stated invariant**, that a supersede must never lose curator state. Two things went with those rows and neither was recoverable from anywhere else. The names printed in the paper stopped resolving: a search for either binomial returns exactly the row about to be deleted, and 3049 carried no synonyms, so after the delete a reader searching a string that appears in10.1038/s42003-021-02401-wwould have got nothing. **The curator's working was destroyed:** both rows carry a curator-settaxonomy_status/taxonomy_causeand ataxonomy_noterecording a live NCBI check on 2026-08-13 — and 2996's note is where the merge was foreseen, in terms, before it was ruled on ("the two may be one organism recorded twice, which is a merge judgement on the paper and was deliberately not made here"). Deleting the rows would have deleted the evidence for the correction that deleted them._preserve()now runs first, inside the same transaction: it writes both superseded binomials tomicroorganisms.synonymson 3049 and carries the NCBI verification and the merge observation onto itstaxonomy_note, attributed and dated, quoting 2996's note rather than paraphrasing it. Ordering is the substance — preserving first means a crash mid-repair leaves the *old rows present*, the state we started in, rather than one where the names resolve to nothing and the only copy of the reasoning is a JSON file. The shape is the established one, not a new one: rows 2563/2565/2576 already keep superseded names insynonyms, andscripts/repair_token_drop_organisms.pystates the rule — the reported name "is kept on the repaired row as a synonym so the paper's own binomial still finds it".synonymsis weight-B infn_update_microorganism_sv(rebuilt by the trigger on this very UPDATE) and is half of the?name=ILIKE, so one statement restores both search paths. Four pre-condition pins (3049'ssynonymsandtaxonomy_noteare empty; each binomial currently resolves only to the row about to go) and five post-conditions (both synonyms present, note non-empty, each binomial resolves to 3049, full-text agrees with ILIKE), all refusing like the rest. Measured against production read-only before writing: the 4 pre-conditions hold, and **all 5 post-conditions are currently false** — a pin that is already true before the write proves nothing.
Version 0.86.0
Rebased onto 0.85.0. #401 (0.84.3) and #402 (0.85.0) landed underneath this change and it touches none of their files — not the proteins list, not the microorganism list or query.py, not headline.py, result_colour.js or evidence_table.js.
Fixed
- **Correcting an approved paper left the old revision's records behind, and
whether it did was decided by the client.**
POST /ingestions/{id}/reopenis the sanctioned way back into an approved paper: it creates a new ingestion carrying a copy of the payload and pointing at its parent throughsupersedes_id, and approving that revision is what retires the parent. Except that the retire was gated on the approve request'smodefield —replacedeleted the parent's rows,merge(the default, and what every caller that is not the review page sends) did not. A rule that only holds when the client remembers to ask is not a rule, and reopen is the correction mechanism, so it littered on every use that went through anything else. Measured on production: ingestion 213 (10.1038/s42003-021-02401-w, a 469-organism fungal phenotyping survey) was reopened as 265, corrected, and approved in merge mode. Two strains —BRFM 2781andBRFM 3051— had been recorded under two different unpublished manuscript names and are one taxon, ruled to be stored asNectriaceae sp.(NCBI 1756110). The corrected organism was created; the two wrong ones stayed. The paper carries **1,033 interactions where its current revision asserts 1,031, and shows three organisms where it asserts one** — two of them under names that exist in no nomenclature (Dialonectriellais not a published genus; Index Fungorum, Species Fungorum and GBIF all return zero for both). 213 is stillapproved. Approving a revision now retires the run it descends from **whatevermodesays**, reading the chain fromsupersedes_id— the linkreopenalready wrote and that approve simply did not read.mode=replacekeeps its own, explicit meaning: it is how a fresh extraction of an already-approved DOI — which has nosupersedes_id— retires the run before it. That DOI inference stays explicit and stays opt-in, because a second contributor may legitimately hold their own ingestion of the same DOI (DECISIONS.md §7.2 — identity is the submission, not the DOI) and retiring it by default would delete a stranger's records. - **Retire now means "delete what the new revision did not re-assert", after the
promotion — not "delete everything and write it again" before it.** The
delete-first shape
replaceused is expensive in a way that is invisible until it happens: promotion's dedup reuses a row it can match, so a correction that re-asserts an entity keeps that entity's row and itsPB…accession. Delete-first destroys the row andapplymints a new accession for the same organism. On paper 302 that is 421 organism rows unique to the paper, each re-accessioned, to fix two of them — and it puts a large transient orphan set through the global sweep, which is the failure surface 0.81.0 was written about.PromotionResult.touchedrecords the evidence rows a payload asserts; the retire deletes the superseded rows outside that set, re-stamps the ones inside it, and hands the entity-level question to 0.81.0's completeness-checked sweep. - The rows a correction keeps now name the revision that asserts them. They
used to keep the stamp of the run that first wrote them, and
ingestion_withdrawal.foreign_ingestion_idsreads stamps, not statuses — so a corrected paper could never be withdrawn again: the withdrawal refused with "also carries evidence from ingestion #213", forever. (Confirmed against production for paper 302 before the fix.) - A superseded run stops reading
approved— new `PaperIngestionStatus .superseded, migration0009_ingestion_status_superseded`, written at the revision's approve and never atreopen(an abandoned revision must leave the database exactly as it was, which is reopen's own promise). Before this, a corrected paper had two rows both readingapproved, one of which asserted nothing, and "which revision is live" was answerable only by comparing ids. Everything that means this run promoted now asksPROMOTED_INGESTION_STATUSESrather than== approved: the public re-extraction lineage on the paper page (which is made of superseded runs — filtering them out would have collapsed the card and made the change invisible), the supplement-delete and re-extract guards, andsubmission_promotion's already-promoted skip.reopenandwithdrawdeliberately stay strict== approved: both act on rows that are live now, and a superseded run has none. - **Pinned: approving a revision cannot move the microorganism headline or the
545/171 split as a side effect.** #399 put the
is_tombstoned/visibilitygate insideattributed_micro_interactions, and 0.85.0 routes the headline, the list, both exports and the split through it — while a supersede changes when a row exists. Structurally the two cannot interact: neitherattributed_micro_interactionsnormicroorganism_entity_unionreadsPaperIngestionat all, and nothing in the supersede path writesvisibilityoris_tombstoned. But "nothing in the path writes it" is the same kind of claim as "the gate is a no-op at 0 rows", which is the one that goes silent when it stops being true, so it is a test rather than an argument (test_pg_supersede_semantics.py, pg tier). It ships with its own control — a correction that genuinely drops an actor moves the headline by **exactly one**, out of the whole-organism half — because an assertion that a number did not move is worth nothing unless that number provably can.
Housekeeping
scripts/repair_superseded_organism_litter.py— dry-run-default, single transaction, declared expected state, refuses to write on any mismatch. It clears organisms 2996 (PBCFWV) and 3037 (PBM9VH) and their spine rows and leaves 3049 (PBKDQX,Nectriaceae sp.) intact. The general fix above is not retroactive — it changes what the next correction does, not what is already stored. Not run. Every pin was re-measured against production immediately before the script was written (0 mismatches); a declaration measured before a concurrent change is stale, not wrong. Blast radius if it is run:microorganisms1219 → 1217,interactions4353 → 4351,experiments11586 → 11582,observations16362 → 16358,strain_occurrences2150 → 2148;proteins,protein_variantsandpapersunchanged. Both removed rows carry a singlenegativeresult and no positive evidence, so they are in neither the headline 716 nor the 545/171 split — but they are in the 1219 and in the 456 negative-only bucket, so **#402's ratified cost figures (1219 public records, 716 in the default view, 503 out as 456 / 46 / 1) become 1217 and 454 / 46 / 1 the moment it is applied.** Flagged rather than silently absorbed.- Only two ingestions in production have ever been reopened, measured:
205 → 44 (
10.1155/2010/148547) was approved inreplacemode, left zero rows on 44 and recorded apaper_supersededevent — clean, untouched. 265 → 213 is the littered one. There is exactly onepaper_supersededevent incontribution_events, which says the same thing from the audit side.
Version 0.85.0
Merges after 0.84.2, which fixes the microorganism-list defects this change builds on.
Changed — API break, documented
- "A microorganism with evidence" now has exactly one definition. Six
numbers existed for the same noun, and no two of them agreed:
| surface | total on v0.84.1 |
|---|---|
| landing headline (
/api/v1/stats) | 716 | |/api/v1/microorganismsdefault | 843 | |?polarity=positive| 545 | |?include_negative=true| 1219 | |/api/v1/stats?evidence_scope=whole_organism| 545 | | PET plastic page vs?plastic=PETvs?plastic=PET&polarity=positive| 166 / 251 / 29 |query.microorganism_entity_union()is now the single builder — the direct analogue ofprotein_entity_unionin #398 — and/api/v1/stats,/api/v1/microorganisms,microorganisms.csvandmicroorganisms.jsonall count and page it.polarity,plastic,evidence_methodandsubstrate_formbecame predicates on the same interaction row, andevidence_scopereaches all of them instead of none of them. Verified against production data (not predicted — run): | query | before | after | |---|---|---| |/api/v1/microorganismsdefault | 843 | 716 = the headline | |?polarity=positive| 545 | 716 | |?evidence_scope=whole_organism| 843 (ignored) | 545 | |?evidence_scope=enzyme_only| — | 171 | |?plastic=PET| 251 | 166 | |?plastic=PET&polarity=positive| 29 | 166 = the PET page | |?min_year=1900| 596 | 716 | |?evidence_method=weight_loss| 189 | 161 | |?include_negative=true| 1219 | 1219 | | organisms with no evidence anywhere admitted by the default | 247 | 0 | Exactly one public record carries no credited evidence at all; under the old default all 247 organisms with no whole-organism interaction were admitted on the strength of aNOT EXISTSthat could not see their enzymes. It is now 0, and that one record is still reachable through?include_negative=true. **The way back to the oldpolaritysemantics is?evidence_scope=whole_organism**, which returns exactly the 545 that?polarity=positiveused to. - The list's default view is now the headline's question: positive results.
Victor's ruling — the number on the list page must be the number on the home
page. The old default asked a third question ("anything except an entity whose
evidence is exclusively negative"), which belonged to neither the card nor
any control and is what made the list say 843 under a card saying 716. That
view is retired; it has no control and no URL.
The cost, stated: of 1219 public microorganism records, 716 are in the
default view and 503 are not — 456 whose only evidence is negative
("tested, no activity"), 46 inconclusive but never positive, and 1 with no
credited evidence at all. None is hidden: the new Result control reaches
each class directly (
?polarity=negative,?polarity=inconclusive) and Everything, negatives included (?include_negative=true) still returns all 1219.microorganisms.csvtherefore moves 843 → 716 — the same class of decision as the 147-rowinclude_negativeincident #398 cites, and the reason it is called out here rather than left to be discovered in a download. Those figures are as-of 2026-08-14, before the supersede cleanup. That cleanup removes microorganism rows 2996 and 3037 — each a single negative result and no positive evidence — taking the public total to 1217 and this breakdown to 454 / 46 / 1. The headline 716, the 545 / 171 split and PET's 166 are unmoved by it, since neither row has positive evidence to contribute and neither is one of the 84 genus-or-family-level… sp.records inside the 716. Recorded so a later reader does not mistake the difference for a discrepancy. evidence_scopeis a public filter on/microorganismsand its exports, with a third value:organism_or_enzyme(default),whole_organism, andenzyme_only— an organism credited solely through an enzyme purified from it. The last two partition the first for any filter combination, which is what makes the landing card's split add up. An unknown value is a 422, as an unknownpolarityalready was. (0.84.2 refused the parameter outright; this release honours it.)
Added
- The landing card shows the split the headline rests on:
716 Microorganisms · 545 tested whole · 171 via their enzyme, each half a
link to the list URL that returns exactly it
(
?evidence_scope=whole_organism/?evidence_scope=enzyme_only). 716 stays as the headline. It is the definition the plastic pages, the phylogenetic tree and PlasticDB v1 all use, and the enzyme-from-a-metagenome case is sanctioned by the project's own extraction rulings — a purified enzyme with no cultured source is a first-class record here — so dropping to 545 would advertise a narrower database than the one that exists. But **24% of the number rested on a distinction the front page never mentioned**, immediately below a hero sentence promising that every entry is backed by experimental evidence. The split is what makes 716 honest rather than merely checkable./api/v1/statsgainsmicroorganisms_whole_organismandmicroorganisms_via_enzyme, following the existingproteins_wild_type/proteins_variantspattern. Both are computed under the requested scope, so they always sum to the headline. - The
/microorganismspage gained a Result control and an Evidence control. Without them the card's link was inert: the deployed page contained zero occurrences ofpolarity,include_negativeorevidence_scope, andsyncUrl()'shistory.replaceStaterewrites the address bar frombuildUrl()— so a filtered deep link silently un-filtered itself on the first interaction. A link that does that is worse than no link. - Plastic badges on the microorganism list are coloured by their own result.
Every chip rendered the same grey, so a row in a negative-filtered list gave
no hint which of its plastics carried which result. The colour is now *that
plastic's* result for that organism — an organism positive on PCL and
negative on PET shows a green PCL beside a red PET in one row. Where a plastic
carries several results the server already reduces them to the **best
polarity** (positive > inconclusive > negative), so a chip means "at least one
paper reports this". The vocabulary is the site's existing one, reused rather
than reinvented:
Evidence.resultColorinstatic/js/evidence_table.js, which the detail pages and the plastic page have always used — positive green, negative red, inconclusive grey. Each chip carries atitleandaria-labelnaming the result, so it survives greyscale and a screen reader.
Documentation
/documentation/reading-resultsdefined the headline as whole-organism positives — it documented 545 while the site shipped 716. It now states the two ways an organism reaches a plastic, which one the site uses, and that the browse list opens on the headline's own question with everything outside it reachable through the Result filter.
Version 0.84.3
Changed — BREAKING (API + exports)
- **"A protein" now means one question on every surface: an entity with at
least one positive result.** Measured on production the same day: landing card
685,
/proteinsdefault 715,/proteins?polarity=positive&include_negative=true685. v0.84.1 (#398) closed that gap from the link side — it pointed the stat card at the filtered URL. Ruling (Victor, 2026-08-14): the default itself should be that question, on the page and in the contract, because a page that disagrees with its own API is the same bug one level up. | surface | before | after | |---|---|---| | landing card | 685 | 685 | |/proteinspage, no query string | 715 | 685 | |GET /api/v1/proteins, no params | 715 | 685 | |downloads/proteins.csv/.json| 715 rows | 685 rows | |downloads/proteins.fasta| 606 records | 584 records | |?include_negative=true(any surface) | 926 | 926 — unchanged | |?polarity=negative/=inconclusive| 276 / 21 | unchanged | The FASTA drops 22 rather than 30 because 8 of the 30 have no sequence and were never in a sequence file. What a consumer must do. Anyone depending on the old bare response adds?include_negative=true— which returns more than the old default (926 against 715: it adds the 211 negative-only records), and is the documented way to every public record. The old set exactly — "anything except an entity whose evidence is exclusively negative" — has no spelling any more. It was a third question, belonging to neither the card nor any control, and it is the reason the card said 685 while the page it links to said 715. Each class it contained is reachable directly:?polarity=inconclusive(21),?polarity=negative(276),?include_negative=true(everything). The 30 records that leave the bare API and CSV response, enumerated from production by diffing the two full lists rather than counted:- 16 with no interaction at all —
OmpA,CysP,EfeO,FliD,LolA,MalE,PotD,PstS,RbsB,Skp,YfaZ,AFRL64 cutinase 1, plusTfCut2,CipA,RoLandPhaZCte, whose evidence hangs off their engineered variants; - 14 with no positive result — 12 inconclusive-only (
GST-SBD,GST-SBDAfa,GST-SBDCac,GST-SBDCte— the binding-only constructs, deliberate records under a project ruling — plusPET46 Δlid,PD3 A294D,PD3 L166M/F299I,PbPL-DM,PhaZBmCAT,PEGC,lipase,AFRL64 cutinase 1 Δ1-433) and 2 mixed inconclusive/negative (EstG4,MG8 S171DAP). **This narrows a public default, which this project has been burned by before —include_negative's default once dropped 147 rows** from the download API with nobody told, and that argument was put to Victor before the ruling. What is different here is that the narrowing is named: every affected parameter now documents the default and the way back in its OpenAPI description (asserted, not merely written), the 30 records are listed above, and each is one parameter away. The rule lives in exactly one place —query.protein_entity_filters, the builder behind the unified list, the wild-type path,/api/v1/statsand all three exports — so the surfaces cannot drift apart again.tests/test_headline_list_agreement.pynow pins the agreement of headline, bare API call and card URL, which is a stronger invariant than the "the browse default is deliberately wider" it replaces.
- 16 with no interaction at all —
- The proteins page's Any except negative-only option is gone. It was the old default's question, and no query produces that set any more; leaving it would have made it a synonym for Positive under a different name — exactly the defect #398 fixed, a control whose label is not what it asks. *Everything, negatives included* is the wide view, and it is wider than the option it replaces. The page's Download FASTA button follows the on-screen set, as it always has.
Fixed
- **Every plastic badge on the proteins list was the same grey chip, whatever
the experiment found.**
Cutinase g16887.t1(PBNH5N, Clonostachys rosea) rendered PCL and PET identically, though its record says PCL positive by clear zone and PET negative by UV-Vis — both from one paper,10.1128/mbio.00335-25, whose Fig. 8B legend reads "showed activity against PCL only". The row carried the answer and the page threw it away. The data was checked against the source paper's markdown and is correct; this was a display defect only. Each chip is now coloured by that plastic's ownresult_polarity, never the row's overall status: a protein positive on PCL and negative on PET shows a green PCL and a red PET in the same row. The palette is not new. It is the one/documentation/reading-resultsalready teaches andapp/static/js/evidence_table.jsalready renders — positivebadge-success, inconclusivebadge-secondary, negativebadge-danger, mixedbadge-warning. The report suggested green/grey for positive/negative; grey is already spoken for by inconclusive, and a list page using it for negative would be saying "inconclusive" in the language the rest of the site speaks. Victor chose consistency with the site once the conflict was put to him. - The result→colour table now has one home. It lived in
app/static/js/evidence_table.js; it now lives inapp/static/js/result_colour.js(window.PlasticResult), andevidence_table.jsdelegates to it. Two copies kept in step by a comment is precisely the drift this project keeps paying for.base_landing.htmlloads the module before{% block scripts %}, which is where all five detail templates loadevidence_table.js, so the dependency is always met. Rendered output on the detail pages is **unchanged for every result in the vocabulary**. One behaviour does change:Evidence.resultBadgeused to give an unrecognised resultbadge-secondary— grey, which on this site means "inconclusive". It now takes the neutralbadge-light border, because no unparsed value has earned that claim. Nothing in the data reaches this branch; a nullresultrenders as–, as before. Where several papers pair one actor with one plastic, the chip showsmixed(amber), not the best result.DegradationLink.result_polarityalready decides this; positive-wins would repaint a disagreement as a finding, which is the same class of error one level down. Colour is never the only carrier: every chip also names its result in atitleand anaria-label("PET — Negative — tested, no activity detected"), so it survives greyscale, colour-blindness and a screen reader, and a legend above the table decodes the shorthand without a trip to the docs.
Version 0.84.2
Fixed
- **
?min_year=1900— a bound that excludes nothing — took the microorganism list from 843 rows to 596.** The year clause readMicroorganism.interactions, which reaches only the organism's own whole-organism assays, while the Year column beside it is computed from the organism's credited interactions — its own assays and the assays of enzymes purified from it. So the column and the filter disagreed row by row: uncultured bacterium displays "Year 2008" and?q=uncultured bacterium&min_year=1900returned nothing. 247 organisms on production have no whole-organism interaction at all; 246 of them are credited through an enzyme, and every year filter was blind to all of them. The filter now reads the same credited evidence the column does (query.credited_paper_exists). Measured on production data:?min_year=1900596 → 842 (the one remaining absence is the single organism carrying no dated evidence anywhere, which has no first-reported year to compare);?q=uncultured bacterium&min_year=19000 → 1. ?plastic=X&polarity=positivedid not conjoin the two. They were separate EXISTS clauses, so the query meant credited with X and *positive on something* rather than a positive result on X. On production, 5 of the 29 organisms returned for?plastic=PET&polarity=positivehave **no positive PET result anywhere** — every PET result they carry isnegative(Pseudomonas fluorescens, Penicillium sp., Fusarium sp., Penicillium restrictum, Aspergillus sp.); they qualified on a positive result against a different plastic. When both parameters are given they are now one predicate on one interaction row (query.credited_interaction_exists).?plastic=PET&polarity=positive29 → 166, which is exactly the "166 degrading microorganisms" the PET page has been showing all along. Those 5 organisms are not hidden: they are returned by?plastic=PETand by?plastic=PET&polarity=negative, which is what is true of them. Consequence, stated rather than smoothed over: 166 is not a subset of the 545 that?polarity=positivereturns, becausepolarityon its own still reads whole-organism evidence only whileplastichas always been inclusive. That incoherence is the subject of the follow-up change, not of this one.evidence_scopewas accepted and silently ignored on/microorganisms. FastAPI drops an undeclared query parameter, so/microorganisms?evidence_scope=whole_organismanswered 200 with the unfiltered 843 rows — while the same parameter genuinely narrows/stats(716 → 545) and/plastics. An answer that looks filtered and is not is worse than an error, and an invalidpolarityon this same endpoint already 422s. It is now declared and refused with 422 on/api/v1/microorganismsand onmicroorganisms.csv/.json: the list's polarity clause reads whole-organism evidence only, so there is no honest scope for it to switch between here yet.- The organism headline had no visibility gate and no tombstone check —
the same latent leak #398 closed for proteins, one entity across.
attributed_micro_interactions()feeds the landing headline, the plastics table, the plastic page's actor buckets, the taxonomy-group stats and the tree, and counted straight off the evidence spine: an embargoed, private or tombstoned organism with positive evidence would have been advertised on a public page while appearing in no list. The gate now lives inside that builder, and its enzyme arm gates the protein too — an embargoed enzyme must not credit a public organism through the inclusive arm. The same rule is applied to the loadedsource_proteinsrelationship (query.visible_source_proteins), which the organism list, the organism detail page and the plastic page read to render "via protein" plastic badges, degradation links, the derived paper list and the Year column, and which none of them gated (services/export.pyalready did — this brings the other four into line). 0 rows are affected today (1218/1218 microorganisms and 604/604 proteinspublic, none tombstoned), which is exactly how it would have shipped. Every headline and plastic-page number is unchanged: 716 / 545, PET 166.
Known, diagnosed, not changed here
- **
?evidence_method=Weight lossreturns 0 — and that is not this class of bug.**evidence_methodmatches the method vocabulary exactly, and that vocabulary is slugs:weight_loss,mass_remaining,clear_zone. The working query is?evidence_method=weight_loss, which returns 189 organisms (101 proteins);Weight loss,Weight_lossandWEIGHT_LOSSall return 0, on/microorganismsand/proteinsalike./documentation/apialready documents the slug form. Making an out-of-vocabulary method a 422 rather than a silent empty page is a real improvement and a change to two public endpoints' contract, so it is left for its own decision rather than folded in here.
Version 0.84.1
Fixed
- The landing page said 685 proteins and the page it links to said 715.
Reproduced live on v0.84.0:
GET /api/v1/stats→proteins: 685(431wild-type +254engineered);GET /api/v1/proteins→total: 715(450+265). Neither query was broken. They were answering different questions under the same noun: | surface | question it answered | total | |---|---|---| |/api/v1/stats(the card) | distinct actors of a positive interaction | 685 | |/api/v1/proteins(the list) | entities whose evidence is not exclusively negative | 715 | |/api/v1/proteins?include_negative=true| every public record | 926 | The 30-row gap was decomposed against production, and it is entirely real data: 16 wild-type proteins carrying no interaction at all (OmpA,CysP,MalE… a proteomics paper's periplasmic set, plus enzymes whose evidence hangs off their variants), 3 wild-type and 11 engineered entities whose only evidence isinconclusive— the GST-SBD binding-only constructs, which are recorded that way on purpose. No cache, no stale precomputed statistic, no dropped JOIN, no soft-deleted row: the visibility columns are unanimous (604/604 proteinspublic, none tombstoned), and the headline is not cached at all. 685 is the right headline and stays./documentation/reading-resultsalready defines a headline count as *degraders only — distinct microorganisms or proteins with at least one positive result*, and a database whose distinguishing claim is "no computational predictions, every entry backed by experimental evidence" cannot advertise as degraders 16 proteins with no assay and 14 whose assay was inconclusive. 715 is also right, as a browse total: narrowing the list default to match the card would hide 30 real records from the only page that lists them — this project has been bitten by exactly that (include_negative=falsesilently hiding 147 rows from the download API). So the fix is not to make one number equal the other. It is that **a headline is a link, and the link must open the query the number came from**:app/services/headline.pyis the one place pairing each/api/v1/statskey with the list URL that reproduces it. The Proteins card now links to/proteins?polarity=positive&include_negative=true, which returns 685 — click the number, see the number./api/v1/statsno longer writes its own aggregate. Both surfaces now build onquery.protein_entity_union(), the same UNION the list pages, so "a protein" has one definition. This also closed a latent leak the old aggregate had: it counted straight offinteractionswith **no visibility gate**, so an embargoed, private or tombstoned protein with positive evidence would have been advertised on a public landing page while appearing in no list. Nothing is in that state today — which is how it would have shipped.- The number itself is unchanged: re-measured against production data, the new query returns the same 431 + 254 = 685.
- The proteins list's Result filter said "Positive only" and was not. The
default option sends no polarity at all, which the API reads as "anything
except entities whose evidence is exclusively negative" — the 715. It now
reads Any except negative-only, and the genuinely positive-only option is
the one labelled Positive. "Mixed" is gone: the result vocabulary is
positive/negative/inconclusive, so selecting Mixed sentpolarity=mixed, took a 422 and rendered an empty table. Inconclusive is added in its place, which is what makes those 14 binding-only records findable rather than merely present.
Added
- An agreement test, not another single-surface test
(
tests/test_headline_list_agreement.py). Every key of the live/statspayload must either have a list URL returning the identical total or a written reason why none does (UNRECONCILED_HEADLINES) — a ratchet that may shrink and never grow, so a new headline number cannot be added without saying which list it is a claim about. It runs on a real session rather than mocks, because the subject is the SQL: mocked sessions agree about numbers neither query computed.proteins_wild_type/proteins_variantsare pinned separately becausekind=wild_typeis served by a different code path from the union, and that is where those two would drift.
Known, not fixed
- **Microorganisms has the same shape and a bigger gap: headline 716, list
default 843,
list?polarity=positive545 — no two equal.** The headline credits an organism when it or one of its enzymes degrades a plastic (evidence_scope=organism_or_enzyme, the site-wide default inherited from PlasticDB v1);/api/v1/microorganismshas noevidence_scopeat all, and its polarity clause reads whole-organism evidence only while its ownplasticfilter is already inclusive — so the endpoint does not agree with itself, and its page has no Result control to hydrate. Reconciling changes what a public filter means and needs a ruling, so it is recorded inUNRECONCILED_HEADLINESwith the measurement rather than fixed quietly here.
Version 0.84.0
Added
- **
P(AN-co-VAc)in the plastics vocabulary — an acrylic fibre is neither polyacrylonitrile nor poly(vinyl acetate).**10.1002/pola.20684(*Fusarium solani pisi* cutinase on synthetic textile fibres) is blocked inNEEDS_HUMAN_REVIEWon a single validator error: its third interaction namesPAN/vinyl acetate copolymer, and the 99-term list holds no polyacrylonitrile or acrylic entry at all. Its other two interactions (Nylon 66,PET) validate. The polymer is a commercial acrylic fabric — polyacrylonitrile with ≈7% vinyl acetate comonomer, which the paper puts at about one unit in twenty. That is the textbook acrylic fibre (ISO/BISFA set the floor at 85% acrylonitrile, the balance vinyl acetate or methyl acrylate), not a specialty grade. Checked against R-16 before any vocabulary was proposed, because if the enzyme had been assayed on a monomer the answer would have been to drop the interaction rather than to mint a term. It was not: the cutinase was handed the solid fabric, and it hydrolyses the acetate ester pendant on the chain, releasing acetic acid (Boehringer enzymatic kit, ≈4 × 10⁻⁴ U against 1 U on pNPP) and leaving hydroxyl behind — confirmed independently by reactive dyeing that stains the new -OH groups at the fibre surface. No prior depolymerisation, chemical or enzymatic. The actor acted on the polymer. Deliberately not folded into an existing row, and both shortcuts corrupt the record in opposite directions: | candidate | why not | |---|---| |PAN(new row, comonomer insubstrate_composition) | The nitrile backbone was never touched and the authors never claim it was, so a positive filed against polyacrylonitrile asserts a chemistry nobody demonstrated. This is thepPVCargument — attacking the ester component of a material is not evidence about its backbone. | |PVAc| That row exists for10.1016/j.biortech.2026.133982, which assays the bulk homopolymer over six interactions. Here vinyl acetate is 7% of an acrylonitrile chain, and accessibility is the finding: acrylic gives the smallest colour increase of the paper's three fibres, and the stated reason is that only one unit in twenty is attackable. A shared row erases exactly that. | |Acrylic| Ambiguous in the wayPHCis — poly(methyl methacrylate) is sold and cited as "acrylic" too, and PMMA is a plausible future row. | Named for the chemistry in theP(X-co-Y)shape the five legacy PHA copolymers already use, so the term outlives any one supplier's fabric.polymer_classisvinyl— both monomers are vinyl-addition monomers and the chain is a vinyl backbone, which isSBR's "classed by its backbone" rule, and it seats the row besidePVAcwhere cutinase deacetylation chemistry belongs.PANitself is refused, for now, and REJECTED_TERMS says what reopens it. It has zero papers and zero interactions across the 214-extraction AI corpus and zero rows in the 1,927-row v1 dataset; the one set of terms added without that kind of evidence — the rubbers — was enumerated by an explicit curator ruling, and this has neither. The parent-shaped hole costs nothing a query can see, sinceparent_idis only ever an explicit filter andPLGA,CAP,SBRandPVAcare unparented copolymers already. Adding it early has its own cost: commercial acrylic fibre is always a copolymer, so two candidate terms would face a producer holding an acrylic fabric with nothing to choose between them — the near-duplicate this vocabulary exists to prevent, and whatO-PEvsOXO-PElooked like after it happened. Reopen on a paper that assays genuine homopolymer PAN (a membrane, a nanofibre, a carbon-fibre precursor), which supplies both the need and the contrast;P(AN-co-VAc)should be parented to it then. AcrylonitrileandVinyl acetatein the compounds vocabulary, so the 93:7 ratio has somewhere structured to live. The extraction wrote it intoadditional_conditionsand said why: *"neither 'polyacrylonitrile' nor 'vinyl acetate' exists in the plastics or compounds vocabularies"*. With both present theSBRrule holds — one row for the copolymer, composition in the experiment, no comonomer sub-rows.Acetic acidis not added: it is a degradation product, andcompoundsis reachable from an extraction only throughsubstrate_composition(the standingMethanerejection). Standing rulings are untouched:PUstays one term, and the rubber differentiation (NR / IR / BR / PI 1000) is unchanged. This is the same rule that produced both — differentiate where the chemistry differs, refuse near-duplicates.
Version 0.83.1
Added
scripts/split_unresolved_organisms.py— gives the two token-drop rows NCBI has no answer for their real names back, now that 0.83.0 makes that state expressible. Dry run by default; one transaction. | row | was | becomes | |---|---|---| | 345 |Japonica(73258, butterflies) | Pseudozyma japonica,unresolved| | 1400 |bacterium(1869227) | split — see below | Row 1400 is two organisms. It began as Type 0803 filamentous bacterium (paper 247), and on 2026-08-12 the orphan-sweep repair — running on 0.81.0, with the token-drop bug still live — resolvedTEP119's source organism Gemmatimonadetes bacterium through the same broken lookup and linked protein 440 (paper 205) here too. So it splits by paper: the row keeps *Type 0803 filamentous bacterium* and paper 247's evidence, and a new row takes Gemmatimonadetes bacterium with protein 440. Neither half is resolved to a higher rank. NCBI does hold Gemmatimonadota, and using it was refused (2026-08-13): a phylum is a long way from an organism and would assert a rank the paper never claimed. A null tax-id with a recorded cause is the honest state for both. Each row's stored lineage and synonyms are the wrong taxon's, so both are cleared rather than carried across — leaving them would keep the row findable as an organism it is not. The moved protein's search vector is refreshed, which no trigger covers when the organism it points at changes underneath it. Every expectation is re-measured against the database at run time and any mismatch stops the run before a write — including that every strain occurrence belongs to the paper that keeps the row, which is the assumption "split by paper" rests on. That check is not ceremony: the pins inrepair_token_drop_organisms.pywere written from a measurement taken before a concurrent repair moved the same rows, and **row 1400 is the row it happened to**.
Version 0.83.0
Added
- **An organism may have no NCBI tax-id, because NCBI Taxonomy is not a register
of validly published names.** It is an index over sequence data: a species
gets a node when somebody deposits sequence under that name. The nomenclatural
authorities are LPSN (bacteria) and MycoBank / Index Fungorum (fungi), and they
hold names NCBI does not. Verified live against both NCBI interfaces —
E-utilities and the Datasets taxonomy service, which indexes synonyms:
| name | status |
|---|---|
| Tamsiniella labiosa | valid (Aptroot 1998); the genus is absent too — described morphologically, never sequenced |
| Gliocladium album | valid (Petch 1939); genus 62887 present, species absent |
| Hypoxylon dussi | valid; genus 42308 present |
| Eutypa caribensis | valid; genus 97095 present |
| Gerronema postii | obsolete — NCBI holds Loreleia postii 2008360 |
| Populacrescentia foricesenensis | misspelled — Populocrescentia forlicesenensis 1762021 |
| Pseudomonas beteli | obsolete — transferred 2024 to Stenotrophomonas beteli 3384461 |
| Pseudomonas tamsuii | nomen invalidum — never validly published |
With
tax_id NOT NULLevery one of those was a hard approval failure whose only workaround was to change the organism's name to something NCBI happens to hold — the remedy for an incomplete authority being to falsify the scientific record. Seven papers plus ingestion 213 were blocked on it.Microorganism.tax_idis now nullable, besidetaxonomy_status(resolved/unresolved/unresolvable),taxonomy_cause,taxonomy_source,taxonomy_noteandtaxonomy_checked_at. **The cause is a column and not a sentence** because those eight names are four different situations wearing one error message, and the remedy differs: an absent name is correct and waits, an obsolete or misspelled one wants correcting, and a *nomen invalidum* never resolves at all — which is the only cause that stops the retry job re-asking.taxonomy_sourceexists before there is a second authority so that adding LPSN or Index Fungorum later is a new value in a column rather than a schema change; nothing hard-codes NCBI as the only possible source. - The identity a nullable
tax_idgives up, paid for elsewhere.UNIQUEon a nullable column constrains nothing — every NULL is distinct — so two spellings of one unresolvable species would become two organisms holding half the evidence each.uq_microorganisms_unresolved_nameis a unique index on the normalised name (casefolded, internal whitespace collapsed) scopedWHERE tax_id IS NULL. Resolved rows are untouched and keeptax_idas their key, including legitimate cross-kingdom homonyms. It is deliberately not fuzzy: Tamsinella and Tamsiniella stay two rows, because deciding that one letter is a typo rather than a different genus needs the paper. Enforced in the database rather than only in promotion, because promotion is not the only writer — admin CRUD, imports and one-off scripts write this table too. - A curator decides at the point of refusal, instead of hitting a wall.
POST /ingestions/{id}/approveacceptstaxonomy_decisions, one per organism name: record it with no tax-id (with the cause), use this NCBI tax-id, or correct the name. The refusal now carriesorganism_namestructurally in its 422, and the review page turns each one into a card with those three answers — so the next unforeseen cause is a decision rather than a blocker. Decisions are stored on the ingestion with the curator's id and the time. Two of the three are resolutions and run in promotion's network phase, before anything is written: a supplied tax-id is verified against NCBI (a mistyped id becomes an approve-time error, never a settled-looking wrong row) and a rename goes through the ordinary cascade, so a correction gets exactly the scrutiny any other name gets. This is also what finally makes "supply a tax_id" actionable —ExtractedMicroorganismActor.tax_idhas been in the contract and read by nothing. retry_unresolved_taxonomy, weekly. NCBI adds nodes continuously and the rows that need re-asking are exactly the rows no promotion will touch again, so without this "unresolved" is permanent in practice. It re-asks through the samelookup_taxidcascade, and refuses to do three things: it never merges (a tax-id another row already holds is reported for a curator, because joining two rows repoints one organism's evidence onto another), never re-asks anunresolvablename, and never records aNCBIUnavailableas a fresh negative — not even by touchingtaxonomy_checked_at.GET /api/v1/microorganisms/accession/{accession}and/microorganisms/accession/{accession}, because an organism with no tax-id has no/{tax_id}to route on and a row that appears in every list and 404s when clicked is worse than one that is not shown. The organism page explains a missing tax-id in the reader's terms instead of printing a dash.
Fixed
- **A reused protein lost its source organism — so the 0.81.0 damage would not
have healed.** The organism a wild-type protein comes from was created only
inside the "protein is new" branch of
_get_or_create_protein, so a protein matched by accession or by name — every seeded PlasticDB v1 row, and any enzyme a second paper re-reports — silently dropped itssource_microorganism_name. This is not what emptied those rows in production. 0.81.0 established that: the orphan sweep did not countProtein.source_microorganism_idas a reference, deleted the organisms reached only that way, and the FK'sON DELETE SET NULLnulled the link behind it — repaired byscripts/repair_orphan_sweep_damage.py. The six are *Saccharomonospora viridis*, Thermomonospora curvata, Oleispira antarctica, *Bdellovibrio bacteriovorus*, Caldimonas brevitalea, Vibrio gazogenes (ingestions 3, 7, 8, 97, 208, 230), four of which are still absent as of 2026-08-13. What this closes is the reason a re-approve would not restore them: those proteins all exist now (the seed's own rows, ids 3–26), so promotion takes the reuse branch and drops the organism again in silence. A paper re-extracted, withdrawn and re-approved, or approved inreplacemode would come back with the same hole. A reused protein now has that link filled when it is null, never repointed (a second paper naming a different source is a disagreement for a curator), and an unresolvable source is logged rather than raised — turning an approval that used to succeed into a failure would be a regression dressed as a fix.
Version 0.82.2
Fixed
- **The token-drop repair could not run: an interaction does not carry a
microorganism_id.**scripts/repair_token_drop_organisms.pychecked which papers hang off a row with `SELECT paper_id FROM interactions WHERE microorganism_id = …`, and that column does not exist — the organism actor is aStrainOccurrence(paper × strain) and the interaction points at that (DECISIONS.md§2). Written from the model rather than checked against the schema, it raisedUndefinedColumnon the first row of the first real run. Nothing had been written and nothing could have been, which is the right failure; but a precondition check has to work on the database it is aimed at. It is now oneUNIONmeasured in-cluster before it was trusted, and it covers the protein routes as well — a source organism is reached only that way for an enzyme-only paper. - Two declared paper sets were narrower than the truth, and the guard would
have stopped the run on both. They had been written from `GET
/microorganisms/{tax_id}
, whosepapers` list comes off the evidence spine: row 1488 (Rugosa) has no interactions and no strain occurrences, and is named by two papers purely as the source organism ofCr-Lipand `Lipase AY` — 143 and 253, not 253 alone. Row 1400 likewise reaches paper 205 throughTEP119as well as its own 247. Both are now measured from the database. A correction to how that was first reported: the endpoint had not hidden anything. Row 1488 genuinely had one source protein when it was measured and two after 0.81.0's repair re-linkedCr-Lipan hour later. The rule that generalises is therefore **a declaration measured before a concurrent repair is stale, not wrong** — re-measure a pin against the database immediately before acting on it. That is what the sequencing of these two repairs was for. - **0.81.0's orphan-sweep repair ran while this bug was still live, and two of
its 325 re-links landed on token-drop rows.** It resolves each
source_microorganism_namethroughlookup_taxid— correctly, and for good reason — but that resolver did not readerrorlist.phrasesnotfounduntil 0.82.0. SoCr-Lip(protein 191, paper 143) was linked to 1488 Rugosa (a genus of frogs) for Candida rugosa, andTEP119(protein 440, paper 205) to 1400 bacterium for Gemmatimonadetes bacterium. Neither is that repair's mistake and no new wrong row was minted; the other four token-drop names appear nowhere in its manifest. Consequences: repairing row 1488 now fixes two proteins across two papers, and row 1400 holds two different organisms — the prediction in 0.82.0's notes is no longer hypothetical. NCBI has no node for either name, so that row cannot be resolved away and has to be split, which needs the unresolved-organism support to have anywhere to put the second one. Recorded inscripts/orphan_sweep_damage/README.md, where the repair's operator looks, and in this script'sNOT_REPAIRED.
Version 0.82.1
Fixed
- A race in the browser tier that fails for everybody, intermittently.
ingestion_review_form.spec.js's "the raw JSON panel can be loaded back into the form" clicked Approve and then read the captured request directly: ```js await page.click('.approve-btn'); expect(calls[0].extracted_data.reference.journal).toBe('Nature'); ```page.clickresolves when the click has been dispatched, not when the page's handler has finished with it, and approve isasync— it awaits form hydration, collects the fields, and only then fetches. So that assertion races Node's next statement against a round trip through the browser. It surfaced asTypeError: Cannot read properties of undefined (reading 'extracted_data')on a pull request that changed no JavaScript at all (scripts, CHANGELOG and VERSION only), which is what identifies it as pre-existing onmainrather than a regression. The fix is the idiom the same file already uses eight lines earlier: wait for the outcome the curator would see, then read the request. Better than polling the array, because it asserts the thing the page is for.retries: 0is what surfaced it, exactly as the testing notes say it should. A retry-pass here would have absorbed a real race instead of fixing it, so the run was not re-run for green. Honest about the evidence: the CI failure is the only observed occurrence. Five deliberate local attempts to reproduce it — three at `--cpus 0.35 --workers=6, two at--cpus 1 --workers=16`, in the CI image on the same commit — never made it fail, while the unstressed suite passed 211/211 three times over. So this is a fix for a path that is racy by inspection and was seen to fail once, not one with a reproduction to point at. (Under 16-way parallelism on one CPU three other specs fail on time-based assumptions — a debounce window and two zoom animations. That is an artifact of a deliberately abusive configuration, not a claim about those specs at the configured worker count, where they pass.) Audited the rest of the tier for the same shape — an action, then a non-auto-retrying assertion on a captured request — across all 19 spec files and 44 such assertions. This was the only unsettled one. Every other waits first:await expect(locator),expect.poll, or a boundedwaitForTimeout, the last being the right tool for the negative assertions ("no request was made"), where there is no positive event to wait for.
Version 0.82.0
Fixed
- **A dropped word is not a match: Entrez answered a question it was not
asked, and three organisms were stored as the wrong species.**
esearch db=taxonomydoes not fail when a phrase of the query is absent from its name index. It removes that phrase, records it inerrorlist.phrasesnotfound, and searches on what is left — returning one confident-looking id. Measured live, quoted and unquoted alike: ``` "Candida rugosa" -> ['1673081'] notfound ['Candida'] "Pseudozyma japonica" -> ['73258'] notfound ['Pseudozyma'] "Pseudomonas alcaligenes" -> ['507'] notfound ['Pseudomonas'] ``` 1673081 is Rugosa, a genus of frogs; 73258 is Japonica, a genus of butterflies; 507 is the genus Alcaligenes — a different genus in a different class from Pseudomonas. All three reached production and are stored under the wrong taxon's name as well as its tax-id, across five papers. Swept across the 1,244 distinct organism names the extraction corpus asks this resolver about, 6 token-drop and 4 are already stored. The fourth is the worst of them:Type 0803 filamentous bacterium→ 1869227, a real current node whose scientific name is the word bacterium, carrying 20 interactions from10.1128/aem.64.12.5008-5011.1998. Nothing about that row looks broken, which is exactly why this needed a check rather than a reviewer's vigilance — andGemmatimonadetes bacterium, in another paper, collapses onto the same row.Polyporus circinatus→ 484236 Circinatus, a subgenus of beetles, is not approved yet: that one is damage the fix prevents rather than repairs. 0.79.0's homonym work could not catch this. It narrows a set that is already plural, and_settle_candidatesreturns a lone candidate unexamined — the single-hit case is the dangerous one precisely because nothing about it looks uncertain._lookup_taxid_uncachednow readserrorlist.phrasesnotfoundon a successful search (it previously read it only on the empty branch, to log it) and discards hits that answer a partial query. The name then falls through to the Datasets rung, which resolved two of the three correctly — 5481 Diutina rugosa, 43263 Aquipseudomonas alcaligenes — and honestly refused the third. The errorlist is the discriminator, and the alternatives were measured and rejected: comparing the returned node's name against the query would reject the reclassifications this resolver exists to serve (Ralstonia eutropha-> 106590 Cupriavidus necator,Ideonella sakaiensis-> 1547922 Pseudideonella sakaiensis, both with an empty errorlist), and a rank test would reject every legitimate strain node (Rhodococcus sp. RHA1-> 101510,Escherichia coli K-12-> 83333). Entrez only ever lists phrases from the term it was given, so a non-emptyphrasesnotfoundis the statement "I did not search for what you asked". Covered bytests/test_taxonomy_token_drop.py, whose second half asserts the clean matches keep working.
Added
scripts/repair_token_drop_organisms.py— the repair for the three rows already stored, dry run by default. Each row is declared with the exact state it expects to find (id, name, tax-id, papers) and any mismatch stops the run before anything is written; the target tax-ids are re-verified against NCBI at run time, so a typo in the file cannot become a second wrong row. It refuses to touch Pseudozyma japonica: NCBI holds no node for that species at all, so there is nothing to move it to — it needs the unresolved-organism support, and the script prints that rather than guessing at a genus.
Version 0.81.0
Fixed
- **Deleting one paper destroyed another paper's data — the orphan sweep's test
for "is anything still referencing this?" was wrong.**
delete_orphaned_organisms_and_proteinsruns on the superuser paper delete, on ingestion withdrawal and on replace-mode approve, and it sweeps the entiremicroorganismsandproteinstables rather than the deleted paper's own entities. That is defensible on exactly one condition — the orphan test has to be complete — and it was incomplete in two independent ways, neither of which left a trace. **Measured, read-only, against production before the fix: 324 of 533 wild-type proteins across 47 papers had lost their source organism, and 200 organism rows had been deleted outright.** Not one of the 325 nulls is legitimate — the extraction names a source organism for every one but a single protein that has no paper link at all.scripts/orphan_sweep_damage/holds the measurement and the two traps found while taking it. An organism was an orphan when it had noStrainOccurrence. ButProtein.source_microorganism_idis a second, independent reference, and it is the only one promotion creates for a wild-type enzyme's source organism:_get_or_create_proteincalls_get_or_create_microorganismand writes no occurrence. Such an organism was therefore an orphan **from the moment it was written**, and the next delete of any paper anywhere took it. The FK isondelete="SET NULL", so nothing was refused, logged or counted — the proteins were quietly emptied. Measured on 2026-08-12: withdrawing ingestion #16, an unrelated genome announcement, deleted five organisms out from under paper #179 (10.3389/fmicb.2021.803896, approved and live) and left eight of its ten proteins withsource_microorganism: null. A protein was an orphan when noInteractionnamed it. Butck_interaction_exactly_one_actormeans an interaction whose actor is an engineered variant hasprotein_id IS NULL— it names the variant. So the parent protein of a paper that only ever assayed variants had no interaction of its own and looked orphaned. Deleting it deleted itsProteinVariantrows, andInteraction.protein_variant_idisondelete="CASCADE", so the variant's interactions and the experiments and observations beneath them went too. A variant is a first-class accessioned entity (PBDS…), not a detail of its parent. This one has cost production nothing so far — all 224 variant rows the 37 variant-bearing papers name are present, and the evidence spine is 5 interactions and 0 observations short across all 221 papers, consistent with curator edits rather than a cascade. It was latent, not harmless: the first delete of a paper whose parent protein happened to have no wild-type interaction of its own would have taken the lot. Both were fixed by making the question exhaustive rather than remembered.app/services/orphan_cleanup.pynow declares every foreign key in the schema that points atmicroorganisms.id,proteins.idorprotein_variants.idin one of two tables:_KEEPS_ALIVE(an independent reference — evidence, identity, curated attribution, citable deposit membership, a contributor's pending submission) or_DEPENDENT(data that exists because of the entity and dies with it). Eight references that keep an entity alive were missing, and two of them (Pathway.organism_id,ProteinSubmission.matched_protein_id) have noondeleteat all, so they were a latentIntegrityErrorthat would have aborted a delete outright.tests/test_orphan_cleanup_completeness.pywalks the ORM metadata and fails if any inbound foreign key is in neither list. A column added tomorrow cannot reintroduce this quietly: it fails a test until somebody classifies it. The sweep now runs variants, then proteins, then organisms, each stage reading what the previous one left. The order is load-bearing in both directions: judging organisms first would spare rows whose only remaining reference is about to be deleted, and judging them without re-reading would leave a genuinely dead organism behind for good. Reproduced first, intests/test_pg_paper_lifecycle.py: two tests delete a bystander paper that shares nothing with the paper under test and assert the victim is untouched. Both fail on 0.79.0. - Repair for the damage already done.
scripts/repair_orphan_sweep_damage.pyre-derives every protein's source organism from the AI pipeline's extraction JSONs — the durable copy, which the sweep never touched — and restores it. Dry run by default, and it refuses to write unless the operator pins the paper and protein counts they reviewed, so a corpus that has moved under the plan stops the run rather than half-applying it. It reports, and deliberately does not attempt, the variant-cascade damage: that is lost evidence rows, not a nulled column, and the remedy is a re-approve in replace mode.
Version 0.80.0
Fixed
- **The chatbot could not draw a chart in production, because the thing that
draws charts was never deployed.** Asked to plot, the assistant replied that
the chart tool was unavailable and fell back to a markdown table. The message
it was paraphrasing is
Chart generation is not configured.—app/services/chart_executor.py, raised before any HTTP request is made, becauseSettings.chart_executor_enabledisbool(chart_executor_url)andCHART_EXECUTOR_URLwas empty on every API pod. It was empty because there was nothing to point it at.docker/chart-executorhad a Dockerfile, pinned requirements, a working Flask app and its own test file, and was referenced only fromdocker-compose.yml. No CI job built it, so no image was ever pushed to Docker Hub; the Helm chart deploys API, Worker and Beat and knew nothing about it. The subsystem was complete, correct, and absent — and nothing anywhere failed: the unit tests passed, the API was healthy, ArgoCD synced green. The chatbot itself works and always did;run_sqlwas never affected. The release workflow now builds and pushesvmar559/plasticdb-chart-executorat the same release SHA as the application image, so the sidecar becomes deployable. The Deployment, ClusterIP Service, deny-all-egress NetworkPolicy andCHART_EXECUTOR_URLland inplasticdb-gitops; this release must be tagged before those are synced, or the new Deployment has no image to pull. Deliberately not fixed by any of the cheaper routes. Not by rewording the error, which would leave charts broken and better disguised. Not by droppinggenerate_chartfrom the advertised tool list when the executor is unconfigured, which reads like tidiness and is really a decision to ship a chatbot that cannot chart. Not by rendering in the API process, which is the one thing the whole design exists to prevent — the code being run is written by a language model at the request of anonymous visitors, and the API pod holds the database, S3 and email credentials.
Added
- **
Apexain the plastics vocabulary — a modified-PET copolyester, not a PBS/PBAT-family polyester.**10.1007/s00253-010-2555-x(Thermobifida alba AHK119 / esterase Est119) fails validation with **37 errors, every one of them this name**. The paper states the chemistry itself: *"Apexa (formerly Biomax 4026 and 4027) is a thermostable polyethylene terephthalate copolymer that is synthesized with terephthalic acid and ethylene glycol and an undisclosed component to be compostable"*, and terephthalic acid is the measured degradation product — which rules out an aliphatic backbone (PBS, PBSA, PCL, PLA, PHA) independently of the label. It earns a row under the rule already set by Mater-Bi — *a trade name earns a row only when its composition is proprietary or unstated*. Two of three monomers is not a composition, so no blend anchor plussubstrate_compositioncan describe it honestly; DuPont's own Biomax patent family describes only a class, never a grade. Deliberately not folded into an existing row. NotPET: the finding is that this degrades where PET does not, and 37 positive interactions filed underPETwould assert PET biodegradation this paper never showed — it is parented toPETinstead, which records the backbone without rolling the evidence up, sinceparent_idis only ever an explicit filter. NotPBAT: the same study runs Ecoflex as a separate substrate and draws the line itself. NotPETG: that is the glycol (CHDM) modification, non-compostable — the near-duplicate collision a controlled vocabulary exists to prevent. AttachingApexaas atrade_nameon an existing row was considered and does not work:resolve_plasticmatches onname/abbreviationonly, andGET /vocabulariesserves onlyname/abbreviation/kind. The 37 errors would remain, and promotion would mint an uncontrolledneeds_reviewstub. tests/test_deploy_contract.py— a Dockerfile outside.github/describes something the product runs, so the release workflow must build *and push* it, at the release SHA and never aslatest. This is the assertion that was missing: every other test in the suite could pass with the sidecar image nonexistent, and did.- The chart-executor's own tests now actually run.
tests/test_chart_sidecar.pyhas alwaysimportorskip-ed on flask/matplotlib, and.github/test.Dockerfileinstalled neither and never copieddocker/in — so the only coverage of the code that draws every chatbot chart was three skips reported as green. The test image now installs the sidecar's pinned requirements (by reference, so the tested versions are the shipped ones) and copies the source; underCIthe module raises rather than skips, because there absence is the defect.
Version 0.79.0
A bulk approval of 209 pending_review ingestions approved 179 and refused 30. Both refusal classes turned out to be V2 contract defects rather than bad extractions, and this release fixes the contract.
Fixed
- The producer contract now states every length limit the database enforces.
app/schemas/ingestions.pydeclared its strings as barestr | Nonewhile the columns they land in areString(n). So the limit was unknowable from outside: absent from the type, therefore absent fromGET /openapi.json, therefore invisible to the AI pipeline's preflight, which validates against exactly that document. The first anybody heard of an over-long value was at approve time, as a psycopgDataErrorsurfaced as *"Invalid value for one or more parameters"* — no field, no path, after promotion had run part-way and rolled back. 13 of the 30 refusals were this, onexperiments.culture_medium(87 values, longest 276),observations.unit(11 values, longest 35) and a mutation residue. Every producer string that reaches a bounded column now carries that column'smax_length— 40 fields across theExtractedDatatree plus the ingestion envelope, listed intests/test_ingestion_length_limits.py, which reads both sides out of the schema and the ORM metadata rather than repeating either. A violation is now a located 422 the review form can mark up, and JSON Schema expressesmaxLength, so the producer's existing preflight starts catching these with no change on that side. Two fields were checked against their data rather than assumed correct.numbering_scheme(100) is a label — migration0004's own docstring records the observed values as "mature", "full_length", "UniProt:<acc>" — and the 98 overflows are prose that belongs indescription.culture_medium(255) is a medium's name or short designation, which is what makes it groupable across studies; a per-litre recipe belongs innotes, which isText. Both limits stand, and both now fail early and legibly instead of late and mutely. - **
mutations.wild_type_residue/mutant_residuewidened from 1 to 20 characters** (migration0007_mutation_residue_width).String(1)encoded an assumption — that a residue is one of the twenty canonical amino acids — which the enzyme-engineering literature does not respect: one paper in the queue substitutesDAP(2,3-diaminopropionic acid), and non-canonical and unnatural residues are precisely the work PlasticDB v1 never covered. 20 covers three-letter codes, PDB Chemical Component Dictionary ids (up to 5) and the hyphenated shorthand for amber-suppression residues (p-azido-Phe), matches the siblingmutations.raw, and stays far too small to invite prose. Both columns move together — a variant with a non-canonical residue on one side only is half-recorded. The two other write paths into that table move with it:MutationCreate(the curator/admin API) and the review form's residue inputs, which weremaxlength="3"— already looser than the column they wrote to, and still too tight forp-azido-Phe. - **Taxonomy resolution gained a third rung: an unresolved
<Genus> sp.is settled at its genus.** NCBI mints a bare<Genus> sp.node only when it happens to have one —Diaporthe sp.is taxon 1756133,Rhodococcus sp.does not exist at all — while the extraction contract explicitly instructs the producer to keep such a name rather than invent an epithet. V2 was therefore rejecting names it had asked for; 17 of the 30 refusals wereUnresolvedTaxonomyError, and six ingestions failed on nothing but this shape. The rung runs last, so it can never pre-empt a species match; it matches exactly, inheriting the Datasets endpoint's refusal to offer a plausible neighbour; it fires only on a bare nomination, never on a strain suffix (Rhodococcus sp. RHA1is a real identification NCBI usually holds); and an unreachable NCBI still raisesNCBIUnavailablerather than being recorded as "no such organism". Off-switch:ncbi_genus_fallback. The resolved tax-id is a genus node, and that is a deliberate widening. The storedMicroorganism.namebecomes the bare genus, precisely so the row cannot be read as a species-level identification; the paper's ownRhodococcus sp.is kept beside it insynonyms. - **An organism name is not a unique key, and the resolver no longer pretends it
is.** Zoological and botanical nomenclature are separate codes, so the same
genus name is legitimately borne by unrelated organisms and NCBI holds a node
for each. Measured live, and all three are in the corpus this resolver serves:
Rhodococcusis an actinomycete (1827) and a scale insect (1661425);Cryptococcusa fungus (5206) and a scale insect (79213);Amphinemaa fungus (137744) and a hydrozoan (367918). Both NCBI endpoints return every match and neither orders them helpfully —esearchranks the insect first for all three, and adds Gloeocapsa (102231) toRhodococcuson a token match. Both rungs took the first id. That was not a harmless tie-break: it is a coin toss whose losing side is silent and unrecoverable — a PCL-degrading actinomycete stored under a scale insect, wrong in the lineage, the organism page and every export, discoverable only by someone reading it years later. **lookup_taxid("Rhodococcus")returned the scale insect before this release.** Ambiguity is now narrowed by two exact steps and otherwise refused: candidates whose scientific name is not the name asked about (E-utilities matches tokens; NCBI Datasets deliberately exempt, since answering with the node's current name is its whole purpose), then candidates NCBI places inside Metazoa or Viridiplantae — an animal or a land plant in aMicroorganismslot is a homonym collision, not the organism the paper assayed, and none of the 1,209 distinct names in the extraction corpus is either. Neither step may empty the candidate set, and an answer is accepted only if exactly one candidate survives; anything else raisesAmbiguousTaxonName, which reaches the curator as an ordinaryfound=Falsewhose reason names every candidate and its NCBI group label. Narrowing can only shrink, never invent, and it runs only on a set already known to be plural — so no name NCBI resolves uniquely today can change. Rung 1's extraefetch(one request, all candidates) is spent only on the plural branch, which none of 150 sampled corpus names reached: 139 resolved to exactly one hit, 11 to none. - A length violation that still reaches the database reads as one. The
DBAPIErrorhandler answered every SQLSTATE-22 fault with the same sentence, so "too long" and "bad enum literal" were indistinguishable. SQLSTATE22001now says a value was too long and quotes the driver's own type. The handler is no longer the front line for extraction fields — Pydantic rejects those first, with aloc— but it still covers values promotion derives (a canonical name from NCBI, a plastic minted from a component string) and the admin surfaces.
Version 0.78.0
Added
- Withdrawing an approved paper. An approval had no undo.
rejectrefuses anything that is notpending_review;reopengives a revision, but rejecting that only flips the revision's own status and leaves the first run's promoted rows live; and approving an emptied revision is impossible becauseExtractedData.interactionsismin_length=1. The only working remedy was the superuserDELETE /api/v1/admin/papers/{id}, which removes the records and leaves the ingestion readingapprovedfor ever — the database and the queue disagreeing, in silence.POST /api/v1/ingestions/{id}/withdrawdeletes the promoted paper and its cascades and marks the ingestionwithdrawn, with the curator's reason, **in one transaction**. The delete is not a new one: it reads the papers entry out of the admin entity registry and runs exactly what the superuser delete route runs, cascade anddelete_orphaned_organisms_and_proteinshook included. The case is now routine rather than hypothetical. The producer pipeline's ruling R-15 — only evidence from the reviewed paper's own Results may be recorded — exists to produce papers that legitimately yield nothing: a genome announcement that asserts degradation and assays none. Some of those were approved before the rule existed. withdrawn, a sixthPaperIngestionStatus(migration0006_ingestion_withdrawn). Reusingrejectedwould have needed no migration and would have stated something false:rejectedmeans a curator turned an extraction away in the queue, before anything was written, whereas a withdrawal is the opposite history — approved, promoted, publicly visible, since deleted. Folding them together makes "was this paper ever live?" unanswerable from the row and makes any later measurement of curator rejection rate count withdrawals as rejections.rejection_reasoncarries the words for both; prose is not a queryable fact. Nothing in the application enumerates ingestion statuses exhaustively, so the new terminal value is inert everywhere it is not named.- A "Withdraw this paper" control on an approved ingestion's review page, beside "Correct this paper" and styled apart from it — correcting is reversible right up to the second approve, withdrawing is not. It states in words that the records will be deleted from the database, requires a typed reason (kept on the record; after the delete it is all that remains to say why) and a confirmation, and a withdrawn paper's page afterwards says so and shows the reason.
Fixed
- Four guards stop a withdrawal destroying evidence it did not write. The
publication right (
_check_publication_right, as approve and reject); the row must beapproved; no open revision for this DOI, checked under the same row lockreopentakes — an open revision is a curator's edit of these very rows waiting to replace them; and the paper must be this ingestion's alone and free of anyDeposit.Paperis get-or-created by DOI, so one row can legitimately carry a second contributor's evidence or unstamped seeded v1 evidence (paper_ingestion_id IS NULL), and aDeposit'spaper_idisondelete="SET NULL", so deleting underneath one does not fail — it quietly leaves a livePB####accession owning nothing. All four refuse before the first delete, so a refusal writes nothing rather than rolling something back. A paper someone already removed with the admin delete is not an error: the ingestion still gets marked, which is the whole point. - A delete now reaches the search index. Two entities kept answering to
evidence that had been deleted out from under them. Both are pre-existing on
the superuser
DELETE /api/v1/admin/papers/{id}path, and withdrawal and replace-mode approve inherit them because they run that same delete. An organism that survives the delete kept the weight-C plastic names of the evidence that was removed: delete one of two papers and the organism was still returned by a full-text search for the plastic only that paper evidenced. The interaction trigger was supposed to prevent this, and could not — it reaches the organism throughstrain_occurrences, and the same cascade had already deleted the occurrence, so the lookup came back NULL and the refresh was silently skipped. A newAFTER DELETE ON strain_occurrencestrigger refreshes fromOLD.microorganism_id, which needs no lookup and so cannot be defeated by cascade ordering. A protein whose source organism was deleted kept advertising it. Orphan cleanup removes the organism, the FK setsproteins.source_microorganism_idto NULL, and that UPDATE does fire the protein's own FTS trigger — which returns early onpg_trigger_depth() > 1. A newAFTER UPDATE OF source_microorganism_id ON proteinstrigger, keyed on the column changing rather than on the organism being deleted, refreshes the protein whichever order the FK action and the triggers run in. It is equally right for a curator re-pointing a protein at a different organism. Both are triggers rather than calls added to the delete paths, because the deletes that cause the staleness happen inside the database: they are FK cascades the application never issues and cannot enumerate. A fix indelete_entitywould have to know that deleting a paper takes strain occurrences with it — exactly the per-caller knowledge that drifts. The refresh half of each entity's vector SQL now lives in one function per entity (fn_refresh_microorganism_sv,fn_refresh_protein_sv) instead of being copied per trigger. Ships throughpython -m app.db.init_schema(CREATE OR REPLACE), so no migration is involved. Note what was not wrong: a deleted paper was never still searchable. A paper'ssearch_vectoris a column on the paper row, so the delete takes the index entry with it. That is now pinned by a test rather than assumed. - Deleting an orphaned protein invalidates the protein cache.
delete_entityinvalidated only when the model it was handed wasProtein, so proteins removed bydelete_orphaned_organisms_and_proteinsleft the cache untouched — and the protein list endpoint went on serving the deleted record until its TTL ran out. Three paths reach it: the superuser paper delete, replace-mode approve, and withdrawal. Fixed at the point that deletes them, with the invalidation **queued on the session** (protein_cache.invalidate_on_commit) and published byget_dbonce the transaction has resolved. The order is the point. Clearing the cache inline — which every admin protein write previously did — opens a window in which another request reads the not-yet-committed state and re-caches the rows under the new generation, after which nothing invalidates again: the same stale-cache bug from the other side. Clearing after a rollback is the harmless direction, a cold cache and nothing more, so the publish runs on the error path too rather than risking the loss of an invalidation for work a handler had already committed itself. It swallows everything it can raise: the delete is already durable, and an unreachable cache must not turn it into a 500.
Version 0.77.0
Added
- **
CRandSBR, the two rubber terms 0.76.0 deferred, on the curator ruling of 2026-08-08.** 0.76.0 addedIR,BRandPolyisoprene oligomerunder ruling R-9 and stopped there, recording chloroprene rubber and styrene-butadiene rubber as deferred, not refused: the chemistry was settled and the evidence attached, but R-9 had enumerated four terms and a vocabulary that grows past the decision authorising it is one nobody can audit. The curator has now ruled — add both, on the same reasoning that acceptedBR— so the argument below is 0.76.0's, unchanged, and what is new is that it is authorised.CR— Chloroprene Rubber, poly(chloroprene).homopolymer.SBR— Styrene-Butadiene Rubber.copolymer, the only one of the five rubbers that is not a homopolymer. Both are siblings ofNR, not children, for the reason already recorded againstIR: parenting would make every "all natural rubber" query return synthetic results. Both take the ISO 1629 / ASTM D1418 elastomer code asname, matchingNR,IRandBR, with an explanatoryfull_name.NR,IR,BRandPolyisoprene oligomerare untouched —NRin particular keeps its NULLkindandpolymer_class, the guarantee 0.76.0 made to the curator reviewing 194 ingestions. **polymer_classispolydienefor both, andSBRis the one that had to be argued.**CRis straightforward: poly(2-chloro-1,3-butadiene) is a conjugated-diene polymer with an unsaturated backbone, and the chlorine sits on the very repeat unit carrying the double bond the rubber oxygenases cleave — which is why a chloroprene result is evidence about the enzyme rather than a restatement of the NR result. It is emphatically notvinyl; that class in this file holds saturated-backbone vinyl-monomer polymers (PVC, PVA, PVAc).SBRis a styrene/butadiene copolymer, so a styrenic class was the obvious alternative — and it was refused, because no such class exists in this file and minting one would classify the material by its minority constituent. The chain is a polybutadiene backbone with pendant phenyl rings (commodity grades, Hycar LX-110 among them, are roughly three-quarters butadiene by mass); the unsaturated allylic backbone an oxygenase attacks is butadiene-derived, so the chemistry deciding degradability is the chemistryBRnames, and classingSBRaspolydieneis what puts its negative besideBR's negative — where the finding lives.PLGA(copolymer,polyester) andCAP(copolymer,cellulosic) are the standing precedent: **kindcarries the copolymer fact,polymer_classcarries the chemistry.** The grade's styrene ratio goes inexperiment.substrate_composition, so noSBRsub-row is ever needed — themcl-PHArule. What this unblocks.10.1128/aem.56.1.269-274.1990reports five unrecordable actor-plastic pairs, not the three R-9 was ruled against. Three became recordable in 0.76.0; these two are the remainder — chloroprene rubber latex (LM-50) and styrene-butadiene rubber latex (Hycar LX-110), both NEGATIVE at not detected and <0.04 µmol carbonyl formed, in the same Table 1 and under the same two reaction conditions as the NR positive that is recorded. A negative on a named polymer assayed head-to-head against a positive is substrate-specificity evidence and is exactly what an interaction row is for; it was dropped because there was nowhere honest to put it. All five pairs are now expressible. TheREJECTED_TERMSdeferral entry is **kept and markedDEFERRED in 0.76.0, REVERSED in 0.77.0**, carrying the ruling's date and its original text verbatim — the conventionparticleestablished in 0.73.0. An entry that vanishes on reversal leaves the next reader to re-derive the original argument and re-reject the term.test_a_reversed_rejection_says_sohad hard-coded0.73.0into every reversal and so would have failed this one for being correct; it now asserts the shape (REVERSED in <version>) plus each known reversal by name.
Version 0.76.1
Fixed
- **The synonym backfill ran on environment settings alone, so the API key
entered in the dashboard was invisible to it.**
init_settings_sync()is what pulls the DB-backed overrides onto theSettingssingleton, and onlyapp/main.py(API lifespan) andapp/workers/celery_app.py(worker boot) called it.scripts/backfill_microorganism_synonyms.pyis the third process that talks to NCBI in bulk, and it did not:ncbi_api_keyset in Admin → Settings never reached it, so a run that asks NCBI once per organism went out unkeyed at 3 requests/second — the anonymous ceiling — and spent that allowance against the same host the live curator approvals depend on. The script now calls it at the top ofmain(), before the engine is built and before anything reads a setting. - A taxon with no other names was left NULL, so every run asked again. NULL
meant both "never asked" and "asked, and NCBI lists nothing", and 52 rows are
genuinely in the second category — every re-run spent 52 requests to be told
the same thing, for ever. The answer is now recorded as an empty list, and the
row selection skips a recorded
[](it testsis None, not falsiness) while--refreshstill re-asks it. An NCBI failure is still left NULL: not answering is not the same as answering "none". - **Synonyms were filed by name onto a row identified by tax_id, without checking
the two agreed.** NCBI splits, merges and reuses names, so a name that resolves
to a different taxon than the one stored on the row would have written that
other organism's synonyms onto it — invisibly, because nothing resolves through
synonyms: the wrong names would simply start matching searches. Such a row is now skipped and reported on stderr with both tax_ids, and the rest of the run continues.
Version 0.76.0
Added
- Three rubber terms, so ruling R-9 has a vocabulary to be true in. R-9
(2026-08-07) put rubber in scope for PlasticDB. The
plasticslist has had exactly one rubber entry since the v1 import —NR, "Natural Rubber" — so a scope ruling arrived at a vocabulary that could express one material out of a literature made of several. **This addition is ruling-driven, not corpus-driven, and the distinction is the point.** Every term in 0.72.0 and 0.73.0 was proposed off a measured pile of extractions collapsing onto a wrong neighbour. Rubber has almost no corpus behind it because it was out of scope: the screener discarded rubber papers before an extractor ever saw one. What did get through is thin and consistent — 5 interactions across 2 papers in the 228-paper AI corpus, and 4 rows in the entire 2,535-row v1 dataset — and waiting for those counts to grow would mean screening in a literature the database cannot record. The evidence below is offered as corroboration of the shape of the gap, not as its justification.IR— Isoprene Rubber, synthetic poly(cis-1,4-isoprene). The same repeat unit as natural rubber, polymerised industrially rather than tapped from Hevea, and therefore without the latex proteins, lipids and phospholipid end groups — which is exactly where microbial attack on NR starts, so the two are not interchangeable substrates. Both corpus papers show the collapse directly.10.1002/mbo3.3assays natural latex concentrate and 0.3% (w/v) synthetic poly(cis-1,4-isoprene) of 800 kDa; all 3 of its interactions sit onNR, and the extraction says why in as many words — *"The controlled plastics list has no term for synthetic poly(cis-1,4-isoprene) (IR)"*.10.1128/aem.56.1.269-274.1990refused to collapse and dropped the pair instead: Maxprene IR-900 (78% cis-1,4) was positive at 0.96 and 2.4 µmol carbonyl formed, and is recorded nowhere. v1 conflated it too — of its fourNRrows, one carries *"Synthetic poly(cis-1,4-isoprene) with an average molecular mass of 800 kDa"* inPlastic usedand two more cite Maxprene IR-900 there.IRis a sibling ofNR, not a child. Parenting it would assert that synthetic polyisoprene is a kind of natural rubber and would make every "all natural rubber" query return synthetic results — the collapse this change exists to undo.BR— Butadiene Rubber, poly(1,3-butadiene). A different monomer, not a variant of isoprene rubber: no methyl group on the repeat unit.10.1128/aem.56.1.269-274.1990assayed butadiene rubber latex (JSR 0700, 16–20% vinyl) and got a clean negative — not detected, <0.04 µmol — against a positive on NR in the same table. A negative on a named polymer is substrate-specificity evidence and is what an interaction row is for; it was omitted because there was nowhere honest to put it.Polyisoprene oligomer— low-molecular-weight cis-1,4-polyisoprene. Same chemistry as NR and IR at a chain length short enough that every chain is accessible, used as a mechanistic probe rather than as a material: the 1990 paper ran PI 1000 (average MW 940) to ask whether the enzyme needs high-MW substrate, and got positive at 0.08 and 0.64 µmol — an order of magnitude below the same enzyme on high-MW rubber, which is the finding. Named for the chemistry, not for the product code.PI 1000was the obvious alternative and is refused under the rule already set with Mater-Bi: a commercial material earns a row only when its composition is proprietary or unstated. This one's is stated in the paper, so a general term describes it exactly and holds the next paper's LIR-30.Nylon oligomeris the precedent for the shape — a short-chain relative of a bulk polymer gets its own row, parented to the bulk polymer, because `substrate_form: model_substrate` records how a substrate was presented and cannot record that the material is a different substance. It is parented toIR: it is synthetic polyisoprene, only short.polymer_classispolydiene, not "elastomer". Every other class in the file names a chemistry (polyolefin, polyester, polyamide, polyether, vinyl, fluoropolymer, cellulosic); an elastomer is a mechanical behaviour, and classifying on a mechanical axis is whatTPUwas refused for. Polymers of conjugated dienes is what makes NR and IR near-identical and BR different.NRitself is untouched, deliberately and under test. It is a legacy row withkindandpolymer_classboth NULL, and the seeder fills NULLs — so tidying it into the newpolydieneclass would write two columns on a row that in-review ingestions may already reference. The cost (apolymer_class='polydiene'query missesNRuntil a curator classifies it) is recorded inREJECTED_TERMSrather than paid silently. **Nosubstrate_formterm was added, and the case was checked rather than assumed.** Every rubber in the 1990 paper is a latex — a surfactant- stabilised aqueous dispersion, which is whatemulsionwas written for and says so — and the low-MW probe ismodel_substrate. An oligomer is a molecular-weight distinction, not a form: the same material can be a latex, a film or a solution. Chain length is now carried by a plastics row and bysubstrate_mw, both more precise than a form term. The existingsoluble_substrate / oligomerrejection is annotated with this re-confirmation instead of a new entry being minted.
Deferred
- **
CRandSBRare recorded as deferred, not refused, and this is the entry most likely to be reversed.** The ruling named four terms, made against a report of three unrecordable pairs in10.1128/aem.56.1.269-274.1990. That paper's owndata_gapslists five: the two not covered are chloroprene rubber latex (LM-50) and styrene-butadiene rubber latex (Hycar LX-110), both negative at not detected and <0.04 µmol in the same Table 1 the recorded NR positive comes from. The argument for them is the acceptedBRargument verbatim — a genuinely different polymer, assayed head-to-head against natural rubber, whose negative is substrate-specificity evidence. Nothing about the chemistry is in doubt; what is missing is the ruling. Two of that paper's five pairs stay unrecordable until one is made, and a vocabulary that grows past the decision authorising it is a vocabulary nobody can audit.
Version 0.75.1
Fixed
- **A synonym carries its authority, so the "this name has been reclassified"
flag never fired.** Found by exercising 0.75.0 against the live service on the
running pod rather than against the fixture. NCBI does not list `Pseudomonas
stutzeri` as a synonym of taxon 316 — it lists *"Pseudomonas stutzeri (Lehmann
and Neumann 1896) Sijderius 1946 (Approved Lists 1980)"*, and a name never
validly published comes wrapped in double quotes (*"Achromobacter stutzeri"
Bergey et al. 1930*). The equality test against the queried string therefore
matched nothing, and the one taxon the feature was written for resolved
silently: correct tax_id, correct stored name, no notice to the curator that
the two names differ.
The comparison is now over the entry's leading tokens, as many as the query
has — a prefix match at a word boundary, not a substring test. *Pseudomonas
stutzeri* matches the entry above; Pseudomonas stutzerii (one letter out) and
Pseudomonas perfectomarina match nothing. A query of fewer than two tokens is
refused outright, because a bare genus would otherwise prefix-match every
species synonym under it and report a reclassification that never happened —
and a warning that fires wrongly teaches a curator to ignore the real ones.
The test fixture is now taxon 316's efetch record **copied verbatim from the
live service**, including the authorities, the quoted name, an
<Includes>strain label and a<Name>/type materialrecord. The idealised fixture is what let an implementation that never fires in production pass.
Version 0.75.0
Changed
- A promotion reports every organism it cannot resolve, not the first one.
applyraisedUnresolvedTaxonomyErrorat the first unresolvable name, so a curator discovered them one approve at a time — and every attempt re-resolved the whole paper before reaching the next failure.10.1038/s42003-021-02401-wnames **469 organisms of which 26 do not resolve**: 26 approve cycles for a list the server could produce in one. The loop now collects the failures and carries on to the next interaction, then raises once with all of them.UnresolvedTaxonomyError.as_detail()already answered in Pydantic's 422 shape — it just never held more than one entry — andcurationForm.showServerErrorsalready marks everylocin that array, so the whole list lands on the review form with no browser change. Skipping an interaction is safe because the raise at the end ofapplyrolls the transaction back: a promotion with any problem in the list still writes nothing, exactly as before (tests/test_pg_taxonomy_synonyms.py). Each entry now also carries what NCBI actually said, rather than a bare "could not resolve" the curator has to interpret.
Added
- Names NCBI has reclassified resolve, instead of failing as typos. NCBI's
Entrez taxonomy index carries current scientific names only. `esearch
db=taxonomy term="Pseudomonas stutzeri"
answerscount=0` withphrasesnotfound: ["Pseudomonas", "stutzeri"]— even though NCBI holds that exact binomial as a synonym of taxon 316, Stutzerimonas stutzeri. Quoting the term and[All Names]were both measured and change nothing. So a faithful extraction of a name that was correct when the paper was published reached a curator as "correct this name", which is the one thing they cannot usefully do. A name E-utilities cannot find is now re-asked of the NCBI Datasets taxonomy service, which does index synonyms. Measured across the AI pipeline's corpus (1,197 distinct organism names in 214 papers, all probed live): **63 names do not resolve, and 9 of them are reclassifications of this kind** — across 8 papers and 47 interactions, Pycnoporus sanguineus (now *Trametes sanguinea*) accounting for 23 of them on its own. The others are 30 genuinely absent binomials, 13 community-level actors that are not organisms at all, and 11 open-nomenclature labels (Cryptococcus sp.,unidentified fungus).- The rung cannot invent a resolution. The endpoint matches exactly and
does no fuzzy or nearest-neighbour lookup:
Pseudomonas stutzerii(one letter out),Blorp gloopiensis,unidentified fungusand `Pleosporales sp. 1all come back asinvalid_identifiers` rather than a plausible neighbour, verified live. It can turn an honest failure into a correct resolution; it cannot turn a wrong name into a wrong node. - It runs only on the failure path, so a name the existing path resolves
costs exactly what it cost before, and a throttled Datasets service raises
NCBIUnavailablerather than being cached for a day as "NCBI does not know this organism" — the conflation 0.73.1 exists to prevent. - Off-switch: *Admin → Settings → NCBI E-utilities → Resolve reclassified
names* (
ncbi_synonym_lookup). It reaches a second host, so an operator mid-review needs a way back that is not a deploy.
- The rung cannot invent a resolution. The endpoint matches exactly and
does no fuzzy or nearest-neighbour lookup:
- A resolution through a synonym is not a silent rename. Promotion stores
NCBI's current accepted name — correct, and unchanged:
tax_idis the identity and the public route is/microorganisms/{tax_id}. But the name in the paper and the name in the database then differ, and nothing said so.TaxIdLookupResponsenow carriesname_classandsynonym_of, set only when NCBI itself lists the queried string under<OtherNames>as a synonym / equivalent name / anamorph — never inferred from two names merely looking different, because every strain name and every rank climb differs too and a warning on all of them would be no warning at all. It is surfaced on the review page's "Look up tax IDs" button, which is the moment before the decision and the place the name is on screen. That button was itself broken: it readextracted.microorganisms, a top-level key the contract does not have, so it answered "No microorganisms in extracted data" for every paper ever reviewed. It now walksinteractions— the same three actor shapes promotion walks — and reports, per organism: resolved, resolved via a synonym with both names, or not found with NCBI's own reason. Microorganism.synonyms(nullable JSON, migration0005_microorganism_synonyms) — every other name NCBI lists for the taxon, plus the name each paper used for it. Storing the current name is right; losing the old one is not. An organism's whole findable surface was built fromname: the FTS vector and the?name=/?microorganism=ILIKE filters. A reader searching the pre-2020 literature for Pseudomonas stutzeri would have got nothing back. The column feeds the microorganism search vector (weightB, in all three copies of that SQL) and both name filters, and is filled from the<OtherNames>block of an efetch promotion already makes — no extra request. It is never an identity key: nothing resolves to a row through it, so a wrong synonym costs a spurious search hit and never a merged record.python -m scripts.backfill_microorganism_synonymsfills it for rows promoted before the column existed (dry-run by default).
Version 0.74.0
Added
- NCBI credentials are entered in the dashboard, not baked into a Secret.
0.73.1 routed every E-utilities call through one
eutils_getthat sendstool/email/api_keyand paces itself to whichever ceiling those buy — 3 requests/second anonymous, 10 with a key. It had no way to get a key. Provisioning one as a Kubernetes Secret works exactly once and then costs a pod restart to rotate, which is the trap this deployment already avoids for every other credential it holds.ncbi_api_key,ncbi_emailandncbi_max_rpstherefore join the existing runtime-settings registry (app/core/settings_schema.py) as a new **NCBI E-utilities** group, and inherit that mechanism whole: the key is typed on Admin → Settings, stored as anapp_settingsoverride row, and applied onto the liveSettingssingleton byapp/core/settings_store.py. Propagation is the existing Redis version counter, so every API worker and the Celery worker pick a new key up within seconds — no restart, no redeploy. Nothing new was invented for this; the whole feature is three field definitions plus the checks below.- Precedence is stored > environment, matching every other field in the
registry.
NCBI_API_KEY/NCBI_EMAILkeep working untouched when no override row exists — that is how the fix behaves before anyone opens the settings page, and how it behaves again the moment the override is reset. Resetting a field is the documented way back to the environment value. - Reading the key costs nothing.
_eutils_identity()reads an attribute off the process-local singleton, not a row. Promotion issues ~2.8 eutils calls per named organism; a database round trip inside each would have been a worse regression than the one 0.73.1 fixed. - The key is verified live at the moment it is saved. A wrong NCBI key
does not announce itself — NCBI simply declines to raise the ceiling, so the
only symptom is the 429 storm that used to surface to a curator as "this
organism could not be resolved". Saving any NCBI field now runs one real
esearchfor Escherichia coli through the sameeutils_getproduction uses, and reports back on the save. A 400/401/403 is named as a rejected key; a 5xx explicitly does not blame the credentials; an empty result for an organism that certainly exists is treated as a failure rather than as an ordinary negative. A failing check does not roll the save back — discarding what the operator pasted would lose it and teach them nothing. Test NCBI credentials re-runs the check later without re-entering the key.
- Precedence is stored > environment, matching every other field in the
registry.
Security
- Secrets are shown as a masked tail, and still never returned. The settings
API has always withheld secret values (
value: null), which is safe but left an operator unable to tell which key is stored — a stale key and the right one both read as "set". Fields now also carrymasked("••••••••1a2b"), computed bysettings_schema.mask_secret. The stored value is never rendered into an input: the mask is placeholder text, the field starts empty, and an empty submit still means "keep what is set". Anything shorter than 12 characters is masked completely — a 6-character secret with 4 shown is published, not masked. api_key=in a query string is now scrubbed from logs. NCBI takes its credential as a query parameter, and httpx embeds the full request URL in the string form of several of its errors. None ofscrub_secrets' existing rules matched that shape — there is no userinfo, and the bearer-token rule wants whitespace after the word rather than=— so an eutils timeout would have written the key to stdout._QUERY_SECRET_REredacts the value ofapi_key/token/secret/password/signatureand friends while keeping the parameter name and the neighbouring parameters, because "which organism was being resolved" is the most useful thing in such a line.
Changed
- The three settings-page connection-test buttons (Slack, SMTP, and the new
NCBI one) share a single delegated click listener inside the nonce'd script
block instead of carrying inline
onclickattributes. A CSP nonce does not cover an inline handler, so the file's entry in thetests/test_csp.pyinline-handler ledger falls 8 → 6 rather than growing to 9.
Version 0.73.1
Fixed
- An NCBI rate-limit no longer arrives as "your organism name is wrong".
Approving ingestion 10 (
10.1007/s10482-022-01782-0, 108 fungal isolates, no proteins) spun for nearly five minutes and came back 422. It was not hung and nothing was half-written — the guard did its job and the row rolled back topending_review— but the reason it gave was, in part, false.lookup_taxidresolves a species-level tax-id with three sequential E-utilities calls (esearch→esummary→efetch), and_resolve_taxidsruns it once per distinct organism name with nothing between the calls. Measured on the live pod, one clean attempt at that paper was **108 names / 190 s / 303 requests — 1.76 s per organism**, fired as fast as the event loop could push them at a service whose documented ceiling is 3 requests/second for an unidentified caller. **51 of the 907 eutils calls that pod made came back HTTP 429**,Aspergillus oryzae,Penicillium crustosum,Pseudogymnoascus pannorum,Cladosporium halotolerans,Acremonium fuci,Epicoccum sorghinumandPenicillium javanicumamong them — seven organisms NCBI Taxonomy indexes perfectly well. Every non-200 becameTaxIdLookupResponse(found=False), promotion turned that intoUnresolvedTaxonomyError, and the review page pointed the curator at a name that was never wrong. Nothing was logged, either: the onelog.warningin the function fires only on an exception, and a 429 returns cleanly, so zero warnings were emitted for 51 throttled calls. The same payload could fail on a different organism each attempt. Every eutils call now goes through oneeutils_get:- A throttle refusal is no longer an answer. A 200 with an empty
idlistis the single honest negative (NCBI even names the term inerrorlist.phrasesnotfound, now logged). A 429, a 5xx or a transport error is retried withRetry-After/exponential backoff and then raisesNCBIUnavailable, whichapp/main.pyanswers as a 503 — "the taxonomy service could not answer, nothing was changed, try again" — never a 422 about the payload. A 400 is not retried: it is an answer about that request. - Paced and identified. Requests are spaced to NCBI's published limit and
carry
tool/email, plusapi_keywhenNCBI_API_KEYis set — which is what lifts the ceiling from 3 to 10 requests/second and takes that paper from 190 s to roughly 30 s.NCBI_EMAILis the contact address NCBI asks for; an identified caller gets an email before it gets blocked.NCBI_MAX_RPSoverrides the derived pacing. The throttle is per process — two API pods each pace independently — so the key matters more than the pacing does. - Taxonomy is cached for a day. It changes on the scale of nomenclature
revisions, not minutes, and promotion asks the same questions repeatedly:
214 extractions name 1,209 distinct organisms across 1,511 slots. More to the
point, a paper refused for one unresolvable name re-resolves every name
on the curator's next attempt — 108 of them, for a payload that changed in one
field. That retry is now nearly free. Only a completed lookup is cached; an
NCBIUnavailableis not, so a throttled minute cannot pin a wrong answer to a name for 24 hours. - The species rank-climb degrades rather than fails: losing it costs
species-level normalisation, not the tax-id already in hand.
Not fixed here, and worth knowing before a large approval run: promotion still
refuses on the first unresolvable name rather than reporting them all, and
UnresolvedTaxonomyError.as_detail()already returns a list in Pydantic's 422 shape, so it can carry more than one. Across the corpus **73 of 1,209 organism names (6.1%) genuinely have no NCBI Taxonomy match**, in 33 of 214 papers — open nomenclature (Pleosporales sp. 1,Acremonium cf. fuci), community-level actors (activated sludge microbial community), and binomials NCBI has reclassified (Pseudomonas stutzeri→ Stutzerimonas,Rhodococcus equi→ Prescottella,Pycnoporus sanguineus→ Trametes sanguinea). All are faithful to their papers. One-at-a-time refusal makes those papers cost one full approve per bad name.
- A throttle refusal is no longer an answer. A 200 with an empty
Version 0.73.0
Added
- **Nine vocabulary terms, from a single sweep of the whole extraction corpus
rather than one deploy per missing word.** 0.72.0 expanded the vocabulary
against a 148-paper batch; this is the same measurement repeated over all 209
extractions on disk, including the papers sitting in
NEEDS_HUMAN_REVIEW, and it asks a question 0.72.0 could not: *did the additions land, and what did the corpus do with what is still missing?* The headline result is that almost nothing now fails the vocabulary check. Scanning every controlled field of every extraction produced exactly two off-vocabulary values in the entire corpus. The gaps that remain are therefore not visible as errors at all — they are terms that *collapsed onto a neighbour that means something else*, which is only findable by measuring what each existing term is actually carrying.PVAc(plastics, Poly(vinyl acetate)) — the one gap blocking a finished extraction.10.1016/j.biortech.2026.133982has 6 interactions and 39 observations, two wild-type proteins and four variants, all against PVAc, and has been inNEEDS_HUMAN_REVIEWsince it was extracted. It is notPVA: polyvinyl alcohol is the product of PVAc hydrolysis, and that paper reports the conversion directly (a new -OH band at 3202 cm⁻¹), so folding the substrate into it would invert the finding.4,4'-Methylenediphenyl diisocyanate,Toluene 2,4-diisocyanate(compounds) — a polyurethane is a polyol polymerised with a diisocyanate, and with neither monomer in the list a PU'ssubstrate_compositioncould not be written at all. 6 papers named their diisocyanate intospecimen_descriptionfor want of an entry. This matters more given the decision below to keepPUas one term: composition is then the only structured place left to record what a given polyurethane was.contact_angle_change(measurement_type, defaultdegree) — found structurally, not from a complaint:contact_anglehad been amethodsince the vocabulary was written and was the **only method in thesurfacegroup with no measurement to pair with. 11 papers ran it; 83 of their 91 observations** went tosurface_morphology, animagingterm declared qualitative, which is now carrying angles under four spellings —°(61),degree(18),deg(3),degrees(3).soluble_carbon_released(measurement_type, defaultmg/L) — same asymmetry (tocandcodare methods with nothing to measure), and the damage is scatter rather than absence: across 6 papers the identical TOC/DOC result landed onmonomer_released(8),product_identified(19) andmass_remaining(2), so no query returns them all.elongation_at_break(measurement_type, default%) — ~60 elongation values across 6 papers were filed undertensile_strength_change, which now holds%,MPa,N,J,cm,mm,Paandkg/mm2at once. Only this one property is split out; modulus, load and energy at break stay in free text until a paper count justifies them.gc(method) — gas chromatography with a non-MS detector (GC-FID, GC-TCD, GLC). 5 papers had onlygc_msavailable, so the record now claims mass-spectrometric identification of products assigned by retention time.visual_inspection(method) — 5 papers recorded photographs, flatbed scans and naked-eye assessment underoptical_microscopy.particle(substrate_form) — this reverses a 0.72.0 rejection, and the reversal is recorded inREJECTED_TERMSrather than quietly deleted. 0.72.0 predicted thatpowderandgranulewould absorb particulate polymer. Measured, they did not: 7 papers filed 1 mm microplastic beads, 500 µm microspheres, resin granulate and 0.6–1 mm chopped pieces undergranule— the one term carved out ofpowderto mean an *intracellular PHA inclusion body*. Both term descriptions now say which is which, because the term alone was demonstrably not enough.
Changed
- **
REJECTED_TERMSwent from 8 entries to 24 and is now the larger half of the sweep.** A controlled vocabulary is damaged as much by near-duplicates as by gaps, so what was refused is recorded with its argument:DEHP(a monomeric plasticiser already held as the compoundDioctyl phthalate— the paper that wanted it tests no polymer at all, which is a scope question for a curator and not a vocabulary gap),Methane(compoundsis reachable from an extraction only throughsubstrate_composition, so adding it would not let anyone record methane as a product),TPU,nanoparticle,ch4_evolved,ph/ph_change,binding_assay/binding_detected,dsf,elastic_modulusand friends,melting_temperature,polymer_content,soluble_substrate,Hydroform/BI-OPL,sds_pageandcolorimetric_assay. - **Polyurethane stays a single
PUterm — a curator ruling on granularity, 2026-08-07.** APU-ester/PU-ethersplit was proposed in this sweep and refused. It is recorded inREJECTED_TERMSrather than simply dropped, and the record deliberately keeps the evidence beside the refusal, because this is the entry most likely to be re-proposed: the measurement behind it is real and still reproducible — 63PUinteractions across 11 papers on one row, including10.1111/j.1574-6968.1995.tb07554.x, which reports a positive (polyester-type) and a negative (polyether-type) on that same term. A later sweep that recounts will reach the identical conclusion, so the note states that the count was known and was not what settled it. Reopen with a curator decision, not with a recount. The soft segment remains recordable where it always was:substrate_composition— now writable for polyurethanes thanks to the two diisocyanates above — andspecimen_description. - Three term descriptions rewritten and five added.
granulenow says a microplastic bead isparticle;contact_angle_change,soluble_carbon_releasedandelongation_at_breakeach name the neighbour they must not be confused with. This is the part that changes behaviour — the 0.72.0 additions that went unused (filmwas still being avoided by 12 papers aswhole_polymer) suggest a term nobody is told about does not get used.
Note for operators
Nothing in this release requires a migration. plastics and compounds are database rows and are picked up by the existing idempotent upsert in the PreSync plasticdb-migrate job; method, measurement_type and substrate_form are Python literals served straight from the image, and promotion get-or-creates their table rows on first use.
Version 0.72.2
Fixed
accession_noteswas accepted, answered200, and silently thrown away.ExtractedProteinActor.accession_noteswas two different things sharing one name. V2 owned it as an audit trail of identifiers its shape-router had moved between columns (#321): the_route_accessionsvalidator overwrote whatever a producer sent, andField(exclude=True)kept the survivor out ofmodel_dump()— which is exactly whatPATCH /ingestions/{id}stores intoextracted_data. Its only consumer was alog.infoduring promotion; it was never persisted, even for V2's own use. The AI pipeline had independently adopted the same name for the opposite thing: producer-supplied provenance prose about a protein's accession and sequence. It read the field offGET /openapi.json— where it is advertised as an ordinarylist[str]input — and reasonably concluded it was supported. It was not, andexclude=Truecould not say so: FastAPI builds request bodies from the validation schema, andexclude=Trueis a serialisation directive, invisible there. The contract said one thing and the code did another, with nothing to reconcile them. Measured on ingestion 8 (10.1128/aem.02773-17): 10 notes sent, 0 stored. Losing it silently is worse than rejecting it, because of what the field says. Ruling R-1 makessequencea deterministic function of the accession — the deposited record verbatim, purification tag and signal peptide included — so when the construct a paper actually assayed differs from what is stored, this prose is the only place a curator is ever told. The ten dropped from that one paper include "sequence stored is ACC95208.1 (308 aa) verbatim" and *"UniParc UPI0003945E1F … is not a UniProt accession, so it is not written touniprot_accession"*. Two meanings, so they are now two things.accession_notesstays the producer's and becomes ordinary data — persisted, rendered on the curation form, never acted on by the server — so the AI pipeline needs no change and every extraction already on disk becomes deliverable as-is. The router's trail becomes_accession_routing_notes, a private attribute rather than an excluded field: renaming alone would have rebuilt the same trap under a new name, because a server-owned field is still advertised in the request schema and a producer could still write to it and still be ignored. A private attribute is absent from the contract altogether, which makes the invariant exact — every field the schema declares round-trips — and makes the trail unforgeable by construction rather than by overwriting. The anti-forgery property from #321 (the curator form picks the target column from a dropdown the server must not trust) is preserved and still tested. Because approve overwritesextracted_datawholesale, a persisted leaf with no control on the curation form is deleted the first time a curator presses Approve, so both protein actors gain astrlistcontrol; the exportable-leaf count goes 100 → 102 andEXCLUDEDin the coverage test is now empty. The loss was invisible because every existing test asserted on fields it already knew about.test_every_extracted_data_leaf_survives_the_patch_round_tripwalks the schema instead — populate every declared leaf, PATCH it, diff sent against stored, no exemptions — so a future field that cannot round-trip fails without anyone having to think of it. Run across the wholeExtracted*contract,accession_noteswas the only casualty; it now reports 77 leaves sent and 77 stored, nothing dropped, changed or added.
Version 0.72.1
Fixed
- **The migrate job printed the production database password into its own pod
log.**
app/db/init_schema.main()opened withprint(f"Initialising schema at {url} ..."), whereurlis the rawDATABASE_URL. That function is the deployment's ArgoCD PreSync hook (plasticdb-migrate), so the credential — userinfo and all — was written to stdout on every release and readable by anyone withkubectl logson the namespace. It was long-standing, not newly introduced, and it survived because it looked like an ordinary progress line. The line kept its diagnostic value and lost the credential: it now printssafe_url(url), which renders driver, host, port and database with the userinfo removed — enough to answer "did this point at the database I meant?" Redaction goes through SQLAlchemy's URL parser rather than a regex, because a regex has to guess where the credential ends and guesses wrong on exactly the passwords most worth protecting (one containing@or:), and it refuses to render a URL it cannot parse rather than failing open. - **Credentials that libraries hand back inside exceptions no longer reach the
logs.** Two call sites were doing the ordinary thing — logging
%sof an exception — and leaking as a result:app/core/slack.pyloggedhttpx.HTTPStatusError, whose string embeds the request URL; for Slack the webhook URL is the credential, so every Slack outage published it.app/core/settings_store.pylogged SQLAlchemy'sArgumentError, which is literallyCould not parse SQLAlchemy URL from string '<the whole DSN>'. Rather than patch each site, the scrub happens once at the sink:scrub_secrets()strips URL userinfo, Slack webhook path secrets and inline bearer tokens, and both log formatters apply it to the rendered line — message, traceback andextra=values alike. The Slack notifier inapp/main.pyscrubs explicitly, since it leaves the process without passing through a formatter.
- Auth tokens no longer reach the logs via the request URL. The exception
handlers logged
request.url, which carries the query string —/auth/confirm-email?token=<jwt>,/reset-password?token=<jwt>and the ORCID callback's?code=all put a live credential there, and the malformed-input handler is client-triggerable, so it did not even need a real fault. They now log the path with credential-shaped path parameters blanked (covering/deposits/review/{reviewer_token}) plus the names of the query parameters.
Changed
- **The Jinja
settingsglobal is an allow-list, not the wholeSettingsobject.** Every template could previously render{{ settings.secret_key }}or{{ settings.database_url }}into a page. Nothing did, but nothing stopped it, and the blast radius of one careless debug partial is every credential the app holds. Templates now see exactlychatbot_enabled,environmentandrecaptcha_site_key(the site key, public by design). Values stay late-bound, so runtime overrides still take effect on the next render.
Added
tests/test_secret_redaction.py— three layers, so this cannot silently recur: unit tests on the redaction helpers, a behavioural test that runs the real migrate entry point with a sentinel password and asserts it never appears in the output, and a static AST ratchet over the whole tree that fails CI if anyprint/log call interpolates a credential-bearing name. The ratchet carries a short, documented allow-list in the idiom oftests/test_csp.py's inline-handler pin: it may shrink, never grow.SecretStrwas considered and rejected — it redactsrepr(), and this bug f-stringedos.environdirectly, so it would not have caught it.
Version 0.72.0
Added
- **The controlled vocabulary can describe materials the modern literature
actually studies.** Nine of the eleven lists
GET /api/v1/vocabulariesserves are Python literals that ship with the image. The two that are database rows —plasticsandcompounds— were therefore the only ones that could silently stop moving, and they had: all 69 labels the 2019 v1 dataset used were present and effectively nothing else, becauseimport_degraders.pymints a plastic row only when a v1 TSV row needs one. The list was never curated to cover the polymer literature; it was a by-product of one import. Measured against a 148-paper AI extraction batch, which confesses every vocabulary miss intoreviewer_guidance.data_gaps: **18 interactions dropped outright for want of an honest term and ~114 collapsed onto a term that means something else** — poly(lactic-co-glycolic acid) filed as "PLA Blend", medium-chain-length PHA copolymers filed as generic "PHA" *beside contradicting short-chain results for the same enzymes*, nylon-6 oligomer hydrolases filed as degraders of bulk "Nylon", PVDF and poly(propylene brassylate) filed under the placeholder "Plastic". None of it is visible in the data; all of it is visible in the extractor's notes.- 20 plastics, each carrying its evidence in
app/services/materials_vocab.py:pPVC,OVA,PVDF,OXO-PE,PLGA,PLLA,PDLA,PTT,PPBr,mcl-PHA,PHHx,PHD,Nylon 6,Nylon 66,Nylon oligomer,PPG,CA,CAP,TPS,Mater-Bi. - 24 compounds, so
substrate_compositionbecomes writable outside PET. The table held eight entries, all PET-pathway, which is why every PHBV valerate fraction, every PBSA succinate:adipate ratio and every starch-blend loading in the batch went to free text instead. Adds the 3-hydroxyalkanoate family C3–C12, the recurring polyester/polyamide monomers, and the formulation additives. functional_group_change(measurement_type) — the single largest hole. 221 FTIR observations across 44 papers were split betweenproduct_identified(179),activity_detected(37),surface_morphology(3) andmolecular_weight_change(2), four terms that each mean something else, which made "what did FTIR show?" unanswerable.film,emulsion,granule(substrate_form) — 904 experiments across 49 papers had a NULL form, 580 of themclear_zoneassays on emulsified polymer that no term described.plant_material(environment_type) — wood, bark, leaf litter and the phyllosphere. 624 wood- and litter-saprotroph strains from one fungal collection screen were sitting inother. Rejected terms and the reason for each are recorded inmaterials_vocab.REJECTED_TERMS, because a controlled vocabulary is damaged as much by near-duplicates as by gaps. Notably PBAT/PLA mulch film: "mulch film" is a form, not a material, and the existingPBAT-Blendanchor plussubstrate_compositiondescribes it exactly.
- 20 plastics, each carrying its evidence in
protein_variants.numbering_offset— the integer that says which frame a variant's mutation positions are in.sequence[position + numbering_offset - 1]is the residue, andMutationRead.sequence_positionnow does that arithmetic server-side so no consumer has to guess. 42 of 110 mutation-bearing variants in the batch hadpositionin the paper's mature-protein frame whilesequencewas the full-length precursor, differing by exactly the signal-peptide length (+38, +58, −34). The sequences were right and the mutations reconciled — the producer already computes the offset as a checksum — but it had nowhere to go, so anything indexingsequencebypositionread the wrong residue.numbering_schemecould not serve: it is free text ("mature", "full_length", "IsPETase_canonical"). Positions keep the paper's own numbering (R-7). NULL means "frame not established", not zero.
Changed
- ESMFold and DIAMOND now use the mature chain.
Protein.sequenceremains the deposited record verbatim (R-1) — that is what makes it a verifiable function of an accession — but a secreted enzyme's deposited record is a precursor, and folding its signal peptide yields a spurious N-terminal helix with the pLDDT to match, while aligning it scores similarity to secretion machinery rather than catalysis. SignalP's output had been written and displayed since it was added and read by nothing.app/services/sequence_frames.mature_sequence()derives it, refuses to trim on an implausible cleavage site, and falls back to the full record whenever the annotation is missing or incredible. SignalP moves ahead of ESMFold in both enrichment ladders so the first pass folds the right chain, and the DIAMOND staleness fingerprint now covers the cleavage site so a signal peptide landing on an unchanged sequence still triggers a rebuild. - Promotion asserts a wild-type sequence against its accession (R-1). The fetch used to be skipped whenever the producer supplied a sequence, so the column's one checkable property went unchecked. It is now always fetched and reconciled, and the deposited record wins. This is what catches a tag-stripper that ate three residues past the tag, and the same accession stored once as a 308-aa precursor and once as a 282-aa mature construct — both real cases from the first batch. A failed lookup changes nothing.
python -m app.db.init_schemaapplies pending migrations. It is what the deployment's PreSync hook runs, and it did not run alembic, so every migration since the baseline reached production only because an operator remembered to runalembic upgrade headby hand. A schema change gated on someone's memory fails as a production database silently older than the code talking to it.
Fixed
- Admin CRUD silently dropped
numbering_schemeon protein variants: it was on the model and both CRUD schemas but missing from the endpoint's field allow-list. Both it andnumbering_offsetare now writable.
Version 0.71.1
Fixed
- Reopening a contributor's paper destroyed their embargo. Found in review of
0.71.0, before it shipped. The revision an approved paper is corrected through
copied
source='submission', but nothing pointed at its new id — andlinked_submissionmatches onDataSubmission.ingestion_id. So the envelope resolved toNone,finish_submission_acceptancenever ran, replace mode's orphan cleanup deleted the embargoed organism, and promotion recreated it atvisibility='public'with no deposit. That passesquery.public_visibility_filter— the exact clause a release snapshot is built from, published to Figshare under a DOI that cannot be unpublished. This was the third distinct route to the same failure, after the approve route creating no deposit at all and reject-then-approve. Each was fixed where it was found and the next appeared somewhere else, because they share a shape: *a path reaches promotion without the submission envelope being resolved, and nothing refuses to proceed.* So the fix is not another special case:apply()now refuses. Every promotion path goes through it —promote(), submission acceptance, the approve route — and asource='submission'ingestion whose envelope cannot be found raisesUnresolvedSubmissionEnvelopeErrorbefore the first insert. Declaring that source is a claim that the record is somebody's own data; if the envelope that would say what visibility it is owed is missing, the honest answer is that we do not know, and the safe one is to write nothing. Surfaced as a 422 naming what to do.- Reopen carries the envelope across to the revision, so the working case works: the deposit intent, the fold jobs and the contributor's own submission history all follow the record that is live.
- A revision reuses its deposit instead of minting a second one.
Depositgainedsubmission_id(migration0003) so "does this contributor already have a deposit here?" is a fact rather than a guess at (owner, paper) — guessing wrong burns a second permanent PB#### accession and leaves the first container owning nothing. Membership inserts are now idempotent, which they had to be once a deposit could be reused. Pinned bytest_reopening_an_embargoed_submission_keeps_its_records_out_of_a_snapshot(pg), which drives submit-with-embargo → approve → reopen → approve and asserts zero rows passpublic_visibility_filter; and bytest_promotion_refuses_a_submission_paper_whose_envelope_is_missing, which pins the shared guard directly. Both were confirmed to fail without the fix.
- Reopen was check-then-act. Two curators pressing "Correct this paper" together both saw no open revision and both created one, and whichever replace-mode approve landed second silently overwrote the other's work. The open-revision check is now taken under a row lock on the paper being reopened, so the second caller's check runs after the first has committed and sees the revision it would have duplicated.
Version 0.71.0
The P3 follow-ups from the ingestion-review plan, plus six findings carried forward from the reviews and live QA of the earlier pull requests.
Added
- A way back into an approved paper. A curator who spotted a mistake after
approving had no path to correct it.
ApproveMode.replacewas never that path and could not have been: approve refuses anything that is notpending_review, andreplacesupersedes prior runs *from a new ingestion of the same DOI* — so the capability that was missing was the new ingestion, not a button.POST /api/v1/ingestions/{id}/reopencreates it: a copy of the approved record, back in the queue, pointing at the run it corrects. The live records are untouched until that revision is approved, so an abandoned one costs nothing. The review page offers it on an approved paper, badges the revision, and sendsmode=replacefor it — a merge there would leave two versions of one paper's evidence side by side. - A diff against the AI's own answer.
extraction_attemptshas held the extractor's untouched output since 0.66 and nothing read it. The review page now marks every field whose value differs from the latest attempt, with the model's own value on hover, so "what did it get wrong" is a glance rather than a JSON comparison done in the reviewer's head. Off by default — the record is what is being reviewed; the comparison is a second question. - Review time and per-field correction counts (
ingestion_review_metrics). Nothing measured review throughput or extractor accuracy, and both facts are destroyed by the act of approving: the stored payload is overwritten by the curator's version, and time-on-page exists only in the browser. Each approve now records which JSON paths differed from the AI original and how long the page was open. The dashboard over this is deliberately deferred until the rows exist. **Neither this table norextraction_attemptsis exported or snapshotted** — they hold unreviewed model output and a named person's working speed, and a release snapshot becomes a Figshare DOI that cannot be unpublished.tests/test_snapshots.pyfails if either name ever appears inexport.pyorsnapshots.py. - A read API for the extraction history.
GET /ingestions/{id}/attemptsand…/attempts/latest, curator-only. The service shipped with no callers; these and the diff view are its first. - A first-time orientation panel on the review page. A newcomer arrived with no explanation of what Approve does, and Approve writes straight into the live evidence spine. Dismissible, remembered, and reachable again afterwards.
- **Vocabulary help text — the mechanism, and only the prose that already
existed.**
/api/v1/vocabulariesnow servesdescriptions(what a vocabulary is for) andterm_descriptions(what a term means), and the curation form surfaces both. Both are deliberately sparse: they carry only sentences already written in this repository — the field-level notes inevidence_vocab.pyand theweight_loss/mass_remainingwarning from the Controlled Vocabularies page. Descriptions for the remaining ~65 terms are domain authorship and have not been invented here; filling them in is a text edit to two dictionaries with no code change.
Fixed
- A partially-filled record with no plastic yet was silently deleted. An
interaction is (actor × plastic), so a card carrying no plastic section
produced no rows at all — a curator could add a protein-variant card, type the
variant name, the mutations and the parent protein, and have the next autosave
store a payload with one fewer record than the screen showed. Approve
overwrites wholesale, so that was a silent deletion of work in progress. Two
changes: adding a record now brings its first plastic section with it, and a
card that holds anything is emitted even without one, so the server refuses
it by name (
interactions.N.plastic) instead of the form making it disappear. A genuinely empty card is still dropped — a stray click is not a record. - A re-extraction now names what it is about to destroy. The confirm dialog asked a generic yes/no while the endpoint deleted the saved draft in the same transaction and the pipeline overwrote the payload on screen. It now says which of those apply.
- A claim button can no longer swallow a click. Live QA found the first "Claim it" immediately after a draft-discard re-render doing nothing, with an identical second click working — the click landed while the page was being torn down by the reload, so the request was aborted and no handler ever ran. The root cause matters less than the silence: a button that can do nothing visible teaches people to click everything twice. Claim, release and takeover now paint a busy state and disable themselves on click, and clear it however the request ends. The discard status line says the page is reloading rather than going blank.
- A structure prediction can no longer be stuck for ever.
alphafold_statushad no terminal failure state and nothing that reconciled one: it stayed"running"when a task's retries ran out mid-fold, and"pending"when.delay()threw after the commit, both indistinguishable from a job genuinely in flight. The last retry now settles the row asfailed, a dispatch the broker refuses settles it too, and an hourly sweep settles anything left mid-flight with no worker behind it.
Documentation
- The Contributing Guide describes the form that now exists. How records and
their plastic sections are laid out and why (one interaction per actor ×
plastic); that the per-pairing context fields really are per pairing, and what
"Copy context to all plastics" does and deliberately does not copy; the
key/value editors and their raw-JSON escape hatch; the parent-protein picker
and why it always prints an accession; that kinetics and properties hang off an
experiment rather than the enzyme. The reference table gained
journal,pubmed_idandcorresponding_author_country. promote_submission's already-promoted path is documented as intentional. Re-accepting a submission whose ingestion is already approved links nothing and folds nothing, andPromotionResult.protein_idscollects only rows this promotion created, never reused ones. Both are the safe direction — the alternatives are a predicted structure attached to an unrelated protein that merely shares a sequence, and a second permanent PB#### accession — but neither was written down anywhere.- The content-match cost in
ingestion_promotion.pyhas a stated ceiling. Each_get_or_create_*re-reads every sibling row under the parent, so promotion is quadratic in the experiments accumulated under a single interaction. Fine at the sizes real papers produce; the comment says what to do if it ever is not, and why the obvious hoist is a correctness change rather than a free one.
Version 0.70.1
Fixed
- **Two third-party sources the new policy blocked, both of which work in
production today.**
/statisticsmakes Plotly fetch its world topojson fromcdn.plot.lyto draw the country choropleth;connect-srcdid not allow it, so the map rendered with no geography at all./docsloads Swagger UI's stylesheet from jsdelivr;style-srcdid not allow it, so the API reference shipped unstyled. Both are now allowed. Neither was visible from the checks that passed. The/statisticswalkthrough ran against a database with no country data, so the page took its "No geographic data available yet" branch and never requested the topojson — the policy was correct for the empty state and wrong for the populated one./docsis generated by FastAPI, not by a template in this repository, so nothing that readsapp/templatescould have found it. The tests now cover both shapes.TestRealPagesAgainstTheirOwnPolicyfetches every page reachable without seeded data — including/docs— and asserts each third-party origin in the served HTML is permitted by that page's own enforced policy.TestRuntimeFetchedOriginspins the ones no markup mentions, because a library asks for them itself on a code path an empty database never reaches. - Google Analytics can complete its round trip.
connect-srcallowed the measurement beacons but notgoogletagmanager.com's tag-diagnostics ping, nor thewww.google.com/g/collecthost gtag falls back to when the primary beacon fails. Analytics loss only — no page was broken — but the other GA hosts were already allowed, so this was an oversight rather than a decision. - The
'strict-dynamic'test now tests'strict-dynamic'. The spec named after it set a nonce on the script it injected, so the assertion passed on the nonce alone and removing the source from the policy left the suite green. The injected script now carries no nonce and comes from an origin in no allowlist, so trust propagation is the only thing that can make it run. - Corrected counts. The split is 52 strict / 21 legacy, and the inline-handler debt is 109 across 22 files — not the pre-conversion numbers quoted in the 0.70.0 notes and the module docstring. The ratchet's whole value is that those numbers can be trusted.
Version 0.70.0
Added
- A Content-Security-Policy, enforced on every page. The application shipped
without one, so a single injected
<script src="…">could load anything from anywhere, a<base>tag could re-point every relative URL, a form could post its contents to another origin, and any site could frame PlasticDB. All four are now refused everywhere, along with<object>/<embed>content andfetch()to origins outside a short allowlist. Inline script is the harder half, and the policy is split rather than fudged. 52 of the 73 templates — the whole ingestion review and curation flow, the dashboard, the tools, the docs, sign-in, and most of the public site — are served a nonce-basedscript-srcwith no'unsafe-inline': an injected inline script does not run on them. That is only reachable because the earlier work in this cycle moved the review and curation JavaScript out of the Jinja templates and intoapp/static/jsmodules. The remaining 21 templates still build markup withonclick=attributes, and a CSP nonce does not cover those — the moment a policy carries a nonce the browser ignores'unsafe-inline'entirely, so giving those pages the strict policy would break their buttons. They keep'unsafe-inline'and carry aContent-Security-Policy-Report-Onlyheader with the strict policy, so the remaining violations are visible in the console instead of being an unmeasured intention. Which page gets which is computed from the templates themselves, not from a hand-kept list: a page upgrades to the strict policy the moment its last inline handler is deleted, and adding one to a page that is currently strict fails the test suite rather than the browser.tests/e2e/csp.spec.jsserves the shipped policy to a real Chromium and asserts it refuses an injected inline script and anonclick, while still running the page's own scripts.
Fixed
- Logout, the API-key Copy button and the pathway explorer's controls no
longer use inline handler attributes. Removing the one on the logout link
moved 24 templates onto the strict policy on its own — every page that extends
base_landing.htmlhad been held back by it.
Version 0.69.1
Review fixes on 0.69.0, all in the same class: something that said, or would have said, something false.
Fixed
- A draft can no longer outlive the extraction it was written against. A
draft is edits of a particular extraction, and nothing tied the two together:
a draft built on one extraction survived Re-run extraction and the pipeline's
replacement write, was restored over the fresh answer on the next page load,
and approving from that form promoted the stale payload. The banner offered
"Discard it and go back to the extraction", but it read identically whether the
draft was thirty seconds or three extractions old, so it carried no signal at
the moment it mattered most.
Re-extraction now discards the draft outright — the curator has just asked for
a new answer, and that is exactly the case where the old edits should lose. For
every other way a new extraction can land under an open tab, a draft records
the
extraction_attemptsrow it was built on, and the review page refuses to restore over a newer extraction: the fresh one stays on screen, the banner says the extraction changed, and loading the draft over it becomes a deliberate click. - **A claim that loses its race to a colleague's release no longer says somebody took the paper.** It reported "Another curator claimed this paper a moment before you did" when the paper was in fact free and claiming again would simply have worked — sending the curator to find a colleague who was not there. The two losses now get different sentences.
- **
DELETE /ingestions/{id}/draftandGET /ingestions/{id}/claimanswer 404 for an ingestion that does not exist**, rather than a confident204/200about nothing. - The claim service no longer answers from a stale in-session row. Every
state change is a conditional
UPDATEwithsynchronize_session=False, so theSELECTafter it handed back the object the session already held — with the pre-update values still on it. The routes happened to be saved by their own refresh after commit; that was luck, not a contract.
Version 0.69.0
Two curators can now work the same review queue without standing on each other, and closing the tab no longer throws the work away.
Both features are built on the same line, which is worth stating before the features themselves: **the thing that keeps two simultaneous approvals from duplicating the evidence is the compare-and-set inside Approve, and nothing here changes that.** A claim is a signpost, not a lock. Approving without one is allowed and is exactly as safe as approving with one.
Added
- Draft autosave on the review page. The curation form saves itself a second
or so after you stop typing, and a saved draft is restored — over the
extractor's payload — the next time the page loads. Closing the tab, a crash,
or a session lapsing no longer costs an hour of careful reading against a
paper.
The draft is deliberately not validated as
ExtractedDataon the way in. A half-typed record is invalid by definition, and refusing to store it is how an autosave loses exactly the work it exists to protect; validation happens when you press Approve, which is the moment it stops being a draft. It also changes no status — the existingPATCH /ingestions/{id}could not be reused for this, because it moves the row topending_reviewwhen it writes. If a colleague saved over the draft while your tab was open, the save is refused with a message that says so, and nothing is overwritten in either direction: your edits stay on screen, and taking theirs or replacing them with yours is an explicit click. Approve and reject delete the draft in the same transaction that decides the paper, so a tab left open overnight cannot restore yesterday's half-edit on top of a published record. - Explicit review claims, with heartbeat, release and takeover. A paper can be claimed from its review page, and the queue index badges papers a colleague is already working on. It is coordination, not control: a claimed paper is still open, editable and approvable by anyone, and the wording never pretends otherwise. Claiming is always a button press. It is never a side effect of opening the page, because prefetch, link previews, crawlers and accidental middle clicks are all page loads, and every one of them would otherwise reserve work nobody is doing. A claim nobody released comes back on its own. The page heartbeats while it is open; a closed tab or a sleeping laptop simply stops, and after fifteen minutes the paper reads as unclaimed and the next curator takes it with no ceremony and no administrator. Taking a live claim is possible too, but it is deliberate — confirmed, and recorded on the record with the previous holder's name — because a signpost you cannot walk past is a lock.
Deployment
Two new tables (ingestion_drafts, ingestion_claims). New tables ship through the ORM models, so the release step is python -m app.db.init_schema. No alembic migration: nothing existing is ALTERed.
Version 0.68.3
A third review break slipped through, and the read that found it also found a live hole in shipped code — one that has been silently deleting extracted data. The hole is the more important half of this release.
Fixed
- **An actor object that does not belong to its interaction is no longer
deleted.** An extraction carrying two actors on one interaction
(
actor_type: 'protein'with both aproteinand amicroorganism), or an actor under anactor_typenothing recognises, had the extra object **silently removed** by the form's round trip. Approve overwrites the stored record wholesale, so that removal was permanent, invisible, and reachable from an ordinary malformed LLM output. The object is now carried through untouched, on the pairing rather than the card — two interactions sharing an actor can each have their own — so what reaches the server is what the extractor produced. The schema rejects it with a 422 that names the field, which the review page now scrolls to and marks. A rejected approve a curator can act on is a good outcome; a quiet deletion is not. Preserving it silently would still leave a dead end, so the record also carries a warning naming the key and pointing at the raw-JSON panel — the one place the record can actually be corrected. It is a warning rather than a block: the data is intact either way, and the server's rejection is more precise than any guess the form could make about which actor was meant.
Changed
- The generator can now express element order and malformed actors. Two
states it could not reach before, both of which hid a real defect:
- nested array order. The actor pool varied key order but never
element order, so
mutations: [S238F, W159H]and[W159H, S238F]— two different variants — were never generated as a pair. A grouping key that canonicalised element order (the most natural "canonicalise harder" edit anyone would make to a canonicaliser) merged them and rewrote the second's mutation order to the first's. Key order and element order pull in opposite directions and are now generated as separate twins: a key-order twin that must merge, and an array-order twin that must not. - mismatched and stray actor keys — the states behind the fix above.
- nested array order. The actor pool varied key order but never
element order, so
- Run counts trimmed to keep the blocking bucket fast. 1200 payloads per run
(the per-state coverage assertion passes at 25 and fails at 8, so the margin is
~50×), and the offline bucket is 11.1 s.
PLASTICDB_PROP_SEEDremains the way to explore a wider slice on demand.
Version 0.68.2
Review found two realistic edits to the serialiser that caused exactly the data loss 0.68.0 exists to prevent, and the oracle caught neither — not because the assertions were wrong, but because the generator could not express the payloads that expose them. A property test is only as strong as the space it samples, so the generator was widened rather than patched case by case.
Fixed
- Clearing a reviewer-guidance box no longer 422s the approve. Found by the
widened generator, in shipped code:
coerce('')returnednullfor astrlist, butdata_gaps/focus_areas/suggested_stepsarelist[str]and not optional, so emptying one of those boxes produced a payload the server rejects. An emptied list is now[]. The three-way rule is unchanged for every other type — an emptied box is still an explicitnull, and still an omission on a required or defaulted field.
Changed
- The generator now reaches the payloads that break things. Interaction
counts run to 14 rather than 5 (below ten, an index comparator that sorts as
strings is indistinguishable from a correct one, because
"0"…"9"sort identically — at ten,10sorts before2and a paper comes back silently reordered). Lists run to 4 rows. Actors are generated populated,null, absent and with an unrecognisedactor_type— the states a half-filled card holds, none of which the server would accept, all of which the serialiser must carry faithfully so the resulting 422 is about what the curator actually did. Strings now include unicode (µmol·min⁻¹·mg⁻¹,30 °C ± 0.5,日本), embedded newlines, leading and trailing whitespace, numeric-looking strings ("2016","0110686"— a collection ID that must not lose its leading zero), and values that collide with the form's own path notation (a.b,interactions[].notes). Numbers include negatives (latitude and longitude legitimately are), zero and exponent form. Dicts nest. Object key order is shuffled, so the grouping key's sort is exercised rather than assumed. - Coverage is asserted per state, not per field. Every optional leaf must
have been generated as a value, as an explicit
null, and as absent. The previous check counted a leaf as exercised if it ever held a value, which a run that never produced a singlenullwould have satisfied — while leaving the module's hardest distinction untested. coerceanddisplayare property-tested as an inverse pair. The round-trip oracle is a pure object shuffle and never converts a value to the string a control holds, which is where a coercion bug actually lives. The exceptions are stated in the test rather than avoided, because the exceptions are the three-way null rule.- The error-location mapping claims what is true. It asserted injectivity
over locations, which is not a property of the system: one card can carry
several interactions, and
interactions.{0,3,4}.protein.nameshare a single control by design — marking three boxes for one mistake would be the bug. The fixture now gives one actor three plastics (so the sharing is exercised at all) and the assertion is injectivity over addresses: a pairing-scoped path is addressed by its location, a card-scoped path by (card, field), and no two distinct addresses may share a control. PLASTICDB_PROP_SEEDshifts the seed range. The blocking bucket stays bounded — a CI gate slow enough to skip locally is not a gate — while the space can be explored on demand without losing per-run determinism.- The grouping sentinel is written as
\u0000, not a literal NUL byte. The byte is what makes it safe; the literal madegreptreat the file as binary and silently return nothing, on the one file every field-map lookup starts from.
Version 0.68.1
The safety gate for the curation form. 0.67.0 took the form to all 99 leaf fields of ExtractedData, and approve overwrites the stored record wholesale — no merge, no preserve-unmapped fallback — so any field the form fails to carry from JSON to form and back is deleted from the database permanently, on a curator's most ordinary action, with no error anywhere. That property was verified once, by hand. It is now checked continuously, by machine, on every pull request.
Added
- A property-based round-trip oracle over the serialiser
(
tests/e2e/curation_form_property.spec.js). Three thousand generatedExtractedDatapayloads per run must survivefromFormState(toFormState(d))unchanged — same keys, same order, and the same three-way distinction between a field that is absent, one that is explicitlynulland one that is the empty string. The generator is built from the module's own field map, which a pytest case pins to be exactly the schema's 99 exportable leaves, so a field added toExtractedDatastarts being generated without anyone editing the test. A failure names the JSON paths that changed rather than printing two payloads to diff by eye. It runs in the Playwright tier, which is a required check, and costs about half a second. - Semantic equality after server validation
(
tests/test_curation_form_roundtrip.py). Exact JSON equality is the right oracle for the module and the wrong one for the system:model_dump()fills absent optionals withNone, accessions are re-routed between columns by a validator, and defaults materialise from nothing. This second oracle generates from the Pydantic models themselves, drives the shipped JavaScript through node, and asserts the stored record is unchanged — the check that would catch the JS field map and the Python schema drifting apart in behaviour rather than in name. - A 422's location is now a place to look. Validation errors arrive as
interactions.3.experiments.1.method; the form has no "interaction 3", it has actor cards with per-plastic pairings, and the payload's interaction order is rebuilt from hydration order rather than from where a card sits on screen. Every such location now resolves to exactly one control — pinned in the browser tier against a payload covering all 99 leaves, and required to be injective, because a mapping that quietly points at the wrong box is worse than one that points nowhere. The offending field is marked with its message, any collapsed section containing it is opened, and it is scrolled into view. A location the form does not render still appears in the summary, labelled as such. - Promotion failures carry a location.
UnresolvedTaxonomyError— the one promotion failure a curator can actually act on, by correcting a species name or supplying a tax-id — used to arrive as prose with no location at all. It now names the organism-name control it came from, in the same shape Pydantic uses, so the review page needs no second code path for it. Raised without a location (the submission-acceptance path, which has no form on screen) it is the plain string it always was.
Version 0.68.0
Added
- The Python test suite now runs in CI, and both tiers gate the merge. Until
now
.github/workflows/pr-checks.ymlhad exactly two jobs —build, which only randocker build, ande2e, which only ran Playwright. Neither image contains a test runner, so the entire mock tier (~2500 tests) and the whole PostgreSQL tier were ungated: every merge tomainrested on someone running them locally and reporting the result honestly. Two jobs close that gap.testsrunspytest -m "not pg"— 2484 passing, ~65s.pgrunspytest -m pg— 170 passing, ~35s — against apostgres:16-alpineservice container, via thePLASTICDB_TEST_PG_URLescape hatch the conftest fixture already provides. Not testcontainers: testcontainers has to drive the docker daemon itself, which is a sidecar on this runner, and that has proven unreliable here. The schema is still built bycreate_schema(), so it is still the seeder's schema under test. Both are required status checks onmainalongsidebuildande2e. Both run inside.github/test.Dockerfile, built frompython:3.12-slim— the same base as the application image, and the reason this is worth doing rather than trusting the dev host. The host runs Python 3.14, where twopasslib/bcrypttests fail and the pg tier is substantially red; on 3.12 both tiers are clean. CI now tests the interpreter that ships, so those host failures stop being noise a human has to remember to discount.
Changed
tests/is no longer excluded from the docker build context. The applicationDockerfileneverCOPYs it, so it does not reach the app image; the two test images build from the same context and need it.
Version 0.67.1
Review fixes to 0.67.0, before release. All three are silent failures — the class of defect the release exists to remove, so none of them was worth deferring.
Fixed
- Editing one row of a free-form dict no longer re-types its neighbours.
Rebuilding the dict from its rows re-inferred every value's type, so touching
one key turned a sibling's
"50"into50and"true"intotrue.additional_conditionsandculture_collection_idsare exactly where free-form strings like lot numbers and collection IDs live, and a collection ID is an identifier rather than a quantity —"0110686"would have lost its leading zero with nothing on screen to say so. Each row now remembers the value and the exact text it was rendered from: an untouched row gives back its original verbatim, a row whose original was a string stays a string however numeric the new text looks, and only a row the curator added is inferred. - A failed vocabulary fetch is announced, and blocks Approve. If
/api/v1/vocabulariescannot be read, every controlled dropdown renders empty while still holding its value, so a record that saysnegativereads as "positive (default)". The payload was never at risk — an untouched control emits its original — but approving on the strength of a screen that says something else is, so both pages now raise a banner and refuse to send until the page is reloaded. On the Add-data page the same state could only ever have produced a 422 (there is nomethodto pick), so refusing early is the kinder failure as well as the safer one. - The form's fields stay hidden until it has been filled in. Hydration has to wait for the vocabularies before putting a value into a dropdown, and it overwrites every control when it runs — so the form was briefly on screen, complete and empty, and anything typed into that window was discarded without a trace. Nothing to type into is the honest state. Approve now awaits hydration too, rather than silently taking the raw-JSON branch when a click gets in first.
DataSubmissionCreateforbids extra fields. The Add-data page hides the extractor-metadata block because a typed record has no model behind it, but that boundary lived only in the browser: a hand-rolled POST carryingextraction_confidencewas accepted and silently ignored, which reads to the caller as "stored". It is now a 422 — which also catches a misspelled field name while it is still a typo.- An
actor_typeno card template renders is reported. Reachable only through the raw-JSON panel, where it used to drop the card and every pairing under it in silence. It now warns on the console and names the count on the form.
Version 0.67.0
One curation form, covering 100% of the extraction schema, shared by the Add-data page and the curator's review page.
The motivation is a single line in the approve route: ingestion.extracted_data = data.model_dump(). Approve is a **wholesale overwrite** — there is no merge and no preserve-unmapped fallback. The form collected 34 of ExtractedData's 99 leaf fields, so the other 65 were not "missing from the UI": they were deleted from the stored record, permanently and without an error, the first time a curator pressed Approve on a paper the extractor had filled in properly.
Added
app/templates/dashboard/_curation_form.html— the shared form. Included verbatim bysubmit_data.htmlandingestions_review.html, so a contributor can describe exactly what an AI extraction can, and an improvement to either page's fields lands in both. It renders a control for all 99 exportable leaves ofExtractedData, andtests/test_curation_form_coverage.pyenumerates the Pydantic models and fails if one is missing — or if a control writes to a path the schema does not define. The only two leaves without a control are theaccession_noteslists, which areField(exclude=True)and never survivemodel_dump(). New to the form: enzyme kinetics (Km/kcat/Vmax and their units), enzyme properties (Tm, T50, half-life, optima), blend and copolymersubstrate_composition, substrate crystallinity/Mw/grade/manufacturer, specimen description, growth-temperature range and oxygen requirement, PDB IDs,genome_assembly_accession,culture_collection_ids, enzyme production context and preparation, and the whole nestedreferenceobject.- The
protein_variantcard. The only actor type with no UI anywhere — all 19 fields, the repeatingmutations[]list, and the nestedparent_protein. The parent picker prints the accession beside the name, always: 134 rows in this database are called "PETase". - A key/value editor for the free-form dicts (
additional_conditions,culture_collection_ids), with a collapsed raw-JSON panel behind it. The textarea is the single control the collector reads, so the two views cannot drift, and malformed JSON is reported by field path rather than swallowed. - **
/api/v1/vocabulariesnow serves the grouping and the default units it always had.**METHOD_VOCABhas carried acategoryandMEASUREMENT_TYPE_VOCABadefault_unitsince they were written, and the endpoint discarded both — so a thirty-item method dropdown was ungrouped and every curator retyped "%" forweight_loss. Additive: every existing key is unchanged, becauseplasticdb-extractreads this endpoint too.
Changed
- **The review page pre-fills the shared form from
extracted_data, and Approve posts what the form holds.** The AI's answer now sits in the boxes the curator would otherwise have typed into, so "what did it get wrong" is a visual comparison. The raw JSON panel is still there, collapsed, with a button to load an edit back into the form. Full page width, deliberately — the reviewer keeps the paper open on a second monitor. - The JSON ⇄ form mapping is a testable module.
app/static/js/curation_form.jsis pure, touches no DOM, and is exercised bytests/e2e/curation_form.spec.js;curation_form_ui.jsbinds it to the markup and is exercised against the shipped partial injected verbatim. Previously all of it lived inside a 1645-line Jinja template that only a browser could execute.
Fixed
- Per-pairing fields are stored per pairing.
notes,sample_source,strain_designation,enzyme_production_context,enzyme_preparation,genome_assembly_accessionandculture_collection_idsare defined by the schema per enzyme–plastic pairing, but the submit form collected them once per actor card and copied the same values onto every plastic ticked. A curator could not record different notes for LIP1→PET and LIP1→PCL — a live defect corrupting manual submissions. They now live on the per-plastic section, with a "copy context to all plastics" button for the case where they really are the same. - Absent,
nulland''stay three different claims. A control holds a string, and all three render as an empty box; the old serialiser collapsed them, which rewrote the extractor's "we looked and there is nothing" into "nobody looked" on every approve. An untouched control now emits its original value verbatim, and only a control the curator actually changed is re-derived from its text — where an empty box means an explicitnull, except on required and defaulted fields, where it means "leave it out". - **
journal,pubmed_idandcorresponding_author_countrysurvive an Add-data submission.** The envelope carried five ofExtractedReference's seven fields, and promotion writesPaper.journalfrom one of the two it dropped. - **Two interactions that share an actor's name but differ anywhere else are never merged.** Actor cards group on the whole actor object, not the name, so regrouping cannot pick one of two accessions and discard the other. Interaction order survives the round trip even when two actors interleave.
Version 0.66.2
Second round of review fixes to 0.66.0, before release. Both close the same class of hole: the envelope and its ingestion disagreeing about whether a record has already been decided.
Fixed
- Rejecting a submission now takes its ingestion out of the review queue.
Nothing carried a decision downward, so a rejected submission's ingestion sat
at
pending_review: still listed, still approvable — and approvable **without the deposit**, because the approve route read an already-decided envelope as "nothing to act on". An explicit reject-then-approve sequence therefore reproduced the embargo leak 0.66.1 closed. Approving such a row is now refused by the ordinary state machine (409), not by a special case.acceptedis deliberately not mirrored downward: acceptance means "promote the evidence", and promotion skips an already-approved ingestion — mirroring it would make acceptance silently write nothing. - **
sync_submission_envelopereturns the envelope, not "the envelope I changed".** That conflation was the underlying defect: itsNonemeant both "there is no envelope" and "there is one but I declined to move it", and the caller used it to decide whether to create the deposit. It now returns the envelope whenever one exists, so promotion cannot run with an unnoticedNoneand silently skip a contributor's embargo — whatever state the envelope is in and whichever path got there. Asource='submission'ingestion with no envelope at all is logged rather than passed over in silence. - The admin acceptance path records who approved the ingestion and when. It
set
status = approvedand nothing else, so an ingestion approved through the submissions table had noreviewed_by_id/reviewed_atwhile the same action through the queue had both. The two paths are meant to be interchangeable.
Version 0.66.1
Review fixes to 0.66.0, before release. The headline one is an embargo leak that 0.66.0 introduced.
Fixed
- Approving a submission in the merged queue now creates its deposit. 0.66.0
moved the primary acceptance path from the admin submissions table to the
shared review queue, but left deposit creation behind on the old path. A
contributor who asked for an embargo could have their records approved with
no
Depositat all, landingvisibility='public'— which passes the very filtersnapshots.pyuses to choose rows for the Figshare tarball. There was no recovery path either: a later admin accept saw the ingestion alreadyapprovedand returned early. Both acceptance routes now run one function,finish_submission_acceptance, so they cannot drift apart again. A deposit that cannot be created refuses the approval with a 422 rather than publishing in the clear. - Re-accepting an already-accepted submission mints nothing. Acceptance is a
transition, not a state. A legacy row with no
ingestion_id(correctly outside the backfill's pending-only scope) could be re-accepted, building a fresh ingestion and a secondDeposit— burning a PB#### accession that is meant to be permanent. - The backfill reports malformed rows instead of dying on them.
data_jsonis an unvalidated JSON column, so a row can hold a list or a numericpaper_doi. Those raiseAttributeError/TypeError, notValidationError, and aborted the run before the report printed — hiding every other row's status. Every per-row failure is now caught and named. - The migration no longer adds a duplicate foreign key. The guard matched the
constraint by name, but
create_alllets PostgreSQL name itdata_submissions_ingestion_id_fkey, so dev and test databases got a second FK on the same column. It now matches on the column. ?page=0,?page=-1and?page=abcclamp to page 1 on the review queue instead of returning a 422. It is a navigation parameter that lands in URLs people edit and bookmark.- **The admin submissions modal no longer shows an empty "Structured Data" table** for post-0.66 submissions, whose envelope holds deposit intent only. It renders the deposit request instead, and the section disappears when there is nothing in it.
Changed
- Code comments cite
DECISIONS.mdsections rather than internal task ids (T41,D24), which were unresolvable to anyone reading the repository.
Known limitation
DECISIONS.md §8 now documents, with the exact leak path, that an embargoed submission whose actor already existed and was already public produces a new interaction that inherits public visibility and therefore still ships in a release snapshot. The entity is deliberately not retracted; closing the remaining gap needs visibility on the evidence spine itself. This is strictly better than 0.65.x, where the embargo had no effect at all — but it is not the embargo a depositor would assume.
Version 0.66.0
One review queue. An "Add data" submission and an AI extraction now land in the same place, in front of the same reviewer, and the two live bugs that made the old split dangerous are closed.
Operator actions are required for this release — see "Deployment" below.
Added
- An "Add data" submission creates its review record at submit time.
DataSubmissionkeeps its job as the envelope — submitter contact, admin notes, deposit intent, contribution ledger target — and gainsingestion_id, pointing at aPaperIngestionbadgedsource='submission'that carries the evidence. Previously an ingestion was manufactured at accept time purely as an adapter to reach the shared promotion pipeline, so nothing a contributor sent was ever reviewed the way an extraction is: a curator could read it in a modal, but not correct it before it went live. - The queue index paginates, badges and filters by origin. It used to stop at a hard 100 rows with nothing to say about the 101st — a cap that bites much sooner now that one queue carries both origins. Rows show whether they came from the AI pipeline or from a person, and the queue can be filtered to either.
scripts/backfill_submission_ingestions.pylinks submissions that are stillpendingand predate the change. Rows whose stored evidence no longer validates are reported with the reason and left untouched, never dropped.
Fixed
- An embargoed submission actually embargoes its records. The deposit helper
set
visibilityanddeposit_idonly inside anif not entity.accessionbranch — but promotion allocates an accession to everything it creates, so the branch never ran, and every submission that requested an embargo published its records aspublic. The embargo was read at acceptance and then had no effect at all. Deposit visibility is what excludes rows from a release snapshot, and a release snapshot is published to Figshare under a DOI that cannot be unpublished, so this is guarded in the pg tier, not the mock tier. - **A deposit now covers the rows promotion reused, not only the ones it created.** A submission whose organism or protein already existed produced a deposit those rows were never attached to. Reused rows are attached and take the deposit's visibility — with one deliberate exception: a row that is already public is not retracted, because an embargo can protect unpublished data but cannot un-publish published data. That case is logged.
Deposit.paper_idis set, so a deposit can be traced to the paper it documents.- Accepting a submission no longer succeeds when the promotion failed. The
accept path ran promotion inside a savepoint, rolled it back on any error,
logged a warning and still reported the submission as
accepted— the contributor was told their data was published while none of it existed, and a failed deposit published in the clear the very records someone had asked to keep private. Failures now return a 422 that names the field or the organism, and the submission stays pending. - The structure-prediction guard asks the question it means. It tested "does
this row have a linked protein", but
matched_protein_idis set at submit time for any sequence already in PlasticDB, so a replayed task could overwritestructure_fileon a live, public protein for a submission nobody had approved. It now tests whether a human accepted the submission.
Changed
- Publishing an outside contributor's submission stays a superuser decision.
Approving an AI extraction is curator-or-superuser, as before. Approving or
rejecting a
source='submission'row requires a superuser — the same right that accepting aDataSubmissionhas always required. Merging the queues must not widen that by accident. Curators still open, read and correct submissions; only the irreversible step is reserved, and the page says so rather than offering a button that can only 403. - **
ExtractedReference.doiandExtractedData.extraction_confidenceare optional.** A submitted record may describe a preprint, thesis or poster with no DOI, and a record a person typed has no model behind it to assess its own confidence.Noneis notlowand is rendered as a dash, not as a bad extraction. A DOI-less submission is filed undersubmission:{id}soPaperIngestion.doistaysNOT NULL; that placeholder never becomes aPaper.doiand is never linked to doi.org. IngestionCreateacceptssource. An extraction must still name the PDF or markdown it came from; a human submission has no such artefact, so the requirement no longer applies to it. Declaringsubmissiononly narrows what the caller can do with the row.- **
DataSubmission.data_jsonfor a new "Add data" row holds deposit intent only.** The evidence lives on the linked ingestion, so there is one copy of it and one form that edits it. The admin submissions page links into the shared queue instead of rendering a third, read-only copy of the payload.
Deployment
data_submissions is a populated table, so the new column ships as a migration, not through create_all. In the API container, after the release is live:
```bash python -m app.db.init_schema # new tables (no-op here) alembic upgrade head # adds data_submissions.ingestion_id
# Report which pending submissions can be linked, then do it. python -m scripts.backfill_submission_ingestions python -m scripts.backfill_submission_ingestions --apply ```
The backfill touches only rows that are pending with submission_type='Add data' and no link. Accepted rows already have an ingestion; rejected rows have nothing to review. It exits non-zero if any row could not be linked, and prints why for each.
Version 0.65.1
Review follow-ups to 0.65.0.
Fixed
- A malformed
extraction_notesno longer 500s the review page. The notes formatter assumed the string the schema promises, but the blob reaches it straight out ofPaperIngestion.extracted_data, which is unvalidated JSON — an extractor emitting a list or a dict took down the primary curation page. It is now total: anything non-string is stringified and rendered plainly. This is the page a curator opens to fix a bad extraction, so failing on one hides the record they came for. - An upload response that clears the supplements now empties the list. The
re-render was guarded on
data.supplements.length, so a response removing every supplement left the old rows on screen, each linking to a file the server no longer had, until someone happened to reload. Any explicit array now re-renders; an absent key still leaves the list alone, since absent is not the same as empty.
Changed
QA_CHECKLIST.mdnow describes the split review surface: the queue index at/dashboard/ingestions/reviewand the per-paper page at/dashboard/ingestions/review/{id}.
Version 0.65.0
Six fixes to the ingestion review page — the surface a curator spends their whole session in, and the one place in the app where a confusing error message costs a paper rather than a click.
Added
- A per-paper review page:
/dashboard/ingestions/review/{id}. The queue used to render every pending paper into one document, each with its own 80vh PDF iframe, so there was no URL for a paper — nothing to bookmark, hand to a colleague, or come back to — and the browser laid out the entire queue to show one item./dashboard/ingestions/reviewis now an index: one row per paper with its confidence badge, what source material is attached, and a link into its own page. The detail page renders any status, so a link shared yesterday still resolves after someone approved the paper instead of 404ing. - The form gets the full page width. The embedded PDF pane is gone; the PDF and markdown open in their own window from a button. Reviewing happens with the paper on a second monitor, and half a screen spent on a cramped viewer is half a screen the extracted data does not get.
app/static/js/ingestion_review.js. The page's JavaScript moved out of the Jinja template into a file, because everything below is client-side and the Python tiers cannot see any of it.tests/e2e/ingestion_review.spec.jsinjects the same file the page loads, so the shipped code is what is tested.
Fixed
- Approve and reject errors no longer print
[object Object]. Pydantic answers a 422 with an array of error objects and the page string-concatenated it, which told the curator nothing about which of ~99 fields was wrong. The message now names the path and the constraint (interactions.0.experiments.2.method: Input should be …), using the same flattener the submit form has had all along. - An expired session is now distinguishable from bad data. The page renders off the httpOnly session cookie while approve sends the bearer token from localStorage, so the two halves can disagree about whether you are signed in — and "could not validate credentials" printed next to a JSON box reads as "your edit is wrong". A 401 now says the session expired and to reload; a 403 says the account lacks curator access and that the data is not the problem.
- The raw JSON panel is readable again without dropping escaping. Jinja's
tojsonescapes<,>,&and'to\uXXXX— correct inside a<script>, unreadable inside a textarea, where'heterologous'became'heterologous'. Simply removingtojsonwas not the fix: a</textarea>in extractor content would close the element and the rest of the blob would be parsed as markup. The payload now ships inside a<script type="application/json">and JS assigns it to.value, which is text and cannot become an element whatever the extractor wrote. extraction_notesrender as headed sections. The extractor writes ~600 words with inlineACTORS./SEQUENCES./PLASTICS / VOCABULARY/COMPOSITIONmarkers that got no formatting at all, so reviewers re-read the same sentence looking for where one topic ended. They now split into headed blocks matching the reviewer-guidance panel, the one panel of the old page that people actually read. The splitter is deliberately conservative:PET.,PLA.andNCBI:1547922stay in the prose.- Rejection reasons are typed into an inline form, not
window.prompt().IngestionReject.reasonismin_length=1, so a whitespace-only reason 422'd — by which time the prompt had closed and taken the typed text with it. The form validates before the request and the words stay on screen.
Version 0.64.2
Three hardening changes to the CI gate added in 0.64.0, each replacing a convention with a check that fails when the convention is broken.
Changed
- Both jobs now have a timeout (
e2e15 minutes,build20; they run in about one minute each). GitHub's default is 360 minutes, so a wedged Chromium or an orphaned runner would have burned six hours of the project's own self-hosted compute before anyone looked. - Playwright retries dropped to 0 for the blocking specs. They touch no
network and load a vendored Cytoscape, so they are deterministic by
construction: a test that passes on retry is a race or an ordering assumption,
and retrying it converts a real defect into a green check. Retries are worth
having against infrastructure you do not control, which here is only the
CDN-dependent bucket —
npm run test:e2e:networkkeeps--retries 1, and that step is advisory anyway. - Playwright version drift is now impossible to introduce silently. The specs
run inside the
mcr.microsoft.com/playwright:vX.Y.Z-nobleimage, which ships the Chromium build that version expects; bumping the lockfile without the image tag would surface as "Executable doesn't exist", which reads like a runner fault rather than a mistake in the commit.package.jsonnow pins the version exactly (no caret), and.github/check-playwright-version.shruns first in the job and fails it unlesspackage.json,package-lock.jsonand the DockerfileFROMtag all agree. Pinning alone would only have made the drift less likely; the assertion is what makes it loud.
Version 0.64.1
Review follow-ups to 0.64.0.
Fixed
- A freshly built database is now stamped at the migration head.
create_allbuilds every table in its current shape, so a brand-new database already contains the outcome of every migration ever written — but it carried noalembic_versionrow to say so. The first real migration would therefore have tried toALTERa columncreate_allhad just created, failing on every developer machine and in the PostgreSQL test fixture while succeeding in production: the worst possible distribution of a failure, and one 0.64.0 only documented its way around.create_schema()now stampsheadwhen — and only when — it built the schema from an empty database. A database that already had tables is left alone, because that is an existing deployment and stamping itheadwould silently mark genuinely pending migrations as applied; those are still baselined once, explicitly, withalembic stamp 0001_baseline. The stamp is best-effort, so an environment without alembic still gets a schema and a printed note rather than an exception. - **Import ordering in
app/db/migrations/env.pyandtests/test_pg_migrations.py.** Both tripped ruff'sI001on a clean checkout.make lintis not in CI, so nothing caught it.
Version 0.64.0
Two pieces of infrastructure, neither of which changes how the application behaves: the browser test tier now blocks a pull request, and the project has a way to change a table that already holds data.
Added
- The Playwright tier joined the CI gate. Seven spec files lived in
tests/e2e/and nothing ran them, so any test placed there could rot silently and the work that depends on browser coverage had no gate to lean on. Pull requests now run ane2ejob alongside the image build, inside the official Playwright docker image — the self-hosted runner has neither Node nor Chromium's system libraries, and installing them per-run would need root, whiledockeris the one thing the runner is known to have. The specs that need no network are blocking: a failing spec fails the pull request.chart_export.spec.jsloads Chart.js and svgcanvas from jsdelivr — exactly as the statistics page does — so it is tagged@networkand runs as a separate advisory step. Making every pull request hostage to a CDN's uptime buys nothing, and the result stays in the job log, so the spec still cannot rot unnoticed. New scripts:npm run test:e2e:offlineandnpm run test:e2e:network, plusmake test-e2e. Two repo-hygiene consequences:package-lock.jsonis now tracked (npm cirequires it, and it is what pins the Playwright version the CI image tag has to match), and.dockerignorere-includestests/e2e/so the build context carries the specs. The application image is unaffected — its Dockerfile never copiestests/. - Alembic, scoped to the one thing
create_allcannot do.create_schema()isBase.metadata.create_allwithcheckfirst=True: it creates missing tables and neverALTERs an existing one. Adding a column to a populated table therefore had no path to production at all. The split is now explicit — a new table still ships through the ORM models pluspython -m app.db.init_schema; a change to a live table ships as a migration — and a mock-tier test enforces the corollary that no migration may create a table the models also declare, since the two would race. Nothing about the existing flow moved:python -m app.db.init_schema,scripts/seed.pyand the PostgreSQL test fixture are untouched. Every existing database, production included, was built bycreate_alland carries no version marker, so it is baselined once withalembic stamp 0001_baseline— a revision that deliberately emits no DDL.make migrate,make migrate-status,make migrate-downandmake migrate-baselinewrap the common commands; the runbook is in the README and in the baseline revision's docstring.
Version 0.63.2
Review follow-ups to 0.63.1, all in the evidence dedup that release introduced.
Fixed
- Re-approving could attach one experiment's measurements to another. The
candidate rows the content match reads were selected without an
ORDER BY, so their order was whatever PostgreSQL returned — which changes once a row has been updated and relocated in the heap. Two experiments alike in every stored condition and distinguishable only by their observations would then swap which stored row each entry of the payload claimed, each would find its own reading missing, and re-promotion would insert it against the wrong experiment. That is worse than the duplication 0.63.1 fixed, because duplicated rows at least stayed correctly attributed. All the dedup feeds now read in primary-key order. - An experiment with no temperature unit duplicated on every re-approve.
Experiment.temperature_unithasserver_default='C', and SQLAlchemy omits an explicitly-null column that has one — so a payload sending `"temperature_unit": nullis stored as'C', and comparing the payload'snull` against the stored'C'never matched. The extraction contract tells the model to leave absent fields null, so this was the ordinary shape, not an edge case: 0.63.1's fix quietly did not apply to it. Content comparison now uses the value the database will actually hold, and the defaults are read from the mapped columns rather than from a hand-kept list, so adding one to another column cannot reintroduce this. - A re-approve discarded corrected kinetics and enzyme properties. Those rows are keyed on the experiment, so once 0.63.1 started matching and reusing an experiment, the stored values were kept and the payload's dropped. Applying a curator's correction is the whole reason to re-approve, so losing it silently was worse than the duplicate it replaced. A matched experiment's kinetics and properties are now updated to the payload's values. The rows are scoped to this paper's own experiment, so the update cannot reach another study's contribution.
Version 0.63.1
Fixed
- Approving a paper for a second time duplicated all of its evidence. The
promotion module's own docstring said "Idempotent: re-promoting the same data
does not duplicate rows", and for papers, organisms, proteins and interactions
that was true. It was not true one level down: the loop that writes
ExperimentandObservationrows sat outside the branch that dedups the interaction, so every merge-mode approve — the default, and the normal path whenever a re-extraction of an already-approved paper is accepted — re-added the whole evidence tree beneath the interaction it had just reused. The existing idempotency test never counted experiments, which is why nothing caught it. Experiments, observations and substrate compositions have no natural key, so they are now matched on their full content within their parent before anything is inserted, and each entry may claim at most one stored row. Re-promoting a payload therefore changes nothing, while an extraction that genuinely reports the same experiment twice still gets two rows. Kinetics and properties, which key off the experiment, stop duplicating as a consequence. - Two curators approving the same paper at the same moment both promoted it.
Approve read the row, checked
status == pending_reviewin Python, and then wrote — so two simultaneous clicks both saw a pending paper, both wrote the evidence, and inreplacemode both ran the destructive delete of the prior run's rows. The write phase now opens with a compare-and-set (`UPDATE … WHERE id = ? AND status = 'pending_review'`) whose row count decides the race: exactly one caller proceeds, and the loser is turned away with a 409 that says, in prose, that someone else's version was saved and theirs was not. - A submitted sequence with a trailing newline silently never got a structure.
ProteinSubmission.sequencewas trimmed but the copy inside the submission'sdata_jsonwas not, so after acceptance the two spellings no longer matched, the fold was never queued, and the only trace was an INFO log. Amino-acid sequences are now normalised once at the schema boundary, so both copies agree. - A predicted structure could be attached to the wrong protein. After
accepting a submission, the promoted row was located by an unordered
SELECT … WHERE sequence = ? LIMIT 1that was not scoped to the promotion, so an unrelated protein already holding the same sequence could win and receive a structure it never asked for. The candidate set is now the ids the promotion actually created. - A re-extraction could be filed under the wrong model. A
PATCHdelivering a new payload without naming its model or version labelled the recorded extraction attempt with whatever the row already held — the previous run's label — quietly attributing one model's answer to another, which is precisely the measurement the attempts table exists to support. The attempt is now labelled with what that request declared, and left unlabelled when it declared nothing.
Changed
promote()is split intoprepare(data)— every external lookup (NCBI taxonomy, UniProt/GenBank sequences), holding no database session — andapply(db, …, prepared), which is database-only. Two HTTP call sites used to sit on either side of the writes, one of them between them; a transaction can no longer hold a row lock open across a remote server's latency, which is what makes the first-position concurrency guard affordable.promote()remains as the wrapper for callers with no race to guard against.
Version 0.63.0
Added
- The AI's original extraction now survives the curator who edits it.
PaperIngestion.extracted_datais a single slot, and everything downstream overwrote it: a re-extraction replaced the first run's answer, and approving replaced it again with the curator's corrected version. The extractor's own output was destroyed by the act of reviewing it, so extractor accuracy was permanently unmeasurable — and there was no way to show a reviewer what they had changed. A new append-onlyextraction_attemptstable records every extraction and re-extraction as its own row — `(ingestion_id, extraction_model, extraction_version, payload, created_at)` — written when a paper is created carrying extracted data and again on everyPATCHthat delivers a new extraction. Rows are never updated or deleted; they go only when their ingestion does. Approving writes nothing here: the curator's payload stays on the ingestion, which makes the diff of record "approved vs the latest attempt". Deployment: this is a new table, soBase.metadata.create_allpicks it up (checkfirst=True) — re-runpython -m app.db.init_schemaagainst the database and no existing table is touched.
Version 0.62.3
Security
- **Any account holder could publish a public protein, with a stored 3D structure,
before a human looked at it.** Posting a submission fired
match_protein_submission.delay()immediately; on a sequence PlasticDB did not already hold,match_proteinallocated aplasticdb_idand called_queue_alphafold, which dispatched the folding task from inside the worker's session — before it committed. That task's_store_protein()then minted aPB####accession, wrote a PDB into the structure store and inserted aProteinrow, whosevisibilitydefaults to"public"in both the ORM and theserver_default. No curator was in that chain anywhere, and the submitter's embargo request is not read until acceptance — long afterwards. Matching is now read-only with respect to the public record. It still runs at submit time and still tells the submitter whether their sequence duplicates an existing entry, but a novel one only setsstatus='new': no identifier is allocated (next_plasticdb_idisnextvalon PostgreSQL, so the old behaviour also burnt a sequence value for every submission that was later rejected), noProteinis created and nothing reaches the structure store._queue_alphafoldis gone. Accession minting,Proteincreation and structure storage all move behind acceptance. When a curator accepts, promotion creates the entities as it always has;collect_pending_structure_jobs()then links each novelProteinSubmissionto the row that was actually created and hands back the fold jobs, which are dispatched after the commit — the worker is a separate process and cannot see a flushed-but-uncommitted row._store_proteinis replaced by_store_structure, which folds into the linkedProteinand raisesSubmissionNotAcceptedrather than creating one, so a stale queue entry or a manual replay cannot reopen the path. Audited against the live V2 database: **noProteinrow was ever created this way** — all 8 rows carrylegacy_id = plasticdb_idand evidence on the spine, the promotion signature. Threeprotein_submissionswere caught mid-bypass, holding pre-minted ids00009–00011for a submission stillpending; those sequence values stay burnt, which is cosmetic.
Version 0.62.2
Fixed
- **Every source-file link in the review dashboard answered
Not authenticatedto the curator looking at it.**ingestions_review.htmlembeds the paper as<iframe src="/api/v1/ingestions/{id}/pdf">and links straight at/markdownand/supplements/{name}. Those are browser navigations: they carry cookies and nothing else. The JWT lives in localStorage, so an<a>click and an<iframe>load cannot sendAuthorization, and the page'sgetAuthHeaders()— which is why the dashboard'sfetch()calls work — never runs for them. The three proxies were guarded byCuratorUser, which reads only theX-API-Key/bearer header, so the PDF viewer rendered a JSON error blob and "View markdown" opened{"detail":"Not authenticated"}. The page auth (_page_user) had honoured the session cookie all along; the API had not, so the UI linked at routes its own visitors could not reach. The three read-only proxies now resolve the visitor throughdeps.require_curator_read: header first, unchanged, then the httpOnlyplasticdb_sessioncookie as a fallback — and only onGET/HEAD. Everything else on the ingestion surface (approve,reject,PATCH,upload-pdf,upload-markdown,files, supplementDELETE) keeps the header-onlyCuratorUserguard. Why that is not a CSRF hole. The cookie isSameSite=lax, so it is never attached to a cross-site form POST; Lax does attach it to a top-level cross-site GET, and the safe-method restriction is what makes that harmless. The most a hostile page can provoke is the curator's own browser streaming a file that curator is already entitled to read, with the response opaque to the attacker. No state changes, so there is nothing to forge._COOKIE_SAFE_METHODSenforces the boundary in the dependency rather than by convention, andtests/test_ingestion_file_cookie_auth.pyasserts both halves — that the cookie works on the three reads, and that it authenticates none of the seven mutating routes. Cookie resolution moved intodeps.get_session_cookie_user, now shared withweb.router._page_user, so pages and file proxies cannot drift on what a browser session means. Non-curators still get 403 and deactivated accounts still get 401 through the cookie path, exactly as through the header path.
Version 0.62.1
Fixed
- **The production paper object store was ephemeral — every uploaded PDF, markdown
and supplement was destroyed by each rollout.**
PAPER_STOREwas never set in the prod ConfigMap, so it fell back to itslocaldefault at/app/papers. That path is not one of the two subPaths of theplasticdb-sharedPVC mounted into the pods (only/app/job_filesand/app/bioinformaticsare), so uploads went to the pod's ephemeral container layer. Withapi.replicas: 2it was worse than ephemeral: only the replica that happened to receive an upload could serve it back. The database never noticed. Ingestion 5 kept itsmarkdown_keyandsupplement_keys,has_markdownreportedtrue, and the review dashboard listedData_Sheet_1.PDF— whileGET /ingestions/5/markdownandGET /ingestions/5/supplements/Data_Sheet_1.PDFboth returned 502Object not found. A row that points at objects nobody kept is the failure mode to watch for whenever a*_STOREis left on its default. Fixed in the gitops chart (PAPER_STORE=s3, bucketplasticdb-papers, on the same sharedS3_*credentials as snapshots and structures), which is where the ConfigMap is defined; no application code changed. That chart also gained achecksum/configpod annotation, becauseenvFromis read once at container start — without it a ConfigMap-only commit syncs green in ArgoCD while every running pod keeps the old environment, which would have hidden this very fix. Documented here as the constraint it is:paper_storeonlocalis a production defect, not a configuration preference. SeeDECISIONS.md§12.
Version 0.62.0
Added
- A curator can take a supplement back off an ingestion. Uploads deduped by
filename but were otherwise append-only, and
IngestionPatchcarries nosupplement_keysfield, so nothing in the product could remove one. Production ingestion 5 is the case in point: four identical ~237 kB HTML landing pages (www.frontiersin.org#s6,#s10,#s11,#supplementary-material) that a fetcher saved as "supplements", permanently attached to a paper under review and wasting a reviewer's attention on every visit.DELETE /api/v1/ingestions/{id}/supplements/{filename}drops the key from the row and deletes the stored object, with a matching remove control on each entry in the review dashboard's Source materials panel. The filename is untrusted input on a path-shaped code path, so it is validated rather than normalised: separators,./.., drive letters and NULs are refused with a 400 instead of being quietly rewritten. It is also never percent-decoded a second time — ASGI decodes path params exactly once, and a second pass would turn%252e%252einto..while corrupting legitimate names containing%. The structural defence is that no object key is ever built from the filename: the name only selects an entry already stored on the row, and the key deleted is the one the row holds, so even a bypassed guard reaches nothing this ingestion does not already own. The same guard now covers theGETproxy so the read and write paths cannot drift apart. Deletion is refused on an approved ingestion (409) — the same line/reextractdraws. Once an ingestion is promoted its source material is the provenance behind live records, not scratch space.supplement_keysis a plainJSONcolumn with no mutation tracking, so the list is reassigned rather than mutated in place; a PostgreSQL test expires the identity map and re-reads to prove the removal actually reached the database.
Version 0.61.0
Added
- Engineered variants get their own structures.
enrich_all_proteins_synciteratedProteinonly —grep ProteinVariant app/workers/enrichment.pyreturned nothing — so no enrichment source had ever visited a variant. In production that was 7 of 14 proteins with no structure at all, and they are the records the field actually cares about: FAST-PETase, DuraPETase, ThermoPETase, the LCC and ICCM mutants. All seven have full sequences (290–304 aa, comfortably under the fold cap). Variants now run their own, deliberately shorter ladder: **PDB sequence search → ESMFold → SignalP**. PDB runs first and it is not optional — FAST-PETase really is deposited (7SH6), and folding first would seat a prediction as terminalokover a measured structure, which is the #320 failure one table across. #319's identity-1.0 gate makes the search safe here: an exact match to the mutant sequence is a correct attribution, not the fragment mis-attribution that gate was raised to stop. UniProt, the EBI AlphaFold DB, InterPro and CAZy are deliberately not run. A variant has no accession columns at all, so the first two have nothing to key on and would sitpendingforever; the latter two describe families and domains that a point mutation does not change, so they are read fromparent_proteinrather than copied where they could drift. SignalP is the exception and does get its own columns — atruncatedorfusionconstruct can genuinely gain or lose a secretion signal. Also surfaces the result:VariantDetailnow carriesstructures,primary_structure,structure_fileand the signal-peptide fields, there is a newGET /api/v1/variants/{id}/structure, and the variant page renders a 3Dmol viewer with the same experimental/predicted badge the protein page uses. The card states that the coordinates are the variant's own, never the parent's.
Fixed
ProteinStructure.protein_variant_idwas unusable, not merely unused. The column has existed since #320 andreflag_primary_structureswas already scoped for it, butprotein_idwasNOT NULL— so a variant-owned structure row could never be inserted at all. Both owner columns are now nullable, with exactly one set.
Migration
- **
scripts/migrate_variant_enrichment.pymust be run against every existing database.** This project has no migration system:alembic/is empty, there is noalembic.ini, andcreate_schemaruns only from the seeder and the test fixtures, never at startup.create_alladds missing tables, never missing columns. Since SQLAlchemy emits every mapped column in its SELECT list, deploying this release without the script makes the first query againstprotein_variantsfail withUndefinedColumnand takes the variants page, the proteins list and the enrichment worker with it. Every statement is idempotent, so the script is safe to re-run and safe to run before or after the rollout. Verified against a real PostgreSQL 16: applied to a rewound pre-#354 schema, confirmed no-op on re-run, and the resulting schema checked column-by-column against the ORM.
Version 0.60.1
Fixed
- ESMFold never ran for the proteins it exists to serve. A forced
enrich-allproduced zero predicted structures, silently:_step_alphafoldand_step_esmfolddeadlocked against each other. For a protein with no UniProt accession, AlphaFold returnspending / awaiting_uniprot(there is nothing to key the EBI API on), and ESMFold declined while AlphaFold waspending, recordingpending / awaiting_alphafold: pending. Neither status is terminal, so both re-ran every pass and reached the same conclusion indefinitely. The guard came from #320 and its intent is right — don't pay for a fold when a better EBI model might land next pass — but it treated allpendingalike.awaiting_uniprotis not "not yet known", it is "cannot run at all": no unattended pass produces a UniProt accession, that takes curation or BLAST (off by default). So the accession-less proteins, which get nothing from UniProt, AlphaFold, or the PDB sequence search since #319 raised its identity gate to 1.0, were exactly the ones never folded. ESMFold now waits only when AlphaFold could still deliver — atransientfailure, or apendingfor any reason other than a missing or unusable accession. #320's guard is intact and covered by its own tests. (#353) Note this yields two structures in production, not eleven: two more of the structure-less proteins have no sequence at all, and the remaining seven areProteinVariantrows, which no enrichment source visits (#354).
Version 0.60.0
Added
- ESMFold now works without an API key. The last rung of the structure ladder
has been dormant since it shipped: it required
ESMFOLD_API_KEY, nobody ever set one, and every protein it was meant to serve recordedpending / no_api_keyforever. It now falls back to the keyless public endpoint documented in the esm README (api.esmatlas.com/foldSequence/v1/pdb/), so a default install predicts structures out of the box; setting a Forge key still upgrades to the paid API. This matters more than a dormant fallback sounds. Production has 14 proteins and 11 of them have no structure at all — they are engineered variants and unaccessioned cutinases, so UniProt has nothing to look up, AlphaFold has no accession to key on, and the PDB sequence search rejects them because #319 (correctly) raised its identity gate to 1.0. De-novo folding is the only rung that can serve them, and it was switched off. The public endpoint has limits the paid one does not: a hard 400-residue cap, and an alphabet excludingU,Oand*. Both are checked before the request and mapped from the service's own 413/422 responses, and both settle the source aspermanentrather than burning five retries over a month on a sequence that can never fit. Every sequence currently in PlasticDB is 218–339 residues, so the cap does not bite today. Configurable viaESMFOLD_PUBLIC_API_URLandESMFOLD_PUBLIC_MAX_RESIDUES; blank the URL to switch the fallback off.
Fixed
- The ESMFold test mocks no longer confirm themselves. They asserted an invented
JSON body against
api.example.com— the same defect class as #347, where a response shape nobody had checked meant every AlphaFold pLDDT was silently NULL. The keyless path is now pinned to bytes captured from the live service, including its real error wording. The Forge path is still unverifiable without a key and now says so in the docstring instead of implying otherwise.
Version 0.58.0
Changed
- Microcopy corrections from Gavin's review. BLAST options on annotate-gene are now
a list rather than a run-on paragraph; the bulk-downloads card reads "FASTA, CSV or
JSON files"; the REST-API
HEADpassage leads with what it means for a reader and keeps the RFC detail as an aside; the Pathway Analysis note reads "Currently analyses the PET degradation pathway only". (#291) The proposed rewrite also restated that **SignalP 5.0 runs based on the selected organism type**. It does not — no worker readsorganism_type, and SignalP exists only as a curation-time enrichment step that is disabled in production. The claim has been removed rather than made more prominent; the inert control is #342. - Ideonella sakaiensis → Pseudideonella sakaiensis across the UI and seed data (NCBI tax 1547922). Reference citations keep the name they were published under, and the worked example now says "published as Ideonella sakaiensis, since reclassified" so contributors can see why the two differ. (#293) Note the spelling: Pseud·ideonella, not "Pseudoideonella" — elision before a vowel, as in Pseudalteromonas. The search example on the landing page returned 0 hits before this change and returns hits now; the misspelling would also have returned 0.
- Submission requirements are stated before the form. Submit Data now says what is accepted — any published DOI, journal article or preprint — and that a review queue reconciles duplicates. Documentation that said "peer-reviewed literature" has been aligned to "published literature with a DOI"; the stronger claim, that nothing here is computationally predicted, is unchanged. (#294)
valuefor a weight-loss measurement is the weight lost. The vocabulary offers bothweight_lossandmass_remaining, so entering one under the other is a silent data error that nothing downstream can detect. Spelled out in the field table and called out under the measurement-type vocabulary. (#295)
Fixed
- Engineered variants have a defined order.
Protein.variantscarried noorder_by, so the comparison table showed whatever order the database returned — stable enough to look deliberate, and free to change under any query-plan shift. Variants are now ordered by publication year then name, undated ones last, applied in the API so every consumer gets it. The table caption says so. (#296)
Version 0.57.0
Added
- The DIAMOND reference databases rebuild themselves. A Celery Beat task runs
hourly and rebuilds them when the public record set has changed, comparing a digest
of the rows the export would emit against a fingerprint stored beside the databases.
Unchanged data is a no-op. Builds are atomic — written under a temporary name and
moved into place — so a running search never reads a half-written file, and the
fingerprint is written last so a crash mid-rebuild leaves the work to be redone
rather than recorded as done. Nothing rebuilt them before, which is how a PlasticDB
v1 export ended up serving a V2 site. (#330)
Hashing rather than counting:
Proteinhas noupdated_at, and a corrected sequence leaves the counts untouched, so a cheaper fingerprint would never notice an edit. - Engineered variants are opt-in on searches. A plain BLAST now answers "what
natural degraders is this like?" — engineered mutants, fusions, truncations and
synthetic sequences are excluded unless the new Include engineered variants
checkbox is ticked (
include_variantson the API). The job records which database it searched, and the status page shows it. Implemented as two databases (PlasticDB.dmnd,PlasticDB_all.dmnd) rather than a post-search filter: DIAMOND reports a bounded number of targets per query and mutants sit at 96–99% identity to their parent, so a combined database would spend that budget on a protein's own mutants and truncate away the wild-type hits — which no amount of filtering afterwards can recover. Submission dedup still searches everything; a submission duplicating a variant is still a duplicate. Existing databases need the additive column:ALTER TABLE jobs ADD COLUMN include_variants BOOLEAN NOT NULL DEFAULT FALSE;
Version 0.56.0
Fixed
- **Job results resolve against the current database instead of a PlasticDB v1
snapshot.** The deployed
PlasticDB.dmndwas built from v1 data (329 sequences,00001|PHB_depolymerase|…headers), so 1509 of 1523 stored hits — 99% — carried noprotein_idand the results table linked nowhere.store_diamond_resultsonly understood those v1 headers, while the reference FASTA has since moved to typedprotein:<pk>/variant:<pk>ids, so regenerating the database without this change would have left every hit unresolved rather than fixing it. Both readers now share one parser (app/services/reference_ids.py); v1 headers still resolve. (#330) - Submission dedup no longer mints an accession for a protein we already hold.
match_proteinsearches the same reference database to decide whether a submitted sequence is a duplicate, and read subject ids the same v1-only way — so against a regenerated database every duplicate would have looked new and been assigned a fresh, permanent accession. A top hit on an engineered variant is credited to its parent protein here (unlike a job hit, which stays with the variant): a curator reviews a "matched" submission, but a wrongly-minted accession cannot be taken back. (#330)
Added
- Hits on engineered variants are labelled and linked as variants. Half the
reference FASTA is
protein_variants, butJobResultcould only point at a protein, so a variant hit had nothing to show but its raw subject id.JobResultnow carriesvariant_idand the results table links to/variants/{id}with the variant's name and type. A variant hit is not also credited to its parent protein. (#330) Existing databases need the additive column:ALTER TABLE job_results ADD COLUMN variant_id INTEGER REFERENCES protein_variants(id); - README: how to rebuild the reference database after an import. It is not rebuilt automatically, and every import leaves it stale — which is how it drifted a whole major version behind. (#330)
Version 0.55.4
Changed
- Corrected the 0.55.3 notes. The claim that Compare Genomes "skipped" compressed
genomes was wrong — verified by walking the signed-in flow on the deployed build.
The old stored name
ideonella.fasta.fastastill matchedinputs/*.fasta, so they were processed all along, only mislabelled. Widening the glob is a consequence of storing them as.fasta.gz, not a fix for a pre-existing skip. (#324)
Version 0.55.3
Fixed
- Gzipped uploads now work on every tool that advertises them.
.gzis in theacceptlist on all five upload controls, but the stored filename came fromPath(filename).stem, which strips only the last suffix — soideonella.fasta.gzwas stored asideonella.fasta.fasta. Uploads are now named after their contents (the gzip magic number, not the filename, which lies in both directions) and keep a.gzsuffix. The practical impact was narrower and different from what the issue described. Checked against the pinned binaries: DIAMOND 2.0.6 detects gzip from the magic number and ignores the name, soannotate-gene,annotate-genome,compare-genomesandpathway-analysisread the misnamed file correctly all along. HMMER 3.4 decides from the suffix alone, sohmm_screenwas the one that genuinely failed — exit 1, "Sequence file is empty or misformatted". That is now exit 0 with hits. - Compare Genomes' input glob widened to match the new
.gznames. This one is a consequence of the rename above, not a pre-existing bug: the old stored nameideonella.fasta.fastastill matchedinputs/*.fasta, so compressed genomes were processed all along — only mislabelled. Storing them asideonella.fasta.gzwould have made the old glob miss them, so it now matches both. - Results are labelled
ideonella, notideonella.fasta, for a compressed upload. Only a recognised inner extension is stripped, soE.coli_K12.gzkeeps its dot rather than collapsing toE. annotate-listreads gzipped tables, which previously surfaced as aUnicodeDecodeErrorsince it parses its input in Python rather than handing it to a search engine.app/static/js/genome_staging.jsmirrors the new stem rules so the staging UI still predicts collisions correctly; the parity cases are asserted in bothtests/test_compare_genomes_upload.pyandtests/e2e/genome_staging.spec.js. (#324)
Version 0.55.2
Changed
- User-facing copy is now en-GB/en-NZ. Fourteen en-US spellings across twelve
templates — "analyze", "visualizations", "organizes", "utilize", "catalyzed",
"characterized", "normalized", "depolymerization", "characterization",
"toward", and "Not authorized" — are now "analyse", "visualisations" and so on.
Prose only: code identifiers, API field names, controlled-vocabulary values
(
depolymerization_rate,immobilized), and dependency names are unchanged.tests/test_engb_spelling.pypins the result so the next page written does not quietly reintroduce en-US spelling; it strips<script>,<style>, and<code>before scanning, which is where this codebase keeps the terms that must stay en-US. (#292)
Version 0.54.1
Fixed
- The curator review queue is no longer offered to every signed-in account.
The "Review Queue" sidebar item was unconditional and
/dashboard/reviewwas guarded by_require_authalone, so any logged-in reader could open the pending community suggestions — submitter names included — and was shown Accept/Reject buttons that the API then rejected with 403. The page now uses a new_require_curatorguard mirroringrequire_curatorfromapp.api.deps, and the sidebar item is revealed by the same/auth/mecallback that already gates the Curation and Ingestions sections. (#331) - The whole curation surface is now curator-gated, not just the review queue.
/curation/review,/curation/stats, and the three/dashboard/ingestions/*pages were guarded by_require_authalone; the only thing keeping them out of sight was a sidebar section toggled by client-side JavaScript, which is not an access control. They all render unreviewed state — pending suggestions, queue depth, and ingestions whose extracted records are not published yet — and now take_require_curator.
Changed
- Breaking (API).
GET /api/v1/annotations/requires authentication. It took no user dependency at all, so the unreviewed curation queue — suggested field edits and the name of whoever filed each one — was readable by anyone who guessed the URL. The endpoint is authenticated but deliberately not curator-only: curators and superusers see every annotation, while any other signed-in user hasuser_idpinned server-side to their own id, so a contributor keeps the ability to list what they submitted. Only the two curator review pages consume the endpoint (entity_edit.jsonly POSTs), and it is not described in the README or the API documentation page.
Version 0.50.0
Changed
- **The statistics "Papers by country" map now keys on research origin, not
strain isolation.** Every layer of
/stats/by-countrypreviously grouped on the strain's isolation location (SampleSource.country/region), so the Papers toggle silently measured "papers reporting a degrader isolated in country X" — not where the paper came from, which is what a viewer expects and what the title implies. It now groups onPaper.corresponding_author_country(already extracted per paper), so a study authored in Japan reporting a strain isolated elsewhere counts under Japan for Papers and under the isolation country for Microorganisms. The Microorganism layer is unchanged.
Added
- Toggle-aware legends under the country map and bar figures. Because the two toggles now measure genuinely different geographic axes (isolation site vs. corresponding-author country), each figure carries a legend that updates with the toggle and spells out exactly what the current view counts, so the two are not conflated.
Version 0.49.4
Fixed
- Proteins with a PDB accession showed no 3D structure. The detail page gates
its viewer on
has_structure, which requires astructure_filepresent in the structure store —/proteins/{id}/structureserves stored bytes and never proxies RCSB, so apdb_idalone renders nothing. Coordinate files only ever arrived throughscripts/import_structures.py, which matches legacy files on disk byplasticdb_id; a pipeline-ingested protein could therefore never get one. Enrichment now downloads the coordinate file from RCSB for any protein that has apdb_idbut nostructure_file. - The structure store ignored the shared S3 credentials.
get_structure_storeread onlySTRUCTURE_S3_*, while the job, snapshot and paper stores all use the sharedS3_*pair — so settingSTRUCTURE_STORE=s3on a deployment configured the normal way produced an S3 store with no endpoint and no keys. The dedicated variables still win when set; unset ones now fall back to the shared values.
Version 0.49.3
Fixed
- Batch enrichment results never reached protein pages.
enrich_all_proteins_synccalledprotein_cache.invalidate_sync()with no argument, which only bumps the list generation — per-protein detail entries kept serving pre-enrichment data until their TTL expired. A dashboard "enrich all" therefore looked like it had done nothing, even on a completely successful run. The batch now drops the detail key for every protein it enriched.
Version 0.49.2
Fixed
- Structures supplied by an ingestion never got their metadata. The PDB step
resolves a single entry per protein and writes it through
upsert_protein_structure, which keys onsource— so repeated calls always land on the firstexperimentalrow. Any further PDB id a protein carries from its extraction stayed bare forever: PETase owns both5XFY(discovered by the enricher) and7SH6(from the FAST-PETase ingestion), and only5XFYever received a method, resolution or deposition date._backfill_structure_metadatanow fills any structure row that has apdb_idbut is missing metadata, and runs before thealready_presentearly return — which is exactly when an ingestion-supplied structure needs it. Only empty fields are written, so curator edits survive a re-run.
Version 0.49.1
Fixed
- Protein enrichment crashed on every run with a PDB structure. RCSB returns
rcsb_entry_info.resolution_combinedas an array (one entry per experimental method), butProteinStructure.resolutionis a singleFloat. Passing the list straight through made PostgreSQL reject theUPDATEwith `DatatypeMismatch: column "resolution" is of type double precision but expression is of type numeric[]`. The failed flush then poisoned the session, so the run died with a cascadingPendingRollbackErrorand every enrichment job launched from the dashboard silently failed._fetch_pdb_metadatanow unwraps the array to its first value. Note the mock test tier could not have caught this: SQLite accepts a Python list where PostgreSQL rejects it, so the type error only ever appeared in production. The new regression tests assert on the unwrap at the function level, which holds regardless of backend.
Version 0.49.0
Fixed
- Release snapshots could never be generated.
app/services/snapshots.pybuilt its export from rawtext()SQL that had drifted off the schema: it selectedprotein_variants.reference_idandinteractions.reference_id/interactions.microorganism_id(all renamed or moved when the organism actor became aStrainOccurrence), and read themicroorganism_referencesandprotein_referencesjunction tables, which were dropped when an entity's papers became derived from the evidence spine. Every one of those would raise at run time, sogenerate_snapshotfailed on its first query. Nothing caught it. Raw SQL fails only when executed, the schema exists only in PostgreSQL, andtests/test_snapshots.pymocks the database and never calledgenerate_snapshot. The monthly Celery export is the only caller, so the breakage was invisible — consistent with there being 0 snapshot rows. Snapshot generation is now built entirely onapp/services/export.py— the same column lists, eager-load sets, and row builders the/api/v1/downloadsroutes use. This is the structural fix, not just a repair: a future schema change now breaks a user-facing download exercised on every test run, instead of a monthly task nobody watches. The hand-rolled join maps that reconstructed organism→paper and protein→paper links from the dropped junctions are gone entirely; the shared row builders already carry that data, derived from the spine. - Snapshots could have leaked embargoed data into a citable artefact. The
export applied no visibility filter, so a private or embargoed deposit would
have been written into a tarball and pushed to Figshare — the one place a leak
is permanent and uncorrectable. Generation now applies
public_visibility_filterandvariant_parent_public_filterexactly as the public bulk exports do. Two narrower leaks in the same family were found in review and closed:- The evidence spine inherits visibility; it has no column of its own.
interactions.csvwas exported unfiltered, so a row referencing an embargoed protein or a private organism still shipped — disclosing that the record exists, plus its paper, plastic, and degradation result — even though the entity's own row was correctly excluded. Interactions are now filtered on every actor slot (protein, strain-occurrence organism, variant parent). - A public organism disclosed the names of its non-public proteins.
microorganism_row'ssource_proteinscolumn was built from an unfiltered relationship, so the row-level visibility filter never reached it. Fixed inexport.py, which closes it for/api/v1/downloadsas well — that path has been leaking these names since the column was added.
- The evidence spine inherits visibility; it has no column of its own.
- A failed publish could strand a snapshot permanently. The admin route
committed
figshare_draftbefore the network calls but caught onlyFigshareError. Anhttpxtimeout orOSErrorleft the row infigshare_draft, which the route itself refuses to publish, makingDELETEthe only exit. It now catches every exception and rolls back toexported, matching the automatic path. - The admin route could mint a DOI for an empty article. It uploaded only
if snap.file_path:and otherwise published metadata alone, producing a permanent citable artefact with no data. It now refuses with 400 when the tarball is missing or its path points at nothing, matching the automatic path. - Confidence badges failed WCAG AA contrast. sb-admin-2 overrides Bootstrap's
color-yiq()and forces white text on every.badge-*fill, leavingbadge-warningat 1.65:1,badge-successat 2.17:1,badge-dangerat 3.86:1, andbadge-secondaryat 3.56:1 at 12px bold. The light fills now take dark text (9.33:1 and 7.11:1) and the two mid-tones darken to values sb-admin-2 already ships (6.04:1 and 4.90:1). Fixed inplasticdb.css, so all 14 templates using badges benefit, not just the review queue. - A failed Figshare upload orphaned its draft article.
figshare_article_idwas written only afterpublish()succeeded, so a failure between article creation and publication left a draft on Figshare whose id was never recorded. It is now persisted immediately after creation, on both the automatic and manual paths.
Added
- Releases publish to Figshare automatically. A successful monthly export now
dispatches
publish_snapshot_task, which creates the article, uploads the tarball, and records the DOI without a human step. Previously export was automated but publication was not, so the steady state was a growing pile ofexportedsnapshots that never received a DOI. Controlled byFIGSHARE_AUTO_PUBLISH(default on, exposed in admin settings); a missingFIGSHARE_API_TOKENis a safe no-op that leaves the snapshotexportedfor a superuser to publish by hand. A publish failure rolls the snapshot back toexported, neverfailed— the export itself succeeded and the bytes are on disk, so only the DOI step needs retrying. - Extraction confidence is visible in the curator review queue. The
extractor's self-assessed
extraction_confidencewas required by the ingestion contract, stored inextracted_data, and then surfaced nowhere and used to filter nothing. It now renders as a colour-coded badge beside each paper's title (green/amber/red for high/medium/low), making it the cheapest triage signal in the queue.extraction_notes— equally buried — is now shown alongside the existing reviewer-guidance panel. generate_snapshotis tested against real PostgreSQL. Two cases intests/test_pg_integration.pybuild an actual archive and assert its members, thatentity_countsagrees with the CSV row counts it describes, that the spine'smicroorganism_idis correctly denormalised through the strain occurrence, and that embargoed and private records are absent from every file including the FASTA. Verified to fail when the original column drift is reintroduced.publish_snapshotis unit-tested. The Figshare publish logic moved from the Celery task body intoapp/services/snapshots.pyso the default test gate can reach it — a@celery_app.taskfunction is replaced by aMagicMockin the unit tier, making any logic inside one unreachable bymake test. The task is now a thin orchestrator, matching the stated design ofapp/workers/tasks.py.
Notes for consumers
- The snapshot archive now contains
papers.csvrather thanreferences.csv, matching thereference→paperrename applied everywhere else, and no longer containsmicroorganism_references.csv/protein_references.csv(those tables no longer exist — an entity's papers are derived from the spine and are already present aspaper_id/paper_citation/paper_doi/paper_yearcolumns).interactions.csvgainsstrain_occurrence_idand a denormalisedmicroorganism_id. No published snapshot has ever existed, so nothing downstream can break. - Snapshot CSVs now carry the same columns as the corresponding
/api/v1/downloadsfiles, so a release and a live export are directly comparable.sequences.fastanow includes engineered variants alongside wild-type proteins, keyed by the sameseq_idthe CSVs expose. - **
source_proteinsinmicroorganisms.csv/.jsonno longer lists embargoed, private, or tombstoned proteins.** This changes existing/api/v1/downloadsoutput: an organism that previously listed such a protein by name now omits it. The column was never meant to bypass the embargo. make test-pgis now required, not optional, when changingapp/services/snapshots.py,app/services/export.py, orquery.py's visibility filters (documented in CLAUDE.md). The mock tier cannot see SQL-level visibility filtering or schema drift, and both failure modes here end in a DOI that cannot be withdrawn.
Version 0.48.0
Fixed
HEADworks on every page and endpoint, not just downloads.curl -Ion any URL — the home page,/about, a plastic detail page,/api/v1/stats, the/healthprobe — answered405 Method Not Allowed, which was untrue: the resource was there and answeredGETperfectly well. All 178 of them now answerHEADwith the status and headers theGETwould send and an empty body (#249). This mattered most to the tools nobody watches. Link checkers and crawlers probe withHEADbefore spending aGET, so every valid page reported itself broken. Uptime monitors commonly default toHEAD, so one pointed at/healthsaw the service as down and had to be reconfigured to work around us. It also quietly undid the honest 404s shipped in v0.45.0: a link checker asking withHEADgot a 405 for live and dead URLs alike, so it never saw the status codes that release existed to provide. The cause was a divergence in FastAPI, not our code — Starlette addsHEADwherever a route acceptsGET, and FastAPI's route class does not, which is why/staticand/docsworked all along while everything we declared did not. v0.43.0 fixed this for the 14 download routes; this covers the rest.
Notes for consumers
HEADstays out of the OpenAPI schema by design. RFC 9110 defines it asGETminus the body, so documenting a twin of all 173GETendpoints would have near-doubled the API reference without adding anything a reader could act on. The 15HEADoperations previously listed for the download routes are gone from the schema for the same reason — those routes' behaviour is unchanged and they still answerHEADexactly as before.
Version 0.46.6
Fixed
- Scientific names italicize only the species, not "sp." or strain codes.
An organism like Absidia sp. was shown as Absidia sp. — the whole string in
italics — when taxonomic convention keeps qualifiers such as
sp.,cf.,spp.and strain designators upright. A shared formatter now italicizes just the genus and species epithet across the microorganism list and detail page, the plastic degrader list, paper detail, the protein list and detail "source organism", and the taxonomy group members table. Full binomials like Ideonella sakaiensis remain fully italic.
Version 0.46.0
Fixed
- The blue got slightly darker. White text on the brand blue was below the legibility minimum everywhere it appeared — the nav bar, the footer, the home page banner, the dashboard sidebar. This could not be fixed by adjusting the text: even pure white was too faint against the lighter end of the gradient. The gradient itself is now a little deeper, which keeps the same colour but makes every white label on it comfortably readable. Footer links, which were half transparent and by far the worst offender, are now legible while still sitting quieter than the text around them.
- Grey captions and the green "Verified" mark are readable. Both were too faint to meet the contrast minimum — the green especially, at less than half the required ratio. Both now use a darker shade already in the site's palette. The search box's own text (group headings, "See all results", "No results found") had the same problem and is fixed too.
- Keyboard users can skip the navigation. Every page now starts with a "Skip to main content" link, so reaching the actual page no longer means tabbing through the whole two-row nav (or the sidebar's twenty links) first. It stays invisible until focused.
- Screen readers can find the main content. No page marked where its content began, so the standard "jump to main content" command had nothing to jump to. Every page — public, dashboard and login — now marks it.
- The search box announces its suggestions. The global search has always offered a dropdown you can walk with the arrow keys, but the highlighted row was only ever a background colour: a screen reader user pressing Down heard nothing. The suggestions are now announced as you move through them, along with how many results were found, and the results grouping is conveyed properly.
- Every page has a heading. Ten pages — including every protein, microorganism, plastic, paper and compound detail page — had no top-level heading, which is how screen reader users find their place on a page.
- Browser tabs, history and bookmarks tell pages apart. All ~337 protein pages shared the title "Protein Detail"; variant and forum-thread pages had the same problem. Each page is now titled after what it actually shows. Detail pages that were titled by internal ID ("Compound 4823") now use the real name too.
- The login and registration forms are usable with a screen reader. Every field relied on placeholder text alone, which is not a label and vanishes the moment you start typing, so all of them announced as unlabelled. Error and success messages were also never announced — a failed login said nothing at all. Both are fixed on all four account pages, including the password-reset pages the original report missed. Password managers can now fill these forms.
Version 0.45.0
Fixed
- The site can now be used with a keyboard. Tabbing through the public pages showed no focus ring at all, so there was no way to tell what was selected: the only rule that applied to the nav changed the link colour and nothing else, and Bootstrap strips the browser's native outline in 22 places. Every interactive element now shows a ring when focused by keyboard (and only by keyboard, so mouse clicks look unchanged) — white against the blue nav bar, dark inside the white search pill. The global search box had its outline explicitly removed and now keeps one.
- **The search filter chips ("All", "Microorganisms", "Proteins"…) can be reached
and pressed.** They were styled
<span>s, which meant Tab skipped straight past them and no keyboard or screen-reader user could change the search filter. They are real buttons now, and the selected one is announced rather than shown only as a colour. - Browse filters tell a screen reader what they are. Every filter label on the proteins, microorganisms, plastics, compounds, papers, downloads and forum pages was plain text with nothing tying it to its input, so all ~50 controls announced as unlabelled — a placeholder is not a name, and it disappears as soon as you type. Labels are now associated with their control, which also means clicking a label focuses its field.
- Pagination arrows say where they go. The next/previous chevrons on every browse page announced as a blank button, because a FontAwesome icon renders as an empty glyph. They now read as "Previous page" / "Next page".
- The statistics charts are readable without seeing them. The three live charts and the country map/bar pair were invisible to screen readers — a canvas is an opaque image and a plotted SVG is a pile of unlabelled paths. Each chart is now named and publishes the same numbers it draws as a hidden data table, which tracks the type toggle, the country search and pagination.
Version 0.44.1
Fixed
- Logging back in from a tool now returns you to that tool. If your browser
session was still valid but your saved token had gone (the two can drift apart),
picking a file on a tool page and hitting Submit bounced you to the login screen
— and then dropped you on the dashboard rather than the tool you were using, so
your upload and settings were lost. The five annotation tools were sending
?redirect=while the login page only reads?next=, so the address was quietly discarded. They now agree, and you land back where you started (#230).
Changed
- Why some tools need an account is now written down. Five of the six tools
ask you to log in; Build Tree doesn't, and that difference looked like an
oversight. It isn't. Anything you upload data to needs an account, because
results for a signed-out job can be opened by anyone holding its link — so
requiring an account is what keeps your own unpublished sequences private. Build
Tree uploads nothing (it only picks from organisms already listed on the browse
pages), so it stays open to everyone, including the "Build Tree" buttons on the
proteins, plastics, and microorganisms pages. The rule and its reasoning are now
in
DECISIONS.md, and both halves are covered by tests so the split can't be "tidied up" by mistake (#230).
Version 0.44.0
Fixed
- A bad URL now shows a PlasticDB page instead of dumping raw JSON. Three
different wrong answers used to come back from the same mistake — asking for a
page that isn't there. An unknown address returned
{"detail":"Not Found"}as bare JSON with no navbar and no way back into the site. A non-numeric id like/plastics/PET— the URL a person would guess, since PET is the label shown everywhere while the route wants61— returned a422dump of FastAPI's internal parameter names. And a missing record returned HTTP 200, then let JavaScript swap in a "not found" card, so crawlers and link checkers recorded the page as real (#229). All three now answer404and render a branded page with the full navbar, footer, and a search box, whenever a browser asks for HTML. The REST API is untouched:/api/v1/…still answers JSON and still validates ids with422, regardless of what the caller says it accepts.curlandfetch()send a bare*/*and keep getting JSON too — only clients that explicitly nametext/htmlget a page. - 500 and 429 got the same treatment. An unhandled error showed a browser raw JSON; so did hitting the rate limit — which matters more than it sounds, because the anonymous limit buckets by IP and an entire NAT'd campus shares one allowance. Both now explain themselves on a real page.
- A withdrawn or merged enzyme accession now says so.
/proteins/{accession}matches the contract the API already published: a merged accession301s to the record that superseded it (previously the page quietly rendered the survivor's data under the old URL, so nobody learned the accession had changed), and a withdrawn one returns410 Gonerather than a "not found" card under a200.
Changed
- Detail pages check that their record exists before rendering, so the HTTP status matches what the visitor is actually looking at. The merge/tombstone rules for enzyme accessions now live in one resolver shared by the API and the pages, rather than being written out separately in each.
Version 0.43.0
Fixed
- **Every protein filter now reaches engineered variants, on
/proteinsand its exports alike.**?microorganism=Ideonella&kind=engineeredreturned an empty file with a200: the user explicitly asked for the Ideonella variants and was told there were none. Four other filters (lineage,substrate_form, and the kinetic thresholds) behaved the same way, and under the defaultkind=boththey silently dropped the engineered side of an otherwise correct answer (#228). The cause was a modelling shortcut, not missing data. A variant has no organism column of its own — it is Ideonella-derived through its parent — so the code concluded no variant could satisfy an organism filter and stopped querying them. The data was there the whole time, one join away. Filters now resolve against a variant one of two ways, and the distinction is the whole point:- Inherited —
microorganism,lineage,plasticdb_idresolve through the parent protein, which is where a variant's organism actually lives. - Its own —
substrate_form,evidence_method,polarityand the kinetic thresholds match the variant's own experiments and measurements, never its parent's. A variant is engineered precisely to beat its parent's kcat, so inheriting kinetics would return sluggish variants of a fast parent. There is no longer any such thing as a "wild-type-only" filter, and no filter can silently drop an entity type.
- Inherited —
?plasticdb_id=00241returned the entire database. Unreported, and worse than the above: the list never appliedplasticdb_idon its unified (kind=both, i.e. default) path, so a precise identifier lookup answered with all 338 records, whileproteins.csvapplied it to wild types only and returned the right protein plus every variant of every other protein. A researcher asking for one enzyme received unrelated enzymes' variants, with a200and no signal.?polarity=inconclusivewas ignored on the default path, likewise returning everything. The unified path tested polarity against `("positive", "negative", "mixed")— a tuple that omits a realResultEnum` value and includes one that does not exist. Both had the same root cause: the list and the exports each hand-copied their own partial filter surface, so they drifted. They now share one builder (protein_entity_filters), which is what a filter means for either entity type. Parity is structural rather than maintained by hand.HEADon a download route returned405, advertising `content-type: application/json` (the error body's type, not the resource's). Clients that probe before streaming — link validators, download managers, content-length checks — broke on it. All 14 download routes now answerHEADwith the resource's real headers and an empty body, without running a query or building the ZIP. NoContent-Lengthis sent: an export's size isn't known until it is generated, and the0an empty response would otherwise carry is worse than no answer — it tells a client the file is empty.
Changed
- BREAKING — an unrecognised
kindis now a422on/api/v1/proteinsand the protein exports, instead of silently falling back toboth.?kind=engineredreturned the full export with a200; a typo was answered with the wrong dataset while?year=notanintwas already a422. Valid values are unchanged (wild_type,engineered,both), and the site's own filter is a closed dropdown, so only third-party callers passing an invalid value are affected — and they were receiving data they did not ask for. - **BREAKING — an organism or lineage filter now includes engineered variants under
the default
kind=both.** Filtering/proteinsby organism previously listed wild types only; it now also lists that organism's variants. Row counts on organism-filtered views go up. This is the fix above, seen from the front end: "everything from Ideonella" includes the enzymes engineered from it.
Version 0.42.0
Removed
- The "biochemically-verified" protein subset is gone — the green card on
/download, the green Verified badge on protein detail pages, theverified_onlyfilter,/api/v1/downloads/verified-set,ProteinDetail.verified,app/services/verified.py, andDIRECT_ASSAY_METHODS. Two reasons, either sufficient on its own. It made a claim we don't stand behind. Everything in PlasticDB is curated from published, peer-reviewed literature — that is a core design principle, stated on/aboutand/contact. Labelling 37 records "verified" implied the other ~300 were not, inviting doubt about data we publish precisely because it is evidenced. The distinction being drawn was never about verification; it was about assay type. The predicate was broken, so the subset was arbitrary.is_verifiedrequired an experiment whose method was inDIRECT_ASSAY_METHODS, a hand-written list in which three of six names did not exist in the method vocabulary: it looked formonomer_release_hplc,sem_surface_erosion, andnmr_product_idwhile the vocabulary calls themhplc,sem, andnmr(the three phantom names are measurement types — a different axis — which is how the confusion arose). Two of the remaining three,co2_evolutionando2_consumption, are used by no experiment. The predicate therefore reduced toweight_lossalone, and the subset was exactly the weight-loss count. The visible consequence: a protein whose degradation was shown by HPLC monomer release (97 proteins) or SEM surface erosion (27) — direct evidence of polymer breakdown — displayed no badge, while a weight-loss protein did. The badge was reporting a vocabulary bug as a scientific judgement. Removed rather than repaired: fixing the three names would have produced a correct highlighted subset, but a highlighted subset is the thing we don't want.
Added
evidence_methodon proteins./api/v1/proteinsandproteins.csv/.json/.fastaacceptevidence_method, matching the assay a record's evidence rests on (weight_loss,hplc,sem,clear_zone, …) exactly against the method vocabulary./microorganismsalready had this filter; proteins did not. It is a descriptive axis with no implied hierarchy — a user who wants weight-loss or HPLC evidence selects it themselves. Unlike the wild-type-only structural filters, it applies to engineered variants too, since a variant carries its own experiments.- The download page's evidence-method selector is rendered from
METHOD_VOCAB(grouped_method_vocab()), so the options cannot drift out of sync with the names the filter matches on — the root cause of the bug above.
Fixed
- A regression guard for vocabulary drift. Nothing asserted that a method-name
constant was a subset of
METHOD_NAMES, which is whyDIRECT_ASSAY_METHODScould list three non-existent names for as long as it did — and the old tests hid it by seeding aMethod(name="monomer_release_hplc")row to match the constant instead of using a real vocabulary name. A new test discovers every module-level*_METHODSset by name and asserts the subset relation, so a future constant is covered the moment it is added; a companion test pins that measurement-type names are not method names.
Notes for consumers
- Breaking.
/api/v1/downloads/verified-set(all formats) is removed and now 404s. Theverified_onlyquery param is removed fromproteins.csv/.json/.fasta; FastAPI ignores unknown query params, so a request still passing it now returns the unfiltered set rather than an error — check your call sites (cf. #246).ProteinDetail.verifiedno longer appears inGET /api/v1/proteins/{id}. - Closest replacement:
?evidence_method=weight_lossreproduces the old verified set as closely as anything can, because the old predicate matchedweight_lossand nothing else in practice. If what you actually wanted was direct polymer-breakdown evidence,evidence_method=hplcandevidence_method=semreach the records the old predicate wrongly excluded. - Release-pinned exports are gone with the endpoint.
verified-set?release=<label>was the only export acceptingrelease=, so no download endpoint takes it now. Snapshots and DOIs are unaffected:/api/v1/snapshotsstill lists every release with itsfigshare_doi, and the citable frozen dataset remains the Figshare artefact behind that DOI. (There are currently 0 snapshot rows, so no published DOI pointed at a verified-set export.) scripts/build_hmm_profiles.pytakes--fastainstead of--verified-fasta; feed itproteins.fasta. Email/ORCID user verification and the AlphaFold/ESMFold "not experimentally verified" structure badges are unrelated concepts and unchanged.
Version 0.41.0
Added
- Engineered variants now appear in every protein export, under one schema.
v0.39.0 carried variants into
proteins.fastabut not the other formats, so one logical entity had three answers:proteins.csv/.jsonreturned wild types only (330), the FASTA returned 337, and/api/v1/proteinsreported 338.proteins.csvandproteins.jsonnow take the samekindparameter as the FASTA and the list (wild_type|engineered|both, defaultboth) and stream wild-type rows followed by engineered-variant rows.PROTEIN_COLUMNSgains two appended columns (existing column positions are unchanged):kind(wild_type/engineered) andparent_accession. Because the two entity types have independent id sequences, the row key is(kind, id)— matching what/api/v1/proteinsalready returned. Variant rows populate their own interactions, kinetics, structures, and papers, inherit the parent's source organism, and leave wild-type-only identity columns (plasticdb_id,gene,uniprot_*, …) null. Variant selection mirrors the list: a structural wild-type-only filter still drops variants. - A join key for engineered variants.
protein-variants.csv/.jsoncarried neitheraccessionnorsequence, so a variant's FASTA sequence could not be lined up with its metadata. Both columns are now present (appended), alongside a newseq_idcolumn on the protein and variant exports. seq_id: a join key that holds for every row.accessionis nullable on bothProteinandProteinVariant(minted at promotion, backfilled onto legacy rows), and the FASTA has always fallen back toPlasticDB_{id}/PlasticDB_var_{id}for those records. Keying the exports onaccessionalone would therefore have orphaned the sequence of any record whose accession is not yet minted — the same class of latent defect this issue reports, surviving in the fallback path. Every export row now carriesseq_id, which is exactly the matching FASTA record's identifier and is never empty, soproteins.csv,proteins.fasta, andprotein-variants.csvall join on one column.accessionstays truthful (empty when unminted) rather than being filled with a synthetic value.protein_seq_id()/variant_seq_id()are the single source shared by the FASTA headers and the row builders.- The dataset bundles are complete.
dataset.zipgains aprotein_variants.csvmember anddataset.jsonaprotein_variantsarray; both bundles'proteinssets now include variant rows under the shared schema. The bundles remain a complete archive and deliberately apply noinclude_negativefilter, so theirproteinsset is a superset of the endpoint's curated default — pass?include_negative=trueto the endpoint to match.
Fixed
protein-variants.csv/.jsonleaked variants of non-public parents. AProteinVarianthas no visibility columns of its own and must inherit its parent's. The unified list and the FASTA each gated on the parent; the variant CSV/JSON export never did, so variants of private, embargoed, or tombstoned proteins were served through a public bulk export — which would have started leaking sequences oncesequencewas added. The clause is now a single source,variant_parent_public_filter(), applied by all three paths.
Notes for consumers
proteins.csv/.jsonreturn more rows by default than before (wild types plus variants). Pass?kind=wild_typefor the previous record set.proteins.fastais a sequence file and still omits records with no sequence, so it can be shorter thanproteins.csvby exactly those rows. This is intended.
Version 0.39.0
Added
- FASTA export now includes engineered-variant sequences. The public
GET /api/v1/downloads/proteins.fastaendpoint served only wild-typeProteinrows, so the/proteinspage disabled its "Download FASTA" button whenever the Type filter was set to "Engineered variant". Now that the producer derives variant sequences, the endpoint takes akindparameter (wild_type|engineered|both, defaultboth): it streams wild-type protein records and then engineered-variant records in one multi-FASTA. Variant records carry the variant's own accession (or aPlasticDB_var_{id}fallback so a null accession never collides with a wild type) and are taggedengineeredin the header. Variant selection mirrors the unified list — name/accession/plastic/ year/polarity filters apply; a structural wild-type-only filter (organism, lineage, substrate form, kinetics, verified-only) drops variants; a variant with no sequence, or under a private/tombstoned parent, is excluded. The/proteinspage's Download button is now enabled for every Type filter and passeskindthrough so the file matches the on-screen result set.
Version 0.38.1
Fixed
- Promotion dedups proteins by accession, not name.
_get_or_create_proteinmatched an incoming protein against the live table bynamewithscalar_one_or_none(), which raisedMultipleResultsFoundand 500'd the curator approve whenever the table already held more than one row with that name — and a full seed holds 100+ rows named "PETase" (plus many "Cutinase", "Lipase", etc.), so approving almost any real paper crashed. Identity is the accession: promotion now matches byuniprot_accessionthengenbank_idwhen the producer supplies one (a distinct accession is a distinct protein, so an accession-bearing "PETase" no longer binds to the seed's generic ones), and falls back tonameonly for accession-less proteins — every lookup bounded withlimit(1)so duplicate names can never raise. Surfaced by the fullmake db-resetseed that 0.37.2 unblocked.
Version 0.38.0
Added
reviewer_guidanceonExtractedData. A new optional object (data_gaps/focus_areas/suggested_steps, each a list of strings) carrying curator-facing guidance from the extractor — distinct fromextraction_notes(provenance/methodology). All three lists default empty and the field itself is optional, so extractions predating it stay valid. It is persisted with the rest ofextracted_dataand is not promoted to live tables (it is review metadata).- Reviewer-guidance panel on the ingestion review screen.
ingestions_review.htmlnow renders a panel at the top of each ingestion's Extracted Data column showing the three guidance lists, so the extractor's "what's missing / what to check / what to do next" is visible instead of buried in the raw-JSON editor.
Notes
- Pairs with PlasticDB-AI's producer-side sequence retrieval: the extractor now
fetches wild-type sequences from their accession and derives variant sequences
from parent + mutations (checksum-gated), recording any it could not resolve in
reviewer_guidance.data_gaps. Variant sequences the producer supplies are persisted on promotion (they have no accession for the server to fetch). No server-side variant-sequence derivation is added — a producer-null variant sequence stays null (with a reviewer note) rather than being guessed without paper context.
Version 0.37.2
Fixed
import_degraders(seed) now dedups microorganisms bytax_id. A freshmake db-resetcrashed with aUNIQUE(tax_id)violation because the degraders TSV lists the same organism under synonymous names at one tax_id (e.g. "Fusarium moniliforme" / "Fusarium verticillioides", both 117187), and the get-or-create keyed on name+tax_id inserted a duplicate. Same fix as 0.37.1, applied to the seed path.
Version 0.37.1
Fixed
- Microorganism promotion now dedups by
tax_id, not just by name. When a source organism's canonical name didn't match an existing row but itstax_iddid, promotion tried to insert a duplicate and hit theUNIQUE(tax_id)constraint. The classic trigger is the seed's"Uncultured bacterium"vs NCBI's canonical"uncultured bacterium"(both tax-id 77133): a metagenomic wild-type enzyme (e.g. LCC) would collide on promotion. The get-or-create helper now falls back to atax_idlookup and reuses the existing row.
Version 0.37.0
Changed
- A wild-type protein's source organism is now auto-created on promotion.
Previously, an extracted
source_microorganism_namewas linked only when aMicroorganismrow with that exact name already existed — otherwise the link was silently dropped to null, leaving wild-type enzymes (e.g. PETase, LCC) with no source organism in the UI. Promotion now resolves the name's NCBI tax-id and creates theMicroorganismif missing, reusing the same resolution path as whole-organism actors (a name NCBI can't resolve raisesUnresolvedTaxonomyError→ 422, routing the paper back to a curator rather than dropping the link). The get-or-create logic is now a single shared_get_or_create_microorganismhelper.
Added
- Metagenomic source sentinel. A wild-type enzyme recovered from a
metagenome / environmental sample with no cultured source organism is emitted
by the producer as the exact literal
"uncultured bacterium", which short-circuits to NCBI tax-id77133deterministically (no live lookup), so every such enzyme dedups to one canonicalMicroorganismrow.
Version 0.33.1
Added
- Paper detail page shows a real title, not just the citation string: the
Papermodel now has atitlefield. The/papers/{id}page displays it as the heading, with the full citation shown underneath for reference. Papers without a title (legacy imports) still fall back to showing the citation as the heading, so nothing regresses. - New papers ingested via the AI extraction pipeline or approved user submissions get their title populated automatically; existing papers that are re-ingested get their title backfilled without overwriting one that's already set.
- Curators can now edit a paper's title directly from the "Suggest edits" / edit form on the paper detail page.
- Paper titles are now searchable alongside citations in full-text search.
Fixed
- The new paper title heading and the citation text below it now both escape their content before rendering, closing a stored-XSS gap where a curator-edited or AI-extracted citation containing HTML could execute in every visitor's browser on the public paper detail page.
- A user submission with no extracted paper title no longer shows the raw DOI as the page heading — it now falls back to the citation, same as any other title-less paper.
Version 0.31.9
Consolidate design rationale into DECISIONS.md
- New top-level
DECISIONS.md: a topic-grouped record of the architectural and data-modeling decisions behind PlasticDB V2 and the reasoning for each — the evidence spine, species-level organisms +StrainOccurrence, paper provenance / re-extraction, the deposit/accession/embargo ecosystem, enrichment, the data contract, plastics/blends/compounds, infrastructure, and more. Distilled from the working plans and verified against the current schema, so only decisions that still hold are kept. - Removed the
plans/directory (16 point-in-time implementation plans). Their durable decisions now live inDECISIONS.md; obsolete or abandoned proposals were dropped. Stale references inapp/workers/enrichment.py,scripts/import_degraders.py,README.md, andtests/test_pg_ecosystem_e2e.pynow point atDECISIONS.md(or were removed where the target no longer existed).
Version 0.31.8
Curation tooling + enrichment fixes
Developer/curator tooling for validating the v1 → v2 import one record at a time, plus two enrichment fixes.
make db-resetfix:scripts/seed_examples.pyno longer passes the relocatedtemperature/ph/substrate_formkwargs toEnzymeKinetics(those assay conditions moved onto the linkedExperiment), which had been crashing the seeder.- Single-record importer (
scripts/import_one.py,make import-one/make import-list): import a chosen subset of the degraders TSV by 1-based row number/range and print a before/after DB diff (new rows per table). Reuses the productionimport_degradersvia a temp one-row TSV. Imports default to the DOI-enricheddegraders_list.enriched.tsv(now also baked into the image and the default seed source, falling back to the legacy TSV). - Adminer DB viewer (
make db-viewer): profile-gatedadminerservice for eyeballing the dev DB; never starts with the normal stack. - Enrichment Make targets (
make enrich-all,make enrich-one ID=…): run the cross-reference enrichment pipeline on demand, mirroringmake lineages. - Fix — UniProt sequence fallback:
_ncbi_fetch_protein_accessionpreviously searched NCBI esearch'saccessionfield with a raw sequence fragment, which can never match (esearch indexes metadata, not residues). It now runs a bounded NCBI blastp to resolve a near-identical accession, gated by the newenrichment_blast_enabledsetting (off by default — live BLAST is slow and rate-limited, unsuitable for the unattended weekly batch). Exposed on the settings page alongside the CAZy/SignalP toggles.
Version 0.31.0
Schema: experimental-system modeling + legacy Evidence → Experiment import
Batch 2 of the schema-review plan (items #8, #1, #2). Pre-release, so this is a breaking schema change with no migration (rebuild from the ORM).
Interaction.experimental_system(whole_organism/cell_free_enzyme, not-null): distinguishes whole-organism assays from cell-free enzyme assays. It is derived from the actor type by a modelbefore_insert/before_updateevent (microorganism →whole_organism, protein/variant →cell_free_enzyme), so callers never set it and the invariant can't be violated.Interaction.enzyme_production_context(native/heterologous/not_reported) andInteraction.enzyme_preparation(crude_extract/partially_purified/purified/immobilized/not_reported): enzyme-assay context. A check constraint requiresenzyme_production_contextfor the cell-free case and forbids it for whole-organism; the event defaults the cell-free fields tonot_reportedwhen unknown. The enzyme's source organism continues to live onProtein.source_microorganism_id.- Ingestion contract:
ExtractedInteractionacceptsenzyme_production_contextandenzyme_preparation(validated, enzyme-actors only);experimental_systemis intentionally not a contract field — it's derived server-side. /api/v1/vocabulariesnow exposesexperimental_system,enzyme_production_context, andenzyme_preparation.- Legacy importer (
scripts/import_degraders.py): the v1Evidencecolumn is no longer dropped — each method token (split on;:/, normalized to the Method vocab) becomes anExperiment, withPlastic used/Manufacturer/Analitical gradecopied onto it. Rows flaggedDegradation extrapolated from enzyme = Yesno longer create an (unsupported) microorganism interaction — only the cell-free enzyme interaction is kept. Unmapped evidence tokens are preserved on the interaction notes. - Fix: the paper footprint/records endpoint now returns interactions in a
deterministic order (
ORDER BY interactions.id) instead of relying on the database scan order.
Version 0.30.1
aerobicity exposed in the vocabularies endpoint
GET /api/v1/vocabulariesnow returns anaerobicitykey (aerobic/anaerobic/microaerophilic/not_reported), so the extraction skill and validator can checkexperiment.aerobicityagainst the database as the single source of truth (it was already a validated field on the ingestion contract but was missing from the public vocab list).
Version 0.30.0
Schema: StrainOccurrence (strain/isolate facts normalized off the species)
Batch 1 of the schema-review plan. PlasticDB V2 is pre-release, so this is a breaking schema change with no migration (rebuild from the ORM).
- New
StrainOccurrencetable at the grain *(paper × microorganism × strain_designation)*. It holds the strain-/isolate-specific facts that were previously mis-placed on the species-levelMicroorganism(where later rows overwrote earlier isolation context):strain_designation(moved offInteraction),genome_assembly_accession,culture_collection_ids, andsample_source_id. Interactionfully normalized onto the occurrence. DroppedInteraction.microorganism_idandInteraction.strain_designation; addedInteraction.strain_occurrence_idas the organism actor slot. The actor XOR is nowstrain_occurrence_id/protein_id/protein_variant_id; the species is reached viastrain_occurrence.microorganism_id.Microorganismis now purely species-level —culture_collection_ids,genome_assembly_accession, andsample_source_idremoved.- Read-side: the microorganism page now aggregates all isolation sources across a species' occurrences (one per study) instead of a single overwrite-prone value; geographic/environment stats now count per occurrence (each reported isolation is one data point), fixing a prior one-per-species undercount.
Experiment.analytical_grade(nullable boolean) added — substrate reagent grade from the v1Analitical gradecolumn; populated by the importer and the AI extraction contract.- Removed
predictedfrominteraction_result_enum(and the extraction contract / vocabularies). PlasticDB holds experimentally-supported claims only; inference-only interactions are omitted rather than recorded. - Extraction + manual-submission contracts now carry strain/source fields at the
interaction level; promotion get-or-creates the
StrainOccurrence. FTS triggers, orphan cleanup, exports, snapshots, and the chatbot schema hint were updated to route through the occurrence.
Version 0.27.0
Sortable entity lists, multi-paper provenance, and two navbar fixes
- Degrading-microorganism / -protein lists are now sortable, server-side.
The plastic detail endpoint accepts
microorganism_sort_by/_sort_order(and the protein equivalents). Allowed keys:name,result(best polarity), andpapers(count of distinct supporting papers). The degrading-microorganism list now defaults to most-reported-first (papersdesc). Column headers on both lists are clickable and toggle direction. - Every source paper is now shown per row. The plastic detail "Source Papers"
column previously rendered only the single best interaction's paper; it now
lists every distinct paper backing each microbe/protein↔plastic pairing as
numbered citation refs (
[1][2]…).MicroorganismListItem/ProteinListItemgained apapersfield for this (the singlepaperis retained). - Client-side sortable tables on detail pages. A reusable
app/static/js/table_sort.js(window.TableSort) makes fully-rendered tables sortable by clicking headers (text/numeric). Wired into the microorganism detail (Associated Proteins, Strains) and paper detail (Microorganisms, Proteins, Engineered Variants) pages. - Fix: Dashboard moved into the main navbar alongside Browse/Tools/Explore/ Community (it previously sat in the right-hand auth cluster).
- Fix: white corners behind the global Search button. The button now rounds its right corners to match the search pill, so the white pill background no longer shows through behind the square button corners.
Version 0.24.3
Ecosystem roadmap — final end-to-end test (+ two visibility fixes it surfaced)
- Final E2E test. Added
tests/test_pg_ecosystem_e2e.py(@pytest.mark.pg) — the 7-step ecosystem scenario fromdocs/ecosystem-feature-roadmap.md, updated for the Feature-2.5 contract: forum verification gate (F5) → deposit + embargo +PB####accessions (F1/F2) → embargoed records hidden from the public list, search, and export (F1/F3) → anonymous reviewer link → owner release →release_due_depositsdated release → accession merge (301) / tombstone (410) /legacy_idresolution (F1). Documented the manual walk indocs/dev-smoke-checklist.md§10. - Fix: embargoed/tombstoned records leaked into the public list and search.
The Feature-1 visibility gate was only applied to exports. The public
/api/v1/proteinsand/api/v1/microorganismslist endpoints and the global/api/v1/searchunion now applypublic_visibility_filter, so private, embargoed, and tombstoned deposits never surface until released. - Fix: a merged accession returned 410 instead of 301.
GET /api/v1/proteins/accession/{accession}checkedis_tombstonedbeforemerged_into_accession; since merge flags both, merged accessions wrongly 410'd. The merge redirect now takes precedence, so a merged accession 301s to its survivor and only genuine tombstones return 410.
Version 0.24.1
Feature 2.5 follow-ups (cleanup deferred from PR #169)
- Submit form no longer collects organism lineage. Removed the
domain/phylum/class/order/family/genus inputs (and their tax-id auto-fill +
review-summary line) from
submit_data.html; the server derives lineage from the species name, so these were gathered but never sent. - Narrower accession-collision retry.
accession.allocate/allocate_syncnow insert inside aSAVEPOINT(begin_nested), so a (near-impossible)PB####collision rolls back only the failed insert instead of unwinding the whole in-progress promotion transaction. - Retired dead free-text tokeniser. Removed
tokenize_evidence_textand its_KEYWORD_MAPfromevidence_vocab.py(no callers since the contract converged) along with its unit tests. - PG tier verified for the F2.5 promotion/accession paths. Ran
make test-pg(not run for PR #169) and fixed the stale tests it surfaced: alookup_taxidmock that leaked aMagicMockspecies name into a PostgreSQL insert, and three protein/variant tests still asserting extraction-suppliedcross_references(now enrichment-derived, dropped from the producer contract).pdb_idpersistence assertions retained.
Version 0.24.0
Feature 2.5 — Contract simplification & server-side derivation
A single, simpler data contract for both the AI extractor and human submitters, with every externally-derivable fact moved onto the server. Lands before Feature 8.
- One interaction-centric contract. The user submission API now accepts the same
interactions[]shape (ExtractedInteraction) the AI pipeline produces; the parallelMicrobeRecord/ProteinRecord/PlasticInteractionRecord/… schema tree is removed andsubmission_promotionpipes straight through the onepromote()path. The submit form keeps its entity-friendly UI but emits the converged payload. - Unified observation vocabulary. The 17-term
observation_typeand 6-termmeasurement_typelists collapse into onemeasurement_typevocab (name + category + default_unit);observation_typeis dropped from the contract and the observationunitis auto-filled from the vocab default. - Leaner producer contract. Dropped server-derived protein fields (
ec_number,has_signal_peptide,cross_references) and organism lineage ranks (+ requiredtax_id) — enrichment and NCBI own them. - Species-rank tax-id normalization.
lookup_taxidclimbs a strain/subspecies/no-rank match to the species ancestor; promotion dedupsMicroorganismby the canonical species, with per-study strain onInteraction.strain_designation. - Sequence-only enrichment fix.
_enrich_uniprotnow runs the `sequence → NCBI esearch → UniProtpath independently ofgenbank_id;no_lookup_path` is permanent only when neither a genbank id nor a sequence exists. - Server-side sequence fetch. Promotion fetches a protein's AA sequence from its accession (UniProt → GenBank) when the producer omits it.
- Plastic by name + canonical resolution. Plastics are referenced by name; the server
resolves to the canonical row by name/abbreviation/normalized synonym and flags genuinely
new plastics
needs_reviewfor curators. - PB accessions on promotion. Both promotion paths mint a
PB####accession for every newProtein/Microorganism(not only deposits), keeping the legacy sequential id inlegacy_id.
Version 0.19.1
Added (Issue 9 — end-to-end researcher-journey verification)
- End-to-end PG test (
tests/test_pg_e2e_researcher_journey.py). One ordered PostgreSQL-tier suite that walks the "find, inspect, and export a high-performance PET hydrolase" scenario across every shipped feature (Issues 1–8): JWT auth round-trip, synonym + field-scoped search,min_kcat_kmkinetic filtering, the surfaced protein/microorganism/plastic/paper detail fields, the parent-polymer vs polymer-class split, aggregate result counts, the re-extraction (supersession) lineage, theconfidence/observation_typecleanup invariants, and a filtered verified-only export. Builds a single realistic world and asserts the surfaces compose into a coherent workflow. - Manual end-to-end checklist added to
docs/dev-smoke-checklist.mdmirroring the automated scenario for live-stack verification.
Fixed
- Protein detail 500 on proteins with engineered variants.
GET /api/v1/proteins/{id}raised aMissingGreenlet500 for any protein that has variants:ProteinVariantReadembedskinetics/properties, butget_proteindid not eager-loadProteinVariant.kinetics/.properties, somodel_validatelazy-loaded them mid-serialization. Surfaced by the Issue 9 live walkthrough (e.g. Cutinase). Fixed by eager-loading the variant kinetics/properties (and their nested plastic/paper/variant refs), and by replacing the recursiveEnzymeKineticsRead.protein_variant/EnzymePropertiesRead.protein_variant(ProteinVariantRead) with a lightweightProteinVariantRef(id + name), which also breaks thevariant → kinetics → variantserialization cycle. The Issue 9 e2e test now builds a protein with a variant + variant-level kinetics/properties to guard the regression.
Changed
tests/conftest.pypg_urlfixture honours aPLASTICDB_TEST_PG_URLescape hatch so the PG tier can target an external (empty) PostgreSQL — e.g. a throwaway container on the compose network — when docker-in-docker testcontainers networking is unavailable. Default testcontainers behaviour is unchanged.
Version 0.19.0
Added (Issue 7 — search synonyms & field-scoped queries)
- Synonym-aware search. A curated plastic synonym map
(
app/services/search_synonyms.py) expands a query to its interchangeable names before building the tsquery, OR-ing them together — soPETalso surfaces records indexed only underpolyethylene terephthalate(and vice versa), across all entities. Groups cover the common polymers (PET, PE/HDPE/ LDPE, PP, PS, PVC, PU, PLA, PHA, PHB, PCL, PBAT, PBS, nylon) and align withPlastic.abbreviation/Plastic.full_name. - Field-scoped search syntax. A
prefix:parser (parse_search_query) supportsplastic:PET,organism:Ideonella,protein:hydrolase,gene:petH, andpaper:…to restrict global search to a single entity;gene:additionally filters on the protein gene column. Unrecognised prefixes (e.g.DSM:1234) fall through to global search. - Matched-entity context. Protein hits in global search now show the source
organism (
Gene: … · from <organism>) so a ranked result is interpretable. - The search page documents the synonym behaviour and the scoping syntax.
Added (Issue 8 — supersession / re-extraction provenance)
- Re-extraction history. The public paper footprint endpoint
(
/api/v1/papers/{id}/footprint) now returns aningestion_lineageof the paper's approved extractions (model, version, approval date, and which run each one replaced), marking the current vs superseded extraction. The paper detail page renders this as a "Re-extraction history" card when a paper has more than one approved extraction. - Superseded-source badges. Every footprint row (interactions, kinetics,
properties, organisms, proteins, variants) carries
source_superseded, set when its originating ingestion has been replaced by a newer one. The paper detail page badges evidence rows whose source extraction is superseded.
Version 0.18.0
Added (Issue 5 — kinetic-parameter filters & catalytic efficiency)
- Catalytic efficiency (kcat/Km) is now a first-class, comparable value. A new
app/services/kinetics.pynormalises heterogeneous units (kcat → s⁻¹, Km → mol/L) and computes kcat/Km in M⁻¹·s⁻¹.EnzymeKineticsReadexposes derivedkcat_km/kcat_km_unitcomputed fields (null-safe when a value or unit is missing). - Protein list endpoint (
/api/v1/proteins) accepts performance filters:min_kcat(s⁻¹),max_km(mM),min_kcat_km(M⁻¹·s⁻¹), andmin_vmax(U/mg). Filters use "any-measurement" semantics and unit-normalise in SQL before comparing. List items now carrybest_kcat_km(best catalytic efficiency across an enzyme's measurements). - Proteins browse page surfaces the new kinetic filters and a kcat/Km results column.
Added (Issue 6 — filtered / custom bulk exports)
- Download page gains a Custom filtered export panel: pick proteins or microorganisms and filter by search, plastic, publication year (≥), substrate form, the Issue 5 kinetic thresholds, and a verified-subset-only toggle — across CSV, JSON, and FASTA.
- Protein download endpoints (
proteins.csv/json/fasta) acceptlineage,min_year, the kinetic filters (min_kcat/max_km/min_kcat_km/min_vmax), andverified_only. Microorganism downloads acceptmin_year.verified_onlyapplies the authoritativeis_verified()predicate while streaming, so a verified export matches the verified-set membership exactly. Filters compose with every format and preserve streaming behaviour. - Protein export rows now include
n_kinetic_measurements,best_kcat_km, andbest_kcat_km_unitso a kinetics-filtered export carries the values it was filtered on.
Version 0.17.4
Changed (Issue 3 — Part B: fix misleading and weak presentation)
- Plastic page now splits the single, mislabeled "Polymer class" row (which actually rendered the parent plastic) into three correct rows: Abbreviation, Polymer class (the real
polymer_classfield, previously never shown), and Parent polymer (the parent link).PlasticRead/PlasticDetailnow exposepolymer_class, and the plastic list/detail endpoints populateabbreviationandpolymer_class. - Plastic page links SMILES (and each monomer's SMILES) to a PubChem structure search instead of presenting a raw code block as the only affordance;
MonomerReadnow exposessmiles. - Plastic page degrading-microorganism/protein tables now show aggregate per-actor result counts (
3+ / 1−viaEvidence.summaryCounts) instead of a single "best" result that hid disagreement, and the Result column carries a polarity legend. - Protein page GO terms now link to QuickGO (
ebi.ac.uk/QuickGO/term/<GO:id>) instead of bare badges. - Protein page kinetics/properties rows now link to their source paper (DOI) via
renderPaperRef, fixing a latent bug where the rows read a non-existentreferencefield (the schema field ispaper) and always showed "–". Engineered variants now display theirvariant_typewith a hover legend. - Microorganism page oxygen-requirement field gains a legend tooltip (aerobic/anaerobic/facultative/microaerophilic).
Removed (Issue 4 — deferred-debt removal, verify-only)
- The evidence-spine
Interaction.confidenceenum and theObservation.observation_typefree-text column (removed in earlier commits as the evidence spine became the source of truth) are now pinned removed by guard tests covering the ORM models, the response schemas, and the public API. TheExtractionConfidenceextraction-quality tag and theExtractedObservation.observation_typeextraction-input field are a separate, documented part of the ingestion contract and are intentionally retained.
Version 0.17.2
Added (Issue 2 — Part A: surface stored-but-hidden fields)
- Microorganism page now shows
culture_collection_idsas linked chips (DSM, ATCC, JCM, NBRC, NCIMB, NRRL URLs auto-resolved) andgenome_assembly_accessionas a direct NCBI Datasets Assembly link. - Protein page now shows
active_site_residuesas a list of 1-based position badges,signal_peptide_cleavage_sitealongside the existing signal peptide badge,cross_references(CAZy, BRENDA, RefSeq, UniRef, InterPro, Pfam) as linked chips, and a per-structure table showingdeposition_dateandresolutionwhen multiple structures exist. - Protein schema now exposes
cross_references(previously stored in the ORM model but absent from the PydanticProteinDetailresponse). - Paper page now shows
corresponding_author_countryin the metadata block, and renders fullEnzymeKineticsrows (Km/kcat/Vmax + units, temperature, pH, variant, substrate) andEnzymePropertiesrows (optimal T/pH, Tm, T50, half-life, notes) instead of count-only badges. The paper footprint API now eager-loadsplasticandprotein_variantfor kinetics/properties so the detail rows carry names, not only IDs.
Version 0.17.1
Fixed
- Authenticated routes no longer 500 on PostgreSQL (ISSUE-BLOCKER-001).
users.idand every foreign key referencing it (jobs, submissions, annotations, contribution events, paper ingestions, forum posts/replies, chat sessions/messages) used the native PostgreSQLuuidcolumn type while the JWTsuband all comparison values are Python strings. Binding a string against auuidcolumn raisedoperator does not exist: character varying = uuidon every JWT-authenticated route in Docker. These columns are nowString(36), so the column and the bound parameter are both varchar — consistent across PostgreSQL and SQLite. The application already treated these ids as strings end-to-end (as_uuid=False, string-UUID defaults), so this is a type-declaration fix with no change to id values. - Stale tree-builder tests. Three
test_tree_builder.pyexpectations still asserted the pre-hierarchical flat Newick format and the oldtree:v1:cache prefix; updated to the current hierarchical topology andtree:v3:prefix.
Added
- Auth id round-trip regression test (
tests/test_auth_id_roundtrip.py) — a JWT →/api/v1/auth/meround-trip on both the SQLite tier and the PostgreSQL tier guards against re-introducing the id type mismatch.
Version 0.16.13
Added
- Strain designation on microorganism detail pages. When multiple strains of the same species have been studied, a "Strains Reported" card now appears listing each strain alongside the plastics tested and the source paper.
- Auto-promotion of accepted data submissions. When an admin accepts an "Add data" submission, the evidence (microorganism, plastic interactions, strain designations, sample source) is now automatically promoted into the live evidence spine. Previously this required manual entry.
- Strain-aware deduplication. The ingestion pipeline now deduplicates interactions at the strain level: the same species tested under different strain designations creates separate interaction records rather than collapsing into one.
- Strain designation in LLM extraction output. The
ExtractedInteractionschema now carries astrain_designationfield so the AI extraction skill can record strain context separately from the species name.
Changed
- Strain designation moved from species to interaction.
strain_designationis now a property of an individual study interaction, not the microorganism species row. This correctly reflects that strain context is per-paper, not a species-level fact, and allows the same species to appear under multiple strain designations across papers. - Microorganism dedup is now species-level only. The
Microorganismtable deduplicates by species name (e.g. "Ideonella sakaiensis") rather than by strain — one row per species, with strain context held on eachInteraction.
Fixed
- XSS in microorganism detail page. All user-contributed text (species names, paper citations, sample source fields, protein names, DOIs, strain designations) is now HTML-escaped before being injected into the page, preventing stored XSS via submitted data.
- Partial-commit safety on failed auto-promotion. If auto-promotion fails mid-way, a database savepoint ensures no partial rows (orphaned
PaperIngestionwithoutInteractionevidence) are committed. - Idempotent auto-promotion. Re-accepting the same submission no longer creates a duplicate
PaperIngestionrecord.
Version 0.14.0
Added
- Task 7 — organism & structure identity. New nullable identity/physiology fields on
Microorganismfor reproducibility and strain de-duplication:strain_designation(str),culture_collection_ids(JSON, e.g.{"DSM": "...", "ATCC": "..."}),genome_assembly_accession(str, indexed),growth_temp_min/growth_temp_opt/growth_temp_max(float), andoxygen_requirement(enumaerobic/anaerobic/facultative/microaerophilic).thermophilicis retained but marked deprecated in the model docstring (derive fromgrowth_temp_optwhen present). ProteinStructuretable — structures are now one-to-many (a real enzyme has many: apo, holo, mutants, ligand complexes). Columns:protein_id(FK CASCADE),protein_variant_id(FK CASCADE, nullable),pdb_id,method,resolution,deposition_date,source(enumexperimental/alphafold/other),confidence,is_primary.Proteingainsprimary_structure/pdb_idconvenience accessors over the loadedstructurescollection.- Protein active-site annotation:
Protein.active_site_residues(JSON list of positions) andProtein.signal_peptide_cleavage_site(int), surfaced in the protein detail schema and accepted onProteinCreate/ProteinUpdate. - Migration 007 (
alembic/versions/007_organism_structure_identity.py): adds the organism identity columns and the protein active-site/signal-peptide columns, createsprotein_structures, backfills oneProteinStructurerow per protein that carried apdb_id/structure_*value, and drops the old single-valuedpdb_id/pdb_*/structure_*columns fromproteins.
Changed
ProteinDetailnow returns astructures[]list (ProteinStructureRead) and the new active-site fields instead of the removed singlepdb_*/structure_*fields; the protein detail, list, engineering, and export read paths eager-loadProtein.structures.- Enrichment and AlphaFold workers write structure metadata to
ProteinStructurerows (via the newapp/services/structures.upsert_protein_structurehelper) rather than the droppedProteincolumns. - Orphan cleanup (Task 4) and replace-mode supersede (Task 5) now also remove
protein_structuresrows explicitly so behaviour matches across PostgreSQL and the SQLite test tier. - The
microorganismsandproteinsadmin CRUD specs accept the new fields.
Version 0.13.0
Added
- Task 6 — experiment & substrate conditions. New nullable fields on
Experimentto make degradation claims interpretable and queryable:sole_carbon_source(bool),culture_medium(str),aerobicity(enumaerobic/anaerobic/microaerophilic/not_reported),substrate_crystallinity_pct(float),substrate_mw(float), andsubstrate_mw_unit(str). MeasurementTypecontrolled vocabulary (measurement_typeslookup table:id,name,category,default_unit) plus an optionalObservation.measurement_type_idFK (ON DELETE SET NULL). The free-textObservation.observation_typecolumn is retained for backfill; new writes that carry ameasurement_typeset the FK. Seeded withmass_loss_pct,mass_loss_rate,clear_zone_mm,co2_evolution,tensile_loss_pct, andcrystallinity_change(seeMEASUREMENT_TYPE_VOCABinapp/services/evidence_vocab.py).- Extractor support:
ExtractedExperimentandExtractedObservation(app/schemas/ingestions.py) accept the new fields with validation against the aerobicity and measurement-type vocabularies;_write_to_live_tables()persists them and resolves the measurement-type FK. - Migration 006 (
alembic/versions/006_experiment_substrate_conditions.py): adds the experiment condition columns, creates and seedsmeasurement_types, and adds theobservations.measurement_type_idFK.
Changed
- Experiment/observation read schemas (
ExperimentRead,ObservationRead, and the footprint variants) now surface the new condition fields and the resolvedmeasurement_typename. Detail endpoints eager-loadObservation.measurement_type. scripts/seed.py: seeds the new measurement-type vocabulary on a fresh database.
Version 0.12.0
Added
- Re-extraction / supersede workflow (
modeparameter onPOST /api/v1/ingestions/{id}/approve): passing"mode": "replace"deletes all prior evidence rows stamped with the previouspaper_ingestion_idvalues before writing the new extraction, then setssupersedes_idon the newPaperIngestiontomax(prior_ids). Orphaned microorganisms and proteins are automatically cleaned up. Apaper_supersededContributionEventis recorded summarising counts removed and the prior run IDs. ApproveModeenum inapp/schemas/ingestions.py(merge= default idempotent behaviour,replace= supersede mode).- Migration 005 (
alembic/versions/005_uuid_column_types.py): converts allVARCHAR(36)UUID columns to native PostgreSQLUUIDtype, fixing an asyncpg type-mismatch that caused "operator does not exist: character varying = uuid" on every authenticated endpoint. record_paper_superseded()inapp/services/contribution.py: writes apaper_supersededcontribution event with DOI, superseded IDs, and per-table removal counts.
Changed
scripts/seed.py: fixed stale"references"table references (renamed topapersin migration 004) that caused the seed script to crash on fresh databases.
Version 0.11.10
Added
- Orphan cleanup on paper deletion: Hard-deleting a paper now automatically removes microorganisms and proteins that no longer have evidence links to any remaining paper, keeping the database free of unreferenced entities.
Changed
- Admin delete system: The generic entity delete route now supports an optional
post_delete_hook, allowing entity types to run custom cleanup logic after deletion. Only the Paper entity uses this hook for now.
Version 0.11.7
Changed
- Paper entity: Renamed
Reference→Paperacross the entire codebase — models, API routes, schemas, templates, admin UI, and search vectors. Backward-compat aliases (ReferenceRead,reference_list_filters) and download routes (/references.csv,/references.json) preserved. - Provenance columns: Added
paper_idandpaper_ingestion_idto all evidence tables (Interaction, Experiment, Observation, EnzymeKinetics, EnzymeProperties, ProteinVariant, Evidence) and junction tables. Foreign key cascades configured for future hard-delete support. - Multiple ingestion runs: Dropped unique constraint on
PaperIngestion.doiand addedsupersedes_idto support re-extraction workflows.
Fixed
- Paper detail page showing "not found" for valid papers (template variable rename).
- Protein detail API crash when loading
sample_source(missing eager-load chain). - Schema validation errors on nullable
paper_idfields after migration. - Download routes now primary at
/papers.csvand/papers.json. - Search autocomplete crash on Paper entity type (dead code branch after rename).
- Engineering lineage endpoint crash (ORM
.reference→.paperattribute rename).
Version 0.11.9
Added
- Paper footprint API:
GET /api/v1/papers/{paper_id}/recordsreturns every record derived from a paper — interactions (with nested experiments and observations), kinetics, properties, microorganisms, proteins, and protein variants — plus aggregate counts. Curator-only endpoint. - Reviewer dashboard — paper footprint page: new page at
/dashboard/papers/{paper_id}that displays all records from a paper in collapsible sections with summary count cards, ingestion-run badges, and links to admin edit pages. Accessible from the admin papers table, ingestion review queue, and paper detail page. - Sample types and evidence types API:
GET /api/v1/submissions/sample-typesandGET /api/v1/submissions/evidence-typesreturn the controlled vocabulary lists used in the submission form dropdowns.
Fixed
- Lint issues in
tests/test_papers.py(import ordering, unused imports).
Version 0.9.1.0
Added
- Navbar grouped-dropdown redesign: Browse, Tools (mega two-column), Explore, and Community dropdowns replace the old flat navigation. Each group has icon-prefixed items and a visual header.
- Search sub-row: a persistent search bar with All / Organisms / Proteins / Papers filter chips appears below the main nav on every page. Typing triggers live autocomplete; selecting a chip filters the dropdown to that entity type. Pressing Search or Enter navigates to
/search?q=...&type=.... - Filter chips are now fully wired: clicking a chip both highlights it visually and scopes the live autocomplete results to the selected entity type.
Fixed
- Tools mega-dropdown was permanently expanded on all pages due to
display: gridoverriding Bootstrap 4'sdisplay: none. Fixed by only applyingdisplay: gridon.dropdown-menu.show. - Search dropdown URL construction now uses
parseInt(item.id)to harden against any non-integer ID returned from the autocomplete API. - Mobile body padding: two-row navbar caused 106 px top offset on all screen sizes; a
@media (max-width: 991.98px)rule drops it to 60 px when the navbar collapses to a single row. - Unit tests: Redis singleton event-loop teardown errors across test functions resolved by autouse monkeypatch that bypasses
cached_or_fetchin the test suite. - Engineering page nav-active assertion updated to match the new dropdown-item anchor structure.
Version 0.9.1.1
Changed (internal — no API changes)
- Split
app/services/search.pyintosearch_query.py(query-time:TsVector,apply_fts_search,ts_rank_expression) andsearch_index.py(index-maintenance:rebuild_*_search_vector,reindex_all,reindex_all_sync).search.pyis now a backward-compat re-export shim. - Extracted list-endpoint filter factories from
microorganisms.py,proteins.py, andreferences.pyinto a newapp/services/query.pymodule (microorganism_list_filters,protein_list_filters,reference_list_filters).downloads.pynow imports from the shared module too. - Extracted inline
ContributionEventwrites from five API handlers into a newapp/services/contribution.pymodule with typed helpers:record_annotation_created,record_annotation_reviewed,record_evidence_added,record_evidence_reviewed,record_submission_created,record_submission_reviewed. - Split monolithic
app/schemas/schemas.py(1 224 lines) into 13 domain-scoped files (common,auth,entities,plastics,references,microorganisms,proteins,jobs,submissions,curation,admin,pathways,snapshots).schemas.pyis now a backward-compat re-export hub — all existing imports continue to work unchanged. - Replaced
dispatch()if/elif chain inapp/workers/pipelines.pywith a_PIPELINESregistry dict. Engine is resolved fromapp.workers.engines.ENGINESbyjob.engineat dispatch time; adding a new job type or engine requires only a dict entry, no control-flow changes.
Version 0.9.1.2
Added
make seed: populates the dev database from inside the running api container — imports microorganisms, proteins, plastics, and references from the degraders TSV, fills plastic full names, loads all protein structures fromapp/static/structures.tar.gz, creates a default superuser (SEED_ADMIN_EMAIL/SEED_ADMIN_PASSWORDenv vars, defaulting toadmin@plasticdb.local/admin), and rebuilds the full-text search index.make db-reset: single command to nuke and rebuild the dev database (docker compose down -v+up --wait --build+ seed). Replaces the previous multi-step manual process.scripts/seed.py: the seeder script that orchestrates schema creation (viaBase.metadata.create_all+CREATE EXTENSION IF NOT EXISTS pg_trgm), all data imports, admin account creation, and search reindex.
Removed
- Seven historical one-time migration/backfill scripts that were only useful for patching an older live database and are no-ops on a fresh schema:
backfill_numbering_scheme.py,backfill_protein_plastic_confidence.py,backfill_result_polarity.py,backfill_structure_source.py,migrate_data.py,migrate_evalue_semantics.py,migrate_evidence_data.py.
Fixed
import_degraders.pyno longer triggers a full search-vector reindex mid-import; reindex now runs once at the end of the full seed sequence, after all data (including plastic full names and protein structures) is loaded.
Version 0.9.0.0
Added
- Enzyme-engineering comparison view at
/engineeringwith per-variant lineage, structured mutations table, kinetics/stability columns, and wild-type vs variant comparison; backed by/api/v1/engineering/proteinslist and/api/v1/engineering/proteins/{id}detail endpoints. - Wild-type kinetics and stability shown in the comparison table when parent-protein measurements are recorded (rows where
engineered_version_id IS NULL); full measurement set linked to the protein record. - Explicit unit columns (
km_unit,kcat_unit,vmax_unit) andsubstrate_formfield on enzyme kinetics, replacing the previous assumption of canonical units. - Evidence confidence levels (
strong,moderate,weak,contradictory) and a controlled evidence-method vocabulary onMicroorganismPlastic; displayed as badges with filtering on the microorganism search page. - Bulk-download streaming endpoints: full dataset as CSV/JSON/zip, all protein sequences as multi-FASTA, and filtered per-entity downloads; 13 endpoints total.
- PostgreSQL integration test tier (
make test-pg) using testcontainers, covering tsvector FTS, pg_trgm fuzzy matching, JSON containment, and download round-trips. - Multiple degradation pathways per plastic with a UI pathway selector.
- Integration tests for taxonomy group stats (
test_microorganism_stats.py) using real SQLite. - Unit tests for pathway graph construction (
test_graph.py). - Unit tests for enrichment worker with injected mock HTTP transport (
test_enrichment.py).
Changed
- Search vectors are now maintained automatically by PostgreSQL triggers, covering joined plastics/microorganisms and engineered-variant names and mutations — no manual reindex needed on data changes.
- Annotate-list matching cascade: exact match → prefix match → fuzzy match, with match-quality output per row.
- Taxonomy analytics for the statistics pages are now served from a dedicated
microorganism_statsservice, making that logic independently testable and reusable across exports. - Pathway graph construction (Cytoscape.js nodes and edges) extracted to a pure
build_pathway_graph()function inapp/services/graph. RedisCacheis now a proper class implementing theCacheBackendprotocol, matchingTTLCacheso both adapters are interchangeable in tests.- Enrichment worker uses typed
EnrichmentStatusconstants and accepts an injectable HTTP client, enabling unit testing without real network calls. - M2M plastic relationships on microorganism and protein records resolve automatically via ORM properties, removing boilerplate post-assignment code from route handlers.
- Domain constants (
ProteinSubmission.MATCH_IDENTITY_THRESHOLD) and ID generation logic (Protein.next_plasticdb_id()) now live on the owning model classes.
Fixed
- Slack 500 alerts are now throttled (300 s window) and never fire for ordinary 4xx responses.
- DIAMOND e-value and pident inputs are now validated (e-value: 1–200, pident: 0–100); out-of-range values are rejected with a 422 response.
- Annotate-list no longer crashes on ambiguous genus matches; ambiguous results are returned with a
match_quality=ambiguousflag instead.
Version 0.8.7.5
Fixed
- PET degradation pathway diagram showed duplicate arrows from PET to MHET (one per enzyme) instead of a single collapsed arrow with combined labels. Duplicate edges with the same source/target pair are now merged into one edge with comma-separated enzyme names.
- Pathway diagram spinner spun indefinitely alongside the rendered graph instead of being replaced when the graph loaded. The loading indicator is now cleared before Cytoscape renders.
- The plastic detail page no longer shows a red "Failed to load pathway: HTTP 404" error when no degradation pathway exists for a plastic. Instead, it displays a calm "No degradation pathway data is available" message and hides the unhelpful Full Screen link.
Version 0.8.7.4
Fixed
- Protein detail page FASTA download now uses the protein ID as the filename (e.g.
00001.fasta) instead of the browser defaultfasta.txt.
Version 0.8.7.3
Fixed
- Documentation page table of contents sidebar now scrolls with the page content instead of staying pinned at the top of the viewport.
Version 0.8.7.2
Fixed
- The preview list on the Build Phylogenetic Tree page did not update when selecting a plastic from the dropdown. The microorganism list API now accepts a
plastic_idquery parameter for exact ID matching, matching what the frontend was already sending.
Version 0.8.7.1
Changed
- The Build Phylogenetic Tree page and its API endpoint no longer require authentication. Anyone can now select microorganisms and generate a phylogenetic tree without logging in. The page uses the public landing template instead of the dashboard template.
Version 0.8.7.0
Fixed
- "View as Tree" on microorganism and protein search pages redirected to an empty tree builder form instead of pre-building the tree. When authenticated, the button now resolves matching microorganism IDs from the current filter set and builds the tree immediately, redirecting to the interactive tree view. Unauthenticated users are redirected to the build-tree page which auto-submits with the selected filters and auto-redirects to the tree on completion.
Version 0.8.6.0
Added
- Data migration script
scripts/populate_plastic_full_names.pyto populate thefull_namecolumn on plastics with known chemical/common names. Covers 71 plastics with 45 pre-filled names; remaining entries are left with blank placeholders for manual editing. Script is idempotent — safe to re-run.
Version 0.8.5.5
Removed
- Signal Peptide filter dropdown and table column removed from the proteins search page. The
has_signal_peptidequery parameter is no longer accepted by the proteins list and download API endpoints.
Version 0.8.5.4
Fixed
- Proteins search page "Download FASTA" button opened a new browser tab for every protein instead of downloading a single combined FASTA file. The download endpoint now accepts the same search filters as CSV/JSON exports and delivers one downloadable file with all matching sequences.
Version 0.8.5.3
Fixed
- Taxonomy lineage level filters (domain, phylum, class, order, family, genus) were incorrectly populated because the code used positional indexing to map lineage parts to taxonomic ranks. NCBI lineages include intermediate ranks like "kingdom" that shifted the mapping, causing "Fungi" to appear under phylum and all subsequent levels to be off. Lineage levels are now parsed by their explicit rank prefix (
rank:nameformat).
Version 0.8.5.2
Fixed
- Paper search page broke after the first search: clicking "Search" or changing sort/page after the initial load did nothing. The loading spinner element was destroyed on the first render, causing a JavaScript TypeError that silently killed all later searches. The page now recreates the spinner on every search call.
Version 0.8.5.1
Fixed
- Global search returns no results for partial search queries (e.g. "ideo" failed to match "Ideonella"). Single-word queries now use prefix matching via
to_tsquerywith the:*suffix. - Global search result ordering now prioritizes by entity type (plastics first, then proteins, microorganisms, references last) with
ts_rankas the secondary sort within each group.
Version 0.8.5.0
Added
- Enzyme kinetics table (
enzyme_kinetics) storing km, kcat, vmax with reference to protein, plastic, reference, and engineered version. Admin CRUD at/api/v1/admin/enzyme-kinetics. - Enzyme properties table (
enzyme_properties) storing optimal temperature, optimal pH, Tm, T50, half-life with reference to protein, reference, and engineered version. Admin CRUD at/api/v1/admin/enzyme-properties. - Protein detail endpoint now includes kinetics and properties arrays with related plastic, reference, and engineered version data.
Version 0.8.4.0
Added
- Bulk data download endpoints at
/api/v1/downloads/: full dataset as CSV/JSON/zip, all protein sequences as multi-FASTA, and filtered CSV/JSON downloads for each entity (microorganisms, proteins, plastics, references). 13 endpoints total, all public (no auth), rate-limited at 200/hour.
Changed
- Removed limit caps on list endpoints (proteins, microorganisms, plastics, references, search) so users can download complete datasets.
Fixed
- FTS ranking was dead in filtered download endpoints because an initial ORDER BY stacked before the FTS
ts_rankordering, making relevance scoring ineffective.
Version 0.8.3.0
Added
- Automatic 3D structure prediction for novel protein submissions via ESMFold API. When DIAMOND matching identifies a new enzyme (<99.9% identity), the worker dispatches an ESMFold prediction job and stores the resulting PDB structure on the Protein record.
Version 0.8.2.0
Changed
- Consolidated four shallow service modules (submissions, stats, references, rate_limit) into
admin_crud.pyandcore/rate_limit.py, reducing import fragmentation across 17 files - Declarative admin CRUD: replaced handwritten CRUD handlers for 9 entities with an
EntitySpecdataclass + route factory, cuttingadmin.pyfrom 702 to ~320 lines - Deduplicated 40+ lines of reindex SQL into a single
_REINDEX_SQLdict insearch.pywith a sharedreindex_all_sync()function consumed by both Celery tasks and the API endpoint - Replaced 11 manual
get_sync_db()/db.close()call sites across worker modules with adb_session()context manager insync_db.py
Added
CacheBackendprotocol inapp/services/cache.py— a seam for interchangeable in-memory (TTLCache) and Redis cache adaptersSequenceSearchEngineprotocol +DiamondEngineadapter inapp/workers/diamond.py— callers depend on the protocol, not the specific DIAMOND binaryaiosqlitereal-DB test fixtures (real_db,real_client) alongside existing mock fixtures intests/conftest.py
Version 0.8.1.0
Added
POST /api/v1/bioinformatics/search-sequence— synchronous single-sequence search against the PlasticDB protein database. Runs DIAMOND locally and returns all hits with protein names in the HTTP response, eliminating the job-submission + polling workflow for one-off sequence lookups.
Version 0.8.0.0
Added
- Full-text search with PostgreSQL tsvector + pg_trgm hybrid ranking across all entities (microorganisms, proteins, plastics, references)
GET /api/v1/search?q=...— cross-entity global search with relevance ranking, returns results from all four entity types in a single UNION queryGET /api/v1/search/autocomplete?q=...— top-5 matches per entity type for search bar dropdowns- Global search bar with live autocomplete dropdown in the navigation bar (all landing pages)
- Dedicated search results page (
/search) with results grouped by entity type and linked to detail pages GET /api/v1/microorganisms/search?q=...— autocomplete endpoint for microorganism picker- FTS query param (
q=) on list endpoints for microorganisms, proteins, plastics, and references — hybrid ranking viats_rank()+similarity() POST /api/v1/admin/reindex-search— admin endpoint to manually rebuild all search vectors- Daily scheduled reindex task (
reindex_search_vectors) via Celery Beat to keep search vectors consistent - Alembic migration adding
search_vectortext columns with GIN indexes to all four entity tables - Trigram GIN indexes on
namecolumns for fuzzy typo-tolerant matching - SQLAlchemy event listeners automatically rebuild search vectors on insert, update, and junction-table changes
Changed
- Microorganism, protein, and plastic list endpoints: FTS query param (
q=) takes precedence over default sort order when present
Version 0.7.1.0
Added
- Automated cross-reference enrichment for proteins: resolves UniProt metadata (accession, protein name, EC number, GO terms) from GenBank IDs, and PDB structural data (resolution, method, deposition date) from structure file names, UniProt cross-references, or sequence similarity
- Admin endpoints
POST /api/v1/admin/proteins/{id}/enrichandPOST /api/v1/admin/proteins/enrich-allfor on-demand enrichment - Scheduled weekly enrichment batch via Celery Beat (Sundays 04:23 UTC)
- Enrichment triggered automatically on protein create and update
- Per-protein enrichment error tracking stored in
enrichment_errorsJSON column - New Protein fields in list/detail responses:
uniprot_accession,pdb_id,ec_number,uniprot_protein_name,uniprot_go_terms,pdb_resolution,pdb_method,pdb_deposition_date,enrichment_errors
Version 0.7.0.0
Added
- Plastic chemical structure fields:
smiles(SMILES notation) andcas_number(CAS registry number) on plastics - Polymer class hierarchy via
parent_idself-referencing foreign key on plastics (e.g., polyester → PET) GET /api/v1/plastics— newcas_numberquery filter andsort_by=cas_numbersort optionGET /api/v1/plastics/{id}— response now includesparent_name,children(sub-plastics), andmonomers(building blocks)- Monomer composition via new
monomerstable andplastic_monomersM2M junction - Admin monomer CRUD:
GET/POST /api/v1/admin/monomers,PATCH/DELETE /api/v1/admin/monomers/{id} - Admin plastic create/update now accepts
parent_id,smiles,cas_number, andmonomer_ids
Changed
- Plastics list and detail responses include
parent_id,smiles,cas_number,parent_name,children, andmonomersfields - Admin plastic create/update handlers updated to manage monomer junction rows
Version 0.6.1.0
Added
- Redis read-through cache for protein list (
GET /api/v1/proteins), detail (GET /api/v1/proteins/{id}), and dropdown endpoints (isolation environments, sample types, evidence types) app/services/redis_cache.py— fail-open caching module withcached_or_fetch()andinvalidate_key()- SHA-256 hashed cache keys for list endpoints to handle varying query parameters
- Redis DB 1 for caching (Celery uses DB 0) via
cache_redis_urlconfig property
Changed
- Config: added
cache_ttl_short(300s),cache_ttl_medium(600s),cache_ttl_long(3600s) settings GET /api/v1/proteins— list response now served from cache with 5-minute TTLGET /api/v1/proteins/{id}— detail response now served from cache with 10-minute TTLGET /api/v1/submissions/isolation-environments,/sample-types,/evidence-types— cached with 1-hour TTL
Version 0.6.0.0
Added
- Database version snapshots for reproducible research with DOI citation
GET /api/v1/snapshotsandGET /api/v1/snapshots/latestpublic endpoints for snapshot discovery- Admin snapshot APIs:
GET/POST/DELETE /api/v1/admin/snapshotsfor ad-hoc and scheduled generation - Celery task
generate_snapshot_taskto export domain data as a gzipped multi-format tarball (CSV + JSON + FASTA + README) - Monthly scheduled snapshot trigger via Celery Beat (1st of each month at 03:05 UTC)
- Figshare API client (
app/services/figshare.py) for DOI deposition (requiresFIGSHARE_API_TOKEN) snapshotstable migration with release_label, status tracking, entity counts, checksums, and Figshare metadata/app/snapshotsDocker volume for persistent snapshot storage- Plastic degradation pathway data model with pathway compounds, reaction steps, and literature references
- Interactive pathway diagrams using Cytoscape.js on plastic detail pages and a dedicated full-screen pathway explorer
- Public API endpoints at
/api/v1/pathwaysfor listing, retrieving, and querying degradation pathways - Pathway graph endpoint returning Cytoscape.js-compatible JSON (nodes + edges) with enzyme labels and evidence
- Admin CRUD for pathway compounds, pathways, and reaction steps
- Seed data: canonical PET degradation pathway (PET → MHET → TPA + EG via PETase and MHETase)
- Community annotations: users can add notes and field-level suggestions on any database record (microorganism, protein, plastic, reference)
- Structured evidence claims with confidence scoring (strong/weak/contradictory) linked to records
- Curation review queue at
/curation/reviewfor curators to accept or reject community submissions - Curator role (is_curator field) — can review annotations, evidence, and submissions without full admin access
- Public contributor profiles at
/contributors/{user_id}showing contribution stats and timeline - ORCID OAuth integration: users can link/unlink ORCID iDs for verified researcher attribution
ContributionEventaudit trail tracking all curation activity (annotation_added, evidence_reviewed, etc.)- Data migration script to parse legacy semicolon-delimited evidence strings into structured Evidence rows
Changed
- Users table: added
is_curator,orcid,orcid_verified,orcid_access_tokencolumns - Config: added
ORCID_CLIENT_ID,ORCID_CLIENT_SECRET,ORCID_REDIRECT_URIsettings - Docker Compose: added
snapshotsvolume to api and worker services - Config: added
SNAPSHOTS_DIR,FIGSHARE_API_TOKEN,FIGSHARE_API_URL,FIGSHARE_ARTICLE_IDsettings - Curation sidebar section visible to curators and superusers in base dashboard template
Version 0.5.0.0
Added
- Phylogenetic tree generation from microorganism NCBI taxonomy IDs using ETE3
- Interactive tree viewer at
/interactive-treeusing Phylocanvas 2.x with plastic degradation metadata rings - Dedicated tree builder at
/tools/build-treewith filter-by-plastic, name search, and taxonomic rank filters - "View as Tree" quick buttons on microorganisms, plastics, and proteins listing pages
POST /api/v1/tree/buildandGET /api/v1/tree/{job_id}API endpointsapp/workers/tree_builder.py— ETE3-based taxonomy tree construction with Newick export- Celery Beat task to evict tree jobs older than 90 days (Mondays at 04:13 UTC)
ETE3_TAXONOMY_DBenv var support for shared taxonomy database on Manila RWX volume
Version 0.4.2.0
Added
- Taxonomy group pages at
/taxonomy/{rank}/{name}with aggregate statistics, Chart.js visualizations, and top organisms table GET /api/v1/microorganisms/taxonomy/{rank}/{name}endpoint returning group-level counts, top plastics, top environments, thermophilic distribution, and top microorganisms- Makefile with targets:
install,run,worker,test,lint,format,lint-fix,migrate,migration - Ruff linter/formatter configuration (
ruff.toml) targeting Python 3.12 with E/F/W/I/UP/B/SIM rule selects
Changed
- Microorganism detail page lineage display: badges replaced with a sortable Rank/Name table linking to taxonomy group pages
- Lineage worker now extracts the species level and filters out "no rank" entries from NCBI taxonomy data
- Entire codebase formatted with ruff (41 files) and linted clean (307 auto-fixes)
Fixed
- Lineage worker
db.commit()moved outside the organism loop (was committing after every row) - Taxonomy group page router parameter collision:
name=namerenamed togroup_nameto avoid conflict with_page()positional argument
Version 0.4.1.0
Added
- Dedicated reference detail page with associated microorganisms, proteins, and engineered versions
- Lineage-level dropdown filters on microorganism search page (domain, phylum, class, order, family, genus)
/api/v1/microorganisms/lineage-levelsendpoint returning distinct values per taxonomic rank- Paginated reference lists on microorganism, protein, and plastic detail pages
- Lineage breadcrumb navigation on microorganism detail page with clickable taxonomic levels
- Statistics sidebar on plastic detail page with proportional organism/protein progress bar
- Paginated microorganism and protein sub-tables on plastic detail page
Changed
- Renamed user-facing documentation route from
/docsto/documentationto avoid conflict with FastAPI Swagger - Landing page nav: Documentation and Dashboard links are now plain text matching other nav items
- DOI lookup in submission form now checks the database first before calling Crossref API
Fixed
- iCn3D 3D structure viewer on protein detail page now properly sized with download link
- Plastic detail endpoint rewritten with separate COUNT queries and paginated sub-entity queries
Version 0.4.0.0
Added
- Admin panel with full CRUD for managing users, microorganisms, proteins, plastics, references, engineered versions, submissions, and jobs
- Admin-only sidebar submenu in the dashboard with entity management links
- User-facing documentation page covering tools, REST API reference, and data submission best practices
- Documentation link in the landing page navigation
- Admin API endpoints: POST/PATCH/DELETE for plastics, microorganisms, proteins, references, engineered versions, and jobs
- Junction table cleanup on plastic deletion (MicroorganismPlastic, ProteinPlastic)
Changed
- Expanded admin API from 3 endpoints to 21 endpoints with full entity management
- Admin dashboard rebuilt with tabbed interface and modal-based CRUD forms
Version 0.3.1.0
Added
- Dropdown endpoints for microorganisms (
GET /microorganisms/names) and plastics (GET /plastics/names) returning[{id, name}]for form autocomplete - Isolation environment and sample type dropdown endpoints (
GET /submissions/isolation-environments,GET /submissions/sample-types) pulling distinct values from existing data - Sequence auto-fill endpoint (
POST /submissions/lookup-sequence) fetching amino acid sequences from GenBank, UniProt, or PlasticDB by accession ID - Sequence validation endpoint (
POST /submissions/validate-sequence) distinguishing amino acid from nucleotide sequences using Biopython with heuristic fallback - Per-plastic degradation evidence selection in the microorganism submission form
- Protein-to-microorganism and protein-to-plastic linking in submission forms via DB-populated dropdowns
- 3-value degradation indicator: whole microbe tested, only purified enzyme tested, or unknown
- In-memory cache with 1-hour TTL for external API sequence lookups
Changed
- Submission form template rewritten with dynamic dropdown loading, multi-select plastic checkboxes, per-plastic evidence sub-forms, sequence lookup with auto-fill, and inline validation feedback
degradation_extrapolatedin submission schema expanded from boolean to 3-value string for richer data capture- Empty submission validator blocks submissions with no microbes, proteins, or plastics
Fixed
- Nucleotide detection corrected: pure DNA sequences (ATGC-only) no longer classified as amino acid. Biopython's
translate()succeeding now immediately identifies the sequence as nucleotide.
Version 0.3.0.0
Added
- Plastic detail pages with linked microorganisms and proteins
- Crossref DOI lookup for auto-filling paper metadata in data submissions
- NCBI taxonomy ID lookup from organism name with scientific name confirmation
- Multi-record data submission form supporting multiple microbes, proteins, and plastics
- 3-step submission workflow: DOI lookup, multi-record entry, review and confirm
- Individual notes fields for each microbe, protein, and plastic record
Changed
- All entity references (microorganism, protein, plastic) now link to their detail pages
- Data submission form redesigned with DOI-first workflow and dynamic record addition
- Degradation extrapolation now set per microbe record instead of at the submission level, giving you finer control over each organism's data
- Signal peptide field removed from the submission form (still tracked at the protein record level, where it belongs)
Version 0.2.0.0
Added
- Admin dashboard with user management, submission review, and system stats (superuser only)
- Admin API endpoints with SuperUser authorization guard
- Enhanced user profile page with name update, password change, API key generation/revocation, and job/submission summaries
- Dedicated jobs listing page for authenticated users
- Logo and favicon from the original PlasticDB Flask application
- Dashboard stats now include plastics count, job statistics, and recent jobs panel
Version 0.1.0.0
Added
- FastAPI backend with async SQLAlchemy + PostgreSQL for all data APIs
- Jinja2 frontend with SB Admin 2 Bootstrap template
- Microorganisms search page with filters (name, plastic, tax ID, year, thermophilic, environment, location) and sortable columns
- Proteins search page with filters (name, PlasticDB ID, plastic, microorganism, year, signal peptide) and sortable columns
- Plastics search page with name filter, sortable columns, and organism/protein counts
- Paginated list APIs for microorganisms, proteins, and plastics with per-page size selector (10/20/50/100)
- Individual detail pages for microorganisms and proteins
- FASTA sequence download for individual proteins
- Landing page, about page, statistics, download, and contact pages
- Dashboard with KPI cards and tools grid
- User authentication (login, register, profile, API keys)
- Job submission and status tracking for bioinformatics tools
- Data submission workflow
Fixed
- FASTA download button now correctly uses per-protein FASTA endpoint