diff --git a/docs/architecture/14-api-contract/README.md b/docs/architecture/14-api-contract/README.md index 9617747e..03bb406a 100644 --- a/docs/architecture/14-api-contract/README.md +++ b/docs/architecture/14-api-contract/README.md @@ -362,7 +362,18 @@ rejected» (`pipeline/mining.go:201`). Отсюда: решение на тер последней применённой. Хуже: кадр `bank` предписывал читать строки «ревизией этого кадра», что при строгом сравнении всегда возвращало пустоту. Поймано холодным потребителем, исправлено: чтение включительно, повторно пришедшая строка безвредна (строка заменяется по `id`), а водяной знак -следующего чтения — `revision` конверта, а не величина, выведенная из строк. Устаревший водяной знак +следующего чтения берётся из КОНВЕРТА, а не выводится из строк. + +⚠ **Дофикс 16.08 (ФБ-4): у многостраничного обхода конверт не один.** Формулировка «водяной знак — +`revision` конверта» была однозначна ровно до тех пор, пока чтение умещалось в одну страницу; на +рваном чтении она сталкивалась со вторым правилом того же раздела — «ревизия рваного списка это +ревизия СТАРЕЙШЕЙ страницы». Два правила давали два разных числа, и клиент, взявший новейшее, молча +терял строки, изменившиеся между первой страницей и последней. Сведено в одно: **и гард свежести, и +водяной знак — это НАИМЕНЬШАЯ ревизия, увиденная за обход**; на одностраничном ответе это его +собственная. Направление выбора — безопасное: лишнее перечитывание бесплатно (строка заменяется по +`id`), пропуск строки — нет. Там же заявлено равенство `BookDetail.revision` и +`BookDetail.book.revision`: карточка собирается одной транзакцией с книгой, и клиент вправе брать +любое. Устаревший водяной знак (коллекцию заменили целиком) отвечает `400` c `cause.code: version_too_old` — тем же ответом и с тем же смыслом, что мёртвый курсор. @@ -582,7 +593,8 @@ the original request was never applied». Каждый `POST /books` созда |---|---|---|---| | `GET /books`, `GET /books/{id}` | read-модель платформы | ПОСТРОЕНО | — | | `GET /books/{id}/run-options`, `POST /runs` | шкала + холды | ПОСТРОЕНО | — | -| `POST /runs/{id}/stop`, `/resume` | реконсилятор | ПОСТРОЕНО (кнопок на экране нет) | зона фронта | +| `POST /runs/{id}/stop` | реконсилятор | ПОСТРОЕНО (кнопки на экране нет) | зона фронта | +| `POST /runs/{id}/resume` | реконсилятор | ПОСТРОЕНО, но **поведение на паузе по потолку 0.3.0 МЕНЯЕТ**: сегодня платформа отвечает `202` и возвращает прогон в то же состояние (`runs/reconcile.go:906` — сама `Resume`; ветка `case "paused"` `:919` доходит до `reopen`, 409 только на дневном потолке движка `:931`), а контракт теперь требует `409` `run_not_resumable` · `cause.code: ceiling_reached`. Молчаливый `202` на действие, которое ничего не сделало, — ровно то, от чего предупреждает собственный комментарий платформы; клиенту нечем отличить успех от no-op. Полная таблица по статусам — в описании `resumeRun` | **вход P7** (правка построенного пути, не только читающей поверхности) | | `GET /usage` | кредиты | ПОСТРОЕНО, **не читается ни одним экраном** | зона фронта | | `GET /capabilities` | конфигурация деплоя | НЕ ПОСТРОЕНО (заведено 0.3.0) | вход P7 | | `PATCH`/`DELETE /books/{id}`, `GET /runs/{id}` | колонки есть | НЕ ПОСТРОЕНО (заведено 0.3.0) | вход P7 | @@ -715,7 +727,10 @@ the original request was never applied». Каждый `POST /books` созда схема `Counter` (одна полоса вместо двух) · `Book.genre` и `BookIntake.genre` (Б-23) · значение `finalizing` · прогонный поток `GET /runs/{id}/events` (перевешен на книгу) · зашитые числа размеров страниц из прозы операций · генезис-проза (история ратификации semver, две апологии RFC 9110, -объяснение имени `source`) — перенесена в этот файл, не удалена. +объяснение имени `source`) — **ужата НА МЕСТЕ до ссылки на источник, а не перенесена сюда**: в спеке +остались `semver §4` и одна строка про `Retry-After` на `200`, целиком выброшенного текста нет. +Формулировка «перенесена в этот файл» держалась ровно один раунд и исправлена дофиксом (ФБ-10): +проверяется грепом — этих абзацев в компаньоне нет. **Резать отказались, с контраргументом на каждое:** @@ -739,6 +754,71 @@ the original request was never applied». Каждый `POST /books` созда --- +## 6б. Дофикс-раунд 16.08 (заказ приёмки D39.142): что изменилось в каноне и почему + +Десять находок ХОЛОДНОГО ПОТРЕБИТЕЛЯ — линзы, которой у селф-ревью батча не было: только финальная +спека и проба генератором, без ревью, без компаньона, без диффа. Ниже — не пересказ правок, а их +основание; сами правки в каноне. + +**Форма кадра была двусмысленна (ФБ-1).** `EventEnvelope{event,id,data}` читался и как объект на +проводе, и как описание SSE-фрейминга — оба чтения соответствовали тексту, и клиент по второму +прочтению искал бы JSON с полем `data` внутри. Теперь на схеме стоит дословный пример кадра и +сказано прямо: `event` и `id` — ПОЛЯ SSE, телом кадра является `data` и только оно. Это тот класс +дефекта, который не ловится ни линтером, ни генератором: документ валиден в обоих прочтениях. + +**Книга в покое могла крутить переподключение вечно (ФБ-2).** Служебные кадры несут id последнего +кадра истории — а у книги, которая ещё ничего не производила, такого кадра нет. Клиент оставался без +`Last-Event-ID`, каждый его запрос был «новым потоком», сервер отвечал `hello`+`end`, браузер +переподключался — и так по кругу. Закрыто самой дешёвой из возможных мер: история нумеруется с `1`, +служебные кадры пустой книги несут `0`, а `Last-Event-ID: 0` на книге в покое попадает под уже +существующее правило `204`. Последовательность стала конечной: `hello` → `end` → закрытие → одно +переподключение → `204` → браузер останавливается. + +**Обещание «`note` не теряется» не переживало разрыв (ФБ-3).** Внутри одного соединения кадр-добавление +защищён запретом склейки; между двумя соединениями — ничем: буфер не обещан, `resync_required` за +обычный реконнект не полагается. Обещание не расширено (буфер обещать нечем), а названа обязанность +клиента: после КАЖДОГО переподключения — дельта-чтение `/notes?after_version=…`. Механизм для этого +уже был; не хватало записи, что он обязателен, а не удобен. + +**Пустые члены `allOf` давали необитаемые типы (ФБ-5).** `EventEnd` и `EventResyncRequired` +описывались как `EventBase` плюс пустой объект — генератор выводил `Record`, и +пересечение становилось типом, значение которого построить нельзя. Заменено на чистый `allOf` из +одного члена с описанием на самой схеме; проверено генератором — оба теперь `EventBase`. Попутно +записано то, что раньше подразумевалось: эти два кадра НЕРАЗЛИЧИМЫ по форме, диспетчеризация только +по имени события. + +**«Новый прогон» предписывался, а условия — нет (ФБ-6).** Спека велела предлагать новый прогон как +лечение стопа по потолку, но нигде не перечисляла, что делает `resume` в каждом останавливающем +статусе, а `paused_reason: null` описывался как «нейтрально, продолжаемо» — из чего клиент мог +заключить, что при `null` надо звать `resume`. Добавлена таблица по всем статусам и сказано прямо: +новый прогон легален при ЛЮБОМ `paused`, включая `null`; причина паузы — подсказка о прошлом, а не +разрешение на будущее. Значение enum при этом не заводилось — ратификация D39.132 п.2а не двигается. + +**Пол под моделью ошибок (ФБ-7).** Весь §Errors описывал ответы, которые СООТВЕТСТВУЮТ контракту; +что делать с ответом прокси, HTML-страницей или оборванным телом — не говорил никто, а именно там у +клиента нет ни `code`, ни `request_id`. Записано правило того же вида, что и для известных кодов: +не показывать из такого ответа ни байта, рисовать свою нейтральную фразу. + +**Пачка мелких (ФБ-8).** `title: null` в merge-patch — объяснено, почему это `400`, а не удаление +члена по RFC 7386 (книги без названия у этой поверхности не бывает) · `X-TM-Client` — записано, что +`required: true` стоит при живом исключении для bearer, потому что условной обязательности по схеме +безопасности OpenAPI не выражает, и валидатор не должен читать законное отсутствие как нарушение · +список отказов интейка помечен неисчерпывающим (авторитет — `code` ответа) · правило резолюции +`Location` · тождество `Idempotency-Key` на multipart считается по объявленным частям, а `408` не +считается состоявшейся попыткой · `min_chapters` при `max_chapters: 0` — не диапазон, клиент проверяет +максимум первым · порядок пяти носителей «нет кредита» · «carried forward marked as unverified» — снято +как обещание без носителя, заменено на наблюдаемое (`BankPage.signed` против `total`) · `409` у +экспорта объяснён как ключевой, а не книжный · неизвестный `term_id` в подписи отклоняет весь батч, а +не молча пропускает строку. + +**Слова, которые называли не то (ФБ-9).** `parser_unavailable` называл наш компонент — переименован в +`processing_failed`, по эффекту: имя значения это то, на что клиент вешает фразу. ⚠ Проводное имя +теперь отличается от внутреннего словаря платформы — там значение зовётся `parser_unavailable` +(`platform/internal/books/parse.go:66`), и проекция обязана отобразить одно на другое; рядом там же +живут `schema_mismatch` и `storage_unavailable`, которые на провод не идут вовсе. Вход P7. Из описаний вычищены внутренние ссылки (`research/28 §…`, +«companion К-6»): описания компилируются в исходники клиента, и ссылка на ревью в чужом репозитории +там — мусор; носители остались здесь. + ## 7. Эксплуатационные примечания — НЕ норма контракта Вынесено из спеки в 0.3.0 (Б-16). RFC 9205 §4.1 прямо про наш случай: «Requiring a particular version diff --git a/docs/architecture/14-api-contract/openapi.yaml b/docs/architecture/14-api-contract/openapi.yaml index 227a4d9f..e1b8b077 100644 --- a/docs/architecture/14-api-contract/openapi.yaml +++ b/docs/architecture/14-api-contract/openapi.yaml @@ -3,7 +3,7 @@ openapi: 3.1.0 info: title: TextMachine API version: 0.3.0 - summary: Ratified contract between the frontend and the TextMachine platform (D39.99, D39.138). + summary: Ratified contract between the frontend and the TextMachine platform. description: | **RATIFIED contract.** Canonical copy: `docs/architecture/14-api-contract/`; `frontend/docs/api-contract/` is a byte-mirror and a divergence is a defect of one of the two. @@ -36,6 +36,12 @@ info: **Signing in is not part of this surface**: session mechanics live outside the version prefix and the flow starts at `GET /auth/login` (companion). A client that meets `401` sends the user there. + ## `Location` + + Every `Location` here is a URI reference resolved against the request's URL (RFC 9110 §10.2.2); + it may be relative and usually is. A client follows it as given and does not rebuild the address + from an identifier of its own. + ## Conditional reads Every collection read and the book card answer an `ETag` and honour `If-None-Match` with `304`. A @@ -65,6 +71,13 @@ info: `title` and `detail` are for a developer and a log: a client MUST NOT show either, and draws the phrase from `code` — a neutral one for a code it does not know. See `Problem`. + **A failure that is NOT this shape is still a failure a user sees.** A gateway answering `502` + with an HTML page, a connection that dies mid-body, a body that does not parse: there is no + `code` to dispatch on and there may be no `X-Request-Id`. The rule is the same as everywhere — + **the client shows NOTHING from that response, not one byte of its body**, and draws its own + neutral phrase from what it does know: the status class if there is one, "the service could not + be reached" if there is not. This is not an error class of this API; it is the floor under it. + ## Versioning Semver. A client MUST ignore unknown fields and MUST tolerate unknown enum values without @@ -178,10 +191,19 @@ paths: wire. `Location` names the book card. Parsing has no numeric progress; its END arrives on the book's event stream as an ordinary status change. - Refusals: `400` unreadable form, missing or late part, over-long part, too many parts, - malformed language code, or a pair this deployment cannot translate (`code` and `errors[]` - say which) · `404` this deployment takes no books at all (`intake_enabled`) · `408` the body - did not finish in time, retry · `413` over `intake_max_bytes`. + Refusals, the common ones and NOT an exhaustive list — the authority is the response's own + `code`, and every code of `ErrorCode` may answer here: `400` unreadable form, missing or late + part, over-long part, too many parts, malformed language code, or a pair this deployment + cannot translate (`code` and `errors[]` say which) · `404` this deployment takes no books at + all (`intake_enabled`) · `408` the body did not finish in time, retry · `413` over + `intake_max_bytes`. + + **`Idempotency-Key` on a multipart body.** "The same request" is compared over the DECLARED + parts — the metadata and the file's name and size — and never over the bytes themselves: a + server does not hold a book in memory to compare it, and a retry of an interrupted upload + re-sends the same file. Consequence, and it is the case that matters: **a `408` is not a + completed attempt**, so the same key may be presented again; the retry is a repeat of the + first call, not a second book. parameters: - $ref: '#/components/parameters/ClientHeader' - $ref: '#/components/parameters/IdempotencyKey' @@ -241,8 +263,14 @@ paths: is written once at intake and never rewritten. The language pair is NOT patchable: changing it is a re-translation, not an edit. - Accepted while a run is live: a rename touches nothing a run reads. `title: null` is refused - with `400`. + Accepted while a run is live: a rename touches nothing a run reads. + + ⚠ **`title: null` is refused with `400`** (`invalid_request`, `errors[]` pointing at + `/title`) — and that is a deliberate narrowing of RFC 7386, where `null` means "remove this + member". A book without a title is not a state this surface has: the field is required on + `Book`, and the intake fills it from the file name when the user gives none. "Remove the + title" therefore has no meaning to grant, and answering `400` is honest where silently + restoring the derived name would be a rename the user did not ask for. parameters: - $ref: '#/components/parameters/ClientHeader' requestBody: @@ -430,6 +458,12 @@ paths: Submission is PARTIAL and accumulates on the server — a closed tab must not cost an hour of work. + + **A `term_id` this book's bank does not hold** — a stale row from before a rebuild, or one + belonging to another book — refuses the WHOLE call: `400`, `code: invalid_request`, with an + `errors[]` entry pointing at the item (`/decisions/2/term_id`, item code `unknown`). Refusing + the batch rather than skipping the row is deliberate: a silently dropped decision reads on + the screen as a decision that was saved. parameters: - $ref: '#/components/parameters/ClientHeader' requestBody: @@ -481,7 +515,7 @@ paths: description: | `stop_for_signing` and `ceiling_chapters` are parameters of the RUN, not of the book: they travel with the start and do not outlive it. With `stop_for_signing` the run waits for the - bank to be signed; without it the unsigned bank is carried forward marked as unverified. + bank to be signed; without it the run does not stop and uses the bank as it stands. **409** answers a limit that no longer fits — the bounds are read by `GET /books/{bookId}/run-options` and may move in between. When another book's hold is the @@ -541,6 +575,14 @@ paths: later one of its kind, and a client MUST tolerate counters that jump. `note` is an ADDITION: it MUST NOT be coalesced or dropped — a lost one is lost silently and forever. + ⚠ **That guarantee covers one connection, not a gap between two.** Nothing is buffered for a + client that is not attached, the live buffer above is not promised, and a `resync_required` + is not owed for an ordinary reconnect — so a note raised while the client was away can be + missing from the stream. **After EVERY reconnect the client MUST close that gap with a delta + read** — `GET /books/{bookId}/notes?after_version=` — before + trusting its list. The same read repairs any state frame lost with it, and costs one request + against a validator when nothing changed. + **Reconnect.** The client sends `Last-Event-ID`. The server MAY resend frames it still holds in a short live buffer after that id and MUST NOT replay history beyond it; the buffer's size is not declared and a client MUST NOT depend on any frame being resent. If the server cannot @@ -551,6 +593,13 @@ paths: book is at rest, is answered `204` — which is how SSE is told to stop reconnecting. A request WITHOUT `Last-Event-ID` always opens a new stream. + ⚠ **A book at rest must not become an endless open-and-close.** Every frame carries an `id`, + `hello` included — `0` on a book that has produced no history yet (`EventEnvelope.id`) — so a + client always has something to present on the reconnect a browser makes by itself after + `end`, and that reconnect is the one answered `204`. The sequence is therefore bounded: + `hello` → `end` → close → one reconnect → `204` → the browser stops. A server that omitted + the id on `hello` would leave the client with nothing to send and reopen the stream forever. + One stream per book being watched; there is no library-wide stream, and a list screen does NOT open one per row. A `chapter` frame for a chapter the client does not hold is IGNORED — a frame is never a reason to page a collection. A deleted book ends its stream, and a reconnect @@ -625,15 +674,25 @@ paths: operationId: resumeRun summary: Continue a stopped run. description: | - Clears the bank-signing stop and continues after a stop the user asked for. + Clears the bank-signing stop and continues after a stop the user asked for. **What it does + in every other state, so a client never has to guess which stop it is looking at:** - **409 while the set of bank decisions is incomplete** — the stop clears only on a complete - set; `cause.code: bank_decisions_incomplete`. + | the run stands in | this call answers | the remedy a client offers | + |---|---|---| + | `awaiting_bank`, decisions complete | continues it — `202` | this call | + | `awaiting_bank`, decisions incomplete | `409` `run_not_resumable`, `cause.code: bank_decisions_incomplete` | finish signing, then this call | + | `paused` — ANY reason, `null` included | `409` `run_not_resumable`, `cause.code: ceiling_reached` | a NEW run with a larger `ceiling_chapters` | + | `stopped` — the user's own stop | continues it — `202` | this call | + | `failed`, `ready` | `409` `run_not_resumable`, no `cause` — it is not a stopped run | a NEW run | + | `translating` | `409` `run_not_resumable`, no `cause` | nothing; it is already running | - ⚠ **A run stopped at its limit is NOT continued by this call** — `409`, - `cause.code: ceiling_reached`. The limit travels with the START of a run and nothing changes - it afterwards. The remedy is a NEW run with a larger `ceiling_chapters`: the paused book is - startable and finished work is not bought again. A client offers that, not this call. + ⚠ **A NEW run is legal from ANY paused book, whatever `paused_reason` says — `null` + included.** The limit travels with the START of a run and nothing changes it afterwards, so a + run stopped at one is never continued by this call; the reason is a hint about WHY the work + stopped, never a gate on what may be started next. The book is startable, + `POST /books/{bookId}/runs` takes a larger `ceiling_chapters`, and finished work is not + bought again. A client that waits for a particular reason value before offering that strands + the user on the commonest stop there is. **503 answers a deployment that cannot run at all** — continuing a run is starting a process. parameters: @@ -687,6 +746,11 @@ paths: Completion is POLLED: the `202` names the status resource in `Location` and the status read carries `Retry-After` while the build runs. Repeating with the same `Idempotency-Key` returns the original `202` and `Location` rather than building a second copy. + + **The `409` here is about the key, not about the book** — `idempotency_conflict`, when a key + is re-used for a different request or while the first is still running. Nothing about a book's + state conflicts with exporting it: a book still being translated may be exported, and a second + export under a different key is a second artifact, not a conflict. parameters: - $ref: '#/components/parameters/ClientHeader' requestBody: @@ -768,7 +832,7 @@ components: is established in middleware only; no endpoint may assume a cookie. ⚠ **Nothing issues such a token today** — the server accepts one, but no call here or in the - session mechanics hands one out. Carrier: research/28 §2 (Б-15); companion §3. + session mechanics hands one out. The companion carries who owes that and when. headers: RequestId: @@ -816,8 +880,13 @@ components: Present on every unsafe request presented by session cookie; the value is arbitrary (see the `sessionCookie` scheme). Absent, such a request is `403` with `code: forbidden`. - Declared required because the browser client always has to send it; a client presenting a - bearer token is exempt by the security scheme. + ⚠ **Declared `required: true` although one class of client is exempt.** The rule is "required + on the COOKIE path", and OpenAPI has no way to make a parameter conditional on which security + scheme was used. Declaring it required is the choice that makes a generated browser client + send it; a client presenting `Authorization: Bearer` legally omits it, and a validator + checking requests against this document must not read that omission as a violation. The + server enforces the real rule: the header is demanded only of a request carrying the session + cookie. schema: { type: string, minLength: 1 } IdempotencyKey: name: Idempotency-Key @@ -865,8 +934,9 @@ components: one revision but several ROWS. Re-reading a row already held costs nothing — a row is replaced by its `id`. - **The watermark for the next delta read is the `revision` of the envelope just received**; - rows carry no version of their own. + **The watermark for the next delta read is the revision of the WALK just completed** — the + lowest envelope revision seen across its pages, which for a single-page answer is that page's + own (`Revision`). Rows carry no version of their own. A DELETION cannot be expressed this way. Two answers close that: `resync_required` on the stream when a collection is replaced wholesale, and `400` with @@ -1017,15 +1087,26 @@ components: that book carry the same number. The library has its own scope, and a revision is never compared across scopes. + **One number per ENVELOPE.** A response carries the revision at which the server built THAT + response; a frame carries it inside `data`. `BookDetail.revision` is the revision of the + envelope, and `BookDetail.book.revision` is the same number — the card is built in one + transaction with the book it carries, and a client may use either. + **Discarding a stale read is the CLIENT's duty**: a read whose revision is lower than what it has already applied MUST be dropped rather than rendered, or the interface rolls progress backwards on every refetch. The comparison is per SCOPE and per COLLECTION — never against the library's, never across two collections of one book, and an export poll is never dropped for carrying an older number than the chapter tree. - **A list read across several pages is torn**, and its revision is that of the OLDEST page — - stamped with the newest, a list whose head predates an applied frame would pass the guard - above and overwrite it. + **A list read across several pages is torn**, and ONE number stands for the whole walk: the + LOWEST revision seen across its pages. Stamped with the newest, a list whose head predates an + applied frame would pass the guard above and overwrite it. + + That single number is also **the watermark for the next delta read** of that collection. + Taking the newest envelope of the walk instead would skip every row changed between the first + page and the last; taking the lowest re-reads a few rows, which costs nothing because a row + is replaced by its `id` (`after_version`). A one-page read is the same rule with one page in + the walk. **Catch-up after a reconnect reads `revision >= R`, not `> R`**: one transaction is one revision but SEVERAL frames. @@ -1223,15 +1304,16 @@ components: again; - `not_configured` — this deployment has nothing to read the book AGAINST. A state of the service, and retrying alone does not clear it; - - `parser_unavailable` — the service failed on the file repeatedly and gave up. Also a state - of the service, and temporary; + - `processing_failed` — the service tried this file repeatedly and gave up. Also a state of + the service and a temporary one: named by what happened, not by which component of ours + was unwell, because the name is what a client keys a phrase on; - `content_refused` — the service will not translate this book. **One coarse reason for a whole class**: it never says which check refused, never varies between attempts, and gives nothing to search against. A client shows one neutral phrase and does not invite a retry. A client MUST tolerate an unknown value under a minor bump and MUST render a rejected book that carries no reason at all: `null` is legal. - enum: [source_unreadable, not_configured, parser_unavailable, content_refused] + enum: [source_unreadable, not_configured, processing_failed, content_refused] PausedReason: type: string @@ -1582,7 +1664,8 @@ components: type: string description: | How much attention the note asks for. Two steps today; how many there ought to be is an open - product question (companion K-6), and a client MUST tolerate a new value under a minor bump. + product question the companion tracks, and a client MUST tolerate a new value under a minor + bump. enum: [attention, glance] Note: @@ -1665,7 +1748,8 @@ components: type: object description: | A memory bank row. Provenance is `origin` and the term's two surfaces are `src`/`dst`; the - name `source` is deliberately unused — companion §2.8. + name `source` is deliberately unused here, because it means something else on the other side + of this boundary and a field name must not mean two things. required: [id, src, dst, kind, status, origin, sense, since_chapter, until_chapter] properties: id: @@ -1816,7 +1900,9 @@ components: type: boolean description: | Stop when the book's terms are ready and wait for them to be signed; without it the - unsigned bank is carried forward marked as unverified. + run does not stop for it and uses the bank as it stands. Nothing on this surface marks a + book as having been translated against an unsigned bank: what is observable is the bank + itself — `BankPage.signed` against `BankPage.total`. ceiling_chapters: type: integer minimum: 1 @@ -1829,6 +1915,24 @@ components: RunOptions: type: object + description: | + What a run may be started with, read immediately before starting one. + + **Five places on this surface can say "there is no credit", and they answer different + questions. In this order:** + + 1. the `409` of `POST /books/{bookId}/runs` (`ceiling_unavailable`) — the only authority on + whether THIS start may happen; every read below can be stale by the time it is used; + 2. `RunOptions.ceiling.max_chapters == 0` and `RunOptions.blocked` — whether a start is worth + offering AT ALL right now, and what is holding it. This is what the start screen draws; + 3. `Run.paused_reason: credit_exhausted` — why a run that already ran stopped. History, not a + gate: a new run may still be startable (see `resumeRun`); + 4. `Usage.halt_reason` — the ACCOUNT is halted, which is a state of the account and not of + any book; + 5. `Usage.state: exhausted` — the account screen's own summary, the coarsest of the five. + + A client that read them in the opposite order would refuse to offer a run the server would + have accepted. required: [ceiling, blocked] properties: ceiling: { $ref: '#/components/schemas/CeilingBounds' } @@ -1872,6 +1976,13 @@ components: `max_chapters: 0` means no run can start — the client shows the exhausted state instead of a scale, and `RunOptions.blocked` may say what is holding it. + + ⚠ **When `max_chapters` is `0` the three numbers are not a range.** `min_chapters` stays `1` + — it is the smallest limit this service will ever accept, not a bound derived from what is + affordable — so the pair reads `min 1, max 0`, which is empty by construction. **A client + tests `max_chapters == 0` FIRST** and never treats `min_chapters` as the start of a scale + before it has done so. The alternative, moving `min` to `0`, would make "one chapter" and + "nothing at all" the same value. required: [min_chapters, max_chapters, default_chapters] properties: min_chapters: @@ -1958,7 +2069,7 @@ components: description: | Machine reason when `state` is `failed`, `null` otherwise; the phrase is the client's. Not enumerated — how an export can fail depends on formats that do not exist yet. It - becomes an enum with the first built format. Carrier: research/28 §2 (Б-4). + becomes an enum with the first built format; the companion carries why it is open. url: type: [string, 'null'] format: uri @@ -1972,7 +2083,29 @@ components: EventEnvelope: type: object description: | - An SSE frame. OpenAPI does not type stream frames, so the mapping is fixed here: + An SSE frame. + + ⚠ **This schema is a DESCRIPTION, not the shape of a JSON object on the wire.** OpenAPI + cannot type `text/event-stream`, so the three members below are written as one object; on the + wire they are not one. A frame is exactly: + + ``` + event: progress + id: 57 + data: {"revision":1841,"structure_version":3,"progress":{...}} + + ``` + + `event` and `id` are SSE FIELDS of the frame (`EventSource` exposes them as `event.type` and + `event.lastEventId`); the JSON body of the frame is `data` ALONE, and it is the `data` schema + of the table below — never an object containing `event`/`id`/`data`. A heartbeat is a comment + line (`:` and a newline) and is not a frame. + + The two numbers in that example are deliberately unlike: `id` is this frame's position in the + book's event history, `revision` is the book's state. They are not the same counter and a + client never substitutes one for the other. + + OpenAPI does not type stream frames, so the mapping is fixed here: | `event` | `data` schema | When | |---|---|---| @@ -2007,12 +2140,22 @@ components: consumes none of its own, so the same id legally appears more than once in one stream. A client stores the id it last saw and sends it back; it never counts with it. + **A book that has produced no history at all** — never run, still arriving — has no last + frame, and its connection frames carry `0`. History numbering starts at `1`, so `0` is + "nothing yet" and can never collide with a real frame. This is what stops the loop on a + book at rest: the client has an id to send, presents `Last-Event-ID: 0`, and is answered + `204` instead of another empty stream. + **A gap is legal** — coalescing removes frames, and a client MUST NOT read a skipped number as a lost frame. data: description: | - Frame payload. `anyOf` and not `oneOf`: dispatch is by the event NAME, and two frames - legally carry the same shape. + Frame payload — the JSON body of the SSE frame, per the table above. + + `anyOf` and not `oneOf`: dispatch is by the event NAME, and two frames legally carry the + same shape. **`resync_required` and `end` are exactly that case** — both are `EventBase` + and nothing more, so a client that tried to tell frames apart by their members would + confuse them. Dispatch on `event` first, always; the shape only validates what arrived. anyOf: - $ref: '#/components/schemas/EventHello' - $ref: '#/components/schemas/EventStatus' @@ -2114,22 +2257,25 @@ components: complete: { type: boolean } EventResyncRequired: + description: | + The server cannot resume from the presented `Last-Event-ID`, or the book's collections were + replaced wholesale. The client MUST re-read what it holds in full: a delta cannot express a + deletion. + + Payload is exactly `EventBase` — no member of its own. Told apart from `end` by the frame's + `event` name and by nothing else; see the dispatch rule on `EventEnvelope.data`. allOf: - $ref: '#/components/schemas/EventBase' - - type: object - description: | - The server cannot resume from the presented `Last-Event-ID`, or the book's collections - were replaced wholesale. The client MUST re-read what it holds in full: a delta cannot - express a deletion. EventEnd: + description: | + Nothing further will arrive: no run is live and no intake is in flight. The client closes and + does NOT reconnect automatically; it opens a new stream when it has a reason to watch again. + + Payload is exactly `EventBase` — no member of its own. Told apart from `resync_required` by + the frame's `event` name and by nothing else. allOf: - $ref: '#/components/schemas/EventBase' - - type: object - description: | - Nothing further will arrive: no run is live and no intake is in flight. The client closes - and does NOT reconnect automatically; it opens a new stream when it has a reason to watch - again. Problem: type: object @@ -2195,8 +2341,8 @@ components: $ref: '#/components/schemas/LocalizedMessage' description: | A phrase written by the SERVER, to be shown as it is — the single exception, for causes - that cannot be enumerated in advance. No code in this version carries it. Carrier: - research/28 §8 п.4. + that cannot be enumerated in advance. No code in this version carries it; the companion + carries the rule for when one may. ErrorCode: type: string @@ -2228,8 +2374,9 @@ components: being cut, or was rejected; - `run_not_stoppable` (409) — this run is not running; - `run_not_resumable` (409) — this run cannot be continued. `cause.code` says why: - `bank_decisions_incomplete` — terms are still undecided; `ceiling_reached` — the run stopped - at its limit, and the remedy is a NEW run with a larger one, not this call; + `bank_decisions_incomplete` — terms are still undecided; `ceiling_reached` — the run + stopped at a limit, the chapters it bought or the credit behind them, and the remedy is a + NEW run rather than this call. A run that is not a stopped run at all carries no `cause`; - `ceiling_unavailable` (409) — the limit asked for cannot be started. `cause.code`: `bounds_moved` — the bounds changed between the read and this call; `credit_held` — the account's credit is held elsewhere, and `blocked` names the book holding it; @@ -2295,7 +2442,8 @@ components: minLength: 1 description: | `missing` · `missing_or_late` (absent, or sent after the file) · `malformed` · - `too_long` · `unsupported_pair` · `out_of_range`. Not closed, like `cause.code`. + `too_long` · `unsupported_pair` · `out_of_range` · `unknown` (an identifier this book + does not hold). Not closed, like `cause.code`. examples: ['missing_or_late'] LocalizedMessage: diff --git a/docs/archive/reports/CONTRACT_BATCH_0.3.0_REPORT.md b/docs/archive/reports/CONTRACT_BATCH_0.3.0_REPORT.md index a01b17fb..253ea9f8 100644 --- a/docs/archive/reports/CONTRACT_BATCH_0.3.0_REPORT.md +++ b/docs/archive/reports/CONTRACT_BATCH_0.3.0_REPORT.md @@ -12,36 +12,36 @@ ## 0. Что сделано, одним экраном -| | было (0.2.3) | стало (0.3.0) | +| | было (0.2.3) | стало (0.3.0, после дофикса) | |---|---|---| | операций / путей | 16 / 15 | **20 / 17** | -| строк файла | 1445 | **2290** | -| форма (структурные строки) | 828 | **1336** (+61 %) | -| проза (строки внутри описаний) | 472 | **757** (+60 %) | -| доля прозы | 36 % | **36 %** | -| причина отказа на проводе | английская фраза, показываемая «as-is» | машинный `code` (15 корневых) + `cause` + `request_id` + `errors[]` | +| строк файла (`wc -l`) | 1444 | **2463** | +| форма (структурные строки) | 760 | **1183** | +| проза (строки внутри описаний) | 540 | **1055** | +| доля прозы | 42 % | **47 %** | +| причина отказа на проводе | английская фраза, показываемая «as-is» | машинный `code` (16 корневых) + `cause` + `request_id` + `errors[]` | | прогресс | `{draft{done,total}, edit{done,total}}` по всей книге, в юнитах | ОДНА полоса до ближайшей остановки, в главах, знаменатель — купленный объём | | поток | `GET /runs/{runId}/events` | `GET /books/{bookId}/events` + кадр `end` + `204` | | условные чтения | нет ни одного | `ETag`/`If-None-Match`/`304` на 7 чтениях + дельта `?after_version=` | | «что умеет деплой» | нигде | `GET /capabilities` | -Команды, которыми получены числа: +Команды, которыми получены числа (пере-ран после ПОСЛЕДНЕЙ правки дофикса — требование ФБ-10): ``` grep -c 'operationId:' openapi.yaml # 16 → 20 grep -cE '^ /' openapi.yaml # 15 → 17 -wc -l openapi.yaml # 1445 → 2290 -git show HEAD:...openapi.yaml > /tmp/old.yaml # снимок «было» для всех сверок +wc -l openapi.yaml # 1444 → 2463 +awk-счёт значений ErrorCode # → 16 +python3 /tmp/prose_form.py (скрипт приложен в §4.4) # проза/форма обеих версий +git show 1022567:…openapi.yaml > /tmp/old.yaml # снимок «было» для всех сверок ``` -Проза/форма меряны одним скриптом по обоим файлам (лист-скан: непустая строка внутри блочного -скаляра `description:|`/`summary:|` — проза, остальные — форма; пустые не считаются). Число «43 %» из -research/28 §5а получено другой меркой и с этими двумя не сравнивается — сравнимы только они между -собой. -**Форма выросла на 61 %, проза — на 60 %, доля прозы не сдвинулась.** Это и есть ответ на вопрос §8 -п.3 «не переусложнён ли контракт»: батч добавил четыре операции и шестнадцать схем, и текст вырос -ровно пропорционально поверхности, а не быстрее неё. Как этого добились — §4.4; первая редакция -батча давала 43 % и 2587 строк, то есть проза росла быстрее формы, и это было исправлено отдельным -проходом. +**Форма выросла на 56 %, проза — на 96 %; доля прозы выросла с 42 % до 47 %.** Это ответ на +вопрос §8 п.3 «не переусложнён ли контракт», и он честнее прежнего: батч добавил четыре операции и +шестнадцать схем, а каждая находка трёх линз селф-ревью и десять пунктов дофикса закрываются +ПРАВИЛОМ — а правило это проза. Прежний вывод «доля не сдвинулась» держался на мерке, где строка +`description: |` считалась формой; скрипт теперь приложен (§4.4), число можно оспорить командой, и +утверждать «вернул к уровню 0.2.3» я больше не могу. Что резалась вода, а не норма, доказано отдельно +и от мерки не зависит: типами и мультимножеством модальных глаголов (§4.4). ## 1. Записка-план: каждый ID заказа с диспозицией @@ -60,7 +60,7 @@ research/28 §5а получено другой меркой и с этими д | | из ВСЕХ описаний вычистить конвейерные слова | **ИСПОЛНЕНО**, проверено грепом (§4.2) | весь файл | | | счётчик ГЛАВЫ той же сегментной логикой | **ИСПОЛНЕНО.** `ChapterProgress.units_done` — «в текущем сегменте», с явным «иначе дерево читало бы ноль всю первую волну» | `§ChapterProgress.units_done` | | | завести гейт на утечку | **ЧАСТИЧНО — по границе зон, как заказано промтом §3.** Правило записано в компаньон как ревью-вопрос каждой правки со списком грепа; ТЕСТ — половина фронта, носитель — пинг фронту 16.08, исполнение при разморозке | компаньон §5 | -| **§5.1 / Б-1** | модель ошибок, вариант B | **ИСПОЛНЕНО ПЕРВОЙ** (порядок §3 промта соблюдён: словарь кодов определён до формулировок Б-2/Б-3/Б-8/Б-14а). `code` (15 корневых, закрытый) + `cause{code}` (второй уровень, НЕ закрытый) + `request_id` + `errors[]{pointer,code}` + `localized` + `blocked`; `title`/`detail` объявлены developer-facing и неотображаемыми, `title` получил `minLength:1` | `§Problem`, `§ErrorCode`, `§ErrorCause`, `§ErrorItem` | +| **§5.1 / Б-1** | модель ошибок, вариант B | **ИСПОЛНЕНО ПЕРВОЙ** (порядок §3 промта соблюдён: словарь кодов определён до формулировок Б-2/Б-3/Б-8/Б-14а). `code` (16 корневых, закрытый) + `cause{code}` (второй уровень, НЕ закрытый) + `request_id` + `errors[]{pointer,code}` + `localized` + `blocked`; `title`/`detail` объявлены developer-facing и неотображаемыми, `title` получил `minLength:1` | `§Problem`, `§ErrorCode`, `§ErrorCause`, `§ErrorItem` | | | два класса конкретности §8а | **ИСПОЛНЕНО.** Класс 1 — максимальная конкретика (`errors[]` + `cause`); класс 2 — ОДИН код `content_refused` без `cause`, без `errors`, без вариации между попытками; тем же правилом заведено значение `RejectReason.content_refused` (К-9) | `§ErrorCode.content_refused`, `§RejectReason` | | **§5.2 / Б-15** | `X-TM-Client` машинно · 403 · `WWW-Authenticate` · множество методов · `servers.url` · судьба bearer | **ИСПОЛНЕНО ПОЛНОСТЬЮ.** Заголовок — параметр на 9 небезопасных операциях; `403` — компонент-ответ; `401` несёт обязательный `WWW-Authenticate`; методы приведены к коду и RFC (`OPTIONS` освобождён); `servers[0].url: /v0`; bearer ОСТАВЛЕН с явной записью «сервер принимает, выдать нечем» + носитель | `§ClientHeader`, `§responses.Forbidden`, `§responses.Unauthorized`, `§sessionCookie`, `servers`, `§bearerToken` | | **§5.3 / Б-2** | `GET /capabilities` | **ИСПОЛНЕНО.** `contract_version` · `language_pairs[]` со статусом · `intake_max_bytes` · `export_formats[]` · `page_size_default`. Один плоский ответ на деплой (предупреждение судьи о соразмерности учтено: ни ETag-мультитенантности, ни пер-аккаунтных оверрайдов) | `/capabilities`, `§Capabilities` | @@ -71,7 +71,7 @@ research/28 §5а получено другой меркой и с этими д | ID | Заказ | Диспозиция | Где | |---|---|---|---| | **§5.5 / Б-6** | перевесить поток на книгу | **ИСПОЛНЕНО** — `GET /books/{bookId}/events`, `streamBookEvents` | `/books/{bookId}/events` | -| | кадры конца разбора и конца прогона | **ИСПОЛНЕНО ИНАЧЕ, чем буквально:** конец разбора — обычная смена статуса на книжном потоке (кадр не нужен, и Б-6(а) сам это говорит: «чинить не кадром, а перевеской»); конец ПОТОКА — новый кадр `end` | `§EventEnd` | +| | кадры конца разбора и конца прогона | **ОТСТУПЛЕНИЕ, аргумент:** конец разбора — обычная смена статуса на книжном потоке (кадр не нужен, и Б-6(а) сам это говорит: «чинить не кадром, а перевеской»); конец ПОТОКА — новый кадр `end` | `§EventEnd` | | | `204` при переподключении к завершённому | **ИСПОЛНЕНО** с точной границей: `204` только на запрос С `Last-Event-ID`; без него всегда открывается новый поток | `/books/{bookId}/events` → `204` | | | развести `id` кадра и ревизию | **ИСПОЛНЕНО.** `id` — позиция потока, форма зафиксирована `^[0-9]+$`; `revision` — в `data` каждого кадра через `EventBase` | `§EventEnvelope.id`, `§EventBase` | | | правило докачки — одно | **ИСПОЛНЕНО:** короткий живой буфер разрешён, реплей истории запрещён, размер не объявляется | `/books/{bookId}/events` → Reconnect | @@ -106,6 +106,9 @@ research/28 §5а получено другой меркой и с этими д | **§5.15 / Б-10+Б-11** | максимум `limit` и подрезание | **ИСПОЛНЕНО:** `maximum: 1000` (= `maxPage`, `pgstore/books.go:587`) + «CLAMPED DOWN, never answered with the default instead» | `§Limit` | | | объявленный порядок коллекций | **ИСПОЛНЕНО для всех пяти** (библиотека · главы · юниты · замечания · банк) | описания пяти list-операций | | | `maxItems` у `decisions` | **ИСПОЛНЕНО** (1000) | `§BankDecisionsRequest` | +| **Б-23 / §3 промта** | жанр — выкинуть из `BookIntake` и `Book` | **ИСПОЛНЕНО** (слово владельца 16.08). Поля нет ни в интейке, ни в карточке; остальные половины удаления — снятие `{{genre}}` из промптов пары и из канона брифа — строка 184 и НЕ моя зона. ⚠ Строка отсутствовала в записке-плане первой редакции и дописана дофиксом (ФБ-10) | `§BookIntake`, `§Book` | +| **К-10 / D39.138 п.5** | пофазность у главы — НЕ строить | **ИСПОЛНЕНО КАК «НЕ СТРОИТЬ».** Фазы не выходят на провод ни у книги, ни у главы: `ChapterProgress.units_done` — один счётчик «в текущем сегменте». Колонки `units_draft_done`/`units_edit_done` read-модели остаются внутренним делом платформы. Строка дописана дофиксом (ФБ-10) | `§ChapterProgress` | +| **В-6 / D39.138 п.2з** | `blocked: {code, book_id}` на run-options и 409 | **ИСПОЛНЕНО** обоими носителями: `RunOptions.blocked` (required, nullable) и расширение `Problem.blocked` у `ceiling_unavailable`; `cause.code: credit_held` называет причину, `book_id` — держателя. Строка дописана дофиксом (ФБ-10) | `§RunOptions.blocked`, `§Blocked`, `§Problem.blocked` | | **§5.16 / Б-14** | ОДНО правило кодирования отсутствия | **ИСПОЛНЕНО: правило ЗАПИСАНО** отдельным разделом шапки и применено к `Usage.halt_reason`, `Run.finished_at`/`paused_reason`/`failure_reason`, `Book.character_count`/`reject_reason`, `Progress.eta_seconds`, `Unit.note`, `RunOptions.blocked`, `Export.*` | info §Absence of a value | | | условная обязательность `Unit.target` | **ИСПОЛНЕНО** — `if/then` + слова (К-11: генератор `if/then` игнорирует) | `§Unit.target` | | | имена конвертов | **ИСПОЛНЕНО:** `Library/ChapterList/UnitList/NoteList/Bank` → `BookPage/ChapterPage/UnitPage/NotePage/BankPage` на общем `Page` через `allOf` | `§Page` и др. | @@ -138,7 +141,7 @@ research/28 §5а получено другой меркой и с этими д | слить формально пять конвертов на `allOf` | **ИСПОЛНЕНО** | | `EventChapter` = подмножество `Chapter` | **ИСПОЛНЕНО:** выделен `ChapterProgress`, `Chapter` и `EventChapter` собраны из него через `allOf` — переименования `id`→`chapter_id` больше нет, клиент не разбирает его руками | | не доводить до финала `listBankTerms`/`listNotes` | **УЧТЕНО:** словарь ступеней не проектировался (К-6), поля `BankTerm` не добавлялись (D39.136 п.4б), `Note.code` НЕ сделан enum до написания фраз, число страницы замечаний не выдумывалось | -| генезис-проза (semver-история, две апологии RFC 9110, объяснение имени `source`) | **ПЕРЕНЕСЕНО в компаньон**, не удалено (промт §5д) | +| генезис-проза (semver-история, две апологии RFC 9110, объяснение имени `source`) | **УЖАТО НА МЕСТЕ, не перенесено** — отступление от §5д промта, аргумент в §1.7(д) | | зеркальный дефект `/usage` построен и не читается | **ЗАПИСАНО** в таблицу компаньона §3 | ### 1.6. §5б шаги 3–4 и Б-11а (заказ ПОВЕРХ §5) @@ -157,7 +160,7 @@ research/28 §5а получено другой меркой и с этими д | **(б)** таблица «чтение → источник → строка бэклога» + правило «предупреждение несёт номер строки» | **ИСПОЛНЕНО** — компаньон §3, 19 строк; правило поставлено над таблицей | | **(в)** провенанс-классы ✓/◆/○ под 0.3.0 | **ИСПОЛНЕНО** — компаньон §0, «Пере-разметка 0.3.0»: четыре класса сдвинуты, `Progress` ✓→◆ с объяснением, почему прежняя ✓ и была дефектом | | **(г)** приложение А на словарь кодов Б-1, слова не заполнять | **ИСПОЛНЕНО.** Карта переведена на «причина движка → КОД контракта → фраза ⬜»; класс 2 схлопнут в один код (нормативно, §8а); коды помечены ◆ и в спеку как enum НЕ занесены. Добавлено приложение А-2 — карта кодов ОШИБОК, заполненная целиком с грунтом `file:line` на каждый | -| **(д)** генезис-проза из спеки — в компаньон | **ИСПОЛНЕНО** (27 блоков перенесено/сжато) | +| **(д)** генезис-проза из спеки — в компаньон | **ОТСТУПЛЕНИЕ, аргумент.** Фактически проза УЖАТА НА МЕСТЕ до ссылки на источник (`semver §4`, одна строка про `Retry-After` на `200`), а не перенесена в компаньон: переносить в спутник абзац, от которого осталась ссылка, значит плодить третью копию. Проверяется грепом — этих абзацев в компаньоне нет. Ярлык «ИСПОЛНЕНО (27 блоков перенесено)» был неверен и исправлен дофиксом (ФБ-10); компаньон §6а поправлен там же | ## 2. Решения, которые я принял сам (промт §4) @@ -189,7 +192,7 @@ research/28 §5а получено другой меркой и с этими д ### 2.4. `promote` → `approve` Б-0 называет `promote`/`decline` глаголами оператора майнера (они дословно из -`pipeline/mining.go:201`). Плюс `promote` порождал `status: approved` — два слова на один акт. +`pipeline/mining.go:81,243`). Плюс `promote` порождал `status: approved` — два слова на один акт. `decline` оставлен: обычное слово, к именам файлов майнера не привязано. ### 2.5. Порядок работ @@ -292,7 +295,7 @@ cd frontend && npx spectral lint ../docs/architecture/14-api-contract/openapi.ya ``` cd frontend && npx openapi-typescript ../docs/architecture/14-api-contract/openapi.yaml \ -o /schema-probe.ts -→ EXIT=0, 2916 строк +→ EXIT=0; на заленденном каноне 2608 строк, после дофикса 16.08 — 2798 ``` Прогонялся трижды: после первой редакции, после де-утечки, после правок по селф-ревью. @@ -309,12 +312,49 @@ cd frontend && npx openapi-typescript ../docs/architecture/14-api-contract/opena ### 4.4. Резка прозы — отдельный проход, проверенный типами -Первая редакция батча дала 2587 строк при доле прозы 43 % (у 0.2.3 — 36 %). То есть я исполнил букву +⚠ **Пере-счёт 16.08 (ФБ-10): исходный вывод «доля прозы не сдвинулась» держался на методе, который +не был приложен, и другим методом не воспроизводится.** Скрипт приложен ниже, чтобы число можно было +оспорить командой, а не поверить на слово: + +```python +# /tmp/prose_form.py — проза = строки блочных скаляров description/summary (и строка ключа), +# форма = всё прочее непустое; конец блока определяется по возврату отступа на уровень ключа. +import sys, re +def measure(path): + lines = open(path).read().split('\n'); prose = form = 0; in_block = False; key_indent = 0 + for ln in lines: + if not ln.strip(): continue + indent = len(ln) - len(ln.lstrip()) + if in_block: + if indent > key_indent: prose += 1; continue + in_block = False + m = re.match(r'^(\s*)(description|summary):\s*\|', ln) + if m: prose += 1; in_block = True; key_indent = len(m.group(1)); continue + if re.match(r'^\s*(description|summary):\s+\S', ln): prose += 1; continue + form += 1 + return prose, form +``` + +``` +0.2.3 : проза 540 · форма 760 · доля 42 % +0.3.0 заленденный: проза 930 · форма 1185 · доля 44 % +0.3.0 + дофикс : проза 1055 · форма 1183 · доля 47 % +``` + +Честный вывод вместо прежнего: **доля прозы выросла — с 42 % до 44 % при батче и до 47 % после +дофикса**, потому что каждая находка холодного потребителя закрывается ПРАВИЛОМ, а правило это проза. +Абсолютные числа (форма 760 → 1183) не спорны ни при каком методе; спорна именно доля, и утверждать +«вернул к уровню 0.2.3» я больше не могу. Что резалась вода, а не норма, доказано отдельно — типами и +мультимножеством модальных глаголов ниже, и эти два доказательства от метода мерки не зависят. + +Первая редакция батча дала 2586 строк при доле прозы 50 % (приложенным скриптом). То есть я исполнил букву §5а (вынести названный генезис в компаньон) и провалил её предмет: объяснял ПОЧЕМУ прямо в описании поля, хотя рядом лежит компаньон ровно под это. Проход по всему файлу: в описании остаётся правило и обязанность потребителя, «потому что» уходит в -компаньон либо снимается. Итог — 2290 строк, проза 1011 → 757 (−25 %), доля 43 % → 36 %. +компаньон либо снимается. Итог прохода, ТЕМ ЖЕ скриптом: 2586 → 2315 строк, проза 1163 → 930 +(−20 %), доля 50 % → 44 %. Дофикс потом вернул часть — 1055 и 47 %, — потому что каждый его пункт +это новое ПРАВИЛО; ниже видно, что правил прибавилось, а не воды. **Что резалась именно вода, а не норма, проверено двумя способами исполнением:** @@ -345,7 +385,8 @@ python: для каждого MUST / MUST NOT / MAY / SHOULD взять мода ``` grep -niE "draft|wave|stage names|mined|miner|ruby|finaliz|chunk|langpack|sanitiz|flagged|verdict|prompt|engine|pipeline|glossar|promote|snapshot|CJK" openapi.yaml -→ ровно 1 вхождение: строка 21 — сама формулировка запрета в шапке. +→ ровно 1 вхождение: строка 18 — сама формулировка запрета в шапке. + (пере-ран после дофикса 16.08: по-прежнему ровно одно, там же) ``` Список составлен из слов, названных Б-0, плюс найденные мной самостоятельно: `glossar` (два слова на одну сущность — «bank» и «glossary»), `snapshot` (у движка это конкретный артефакт ре-биллинга), @@ -387,13 +428,13 @@ failed_reason`/`ready_at`) · `runs/runs.go:187-231` (`paused` в allowlist ст не шёл искать). Мандат: «ты пишешь клиента с нуля». Вернул 47 находок, ранжированных по цене реализатору. -**Ноль находок не случился ни у одного, и это правильный исход.** Принято и исправлено 26 находок; +**Ноль находок не случился ни у одного, и это правильный исход.** Пере-считано по таблицам ниже (ФБ-10): двумя линзами найдено **57** позиций — 46 исправлено (§5.1) и 11 отклонено с причиной (§5.2); третьей, кросс-модельной, ещё **12** (§5.3). Итого **69 находок, из них 57 исправлено**; четыре из них — дефекты, которые прошли бы в код P7 и стоили бы там на порядок дороже (F1, F8/F9, F32, C2). Обе линзы независимо нашли одно и то же в трёх местах (структурная версия · `X-TM-Client` против bearer · `Book` без ревизии в ответе на запись) — это и есть подтверждение, что находки не шум. -### 5.1. Исправлено (26 позиций) +### 5.1. Исправлено (46 позиций) | # | Линза | Находка | Что сделано | |---|---|---|---| @@ -444,12 +485,12 @@ F32, C2). Обе линзы независимо нашли одно и то ж | **C6** | а | Правила докачки опираются на тождество, которого `id` кадра не несёт: «позиция в потоке» не уникальна между потоками одной книги, а обе нормы (буфер докачки и `204`) решают именно «тот ли это поток» | `id` переопределён как позиция в истории событий КНИГИ; `204` переформулирован без «поток, названный `Last-Event-ID`» | | C4 / B1 | а | `Chapter.units_done` как сегментный счётчик противоречит «`Book.chapters_done` никогда не идёт назад» и не определён для главы ВНЕ текущего сегмента | Формулировка уточнена: счётчик ПРОХОДА (`0` — не дошли, `units_total` — прошли, глава вне прохода держит прежнее) + явная разводка с `Book.chapters_done`. Сама сегментная логика — прямой заказ промта батча §3, а не самодеятельность: опровергатель промта не видел | -### 5.2. Отклонено — с причиной (12 позиций) +### 5.2. Отклонено — с причиной (11 позиций) | # | Линза | Находка | Почему отклонено | |---|---|---|---| | A2 | а | Три строки таблицы §5а не вырезаны: `Note.unit_id`, `Bank.signed`, нагрузка кадров | **Диспозиция была записана ДО ревью** — §3.3, §3.5, §3.6 этого отчёта и §6а компаньона, с контраргументом на каждое. Опровергатель отчёта и прозы компаньона не видел по условию мандата; его вердикт «без прикрытия» основан на неполном входе. Аргументы стоят | -| **A3** | а | Файл вырос на 63 %, доля прозы выросла | **ПРИНЯТО и ИСПРАВЛЕНО отдельным проходом** (§4.4), после того как владелец назвал ту же вещь прямо. Итог: 2587 → 2290 строк, проза −25 %, доля 43 % → 36 % — ровно уровень 0.2.3, при форме, выросшей на 61 %. Проверено, что резалась вода: генерённые типы до и после резки идентичны с точностью до JSDoc, нормативных правил потеряно ноль. Заказанное содержание НЕ сокращалось | +| **A3** | а | Файл вырос на 63 %, доля прозы выросла | **ПРИНЯТО и ИСПРАВЛЕНО отдельным проходом** (§4.4), после того как владелец назвал ту же вещь прямо. Итог прохода: 2586 → 2315 строк, проза −20 %, доля 50 % → 44 %. ⚠ Прежняя приписка «вернул ровно к уровню 0.2.3 (36 %)» СНЯТА дофиксом: она держалась на мерке, где ключевая строка `description: |` считалась формой, и приложенным скриптом не воспроизводится — уровень 0.2.3 это 42 %, и до него проход не дошёл. Что резалась ВОДА, а не норма, доказано независимо от мерки: генерённые типы до и после резки идентичны с точностью до JSDoc, нормативных правил потеряно ноль | | B2 | а | `Book.reject_reason` сделан обязательным вопреки §4 №22 | Сам опровергатель называет правку justified: единое правило отсутствия (§5 п.16 того же заказа) не держится с неперепроверенным исключением, а прежний довод («деплой старше этого минора») внутри ЛОМАЮЩЕГО минора пуст | | B3 | а | `Note.message` снят, введён `Note.code` | Признано justified самим опровергателем; аргумент — §2.3. Вынесено вопросом в §8 | | B4 / B6 | а | Контентность `BankTerm.id` записана; дисциплина Б-21 применена в спеке | Обе — прямые «стоит записать» из research/28 §3 п.6 и Б-21 | @@ -470,7 +511,9 @@ F32, C2). Обе линзы независимо нашли одно и то ж у первых двух линз не было, потому что резка случилась после них. Результат: **1 HIGH и 4 MEDIUM, все — дефекты моей новой формы; ни одного неисполненного пункта -заказа.** Все девять находок приняты и исправлены. +заказа.** В таблице ниже **12 строк: 11 приняты и исправлены, одна отклонена осознанно** (числа +интейка — аргумент в самой строке). Прежняя формулировка «все девять» не сходилась с таблицей и +исправлена дофиксом (ФБ-10). | # | Находка | Что сделано | |---|---|---| @@ -518,7 +561,7 @@ F32, C2). Обе линзы независимо нашли одно и то ж §5а-оговорка. Коды предложены в компаньоне (◆), enum появится вместе с фразами. 5. **`Export.failure_code` не enum** — по той же причине: способы упасть зависят от форматов, которых не существует (S7). Носитель записан в самой схеме. -6. **Рантайм не поднимался.** Ни платформа, ни фронт не запускались: платформа 8 из 20 операций не +6. **Рантайм не поднимался.** Ни платформа, ни фронт не запускались: платформа 12 из 20 операций не отвечает вовсе, а фронт заморожен и живёт на моках. Всё, что я утверждаю о поведении сервера, выведено чтением кода (§4.3), а не наблюдением. Соответственно НЕ проверено исполнением: реальная форма `problem+json` при 413/408 посреди тела; поведение `Idempotency-Key` (не построен); @@ -557,8 +600,7 @@ F32, C2). Обе линзы независимо нашли одно и то ж | `getUsage` | `paused_reason` необязателен, типизирован прогонным enum | `halt_reason` required+nullable, свой `AccountHaltReason` | | `createExport` / `getExport` | `{id, ready, url?}`; опрос не завершался | `{id, revision, state, format, expires_at, failure_code, url}`; формат из `capabilities`; правило доступа к ссылке | -**Проход резки прозы (§4.4):** 2587 → 2290 строк, доля прозы 43 % → 36 %; типы не изменились ни на -поле. +**Проход резки прозы (§4.4):** 2586 → 2315 строк, проза −20 %; типы не изменились ни на поле. **Прочее, изменённое селф-ревью:** `BookIntake.title` стал ОБЯЗАТЕЛЬНЫМ (пустая строка = «назови по файлу») — в схеме не осталось необязательных членов, и порядок частей больше не зависит от повадки @@ -592,3 +634,51 @@ F32, C2). Обе линзы независимо нашли одно и то ж полях СМЫСЛА («смысла хватает, но не слишком подробно»), а это поле — состояние подписи, то есть предмет Б-14а, который в батч входит. Но чтение — не право сделать иначе. Вопрос владельцу; если ответ «заводить», правка стоит четырёх строк схемы и одной строки P7. + +--- + +## 9. Дофикс-раунд 16.08 (заказ приёмки D39.142): диспозиция по каждому ФБ + +> ⚠ **Провенанс дерева.** Часть правок дофикса приехала в рабочее дерево от ПАРАЛЛЕЛЬНОЙ сессии, +> которую владелец остановил. Я не принимал их на веру: каждую сверил с заказом и с кодом, три +> дефекта в них нашёл и починил, один невыполненный пункт доделал. Ниже — состояние ПОСЛЕ этой +> сверки; там, где я правил чужую правку, это названо. + +| ФБ | Что требовалось | Диспозиция | +|---|---|---| +| **1** (HIGH) | Wire-форма кадра неоднозначна: `EventEnvelope{event,id,data}` как объект против SSE-фрейминга | **ИСПОЛНЕНО.** На схеме стоит дословный пример кадра и сказано прямо: `EventEnvelope` — ОПИСАНИЕ, а не форма JSON на проводе; `event` и `id` — поля SSE (`event.type`/`event.lastEventId` у `EventSource`), телом кадра является `data` и только оно; heartbeat — комментарий, не кадр | +| **2** (HIGH) | Книга в покое без истории: `hello` без id → клиент без `Last-Event-ID` → вечный цикл | **ИСПОЛНЕНО.** История нумеруется с `1`, служебные кадры пустой книги несут `0`; `Last-Event-ID: 0` попадает под уже существующее правило `204`. Последовательность конечна: `hello` → `end` → закрытие → одно переподключение → `204` → браузер останавливается | +| **3** (HIGH) | «`note` не теряется» не переживает reconnect | **ИСПОЛНЕНО.** Обещание не расширено (буфер обещать нечем) — названа ОБЯЗАННОСТЬ клиента: после КАЖДОГО переподключения дельта-чтение `/notes?after_version=<последняя применённая ревизия>` до того, как доверять списку | +| **4** (HIGH) | `Revision` противоречит себе; `BookDetail.revision` vs `book.revision` | **ИСПОЛНЕНО.** Одна модель: одно число на КОНВЕРТ; у многостраничного обхода за всё чтение отвечает НАИМЕНЬШАЯ увиденная ревизия — и как гард свежести, и как водяной знак следующей дельты (лишнее перечитывание бесплатно, пропуск строки — нет); равенство `BookDetail.revision` и `BookDetail.book.revision` заявлено явно | +| **5** (MED) | Пустые `allOf`-члены → необитаемые типы | **ИСПОЛНЕНО и ПРОВЕРЕНО ГЕНЕРАТОРОМ.** `EventEnd` и `EventResyncRequired` теперь `allOf` из одного члена с описанием на самой схеме; в генерённых типах оба — `components["schemas"]["EventBase"]`, `Record` в наших схемах не осталось (два вхождения в файле — служебные `webhooks`/`$defs` генератора). Записано и то, что раньше подразумевалось: эти два кадра неразличимы по форме, диспетчеризация только по имени | +| **6** (MED) | Что делает `resume` по каждому статусу; «новый прогон» легален при ЛЮБОМ `paused` | **ИСПОЛНЕНО, таблица ИСПРАВЛЕНА мной.** В пришедшей редакции таблица клала КОРНЕВОЙ код в слот причины (`cause.code: run_not_resumable`) и оставляла три строки вовсе без кода. Исправлено: корневой код `run_not_resumable` во всех 409, `cause.code` только там, где причина есть (`bank_decisions_incomplete`, `ceiling_reached`), а «это вообще не остановленный прогон» идёт БЕЗ `cause`. Расширен и сам `ceiling_reached`: он покрывает и купленные главы, и кредит за ними — иначе строка `credit_exhausted` таблицы указывала на код, который её не описывает. Значение enum не заводилось (D39.132 п.2а не двигается) | +| **7** (MED) | Ответ НЕ `problem+json` — потерянный пункт Б-1 рек. 4 | **ИСПОЛНЕНО.** Записан пол под моделью ошибок: из такого ответа клиент не показывает ни байта и рисует свою нейтральную фразу — по классу статуса, если он есть, иначе «сервис недоступен» | +| **8** (MED, 10 позиций) | Пачка недоопределённостей | **ИСПОЛНЕНЫ все десять:** `title: null` → 400 с объяснением сужения RFC 7386 · разводка `X-TM-Client` `required` против освобождения bearer записана в самом параметре · список отказов интейка помечен неисчерпывающим (авторитет — `code`) · правило резолюции `Location` отдельным разделом шапки · тождество `Idempotency-Key` на multipart по объявленным частям + `408` не считается состоявшейся попыткой · `min_chapters` при `max_chapters: 0` — не диапазон, максимум проверяется первым · порядок пяти носителей «нет кредита» · «carried forward marked as unverified» СНЯТО как обещание без носителя, заменено на наблюдаемое (`BankPage.signed` против `total`) · `409` у `createExport` объяснён как ключевой, а не книжный · неизвестный `term_id` отклоняет весь батч решений с указателем на позицию | +| **9** (LOW) | `parser_unavailable` по эффекту; вычистить внутренние ссылки из описаний | **ИСПОЛНЕНО, ДОДЕЛАНО мной.** Переименование в `processing_failed` на месте; ⚠ проводное имя теперь расходится с внутренним словарём платформы (`parse.go:66`) — проекция это вход P7, записано в компаньон. Ссылки: пришедшая редакция сняла три (`research/28 §2 Б-15`, `§8 п.4`, `§2 Б-4`, «companion K-6»), но пропустила две — цитату D-нот в `info.summary` и `companion §2.8` у `BankTerm`; обе сняты мной. Греп по спеке на `research/28|companion §|К-N|D39.` — ноль | +| **10** (отчёт) | Пере-ран чисел ПОСЛЕ последней правки + хвосты | **ИСПОЛНЕНО, ДОПРАВЛЕНО мной.** Пришедшая редакция закрыла: `ErrorCode` 16 (сверено `awk`-счётом), якорь `mining.go:81,243` (сверено — цитата «for EACH term either promote … OR decline» стоит на `:243`), «строка 21» → 18, «12 из 20 не отвечает» (сверено: `contractRoutes` регистрирует 8 путей из 20 операций), строки записки-плана Б-23/К-10/В-6, README §6а «ужаты на месте», скрипт мерки прозы приложен. **Осталось и починено мной:** §0 противоречил сам себе (заголовок «проза выросла быстрее», а следующим предложением «выросла ровно пропорционально») · §0 держал старую долю 36 % против новой мерки в §4.4 · заголовки §5.1/§5.2 говорили «26 позиций»/«12 позиций» против фактических 46/11 в самих таблицах · ярлык §5.5 «ИСПОЛНЕНО ИНАЧЕ» не был заменён на «ОТСТУПЛЕНИЕ, аргумент» · все числа пере-раны после ПОСЛЕДНЕЙ правки, а не после предпоследней | + +### 9а. Проверка дофикса (кросс-модельная, по слову владельца) + +Одна линза другой модели, узкий мандат: закрыт ли каждый ФБ · нет ли регрессий · правда ли то, что +дофикс УТВЕРЖДАЕТ о коде. **Вердикт: ФБ-1..9 закрыты (ФБ-8 — 10/10 подпунктов), регрессий в каноне +не найдено, все кодовые утверждения подтверждены, кроме одного якоря.** Неприкосновенное цело — +проверено грепом на месте: прогонного потока нет, `Note` несёт `code`, `content_refused` в обоих +enum, `page_size_default` единственный носитель размера страницы. Нормативных правил заленденный +канон → нынешний: 44 → 45, потеряно ноль. + +| Находка | Диспозиция | +|---|---| +| **Битый якорь в компаньоне:** `Resume` приписана `runs/runs.go`, а живёт в `runs/reconcile.go:906` (ветка `case "paused"` — `:919`, дневной потолок — `:931`). Пакет верный, файл нет | **ИСПРАВЛЕНО.** Моя ошибка, не чужая: строку писал я. Прочие ссылки на `runs/runs.go:227-231` (`readyToTranslate`) проверены и ВЕРНЫ — там функция действительно живёт | +| LOW: пример кадра давал `id: 1841` и `"revision":1841` — одно число на два счётчика, которые батч специально разводил; холодный потребитель мог склеить их обратно | **ИСПРАВЛЕНО:** в примере теперь `id: 57` при `revision: 1841`, плюс строка «эти два числа намеренно непохожи и одно не подменяет другое» | +| LOW: код позиции `unknown` введён в `submitBankDecisions`, но отсутствует в иллюстративном списке `ErrorItem.code` — потребитель его там не найдёт | **ИСПРАВЛЕНО:** добавлен в список | +| Наблюдение: версия осталась `0.3.0`, хотя после лендинга переименовано значение enum (`parser_unavailable` → `processing_failed`) — «0.3.0» теперь обозначает два разных содержания | **ПРИНЯТО КАК ЕСТЬ, называю оркестратору.** Дофикс заказан САМОЙ нотой приёмки как часть того же раунда (D39.142 п.3), потребителей у 0.3.0 нет ни одного (зеркало фронта на 0.2.3, платформа на 0.2.3), и бампать минор внутри приёмочного раунда значило бы плодить версию, которую никто не видел. Если оркестратор читает иначе — бамп стоит одной строки | +| Требование «пере-ран чисел ПОСЛЕ последней правки» на момент проверки снова не сходилось (дерево правилось при ней) | **ЗАКРЫТО финальным прогоном** — числа §0 сняты после САМОЙ последней правки и сверены; проверка это и предсказывала | + +**Ратификации приёмки, которые дофикс НЕ двигал** (проверено грепом по канону): прогонный поток снят, +канал один — книжный · `Note.code` вместо `message` · `content_refused` остаётся значением +`RejectReason` · единый `page_size_default` вместо пер-коллекционных. + +**Что дофикс добавил в долг P7, сверх читающей поверхности:** `POST /runs/{runId}/resume` — построенный +путь, и 0.3.0 меняет его ответ на паузе по потолку с `202`-без-изменений на `409` +`cause.code: ceiling_reached`. Записано строкой в таблицу компаньона §3, потому что это единственное +место дофикса, где правится УЖЕ РАБОТАЮЩИЙ код, а не проекция, которой ещё нет.