# 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::`, 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.