# Airbyte — Automation: A Drift Gate for Airbyte: CI, Badges, and the Heartbeat Between Runs

A dbt gate has an obvious trigger: someone opens a pull request, the project compiles, and the compiled artifact is the thing you audit. Airbyte has no build step. A catalog is *discovered*, not compiled, and the schema it describes belongs to a system nobody in the repository controls. So the first question for an Airbyte drift gate is not "which endpoint do I call" - it is "where does `catalog.json` come from, and when".

# A Drift Gate for Airbyte: CI, Badges, and the Heartbeat Between Runs

A dbt gate has an obvious trigger: someone opens a pull request, the project compiles, and the compiled artifact is the thing you audit. Airbyte has no build step. A catalog is *discovered*, not compiled, and the schema it describes belongs to a system nobody in the repository controls. So the first question for an Airbyte drift gate is not "which endpoint do I call" - it is "where does `catalog.json` come from, and when".

There are two good answers, and they gate different risks.

**Version-control the catalog.** Export the connection's configured catalog and commit it. A change to the catalog becomes a reviewable diff, and the gate runs on pull requests that touch it. This catches deliberate changes - enabling a stream, changing a sync mode, adding a field - before they merge.

**Re-discover on a schedule.** Run source discovery against a staging or read-only source in a scheduled job and audit the fresh result. This catches the changes nobody proposed: the upstream team that renamed a column last night.

Most teams want both. The pull-request gate protects intent; the scheduled job protects against surprise. Both call the same endpoint.

## The endpoint under the gate

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

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

This is the machine-to-machine surface, so `$TOKEN` is a revocable user API key rather than an interactive login - CI never needs a human session. Viewer access to the governing project is sufficient, because the audit never writes. The response is wrapped in the standard envelope, so every field lives under `data`:

```json
{
  "success": true,
  "error": null,
  "data": {
    "vendor": "airbyte",
    "projectName": "airbyte-connection",
    "errorCount": 0,
    "warningCount": 1,
    "infoCount": 2,
    "codes": { "stream-no-primary-key": 1, "no-cursor-field": 1, "untyped-fields": 1 },
    "driftedObjects": [],
    "fingerprint": "9f2c41ab7d0e5b83",
    "findings": [],
    "markdown": "# airbyte Schema Audit - airbyte-connection\n…",
    "historyRecorded": true,
    "lossiness": []
  }
}
```

Two integers do the work: `data.errorCount` is the gate, `data.warningCount` is the optional tightening. Everything else - `codes`, `driftedObjects`, `findings`, `markdown` - exists so a failed build can explain itself without anyone opening a browser.

## The workflow

```yaml
name: CoreModels Airbyte Schema Audit

on:
  pull_request:
    paths:
      - "airbyte/catalog.json"
  schedule:
    - cron: "17 6 * * 1-5"
  workflow_dispatch:

jobs:
  audit:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    env:
      COREMODELS_API_URL: ${{ vars.COREMODELS_API_URL }}
      COREMODELS_API_KEY: ${{ secrets.COREMODELS_API_KEY }}
      COREMODELS_PROJECT_ID: ${{ vars.COREMODELS_PROJECT_ID }}
      FAIL_ON: error
    steps:
      - uses: actions/checkout@v4

      - name: Audit the catalog against governed meaning
        run: |
          set -euo pipefail

          test -s airbyte/catalog.json
          jq -e '.streams | length > 0' airbyte/catalog.json > /dev/null

          jq -n --rawfile catalog airbyte/catalog.json \
            '{artifacts: {catalog: $catalog}, recordHistory: true}' > audit-request.json

          status=$(curl -sS -o audit-response.json -w "%{http_code}" -X POST \
            "$COREMODELS_API_URL/v1/$COREMODELS_PROJECT_ID/integrations/airbyte/audit" \
            -H "Authorization: Bearer $COREMODELS_API_KEY" \
            -H "Content-Type: application/json" \
            --data-binary @audit-request.json)

          if [ "$status" != "200" ]; then
            echo "::error::CoreModels audit call failed with HTTP $status"
            head -c 2000 audit-response.json || true
            exit 1
          fi

          if [ "$(jq -r '.success' audit-response.json)" != "true" ]; then
            echo "::error::Audit rejected: $(jq -r '.error.message // "unknown"' audit-response.json)"
            exit 1
          fi

          jq -r '.data.markdown' audit-response.json >> "$GITHUB_STEP_SUMMARY"

          jq -r '.data.findings[] | "\(.severity)|\(.code)|\(.subject)|\(.message)"' audit-response.json |
          while IFS='|' read -r severity code subject message; do
            case "$severity" in
              Error)   echo "::error title=$code::$subject - $message" ;;
              Warning) echo "::warning title=$code::$subject - $message" ;;
            esac
          done

          errors=$(jq -r '.data.errorCount' audit-response.json)
          warnings=$(jq -r '.data.warningCount' audit-response.json)
          drifted=$(jq -r '(.data.driftedObjects // []) | join(", ")' audit-response.json)
          echo "errors=$errors warnings=$warnings drifted=[$drifted]"

          test "$errors" -eq 0
          if [ "$FAIL_ON" = "warning" ]; then test "$warnings" -eq 0; fi

      - name: Keep the response for debugging
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coremodels-airbyte-audit
          path: audit-response.json
```

Three repository settings make it run: the variables `COREMODELS_API_URL` and `COREMODELS_PROJECT_ID`, and the secret `COREMODELS_API_KEY`.

One honest note on tooling. We ship a composite GitHub Action for this audit, and it is dbt-shaped: it reads a manifest path and posts the file under the artifact key `manifest`. Airbyte's connector requires the artifact key `catalog`, so that body comes back rejected with `The 'catalog' artifact ((Configured)AirbyteCatalog JSON) is required.` The `curl` and `jq` step above is the Airbyte equivalent, and it does the same four things: build the body, post it, publish the report, gate on the count.

## What `errorCount` actually counts for Airbyte

A gate people cannot predict is a gate people disable. Here is the full picture of which findings can fail an Airbyte build.

Error severity comes only from the **Drift** section - the comparison between the estate and what the graph already governs:

| Code | Fires when |
|---|---|
| `dataset-removed` | a governed stream no longer exists in the catalog |
| `field-removed` | a governed property no longer exists on its stream |
| `field-type-drift` | a property's native type changed since the last import |
| `enum-narrowed` | accepted values were removed while still governed as taxonomy terms |

**Coverage** findings never fail a build: `dataset-unmapped` is a Warning (a stream not yet under governance), `field-unmapped` is Info (a property added since the last import). **Conformance** for Airbyte is Warning and Info only - `stream-no-primary-key` (Warning), `no-cursor-field` (Info), `untyped-fields` (Info). `enum-widened` and `enum-constraint-removed` are Warnings.

The practical consequence: a green build means nothing governed was removed, retyped or narrowed. A brand-new stream appearing in the catalog will not fail the gate - it surfaces as a coverage warning telling you there is something new to govern.

If you want a specific hygiene rule to block a merge, gate on the code histogram rather than on severity:

```bash
# Fail when a stream with no primary key is proposed, even though the rule is a Warning.
jq -e '(.data.codes["stream-no-primary-key"] // 0) == 0' audit-response.json
```

The same shape expresses the opposite policy - accepting a known finding while a fix is in flight:

```bash
# Fail on errors, except a single field-type-drift that already has a ticket.
jq -e '.data.errorCount - (.data.codes["field-type-drift"] // 0) == 0' audit-response.json
```

## Recording runs, and reading the trail

`recordHistory: true` appends one compact record - timestamp, trigger, counts, code histogram, fingerprint - to the project's rolling audit trail. From the `v1` surface the trigger is `ci`; interactive audits record as `audit`, re-audits as `reaudit`, the server-side heartbeat as `scheduled`.

```bash
curl -sS "https://coremodels.example.com/graph/integrations/airbyte/history/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" \
  | jq -r '.projects[] | .projectName as $p
           | .runs[] | "\($p)\t\(.at)\t\(.trigger)\t\(.errorCount)E \(.warningCount)W\t\(.fingerprint)"'
```

```
airbyte-connection	2026-08-04T09:31:07.4410000+00:00	ci	0E 1W	9f2c41ab7d0e5b83
airbyte-connection	2026-08-03T05:00:11.9020000+00:00	scheduled	0E 1W	9f2c41ab7d0e5b83
airbyte-connection	2026-08-01T14:22:48.1770000+00:00	audit	1E 1W	4b7e0d19c6aa2f30
```

The trail is per vendor and estate, newest first, capped at the fifty most recent runs. The `fingerprint` column is the one to triage with: identical fingerprints mean the same catalog content was audited, so changed counts across two same-fingerprint runs mean the *governed model* moved, not the estate. Note that `history` and `reaudit` are on the interactive surface only - the API-key surface carries `audit` and `badge`.

## The badge

```
GET https://coremodels.example.com/v1/{PROJECT_ID}/integrations/airbyte/badge
Authorization: Bearer $TOKEN
```

The response is `image/svg+xml`: a shields-style badge labeled `airbyte audit`, rendered from the latest recorded run - green when clean, yellow when only warnings, red with the error count, gray before any run is recorded. Because the route is authenticated, embedding it somewhere whose fetcher cannot send a header will not work; the reliable pattern is to pull it in the job and publish it where your documentation lives:

```yaml
      - name: Refresh the audit badge
        if: always()
        run: |
          curl -sS -o docs/airbyte-audit.svg \
            "$COREMODELS_API_URL/v1/$COREMODELS_PROJECT_ID/integrations/airbyte/badge" \
            -H "Authorization: Bearer $COREMODELS_API_KEY"
```

## The other direction: re-audit

The CI gate answers "does this catalog still conform to the governed model?" There is a mirror question no pull request will ever ask: *the governed model just changed - does the last-known catalog still conform to it?*

```bash
curl -sS -X POST \
  "https://coremodels.example.com/graph/integrations/airbyte/reaudit/$PROJECT_ID" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{}' \
  | jq '{errorCount, warningCount, codes}'
```

Re-audit runs the identical audit engine over the estate snapshot stored at import time against the *current* governed model. No artifacts, no Airbyte call, no credentials. Its run is always recorded, with trigger `reaudit`. This is the call to make right after someone tightens a taxonomy or renames a governed element: it says immediately whether the change just invalidated a live ingestion path.

Two caveats. Re-audit needs a stored snapshot - import persists one, and reports `snapshotStored: false` with a lossiness record when the encoded snapshot exceeds the storage cap. And it is on the interactive surface, so a scheduled job calling it needs an interactive credential rather than an API key.

## The heartbeat

Which is why the server can do it instead. A recurring re-audit worker re-audits opted-in projects against their stored snapshots and appends each run to the same history with trigger `scheduled`. It is configuration-gated and off by default: an enable flag, an optional cron expression, and an explicit list of project ids. Projects opt in, nothing runs silently, and the worker skips any connector that does not declare the audit capability.

The result is one drift trail fed by three independent sources: pull requests (`ci`), people asking questions (`audit`, `reaudit`), and the heartbeat (`scheduled`). When someone asks "when did this start?", the answer is a row with a timestamp instead of a recollection.

For the catalog extraction commands that feed all of this, see the Airbyte quickstart in the CoreModels integration docs.
