cBioPortal logoProblem

Four Comment Rows Are the Schema of Your Study

Open `data_clinical_sample.txt` from any cBioPortal study and look at the top of the file. Four lines beginning with `#`, then a row of attribute IDs, then the data. Those four lines carry the display names, the human descriptions, the datatypes — `STRING`, `NUMBER`, `BOOLEAN` — and the priorities for every clinical attribute in the study. They are the study's complete declared clinical meaning.

Four Comment Rows Are the Schema of Your Study

Open data_clinical_sample.txt from any cBioPortal study and look at the top of the file. Four lines beginning with #, then a row of attribute IDs, then the data. Those four lines carry the display names, the human descriptions, the datatypes — STRING, NUMBER, BOOLEAN — and the priorities for every clinical attribute in the study. They are the study's complete declared clinical meaning.

They are also, structurally, comments. Nothing validates them until upload. Nothing diffs them between study versions. Nothing connects them to the analysis code, the manuscript methods, or the next study your group curates. The most consequential schema in a cancer-genomics project lives in the one place every tool that reads the file has been taught to skip.

That is the gap the CoreModels cBioPortal connector (vendor key cbioportal) closes — here is what it looks like from the inside.

The rename that nothing objected to

A curator tightens a label. AGE becomes AGE_AT_DIAGNOSIS, because two reviewers asked what the age was measured at and they were right to ask. The file is regenerated, revalidated, re-uploaded, and the portal is happy: a legal attribute ID in a legal file.

Downstream, a notebook that filtered on AGE now filters on nothing. A cohort definition in a sibling study still says AGE. A dictionary on a shared drive still says AGE, and will for two more years. No system recorded that an identity moved, because no system was holding the old one.

The type that widened on a Friday

An attribute typed STRING in version one becomes NUMBER in version two, because someone normalized "not reported" out of the column. Usually an improvement. Also a change to what every consumer may assume, arriving with no announcement, no diff, and no reviewer.

Run it the other way and it is worse. A NUMBER becomes a STRING because one row carried a range instead of a value. Nothing errors. Aggregations downstream quietly start returning less, and the first person to notice is whoever cannot reproduce a result.

The validator as first reviewer

cBioPortal's validation rules are real and enforced late. Attribute IDs have to be UPPER_CASE; one Age_at_dx in the attribute row is enough to fail the upload. Learning that after a long curation cycle, against a freeze date, is the most expensive place to learn it.

Missing descriptions work the same way, only quieter. The description row is optional in practice, so it gets skipped under time pressure, and the cost lands on an analyst six months later deciding what OS_STATUS counts as an event. Nobody sets out to ship an undocumented attribute. There is just no moment in the process where the omission becomes a finding rather than an absence.

The dictionary curators are free to ignore

Zoom out from one study and the same gap compounds. A public audit of 375 cBioPortal studies found the same clinical concept encoded dozens of different ways from study to study — radiation therapy alone appeared under 24 distinct encodings. That is not carelessness; it is what happens when every study declares its own schema in four comment rows and nothing holds the declarations together. MSK maintains a Clinical Data Dictionary of preferred attribute definitions, but it is explicitly advisory — the file-format documentation says curators can choose to ignore the proposed definitions — and it offers no authoring or enforcement path for anyone else's institution.

The validator cannot close this gap, because it was never asked to. validateData.py checks one study at a time, strictly and authoritatively, and never looks across two. Whether this study's RADIATION_THERAPY means what the last study's RAD_TX meant is a question no tool in the upload pipeline is positioned to answer. Every check runs inside the study boundary; the chaos lives across it.

The join that nobody wrote down

Every cBioPortal study has one structural relationship at its heart: the sample file's PATIENT_ID points back at the patient file. It is the reason a sample-level result can be rolled up to a person, and it is a foreign key in everything but declaration. The format has nowhere to say so, so the knowledge lives in the heads of people who have curated three studies, and in whatever code wrote the join last.

PATIENT_ID and SAMPLE_ID are identities too: unique, never null. Also undeclared. Load a study into any downstream system and you re-derive facts that were perfectly well known and never written anywhere a machine could read them.

The credentials problem underneath all of it

The obvious answer to "nothing is watching the schema" is "connect a tool to the portal." In this domain that answer usually dies in the first meeting: the person curating a study is rarely the person who administers the instance, and institutional portals sit beside patient-derived data. Handing a third-party service live credentials there is a non-starter — and should be.

So governance here has to work on artifacts and hold nothing. CoreModels never holds portal credentials; a live portal connection is a declared-but-deferred capability rather than something we quietly do anyway. The inputs are the staging files you already have open, and because only the four # header rows and the attribute-ID row are read, a schema-only upload with the data rows stripped is a complete input. An optional meta_study.txt adds the study id, name, and cancer type.

What the absence actually costs

Line the scenarios up and the pattern is obvious. None of these failures is dramatic — no outage, no red build, no page. Each is a small, silent divergence between what the file says and what everyone believes it says, and those compound, because the only correction mechanism is a person remembering. The costs land in four places:

  • Re-derivation. Every new consumer re-reads the header rows and re-decides what they mean, because the last person's conclusion was never stored.
  • Late failure. Conformance problems surface at upload, in the least recoverable part of the cycle, instead of at edit time where the fix is one line.
  • No evidence. "Did this attribute always mean that?" is answered by file archaeology, because nothing kept a trail of what changed when.
  • Person-shaped risk. The study's semantics are load-bearing on one or two people's memory — which holds until a rotation, a grant end, or a handover.

Treating the header rows as what they are

The fix is not a new file format and not a portal integration. It is refusing to treat four lines of declared meaning as decoration.

CoreModels reads the staging files you already curate and builds a governed model from them. Each clinical file becomes a governed Type. Each attribute becomes a governed Element that keeps its description and datatype, with its display name and priority as metadata. PATIENT_ID and SAMPLE_ID become declared unique, not-null identities. The sample file's PATIENT_ID becomes a governed reference to the patient instrument — the study's join, finally written somewhere a machine can read it.

Each new version of the study can then be audited against that model: coverage, drift in fields and types and allowed values, and cBioPortal-specific conformance rules — attribute-id-not-upper, the UPPER_CASE rule that would otherwise fail at the portal, and attribute-no-description, an aggregate count per instrument of attributes that shipped with an empty description row.

None of that requires a credential, a portal connection, or a change to how you curate. It requires one decision: that the schema at the top of the file is a schema.

The cBioPortal quickstart in the CoreModels integration docs walks the extraction, import, and audit loop end to end.