# Declarative configuration

A JSON manifest describes the desired state of part of an account's catalog and presentation. The API validates the manifest, calculates a signed plan with the differences from the current state, and applies that plan as an asynchronous operation, one step per resource. The manifest itself is never stored. Only the plan is kept, for 24 hours.

The flow has three steps, each with its own permission: validate the manifest, create a plan, and apply the plan. See the [Configuration reference](https://cademi.dev/api/reference/configuration.md) for the full schemas.

## Manifest (`cademi/configuration-manifest/v1`)

```json
{
  "$schema": "cademi/configuration-manifest/v1",
  "resources": [
    {"kind": "product", "ref": "course-a", "external_id": "erp-123", "attributes": {"name": "Course A", "status": "published"}},
    {"kind": "module", "ref": "mod-1", "attributes": {"name": "Module 1", "product": {"$ref": "course-a"}}},
    {"kind": "webhook", "ref": "erp", "id": "whk_01J8Z3...", "attributes": {"url": "https://erp.example/hook", "secret": "***"}},
    {"kind": "banner", "ref": "old", "id": "ban_01J8Z2...", "$delete": true}
  ],
  "order": {"showcases": ["shw_01J8Z1...", "shw_01J8Z0..."]}
}
```

- Supported `kind` values in this version: `showcase`, `product`, `module`, `lesson`, `banner`, `menu_item`, `settings` (all `/settings/*` groups), `webhook`, and `custom_code`. Any other value is rejected with `422 unsupported_resource`. The account itself, domains, replicas, imports, exports, event streams, users, and enrollments cannot be managed through a manifest.
- Identity: every item has a `ref`, a label that must be unique within the manifest. To target an existing resource, also send its public ID in `id`, or its `external_id` where the resource has that field (for example, `product`). An item without `id` or `external_id` is created.
- `attributes` accepts the same fields as the create and update operations of the resource. To reference another item in the same manifest, use `{"$ref": "<ref>"}`. References are resolved in dependency order (`showcase → product → module → lesson`); the other kinds are independent. A circular reference is reported with the `cyclic_reference` error code.
- Deletion is always explicit, with `"$delete": true`. Existing resources that are omitted from the manifest are left unchanged: nothing is pruned.
- Ordered sets (`order.showcases`, `order.products.<id>.content`, `order.menu_items`) must be complete. A partial set is rejected with `order_set_mismatch`.
- Secrets (`webhook.secret` and write-only `settings` fields) are accepted in the manifest but never returned. They appear as `"***"` in the plan.
- Limits: the request body can be up to 1 MiB, and the manifest can contain up to 500 items (`422 too_many_items`).

## Validation (`POST /configuration/validations`, `configuration.validate`)

Validation never writes anything and never queues work. It returns `200` even when the manifest has errors, so check the `valid` field:

```json
{
  "data": {
    "object": "validation",
    "valid": false,
    "errors": [{"path": "resources.1.attributes.product", "code": "reference_not_found", "message": "..."}],
    "warnings": [],
    "required_capabilities": ["products.create", "modules.create", "webhooks.update"],
    "unsupported": []
  }
}
```

Each item is checked against the manifest schema and against the same rules as the resource's create operation (for new items) or update operation (for existing items). References must exist and be accessible with the current credentials. `required_capabilities` lists the permissions that applying the manifest would require. The `Idempotency-Key` header is optional here and has no effect.

## Plan (`POST /configuration/plans`, `configuration.plan`)

This operation is synchronous and does not modify any resource. If the manifest is invalid, the API returns `422 validation_failed` with the same error codes as the validation operation, and no plan is created. Otherwise, the response contains the plan:

```json
{
  "data": {
    "object": "plan",
    "id": "pln_01J8Z4...",
    "summary": {"create": 2, "update": 1, "delete": 1, "noop": 0, "skip": 0},
    "steps": [
      {"ref": "course-a", "kind": "product", "action": "create", "id": null, "external_id": "erp-123", "revision": null, "attributes": {...}, "requires": ["products.create"], "changes": {"name": {"from": null, "to": "Course A"}}},
      {"ref": "erp", "kind": "webhook", "action": "update", "id": "whk_01J8Z3...", "revision": "2026-09-24T11:40:12.000000Z", "changes": {"secret": {"from": "***", "to": "***"}}, "requires": ["webhooks.update"]}
    ],
    "revisions": {"whk_01J8Z3...": "2026-09-24T11:40:12.000000Z", "ban_01J8Z2...": "2026-09-23T18:02:47.000000Z"},
    "expires_at": "2026-09-24T12:00:00+00:00",
    "signature": "hmac-sha256..."
  }
}
```

`action` is `create`, `update`, `delete`, `noop`, or `skip`. For skipped steps, `skip_reason` explains why, for example `replica_readonly` for a replicated resource, which is read-only. The plan is valid for 24 hours, as indicated by `expires_at`. It can only be applied once, and only with the same credentials that created it.

## Application (`POST /configuration/applications`, `configuration.apply`, `Idempotency-Key` required)

Send `{"plan_id": "pln_...", "signature": "...", "steps": [...]}`: the plan exactly as it was returned, with the real secret values in place of `"***"`. Replacing the masked values does not invalidate the signature. Before accepting the request, the API runs these checks in order:

| Response | When |
|---|---|
| `404 not_found` | The plan was not found or is not accessible with the current credentials. |
| `409 plan_expired` | The plan has expired or was already applied. |
| `422 plan_invalid` | The signature does not match, or a step was modified. |
| `409 plan_stale` | The `revision` of a resource has changed since the plan was calculated. Calculate a new plan. |

If every check passes, the API returns `202` with a `configuration.apply` operation, and the `Location` header points to it. The operation has one item per step, in dependency order, up to 500 items. Track it as described in [Asynchronous operations](https://cademi.dev/api/operations.md).

Each item is processed as follows:

- Permissions and resource access are checked again when the item runs. If the credentials lose a permission between planning and application, only the affected item fails.
- The item's `revision` is checked against the current state of the resource. A mismatch fails the item with `revision_mismatch`, and the item is not retried.
- The change produces the same result and emits the same public events as calling the resource's own operation.

Steps are not applied as a single transaction. If a step fails, the steps that were already applied remain in effect, and the operation ends as `partially_succeeded`. Only `failed` operations with recoverable items can be resumed with `POST /operations/{operation_id}/attempts`. To complete a partially applied configuration, calculate and apply a new plan.

When the operation finishes, the plan is marked as applied, and the internal event `configuration.applied` (`{plan_id, operation_id, summary}`) becomes visible to credentials with `audit.read`. Repeating the request with the same `Idempotency-Key` returns the same operation.

Expired plans are permanently removed 7 days after they expire.
