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

16 KiB
Raw Blame History

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_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:

{
  "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_mismatchfromVersion не = 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 → status APPROVED (готов к publish)
  • REQUEST_CHANGES → status CHANGES_REQUESTED (maker должен доработать)
  • REJECT → status REJECTED (terminal)

Response 200 — SchemaDraftResponse с updated status.

Errors:

  • 403 self_approve_forbiddenactor == 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_PUBLISH action).
  • Outbox event: SchemaDraftPublished.
  • WAL/Kafka: subscribers получают definition update в обычном порядке.

Errors:

  • 409 invalid_transition — draft не в APPROVED.
  • 409 version_mismatchexpectedHeadVersion 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:

{
  "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 = новый 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.