445 lines
17 KiB
Markdown
445 lines
17 KiB
Markdown
# 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.
|