# cBioPortal — Quickstart: 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.

```text
#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:

```bash
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:

```text
#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.

```text
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:

```bash
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

```bash
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

```json
{
  "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`.

```bash
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:

```json
{
  "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:

```json
[
  {
    "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:

```bash
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:

```json
{
  "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.
