Files
mdm-ordinis/docs/integration/api/schema-workflow.md
T

421 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (TBD)
В текущей реализации workflow доступен любому authenticated user'у через
network-level auth policy. Когда Keycloak roles claim прорастёт в JWT,
будут отдельные требования:
- `dict_schema_maker` — для create / submit / withdraw
- `dict_schema_reviewer` — для decision
- `dict_schema_publisher` — для publish
См. record-level `DraftController` для reference architecture pool model.