Zero to first audit: putting a cBioPortal study's clinical schema under governance
The clinical schema of a cBioPortal study does not live in a database, a catalog, or an API. It lives in the first five lines of two tab-separated text files that a curator maintains by hand. Those five lines decide whether an attribute passes portal validation, what it is called in the UI, whether it is a number or a string, and how prominently it is displayed. They are also, in most study repositories, the least reviewed lines in the project.
Zero to first audit: putting a cBioPortal study's clinical schema under governance
The clinical schema of a cBioPortal study does not live in a database, a catalog, or an API. It lives in the first five lines of two tab-separated text files that a curator maintains by hand. Those five lines decide whether an attribute passes portal validation, what it is called in the UI, whether it is a number or a string, and how prominently it is displayed. They are also, in most study repositories, the least reviewed lines in the project.
CoreModels reads exactly those lines. This walkthrough goes from a study folder on disk to a
governed model plus a first drift audit, using nothing but curl, jq, and the staging files you
already curate. No portal credentials are involved at any point — CoreModels never connects to
cBioPortal.
What you need
- A CoreModels project to govern the study, and its 32-character hex project id.
- A token for the CoreModels API (
$TOKENbelow). Import needs Admin on the project; audit needs only Viewer. - The study's staging folder with
data_clinical_patient.txtand/ordata_clinical_sample.txt, and optionallymeta_study.txt.
Throughout, the API base is https://coremodels.example.com and the project id is
9d41c2b7e85f4a63b0d7c1e58f2a6b04.
1. The four header rows are the schema
A cBioPortal clinical staging file starts with four rows that begin with #, followed by the
attribute-ID row, followed by data. Read top to bottom: display names, descriptions, datatypes
(STRING, NUMBER, BOOLEAN), priorities, then the attribute IDs themselves.
#Patient Identifier Age Sex Overall Survival Status
#Unique patient identifier Age at diagnosis in years Sex at birth
#STRING NUMBER STRING STRING
#1 1 1 2
PATIENT_ID AGE SEX OS_STATUS
P-0001 62 Male LIVING
(Every gap above is a literal tab.) Note the description row: it stops after three entries, so
OS_STATUS has no description. Hold that thought — the audit finds it in step 7.
The connector parses the four # rows plus the attribute-ID row and ignores everything below.
That is not a limitation, it is the point: a schema-only upload is a legitimate input.
2. Cut schema-only artifacts
Because data rows are ignored, send only the header block. Five lines per file:
mkdir -p schema-only
head -5 data_clinical_patient.txt > schema-only/data_clinical_patient.txt
head -5 data_clinical_sample.txt > schema-only/data_clinical_sample.txt
cp meta_study.txt schema-only/meta_study.txt # optional
head -5 captures the four # rows plus the attribute-ID row. If your study has no sample file,
skip it — at least one clinical file is required, either one will do. The sample file follows the
same shape, and its PATIENT_ID column is the join back to the patient instrument:
#Patient Identifier Sample Identifier Cancer Type Tumor Purity
#Patient this sample belongs to Unique sample identifier OncoTree cancer type Estimated tumor purity percentage
#STRING STRING STRING NUMBER
#1 1 1 3
PATIENT_ID SAMPLE_ID cancerType TUMOR_PURITY
The optional meta_study.txt is read as plain key: value lines, and exactly three keys are used:
cancer_study_identifier (which becomes the vendor-side study name), name, and type_of_cancer.
Everything else in the file is ignored, safely.
type_of_cancer: brca
cancer_study_identifier: example_brca_2026
name: Example BRCA Cohort 2026
description: Clinical and genomic profiling of the example BRCA cohort.
3. Build the request body
The three artifact names are clinical_patient, clinical_sample, and meta. Their values are the
raw file contents as JSON strings. jq --rawfile does the escaping for you and keeps file contents
out of your shell history:
jq -n \
--rawfile patient schema-only/data_clinical_patient.txt \
--rawfile sample schema-only/data_clinical_sample.txt \
--rawfile meta schema-only/meta_study.txt \
'{artifacts: {clinical_patient: $patient, clinical_sample: $sample, meta: $meta}}' \
> import-request.json
4. Import
curl -sS -X POST \
"https://coremodels.example.com/graph/integrations/cbioportal/import/9d41c2b7e85f4a63b0d7c1e58f2a6b04" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @import-request.json | jq .
Import is additive. Governed nodes that already exist are never mutated or deleted by this call — if the study files have moved on, the audit reports it and a human applies the change. Re-running the same import is safe.
One note on surfaces before the response. The routes under graph/integrations/... used here are
the interactive ones, and they take your normal CoreModels login token. There is a second,
machine-to-machine surface under v1/... that accepts revocable user API keys and carries the two
verbs automation needs — audit and badge. Use the interactive surface while you are working by
hand, and switch to the API-key surface when you wire the same audit into a pipeline.
5. Read the response
{
"success": true,
"vendor": "cbioportal",
"projectName": "example_brca_2026",
"datasetsAdded": 2,
"datasetsSkippedExisting": 0,
"fieldsAdded": 0,
"lineageEdgesAdded": 0,
"lineageEdgesSkipped": 0,
"nodesEnriched": 10,
"snapshotStored": true,
"lossiness": [
{
"kind": "TypeApproximation",
"path": "patient.AGE",
"explanation": "Native type 'NUMBER' was approximated as Double; the exact native type is preserved in the vendor metadata mixin."
},
{
"kind": "TypeApproximation",
"path": "sample.TUMOR_PURITY",
"explanation": "Native type 'NUMBER' was approximated as Double; the exact native type is preserved in the vendor metadata mixin."
}
],
"errors": []
}
Four fields deserve a second look.
fieldsAdded is 0, and that is correct. It counts attributes added to instruments that were
already governed. On a first import both instruments are new, so they arrive complete through the
schema writer and nothing is added on top. Add a column to the sample file and re-import: then
fieldsAdded becomes 1 and datasetsSkippedExisting becomes 2.
nodesEnriched is 10: two instrument Types plus eight attributes, each of which now carries a
cBioPortal metadata record with the native datatype, the description row, the display name, the
priority, and the declared checks.
lossiness is a success channel, not an error list. cBioPortal's NUMBER covers integers and
decimals alike, so the governed model approximates it as a floating-point type while the literal
string NUMBER is kept in the vendor metadata, so later audits compare like with like. BOOLEAN
and STRING map exactly; an unrecognized datatype falls back to string and says so.
snapshotStored: true means the parsed study was persisted — that is what makes credential-free
re-audits possible later.
6. What is now in the graph
- Two Types,
patientandsample, materializationclinical. - Eight Elements, one per attribute, labeled with the attribute ID.
PATIENT_IDandSAMPLE_IDcarry unique and not-null identity checks.- The sample file's
PATIENT_IDis no longer a string. It is a governed reference to the patient instrument: the study's join now exists as an edge, not as a naming convention. - Study id, name, and cancer type from
meta_study.txtride on the integration state record.
7. The first audit
The audit compares fresh artifacts against the live governed graph. It runs at Viewer role and
writes nothing, except the single piece of bookkeeping you explicitly ask for with recordHistory.
jq -n \
--rawfile patient schema-only/data_clinical_patient.txt \
--rawfile sample schema-only/data_clinical_sample.txt \
'{artifacts: {clinical_patient: $patient, clinical_sample: $sample}, recordHistory: true}' \
> audit-request.json
curl -sS -X POST \
"https://coremodels.example.com/graph/integrations/cbioportal/audit/9d41c2b7e85f4a63b0d7c1e58f2a6b04" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data-binary @audit-request.json > audit-response.json
jq '{errorCount, warningCount, infoCount, codes, metrics}' audit-response.json
Immediately after an import nothing has drifted, so the interesting output is conformance:
{
"errorCount": 0,
"warningCount": 1,
"infoCount": 1,
"codes": {
"attribute-no-description": 1,
"attribute-id-not-upper": 1
},
"metrics": {
"Datasets (estate)": "2",
"Datasets governed": "2 / 2",
"Fields governed": "8 / 8",
"Governed nodes with canonical mappings": "0 / 10 (0%)",
"Last import": "2026-08-03T09:14:22.7431180+00:00"
}
}
And the findings themselves:
[
{
"section": "Conformance",
"severity": "Info",
"code": "attribute-no-description",
"subject": "patient",
"message": "1 clinical attribute(s) carry no description row — curators downstream will guess.",
"detail": "OS_STATUS"
},
{
"section": "Conformance",
"severity": "Warning",
"code": "attribute-id-not-upper",
"subject": "sample.cancerType",
"message": "Attribute IDs must be UPPER_CASE for cBioPortal validation to pass.",
"detail": null
}
]
Two real problems found without loading a single data row. cancerType will fail portal validation
because attribute IDs must be upper case. OS_STATUS has no description, so every downstream
curator gets to guess what it means. The same response carries markdown — a report with the
verdict, the metrics table, and collapsible sections, ready to paste into a pull request:
jq -r '.markdown' audit-response.json
Governed nodes with canonical mappings: 0 / 10 (0%) is the coverage number worth watching over
time. It counts governed nodes that also map to a standard other than cBioPortal — an ontology term,
for instance. Zero is the honest starting point for a fresh import.
8. Now make it fail on purpose
Edit the datatype row of the patient file so AGE reads STRING instead of NUMBER, then re-run
the audit. The conformance findings stay, and one more appears:
{
"section": "Drift",
"severity": "Error",
"code": "field-type-drift",
"subject": "patient.AGE",
"message": "Field type changed since the last import.",
"detail": "governed: NUMBER, estate: STRING"
}
errorCount is now 1. That single number is the whole contract for automation: anything above
zero means the study files have moved away from the meaning the project governs. Delete an attribute
from the file instead and you get field-removed, also at Error severity, also with the affected
identity listed in driftedObjects.
What you will not get is a false alarm for a file you simply did not send. Drift comparison is
scoped to the instruments present in the submission, so auditing clinical_patient alone against a
project that governs both instruments reports nothing about sample. Partial uploads are safe by
construction.
9. Where to go from here
Four more calls, same project, same vendor key:
POST /graph/integrations/cbioportal/reaudit/{projectId}with body{}— re-checks the stored study snapshot against the current governed model. No artifacts, no credentials.GET /graph/integrations/cbioportal/history/{projectId}— the rolling trail of recorded runs.GET /graph/integrations/cbioportal/badge/{projectId}— an SVG status badge of the latest run.POST /graph/integrations/cbioportal/generate/{projectId}— upload-ready staging-file header scaffolds, generated back out of the governed model.
The cBioPortal quickstart in the CoreModels integration docs covers each of these with full payloads.