---
title: Mongo-схема
description: Колекції products → tasks → creatives, дзеркало users із Clerk і версіонування документів.
---

Три пов'язані колекції: **products → tasks → creatives**. Каталог користувачів (`users`)
дзеркалить Clerk (синк через webhook). Enum-словники — джерело правди в `kumquat_core.enums`;
`$jsonSchema` в БД перевіряє лише структуру/типи, тож додати мову чи гео — це деплой, не міграція.
На кожному документі є `schema_version` для лінивих міграцій.



## PRODUCTS — колекція `products`

Фіксований набір із 4 продуктів; `_id` — це код продукту (він же вживається в неймінгу креативів),
тож зовнішній ключ на задачах/креативах читабельний і не потребує денормалізованої копії.

- `_id` (text) — код продукту, UNIQUE: PLT, CNI, RKI, SPD
- `name` (text) — повна назва: PlantIn, CoinIn, RockIn, SportDex
- `schema_version`, `created_at`, `updated_at`



## TASK — колекція `task__tasks`

Одна задача — одна одиниця виробництва, що групує свої креативи.

━━━ ОСНОВНІ ━━━
- `name` (text) — технічна назва картки
- `number` (number) — авто-номер задачі
- `generated_name` (text) — обчислювана назва задачі

━━━ ТИП ЗАДАЧІ / ТАКСОНОМІЯ ━━━
- `product_id` (→ products._id) — відношення до продукту
- `task_type`: NEW, VAR, LOC, WHT, NEWDES, VARDES, DES, CLN, VARAI, ADPT, RSZ
- `work_stream`: Performance, Product, Web, Brand, SMM, HR
- `media_type`: VID, STA, ANI, WEB, LND, ENDC
- `execution_complexity` (number 1–5) — складність виконання
- `creation_complexity` (number 1–5) — складність креативного задуму
- `priority` (number 1–3)

━━━ СТАТУСИ ━━━
- `status` (TaskStatus):
   – To-do: backlog, ready_for_production
   – In progress: in_progress, tech_check, creative_check
   – Complete: done, archived
- `test_status`: not_tested, in_test, tested, paused

━━━ КРЕАТИВНІ ПАРАМЕТРИ ━━━
- `count` (number) — к-сть Creative-карток
- `resolution` (без дефолту — задачу без нього прогон відхиляє, крім LOC/WHT/ADPT, які беруть розмір з оригіналу): 1080x1920, 1920x1080, 640x768, 800x800, 320x50, 1080x1350, 250x250, 300x250, 336x280, 200x200, 300x600, 1242x2688, 1080x1080, 600x800, 800x600
- `source` (дефолт META): META, TIK, APL, GGL, SNA, SKN, TAB, UNT
- `geo`: US, FR, DE, CA, UA, ES, PT, BG, TW, KR, GR, CL, ZA, NZ
- `language`: EN, FR, DE, PT, ES, UA, CA-FR, IT, UK, TR, PL, JA, HE, MX, BR, DA, NO, CH, SV, NL, BE, IE, AT, CS, RO, HI, BG, ZH, KO, EL
- `varied_elements` (multi): hook, cta, body_concept, background, hook_script, voice, music, persona
- `var_description` (text)

━━━ ВІДНОШЕННЯ ━━━
- `origin_creative_id` (→ creatives._id) — вихідний креатив (для VAR/VARDES)
- `source_creative_ids` (→ creatives._id[]) — вихідні креативи (для LOC/WHT/ADPT/RSZ)

━━━ ROLLUP / FORMULA ━━━ (`rollups`, кеші, оновлює sync/rollup-джоб)
- `rollups.derived_task_count` (count) — к-сть похідних креативів
- `rollups.creation_points`, `rollups.execution_points` (formula) — таблична залежність лише
  від власної складності (`creation_complexity`/`execution_complexity`): Level 1 → 0.5, Level 2 →
  1.0, Level 3 → 2.0, Level 4 → 3.0, Level 5 → 5.0. `null`, поки відповідна складність не
  заповнена
- `comment_count` (number, дефолт 0) — живі коментарі задачі; **не в `rollups`**, бо це єдине таке
  число, яке малює картка дошки, а `TaskCard` весь блок `rollups` відкидає. Тримає його актуальним
  сам запис коментаря (`$inc` разом із позначкою `__last_comment_id`), лагодить
  `kumquat-cli backfill-comment-counts` — див. [коментарі](/backend/comments-for-task)

━━━ ЛЮДИ ━━━ (→ users._id)
- `assignee_id` — виконавець
- `task_owner_id` — постановник

━━━ ДАТИ ━━━ (усі, крім `deadline_at`, — тільки [воркфлоу](/backend/workflow), по HTTP не виставити)
- `deadline_at` — бажана дата здачі; час — опційний, і `00:00` UTC означає «час не задано» (див. `kumquat_core.optional_time`)
- `started_at` — вхід у `in_progress`
- `submitted_at` — віддано на Tech/Creative Check
- `completed_at` — прийнято на `done`

━━━ ІНШЕ ━━━
- `is_legacy` (bool) — старий формат назви
- `manual_launch` (bool)
- `legacy_remap_list` (text)
- `drive_url` (url) — папка задачі

━━━ PRODUCT STACK / MARKETING FUNNEL ━━━ (заповнюються вручну при створенні брифу; free-text
поля — заглушки до появи власних колекцій; `subject`/`topic`/`audience` беруться зі словників
продукту, `Product.dictionaries`)
- `funnel` (text), `hypothesis` (text)
- `subject` (text), `topic` (text), `subtopics` (text[])
- `traffic_flow`: W2A, WEB
- `landing` (text — пізніше Relation → Landing DB)

━━━ VARIANTS (`variants`, list of `TaskVariant`) ━━━ — по одному запису на майбутній креатив,
для `NEW`/`NEWDES`/`VAR`/`VARDES`/`VARAI`. Це те, що раніше називалося Creative Stack і
заповнювалося один раз на всю задачу — тепер кожна майбутня варіація отримує власний бриф, і
`creative_factory._scratch_card` бере ці поля з `variants[n - 1]`, а не з полів задачі. `LOC`/
`WHT`/`ADPT`/`RSZ` не мають `variants` взагалі — їхня картка будується з оригінального креативу.
- `key` (text) — клієнтський ідентифікатор блоку, не показується в назві й ні на що не впливає
  на бекенді
- `variation_num` (int, 1..count) — яку картку заповнює цей блок
- `primary_anchor`: pain_led, desire_led
- `audience` (text — зі словника продукту)
- `awareness_stage`: unaware, problem_aware, solution_aware, product_aware, most_aware
- `messaging_angle`: pattern_interrupt, proof_driven, trust_driven
- `hook_trigger`: pattern_interrupt, identity_call_out, pain_agitation, curiosity_gap,
  social_proof_credibility, contrarian_myth_busting, aspiration_desire, urgency_stakes
- `hook_type`: 24 значення (бриф-девайс хука; не плутати з VLM `hook.format`/`hook.script`)
- `ad_format`: ~86 значень (формат подачі креативу)
- `notes` (text) — коментар креативного маркетолога
- `tag_slugs` (text[]) — довільні теги цієї варіації, зі спільного словника `creatives__tags`
  (див. нижче)
- `description` (text, BlockNote JSON) — ТЗ саме до цієї варіації, окремо від загального ТЗ задачі
  (`var_description`)

Задача, створена до появи `variants` (або будь-яка задача, для якої це поле лишили порожнім),
досі має старі скалярні поля `primary_anchor` .. `notes` прямо на задачі — вони не видалені,
`_scratch_card` читає їх як фолбек, коли `variants` порожній. Нове створення завжди пише в
`variants`; ці скалярні поля існують лише для сумісності зі старими документами.

━━━ CREATIVE TAGS — колекція `creatives__tags` ━━━ — довідник тегів, на які посилається
`variants[].tag_slugs`/`Creative.tag_slugs`. Будь-хто може завести новий тег прямо з поля Tags
(потрапляє як `proposed`); адмін підтверджує його (`approved`) або відхиляє (`archived`) зі
сторінки `/creative-tags`.
- `slug` (text, UNIQUE) — похідний від `label`, те, що зберігається в `tag_slugs`.
  Правило одне (`kumquat_creatives.tag_slug.normalize_tag_slug`, дзеркало —
  `frontend/src/modules/creative-tags/model/slug.ts`): lowercase, і кожна послідовність усього,
  що не літера й не цифра (пробіли, пунктуація, дефіси, підкреслення), стає одним `_`; кінці
  обрізаються; максимум 32 символи, з обрізанням по межі слова. Літери будь-якої мови
  лишаються, тож `«Зимова акція»` → `зимова_акція`. Тому `ugc-hook`, `UGC  hook` і `ugc_hook` —
  один тег, а не три. Нормалізує сервер (і в тілі, і в шляху), не клієнт
- `label` (text, UNIQUE без урахування регістру) — назва для показу. Унікальний, бо поле Tags
  пропонує саме лейбли й мапить обраний назад у slug: два однакових лейбли зробили б один із
  тегів невибираним. Лейбл мусить містити хоч одну літеру чи цифру, інакше 422 — з нього нічого
  було б вивести
- **Перейменування рухає `slug` разом із лейблом** (`TagsService.set_label`), бо slug — похідний,
  і розійтися вони не можуть. Тому це міні-міграція: переписуються `creatives.tag_slugs` і
  `task__tasks.variants[].tag_slugs` усіх носіїв. 409, якщо новий лейбл або похідний від нього
  slug уже чужі (два різних лейбли можуть давати один slug). Зміна лише регістру slug не рухає,
  тож нічого й не переписує. Атомарності немає (ні транзакцій, ні `arrayFilters` у тестах):
  посилання їдуть першими, тож збій на півдорозі лікується повторним тим самим запитом
- `status`: proposed, approved, archived
- `created_by` (→ users._id)

Підтвердження тега (зміна `status`) — це запис лише в `creatives__tags`: жоден креатив чи
варіація, що вже посилається на цей `slug`, не переписується. Це стосується саме статусу —
перейменування, навпаки, переписує (див. вище).

Адмін також може завести тег прямо зі сторінки `/creative-tags` — такий одразу `approved`, бо саме
додавання і є тим рішенням, на яке чекає `proposed` — і видалити тег назовсім. Видалення
дозволене лише поки жоден креатив не несе цей `slug` (інакше 409): схема фільтрів креативів
пропонує як `tag_slugs` саме цю колекцію, тож видалений тег зробив би кожен креатив із ним
незнаходжуваним. Тег, який уже використовували, виводять з обігу через `archived`.



## CREATIVE — колекція `creatives`

Один документ на один креатив-варіант.

━━━ ОСНОВНІ ━━━
- `name` (text, UNIQUE) — канонічна назва (джерело правди, будується скриптом)
- `product_id` (→ products._id) — rollup продукту з задачі
- `task_type`: NEW, VAR, LOC, WHT, NEWDES, VARDES, DES, CLN, VARAI, ADPT, RSZ
- `work_stream`: Performance, Product, Web, Brand, SMM, HR
- `media_type`: VID, STA, ANI, WEB, LND, ENDC
- `execution_complexity` (number 1–5) — складність виконання
- `creation_complexity` (number 1–5) — складність креативного задуму
- `variation_num` (number) — номер варіації в задачі
- `modifier`: HK1, HK2, HK3, UC, AI, RSIZ, CTA1, CTA2, PX
- `resolution`: 1080x1920, 1920x1080, 640x768, 800x800, 320x50, 1080x1350, 250x250, 300x250, 336x280, 200x200, 300x600, 1242x2688, 1080x1080, 600x800, 800x600
- `source`: META, TIK, APL, GGL, SNA, SKN, TAB, UNT
- `geo`: US, FR, DE, CA, UA, ES, BG, TW, KR, GR, CL, ZA, NZ
- `language`: EN, FR, DE, PT, ES, UA, CA-FR, IT, UK, TR, PL, JA, HE, MX, BR, DA, NO, CH, SV, NL, BE, IE, AT, CS, RO, HI, BG, ZH, KO, EL

━━━ СТАТУСИ ━━━
- `test_status`: not_tested, in_test, tested, paused
- `performance_tier`: promising, potential, pre_scale, scale, top_performer
- `flagged_for_review` (bool)
- `is_whitened` (bool) — потребує вайтлістінгу
- (робочий статус — rollup зі статусу задачі, резолвиться через `task_id`; `is_legacy` живе на задачі)

━━━ МЕТРИКИ ━━━ (`metrics` — останній снапшот; історії поки немає, див. `creative_metrics_ts` нижче)

Блок пише цілком **тільки** батч `kumquat-creative-metrics` (ClickHouse → Mongo),
атомарним `$set` на весь піддокумент. Усі поля nullable: документи, старші за
скоркард, мусять валідуватися, бо їх читає `kumquat-ch-sync`. Інваріанти, заміряне
покриття джерела й семантика для людей — README пакета `kumquat-creative-metrics`.

Легасі-скаляри (чинний контракт UI-карток, фільтрів і сортів) — кожен є проєкцією
вікна нижче, ставки завжди частки 0–1, не відсотки:
- `metrics.ctr` = `w.d7.ctr.v`, `metrics.hook_rate` = `w.d7.hook.v`, `metrics.hold_rate` = `w.d7.hold.v`
  — `vv15` під ними означає 15-секундний перегляд у картки з атрибуційного фіда і
  **6-секундний** у картки з `source = TIK` (TikTok 15-секундних не публікує), тож
  одне поле несе два трохи різних питання
- `metrics.spend` = `w.life.spend`, `metrics.purchases` = `w.life.purchases`
- `metrics.cpi`, `metrics.ipm` — від life
- `metrics.updated_at` = `computed_at`

Три вікна `metrics.w.{d7,t7,life}`, кожне однакової форми:
- лічильники: `imp`, `vv`, `vv15`, `clicks`, `spend`, `purchases`, `installs`
- ставки з Wilson-інтервалом: `hook`, `hold`, `ctr` — кожна `{v, lo, hi, hw, n}`, `v` може бути null
- `cpm`, `cpa` (`cpa` — від `purchases`, тієї самої колонки, що й сторінка аналітики)
- лише t7: `hook_delta`, `hold_delta` (проти попередніх 7 днів)
- лише life: `ltv`, `roas` (модельний прогноз, не факт), `trials`
- лише life **і лише TikTok**: `likes`, `comments`, `shares`, `follows`,
  `profile_visits` та власна атрибуція TikTok `tt_app_install`, `tt_registration`,
  `tt_purchase`, `tt_conversion`, `tt_skan_conversion`. У картки, якої звіт TikTok
  не бачив, усі вони `null`, а не `0`: нуль читався б як «поміряли й вийшло
  нічого». Префікс `tt_` навмисний — `tt_purchase` і `purchases` рахують ту саму
  подію різними правилами й не складаються

d7 прибите до старту креатива (порівнюване між креативами), t7 — до watermark
(для kill/scale), life — облік, не сигнал якості.

Оцінки й контекст:
- `metrics.status`: learning, mature, stale
- `metrics.confidence.{hook,hold,ctr}`: ok, thin, none
- `metrics.bench`: `{hook_med, hold_med, cohort_n, hook_index, hold_index}` — d7-ставки
  проти медіани 90-денної когорти свого продукту, 1.0 = медіана. Усе null, якщо
  `d7_truncated` або когорти нема
- `metrics.d7_truncated` (bool) — креатив уже крутився до початку імпортованої історії,
  тож його «перші 7 днів» не перші; з бенчмарку виключений. У TikTok-картки історій
  дві (фід і звіт TikTok) і прапорець ставиться за пізнішою з них
- `metrics.product` — продукт із найбільшими показами в CH; може розходитись із
  `product_id`, і саме за ним береться когорта
- `metrics.first_seen`, `last_seen`, `days_live`, `active_days`

Походження й свіжість:
- `metrics.defn_version` — версія контракту документа, `metrics.run_id`
- `metrics.computed_at` (коли крутився DAG) і `metrics.data_through` (останній день
  даних) — це **різні** речі; злиття їх ховає застряглий імпорт за свіжим таймстемпом
- `metrics.conv_provisional_from` — конверсії й ltv після цієї дати ще попливуть
- `metrics.media_sources` — скоуп run-у (`[]` = всі джерела)

━━━ VLM (`vlm`, nullable до аналізу) ━━━
- `vlm.analyzer_version`, `vlm.model`, `vlm.analyzed_at`, `vlm.notes`
- `vlm.rev` — фінгерпринт дайджеста кроку аналізу (як `media.rev`): охоронець
  проєкції від повторного запису, не аналітичний вміст

Майже кожен закритий словник має `other_unrecognized`: JSON-схема Gemini не
дозволяє значення поза списком, тож без цього виходу модель була б змушена
вгадувати найближче — і підрахунки «який елемент спрацював» тихо збирали б у
найпопулярніше значення все, що насправді туди не належить. Виняток —
`cast` (`no_person`) і `visual_style` (`mixed`), де прогалину вже покрито.
`null` означає інше: ознаки немає в цьому сегменті взагалі.

HOOK (`vlm.hook`):
- `format`: pov_selfie_talking, ugc_testimonial, person_reaction, green_screen_reaction, object_close_up, screen_record_demo, social_screen, text_animated, text_on_screen_static, before_after_reveal, unboxing_reveal, split_screen, vo_visuals, other_unrecognized
- `script`: question, fact, problem, claim, challenge, warning, teaser, instruction, fomo, story_anecdote, other_unrecognized
- `marketing_angles` (multi): pain_callout, curiosity_gap, bold_claim_stat, question_hook, social_proof_open, direct_address, other_unrecognized
- `visual_triggers` (multi): surprising_object_reveal, shock_claim_text, before_after_contrast, pattern_interrupt_motion, question_overlay, relatable_scenario, other_unrecognized
- `cast`: woman, man, couple, kid_family, no_person, multiple_people
- `camera_angle`: selfie_handheld_pov, static_tripod, macro_close_up, screen_recording, other_unrecognized
- `setting`: home_interior, outdoor_nature, store_shop, car, studio_plain_bg, screen_only, other_unrecognized
- `subject` (text — відкритий словник), `face_visible` (bool), `duration_sec` (number)
- `description`, `on_screen_text`, `pain_point`, `vo` (text)

BODY (`vlm.body`) — усі поля `null` для відео без окремого body-сегмента:
- `format`: demo_walkthrough, feature_carousel, testimonial_montage, before_after_result, educational_explainer, reviews_social_proof_screen, other_unrecognized
- `script`: problem_solution, how_it_works, facts_tips, benefits_list, before_after, tutorial_steps, social_proof, statistics, urgency, other_unrecognized
- `concept` (text — складена таксономія Scan:ID тощо)
- `marketing_beats` (multi): pain_problem, agitation, solution_intro, feature_benefit, rationale_credibility, social_proof, urgency_scarcity, cta, other_unrecognized
- `cast`, `camera_angle`, `setting` (ті самі словники, що й у hook), `face_visible` (bool)
- `description`, `vo` (text)
- `cta_type`: download_free, free_trial, limited_time_offer, direct_benefit_cta, other_unrecognized

CONTENT (`vlm.content`):
- `app_features_shown` (text[]), `proof_types` (text[])
- `solution_framing` (text), `primary_pain_point` (text)
- `emotional_driver`: curiosity, fear_of_missed_value, relief, pride_collection_completion, urgency, nostalgia, surprise_delight, other_unrecognized
- `visual_realism`: photorealistic_live_action, realistic_ai_generated, cartoon_2d, cgi_3d, stylized_flat_graphic, mixed_hybrid, other_unrecognized
- `visual_style`: ugc, screen_demo, motion_graphics, ai_video, text_based, mixed
- `pacing`: fast_cut, medium, slow_cinematic, other_unrecognized

━━━ ВІДНОШЕННЯ ━━━
- `task_id` (→ tasks._id) — обов'язково, 1 задача на креатив
- `origin_ref` ({`_id` → creatives._id, `name`}) — розширене посилання на оригінал (LOC/WHT/ADPT/RSZ)

━━━ PRODUCT STACK / MARKETING FUNNEL ━━━ (rollup з task; той самий набір полів і той самий
словник значень, що й у TASK вище)
- `funnel`, `hypothesis`, `subject`, `topic`, `subtopics`, `traffic_flow`, `landing`

━━━ CREATIVE STACK / TAGS / BRIEF ━━━ — rollup з відповідного `TaskVariant` (`task.variants
[variation_num - 1]`), не з полів задачі — кожна варіація копіює свій власний блок, а не всі
однаковий. Для задачі без `variants` — rollup зі старих скалярних полів задачі (той самий
фолбек, що й у `_scratch_card`).
- `primary_anchor`, `audience`, `awareness_stage`, `messaging_angle`, `hook_trigger`,
  `hook_type`, `ad_format`, `notes`
- `tag_slugs` (text[]) — з `TaskVariant.tag_slugs`; переживає перехід тега з `proposed` у
  `approved` без жодної зміни в цьому масиві
- `description` (text, BlockNote JSON) — з `TaskVariant.description`

━━━ ЛЮДИ ━━━ (→ users._id)
- `assignee_id`, `task_owner_id` (успадковуються із задачі)

━━━ ДАТИ ━━━
- `deadline_at`, `in_work_at`, `done_at`
- `launched_at` — коли test_status → in_test
- `ad_id` (text) — id кампанії в рекламному кабінеті, який назвала платформа разом із запуском
  (див. [Інтеграційний API](/backend/integration-api)); пишеться один раз, Kumquat його не
  генерує. Те саме поле є на паку (`tasks.ad_id`) — звіт по паку розкладає його на всі картки.

━━━ ІНШЕ ━━━
- `schema_version`, `created_at`, `updated_at`



## USERS — колекція `users`

Локальне дзеркало Clerk (синк через webhook `apply_webhook_event`); директорні читання йдуть звідси.

- `_id` (text) — Clerk user id (`user_...`), не ObjectId
- `email`, `name`, `image_url` (text)
- `role` (text)
- `groups` (text[]) — частини команди, до яких належить людина; одна людина може бути в
  кількох. Дзеркалиться з Clerk `publicMetadata.groups`, як і `role`: `upsert` замінює
  документ на кожному вебхуці, тож поле, записане лише в Mongo, зникає. Дошка capacity
  малює колонку кожному з груп, обраних у фільтрі `groups`; хто не в жодній групі —
  колонки не отримує ніколи
- `created_at`, `updated_at`



## creative_metrics_ts — time-series

> **Ще не існує.** Колекцію ніхто не створює і ніхто в неї не пише: скоркард
> (`kumquat-creative-metrics`) пише тільки `creatives.metrics` і
> `creatives__metrics_runs`, історія метрик поки не зберігається взагалі.
> Форма нижче — це задекларована модель `MetricsSnapshot`
> (`kumquat_creatives.models`), тобто план, а не факт.

Append-only снапшоти метрик (`timeField=ts`, `metaField=meta`); останній снапшот денормалізується
в `creatives.metrics`. Поля: `ts`, `meta` ({`creative_id`, `source`}), `ctr`, `cpi`, `ipm`,
`hook_rate`, `hold_rate`, `spend`, `purchases`.



## COMMENTS — колекція `comments`

Коментарі до задач. Узагальнена схема (`entity_type` + `entity_id`) замість `task_id`, щоб інші
сутності могли отримати коментарі без міграції; сьогодні підключено лише `task`.

- `entity_type` (text) — `task` (єдине значення поки що)
- `entity_id` (→ tasks._id)
- `author_id` (→ users._id, Clerk id — рядок `user_...`, **не** ObjectId)
- `body` (text) — зберігається нормалізованим: NFKC, без невидимих символів (категорії `Cc`/`Cf`/
  `Zl`/`Zp`, окрім `\t` і `\n`), прогони з 3+ переводів рядка стиснуті до одного порожнього рядка,
  краї обрізані. Максимум 2000 символів **після** нормалізації — NFKC вміє подовжувати рядок
  (`ﬁ` → `fi`), тож ліміт на вхідному запиті сам собою не гарантує розмір збереженого
- `mentioned_user_ids` (→ users._id[]) — Clerk id користувачів, передані фронтендом явно;
  звалідовані на існування, без автора
- `reactions` (map emoji → users._id[]) — `{"👍": ["user_a", "user_b"]}`. Ключі — закритий набір
  `ReactionEmoji` (30 емодзі), бо ключ тут це **ім'я поля**; емодзі без реакторів не лишається
  порожнім масивом, а видаляється з мапи
- `created_at`
- `updated_at` (nullable) — проставляється при редагуванні автором; `null` = ніколи не редагували
- `deleted_at` (nullable) — м'яке видалення; `null` = живий коментар. Документ лишається в
  колекції разом із `body`, але API віддає видалений коментар із порожніми `body`,
  `mentioned_user_ids` і `reactions` і не рахується в `task__tasks.comment_count`

Відповідей (тредів) немає: коментарі плоскі. `GET /tasks/{id}/comments` повертає хронологічний
список, включно з видаленими — сортування завжди за `created_at`, тож редагування не міняє порядок.



## AUDIT__LOGS — колекція `audit__logs`

Єдиний append-only журнал змін: хто, що і коли змінив. Пишеться декоратором `@audited` на методах
запису репозиторіїв (пакет `kumquat-audit`), а не викликами по бізнес-логіці.

- `at` (date) — коли
- `action` (text) — `task.created`, `task.status_changed`, `comment.mention_notified`, …
- `entity_type` / `entity_id` (text) — `entity_id` завжди рядок: типи id різні по колекціях
  (`ObjectId` у задач і креативів, код продукту в `products`, Clerk id у `users`, S3-ключ в `assets`)
- `actor_id` (→ users._id, Clerk id; null для системних дій), `actor_kind` — `user` / `system` /
  `webhook` / `cli`
- `source` — `api` / `worker` / `webhook` / `cli`; `request_id` — id HTTP-запиту або Celery-таски
- `changes` (object | null) — застосований `$set`; на рядку створення це повний документ, що і є
  якорем для відновлення стану. `null` там, де подія нічого локально не змінила (спроба надіслати
  DM) — згортка дельт такі рядки пропускає
- `metadata` (object | null) — решта аргументів методу; тільки BSON-скаляри

Дві колекції згорнуті сюди й більше не пишуться:

| Було | Стало |
|---|---|
| `task__transitions` | `action = "task.status_changed"`, `changes.status` = куди, `metadata.expected_status` = звідки |
| `mention_notification_logs` | `action = "comment.mention_notified"`, `metadata.recipient_id` + `metadata.status` (`sent` / `skipped_no_email`), `changes = null` |

`sent` означає "спробу зроблено", а не "Slack підтвердив доставку": `SlackNotifier` ковтає помилки
Slack API і лише логує warning.



## creatives__metrics_runs — стан між run-ами скоркарду

Один документ на run пакета `kumquat-creative-metrics`. Не історія метрик — саме стан,
потрібний наступному run-у: гард на обвал кількості рядків порівнюється з останнім
успішним не-dry run-ом, а модальна `defn_version` по колекції — це те, що перевіряє
`doctor`. Індекси: `finished_at` (спадний), `run_id`.

Поля: `run_id`, `defn_version`, `wm`, `dry_run` (bool), `media_sources`, `rows`,
`matched`, `modified`, `missing`, `match_ratio`, `swept`, `started_at`, `finished_at`,
`ok` (bool), `error` (лише коли `ok: false`). Dry-run-и не пишуться взагалі.

---

## Індекси і ліміт 64 на колекцію

MongoDB не дає колекції більше **64 індексів**, і `creatives` стоїть рівно на цій межі —
64/64, включно з `_id_`. Практичний наслідок: **нового індексу на `creatives` додати не можна**,
не прибравши інший. Специфікації живуть у `indexes.py` кожного feature-пакета, композуються в
`kumquat_api/indexes.py`.

Дві речі, які варто знати перед тим, як щось тут міняти.

**`ensure_indexes` тільки створює, ніколи не видаляє.** Перейменоване поле лишає свій індекс на
колекції назавжди, і той тихо тримає слот. Саме так це й зламалось: `complexity` колись
розділили на дві оцінки — одна sort-опція стала двома, тобто декларований набір виріс з 63 до
64, а забутий `complexity_1__id_1` зробив 65. Тому кожне перейменування поля, яке має індекс,
тягне за собою явний `dropIndex` поруч зі створенням (`_drop_legacy_unique_name` у
`kumquat-tasks`, `_drop_redundant_metrics_status_index` у `kumquat-creatives`) — щоб кожна база
лікувала себе сама, а не чекала ручного `dropIndex` в кожному середовищі. Такий дропер живе
рівно доти, доки всі середовища його не проковтнули; після цього його прибирають разом з
одноразовою міграцією, яка перейменувала поле.

**Помилка про ліміт вказує не на винного.** Mongo репортує `CannotCreateIndex` на тому індексі,
який трапився останнім у цикл створення, — у тому випадку це був `marketing_complexity`, який ні
до чого не причетний. Тому створення йде через `kumquat_core.db.indexes.create_indexes()`: на
коді 67 вона перечитує колекцію і каже, що насправді сталося — ліміт, скільки індексів є проти
скількох задекларовано, і перелік тих, яких у коді вже немає.

Стеля перевіряється тестом (`tests/core/test_db_indexes.py`), щоб набір не доріс до 64 мовчки.
Підняти число в тесті — це рішення: спершу варто прочитати `CREATIVE_INDEX_FIELDS` і зняти те,
чого жоден запит не потребує.
