docs(integrator): v2.x.x update — workflow API + full changelog + schema extensions

This commit is contained in:
Александр Зимин
2026-05-12 00:26:37 +00:00
parent bc3373ccd8
commit d790a77313
7 changed files with 552 additions and 17 deletions
+420
View File
@@ -0,0 +1,420 @@
# 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.