> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kaireonai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# GitOps

> Export decisioning config as YAML, diff a posted bundle against the live database, and apply a reviewed bundle with dry-run and prune

The GitOps endpoints turn a tenant's decisioning configuration (offers, creatives, channels, decisioning gates, contact policies, decision flows, ranking profiles) into a reviewable YAML/JSON bundle and reconcile a posted bundle back into the database. Reconciliation matches resources by `metadata.name` rather than by id, so the same bundle promotes across `dev / stage / prod` where ids differ.

## What it does

Three routes share the same bundle shape:

* `GET  /api/v1/gitops/export` — dump the tenant's current state as YAML or JSON, optionally split into per-resource files.
* `POST /api/v1/gitops/diff` — compute the diff between a posted bundle and the live DB without mutating.
* `POST /api/v1/gitops/apply` — reconcile a posted bundle into the DB, dry-run by default.

A separate scheduled cron at `/api/v1/cron/gitops-drift-check` runs the diff against a stored snapshot and surfaces drift through the cron schedule configuration. See [Cron tier](/self-host/configure/cron-tier).

V1 reconciler scope — applied through to the database:

```
Category, Channel, Offer, DecisionFlow, RankingProfile
```

Other resource kinds — recognized and validated, but always reported as `unchanged`:

```
SubCategory, Creative, QualificationRule, ContactPolicy
```

Apply support for the second group is pending.

## Quick start

Round-trip a tenant through git:

```bash theme={null}
# 1. Export current state to YAML
curl https://playground.kaireonai.com/api/v1/gitops/export \
  -H "X-API-Key: krn_your_api_key" \
  -H "X-Tenant-Id: 5a9904b9-..." \
  -o tenant-bundle.yaml

# 2. Edit a file, commit to git, then preview the diff
curl -X POST https://playground.kaireonai.com/api/v1/gitops/diff \
  -H "Content-Type: application/yaml" \
  -H "X-API-Key: krn_your_api_key" \
  --data-binary @tenant-bundle.yaml

# 3. Apply for real (dryRun=false)
curl -X POST 'https://playground.kaireonai.com/api/v1/gitops/apply?dryRun=false' \
  -H "Content-Type: application/yaml" \
  -H "X-API-Key: krn_your_api_key" \
  --data-binary @tenant-bundle.yaml
```

## How it works

### Bundle format

A bundle is a `manifest` plus a flat `resources[]` array. Each resource follows a Kubernetes-like shape:

```yaml theme={null}
apiVersion: kaireonai.com/v1
kind: Offer
metadata:
  name: gold-card-application       # natural key for reconciliation
  annotations:
    kaireonai.com/version: "3"
    kaireonai.com/id: "..."         # optional DB id hint
spec:
  # entity-specific fields
```

The `apiVersion` value is always `kaireonai.com/v1`. Reconciliation matches by `metadata.name`, never by id, so the same bundle is promotable across environments.

<Note>
  The `Channel` resource `spec.status` accepts `draft`, `active`, `paused`, or
  `archived` (it defaults to `active` when omitted). `draft` is accepted because
  `Channel.status` defaults to `draft` in the database — without it, a tenant
  with any draft channel would export a bundle its own diff/apply endpoints then
  reject. Draft channels therefore round-trip cleanly through export → apply.
</Note>

### Authentication

Every route requires a tenant identifier on the request. The tenant binds via `X-API-Key` (preferred — also used as the rate-limit identifier) or `X-Tenant-Id`. Missing tenant returns 401; invalid tenant returns 403. The current implementation does not enforce a role gate beyond tenant binding — a viewer-tier API key can apply changes. Production deployments wire role enforcement via an upstream proxy or by extending the route with role-based authorization.

### Audit trail

Real applies (`dryRun=false`) write a single `gitops_apply` row to the audit trail summarizing `{ created, updated, unchanged, kinds }`. The audit write is best-effort — if it fails the apply still returns success.

### Diff-then-apply pattern

`POST /api/v1/gitops/diff` is the same code path as `POST /api/v1/gitops/apply?dryRun=true` — the dedicated diff endpoint runs the reconciler in preview mode and returns the same response shape. The dedicated diff endpoint is convenience — no body parsing differences, identical validation.

## Reference

### `POST /api/v1/gitops/apply`

Reconciles a posted bundle into the tenant's DB. Dry-run by default.

#### Body formats accepted

The route reads the raw request body and dispatches based on `Content-Type`:

* `Content-Type: application/yaml` (or any value containing `yaml`) — body is parsed as YAML.
* `Content-Type: application/json` with a top-level `yamlText` field — the field is unwrapped and YAML-parsed.
* `Content-Type: application/json` with a bare bundle JSON object — parsed as JSON.
* Any other content type — parsed first as JSON, then as YAML on JSON failure.

An empty body returns `400 Invalid bundle: Empty request body`.

#### Query parameters

<ParamField query="dryRun" type="string" default="true">
  When `"false"`, mutations are committed and an audit row is written. Any other value (including absent) is treated as a dry run.
</ParamField>

<ParamField query="prune" type="string" default="false">
  Reserved — when `true`, the reconciler deletes resources that exist in DB but not in the bundle. Accepted as a query parameter today but not enforced for every kind in V1.
</ParamField>

<ParamField query="kinds" type="string">
  Comma-separated kind filter — only the listed kinds are reconciled. Valid values:

  ```
  Category, SubCategory, Offer, Creative, Channel,
  QualificationRule, ContactPolicy, DecisionFlow, RankingProfile
  ```
</ParamField>

#### Response

Body shape:

```json theme={null}
{
  "dryRun": false,
  "diffs": [
    {
      "kind": "Offer",
      "name": "gold-card-application",
      "op": "update",
      "changedFields": ["priority", "businessValue"]
    },
    {
      "kind": "Channel",
      "name": "email",
      "op": "unchanged"
    }
  ],
  "summary": {
    "created": 1,
    "updated": 1,
    "deleted": 0,
    "unchanged": 14
  }
}
```

<ResponseField name="dryRun" type="boolean">
  Mirrors the `dryRun` query parameter — `true` for a preview, `false` when changes were committed.
</ResponseField>

<ResponseField name="diffs" type="array">
  Per-resource diff. Each entry is `{ kind, name, op, changedFields?, before?, after? }`. `op` is one of `"create" | "update" | "delete" | "unchanged"`.
</ResponseField>

<ResponseField name="summary" type="object">
  Counts across all diffs: `{ created, updated, deleted, unchanged }`.
</ResponseField>

#### Status codes

| Code | When                                                       |
| ---- | ---------------------------------------------------------- |
| 200  | Apply or dry-run succeeded                                 |
| 400  | Invalid bundle (parse error, empty body, schema violation) |
| 401  | Missing tenant context                                     |
| 403  | Invalid tenant identifier                                  |
| 500  | Reconciler raised — full message in `error.message`        |

***

### `POST /api/v1/gitops/diff`

Computes the diff against the live DB without mutating. Same body formats as `apply`.

#### Query parameters

<ParamField query="kinds" type="string">
  Same comma-separated filter as `apply`. Other apply-only flags (`dryRun`, `prune`) are not read by this route.
</ParamField>

#### Response

Same response shape as `apply` with `dryRun: true` always.

#### Status codes

| Code      | When                              |
| --------- | --------------------------------- |
| 200       | Diff computed                     |
| 400       | Invalid bundle                    |
| 401 / 403 | Missing or invalid tenant context |
| 500       | Reconciler raised                 |

***

### `GET /api/v1/gitops/export`

Dumps the tenant's current decisioning state as a bundle.

#### Query parameters

<ParamField query="format" type="string" default="yaml">
  `yaml` (default) or `json`.
</ParamField>

<ParamField query="layout" type="string" default="bundle">
  `bundle` (default) returns one document. `files` returns a map of relative path → file content, suitable for writing to disk for git review.
</ParamField>

#### Response headers

`bundle` layout sets `Content-Disposition: attachment; filename="kaireon-export-<tenantId>.<ext>"`. YAML responses use `Content-Type: application/yaml`.

#### Response body

Bundle layout (default) — a single YAML or JSON document with the bundle shape:

```yaml theme={null}
manifest:
  apiVersion: kaireonai.com/v1
  kind: Bundle
  tenant: 5a9904b9-...
  exportedAt: "2026-04-30T14:00:00.000Z"
  resourceCounts:
    Offer: 12
    Channel: 4
    DecisionFlow: 1
resources:
  - apiVersion: kaireonai.com/v1
    kind: Offer
    metadata:
      name: gold-card-application
    spec:
      priority: 80
      businessValue: 100
      ...
```

Files layout — JSON response with a `files` map plus the original `manifest`:

```json theme={null}
{
  "files": {
    "bundle.yaml": "...",
    "offers/gold-card-application.yaml": "...",
    "channels/email.yaml": "..."
  },
  "manifest": { "apiVersion": "kaireonai.com/v1", "kind": "Bundle", ... }
}
```

#### Status codes

| Code      | When                                            |
| --------- | ----------------------------------------------- |
| 200       | Export succeeded                                |
| 401 / 403 | Missing or invalid tenant context               |
| 500       | Export raised — full message in `error.message` |

### Required headers

| Header         | Required             | Purpose                                                                |
| -------------- | -------------------- | ---------------------------------------------------------------------- |
| `Content-Type` | Yes (apply / diff)   | `application/yaml`, `application/json`, or any value containing `yaml` |
| `X-API-Key`    | Yes (one of the two) | API key (`krn_…`)                                                      |
| `X-Tenant-Id`  | Yes (one of the two) | Direct tenant id                                                       |

## Configuration

### Drift detection

`/api/v1/cron/gitops-drift-check` runs the diff against a stored bundle snapshot on a schedule defined in your Helm values:

```yaml theme={null}
cron:
  schedules:
    gitopsDriftCheck:
      enabled: true
      schedule: "0 5 * * *"
      path: "/api/v1/cron/gitops-drift-check"
```

The cron does not run apply — it only surfaces drift through metrics and audit. See [Cron tier](/self-host/configure/cron-tier).

### File layout for git

Recommended directory structure when using `layout=files`:

```
tenant/
├── bundle.yaml
├── categories/
├── channels/
├── decision-flows/
├── offers/
└── ranking-profiles/
```

The exact filenames are returned by the export endpoint — operators do not need to invent the layout.

## Honest limits

* Reconciler scope in V1 covers five kinds: Category, Channel, Offer, DecisionFlow, and RankingProfile. The other recognized kinds (SubCategory, Creative, QualificationRule, ContactPolicy) parse and validate but always report as `unchanged` — apply support for those kinds is on the roadmap.
* The current routes enforce tenant binding but not a role gate. A viewer-tier API key can call apply with `dryRun=false`. Production deployments either wire role enforcement upstream or extend the route to require an admin or editor role.
* `prune=true` is accepted as a query parameter, but full prune-by-kind enforcement is not yet implemented for every kind. Treat prune as opt-in and verify behavior per kind before enabling.
* The audit-log write inside the apply path is best-effort and silenced on failure. The reconciliation itself still commits — if the audit row matters for compliance, monitor audit-log ingestion separately.
* An empty request body is rejected with `400 Invalid bundle: Empty request body`, but a body containing only `{}` will pass through to the YAML parser fallback and surface a less specific error. Senders should always include the full `manifest + resources` shape.

## Related

* [Cron tier](/self-host/configure/cron-tier) — runs the daily `/api/v1/cron/gitops-drift-check`.
* [Audit Logs](/api-reference/audit-logs) — `gitops_apply` action rows record every real apply.
* [Decision Flows](/api-reference/decision-flows) — one of the reconciler-supported resource kinds.
* [Offers](/api-reference/offers) — one of the reconciler-supported resource kinds.
