textmachine/docs/architecture/14-api-contract
2026-08-13 21:50:51 +03:00
..
openapi.yaml Accept S3.7 as D39.129: canon contract follows 0.2.2, arbitration overturns the S3.6 high finding as false positive, PT-36 mechanism built, S4 prompt gated on P5 acceptance 2026-08-13 21:50:51 +03:00
README.md Land docs pack 167 as D39.126: full note registry with counts completeness check and anchors lint, slimmed backlog rows, topical entries for money, emitter, and stack 2026-08-09 21:33:37 +03:00

Контракт API v0 — спутник спеки: провенанс, обоснования, вопросы

Нормативная поверхность контракта — openapi.yaml (файл рядом, в этой же папке) (OpenAPI 3.1). Этот файл её НЕ дублирует: он несёт то, чего YAML не выражает — откуда взято каждое решение, чем оно обосновано, что осталось открытым. При расхождении по ФОРМЕ побеждает YAML; при вопросе «почему так» — этот файл.

Статус: РАТИФИЦИРОВАН — D39.99 (04.08), контракт 0.2.0 (D39.115, 08.08); 0.2.1 (D39.123, 09.08): += 503 на POST /books/{bookId}/runs — старт прогона на неукомплектованном деплое (шов движка не сконфигурирован) не выражается ни одним прежним кодом; закрывает PD-112 платформы. Дом канона — этот каталог; frontend/docs/api-contract/openapi.yaml — байт-зеркало. ⚠ Испр. оркестратором №15 08.08: файл называл себя черновиком на ратификацию четверо суток ПОСЛЕ ратификации — тот же класс, что шапка info спеки. docs/architecture/14-api-contract/, зона оркестратора; перенос делает он, генерация типов после ратификации идёт из перенесённой копии — контракт первичен, код вторичен.

Язык. Спека английская: из неё генерятся типы, а исходники фронта по конвенции английские (слово владельца 04.08). Ссылки на К-вопросы внутри YAML набраны латинской K-N — это те же вопросы §4. Спутник и остальные доки зоны — русские.

Зона строки 95 — «оркестратор/бэкенд/фронт». Фронт авторитетен в одной трети: форма read-модели и продуктовые словари. Транспорт платформы (пути, аутентификация, коды) и работы движка (99103) здесь ПРЕДЛОЖЕНЫ и без подтверждения своих зон не действуют.

0. Пометки провенанса

Пометка Что значит
✓ выведено следует из кода движка или ратифицированного решения; грунт file:line рядом
◆ предложено решение фронта, разумное по его сведениям; подтверждает названная зона
○ открыто развилка, на которую у фронта ответа нет; перечень — §4

⚠ Пометка ставится на утверждение, а не на раздел: у одного пункта половина бывает выведенной, а половина предложенной. Первая редакция черновика этим и грешила — восемь мест несли ✓ там, где верно было ◆; ниже разведено.


1. Почему YAML, а не проза

Контракт — машинный артефакт: из него генерируются типы, по нему линтуется форма, им типизируются моки. Прозаический контракт расходится с кодом ровно тем способом, ради предотвращения которого заведена строка 95.

Инструменты, пины и отклонение по пиру TS не дублирую: они в STACK_DECISIONS.md §3 и в бэклоге зоны — Ф-23 (overrides вместо --legacy-peer-deps), Ф-24 (AsyncAPI отложен с причиной). Четыре формы нарушения гейта, каждая проверена живьём, — FRONTEND_PLAN.md §5.4.2.


2. Решения и их происхождение

2.1. Язык — код, никогда не имя — ✓ выведено

Движок держит коды (backend/internal/config/book.go:26-27), ключ пары — zh-ru (configs/langpacks/zh-ru/). Имя языка в данных — пар-специфика в общем слое, запрещённая §2 канона. Вторая цена, дороже: lang элемента берётся из данных книги, и пара ja→ru с именем вместо кода отрисует кандзи китайскими начертаниями молча.

2.2. Идентификаторы непрозрачны — ✓ выведено

glossary.id — свежий автоинкремент на каждой пересборке банка и намеренно не хешируется (store/migrate.go:173-174); номер главы плотный, «Chapters that yield no text … do NOT consume a chapter number» (chunk/chunker.go:99-105), поэтому правка исходника сдвигает номера последующих глав. Стабильность обеспечивает платформа при персисте манифеста (100).

2.3. Заголовок главы отдельным полем — ◆ предложено

Движок сегодня делает ОБРАТНОЕ, и это надо назвать прямо: титул рендерится детерминистически из шаблона пары (configs/langpacks/zh-ru/heading.txt: template Глава {n}), исходный маркер вырезается из текста для модели (chunk/chunker.go:110-114), а на экспорте титул вклеивается внутрь текста первого юнита (pipeline/export.go:215), причём колонка исходника остаётся без него.

Выведена здесь только МЕХАНИКА. Само поле heading — предложение фронта, и у него есть цена на другой стороне: движку придётся отдавать титул отдельно. Альтернатива (оставить вклейку, фронт отрезает строку) хуже: отрезание титула из текста — это парсинг прозы, и он сломается на первой главе без заголовка. Развилка — К-2.

Расхождение фикстуры, найденное разбором: дерево витрины показывает «Раздел 2. …», колонка оригинала — неснятый «第二节:». Для zh→ru движок не порождает ни одной из форм. Не чинится до ответа на К-2.

2.4. Состояние — у прогона; у главы выполнение — ✓ выведено

Подпись банка это один стоп на всю книгу (pipeline/mining.go:201), поэтому «глава ждёт подписи, пока соседняя финализируется» — невозможная картина. У главы движок держит ChapterPassport (pipeline/status.go:37-55).

2.5. Прогресс пофазно и в юнитах — ✓ выведено

«A unit is DONE when every member draft AND the unit's edit resolved ok» (pipeline/status.go:328-331), редактура не стартует до стопа банка ⇒ сквозной счётчик стоит на нуле всю черновую волну. Зависимость — строка 99.

2.6. Словарь статусов — ✓ лестница, ◆ ненормальные исходы

Лестница дословно из строки 95: «загрузка → разбор → перевод → подпись банка → финал → готово». not_started — дыра, найденная самопроверкой черновика: лестница описывает идущий прогон, а библиотека обязана показывать разобранную книгу, которую не запускали. stopped, rejected, not_started контракт ВЫВОДИТ из поведения процесса, а не получает полем: механика стопа у движка есть (cmd/tmctl/main.go:63), но «кто нажал» знает платформа.

Стоп по потолку — не failed ✓ выведено: «Ceiling is a hard, book-wide stop (not a per-chunk flag): the job stays 'pending' and resume continues once the ceiling is raised» (pipeline/stagerun.go:488-489). Мапить его в failed запрещено — это соврало бы про резюмируемость. Каким статусом и словом он показывается — К-8, вопрос владельцу.

2.7. Состояние пары выводится из ПАРЫ — ✓ выведено (исправление первой редакции)

Первая редакция утверждала «withheld = текст не выдан» как факт о движке. Это было ложно: флагнутый юнит легально приходит С ТЕКСТОМ в двух случаях —

  • косметическая зачистка санитайзера: «the chunk is NOT lost — the cleaned text is committed as the export» (pipeline/disposition.go:96-104);
  • c-lite member-drop: редактор отгружает отредактированный чистый остаток, а юнит флагнут из-за выпавшего члена (pipeline/export.go:203-207).

Поэтому состояние выводится из ПАРЫ (вердикт, наличие финального текста): флаг+текст → translated с замечанием; флаг+пусто → withheld.

Причина флага при этом ПРОИЗВОЛЬНА, и «единственный легальный случай» — снято (ревью оркестратора, round-2, пункт 4; утверждение противоречило выводу строкой выше). При c-lite drop юнит несёт FlagReason ПЕРВОГО выпавшего члена, каким бы он ни был (export.go:203-207: ce.FlagReason = drops[0].Reason, и тут же ce.FinalText … still ships). Значит «текст + замечание» — это класс, а не один случай, и карта вердиктов обязана иметь фразу для каждой причины, а не для двух.

Но glossary_miss в этот класс НЕ входит, и это проверено отдельно (иначе правка выше воскресила бы невоспроизводимый пример). memberDrops берёт причину из ЧЕРНОВОЙ строки члена (status.go:242-257: draftStages[cs.Stage] && flagged), а glossary_miss ставится пост-чеком только там, где отгружается финал: в черновой волне — лишь когда она сама финальная (waverun.go:373-381, draft-only), в c-lite — на строке РЕДАКТУРЫ (waverun.go:494). В пайплайне, где текст отгружает редактура (то есть где c-lite drop вообще возможен), черновая строка glossary_miss нести не может. При включённом гейте текст удерживается целиком (export.go:295-300), при выключенном мисса нет вовсе. Итог: пара «текст + промах словаря» невозможна ни одним каналом — фикстура витрины, показывавшая её, переведена на c-lite drop.

Свежесть ✓ выведено: target обновляется на границах стадий и на стопах, а не непрерывно — посреди прогона канала чтения не существует (эксклюзивный лок движка; санкционированное чтение — завершённый либо остановленный прогон, research/23 §0, §4).

2.8. Банк: словари ✓, имена ◆, kindс дырой

Значения выведены из схемы и гейтов Go; имена полей контракта — предложение фронта.

Поле Словарь Грунт
status auto · draft · approved store/migrate.go:191; только approved — канон
kind name · place · title · term · nickname плюс отсутствие значения terminology/classify.go:15 + pipeline/banknote.go:74; пустое — membank/memseed.go:323-326
origin seed · ruby · mined пути записи, см. ниже
sense свободный текст store/migrate.go:182
since_chapter/until_chapter целые, 0 = без границы store/migrate.go:189-190

Фантом auto в провенансе убран. Первая редакция взяла словарь из комментария схемы (migrate.go:193: seed|ruby|auto) — комментарий устарел. По путям записи "auto" пишет статус, не провенанс (membank/memseed.go:328: Status:"auto", Source:"ruby"), а майнинг ставит mined (pipeline/mining.go:424). Правка комментария в движке — за оркестратором.

kind пере-размечен ✓→◆, и вот почему это не косметика (ревью round-2, пункт 3). Словарь из пяти значений выведен верно, но ЗАКРЫТЫМ и обязательным он делает нелегальной легальную строку: ruby-кандидат получает Type: "", если его класс не name — то есть gloss и ambiguous живут без типа по построению (membank/memseed.go:323-326: typ := "", и только class == rubyClassName даёт "name"). Материализатору read-модели такую строку было физически нечем заполнить. Правило пустого: kind присутствует всегда и допускает null; null значит «движок не решил», строка при этом остаётся подписываемой, и клиенту запрещено и выбрасывать её, и додумывать тип за движок. Проекция ""null — работа платформы.

Ложный друг устранён. У движка колонка source — это ПРОВЕНАНС. Первая редакция назвала провенанс origin, а имя source отдала ДРУГОЙ колонке (тексту термина) — то есть завела между схемами ложного друга. Теперь: провенанс origin, формы термина src/dst, как их зовёт сам движок; имя source в схеме банка не используется вовсе.

2.9. Подпись — набор решений — ✓ выведено

Конвейер заменяет банк целиком (store/migrate.go:169-170), поэтому PATCH /term/{id} молча не работает. Механика дословно: «for EACH term either promote it into the mined-delta file OR decline it in the mined-rejects file, then resume — the stop clears once every proposed term is promoted or rejected» (pipeline/mining.go:201). Отсюда: решение promote|decline · счётчик «решено N из M» · запрет «продолжить» при неполном наборе · частичное сохранение ◆.

POST /runs/{id}/resumeнормативная операция, а не резерв (первая редакция помечала её «○ резерв строки 94», хотя тут же делала её носителем снятия стопа банка). Резерв строки 94 — это stop, продуктовая кнопка.

2.10. Ревизия — ✓ у ре-синка, ◆ у чтений

✓ выведено: правило ре-синка — идемпотентный апсерт по (run_id, seq), канал согласования — status --json (research/23 §2, §8; D39.85).

◆ предложено фронтом: что ревизию несут и ЧТЕНИЯ, и что счётчик у потока и у чтений ОДИН. Обоснование — гонка, которую иначе нечем разрешить: фронт живёт на снимке и потоке разом, а рефетч по возврату фокуса окна у ратифицированного @tanstack/react-query включён по умолчанию, то есть гонка на каждое переключение вкладки. Но это просьба, не вывод; выбор — К-4.

Скоуп ревизии (дыра первой редакции: она отдавала revision на межкнижной библиотеке при пер-прогонном определении): в спеке ревизия объявлена НА РЕСУРСу библиотеки своя, у прогона своя. Единая сквозная или пер-ресурсная — часть К-4.

2.11. Разрыв потока — ◆ предложено

При переподключении клиент шлёт Last-Event-ID. Если сервер докачать не может, он обязан ответить событием resync_required, а не молча начать с текущего момента: реплей истории запрещён, иначе разовое событие вроде note теряется молча и замечание не появится до перезагрузки. Клиент по этому событию перечитывает снимки.

2.12. Разрешающий список — ✓ инвариант, ◆ форма

Проекция «read-модель → фронт» строится как allowlist. Что лежит в операторских структурах (pipeline/status.go:58-130, :37-55) и не может доехать: пять денежных полей плюс cost_usd главы (§4.8 — денег в MVP-интерфейсе нет вовсе) · routing вида «stage=model», content_labels, content_routing_problems (ПТ-33) · снапшот, дрифт, ре-билл · escalations, postcheck_misses, style_flags, glossary_miss_flagged, stages_skipped, repair_applied, worst_flag_reason · flag_reason и detail — последний несёт сырой текст движка вида «CJK leak in the ru output: 第一节»; строку собирает checks/sanitizer.go:658, а pipeline/export.go:34 — лишь объявление поля, куда она доезжает.

2.13. ПТ-34 — перевод не индексируется — ✓ инвариант

Реестр требований назначает носителем ПТ-34 в том числе контракт, а первая редакция пункта не имела вовсе. В спеке: приложение живёт под X-Robots-Tag: noindex, ответы с текстом перевода несут Cache-Control: no-store, ссылка на выгрузку выдаётся только владельцу.


3. Зависимости: без чего контракт не заработает

Что Строка Без чего именно
Пофазный прогресс draft ∥ edit 99 прогресс (§2.5)
Персист манифеста + chunker_version 100 стабильный id главы (§2.2)
Машиночитаемая таблица ПОДПИСИ 101 экран подписи (§2.9)
Событийный эмиттер + событие потолка 103 весь поток (§2.11), событие note, событие ceiling
Артефакт экспорта БАНКА строки нет — заводит оркестратор чтение GET /books/{id}/bank
Механизм поднятия потолка строки нет — заводит оркестратор POST /runs/{id}/resume после стопа по потолку (см. ниже)
HTTP/SSE, аутентификация, воркер П-1 всё; в platform/ ноль строк кода

Отдельно про банк — дыра, найденная ревью оркестратора. Строка 101 даёт таблицу ПОДПИСИ (стоп-таблица, кап 20 на stdout — cmd/tmctl/render.go:98), а не экспорт всего банка; сам банк живёт в приватном SQLite движка, читать который платформе запрещено (D39.85). То есть у чтения /bank сегодня нет канала вообще. Фронт этого не решает — нужна строка единого бэклога, и заводит её оркестратор.

Та же природа у события note: пер-юнитных замечаний посреди прогона движок не эмитит — зависимость на словарь строки 103.

Потолок: resume сам по себе не сдвинет прогон (ревью round-2, пункт 5). Движок продолжает «once the ceiling is raised» (pipeline/stagerun.go:488-489), а канала поднятия в контракте нет — и в MVP-интерфейсе быть не может: денег на экране нет вовсе (D39.84). Значит между стопом по потолку и продолжением обязан стоять механизм ПЛАТФОРМЫ (поднятие по политике, или явное действие вне интерфейса книги), и до него resume после потолка возвращает прогон в то же состояние. Что при этом видит пользователь — К-8, вопрос владельцу; чем поднимают — строка единого бэклога, которой нет.


4. Открытые вопросы

Статусы « ЗАКРЫТ» аннотированы оркестратором №16 (строка 167; авторские формулировки вопросов сохранены под аннотацией).

# Вопрос Кому
К-1 ЗАКРЫТ D39.100 (десять статусов приняты). Было: десять статусов (§2.6) — принять или поправить? Три контракт выводит, а не получает владелец / автор контракта
К-2 Титул главы: отдать полем heading (предложено) — или оставить вклейку в текст, и фронт отрезает строкой? ⚠ Контекст 09.08: heading манифеста движка = ВРЕМЕННЫЙ рендер (D39.122 п.2д), настоящие заголовки — строка 160 (titleRaw) автор контракта + бэкенд
К-3 ЗАКРЫТ D39.100 (метка главы — из ДАННЫХ книги; зашитой формы «Глава N» не существует, легальна книга без номеров; глава без заголовка на экране — вопрос В-4/Ф-30). Было: титул это ровно «Глава N», узлов 2284 — дерево одинаковых по форме строк владелец (продуктовое)
К-4 Ревизия: одна сквозная на прогон или своя на ресурс? И несут ли её чтения вообще (§2.10) платформа
К-5 ЗАКРЫТ D39.100 (ETA показывать: eta_seconds в спеку). Было: показывать ли оценку времени. ПТ-19 существует (docs/product-requirements.md: «видимый прогресс/ETA»), посылка «не запрошено» неверна владелец (продуктовое)
К-6 Ступени замечания: сколько их и где граница. Сегодняшние две — проекция ОПЕРАТОРСКОЙ лестницы рангов, а она не обязана совпадать с продуктовой осью. Статус D39.100: принцип принят (две ступени по читательскому эффекту), карта — с В-3 владелец (продуктовое)
К-7 Пагинация: 2284 главы и 1200 терминов одним ответом или курсором? Фронт виртуализует, ему годится любой платформа
К-8 ЗАКРЫТ D39.100 (BookStatus получает 11-е значение paused + оповещение «лимиты исчерпаны» — ПТ-35; точное продуктовое слово — В-3 на владельце). Было: стоп по потолку — каким статусом и словом? В failed мапить нельзя — стоп резюмируемый владелец (продуктовое)
К-9 Отказ прескрина не выразим ни одним из десяти статусов. Абьюз/misuse-прескрин до трат токенов и UI-контракт отказа — строка 94 (ПТ-16); книга, отклонённая прескрином, это не rejected (тот про неразобранный файл) и не failed владелец + автор контракта
К-10 Выполнение главы — тот самый несплитованный счётчик, который §2.5 объявляет негодным. Chapter.units_done не разведён по фазам, значит дерево глав показывает ноль всю черновую волну — ровно то, из-за чего прогресс книги сделан пофазным. Развести и тут (цена — пофазные счётчики НА ГЛАВУ в строке 99) или показывать в дереве другое автор контракта + бэкенд
К-11 Условная обязательность полей — выражена у банка, не выражена у чтений. У BankDecision констрейнт поставлен (if action=promote → dst непустой), и вот что это стоило, измерено: spectral его валидирует, а openapi-typescript его игнорирует — в генерённых типах dst?: string как был. То есть 3.1-условие защищает сервер, но не экран; клиентское сужение (юнион promote-с-dstdecline) — работа подписного экрана S5, писать его до экрана не на чем проверить. Остаётся решить то же для чтений: Note не требует ни chapter_id, ни unit_id, Unit.target не обязателен при translated; обе схемы служат и вложенно, и отдельно, поэтому простое required соврало бы автор контракта
К-12 Завершение выгрузки: опрос или событие? Чтение GET /books/{id}/exports/{id} заведено — без него создающий вызов был тупиком (ready:false и ни слова дальше). Но пушить ли завершение ещё и кадром потока, чтобы не опрашивать, решает платформа: у неё воркер и её цена платформа
К-13 paused_reason не различает две разные беды (заведён D39.115 п.6б = Ф-31): единственное значение credit_exhausted, а упор в СВОЙ потолок прогона и исчерпание кредита — разные состояния с разным следующим действием; первому из двух фраза врёт. После PD-158 цена выросла: консервативный потолок останавливает ровно на исчерпании холда (D39.123 п.2ж) владелец (продуктовое)

5. Проверка ревью-вопросом строки 95

«Сменится стадия конвейера — придётся ли править фронт?»

Изменение в движке Правит ли фронт
переименована стадия / добавлена волна нет — имена стадий не пересекают шов, прогресс пофазный, а не постадийный
сменилась модель или маршрутизация нетrouting/content_labels в allowlist не входят
добавлена новая причина флага нет — на провод идёт продуктовая фраза, карта живёт в контракте
добавлен новый тип термина нет — словарь расширяется минором, ветка неизвестного стоит на шве
добавлено новое продуктовое состояние да, один файл — карта «статус → вид» на шве src/api/; это и есть контрольный вопрос владельца
сменился чанкер, главы пере-разобраны частично — код фронта не правится (ключ непрозрачный, номер отображаемый), но сохранность соответствия старых id новым главам контрактом не гарантируется: это работа персиста манифеста (строка 100). Если соответствие потеряно, у пользователя разъезжаются открытые вкладки и закладки — не правка кода, но видимый ущерб, и решать его строке 100

Единственное безусловное «да» — то, которое и должно быть «да».

2.14. Поверхность входа /auth/* — ✓ построено платформой (внесено оркестратором №15 при лендинге S3)

Четыре ручки живут ВНЕ версионного префикса, как /healthz: это механика сессии, а не контрактная поверхность, поэтому в openapi.yaml они не тащатся (решение оркестратора как владельца контракта, подтверждено платформой).

Ручка Метод Что делает
/auth/login GET начинает вход, редиректит к провайдеру; принимает ?return_to=<путь этого сайта>
/auth/callback GET завершает вход, ставит сессионную куку, редиректит на return_to либо на дефолт
/auth/logout POST завершает ЭТУ сессию
/auth/logout-all POST завершает ВСЕ сессии пользователя («выйти везде»)

Клиенту нужно знать три вещи. return_to принимает ТОЛЬКО путь этого сайта, и чужой путь сервер молча заменяет дефолтом — открытого редиректа нет, но и ошибки клиент не получит (сверено с login.go:safeReturnTo). Обе POST-ручки лежат на cookie-пути, то есть требуют X-TM-Client. Отказ входа — problem+json, как везде; различать причины отказа клиент не может по замыслу.

2.15. Потолок прогона — ◆ форма предложена фронтом, РАТИФИЦИРОВАНА оркестратором №15 (08.08)

Решение владельца 07.08: шкала в интерфейсе от минимума до максимума, ноль выбрать нельзя, единица — ГЛАВЫ, потолок принадлежит ПРОГОНУ. Ручки, отдающей границы шкалы, в контракте не было — объявлена правкой 0.2.0 как GET /books/{bookId}/run-optionsCeilingBounds.

Отдельный ресурс, а не поле карточки книги. Максимум зависит от АККАУНТА и двигается, когда книга не меняется: холд под другую книгу опускает остаток. Карточка книги кэшируется библиотекой, то есть назвала бы максимум, которого уже нет, ровно когда человек двигает ползунок. Второй довод дешевле, но настоящий: граница нужна один раз перед стартом, а поле на карточке заставило бы КАЖДОЕ чтение библиотеки нести состояние счёта.

Три числа, а не два. min_chapters объясняет себя единицей — одна глава. max_chapters приходит УЖЕ подрезанным и по остатку, и по непереведённому хвосту книги; клиенту подрезать второй раз ЗАПРЕЩЕНО, иначе правило живёт в двух местах и расходится. default_chapters отдаёт платформа, потому что предустановленное значение — продуктовая политика («потратить всё» ↔ «одна глава»), а не презентация. max_chapters: 0 — легальный ответ, значит «прогон начать нельзя вовсе»; тогда и default_chapters равен нулю, а клиент показывает исчерпанное состояние вместо шкалы.

max_chapters — величина, а не арифметика. Ратификация D39.110 в первой редакции требовала «баланс МИНУС открытые холды»: это была ОШИБКА оркестратора — вычитание дважды. Холд есть дебет в момент взятия (pgstore/credits.go:179 пишет отрицательную строку и тем же знаком двигает кэш баланса), поэтому баланс уже не содержит открытых холдов. Замерено при приёмке на живом PostgreSQL: грант $10 и холд $1 дают Balance 9 и Reserved 1, а «баланс минус Reserved» дало бы 8, то есть вдвое урезанную шкалу. Ошибку нашла фронт-сессия S3 чтением Go-кода платформы.

Пересчёта «главы → деньги» на проводе нет ни в каком виде (D39.84) — он живёт на платформе по оценке движка. ceiling_chapters обязателен и в запросе старта, и на Run: прогон без объявленного потолка тратит мимо границы, которую человек вправе поставить ДО, а не узнавать после, а поле на Run позволяет перезагруженному экрану назвать выбранный колпак. 409 на старте отвечает и на «потолок больше не помещается»: границы читаются отдельным вызовом и могут сдвинуться.

2.16. Транспорт: тот же origin — ФАКТ, а не выбор (внесено оркестратором №15)

CORS-слоя в платформе нет вовсе: preflight OPTIONS с чужим Origin получает 401 от гарда сессии, заголовков Access-Control-* нет ни на одном ответе (замер приёмки на живом бинаре, PD-96 регистра платформы). Браузерный клиент с другого origin неработоспособен как класс. В деве фронт ходит через прокси dev-сервера; кросс-origin не проектируется. X-TM-Client обязателен и на same-origin — он не про CORS.



Приложение А. Карта «вердикт → продуктовая фраза» — ЗАГОТОВКА

Заполняет автор контракта вместе с бэкендом и владельцем. Правило: фраза пишется по доккомменту disposition.go, а не по имени константы, и рядом кладётся цитата — иначе повторяется инверсия, стоившая двух фраз (glossary_miss подан как «термин не подписан», хотя термин ПОДПИСАН и его проигнорировали, disposition.go:78-79; sanitizer_stripped подан как потеря текста, хотя «the chunk is NOT lost», disposition.go:99).

Русские фразы ниже — плейсхолдеры, а не предложение фронта. Словарь продуктовый, его слова выбирает владелец (ПТ-33, В-3). Ступень — тоже: сегодняшние attention/glance унаследовали ОПЕРАТОРСКУЮ ось рангов, а она не обязана совпадать с продуктовой (К-6).

Причина движка Ранг Продуктовая фраза Ступень
hard_refusal · soft_refusal · content_filter · hard_block 0
cjk_artifact · excision_suspect · coverage_fail 1
sanitizer_defect 2
loop_degenerate 3
decode_error 4
glossary_miss 5 плейсхолдер: «Подписанный термин не применён в переводе»
length · empty 6
sanitizer_stripped 7 плейсхолдер: «Служебная разметка вычищена автоматически»
upstream_not_ok 8 (по умолчанию)
незнакомая причина 8 (по умолчанию) нейтральная, НЕ «ошибка»

Причин пятнадцать; upstream_not_ok в первой редакции отсутствовал — у него нет своей ветки в flagReasonSeverity, поэтому он падает в ранг по умолчанию (pipeline/status.go:174), как и любая будущая причина. Последняя строка — не формальность: контракт обязан иметь фразу для причины, которой ещё не существует.