diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3f5dce0f..f6be9376 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,7 +1,7 @@ # Журнал прогресса -> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-08, голова D39.114; хендофф №14→№15 — D39.107). ⚠⚠ **ИНЦИДЕНТ 08.08 00:13 (D39.113): агент моей панели откатил незакоммиченные правки СЕМИ файлов чужих сессий** — `git checkout -- .` в реальном дереве вместо песочницы. Пинг полигона в этом файле ВОССТАНОВЛЕН из бэкапа 00:08; безвозвратно потеряны правки отчёта эксп-21, промта полигона, Приложения А контракта и трёх скриптов `eval/role_topology/`. Коммиты целы. Инвентарь и норма изоляции панелей — D39.113. ⚠ **Оркестраторов сейчас ДВА, зоны разведены явно (решение владельца 07.08):** №15 — приёмка ПЛАТФОРМЫ (исполнена, D39.109–D39.114: P1+P2 приняты, фикс-пак P3 принят и заленден `99f049c` — девять строк регистра закрыты, 8/8 моих посадок поймано, PD-80 и PD-91 пере-проверены исполнением; записи верифицированы независимым агентом, поправки — D39.112) · ВТОРОЙ, параллельный — приёмка ПОЛИГОНА (эксп-21, ждёт результатов; его пинг лежит в секции «Полигон» незакоммиченным). **Номер второму НЕ выдан намеренно:** канонический счётчик один и живёт в `ORCHESTRATOR_SESSION_PROMPT.md`, а выдавать номер сессии, которой я не писал промт, — второй носитель состояния. Одновременно не запускаются; CURRENT-STATE ведут оба, каждый пишет про свою зону и чужую строку не трогает. -> - **Сессия №15 (07.08, ИСПОЛНЕНА):** **приёмка платформы P1+P2 — ПРИНЯТО и ЗАЛЕНДЕНО `fcdab81`** (62 файла, +7066/−252; D39.109). Адверсариально: своя батарея с живым PostgreSQL 18.4 (скипов ноль) · 45 СВОИХ мутаций (33 поймано, 8 выжило, 4 моих посадки негодны и разобраны) · живые пробы на боевом бинаре (PD-2 отпускает на 30.0 с; RFC 9207 сверен у Google) · панель 7 линз с опровергателем на каждую находку. Ратифицировано 6 решений (срок сессии 30 суток — **CONFIRM владельца 07.08 получен** · `iss` авторизационного ответа + миграция 00008 · §13 политики сессий · ломающие изменения зоны · `MemoryMax=80%`+`OOMPolicy=continue` · PD-71 риском). Регистр зоны доведён до **PD-107**. На момент приёмки открытыми были тридцать семь строк, весом major — три: PD-80 (вход выключается ~3 rps, и 429 колбэка добивает начатые входы) плюс PD-95 и PD-105, поднятые эрратой как задание эмиттеру. ⚠ **Числа здесь ИСТОРИЧЕСКИЕ — это то, что нашла приёмка;** живой счёт регистра меняется с каждым фиксом зоны и берётся командой `python3 docs/scripts/counts.py`, а не из этой строки (норма D39.112: один носитель на факт). **Живой уязвимости нет.** ⚠ **ЭРРАТА 07.08 (поймано вопросом владельца, не процессом):** приёмка пропустила, что **D39.106 п.3 снял пайп-транспорт** («PD-59 superseded; пайп-путь `supervisor.go` P1 = дев-режим»), и приняла зонную секцию «канал остаётся stdout» как живое решение. Ратифицированная форма — транзиентный systemd-юнит на прогон, платформа НЕ родитель, события = `events.jsonl` каталога книги, платформа ТЕЙЛИТ с курсором (`research/25` §Форма; родитель+пайп 0/15 голосов). Исправлено: секция журнала помечена superseded, PD-95 переписан и поднят в вес, PD-92 понижен до info, PD-105 повышен, PD-60 пере-диспозиционирован; вердикт лендинга не меняется (код и был дев-путём по D39.107 п.3). ⚠ Пять строк реестра — движковые по существу и должны быть разрулены ДО промта эмиттера (строка 103): PD-95 (транспортная история зоны устарела против D39.106 в двух местах — эмиттер-сессия прочтёт её как задание) · PD-105 (дубль `seq` = убитый платный прогон против норматива «дубль не ошибка»; при тейле файла повторная доставка — норма) · PD-79 (строковый `"null"` в цене = ноль денег) · PD-99 (argv на INFO — деньги/book-id при воркере) · PD-107 (удаление аккаунта обходит гарантию PD-25) · **PD-60 и PD-61** (обратное давление и сброс буфера — свойства эмиттера, задаются дешевле ДО постройки, чем миграцией после; PD-60 переформулируется под файл-транспорт). **Потолок прогона РЕШЁН владельцем 07.08 и ратифицирован формой (D39.110):** шкала в интерфейсе от минимума (ноль нельзя) до баланса аккаунта ⇒ Р7 не амендируется, «выключено» не существует как состояние. Добор: единица шкалы — ГЛАВЫ, не доллары (иначе амендмент D39.84) · потолок в `book.yaml` НЕ пишется, движку нужен приём на прогон — НОВАЯ строка **145** · максимум шкалы = баланс минус открытые холды. **Фри-тир:** ответ дан нормой (автогрант без якоря — не стандарт; `email_verified` Google якорем не является), предложение оркестратора — на бете дефолт в НОЛЬ и начисление руками, при платежах $5 вместе с суточным потолком; **ждёт слова владельца** (PD-104, там же расхождение док↔код: реестр обещает ноль, код начисляет $5) · **приёмка экспов 18/19/20 ИСПОЛНЕНА и залендена** (D39.108: приняты с ревью-шапками; §5.2 экспа 20 — «со слов»; деньги сессии полигона $2.0720; квирк §3б отозван, §3а в силе; D21.4/D21.10 закрыты) · **эксп-21 «топология ролей» — промт выдан** (`POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md`: скрин моделей по 7 ключам → бейк-офф топологий на невиданном срезе; потолки ПРЕДВАРИТЕЛЬНЫЕ ≈$7.3 стадиями; **ЗАПУСК = слово владельца**) · строки 135–144 заведены, 103/106/134/65/55/12 пере-диспозиционированы · дальше по D39.107 п.3: промты эмиттера (бэкенд) и денег шва (платформа) — по команде · запуск S3 (промт готов, `6ac26f3`) · вахта deepseek-v4-pro — пере-проба маппинга вшита в Ф1 эксп-21 · мини-проба флора 44 — одобрена, промт не выдан. **Открыто на владельце:** запуск эксп-21 (потолки + готовность слепого чтения ~50 пар) · передача платформы · Приложение А `14-api-contract` (незакоммичено) · запуск S3 · 116 (порог подан) · мини-проба 44 · подпись денежного шага 46 · лист В-3+К-6 · вопрос платформы (авто-резюме `paused`). +> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-08, голова D39.115; хендофф №14→№15 — D39.107). ⚠⚠ **ИНЦИДЕНТ 08.08 00:13 (D39.113): агент моей панели откатил незакоммиченные правки СЕМИ файлов чужих сессий** — `git checkout -- .` в реальном дереве вместо песочницы. Пинг полигона в этом файле ВОССТАНОВЛЕН из бэкапа 00:08; безвозвратно потеряны правки отчёта эксп-21, промта полигона, Приложения А контракта и трёх скриптов `eval/role_topology/`. Коммиты целы. Инвентарь и норма изоляции панелей — D39.113. ⚠ **Оркестраторов сейчас ДВА, зоны разведены явно (решение владельца 07.08):** №15 — приёмка ПЛАТФОРМЫ (исполнена, D39.109–D39.114: P1+P2 приняты, фикс-пак P3 принят и заленден `99f049c` — девять строк регистра закрыты, 8/8 моих посадок поймано, PD-80 и PD-91 пере-проверены исполнением; записи верифицированы независимым агентом, поправки — D39.112) · ВТОРОЙ, параллельный — приёмка ПОЛИГОНА (эксп-21, ждёт результатов; его пинг лежит в секции «Полигон» незакоммиченным). **Номер второму НЕ выдан намеренно:** канонический счётчик один и живёт в `ORCHESTRATOR_SESSION_PROMPT.md`, а выдавать номер сессии, которой я не писал промт, — второй носитель состояния. Одновременно не запускаются; CURRENT-STATE ведут оба, каждый пишет про свою зону и чужую строку не трогает. +> - **Сессия №15 (07.08, ИСПОЛНЕНА):** **приёмка платформы P1+P2 — ПРИНЯТО и ЗАЛЕНДЕНО `fcdab81`** (62 файла, +7066/−252; D39.109). Адверсариально: своя батарея с живым PostgreSQL 18.4 (скипов ноль) · 45 СВОИХ мутаций (33 поймано, 8 выжило, 4 моих посадки негодны и разобраны) · живые пробы на боевом бинаре (PD-2 отпускает на 30.0 с; RFC 9207 сверен у Google) · панель 7 линз с опровергателем на каждую находку. Ратифицировано 6 решений (срок сессии 30 суток — **CONFIRM владельца 07.08 получен** · `iss` авторизационного ответа + миграция 00008 · §13 политики сессий · ломающие изменения зоны · `MemoryMax=80%`+`OOMPolicy=continue` · PD-71 риском). Регистр зоны доведён до **PD-107**. На момент приёмки открытыми были тридцать семь строк, весом major — три: PD-80 (вход выключается ~3 rps, и 429 колбэка добивает начатые входы) плюс PD-95 и PD-105, поднятые эрратой как задание эмиттеру. ⚠ **Числа здесь ИСТОРИЧЕСКИЕ — это то, что нашла приёмка;** живой счёт регистра меняется с каждым фиксом зоны и берётся командой `python3 docs/scripts/counts.py`, а не из этой строки (норма D39.112: один носитель на факт). **Живой уязвимости нет.** ⚠ **ЭРРАТА 07.08 (поймано вопросом владельца, не процессом):** приёмка пропустила, что **D39.106 п.3 снял пайп-транспорт** («PD-59 superseded; пайп-путь `supervisor.go` P1 = дев-режим»), и приняла зонную секцию «канал остаётся stdout» как живое решение. Ратифицированная форма — транзиентный systemd-юнит на прогон, платформа НЕ родитель, события = `events.jsonl` каталога книги, платформа ТЕЙЛИТ с курсором (`research/25` §Форма; родитель+пайп 0/15 голосов). Исправлено: секция журнала помечена superseded, PD-95 переписан и поднят в вес, PD-92 понижен до info, PD-105 повышен, PD-60 пере-диспозиционирован; вердикт лендинга не меняется (код и был дев-путём по D39.107 п.3). ⚠ Пять строк реестра — движковые по существу и должны быть разрулены ДО промта эмиттера (строка 103): PD-95 (транспортная история зоны устарела против D39.106 в двух местах — эмиттер-сессия прочтёт её как задание) · PD-105 (дубль `seq` = убитый платный прогон против норматива «дубль не ошибка»; при тейле файла повторная доставка — норма) · PD-79 (строковый `"null"` в цене = ноль денег) · PD-99 (argv на INFO — деньги/book-id при воркере) · PD-107 (удаление аккаунта обходит гарантию PD-25) · **PD-60 и PD-61** (обратное давление и сброс буфера — свойства эмиттера, задаются дешевле ДО постройки, чем миграцией после; PD-60 переформулируется под файл-транспорт). **Потолок прогона РЕШЁН владельцем 07.08 и ратифицирован формой (D39.110):** шкала в интерфейсе от минимума (ноль нельзя) до баланса аккаунта ⇒ Р7 не амендируется, «выключено» не существует как состояние. Добор: единица шкалы — ГЛАВЫ, не доллары (иначе амендмент D39.84) · потолок в `book.yaml` НЕ пишется, движку нужен приём на прогон — НОВАЯ строка **145** · максимум шкалы = баланс минус открытые холды. **Фри-тир:** ответ дан нормой (автогрант без якоря — не стандарт; `email_verified` Google якорем не является), предложение оркестратора — на бете дефолт в НОЛЬ и начисление руками, при платежах $5 вместе с суточным потолком; **ждёт слова владельца** (PD-104, там же расхождение док↔код: реестр обещает ноль, код начисляет $5) · **приёмка экспов 18/19/20 ИСПОЛНЕНА и залендена** (D39.108: приняты с ревью-шапками; §5.2 экспа 20 — «со слов»; деньги сессии полигона $2.0720; квирк §3б отозван, §3а в силе; D21.4/D21.10 закрыты) · **эксп-21 «топология ролей» — промт выдан** (`POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md`: скрин моделей по 7 ключам → бейк-офф топологий на невиданном срезе; потолки ПРЕДВАРИТЕЛЬНЫЕ ≈$7.3 стадиями; **ЗАПУСК = слово владельца**) · строки 135–144 заведены, 103/106/134/65/55/12 пере-диспозиционированы · дальше по D39.107 п.3: промты эмиттера (бэкенд) и денег шва (платформа) — по команде · **S3 «слой данных» ПРИНЯТ и заленден `60595f6` (D39.115): контракт 0.2.0 в каноне, копии байт-равны, блоки компаньона 2.14–2.16 внесены; сессия нашла ТРИ ошибки оркестратора — «баланс минус холды» (вычитание дважды, пере-мерено), `nullable: true` невалиден в 3.1, инструкция внутри нормативного дока протухла от исполнения; норма «не ходить в чужую зону» амендирована: не ПИСАТЬ, а читать код для сверки — можно и нужно** · вахта deepseek-v4-pro — пере-проба маппинга вшита в Ф1 эксп-21 · мини-проба флора 44 — одобрена, промт не выдан. **Открыто на владельце:** запуск эксп-21 (потолки + готовность слепого чтения ~50 пар) · передача платформы · Приложение А `14-api-contract` (незакоммичено) · запуск S3 · 116 (порог подан) · мини-проба 44 · подпись денежного шага 46 · лист В-3+К-6 · вопрос платформы (авто-резюме `paused`). > - **Сделано (сводно; детали — D-лог и архив-слайсы):** Ф0 ✅ · Ф1-инфра ✅ · арх-ресет D39 (7 слоёв, паки 11–16) ✅ · паки 17 «канал B» · 18 «долги» · 19 «голос/состояние» · 20 «банк+терминолог» ✅ (D39.26–28/31/41–56) · мини-прогон (D39.37) и ХОЛОДНЫЙ прогон (D39.58: recall банка 0.918/0.980, банк приходит переведённым) приняты · полигон-пакеты 5–8, ToS-речек (D39.57), Р6+P4 (D39.61) закрыты · **карта языковой привязки движка (D39.60): 149 сайтов/45 файлов, книго-ось чиста (4), ja→ru безопасно без правки Go, en→ru — нет** · Р1–Р4 закрыты целиком (D39.63: Р2 = ЗНАЧЕНИЕ, Р4 = норма потолка recall) · **ФАЗА 2 ОБЩНОСТИ ✅ (D39.64: П0 эмбед-хеш · П1 цель-шов · П2 скрипт-шов, ko-баг закрыт · П3 нарезка · П4 манифест-по-каналам; голден вердикт-нейтрален 0/172, майнер-парити EXACT)** · жанровый словарь отменён как класс (D39.47) · петля ремонта построена и НЕ включена (D39.38). > - **Курс:** ОБЩНОСТЬ ✅ → КАЧЕСТВО БАНКА ✅ → ПАКЕТ-ЧЕКЕРОВ ✅ (D39.59–78) → **ФРОНТ-ЭРА открыта** (D39.81–100: зоны `frontend/`+`platform/` живые, контракт API ратифицирован). Развилка блокера 0731 решена вариантом (б) и ИСПОЛНЕНА (D39.87/91: `reasoning_effort:"low"` черновику + ручка эффорта бэнк-ролям, код `553f1a3`); отладочный прогон C не стартовал ⇒ **строка 16 жива и оси голоса 13б без данных**; пак экрана 46 — дизайн заморожен (D39.92 п.1 + D39.93), промт ждёт выдачи; coldrun-b фаза C заморожена чекпойнтом — легитимный исход (D39.86); эталон денег/поведения — coldrun-a (read-only). Числа и хроника закрытых паков — D-лог D39.79–97, сюда НЕ дублируются. > - **Горизонт (D39.62/67, освежён D39.95):** ре-проба flash ИСПОЛНЕНА и строка 74 закрыта (D39.86/91; порог 6/6 → пере-ратификация выборочным, строка 116; проба Q2 ИСПОЛНЕНА — research/24, D39.102: семейства не окупаются, остаток = рецензент спорных кластеров при ре-пробе 74; эмиссия Q7 — по слову) → **ДОБОР ИДЕАЛА** (первым прогоном: оси голоса 24 · авто-режим · итерация №2 редакторов 65 · цена 16 · веса K1–K12 13а · вне-претрейн чекпоинт 55) → ВТОРАЯ ПАРА живьём (ja→ru; преп 81) → МАСШТАБ → пилот Ф2.5 (гейт резюме-строки 80; строки 62–68, 85) → Ф3 ридер-IDE (69–71). **Стоячие:** ToS-триггер 25.10 · Ш-2 до go1.27 (⚠ + x/text-таблицы Unicode 17 тем же тулчейном — строка 119/реестр 108) · проба провода исполнена и 117 закрыта (D39.97), конфиги 112 залендены — платные прогоны разблокированы. @@ -128,7 +128,7 @@ | 102 | **Приём внешнего trace-контекста в tmctl**: TraceID минтится заново каждым вызовом (`main.go:67-70`; внутри вызова трасса цельная, `request_log.trace_id` есть) — прогон, запущенный платформой, должен быть одной трассой | бэкенд | когда-нибудь (Ф3, с 96) | малое касание | релей фронта 02.08, D39.84 | | 103 | **NDJSON-эмиттер прогонных событий со стабильным словарём** (машинный канал живого статуса для платформы, `research/23` §2): NDJSON-логи уже есть (`LOG_FORMAT=json`, авто-оси trace/book/chapter/stage/role — `obs/logging.go:15-54`), но сообщения прозой, version-хендшейка нет, а **событие потолка отсутствует вовсе** (деньги только в тексте ошибки `stagerun.go:472-494`); нужен словарь-enum + version-хендшейк первой строкой (образец terraform -json: минор = ignore-unknown, мажор = reject) + событие потолка; все границы — единичные call-sites в pipeline рядом с готовыми slog; человеческие логи уже на stderr — stdout-дисциплина соблюдена. **Форма ратифицирована D39.106 (research/25 §эмиттер):** `events.jsonl` каталога книги = outbox-проекция коммитов SQLite (та же транзакция, что чекпойнт) · fsync/seq/hello-версия · события денег и потолка · развилка формата (JSONL против пофайловых/CRC-фрейминга) — решить дизайном · `status --json` отдельным каналом (ремонт, тот же flock) | бэкенд | скоро (промт бэкенду — по команде владельца, D39.107 п.3) | стройка по D39.106 + research/25 | research/23, D39.85, **D39.106** | | 125 | **Артефакт экспорта БАНКА** (полный банк тремя статусами — канал чтения `/bank` контракта 14): строка 101 это таблица ПОДПИСИ (кап 20 stdout, `cmd/tmctl/render.go:98`), не экспорт; сам банк в приватном SQLite, платформе закрыт (D39.85) — нужен экспорт-артефакт движка на границе стадии/стопе | бэкенд | скоро (БЛОКЕР контракта API, D39.99) | дизайн + малое касание export | ревью контракта 04.08, D39.99 | -| 126 | **Потолок прогона — УПРАВЛЯЕМЫЙ, решение владельца 07.08: настройка в интерфейсе фронта, шкала от минимума (ноль выбрать НЕЛЬЗЯ, подсвечен серым) до максимума баланса аккаунта.** Форма снимает конфликт с Р7 бесплатно: максимум = баланс ⇒ движок всегда получает положительное число и состояния «выключено» не существует, валидатор `book.go:250` не правится. **Три следствия, ратифицированы формой (D39.110):** (а) **единица — НЕ доллары**, а главы (или % баланса): тогда минимум объясняет себя сам («одна глава»), а D39.84 цел — читатель не видит структуру расходов; доллары на пользовательском экране = осознанный амендмент D39.84, не побочный эффект виджета; (б) это потолок ПРОГОНА ⇒ в `book.yaml` он не пишется, движку нужен приём потолка на прогон — строка 145; (в) **максимум шкалы = баланс МИНУС открытые холды**, иначе ползунок в упоре запирает весь баланс на одну книгу и вторую начать нельзя (холд берётся ДО спавна; `ReadAccount` уже возвращает `Balance` и `Reserved` раздельно). **НОСИТЕЛЬ ПЕРЕСЧЁТА НАЗВАН (добор после верификации 07.08):** «сколько глав влезает в доступный остаток» считает ПЛАТФОРМА (по оценке движка) и отдаёт фронту ГРАНИЦЫ шкалы и выбранное значение в главах; сама формула наружу не выходит. Ручки под это в контракте НЕТ — сегодняшний `GET /v0/usage` несёт только процент, поэтому поверхность объявляется при правке спеки вместе с созданием прогона (работа П-1 + S3), и до её объявления фронт максимум шкалы взять неоткуда. Прежнее тело строки (механизм поднятия потолка для resume после ceiling-стопа: движок продолжает «once the ceiling is raised», `stagerun.go:488-489`) — половина этой же работы. Продуктовое слово стопа — К-8 у владельца | платформа/фронт/бэкенд | скоро (форма решена, стройка — промтами) | дизайн-вход П-1 + правка спеки + строка 145 | ревью контракта 04.08, D39.99, **D39.110** | +| 126 | **Потолок прогона — УПРАВЛЯЕМЫЙ, решение владельца 07.08: настройка в интерфейсе фронта, шкала от минимума (ноль выбрать НЕЛЬЗЯ, подсвечен серым) до максимума баланса аккаунта.** Форма снимает конфликт с Р7 бесплатно: максимум = баланс ⇒ движок всегда получает положительное число и состояния «выключено» не существует, валидатор `book.go:250` не правится. **Три следствия, ратифицированы формой (D39.110):** (а) **единица — НЕ доллары**, а главы (или % баланса): тогда минимум объясняет себя сам («одна глава»), а D39.84 цел — читатель не видит структуру расходов; доллары на пользовательском экране = осознанный амендмент D39.84, не побочный эффект виджета; (б) это потолок ПРОГОНА ⇒ в `book.yaml` он не пишется, движку нужен приём потолка на прогон — строка 145; (в) **максимум шкалы = ДОСТУПНЫЙ ОСТАТОК, то есть `Balance` как есть** — ⚠ испр. 08.08 (D39.115): формулировка «баланс минус открытые холды» была ОШИБКОЙ приёмки, вычитанием дважды. Холд — это дебет в момент взятия (`credits.go:179`), поэтому баланс уже без него; замерено: $10 грант + $1 холд = `Balance` 9, `Reserved` 1, «минус Reserved» дало бы 8. Спека несёт верную форму: `max_chapters` приходит УЖЕ подрезанным и по остатку, и по непереведённому хвосту книги, клиенту подрезать второй раз ЗАПРЕЩЕНО. **НОСИТЕЛЬ ПЕРЕСЧЁТА НАЗВАН (добор после верификации 07.08):** «сколько глав влезает в доступный остаток» считает ПЛАТФОРМА (по оценке движка) и отдаёт фронту ГРАНИЦЫ шкалы и выбранное значение в главах; сама формула наружу не выходит. Ручки под это в контракте НЕТ — сегодняшний `GET /v0/usage` несёт только процент, поэтому поверхность объявляется при правке спеки вместе с созданием прогона (работа П-1 + S3), и до её объявления фронт максимум шкалы взять неоткуда. Прежнее тело строки (механизм поднятия потолка для resume после ceiling-стопа: движок продолжает «once the ceiling is raised», `stagerun.go:488-489`) — половина этой же работы. Продуктовое слово стопа — К-8 у владельца | платформа/фронт/бэкенд | скоро (форма решена, стройка — промтами) | дизайн-вход П-1 + правка спеки + строка 145 | ревью контракта 04.08, D39.99, **D39.110** | | 127 | **Устаревший DDL-коммент `glossary.source`** (`migrate.go:193`: «seed|ruby|auto», фактические пути записи — seed/ruby/mined, `auto` не пишет никто; фантом уже утёк во фронт-доки дважды) — поправить коммент | бэкенд | когда-нибудь (попутно любым паком store) | однострочная правка | ревью контракта 04.08, D39.99 | | 105 | **Роль `classifier` мис-вердиктится как эхо + честность двух новых строк наблюдаемости (D39.86).** (а) `classifyOutput` даёт поблажку `SourceEchoExpected` только `roleTerminologist` (`chunkrun.go:37`), а ответ классификатора по формату состоит из исходных ханьских термов ⇒ доля исходного письма 0.20–0.29 > порога 0.15 на каждом батче: живой артефакт пробы 6/6 несёт `verdict=cjk_artifact` при `bad_lines=0`. Ущерб узкий — `runBankRoleBatches` кладёт `run.texts[i] = att.text` без проверки диспозиции (`terminologist.go:603`), посчитанные эхо-ставки (`quality.go:313-321`) берутся из `chunk_status`, куда бэнк-путь не пишет ⇒ портятся только строки `tmctl report`/лога; **латентно** (`classify_types` выключен во всех шиппинг-конфигах). ⚠ Цена дисциплинированного фикса, которую надо знать заранее: бамп `classifierVersion` (литерал, сам не двигается) меняет payload снапшота → snapshot id → `RequestHash` ⇒ промах ВСЕХ чекпойнтов книги, то есть `--resnapshot` + пере-покупка. (б) тем же касанием — честность поверхностей: строка `VOICE` берёт `rules=` из конфига ВРЕМЕНИ ОТЧЁТА (`quality.go:436`), поэтому включение `gates.voice` после прогона печатает «измерено, чисто» для неизмеренной книги (воспроизведено); и она печатается при выключенном гейте, если `SpoilerLeaks>0` (дизъюнкция — поведение верное, формулировка отчёта/коммента неверна); знаменатель `BANKNOTE` = `len(states)` включая призрачные строки `retrieval_state`, тогда как соседний агрегатор их фильтрует (`quality.go:259-260`) ⇒ после пере-чанковки покрытие занижается | бэкенд | когда-нибудь (ближайшее касание банка/наблюдаемости; (б) — дешёвая часть, вердиктов не двигает) | малое касание + вердикт-двигающая половина (а) через СТОП-решение | D39.86 | | 106 | **Слой 3 НЕ достроен: контракт редактора = full-regen, а целевой — узкие мандаты + ДИФФЫ.** `09-target-architecture.md:121-126` дословно: «research/07 предписывает узкие мандаты + диффы, а не полную перегенерацию — сейчас ровно наоборот (4 мандата, full-regen), что и породило хвост инверсий»; целевой контракт — редактор правит anchored search/replace-спанами с детерминированным Go-apply (ограниченный blast-radius, дешевле, аудируемо, структурно omission-safe). Пак-16 построил петлю РЕМОНТА (починка флагнутого спана постфактум), а не diff-контракт роли ⇒ это разные вещи. **Строки не было ни одной** — дыра учёта, найденная сверкой 02.08 (грепы `diff`/`мандат`/`span` по бэклогу = 0). Смежное: арм Q4b «diff-fidelity+style редактор» (`exp15:108`) не прогонялся НИ РАЗУ, хотя трек B объявлен завершённым. **ЭМПИРИКА ПОЛУЧЕНА (D39.108, экспы 19/20):** дифф-контракт механически исправен (комплаенс 0.966 на боевой единице, аппликатор 15/15), но по замороженному гейту Q4b диффы ОТЛОЖЕНЫ (fidelity-trap 11/12; дороже full-regen на dspro×ru ×2.5–2.9 при невскрытой причине; D21.4/D21.10 закрыты явно). Дальше — эксп-21 (топология) → решение владельца | владелец → бэкенд | гейт: эксп-21 | решение владельца по бейк-оффу эксп-21 | 09 §Слой 3, D39.90, **D39.108** | diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index bbfd292b..758e90df 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1,4 +1,4 @@ -# Журнал решений оркестратора — контракт D1–D39.114 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07) +# Журнал решений оркестратора — контракт D1–D39.115 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07) > **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто: > ⚠ **Навигация (актуализация 07.08, эра D39.1xx):** append-only-дисциплина (D23.3) означает, что @@ -1566,7 +1566,7 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу **1. Решение владельца 07.08 (дословно по смыслу):** потолок управляется двумя вещами — «выключить потолок» и «выставить вручную с фронта»; форма — шкала в интерфейсе от минимума (ноль выбрать НЕЛЬЗЯ, подсвечен серым) до максимума баланса аккаунта, конкретный виджет на выбор исполнителя. **Ратифицировано формой, и форма сама снимает конфликт с Р7:** максимум = баланс ⇒ движок всегда получает положительное число, состояния «потолка нет» не существует, валидатор `book.go:250-251` («a ledger with no ceiling is forbidden, Р7») не правится и Р7 не амендируется. Носитель — пере-поставленная строка **126**. -**2. Три следствия, без которых форма неполна (добор оркестратора, каждое проверено кодом).** **(а) Единица шкалы — НЕ доллары, а главы (либо % баланса).** Тогда минимум объясняет себя сам («одна глава», ниже прогон бессмыслен — владелец сам отметил, что непонятно, какое минимальное число ставить), а **D39.84 остаётся цел**: читатель не видит структуру наших расходов, платформа сама переводит главы в доллары по оценке движка. Доллары на ПОЛЬЗОВАТЕЛЬСКОМ экране = осознанный амендмент D39.84, и это отдельное решение владельца, а не побочный эффект виджета (П-5 показывает процент ровно по этой причине). **(б) Это потолок ПРОГОНА, значит он не пишется в `book.yaml`.** Дизайн платформы объявлял её владельцем `book.yaml` и намеревался поднимать `ceilings.book_usd` правкой файла — тогда пользовательское число становится постоянной записью в данных движка, что смешивает зоны против D39.81/D39.85. В коде этого НЕТ (проверено: платформа только ставит `cmd.Dir`), значит форма выбирается бесплатно ⇒ **НОВАЯ строка 145**: движок принимает потолок аргументом прогона; сегодня ни флага, ни ENV не существует (`tmctl` таких флагов не имеет, значение идёт только через `Ceilings.BookUSD`, `stagerun.go:480,505`). Цена нулевая: `Ceilings` в `BriefHash` не входит (`book.go:280-297`) — ни снапшот, ни ре-билл не двигаются. **(в) Максимум шкалы = баланс МИНУС открытые холды.** Платформа берёт холд на сумму потолка ДО спавна, поэтому ползунок в упоре запирает весь баланс на одну книгу и вторую начать нельзя; `ReadAccount` уже отдаёт `Balance` и `Reserved` раздельно, данных хватает. +**2. Три следствия, без которых форма неполна (добор оркестратора, каждое проверено кодом).** **(а) Единица шкалы — НЕ доллары, а главы (либо % баланса).** Тогда минимум объясняет себя сам («одна глава», ниже прогон бессмыслен — владелец сам отметил, что непонятно, какое минимальное число ставить), а **D39.84 остаётся цел**: читатель не видит структуру наших расходов, платформа сама переводит главы в доллары по оценке движка. Доллары на ПОЛЬЗОВАТЕЛЬСКОМ экране = осознанный амендмент D39.84, и это отдельное решение владельца, а не побочный эффект виджета (П-5 показывает процент ровно по этой причине). **(б) Это потолок ПРОГОНА, значит он не пишется в `book.yaml`.** Дизайн платформы объявлял её владельцем `book.yaml` и намеревался поднимать `ceilings.book_usd` правкой файла — тогда пользовательское число становится постоянной записью в данных движка, что смешивает зоны против D39.81/D39.85. В коде этого НЕТ (проверено: платформа только ставит `cmd.Dir`), значит форма выбирается бесплатно ⇒ **НОВАЯ строка 145**: движок принимает потолок аргументом прогона; сегодня ни флага, ни ENV не существует (`tmctl` таких флагов не имеет, значение идёт только через `Ceilings.BookUSD`, `stagerun.go:480,505`). Цена нулевая: `Ceilings` в `BriefHash` не входит (`book.go:280-297`) — ни снапшот, ни ре-билл не двигаются. **(в) Максимум шкалы = доступный остаток аккаунта.** ⚠ **ИСПРАВЛЕНО 08.08 приёмкой S3 (D39.115): первая редакция этого подпункта говорила «баланс МИНУС открытые холды» — это ОШИБКА, вычитание дважды.** `Hold` пишет в леджер отрицательную строку и тем же знаком двигает кэш баланса (`credits.go:179` → `appendLedger` `:297-305`), поэтому баланс УЖЕ не содержит открытых холдов; `Reserved` — отдельная справочная сумма. Замерено приёмкой на живом PG: грант $10, холд $1 → `Balance=9.000000`, `Reserved=1.000000`, а «баланс минус Reserved» дало бы $8. Поймала фронт-сессия S3 чтением Go-кода платформы, а не доков — и ради этого нарушила мой же запрет ходить в чужую зону. Платформа берёт холд на сумму потолка ДО спавна, поэтому ползунок в упоре запирает весь баланс на одну книгу и вторую начать нельзя; `ReadAccount` уже отдаёт `Balance` и `Reserved` раздельно, данных хватает. **3. Фри-тир — вопрос владельца («а как это обычно делается в бэкендах?»), ответ и ПРЕДЛОЖЕНИЕ, а не ратификация.** Разделяется на две половины, и стандартный ответ есть только у одной. **Где живёт начисление:** учебник — промо-кредит пишется строкой леджера в ТОЙ ЖЕ транзакции, что создание аккаунта, идемпотентно по `(аккаунт, source, source_id)`; код зоны это уже делает верно (`upsertIdentityTx`), менять нечего. **Стреляет ли само:** норма — «автоматически, но с ЯКОРЕМ, который стоит абьюзеру денег» (карта на файле — AWS/GCP; телефон — OpenAI добавил его к своим $5 после фарминга; инвайт), плюс бюджет кампании отдельной суммой и алерт при упоре. `email_verified` от Google якорем НЕ является — аккаунты бесплатны и создаются пачками, признак доказывает существование ящика, а не отдельного человека. У нас нет ни одного из трёх, а деньги — предоплаченные ключи владельца. **Предложение: на бете дефолт в НОЛЬ и начисление руками** (платёжного инструмента нет ⇒ между скриптом и ключами нет ничего; бета-тестеров столько, что одна команда в шелле на человека не работа); при появлении платежей дефолт возвращается к $5 ВМЕСТЕ с суточным агрегатным потолком — якорь и потолок приезжают по одному поводу. Ждёт слова владельца; носитель — PD-104. @@ -1621,3 +1621,19 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу **4. Ратифицировано отдельно: PD-104 оставлен открытым ПРАВИЛЬНО.** Зона починила половину док↔код (ячейка PD-30 больше не обещает нулевой аккаунт при автогранте) и строку не закрыла, потому что продуктовая половина — ноль на бете, суточный агрегатный потолок, счётчик аномалий — ждёт слова владельца. Это правило PD-83 («свойство без пина закрытым не считается»), применённое зоной к себе. **5. Оговорка зоны принята как честная:** адверсариального ревью вторым читателем у P3 не было — сессия без права на субагентов. Второй читатель — эта приёмка. **Живой счёт регистра руками больше не переписывается** (норма D39.112): `python3 docs/scripts/counts.py`; исторические числа в CURRENT-STATE помечены историческими, а литерал веса снят с автопроверки, потому что легитимного живого носителя в зоне `docs` у него нет. + +## D39.115 — S3 «СЛОЙ ДАННЫХ» ПРИНЯТ И ЗАЛЕНДЕН: контракт 0.2.0 в каноне, три ошибки ОРКЕСТРАТОРА поймала сама сессия, правило 0.x ратифицировано (08.08). ✅ + +**1. Вердикт:** ПРИНЯТ, заленден `60595f6` (46 файлов). Пере-прогнано мной: `npm run check` — 108 тестов, prettier/eslint/stylelint/tsc чисты, spectral по спеке без замечаний; `npm run check:full` — сборка плюс СЕМЬ кадров с axe, включая новый маршрут `/partial` (отказал один рид при рабочих остальных — ветка жила незамеченной именно потому, что её нельзя было открыть по ссылке). Кадр `/scale` прочитан глазами: пер-главный счётчик замечаний на месте, и честно оставленный Ф-30 виден прямо на экране — глава без заголовка показана голым номером «12», а не выдуманной «Главой N». + +**2. Три моих ошибки, найденные сессией. Это главный результат пака.** **(а) «Максимум шкалы = баланс МИНУС открытые холды» (D39.110 п.2(в), строка 126, промт S3 п.17) — НЕВЕРНО, вычитание дважды.** `Hold` пишет отрицательную строку в леджер и тем же знаком двигает кэш баланса (`credits.go:179` → `appendLedger` `:297-305`), значит баланс УЖЕ не содержит открытых холдов, а `Reserved` — справочная сумма. Пере-мерено мной на живом PostgreSQL: грант $10 и холд $1 дают `Balance` 9.000000 при `Reserved` 1.000000, а моя формула дала бы 8 — шкала вдвое короче правды. Исправлено во всех трёх носителях. **(б) Форма `nullable: true`,** которую я вписал в промт для `GET /usage`, в OpenAPI 3.1 невалидна; сессия выразила через `oneOf` с `type: 'null'`, как во всей остальной спеке. **(в) Инструкция ВНУТРИ нормативного дока протухает от исполнения:** я оставил в шапке спеки фразу «version всё ещё `-draft` — S3 бампнет первым действием», и после бампа она стала ложной; сессия заменила её на правду и назвала это отступлением вслух. Тот же класс, что рукописные производные числа (D39.112): предписание, записанное в артефакт, живёт до первого действия. + +**3. Норму «не ходить в чужую зону» АМЕНДИРУЮ, потому что она и была источником (а).** Сессия объявила вслух, что нарушила мой запрет и читала Go платформы (только чтение, ничего не тронуто) — и ровно этим нашла ошибку в моей ратификации. Новая формулировка: **в чужую зону НЕ ПИСАТЬ; ЧИТАТЬ код для сверки несущего утверждения — можно и НУЖНО**, потому что доки врут, и это норма проекта («не верь заголовкам, грунтуй `file:line`»). Запрет читать превращал сессию в заложника моего текста. Отдельно: раскрытие нарушения вслух — правильное поведение, и его надо поощрять, а не наказывать. + +**4. Ратифицировано.** Форма управляемого потолка — `GET /books/{bookId}/run-options` → `CeilingBounds` отдельным ресурсом, а не полем карточки книги (максимум зависит от АККАУНТА и двигается, когда книга не менялась; карточка кэшируется библиотекой и назвала бы протухший максимум ровно в момент выбора) · три числа вместо двух, `max_chapters` приходит уже подрезанным и клиенту подрезать второй раз запрещено, `default_chapters` — продуктовая политика платформы, `max_chapters: 0` легален и означает «прогон начать нельзя» · `ceiling_chapters` обязателен в запросе старта и на `Run` · `409` покрывает «потолок больше не помещается». **Правило версий 0.x:** минорный бамп — законная полоса ломающих правок (semver §4), клиент пинит точную 0.x-версию; с 1.0.0 действует общее правило. Сессия НЕ стала править раздел Versioning своей рукой, а назвала противоречие — это верно, правило версий принадлежит владельцу контракта; вписано мной в обе копии. **Блоки компаньона 2.14 (поверхность входа) · 2.15 (потолок) · 2.16 (тот же origin — факт, CORS-слоя нет)** внесены в канон; патч-файл `S3_CONTRACT_HANDOFF.md` применён и удалён, как он сам себя и объявил. + +**5. Канон и зонная копия снова байт-равны** (`ba7f3745…`), spectral по обеим чист. + +**6. На владельце — три вопроса, сессия их назвала сама.** (а) Метка «остановлена: лимиты» в дереве — сжатие ратифицированной фразы «перевод остановлен: лимиты исчерпаны», сделанное сессией; продуктовые слова по В-3/Ф-21 не её. (б) **К-13 (Ф-31): упереться в СВОЙ потолок прогона и остаться без кредита сегодня неразличимо** — `paused_reason` имеет одно значение `credit_exhausted`, и первому из двух фраза врёт, а следующее действие у него другое. (в) Ф-30: глава без заголовка показана голым порядковым номером, потому что синтез «Глава N» запрещён К-3; это вопрос В-4, уже стоящий владельцу. Плюс три поля, придуманные сессией по её собственному признанию и ратифицированные мной выше: `default_chapters`, `maxLength: 200` у заголовка, `paused_reason` в кадре статуса. + +**7. Открытые хвосты зоны, заведённые честно:** Ф-29 (мок едет в прод-бандл — цена того, что скриншот-цикл гоняет прод-сборку; папка удаляется в день живой платформы) · Ф-32 (замечания читаются пер-книжно, показываются пер-главно: экран починен, память нет — на книге с тысячами замечаний клиент тянет весь список ради трёх строк) · `ceiling_chapters` обязателен сегодня бесплатно, после первого реального прогона это станет ломающей правкой. diff --git a/docs/architecture/14-api-contract/README.md b/docs/architecture/14-api-contract/README.md index 11d38fbf..8d46b408 100644 --- a/docs/architecture/14-api-contract/README.md +++ b/docs/architecture/14-api-contract/README.md @@ -317,6 +317,66 @@ is promoted or rejected» (`pipeline/mining.go:201`). Отсюда: решени Единственное безусловное «да» — то, которое и должно быть «да». +## 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-options` → `CeilingBounds`. + +**Отдельный ресурс, а не поле карточки книги.** Максимум зависит от АККАУНТА и двигается, когда книга +не меняется: холд под другую книгу опускает остаток. Карточка книги кэшируется библиотекой, то есть +назвала бы максимум, которого уже нет, ровно когда человек двигает ползунок. Второй довод дешевле, но +настоящий: граница нужна один раз перед стартом, а поле на карточке заставило бы КАЖДОЕ чтение +библиотеки нести состояние счёта. + +**Три числа, а не два.** `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. + +--- + --- ## Приложение А. Карта «вердикт → продуктовая фраза» — ЗАГОТОВКА diff --git a/docs/architecture/14-api-contract/openapi.yaml b/docs/architecture/14-api-contract/openapi.yaml index 125d0f5d..77a0d53a 100644 --- a/docs/architecture/14-api-contract/openapi.yaml +++ b/docs/architecture/14-api-contract/openapi.yaml @@ -2,16 +2,16 @@ openapi: 3.1.0 info: title: TextMachine API - version: 0.1.0-draft + version: 0.2.0 summary: Ratified contract between the frontend and the TextMachine platform (D39.99). description: | **RATIFIED contract (D39.99, 04.08.2026).** The canonical copy lives in `docs/architecture/14-api-contract/` (orchestrator's zone); `frontend/docs/api-contract/` is a byte-mirror of it, and a divergence is a defect of one of the two, never a local edit. - ⚠ Text corrected by orchestrator #15 on 07.08: this file called itself an unratified draft of - session S3 for three days after it was ratified, and pointed at a companion path that does not - exist beside the canonical copy. The version string still reads `-draft` — session S3 bumps it - as the first item of its spec work. + ⚠ **This copy is deliberately AHEAD of the canonical one right now.** Version 0.2.0 was written + by session S3 from the owner's decisions of 04–07.08 and the platform's ratified answers; the + orchestrator re-ratifies the canonical copy by diff, and the two are byte-equal again after + that landing. Companion document: the `README.md` beside the canonical copy — provenance of every decision (derived from engine code / proposed by the frontend / open), rationale, dependencies and open @@ -30,13 +30,34 @@ info: `X-Robots-Tag: noindex`; responses carrying translated text MUST be sent with `Cache-Control: no-store`. + ## Transport + + The browser client is served from the **same origin** as this API. That is a fact of the + platform, not a setting: it has no CORS layer at all — a preflight `OPTIONS` carrying a foreign + `Origin` is answered `401` by the session guard, and no `Access-Control-*` header is sent on any + response. A cross-origin browser client is therefore inoperable as a class rather than + unconfigured, and nothing here is designed around cross-origin requests. A development server + reaches this API through a proxy onto its own origin. + + Session mechanics — starting a login, finishing it, ending one session, ending all of them — + live OUTSIDE the version prefix, like `/healthz`, and are described in the companion rather + than here: they are the mechanics of holding a session, not a contract surface. + ## Versioning Semver. **Minor** — backwards-compatible additions: a client MUST ignore unknown fields and MUST tolerate unknown enum values without failing. **Major** — a client MUST refuse an unsupported version and tell the user. - Every `enum` below is the vocabulary of version 0.1, not a closed world. Generated types are + ⚠ **While the version is 0.x, a MINOR bump is the lane for breaking changes** (semver §4: + "Major version zero … anything MAY change at any time"), and 0.2.0 carries several — a required + `next_cursor` on every list, a required `ceiling_chapters` on a run request. A client pins the + exact 0.x version it was generated against and does not assume compatibility across minors. + From 1.0.0 the rule above applies unqualified. Ratified by the orchestrator at the S3 landing: + session S3 named the contradiction between this section and its own bump instead of resolving + it by its own hand, which was correct — the versioning rule is the contract owner's. + + Every `enum` below is the vocabulary of THIS version, not a closed world. Generated types are closed unions and do NOT protect against an unknown value, so the unknown-value branch belongs on the client seam (`src/api/`), where values enter, not in every component. @@ -46,7 +67,10 @@ info: servers: - url: https://app.example.org/v0 - description: Platform. The base path is a frontend proposal; the platform confirms it. + description: | + The platform, on the SAME ORIGIN as the browser client (see Transport). Only the version + prefix is fixed here: the host is whatever origin served the application, and a client that + hard-codes one is a client that cannot be deployed anywhere else. security: - sessionCookie: [] @@ -63,6 +87,8 @@ tags: description: Translation runs, live progress, control. - name: export description: Export of a finished book. + - name: account + description: Credit balance of the account. paths: /books: @@ -72,13 +98,21 @@ paths: summary: Book library. description: | Flat list of the user's books. `revision` is the revision of the LIBRARY itself - (membership and statuses), not of any run. Revision scope is open — companion §4 (K-4). + (membership and statuses) and belongs to the library's own scope: it is never compared + with the revision of a book. + + Page size default is the platform's choice here — the client MUST follow `next_cursor` + until it is `null` rather than assume the library fits in one page. + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' responses: '200': description: Library. content: application/json: schema: { $ref: '#/components/schemas/Library' } + '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } post: tags: [library] @@ -130,12 +164,18 @@ paths: Chapters in reading order. A chapter has NO status, only unit progress: bank signing is a single book-wide stop, so "one chapter awaits signing while its neighbour finalizes" cannot happen. + + Default page size **5000**. + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' responses: '200': description: Chapters of the book. content: application/json: schema: { $ref: '#/components/schemas/ChapterList' } + '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } @@ -151,12 +191,21 @@ paths: The unit of shipping is the EDIT UNIT, not a paragraph and not a chunk: roughly 1.9 units per chapter, and a whole chapter is sometimes a single block. Alignment is coarse and accepted as such. + + **Units are read PER CHAPTER and only per chapter.** A book-wide units endpoint is never + introduced: the whole memory model of the client stands on this — the working set stays + tens of kilobytes instead of tens of megabytes. Page size default is the platform's choice; + the client follows `next_cursor`. + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' responses: '200': description: Pairs of the chapter. content: application/json: schema: { $ref: '#/components/schemas/UnitList' } + '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } @@ -170,12 +219,18 @@ paths: description: | A note addresses a unit or a whole chapter. Byte offsets do not exist in the engine's checks and are not planned. + + Default page size **500**. + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' responses: '200': description: Notes of the book. content: application/json: schema: { $ref: '#/components/schemas/NoteList' } + '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } @@ -190,12 +245,18 @@ paths: ⚠ **No backing channel exists for this read today.** The engine ships a signing table, not a bank export, and its private store must not be read by the platform. The export artifact is a dependency the frontend cannot create — companion §3. + + Default page size **1000**. + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' responses: '200': description: Bank of the book. content: application/json: schema: { $ref: '#/components/schemas/Bank' } + '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } @@ -228,6 +289,28 @@ paths: '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } + /books/{bookId}/run-options: + parameters: + - $ref: '#/components/parameters/BookId' + get: + tags: [runs] + operationId: getRunOptions + summary: Bounds for starting a run. + description: | + Bounds of the run-ceiling scale, read right before a run is started. + + A resource of its own rather than a field of the book card: the maximum depends on the + ACCOUNT and moves while the book does not, so a cached card would state a maximum that is no + longer true — at the moment the user is dragging the scale. + responses: + '200': + description: Bounds of the scale. + content: + application/json: + schema: { $ref: '#/components/schemas/RunOptions' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + /books/{bookId}/runs: parameters: - $ref: '#/components/parameters/BookId' @@ -239,6 +322,13 @@ paths: `verify_bank` is a parameter of the RUN, not a global setting. With it the run stops at the bank boundary; without it the unsigned bank is carried forward marked as unverified. It is the user's choice between "I will sign" and "translate as is". + + `ceiling_chapters` is a parameter of the RUN as well, and it is not a property of the + book: it travels with the start and does not outlive the run. + + **409** also answers a ceiling that no longer fits: the bounds are read by + `GET /books/{bookId}/run-options` and may move between that read and this call, because a + hold taken for another book lowers the remainder. requestBody: required: true content: @@ -250,6 +340,7 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/Run' } + '400': { $ref: '#/components/responses/BadRequest' } '401': { $ref: '#/components/responses/Unauthorized' } '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } @@ -316,10 +407,12 @@ paths: description: | Clears the bank-signing stop and continues after a user stop. - ⚠ **After a ceiling stop this call alone does not move the run:** the engine continues - only once the ceiling has been raised, and no channel for raising it exists here — money - is absent from the MVP interface by decision. The mechanism belongs to the platform - (companion §3), and what the user sees meanwhile is companion §4 (K-8). + ⚠ **After a ceiling stop this call alone does not move the run.** The engine continues only + once the ceiling has been raised, and no handle raises it: `ceiling_chapters` travels with + the START of a run and this contract has no way to change it afterwards. The mechanism is + the platform's and does not exist yet (companion §3); until it does, `resume` on a run + paused by a ceiling returns it to the same state, so the client MUST NOT offer resume as + the remedy for `paused`. **Answers 409 while the set of bank decisions is incomplete** — the stop clears only on a complete set. @@ -333,6 +426,25 @@ paths: '404': { $ref: '#/components/responses/NotFound' } '409': { $ref: '#/components/responses/Conflict' } + /usage: + get: + tags: [account] + operationId: getUsage + summary: State of the credit balance. + description: | + Credits are a BALANCE, not a subscription with windows. There is no period, no `resets_at` + and no "resets in": the screen shows what is LEFT. The percentage is computed against the + sum of the account's grants, not against a limit of a period — periods do not exist. + + Money SUMS never cross this boundary in any form: a percentage, never an amount. + responses: + '200': + description: Balance state. + content: + application/json: + schema: { $ref: '#/components/schemas/Usage' } + '401': { $ref: '#/components/responses/Unauthorized' } + /books/{bookId}/exports: parameters: - $ref: '#/components/parameters/BookId' @@ -340,7 +452,15 @@ paths: tags: [export] operationId: createExport summary: Build a book export. - description: Formats and their contents are stage S7 work; only the call shape is fixed here. + description: | + Formats and their contents are stage S7 work; only the call shape is fixed here. + + The export is an ARTIFACT BEHIND A LINK. Assembling the text of a book on the client — + reading every chapter and stitching it together — is forbidden explicitly: it would defeat + the per-chapter working set that the read paths are built around. + + Completion is POLLED, not pushed: the `202` names the status resource in `Location`, and + the status read carries `Retry-After`. requestBody: required: true content: @@ -349,6 +469,11 @@ paths: responses: '202': description: Export is being prepared. + headers: + Location: + required: true + description: Address of the status resource for this export. + schema: { type: string, format: uri-reference } content: application/json: schema: { $ref: '#/components/schemas/Export' } @@ -365,11 +490,19 @@ paths: summary: State of an export. description: | Without this read the creating call is a dead end: it answers `ready: false` and nothing - ever says otherwise. Whether completion is ALSO pushed as a stream event, making polling - unnecessary, is the platform's call — companion §4 (K-12). + ever says otherwise. **Completion is polled, not pushed** — no stream frame announces it. responses: '200': description: State of the export. + headers: + Retry-After: + description: | + Seconds to wait before polling again; sent while `ready` is `false`. + + Declared here on purpose. RFC 9110 defines this header for `503` and for `3xx`, + and its general semantics do not reach a `200`, so a contract that wants it on a + `200` has to say so itself. + schema: { type: integer, minimum: 0 } content: application/json: schema: { $ref: '#/components/schemas/Export' } @@ -383,8 +516,15 @@ components: in: cookie name: __Host-tm_session description: | - Browser presentation of one server-side session: HttpOnly, Secure, SameSite=Lax. CSRF - protection is mandatory on the cookie path. The form belongs to the platform. + Browser presentation of one server-side session: HttpOnly, Secure, SameSite=Lax. + + **CSRF.** On the cookie path a browser client MUST send the header `X-TM-Client` on every + UNSAFE request — anything other than GET and HEAD. What carries the protection is the + PRESENCE of the header; the value is arbitrary and has no token semantics, so do not invent + any. It is required on same-origin requests as well: it is not a CORS mechanism. + + The same requirement holds for the session-mechanics endpoints that live outside the + version prefix (companion). bearerToken: type: http scheme: bearer @@ -418,6 +558,26 @@ components: required: true description: Opaque export identifier. schema: { $ref: '#/components/schemas/Id' } + Limit: + name: limit + in: query + required: false + description: | + Page size. The default is stated per collection on the operation; a server MAY return + fewer rows than asked for, and the client decides nothing from that — only from + `next_cursor`. + schema: { type: integer, minimum: 1 } + Cursor: + name: cursor + in: query + required: false + description: | + Keyset cursor taken from `next_cursor` of the previous page. Opaque: the client MUST NOT + parse, compare or construct it. Omitted for the first page. + + A cursor that no longer applies is rejected with `400`; see `NextCursor` for why that + rejection is the server's duty and not the client's. + schema: { type: string, minLength: 1 } responses: BadRequest: @@ -459,11 +619,41 @@ components: type: integer minimum: 0 description: | - Monotonic revision of a resource. A read whose revision is LOWER than an event the client - already applied MUST be dropped rather than rendered, otherwise the interface rolls - progress backwards on every refetch. + Monotonic revision. **The counter is PER BOOK:** every book-scoped read and the `id` of + every stream frame of that book's run carry the same number. The library has a scope of its + own. + + A revision is monotonic WITHIN its scope and is NEVER compared across scopes. + + Discarding a stale read is the CLIENT's duty: a read whose revision is lower than what the + client has already applied MUST be dropped rather than rendered, otherwise the interface + rolls progress backwards on every refetch — and a refetch on window focus is the default + behaviour of the client's query layer, so the race happens on every tab switch. + + **Catch-up after a reconnect reads `revision >= R`, not `> R`.** One transaction is one + revision but SEVERAL frames; strict "greater than" drops the sibling frames of the last + one the client applied. + + After a transaction of FULL REPLACEMENT — the bank rebuilt from scratch, re-chunking + replacing the chapters — the server MUST answer `resync_required` rather than a delta: a + delta read cannot express a deletion. examples: [1841] + NextCursor: + type: [string, 'null'] + description: | + Cursor of the NEXT page, or `null` on the last one. Present on EVERY list response, + always — introducing it later would silently cut the tail off a client that does not read + the field. + + The cursor is bound to the STRUCTURAL epoch of the collection — the generation of the + manifest, the chunker version — and **not to the revision of the book**: the revision bumps + on every materialization, so binding to it would restart pagination forever while a + 5000-chapter book is running. + + Rejecting a cursor from a dead epoch is the SERVER's duty (MUST), answered `400`. The + client cannot perform it: the cursor is opaque to it by construction. + LangCode: type: string pattern: '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$' @@ -493,6 +683,13 @@ components: properties: draft: { $ref: '#/components/schemas/Counter' } edit: { $ref: '#/components/schemas/Counter' } + eta_seconds: + type: [integer, 'null'] + minimum: 0 + description: | + Estimated seconds to the end of the run. **Optional:** it is absent whenever there is + nothing to estimate from — before the first calls of a wave there is no throughput yet + — and the screen MUST render without it rather than show a zero. BookStatus: type: string @@ -500,15 +697,15 @@ components: Product status of a book: `uploading` file is being accepted · `parsing` split into chapters · `not_started` parsed, never run · `translating` translation in progress · `awaiting_bank` waiting for the glossary to be signed · `finalizing` final pass · - `ready` done · `stopped` stopped by the user · `rejected` file could not be parsed · - `failed` run aborted by an error. + `ready` done · `paused` halted and resumable · `stopped` stopped by the user · + `rejected` file could not be parsed · `failed` run aborted by an error. The engine has no run-state vocabulary at all, so `not_started`, `stopped` and `rejected` are DERIVED by the contract rather than received as a field. - **A ceiling stop is NOT `failed`.** It is a resumable book-wide stop; mapping it to - `failed` is forbidden, as that would lie about resumability. Which status and which word - the user sees is an open product question — companion §4 (K-8). + **A ceiling stop is `paused`, never `failed`.** It is a resumable book-wide stop, and + mapping it to `failed` is forbidden because that would lie about resumability. The machine + reason travels as `Run.paused_reason`; the phrase the user reads is drawn by the client. A prescreen refusal maps to none of these values either — companion §4 (K-9). enum: @@ -519,6 +716,7 @@ components: - awaiting_bank - finalizing - ready + - paused - stopped - rejected - failed @@ -546,19 +744,30 @@ components: Library: type: object - required: [revision, books] + required: [revision, next_cursor, books] properties: revision: { $ref: '#/components/schemas/Revision' } + next_cursor: { $ref: '#/components/schemas/NextCursor' } books: type: array items: { $ref: '#/components/schemas/Book' } + PausedReason: + type: string + description: | + Machine reason a run is paused. The API carries STATE; the phrase the user reads is drawn + by the client, so no wording appears here. + + One value exists today. A client MUST tolerate an unknown one arriving under a minor bump + and show the neutral "halted, resumable" state rather than failing or guessing. + enum: [credit_exhausted] + Run: type: object description: | A run over a book. `status` reuses the book vocabulary, but the book-level values (`uploading`, `parsing`, `not_started`, `rejected`) never appear on a run. - required: [id, revision, status, verify_bank, started_at] + required: [id, revision, status, verify_bank, ceiling_chapters, paused_reason, started_at] properties: id: { $ref: '#/components/schemas/Id' } revision: { $ref: '#/components/schemas/Revision' } @@ -566,6 +775,18 @@ components: verify_bank: type: boolean description: The run was requested with a stop for bank signing. + ceiling_chapters: + type: integer + minimum: 1 + description: | + The ceiling this run was started with, in CHAPTERS. A property of the RUN, not of the + book: it travels with the start and does not outlive the run. Present so that a reloaded + screen can still name the cap the user chose. + paused_reason: + oneOf: + - $ref: '#/components/schemas/PausedReason' + - type: 'null' + description: Reason when `status` is `paused`; `null` in every other state. started_at: { type: string, format: date-time } finished_at: type: [string, 'null'] @@ -598,21 +819,35 @@ components: Chapter: type: object - required: [id, number, units_total, units_done, note_count] + required: [id, number, heading, units_total, units_done, note_count] properties: id: { $ref: '#/components/schemas/Id' } number: - type: integer + type: [integer, 'null'] minimum: 1 description: | - Displayed ordinal. **Not a key:** numbering is dense — chapters that yield no text do - not consume a number — so editing the source shifts every later chapter. + Displayed ordinal, or `null` when the book has no numbering — a legal book. Always + present, possibly null, so the client handles one shape rather than two. + + **Not a key:** numbering is dense — chapters that yield no text do not consume a + number — so editing the source shifts every later chapter. heading: - type: string + type: [string, 'null'] + maxLength: 200 description: | - Chapter title as a field is a frontend PROPOSAL: today the engine glues the rendered - title into the text of the first unit and leaves the source column without it. - Unresolved — companion §4 (K-2). + The chapter's label **as it comes from the data of the book**, or `null` when the book + carries none. + + A client MUST NOT synthesize a label from a template such as "Chapter {n}": no such + form exists, a book legally has no numbers, and a book legally has no chapters at all. + An unlabelled chapter is shown without a name rather than given an invented one. + + The server bounds the length — in a list of 5000 rows this is the only string that + would otherwise be unbounded. + + Who produces the label is still a backend question: today the engine glues a rendered + title into the text of the first unit and leaves the source column without it — + companion §4 (K-2). units_total: { type: integer, minimum: 0 } units_done: type: integer @@ -625,9 +860,10 @@ components: ChapterList: type: object - required: [revision, chapters] + required: [revision, next_cursor, chapters] properties: revision: { $ref: '#/components/schemas/Revision' } + next_cursor: { $ref: '#/components/schemas/NextCursor' } chapters: type: array items: { $ref: '#/components/schemas/Chapter' } @@ -671,9 +907,10 @@ components: UnitList: type: object - required: [revision, units] + required: [revision, next_cursor, units] properties: revision: { $ref: '#/components/schemas/Revision' } + next_cursor: { $ref: '#/components/schemas/NextCursor' } units: type: array items: { $ref: '#/components/schemas/Unit' } @@ -706,9 +943,10 @@ components: NoteList: type: object - required: [revision, notes] + required: [revision, next_cursor, notes] properties: revision: { $ref: '#/components/schemas/Revision' } + next_cursor: { $ref: '#/components/schemas/NextCursor' } notes: type: array items: { $ref: '#/components/schemas/Note' } @@ -777,14 +1015,18 @@ components: Bank: type: object - required: [revision, total, signed, terms] + required: [revision, next_cursor, total, signed, terms] properties: revision: { $ref: '#/components/schemas/Revision' } - total: { type: integer, minimum: 0 } + next_cursor: { $ref: '#/components/schemas/NextCursor' } + total: + type: integer + minimum: 0 + description: Rows in the whole bank, not on this page. signed: type: integer minimum: 0 - description: How many rows are in status `approved`. + description: Rows in status `approved` in the whole bank, not on this page. terms: type: array items: { $ref: '#/components/schemas/BankTerm' } @@ -840,11 +1082,86 @@ components: RunRequest: type: object - required: [verify_bank] + required: [verify_bank, ceiling_chapters] properties: verify_bank: type: boolean description: Stop for bank signing before the final pass. + ceiling_chapters: + type: integer + minimum: 1 + description: | + Ceiling of THIS run, in chapters, within the bounds returned by + `GET /books/{bookId}/run-options`. + + Required: a run started without a declared ceiling would spend past the limit the user + is entitled to set before it begins rather than learn about afterwards. `0` is not a + legal value — a run with a zero ceiling does not start, so it is not offered. + + RunOptions: + type: object + required: [ceiling] + properties: + ceiling: { $ref: '#/components/schemas/CeilingBounds' } + + CeilingBounds: + type: object + description: | + Bounds of the run-ceiling scale, in CHAPTERS. The chapters → money conversion lives on the + platform and is not exposed here in any form. + + `max_chapters` is what the account can still spend, already clamped to what is left of the + book. A client MUST NOT clamp it again. + + ⚠ A quantity, not arithmetic: a hold is a debit when it is taken, so a running balance + already excludes the holds open against it, and subtracting them a second time would halve + the scale. + + `max_chapters` of `0` means no run can start at all — the client shows the exhausted state + instead of a scale. Zero is never selectable. + required: [min_chapters, max_chapters, default_chapters] + properties: + min_chapters: + type: integer + minimum: 1 + description: Smallest ceiling that can be started. + max_chapters: + type: integer + minimum: 0 + description: Largest ceiling that can be started; `0` when none can. + default_chapters: + type: integer + minimum: 0 + description: | + Pre-selected value. The platform owns it because the choice is product policy — a client + picking it would decide "spend everything" or "one chapter" on its own. `0` only when + `max_chapters` is `0`. + + Usage: + type: object + description: | + State of the credit balance. No window, no `resets_at`, no sums — see `GET /usage`. + required: [state, remaining_percent] + properties: + state: + type: string + description: | + `ok` · `low` the threshold at which the interface warns · `exhausted` nothing left. The + threshold itself belongs to the platform and is not on the wire: a client that computed + it from the percentage would carry a second copy of the policy. + enum: [ok, low, exhausted] + remaining_percent: + type: integer + minimum: 0 + maximum: 100 + description: Share of the account's grants still available. A percentage, never an amount. + paused_reason: + oneOf: + - $ref: '#/components/schemas/PausedReason' + - type: 'null' + description: | + Set when the account itself is in a halted state; `null` otherwise. The same value + travels per-run as `Run.paused_reason`. ExportRequest: type: object @@ -883,8 +1200,15 @@ components: | `ceiling` | `EventCeiling` | the run was halted by a ceiling | | `resync_required` | `EventResyncRequired` | resuming the stream is impossible | - The frame `id` carries a monotonic revision. Whether it is the same counter that reads - carry is open — companion §4 (K-4). + The frame `id` carries the book's revision — the same counter every book-scoped read + carries, so a frame and a read can be ordered against each other. One transaction produces + one revision but possibly SEVERAL frames, which is why catch-up reads `>=` and not `>` + (see `Revision`). + + **The server MAY COALESCE frames**, and a client MUST tolerate counters that jump: a run + over 9500 units would otherwise be an unbounded source of renders. A client therefore must + not animate from its previous value as though every step had arrived, and must not treat a + skipped number as a lost frame. required: [event, data] properties: event: { type: string } @@ -916,9 +1240,17 @@ components: EventStatus: type: object - required: [status] + description: | + Product status changed. `paused_reason` travels with it so that a pause is actionable + without a second read — the frame that announces the stop is exactly the moment the screen + has to say why. + required: [status, paused_reason] properties: status: { $ref: '#/components/schemas/BookStatus' } + paused_reason: + oneOf: + - $ref: '#/components/schemas/PausedReason' + - type: 'null' EventProgress: type: object @@ -955,8 +1287,12 @@ components: type: object description: | The run hit a ceiling and halted. **Carries no figures** — the fact of the stop, not a - sum: money does not appear in the MVP interface at all. The stop is resumable, so it does - not yield `failed`. Depends on the event emitter (companion §3). + sum: money does not appear in the MVP interface at all. The resulting status is `paused`, + never `failed`: the stop is resumable. Depends on the event emitter (companion §3). + + Which ceiling — the account's credit or the run's own `ceiling_chapters` — is not + distinguished by this frame; whether the two need separate `paused_reason` values is open, + companion §4 (K-13). required: [halted] properties: halted: { type: boolean } @@ -976,13 +1312,21 @@ components: Problem: type: object description: | - Error per RFC 9457. ⚠ `detail` NEVER carries engine text: the engine's own detail strings - read like "CJK leak in the ru output: 第一节", which exposes how the pipeline works. Here - `detail` is either empty or already a product phrase. + Error per RFC 9457. + + ⚠ **Neither `title` nor `detail` ever carries engine text.** The engine's own detail strings + read like "CJK leak in the ru output: 第一节", which exposes how the pipeline works. + + The constraint applies to BOTH fields because both are shown: a client has nothing else to + put on the screen when a call fails, so a `title` written for a developer becomes the + sentence the reader gets. `title` is the CLASS of the failure, `detail` the specific + sentence; both are product language, and either may be empty. required: [type, title, status] properties: type: { type: string, format: uri } - title: { type: string } + title: + type: string + description: Product phrase naming the class of failure. Shown to the user as-is. status: { type: integer } detail: { type: string } instance: { type: string, format: uri-reference }