# Spec Delta ## Purpose Дельта change `quality-metrics` (метрики качества первички). ## MODIFIED Requirements ### Requirement: Inbox принимает результат идемпотентно Для конверта со `status ∈ {done, needs_check, error}` ядро SHALL валидировать его по `recognition-result-message.schema.json`, найти задание по `job_id` и: если `result_msg_id` задания уже равен `Nats-Msg-Id` сообщения — ничего не менять; иначе зарегистрировать файл результата в `s3_object` (`bucket_role = results`, `created_by = `; только при наличии `result_s3`), обновить задание (`status`, `service_name`, `service_version`, `finished_at`, `metrics`, `error_code`, `error_message`, `result_schema_version`, `result_s3_object_id`, `result_msg_id`) и зафиксировать транзакцию. При `status = error` файл задания (если `current_recognition_job_id` равен заданию) SHALL получить `stored_file.status = ERROR`; при `done`/`needs_check` файл остаётся `PROCESSING` и SHALL быть обработан циклом маппинга (спека `osv-result-mapping`); inbox MUST NOT читать файл результата. Неизвестный `job_id` SHALL быть залогирован. Результат для задания, уже находящегося в финальном статусе (`done`, `needs_check`, `error`, `timeout`, `dlq`), SHALL логироваться и не применяться. Если в конверте есть `raw_s3_key`, ядро SHALL независимо от `status` зарегистрировать сырой ответ ИС в `s3_object` (`bucket_role = raw`, ключ `raw/{job_id}.json`) и проставить `recognition_job.raw_s3_object_id`. При `status = error` в той же транзакции SHALL записываться `quality_failure_fact` (`failure_kind = SERVICE_ERROR`, `error_code` из конверта). #### Scenario: Успешный результат - **WHEN** сервис публикует конверт `status = done` с `result_s3 = {arm-ka, results///result.json}` и `Nats-Msg-Id = result::1` - **THEN** задание получает `status = done`, `result_msg_id = result::1`, `result_s3_object_id` указывает на новую строку `s3_object` с этим ключом, `finished_at` заполнен; файл остаётся `PROCESSING` до маппинга #### Scenario: Повторная доставка - **WHEN** то же сообщение получено второй раз - **THEN** строка `recognition_job` и `s3_object` не меняются #### Scenario: Ошибка сервиса - **WHEN** конверт содержит `status = error`, `error = {UNSUPPORTED_FORMAT, …}` без `result_s3` - **THEN** задание получает `status = error`, `error_code = UNSUPPORTED_FORMAT`, `error_message` из конверта, `result_s3_object_id` пуст; файл — `ERROR` #### Scenario: Результат пришёл после таймаута - **WHEN** задание уже `timeout`, и приходит конверт `done` - **THEN** задание не меняется, событие залогировано #### Scenario: Сырой ответ зарегистрирован - **WHEN** конверт `done` содержит `raw_s3_key = raw/.json` - **THEN** создан `s3_object` с `bucket_role = raw`, `recognition_job.raw_s3_object_id` указывает на него #### Scenario: Факт отказа сервиса - **WHEN** конверт содержит `status = error` - **THEN** кроме `ERROR` файла записан `quality_failure_fact` с `failure_kind = SERVICE_ERROR` ### Requirement: Таймаут ожидания результата Фоновый вахтёр SHALL с периодом 30 с переводить задания `processing` и `published` с `reply_deadline < now()` в `status = timeout`, `error_code = TIMEOUT`, `finished_at = now()`, а их файлы (по `current_recognition_job_id`) — в `stored_file.status = ERROR`. Публикатор при переводе задания в `dlq` SHALL так же переводить файл в `ERROR`. Ручной повтор после TIMEOUT/NO_ACK SHALL создавать новое задание по требованию «Новая попытка обработки файла». Каждый перевод в `timeout` и в `dlq` SHALL писать `quality_failure_fact` (`TIMEOUT` / `NO_ACK`) в той же транзакции. #### Scenario: Сервис взял, но не ответил - **WHEN** задание в `processing` и `reply_deadline` прошёл без результата - **THEN** не позднее чем через 30 с задание имеет `status = timeout`, файл — `ERROR` с `error_code = TIMEOUT` #### Scenario: Факт таймаута - **WHEN** вахтёр перевёл задание в `timeout` - **THEN** записан `quality_failure_fact` с `failure_kind = TIMEOUT`