# Schema workflow API (drafts / review / publish)

Maker-checker approval workflow для schema-level изменений.
Параллельная архитектура к record-level `DraftController` (см.
[bitemporal.md](bitemporal.md)), но drafts содержат **полное proposed
JSON Schema** dictionary'а, а не отдельные записи.

Доступно с **v2.2.0**.

## Зачем

Schema изменения (добавить поле, поменять enum, поменять `x-references`)
часто требуют согласования: maker готовит изменение, reviewer одобряет,
publisher выпускает новую semver. Workflow API даёт state machine для
этого процесса с audit trail в `audit_log` + Kafka outbox events
(`SchemaDraft*`) + lock на concurrent drafts.

Альтернатива workflow: inline `PATCH /api/v1/dictionaries/{name}/schema`
для **non-breaking** изменений (см. [read-endpoints.md](read-endpoints.md)).
Если PATCH возвращает 422 `breaking_change_requires_draft` — переходи на
workflow.

## State machine

```
                      ┌──────────────────────────────────┐
                      ▼                                  │
              ┌──────────────┐                           │
   create  ──▶│    DRAFT     │──── withdraw ────┐        │
              └──────┬───────┘                  │        │
                     │ submit_review            │        │
                     ▼                          │        │
            ┌──────────────────┐                │        │
            │ REVIEW_PENDING   │── withdraw ────┤        │
            └──────┬───┬───┬───┘                │        │
              approve│   │reject                │        │
                    │   └─request_changes─┐    │        │
                    ▼                     ▼    ▼        │
              ┌──────────┐       ┌─────────────────┐    │
              │ APPROVED │       │CHANGES_REQUESTED│────┘
              └─────┬────┘       └───────┬─────────┘   re-submit
            publish │                    │
                    ▼                    ▼ withdraw
              ┌─────────────┐      ┌───────────┐
              │ PUBLISHED   │      │ WITHDRAWN │
              │ (terminal)  │      │ (terminal)│
              └─────────────┘      └───────────┘
                                 ┌───────────┐
                                 │ REJECTED  │ (terminal — от reject)
                                 └───────────┘
```

**Терминальные** статусы: `PUBLISHED`, `REJECTED`, `WITHDRAWN` — изменению
дальше не подлежат, но остаются в БД для audit trail.

**Invariants** (enforced на уровне Liquibase 0022 + service):
- Maximum один active draft per dictionary
  (`schema_drafts_one_active_per_dict` EXCLUDE constraint покрывает
  `draft|review_pending|changes_requested|approved`).
- Self-approve forbidden (`schema_drafts_no_self_approve`: `maker_id !=
  decision_user_id`).
- Optimistic version lock на create + publish — `fromVersion` /
  `expectedHeadVersion` сверяется с текущим `definition.schemaVersion`,
  если разошлось → 409 `version_mismatch`.

## Endpoints

### Создание / переходы

#### `POST /api/v1/dictionaries/{name}/drafts`

Создаёт draft от текущего HEAD schema. Status = `DRAFT`.

Request:
```json
{
  "fromVersion": "1.4.0",
  "reason": "добавить optional поле altitude",
  "proposedSchema": { "type": "object", "properties": { ... } }
}
```

Response 201 (см. [Полный response shape](#полный-response-shape) ниже):
```json
{
  "draftId": "8f3...",
  "dictionaryName": "satellite",
  "status": "draft",
  "branchedFromVersion": "1.4.0",
  ...
}
```

Errors:
- 409 `version_mismatch` — `fromVersion` не = current HEAD.
- 409 `active_draft_exists` — другой draft уже active на этом dict'е
  (терминальные не блокируют).

---

#### `POST /api/v1/dictionaries/{name}/drafts/{draftId}/review`

Maker submit'ит draft → status `REVIEW_PENDING`.
Работает из `DRAFT` (initial submit) и `CHANGES_REQUESTED` (re-submit
после доработки).

Request:
```json
{
  "reviewers": ["u-17", "u-92"],  // optional — NULL = open pool (anyone with approve role)
  "note": "проверьте границы altitude"
}
```

Response 202:
```json
{ "status": "review_pending", ... }
```

Errors:
- 403 `maker_only` — actor != maker.
- 409 `invalid_transition` — draft не в DRAFT или CHANGES_REQUESTED.

---

#### `POST /api/v1/dictionaries/{name}/drafts/{draftId}/decision`

Reviewer's decision.

Request:
```json
{
  "decision": "APPROVE",   // или "REQUEST_CHANGES" или "REJECT"
  "comment": "OK по полю, но опечатка в описании"
}
```

Verdict mapping:
- `APPROVE` → status `APPROVED` (готов к publish)
- `REQUEST_CHANGES` → status `CHANGES_REQUESTED` (maker должен доработать)
- `REJECT` → status `REJECTED` (terminal)

Response 200 — `SchemaDraftResponse` с updated status.

Errors:
- 403 `self_approve_forbidden` — `actor == maker`.
- 409 `invalid_transition` — draft не в `REVIEW_PENDING`.

---

#### `POST /api/v1/dictionaries/{name}/drafts/{draftId}/publish`

Publish approved draft → live HEAD. Bump schema version (minor default,
major на breaking changes — type change OR required removal в top-level
properties).

Request:
```json
{
  "expectedHeadVersion": "1.4.0",
  "publishNote": "v1.5.0 — добавил altitude"
}
```

Response 200:
```json
{
  "draft": { "status": "published", ... },
  "newVersion": "1.5.0",
  "publishedAt": "2026-05-12T22:00:00Z"
}
```

Side effects:
- `dictionary_definitions.schema_json` и `.schema_version` обновляются
  в одной SERIALIZABLE транзакции.
- Audit log: `SCHEMA_DRAFT_PUBLISHED` (`DRAFT_PUBLISH` action).
- Outbox event: `SchemaDraftPublished`.
- WAL/Kafka: subscribers получают definition update в обычном порядке.

Errors:
- 409 `invalid_transition` — draft не в `APPROVED`.
- 409 `version_mismatch` — `expectedHeadVersion` stale (кто-то другой
  опубликовал свой draft первым). Refetch + retry.

---

#### `DELETE /api/v1/dictionaries/{name}/drafts/{draftId}`

Maker отзывает свой draft. Status → `WITHDRAWN` (terminal).
Работает из любого non-terminal статуса.

Errors:
- 403 `maker_only` — actor != maker.
- 409 `invalid_transition` — draft уже в terminal статусе.

### Read-only

#### `GET /api/v1/dictionaries/{name}/drafts`

List всех drafts на dict (newest first). Включает terminal — для audit
trail UI.

#### `GET /api/v1/dictionaries/{name}/drafts/active`

Convenience: возвращает текущий **active** draft (любой non-terminal статус)
для UI workflow banner. **404 `no_active_draft`** если нет — это нормальный
сигнал, фронт показывает «no draft» состояние.

#### `GET /api/v1/dictionaries/{name}/drafts/{draftId}`

Single draft по id (drilling в reviewer queue).

#### `GET /api/v1/admin/schema-reviews/pending`

Global pool queue для approvers — все `review_pending` drafts across all
dicts, sorted oldest-first (fairness). Paginated:

```
?page=0&size=50
```

Response:
```json
{
  "items": [ { "draftId": "...", "dictionaryName": "satellite", ... } ],
  "page": 0,
  "size": 50,
  "totalElements": 7,
  "totalPages": 1
}
```

## Полный response shape

`SchemaDraftResponse`:

```json
{
  "draftId": "8f3a...",
  "dictionaryId": "5fe1...",
  "dictionaryName": "satellite",
  "status": "review_pending",                       // см. State machine
  "branchedFromVersion": "1.4.0",
  "proposedSchema": { ... },                        // full JSON Schema
  "reason": "добавить altitude",                    // optional
  "makerId": "u-42",
  "createdAt": "2026-05-12T20:00:00Z",
  "submittedAt": "2026-05-12T20:05:00Z",            // NULL до submitForReview
  "reviewers": ["u-17", "u-92"],                    // NULL = pool
  "reviewNote": "проверьте altitude",               // optional
  "decisionUserId": null,                           // set после decide
  "decisionAt": null,
  "decisionComment": null,
  "publishedAt": null,                              // set после publish
  "publishedVersion": null,
  "publishNote": null,
  "version": 1                                      // optimistic lock counter
}
```

## Outbox events

Каждый transition emit'ит event в Kafka outbox topic `definition.draft`
(routed через webhook subscribers как `dict.draft.*` category).

| Event type | Trigger | Payload extras |
|---|---|---|
| `SchemaDraftCreated` | POST /drafts | `fromVersion` |
| `SchemaDraftReviewRequested` | POST /review | `reviewers[]`, `note` |
| `SchemaDraftDecided` | POST /decision | `decision`, `reviewerId`, `comment` |
| `SchemaDraftPublished` | POST /publish | `newVersion`, `publishedAt`, `publishNote` |
| `SchemaDraftWithdrawn` | DELETE | (без extras) |

Envelope:
```json
{
  "eventId": "...",
  "occurredAt": "2026-05-12T20:00:00Z",
  "schemaVersion": "1.0",
  "bundle": "cuod",
  "dictionaryName": "satellite",
  "scope": "PUBLIC",
  "payload": { "draftId": "...", "dictionaryName": "...", "status": "...", ... }
}
```

## Audit log

Параллельно outbox'у каждое transition пишется в `audit_log` через
`AuditLogger.schemaDraftEvent`:

| `event_type` | `action` | `payload_before` | `payload_after` |
|---|---|---|---|
| `SCHEMA_DRAFT_CREATED` | `DRAFT_CREATE` | live schema | proposed schema |
| `SCHEMA_DRAFT_REVIEW_REQUESTED` | `DRAFT_REVIEW` | live schema | proposed schema |
| `SCHEMA_DRAFT_DECIDED` | `DRAFT_APPROVE` / `DRAFT_REJECT` / `DRAFT_CHANGES_REQUESTED` | live | proposed |
| `SCHEMA_DRAFT_PUBLISHED` | `DRAFT_PUBLISH` | previous live | new live (= proposed) |
| `SCHEMA_DRAFT_WITHDRAWN` | `DRAFT_WITHDRAW` | live | proposed |

`payload_before` / `payload_after` доступны через `GET /admin/audit` UI
для diff render'а в History timeline.

Также эти event_types включены в `GET /api/v1/dictionaries/{name}/changelog`
— см. [Full timeline в changelog](#full-timeline-в-changelog) ниже.

## Full timeline в changelog

`GET /api/v1/dictionaries/{name}/changelog` после MR !116 возвращает unified
timeline definition-level + workflow draft events. Каждая entry имеет:

```json
{
  "version": "1.5.0",                     // только для versioning events
  "publishedAt": "2026-05-12T20:23:00Z",
  "publishedBy": { "sub": "u-42", "name": "..." },
  "headers": null,                        // commit msg — не captured пока
  "diff": { "added": [...], "removed": [...], "changed": [...] },
  "recordsAffected": 0,
  "isCurrent": true,
  "kind": "draft_publish"                 // ← NEW field в MR !116
}
```

Поле `kind`:

| Value | Source event_type |
|---|---|
| `definition_create` | DEFINITION_CREATED |
| `definition_update` | DEFINITION_UPDATED (inline PATCH path) |
| `draft_create` | SCHEMA_DRAFT_CREATED |
| `draft_review` | SCHEMA_DRAFT_REVIEW_REQUESTED |
| `draft_approve` | SCHEMA_DRAFT_DECIDED + action=DRAFT_APPROVE |
| `draft_reject` | SCHEMA_DRAFT_DECIDED + action=DRAFT_REJECT |
| `draft_changes_requested` | SCHEMA_DRAFT_DECIDED + action=DRAFT_CHANGES_REQUESTED |
| `draft_publish` | SCHEMA_DRAFT_PUBLISHED ← versioning event |
| `draft_withdraw` | SCHEMA_DRAFT_WITHDRAWN |
| `other` | fallback (unknown event_type) |

**Versioning events** (`definition_create`, `definition_update`,
`draft_publish`) получают `version` label из `dictionary_definitions.schema_version`
для current HEAD, NULL для исторических entries (semver backfill TBD).

**Promotional events** (`draft_create`, `draft_review`, `draft_approve`,
`draft_reject`, `draft_changes_requested`, `draft_withdraw`) возвращаются
с `version=null` — фронт рендерит как timeline tick между публикациями.

## Пример workflow

Полный flow «изменить enum в `mission.status`»:

```bash
# 0. Получить current HEAD
curl /api/v1/dictionaries/mission
#  → schemaVersion: "1.4.0", schemaJson: { ... }

# 1. Создать draft с новым enum
curl -X POST /api/v1/dictionaries/mission/drafts -d '{
  "fromVersion": "1.4.0",
  "reason": "добавить status=deferred",
  "proposedSchema": { ... }
}'
#  → 201, draftId: "8f3a..."

# 2. Submit на review
curl -X POST /api/v1/dictionaries/mission/drafts/8f3a.../review -d '{
  "reviewers": ["u-17"],
  "note": "deferred — это inactive но не decommissioned"
}'
#  → 202, status: "review_pending"

# 3. Reviewer u-17 (другой actor) одобряет
curl -X POST /api/v1/dictionaries/mission/drafts/8f3a.../decision -d '{
  "decision": "APPROVE",
  "comment": "OK"
}'
#  → 200, status: "approved"

# 4. Publisher (любой actor != maker) publish'ит
curl -X POST /api/v1/dictionaries/mission/drafts/8f3a.../publish -d '{
  "expectedHeadVersion": "1.4.0",
  "publishNote": "v1.5.0 — status enum +deferred"
}'
#  → 200, newVersion: "1.5.0"
```

После publish:
- `dictionary_definitions.schema_version` = "1.5.0"
- `dictionary_definitions.schema_json` = новый schema
- `audit_log` строки: SCHEMA_DRAFT_CREATED, REVIEW_REQUESTED, DECIDED
  (APPROVE), PUBLISHED
- Kafka outbox: 4 события `SchemaDraft*`
- `changelog` endpoint показывает полную цепочку с `kind` field
- Webhook subscribers получают `dict.draft.*` notifications

## Error codes (full reference)

| HTTP | Code | Когда |
|---|---|---|
| 404 | `schema_draft_not_found` | id не существует / scope-hidden |
| 404 | `dictionary_not_found` | dict не существует / scope-hidden |
| 404 | `no_active_draft` | `/drafts/active` нет non-terminal draft'а |
| 409 | `active_draft_exists` | попытка create при существующем active |
| 409 | `version_mismatch` | optimistic lock на create или publish |
| 409 | `invalid_transition` | state machine violation |
| 403 | `self_approve_forbidden` | maker decision на own draft |
| 403 | `maker_only` | submit/withdraw от чужого actor'а |
| 422 | `breaking_change_requires_draft` | inline PATCH вернул это — переходи в workflow |

## RBAC

Phase 1d (2026-05-13) — backend enforced. Реализация в [`WorkflowRoles`](https://git.nstart.cloud/2-6/2-6-4/terravault/ordinis/-/blob/main/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRoles.java). Полная матрица — [`docs/operator/rbac.md`](../../operator/rbac.md).

| Действие | Workflow role |
|---|---|
| create / submit / update / withdraw schema draft | — (maker — любой authenticated с scope ACL) |
| decide (approve / request_changes / reject) | `ordinis:schema:reviewer` |
| publish (approved → live) | `ordinis:schema:publisher` |
| Super-role для всех workflow actions | `ordinis:admin` |

Naming convention — colon-separated `ordinis:<domain>:<action>`, consistent
со scope ACL (`ordinis:client:public/internal/restricted`).

**Phase 2 (2026-05-13) — per-dict минимальная роль ревьюера.** Поле
`approvalMinRole` на `DictionaryDefinition` (set'ится через
`DictionaryEditorDialog` или import CUOD-bundle манифеста). Если задано —
backend требует чтобы reviewer имел эту роль **дополнительно** к
стандартной `schema:reviewer` / `schema:publisher`. `ordinis:admin`
проходит автоматически. Use case: критичный справочник
(`approvalMinRole = "ordinis:restricted"`) — approve может только user с
restricted-scope role'ю.

**JWT claim path:** на staging/prod — `resource_access.ordinis.roles`
(client-scoped Keycloak roles), default fallback `realm_access.roles`.
Configurable через `ORDINIS_AUTH_ROLES_CLAIM`. `usePermissions.ts` на
frontend читает оба пути.

**Maker-checker invariant:** maker (creator) не может decide свой
собственный draft — 409 `self_approve_forbidden`. Backend rule, не
обходится через admin role.

См. также:
- [`docs/operator/rbac.md`](../../operator/rbac.md) — полная матрица ролей + setup в Keycloak (включая composite `ordinis:admin` role и mapper).
- `DraftController` (record drafts) — reference architecture pool model с теми же gates.
