# Apache Airflow — API: The Airflow Integration API, End to End: Every Route, Role, and Payload

Every vendor integration in CoreModels answers the same first question the same way. Ask the platform what it knows how to govern:

# The Airflow Integration API, End to End: Every Route, Role, and Payload

Every vendor integration in CoreModels answers the same first question the same way. Ask the platform what it knows how to govern:

```http
GET https://coremodels.example.com/graph/integrations/vendors
Authorization: Bearer $TOKEN
```

```json
{
  "success": true,
  "vendors": [
    {
      "key": "airflow",
      "displayName": "Apache Airflow",
      "capabilities": "Import, Audit",
      "artifacts": {
        "dags": "required - GET /api/v1/dags (the full response or just its 'dags' array)",
        "tasks": "optional - {\"<dag_id>\": <GET /api/v1/dags/{dag_id}/tasks response>} aggregated per DAG",
        "datasets": "optional - GET /api/v1/datasets (data-aware scheduling assets; becomes cross-DAG lineage)"
      }
    }
  ]
}
```

That one response (the `vendors` array lists every registered connector; abridged here to the Airflow entry) is the contract for this article: the vendor key is `airflow`, the artifact names and their sources are self-describing, and the capability string tells you up front that this connector imports and audits but does not generate. This piece walks the HTTP surface of that vendor's import-audit loop - both surfaces, every verb of the loop, real payloads, and the role each call requires.

## Two surfaces, one distinction

The Airflow integration lives on two HTTP surfaces:

- **Interactive surface** - routes under `graph/integrations/...`, authenticated with your normal CoreModels login token. This carries the import-audit loop's full verb set: import, audit, reaudit, history, badge, generate, status.
- **Machine-to-machine surface** - routes under `v1/...`, where user API keys work. This is what CI should call. It deliberately carries only two verbs: `audit` and `badge`. Reaudit and history live on the interactive surface only.

Roles are enforced per project on every route:

| Route | Role |
|---|---|
| `POST graph/integrations/airflow/import/{projectId}` | Admin |
| `POST graph/integrations/airflow/audit/{projectId}` | Viewer |
| `POST graph/integrations/airflow/reaudit/{projectId}` | Viewer |
| `GET graph/integrations/airflow/history/{projectId}` | Viewer |
| `GET graph/integrations/airflow/badge/{projectId}` | Viewer |
| `POST graph/integrations/airflow/generate/{projectId}` | Viewer |
| `GET graph/integrations/airflow/status/{projectId}` | Viewer |
| `POST v1/{projectId}/integrations/airflow/audit` | Viewer |
| `GET v1/{projectId}/integrations/airflow/badge` | Viewer |

The posture behind that table: import is the only verb that writes to the graph, and it writes additively - existing governed nodes are never mutated. Audit and generate never write anything; recording an audit run into the history is opt-in bookkeeping, and only the reaudit verb always records its run.

## Import

`POST graph/integrations/airflow/import/{projectId}` takes an artifacts request - artifact name to raw content, with the file contents passed as JSON strings:

```http
POST https://coremodels.example.com/graph/integrations/airflow/import/{PROJECT_ID}
Authorization: Bearer $TOKEN
Content-Type: application/json

{ "artifacts": { "dags": "<dags.json>", "tasks": "<tasks.json>", "datasets": "<datasets.json>" } }
```

The body supports two optional properties beyond `artifacts`: `spaces` (target space ids; empty means the project's main space) and `recordHistory` (audit only - meaningless on import). Send no artifacts at all and you get an explicit refusal: `Body must include 'artifacts': { "<name>": "<content>" } (e.g. manifest for dbt).` Misspell the vendor and the error names every registered key: `Unknown vendor '<v>'. Registered: ...`.

The response reports `datasetsAdded`, `datasetsSkippedExisting`, `fieldsAdded`, `lineageEdgesAdded`, `lineageEdgesSkipped`, `nodesEnriched`, and `snapshotStored`, plus `lossiness` (what was approximated - a success channel, not a failure) and `errors` (what could not proceed). For Airflow, "datasets" means DAGs and data-aware-scheduling assets, "fields" means tasks, and `snapshotStored: true` means the parsed deployment was persisted for later re-audits.

## Audit

Same body shape, Viewer role, strictly read-only:

```http
POST https://coremodels.example.com/graph/integrations/airflow/audit/{PROJECT_ID}
Authorization: Bearer $TOKEN
Content-Type: application/json

{ "artifacts": { "dags": "<dags.json>", "datasets": "<datasets.json>" }, "recordHistory": true }
```

The response is the full audit report (abridged here):

```json
{
  "success": true,
  "vendor": "airflow",
  "projectName": "airflow",
  "errorCount": 0,
  "warningCount": 1,
  "infoCount": 1,
  "codes": { "asset-unproduced": 1, "dag-no-description": 1 },
  "driftedObjects": [],
  "fingerprint": "4be09c11a2f37d80",
  "metrics": { "Datasets (estate)": "14", "Datasets governed": "14 / 14", "Fields governed": "48 / 48" },
  "findings": [
    { "section": "Conformance", "severity": "Warning", "code": "asset-unproduced",
      "subject": "s3://lake/clickstream.parquet",
      "message": "Asset is consumed by DAGs but produced by nothing in this deployment - an ungoverned upstream dependency.",
      "detail": null }
  ],
  "markdown": "…",
  "historyRecorded": true,
  "lossiness": []
}
```

`errorCount > 0` is the CI-gate fail signal. `codes` aggregates finding codes to counts, `driftedObjects` lists the vendor identities of drifted objects, `fingerprint` is a content hash of the audited artifacts (useful for correlating history entries), and `markdown` is the complete human-readable report. Findings fall into three sections - `Coverage`, `Drift`, `Conformance` - with severities `Error`, `Warning`, `Info`. With `recordHistory: true`, the run lands in the audit trail with trigger `audit`.

## Reaudit

The audit above asks "do these fresh artifacts still conform to the governed model?" Reaudit asks the reverse: "the governed model changed - does the last-known estate still conform?" It runs the same audit engine over the deployment snapshot stored at import time, so it needs no artifacts at all:

```http
POST https://coremodels.example.com/graph/integrations/airflow/reaudit/{PROJECT_ID}
Authorization: Bearer $TOKEN
Content-Type: application/json

{}
```

The optional body properties are `projectName` (the vendor-side project name to re-audit; `null` means the latest stored snapshot), `spaces`, and `notifySlack` (post a notification to the project's configured Slack webhook when the recorded run has errors or warnings; default false). The response shape is the same audit report, and the run is *always* recorded in the history with trigger `reaudit` - that is the one verb where recording is not opt-in. If the stored snapshot exceeded the ~1.5 MB storage cap at import time (`snapshotStored: false`), there is nothing to reaudit against; fresh-artifact audits still work.

## History

`GET graph/integrations/airflow/history/{projectId}` returns the rolling trail, newest first, grouped by vendor-side project:

```json
{
  "success": true,
  "vendor": "airflow",
  "projects": [
    {
      "projectName": "airflow",
      "runs": [
        { "at": "2026-07-29T06:41:12.4183920+00:00", "trigger": "reaudit", "errorCount": 1,
          "warningCount": 1, "infoCount": 0, "codes": { "dataset-removed": 1, "dag-no-owner": 1 },
          "fingerprint": "4be09c11a2f37d80" },
        { "at": "2026-07-28T18:02:55.0917744+00:00", "trigger": "audit", "errorCount": 0,
          "warningCount": 1, "infoCount": 1, "codes": { "asset-unproduced": 1, "dag-no-description": 1 },
          "fingerprint": "4be09c11a2f37d80" }
      ]
    }
  ]
}
```

Each run records when, what triggered it (`audit`, `ci`, `reaudit`, or `scheduled`), the severity counts, the per-code tallies, and the artifact fingerprint. Same fingerprint but different counts, as above, means the governed model moved, not the estate.

## Badge and status

`GET graph/integrations/airflow/badge/{projectId}` returns `image/svg+xml` - a shields-style badge rendered from the latest recorded run, labeled `airflow audit`: green when clean, yellow for warnings only, red for errors, gray when no runs are recorded. `GET graph/integrations/airflow/status/{projectId}` returns the last-import state:

```json
{ "success": true, "vendor": "airflow", "imported": true, "state": { "…": "…" }, "governedDatasets": 14 }
```

`state` carries the bookkeeping recorded at import (timestamps, fingerprint, counts, estate facts); `governedDatasets` counts vendor-identified datasets that resolve to a governed Type.

## Generate: an honest no

The controller exposes `POST graph/integrations/{vendor}/generate/{projectId}` for every vendor, but the Airflow connector's capabilities are Import and Audit only - so we should be equally plain: **there is no Generate section for Airflow beyond this paragraph.** A generate call against `airflow` returns `success: false` with an explicit error - "Connector 'airflow' does not support generation." - and nothing is silently emitted: the capability gate refuses before any emission code runs. A governed schema can legitimately generate warehouse DDL or dbt contracts; it cannot know your operator logic, and pretending otherwise would be generating fiction.

## The v1 surface for machines

CI and other automation should use the API-key surface. The audit is the same engine with the response wrapped in an `ApiResponse` envelope, so every field moves under `data`:

```http
POST https://coremodels.example.com/v1/{PROJECT_ID}/integrations/airflow/audit
Authorization: Bearer $TOKEN
Content-Type: application/json

{ "artifacts": { "dags": "<dags.json>" }, "recordHistory": true }
```

Gate on `data.errorCount`; render `data.markdown` into your job summary. Runs recorded from this surface carry trigger `ci`. And `GET v1/{PROJECT_ID}/integrations/airflow/badge` serves the same SVG badge on an API key, embeddable in READMEs.

That is the whole import-audit loop: seven interactive verbs plus discovery, two machine verbs, one honest refusal. The self-contained extraction recipe and CI walkthrough live in the Apache Airflow quickstart in the CoreModels integration docs.
