cBioPortal logoQuickstart

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 ($TOKEN below). Import needs Admin on the project; audit needs only Viewer.
  • The study's staging folder with data_clinical_patient.txt and/or data_clinical_sample.txt, and optionally meta_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, patient and sample, materialization clinical.
  • Eight Elements, one per attribute, labeled with the attribute ID.
  • PATIENT_ID and SAMPLE_ID carry unique and not-null identity checks.
  • The sample file's PATIENT_ID is 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.txt ride 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.