17 KiB
Schema workflow API (drafts / review / publish)
Maker-checker approval workflow для schema-level изменений.
Параллельная архитектура к record-level DraftController (см.
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).
Если 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_dictEXCLUDE 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, если разошлось → 409version_mismatch.
Endpoints
Создание / переходы
POST /api/v1/dictionaries/{name}/drafts
Создаёт draft от текущего HEAD schema. Status = DRAFT.
Request:
{
"fromVersion": "1.4.0",
"reason": "добавить optional поле altitude",
"proposedSchema": { "type": "object", "properties": { ... } }
}
Response 201 (см. Полный response shape ниже):
{
"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:
{
"reviewers": ["u-17", "u-92"], // optional — NULL = open pool (anyone with approve role)
"note": "проверьте границы altitude"
}
Response 202:
{ "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:
{
"decision": "APPROVE", // или "REQUEST_CHANGES" или "REJECT"
"comment": "OK по полю, но опечатка в описании"
}
Verdict mapping:
APPROVE→ statusAPPROVED(готов к publish)REQUEST_CHANGES→ statusCHANGES_REQUESTED(maker должен доработать)REJECT→ statusREJECTED(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:
{
"expectedHeadVersion": "1.4.0",
"publishNote": "v1.5.0 — добавил altitude"
}
Response 200:
{
"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_PUBLISHaction). - Outbox event:
SchemaDraftPublished. - WAL/Kafka: subscribers получают definition update в обычном порядке.
Errors:
- 409
invalid_transition— draft не вAPPROVED. - 409
version_mismatch—expectedHeadVersionstale (кто-то другой опубликовал свой 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:
{
"items": [ { "draftId": "...", "dictionaryName": "satellite", ... } ],
"page": 0,
"size": 50,
"totalElements": 7,
"totalPages": 1
}
Полный response shape
SchemaDraftResponse:
{
"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:
{
"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
GET /api/v1/dictionaries/{name}/changelog после MR !116 возвращает unified
timeline definition-level + workflow draft events. Каждая entry имеет:
{
"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»:
# 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= новый schemaaudit_logстроки: SCHEMA_DRAFT_CREATED, REVIEW_REQUESTED, DECIDED (APPROVE), PUBLISHED- Kafka outbox: 4 события
SchemaDraft* changelogendpoint показывает полную цепочку сkindfield- 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. Полная матрица — docs/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— полная матрица ролей + setup в Keycloak (включая compositeordinis:adminrole и mapper).DraftController(record drafts) — reference architecture pool model с теми же gates.