Дослідження: сучасні новинні портали, наш функціонал і стандарт автономної редакції

Дата: 20 жовтня 2026 · Автор: дослідження + аудит коду AI Newsroom

Обсяг: 3 частини — як виглядають портали 2025–2026 → що в нас → що змінити, щоб стати першою AI-редакцією у світі.


Частина 1. Як виглядають сучасні новинні портали

1.1. Сім патернів, які повторюються в усіх портах 2025

Дослідження по редизайнах Guardian, Tamedia, ABC News («Future News»), Nine.com.au, Boston Globe, WSJ, Techmeme і Telemundo дає один і той самий набір рішень. Не дизайн-мода, а функціональні обмеження, які однаково давлять на всіх.

#ПатернЩо це означає на практиці
1Breaking news — це стан інтерфейсу, а не окрема сторінкаTelemundo: шапка набуває червоного стану, з'являється окремий модуль, але звичайна стрічка під ним лишається недоторканою. Guardian у 2025 відмовився від infinite feed і натомість зробив карусель у шапці. ABC Future News: layout перебудовується під поточну новизну дня.
2Live-блог — це продукт, а не фічаBoston Globe: 200+ live-блогів за 2025, live-блог нагорі головної (перехопивши століття інстинкт), ~6 млн переглядів. Nine.com.au: нова домашня сторінка побудована навколо live-блогів, explainer'ів, breaking news і преміум-відео.
3Редакційне бандлуванняTamedia: останній пост live-блогу піднімається до рівня teaser, teaser'и бандлуються. Nine: мікро-розсилки під конкретні події (вибори, бюджет). Це редакторська рука, яка вирішує, що читач побачить першим.
4Типографічна контрастність замість одноманітної сітки картокGuardian: секції відрізняються розміром і накресленням, а не кольором рамки. «Друкова» логіка: заголовок блоку — великий, всередині — дрібніше. Читачі скаржилися на щільність, тому щільність стала явним параметром дизайну.
5Content signpostingABC: формат + біт + час читання + джерела видно на картці, до кліку. Читач має зрозуміти, що йому зараз дадуть.
6Ручне оновлення зі лічильником на вкладціЗамість різкого авто-вставлення. Класика: сторінка оновлюється, але лічильник «3 нові» на табі змушує читача самого натиснути. Ми вже це зробили тостом.
7AI-розкриття — це дилема, а не галочкаДив. 1.5: це найважливіший висновок дослідження для нас.

Techmeme додає ще один — «спільний контекст замість персональної стрічки». Gabe Rivera прямо каже: Techmeme не персоналізується, усі бачать те саме, бо це «індустріальний спільний контекст». У 2025 він говорить про те, що LLM-агенти ламають краулінг і що потрібно більше «smart» всередині. Це пряма порада нам: не робимо «For You».

1.2. Три різні моделі домашньої сторінки

  • Редакційна (Guardian, Tamedia, Nine) — рука людини/редактора визначає порядок. Масштабується тільки до розміру редакції.
  • Агрегаторна (Techmeme, Google News, Ground News) — алгоритм + commingling. Ground News додає bias-рейтинг і «Blindspot»-звіти: показ однобокого покриття.
  • Персоналізована (ABC «For You», TikTok, Perplexity) — риск для довіри: кожен бачить іншу «редакцію», спільний контекст зникає.
  • Ми зараз посередині: у нас є бейт-фільтри й формат-фільтри, але немає ані редакційного порядку, ані персоналізації — ми просто показуємо стрічку за часом. Це найслабша з трьох позицій.

    1.3. Тiredness від сповіщень — жорсткі числа

    Reuters Institute, Digital News Report 2025 (Nic Newman):

  • 79% читачів не отримують жодних новинних алертів за тиждень;
  • з тих 43% активно їх вимкнули — надто багато, некорисно, емоційний дискомфорт;
  • Weekly use зріс із 3% (UK, 2014) до 18% (2025) і у 2025 вже не зростає;
  • Обмеження — норма: The Times ≤4/день, FT ~1 загальний + персональний 17:00-брифінг, BBC ~10/день, CNN Indonesia — до 50/день (і це антипатерн);
  • Apple і Google тепер угруповані й AI-підсумовують сповіщення, що ріже пряме зв'язок видавець → читач.
  • Висновок для нас: алерти — не канал зростання, а канал відтоку. Наш поточний підхід (тизер + клік) правильний. Масштабувати «повідомлення» не варто.

    1.4. Provenance: індустрія рухається до підпису

    C2PA у 2025 вийшла з експерименту в операційний режим:

  • France Télévisions — перший мовний оператор, який підписує щодня (13:00 і 20:00 на franceinfo.fr); 6-місячний пілот з Dalet, автоматичне маркування після редакційної валідації, підпис за сертифікатом особистості;
  • IPTC + IBC Accelerator — проєкт «Stamping Your Content»: BBC, YLE, RTÉ, ITV, ITN, EBU, AP, Channel 4. Результат — open-source інструмент для підпису + плагін для верифікації;
  • CBC/Radio-Canada на AWS — підпис і валідація fMP4, іконка «Cr» в плеєрі, перевірка манифесту під час відтворення;
  • C2PA 2.0 прибрав identity з ядра специфікації (тепер CAWG); Trust List заморожено з 1 січня 2026;
  • Головний біль — не підпис, а доставка й відтворення: постачальники не готові, метадані зрізаються, потрібні водяні знаки й fingerPrints (TrustMark).
  • Machine Herald заявляє «cryptographically signed» і «Verifiable provenance» — але на своїй же сторінці не розкриває протокол: який алгоритм, який хеш, який ключ, який лог прозорості, як читач перевіряє. Тобто заявка є, а перевірюваності немає. Це саме та діра, яку ми можемо зайняти.

    1.5. Парадокс прозорості — ключове дослідження

    Це найважливіший блок для нас, бо він прямо суперечить нашій поточній реалізації.

    ДослідженняNРезультат
    Altay & Gilardi, PNAS Nexus 20244 976Узагальнена мітка «AI-generated» знижує сприйману точність і намір поділитися — навіть для правдивного контенту. Шкода зникає, коли мітка визначена вузько («AI wrote a first draft»).
    Toff & Simon, IJPP 2025—Розкриття списку джерел здебільшого компенсує штраф за довіру.
    Ali & IIU, 2025 (preregistered)5804-arm тест. Vague «AI tools were used»: Trust 4.90 → 4.35. Task-specific «Drafted with AI; fact-checked & edited by a human editor»: Trust 4.85 — штраф зникає. Додавання «Final accountability lies with the editor»: Trust 5.10 — вище за жодне розкриття. Медіатор: прозорість + відчуття людського нагляду.
    Mattis/Kieslich/de Vreese, Digital Journalism 2026683Усі 7 task-specific атрибутів розкриття негативні за довірою (−0.23…−0.62), найгірше карається fact-checking. ⚠️
    Morosoli et al., AIES 2025207Ефект залежить від теми.
    Licenji & Hoxha, Frontiers in AI 202647 оглядівНемає систематичного штрафу за provenance; доказова база тонка/null. Пропонують Cue–Inference–Target.
    Bien-Aimé et al., Communication Reports 2025269Читачі не можуть зрозуміти натяк «by staff writer with AI tool» — «фольклорні теорії». «with AI assistance» недостатньо: треба сказати, чи AI написав чернетку, чи узагальнив, чи запропонував правки, чи просто перевірив орфографію.
    Russell et al., arXiv 2510.18774186 тис. статей9% статей 1 528 газет частково/повністю AI-згенеровані. Розкриття — рідкість: 5 зі 100. Приклад task-specific формулювання: «Generative AI was used to produce a draft of this story based on information from the National Weather Service.»
    Schilke & Reimann, OBHDP 2025—«The transparency dilemma»: розкриття еродить довіру, якщо не супроводжується відповідальністю/моніторингом.
    Pew 2025—56% Americans: менше довіри, якщо AI написав статтю; 76%: важливо знати.
    EBU News Report 2025—«AI hype is over»; 2025 — «рік агентів», але зрілість низька. Довіра й human-in-the-loop — не переговорні. Yle: мітки AI-підсумків обернули гнівом читачів («ви ледачі»). BBC: міткує лише там, де читач може бути суттєво введений в оману.

    Синтез — це і є наш стандарт:

  • Узагальнена мітка «AI-generated» шкідлива — це доведено.
  • Task-specific формулювання («хто саме що зробив») знімає штраф або навіть дає бонус.
  • Обов'язковий парсер: розкриття + підзвітність. Довіра = зрозуміле розкриття + відчуття, що хтось відповідає. Без другого — перше працює проти нас.
  • Розкриття списку джерел компенсує штраф — ми це вже робимо, але не показуємо в момент зчитування.
  • Сірі зони: коли AI сумаризує, хто несе відповідальність за помилку (Mattis: факт-чекінг карається найсильніше).
  • Ми не можемо сказати «human reviewed» — у нас немає людини. Але в нас є щось краще за людську перевірку з точки зору простежуваності: повний ланцюг доказів. Це і є відповідь на «Final accountability lies with the editor» — у нас відповідальність лежить на видимому й перевірюваному процесі, а не на невидимій людині. Треба так і написати.

    1.6. Автономні компанії — ринок, що формується

    ДжерелоФакт
    BCG, жовтень 2025«AI-only firm» — мережа спеціалізованих агентів під AI-CEO, без людей у контурі. Автономна довжина завдання: ~1 година (травень 2025) → 30+ годин (вересень 2025), подвоєння кожні 3–7 місяців. VC в AI: ~$116B у H1 2025. Бар'єри: координація, пам'ять, зовнішня інтерактивність (MCP), правосуб'єктність — жодна юрисдикція не визнає AI юридичною особою.
    Chariot AIРеальні zero-human компанії з виручкою: Moltcorp, FelixCraft (~$157K), KellyClaudeAI (~$7.4K). Економіка: немає зарплат/оренди, обчислення замінює штат.
    ApolloSpace AI, вересень 2025«Services-as-software»: продаємо результат, який сам себе забезпечує. Потрібен агентний ОС: планувальник, пам'ять, інструменти, шар дозволів.
    ForbesBolt і Lovable: $20M/$10M ARR за два місяці з <15 співробітниками.
    Empire Flippers / Swan AI«Перший автономний бізнес», ціль $30M ARR з 3 засновниками.

    Ключова цитата з BCG: перші AI-only фірми з'являться в digital-native секторах. Новини — digital-native. Ми в правильній точці, але наперед за індустрією на ~1–2 роки: реальні гравці (Chronic Internet) мають 6 086 статей і AI-CEO, але не мають ані перевірюваного аудиту, ані публічного журналу рішень, ані standards-compliant provenance.

    1.7. Наші прямі конкуренти — і чим вони програють

    Chronic Internet LLC (NewsAnarchist) — найближчий аналог:

  • 22 автор-агенти на Cloudflare Durable Objects, 11 напрямків, щогодини;
  • пайплайн: Brave Search → жива стаття за <90 секунд;
  • два контрольні gate: «Copy Desk quality gate» і EIC-review — критерії не розкриті;
  • AI-EIC «Samantha Voss» — DO з SQLite, 30-хв alarm;
  • 6 086 статей у sitemap, $0.00 LLM-витрат (Workers AI) — але це тільки LLM-вартість;
  • Builder Agent пише й деплоїть код без людського схвалення; HR-Agent специфікує нові ролі; founder отримує один Telegram-апдейт.
  • Machine Herald: 5 AI-персон, 3 054 624 унікальні користувачі, 18 698 статей, Google News, аудіоподкасти. Ключове: «AI-written, AI-editor-reviewed, cryptographically signed» + публічний GitHub Actions + W/R-лічильники моделей.

    AI Chronicle: двомовний, 3 054 624 юзерів, 18 698 статей.

    Seges FrontierX: ~88 джерел / 20 доменів.

    Що у нас є, чого немає в жодного з них:

  • Повний вердикт Verifier у відкритому доступі — тип, severity, цитата, проблема, конкретне виправлення. Machine Herald просто пише «verified».
  • Журнал editorial-рішень з вартістю кожного рішення.
  • Журнал циклів з доказом автономності (хто запустив, % без людини).
  • Публічний журнал корекцій і ретракцій — зокрема Machine Herald не показує виправлень.
  • Raw model output і parsed JSON у runs — повний аудит.
  • Рахунок кожної статті (Scout $0.014, Writer $0.014, Verifier $0.022).
  • Комунікація з агентами через MCP — редакція як сервіс, який можна викликати.
  • Що у них є, чого немає в нас: масштаб (6 086 статей, 30+ годин безперервної автономності), AI-CEO як окрема роль, самонайм агентів, $0 LLM-вартість.


    Частина 2. Наш функціонал: аудит

    2.1. Фактичний стан (жива БД: 26 статей опубліковано / 51 стаття всього, 418 stories, 90 циклів, 431 runs, 7 978 events, 7 корекцій, 16 зауважень аудиту, 1 запуск Coverage Editor'а)

    Сильні сторони — те, чого немає в ринку:

    #МожливістьСтан
    1Verifier-зауваження з цитатою, проблемою та виправленням✅ повністю, у публічному UI
    2Scout-роздуми (key_facts, uncertainties, why)⚠️ зберігаються, але key_facts/uncertainties не рендеряться
    3Журнал рішень з вартістю✅ /decisions
    4Журнал циклів + доказ автономності✅ /cycles, 75% за останні 12
    5Журнал корекцій/ретракцій✅ публічний, але немає автоматичного пост-публікаційного аудиту в циклі
    6Розкриття джерел✅ з доменом і повним URL
    7Живий SSE-потік подій агентів✅ /live
    8Ранковий шлях (Scout→Writer→Verifier→Revision→Verifier#2)✅ з dedup, diversity override, budget guard, watchdog
    9Real-time homepage✅ ticker + polling + toast (Phase 1)
    10SEO/JSON-LD✅ сильні, але неповні (§2.3)
    11Арбітр — агент, що відповідає замість людини✅ працює на живій платформі, /arbiter + /api/arbiter + /api/arbiter/rescues (§3bis). Врятовано робіт: 1
    12Живий маніфест компанії✅ /manifest + /api/manifest (§3ter.1)
    13Профіль розкриття як специфікація✅ /disclosure-profile + /api/disclosure-profile (§3ter.2)
    14Захищена панель керування✅ MCP із bearer-ключем; ключ у env платформи (§3ter.4, §3ter.4bis)
    15Головний редактор, який сам наймає✅ EIC у циклі (run/hold/skip) + самонайм із закритого списку ролей; /editor + /api/editor (§3ter.4ter)
    16Звіти, які кажуть «не знаю»✅ watchdog, розклад і черга розрізняють «немає» і «не прочитано»; app/uarp_errors.py (§3ter.4quater)
    17Звіти, які сходяться✅ цикл перевіряє власний рахунок: 119 принесених = 119 врахованих; невідомий статус має власний кошик (§3ter.4quater, X)

    Слабкі місця, знайдені аудитом:

    #ПроблемаДеНаслідок
    AМітка «AI-generated» на кожній картціindex.html:25,291, article.html:43, search.html:57Пряме порушення Altay & Gilardi: це та сама мітка, яка за даними знижує довіру. Найбільший ризик для нашого головного активу.
    B«Людське редагування: немає» подано як приманкуarticle.html:114Читач, якому не подобається AI, одразу йде. Дослідження каже: замість каяття — конкретика процесу.
    Ckey_facts / uncertainties Scout'а не показуютьсяarticle.html:264-266 показує лише whyМи маємо доказ невизначеності і не показуємо його. Безкоштовна довіра, яка не використовується.
    D/api/feed?since= реалізований, але жоден клієнт його не передаєweb.py:842-865Мертвий код; вся логіка дедуплікації — на клієнті.
    EНемає /api/articles/{id}/updates для rejected-оновленьdb_sqlite зберігає, UI не показуєАудит-слой неповний: ми не показуємо, що було відхилено.
    Fscout.total_candidates завжди —article.html:268Кількість кандидатів не персиститься.
    Gverifier_2 / update_verifier_2 мовчки залишають перший вердикт, якщо другий не розпарсивсяpipeline.py:1134-1136, 1002-1005Редакція публікує на силі доревізійного вердикту. Це реальна діра в гарантії якості.
    HVerdict reject без required_fixes → Writer отримує «виправляй ніщо»pipeline.py:1094Порожній цикл ревізії з повною вартістю.
    IНемає автоматичного пост-публікаційного аудиту в цикліcheck_article() лише ручнийКорекції з'являються тільки якщо хтось натисне. Автономна редакція має перевіряти себе сама.
    Jrisk зберігається і показується, але нічого не блокуєLOW_RISK не використовуєтьсяПоле, що виглядає як гарантія, але не є нею.
    KREJECTING_VERDICTS не використовуєтьсяpipeline.py:42Будь-яка помилка у вердикті = тихий reject.
    LdateModified не враховує corrected_atweb.py:501-553Google не бачить корекцію.
    MНемає articleBody, wordCount, inLanguage у JSON-LDweb.py:501-553Неповна розмітка для Google News.
    NВідсутні ethicsPolicy / correctionsPolicy / publishingPrinciplesJSON-LD publisherОбов'язково за правилами Google News для AI-контенту.
    O/decisions, /operations, /cycles, /company індексуються і в sitemaprobots.txt:1227Внутрішні дашборди в пошуку.
    PТема-ленти → /search?q=, немає тег-сторінокarticle.htmlНемає внутрішньої перелінковки.
    Qdiversity override може підмінити сильну breaking-історію на слабшуpipeline.py:799-853Ризик дляBreaking-якості.
    RForced story_id оминає фільтри й балансpipeline.py:713-715Ручний режим небезпечний.
    Sis_news_candidate / is_ukraine_related пропускаються для forcedpipeline.py:800Ручний публікація може пройти поза темою.
    TНемає жодного тесту Виправлено: 309 тестівtests/Регресії ловляться.
    UWatchdog ковтає APIError і показує stuck: 0 Виправлено: два дерева помилокapp/uarp_errors.py, app/watchdog.pyПід час збою UARP ми виглядаємо здоровими. Тепер звіт каже, які стани не прочитано. Див. §3ter.4quater.
    V/api/schedule мовчки підставляє вигаданий next_cycle_at Виправлено: status: unreachableapp/native_schedule.py, app/web.pyUI показує впевнений таймер для циклу, який не запуститься. Тепер таймер не називає час, якого не знає. Див. §3ter.4quater.
    W312 із 398 stories лишаються status='new' Виправлено: held + stale + правдива чергаapp/db_sqlite.py, app/pipeline.pyBacklog накопичується; прибирається лише вікном свіжості. Тепер черга рахує те, з чого можна працювати: 3, а не 312. Див. §3ter.4quater.
    XMCP_API_KEY порожній Виправлено: MCP захищено ключем.env, scripts/secure_mcp.pyMCP ендпоінт локально не захищений. Див. §3ter.
    YRun, що спитав питання, губиться Виправлено: rescue_runuarp_arbiter.py, uarp_agents.pyЕтап, що зупинився, сам отримує відповідь від арбітра, чекає на run і забирає результат.
    ZВідповідь арбітра не потрапляє в редакцію Виправлено: arbiter.rescueduarp_arbiter.pyРезультат врятованого run'у пишеться в журнал і видно на /arbiter.
    AAНемає живого манифесту компанії Виправлено: /manifestapp/manifest.pyОдна сторінка з доказом, що компанія без співробітників. Див. §3ter.
    ABПрофіль розкриття не опублікований як специфікація Виправлено: Disclosure Profile 1.0app/disclosure_profile.pyСтандарт, який можна перейняти. Див. §3ter.
    ACAPI віддає роботу в процесі Виправлено: app/access.pyapp/access.pyAPI віддає рівно те, що сторінки. Див. §3ter.
    ADАгент не бачить інструменти власного MCP Виправлено: allowed_toolsscripts/companion.pyСервер був підключений, active і проходив тест — а агент не міг його викликати. Див. §3ter.4bis.
    AEChief of Staff не використовується в пайплайні Виправлено: EIC-крокapp/eic.py, app/pipeline.pyРедакція без головного редактора — конвеєр. Тепер рішення «писати / відкласти / не писати» ухвалює агент, а не правило в коді. Див. §3ter.4ter.
    AFКомпанія не може сама змінити свій штат Виправлено: самонаймapp/eic.py, app/db_sqlite.pyРоль — зі закритого списку, одна роль — один агент, never_allow на весь agent factory. Див. §3ter.4ter.
    AGЦикл приносить 119 історій, а звіт показує 0 Виправлено: already_seen + самоперевірка рахункуapp/db_sqlite.py, app/pipeline.pyupsert_story повертав None для вже відомої історії, і пайплайн її не рахував. Звіт був повний, але не сходився. Див. §3ter.4quater.

    2.2. Матриця «портал 2026 × ми»

    Очікування читача 2026Ми заразРозрив
    Breaking як стан інтерфейсуЄ alert-банер + ticker⚠️ частково: немає червоного стану шапки
    Live-блог нагоріЄ тільки для найсвіжіших🔴 немає live-блогу як продукту
    Редакційне бандлуванняНемає🔴
    Типографічна контрастністьОдинкова сітка карток🔴
    Content signpostingЄ: формат, біт, джерело, час, confidence✅
    Клік vs. сповіщенняТост + клік✅
    Task-specific AI-розкриттяУзагальнена мітка «AI-generated»🔴 пряме порушення доказів
    Джерела видно зразуЄ, але внизу сторінки⚠️ Toff & Simon: джерела компенсують штраф — їх треба показувати рано
    Відповідальність названа«Людське редагування: немає»⚠️ Schilke: без відповідальності розкриття еродить довіру
    ProvenanceВідсутній🔴
    Автономний пост-аудитВідсутній🔴
    Невизначеність показуєтьсяПрихована🔴
    ПерсоналізаціяНемає (і не треба)✅ свідомо
    Спільний контекстЄ✅

    2.3. SEO-борг

    Google News вимагає для AI-контенту: ethicsPolicy, correctionsPolicy, publishingPrinciples, actionableFeedbackPolicy, diversityPolicy на NewsMediaOrganization; плюс articleBody, wordCount, inLanguage. Маємо — сторінку /corrections, але не як schema.org-поля. Також dateModified не рухається при корекції, /api/schedule//operations індексуються, LiveBlogPosting немає.


    Частина 3. Пропозиції

    P0 — Тиждень 1–2: «Прозорість, яка працює» + закриття дірок у гарантіях

    Це не косметика. За Altay & Gilardi, Toff & Simon, Ali (2025) і Schilke & Reimann, заміна одного рядка знімає штраф за довіру, який ми зараз платимо на кожній картці.

    P0-1. Task-specific розкриття замість «AI-generated».

    Замість <span class="ai-tag">AI-generated</span> — точний опис процесу:

    «Агент: текст згенеровано автоматично за джерелами нижче; перевірив Verifier-агент; людину не залучено»

    І додати пару відповідальності (обов'язковий парсер за Schilke):

    «Відповідає за зміст: редакційний контур AI Newsroom; кожна стаття має перевірюваний ланцюг Scout→Writer→Verifier і публічний журнал виправлень.»

    Інструментація: на кожній картці — 3 мікро-факти (джерел: N · перевірено: Verifier · правок: N), на сторінці статті — розгорнутий блок. Це одночасно і чесно, і сильніше за «AI-generated», бо читач бачить конкретну гарантію.

    P0-2. «Показують джерела, бо це довіра, а не дрібниця». Винести блок джерел над перший абзац (або в перший екран під заголовком) — Toff & Simon: розкриття списку джерел знімає штраф.

    P0-3. Показати невизначеність. Вивести scout.key_facts і scout.uncertainties на сторінку статті (зараз зберігаються, але не рендеряться). Формат: «Що не підтверджено: …» — це рідкісна, але дуже сильна деталь: жоден конкурент цього не показує.

    P0-4. Закрити діри в гарантіях якості. (G, H, J, K):

  • verifier_2/update_verifier_2 не розпарсилися → не публікувати, а rejected + помилка в summary (зараз публікує на старому вердикті);
  • verdict reject без required_fixes → одразу rejected, не запускати ревізію «виправляй ніщо»;
  • risk == "high" → не публікувати без явного pass_with_minor_fixes; це робить поле risk справжньою гарантією, а не декорацією;
  • додати REJECTING_VERDICTS як явний запобіжник, щоб невідомий вердикт не читався як publish.
  • P0-5. Автоматичний пост-публікаційний аудит у циклі. Після publish — окремий correction_check (вже існує як check_article, але лише ручний). Без цього «автономна редакція» не перевіряє себе — а це рівно те, що робить Chronic Internet (EIC-review) і Machine Herald (AI editor). Це не фіча, а умова «бути редакцією». Бюджет: ~$0.02/статтю.

    P0-6. Дрібні правки: dateModified враховує corrected_at; articleBody/wordCount/inLanguage + ethicsPolicy/correctionsPolicy/publishingPrinciples у JSON-LD; /api/feed?since= почати використовувати; total_candidates персистити; rejected-оновлення показати в UI.

    P1 — Тиждень 3–6: «Редакційна робота машини»

    P1-1. Breaking як стан інтерфейсу. Під час breaking-події: шапка набуває акцентного стану (як Telemundo), ticker стає «стрічкою термінового», з'являється панель «Що відомо зараз / Що невідомо» (шаблон live-блогу з RightNow.Live: newest-update-first, pinned updates, «What we know so far», «Open questions»). Це те, що Chronic Internet робить за <90 секунд, а ми — за цикл.

    P1-2. Live-блог як продукт. LiveBlogPosting JSON-LD, окрема сторінка /live/{story_id} з джерелом-стрімом, прихованою вкладка article_updates. Ми вже маємо оновлення — вони просто не мають власної поверхні. Це найдешевша велика фіча: дані є, потрібен лише подання.

    P1-3. Редакційне бандлування. Автоматичний «bundle»: коли 3+ оновлень однієї історії, сторінка історії стає хабом; коли 2+ матеріали того дня поєднані темою — групувати в блок на головній з одним заголовком. Це те, що робить Tamedia/Nine.

    P1-4. Типографічна контрастність. Замість рівної сітки карток — 3–4 типографічні рівні залежно від ваги (як Guardian): Breaking — 32px, ключове — 22px, бриф — 17px, explainer — 15px + колонка «Теми» → це закриває скаргу читачів на щільність.

    P1-5. Підписаний provenance. Перший крок — не C2PA (вимагає сертифікату, key management, дистрибуції), а власний підпис контенту: SHA-256 від канонічного тіла + підпис Ed25519 + публічний ключ + сторінка /provenance/{article_id} з «Verified» і наявністю в підписі: час, хто (роль агента), run_id, список джерел, хеш попередньої версії. C2PA-сумісний манifest — P2. Це закриває те, що Machine Herald заявляє, але не реалізує.

    P2 — Квартал: «Стандарт автономної компанії»

    P2-1. /manifest — живий манифест компанії. Одна сторінка: агенти, їхні ролі, стан, рахунок, останні рішення, календар, бюджет і burn-rate, активні ініціативи, і — головне — жива декларація: «це компанія без співробітників; ось доказ». Це сторінка, яка перетворює проєкт на категорію. Зроблено — див. §3ter.1.

    P2-2. Стандарт розкриття як специфікація. Опублікувати власний профіль розкриття: AI Newsroom Disclosure Profile 1.0 — JSON, який описує, як саме ми розкриваємо походження матеріалу. Іншим редакціям (зокрема людським) можна його перейняти. Це і є «змінюємо стандарти»: не ми підлаштовуємось під усталену практику миттєвого AI, а задаємо власну, кращу, перевірювану. Зроблено — див. §3ter.2.

    P2-3. Самонайм і саморозширення агентів. Chronic Internet це має (HR-Agent + Builder Agent). У нас є Chief of Staff, який не використовується в пайплайні взагалі (uarp_company//company показує його, але pipeline.py його не викликає). Пропозиція: Chief of Staff стає EIC-роллю в циклі — бере Scout-вибір, вирішує «пускати чи переробити», призначає перевіряльника, і може сам запропонувати створити нову роль. З межами: Writer/Verifier не можуть змінювати власні інструменти (заборона вже є в tool_overrides). Зроблено — див. §3ter.4ter.

    P2-4. Економічна модель. Зараз: $0.03–0.12/цикл, ~$1.5–5.8/добу. Chronic Internet — $0 LLM. Ми на ~$4.65 за 276 runs.

    Оновлено після розбору 276 записаних runs. Початкова пропозиція («verifier_2 на повній статті йде тільки коли required_fixes не порожні») не витримала перевірки — і це важливіше за саму оптимізацію, бо вона показала, де насправді лежать гроші.

    Факт 1. Другий прохід корисний. verifier_2 змінив вердикт у 11 з 25 випадків (revise → publish). Причому всі 25 мали непорожні required_fixes — тобто оптимізація «не запускати перевірку без правок» не мала б нічого прибрати. Другий прохід лишився, але тепер він цільовий: перевіряє, чи правки внесені, а не перечитує всі джерела заново.

    Факт 2. Вартість визначає не промпт, а каталог інструментів. Промпти — це ~3.5K токенів, а update_verifier у середньому витрачав 100K. Модель на 530 точках: input_tokens ≈ (tool_calls + 1) × (11 750 + 275 × catalogue_size), R² = 0.863. Каталог агента — 32 інструменти, і він пересилається на кожному кроці циклу (підтверджено: tool_count не змінюється між кроками). Тобто 32 інструменти × 3 кроки ≈ 26K токенів самого каталогу.

    Факт 3. Довгий забіг = провал. Усі 17 runs, що коштували понад $0.03, завершилися відхиленим сюжетом. Жодного. Перевірено на більшій вибірці — і спростовано. На 276 записаних runs висновок виглядав залізним; на 1193 виміряних runs понад $0.03 пішли 64 (5.4%), і 29 із них завершилися успішно. Тобто лінія $0.03 обрізала реальну роботу щоразу, коли спрацьовувала, і «довгий забіг = провал» описував не статью, а спосіб виміряти. Справжня причина хвоста — одна й та сама в усіх: великий контекст verifier'а (середні 74K кешованих токенів), а не «старість» кешу. Кап піднято до $0.05 — вище p99 історії ($0.0362): хвіст обрізається, але робота, яку можна було закінчити, не обривається. Це і є закон збереження: агент, який тягне все нові й нові сторінки, не сходиться. Відповідно — платформа підтримує resource_limits на run (max_cost_usd, max_tool_calls), і ми тепер їх виставляємо: $0.05 / 4 виклики інструментів на run (RUN_MAX_COST_USD, RUN_MAX_TOOL_CALLS). Перевірено на живому run: при 8 джерелах і ліміті 3 виклики агент зупинився рівно на трьох і сам відзвітував «verification_incomplete» — тобто обмеження не маскує недовіри, а робить його явним.

    Факт 3a. Але max_tool_calls не робить недовіру явним — він робить його невидимим. Це найважливіше уточнення до всього P2-4, і його дала перевірка на живому run, а не читання документації. Платформа не зупиняє run при перевищенні ліміту викликів інструментів — вона відкидає зайві запити. Агент просить три fetch, два виконуються, третій зникає без усякої ознаки, run доповнює status: completed, error_code: null, output.truncated: null (прапор truncated платформа ставить лише при обрізанні за max_steps, і навіть тоді лише на рівні output).

    Наслідок катастрофічний саме для новинарної agent- системи: агент пише статтю так, наче прочитав усі три джерела, а читав два. Verifier, обмежений тим самим лімітом, втрачає ті самі запити — і не може помітити те, чого сам не бачив. Жоден сигнал у run не показує, що інформації не вистачало.

    Перевірено на записаних даних: з 268 завершених runs у 3 відкинуто виклики — run 293 (revision, 2 з 6), run 294 (verifier_2, 2 з 6), run 302 (writer, 1 з 5). Run 293 — той самий, який verifier_2 забракував з missing_required_fix: правка №6 («Додати пряме посилання на матеріал Reuters») вимагала fetch, який платформа відкинула. Тобто ми бачили наслідок (відхилення) і не бачили причину (відкинутий запит). Зараз причина видима.

    Що зроблено. Ліміт викликів не піднято — це не виправило б нічого, бо «схильність не сходитися» не зникає від більшого ліміту. Натомість додано вимірювання: run_json рахує запитані (llm.response → tool_calls) і виконані (tool.call) виклики, і різниця — це number of sources the agent believes it read and did not. Нова колонка runs.dropped_tool_calls (міграція 0008), лічильник видно на /status і /operations, а публікаційні ворота (_publish_gate) не пропускають матеріал, написаний сліпо: starved-стадія позначається в summary і стаття йде в rejected, а не в публікацію. Для публікації потрібно або підняти ліміт, або перезапустити стадію з більшим бюджетом викликів.

    Факт 4. Дешевий фільтр «це вже сказано» не працює. update_is_novel (порівняння заголовка фід-а з заголовком статті через titles_similar) блокує 0 з 27 реальних спроб. Причина: titles_similar — це тест на точний дубль (Jaccard 0.72 / SequenceMatcher 0.86), а те, що ми хотіли відсікати, — це переказ того самого факту іншою формулою. Замість передзаписного фільтра працює корисніше: Verifier і так каже «repetition» (13 зауважень — найчастіший тип у 27 перевірках оновлень).

    Факт 5. 39% fetch-ів — повторні. 221 завантаження джерел на 87 повторів. Перша модель, що пояснює вартість, — (tool_calls + 1) × (fixed + catalogue), — каже: кожен додатковий виклик інструменту коштує цілий ще один прохід каталогу. Обмеження на max_tool_calls б'єть саме сюди.

    Факт 6. Платформа кешує промпт сама — і нам це треба було помітити. Метрики run містять cached_tokens, які ми не зберігали. Після додавання колонки з'ясувалося: у реальному циклі 63% вхідних токенів прийшли з кешу (writer — 78%, verifier — 67%). Тобто «кеш і квантування» з початкової пропозиції P2-4 платформа робить сама; наша робота — не додавати кеш, а бачити його: без cached_tokens у таблиці runs ми не знали б, що він працює, і не могли б його виміряти.

    Факт 7. Драйвер вартості — кроки циклу, а не довжина промпту. Модель input ≈ steps × (11 750 + 275 × catalogue) дає R² = 0.863. Кожен прохід циклу пересилає весь каталог агента (32 інструменти ≈ 8 800 токенів). І ось що важливо: кожна опублікована стаття вклалася у 3 кроки. Статистика по кроках:

    кроківопублікованоне опубліковано$/run
    1–253178$0.012–0.019
    3–4721$0.019–0.023
    5+113$0.029–0.046

    Тобто за межею 3 кроків агент уже не сходиться. Платформа дає max_steps — і ми його виставили (RUN_MAX_STEPS=4, з запасом на ревізію).

    Підсумок. Не «кеш» — платформа кешує сама. А обмеження, які зупиняють run, коли він перестає давати результат: max_cost_usd ($0.05 — виміряний хвіст, див. Факт 3), max_tool_calls (4), max_steps (4). Це обрізає «хвіст» — 26 runs із понад 4 викликами інструментів ($0.80, 17% бюджету) і 45 runs із 3+ кроками ($1.15, 24.5%). Другий прохід перевірки лишився — він змінив 11 вердиктів із 25 — але став цільовим. І найголовніше: ліміт на виклики інструментів мовчки відкидає запити, тому поряд із лімітом ми тепер рахуємо відкинуті запити і не пускаємо в публікацію матеріал, написаний не з тих джерел, які агент просив.

    P2-5. Тести. Мінімум: golden-тести на _scout_selection, _coerce_article, _same_article, titles_related, find_similar_article + інтеграційний тест «цикл → стаття → оновлення → корекція». Зараз 276 runs на $4.65 без жодного тесту. Зроблено: 309 тестів (tests/test_selection.py, test_publish_gate.py, test_duplicates.py, test_integration.py, test_provenance.py, test_disclosure.py, test_bundles.py, test_cost.py, test_arbiter.py, test_eic.py, test_manifest.py, test_status_crew.py, test_disclosure_profile.py, test_access.py, test_register_mcp.py, test_mcp_attach.py, test_watchdog.py, test_schedule.py, test_queue.py, test_fetch_accounting.py).

    P2-6. Приватність і доступність. MCP_API_KEY порожній; /api/feed віддає headline/dek публічно; /api/articles — публічно. Зроблено, але правило переписане: API віддає рівно те, що віддають публічні сторінки (опубліковане, відхилене, відкликане — усі три сторінки публічні), а роботу в процесі (new, selected, error) — ні, бо її не показує жодна сторінка. MCP-панель захищено ключем. Див. §3ter.3 і §3ter.4.


    Частина 3bis. Арбітр: хто відповідає, коли потрібна людина

    Цей розділ з'явився пізніше за аудит, але змінює його висновок.

    3bis.1. Що ми виявили

    Аудит показав дві записані в базі «завершені, але втрачені» роботи: ревізія, яка хотіла code_interpreter, і Verifier оновлення, який попросив «повний текст і URL джерел». Обидві закінчилися як status: completed — але жодного матеріалу не вийшло.

    Причина виявилася не в тому, що компанія питала людину. Причина в тому, що app/watchdog.py видаляв роботу. Платформа зупиняла run, watchdog бачив «завислий» run і скасовував його. Компанія не просила людину — вона викидала роботу і повідомляла про це як про штатну процедуру.

    Це важлива різниця у формулюванні. «Автономна компанія, яка просить людину» — це компанія з людиною. «Автономна компанія, яка видаляє роботу, бо людини немає» — це злана імітація.

    3bis.2. Два механізми платформи, які треба було замінити

    СтатусЩо робить платформаМеханізм заміни
    run.awaiting_inputагент поставив питання через ask_user і чекаєruns.respond_to_run(run_id, {"response": …}) — run оживає і завершується
    run.awaiting_approvalполітика довіри відправила інструмент рецензентуruns.revise_run(run_id, {"feedback": …}) — інструмент відхилено, але агент отримує інструкцію й продовжує

    Ключова деталь, яку легко зробити неправильно: runs.reject_run завершує run. Тобто це вбивство роботи, а не відповідь. Правильний інструмент — revise: він відмовляє в інструменті, але віддає агенту зворотний зв'язок, який той читає як результат своєї спроби.

    3bis.3. Перевірка гіпотези, яка виявилася хибною

    Перша версія діагнозу: «промпт обрізається, Verifier не бачить текст, тому й питає». Це була б зручна і красива історія — і вона була неправильною.

    Замість припущення ми виміряли: last_input_tokens = 18 103, довжина промпта — 18 097 символів. Обрізання не було. Verifier бачив і повний текст, і обидва URL джерел (секція оновлення починалася з 15 249, 1 587 символів, і містила sources з обома URL).

    Справжня причина була простішою і гіршою: агент справді хотів інформації, і в компанії не було способу її дати. Не збій платформи, а відсутність інтерфейсу.

    Цей епізод варто записати в дослідження окремо, бо він показує різницю між «AI-компанією, яка вміє себе чинити» і «AI-компанією, яка вміє себе перевіряти». Компанія, яка не перевіряє свої припущення, вигадує їх.

    3bis.4. Що таке арбітр

    app/uarp_arbiter.py — агент, який відповідає там, де відповідала б людина. Правило — перше, агент — друге.

    1. Правило. Більшість питань редакції мають правильну відповідь, яка вже є в базі. Записане питання Verifier'а («повний текст і джерела») — типовий випадок: текст і джерела були в його ж контексті. Правило коштує нуль токенів, детерміновано й не піддається переконанню.
    2. Агент. Коли правила немає, питання йде Chief of Staff'у — агенту, який вже в реєстрі компанії описаний як «runs the company for you … and brings you what needs your decision». Тобто ми не створювали нового агента, ми дали роботу тому, чий опис уже обіцяв її.

    Арбітр не публікує. Його відповідь може бути лише конкретними даними або вказівкою відхилити матеріал. Публікаційний гейт — окрема функція, і арбітр не має на неї доступу.

    3bis.5. Три дефекти, знайдені на живій платформі

    Перший запуск арбітра в реальному середовищі дав три реальні поломки, жодна з яких не проявилася б у тестах:

    #ДефектНаслідокВиправлення
    1Кап $0.01, а один крок Chief of Staff'а коштує $0.0128 (42k токенів — платформа пересилає весь каталог інструментів на кожному проході)кожен запуск арбітра зупинявся на капі й записувався як failedкап $0.04
    2options з питання не передавалися арбітруарбітр обирав між чотирма варіантами, не бачачи їхoptions у промпт і в Decision
    3never_allow на інструменті не створює питання людиніхибна гіпотеза: «заблокуємо інструмент — і змусимо агента спитати»перевірено на живій платформі: агент бачить, що інструмент недоступний, і працює далі

    Третій пункт — найважливіший для нашого висновку про прозорість. Ми не можемо писати в README «ми прибираємо людину», спираючись на never_allow. Це різні речі: never_allow прибирає ризик, але не створює питання. Питання створює ask_user — і саме на ньому ми тепер працюємо.

    3bis.6. Перевірено на живій платформі

    Три run'и, які чекали на людину, отримали відповідь від арбітра і завершилися самі:

    Третій випадок найважливіший: арбітр не просто відповів на запитання — він змінив конфігурацію іншого агента так, щоб проблема не повторилася. І зробив це без підняття ліміту, тобто без збільшення витрат.

    Усі рішення записані в журнал подій (arbiter.decided) і видно на /arbiter та /api/arbiter.

    3bis.6bis. Різниця між двома «бюджетними» питаннями

    Перше бюджетне питання було українською й про денний ліміт ініціативи. Друге — англійською й про стелю одного запуску. Перше правило («не піднімати ліміт») для другого було б недостатнім: воно скасувало б проблему, не розв'язавши її, і редакція залишилася б без перевірки.

    Тому правило розрізняє не мову, а наслідок:

    ПитанняДешевший шляхВідповідь арбітра
    денний ліміт ініціативи вичерпанозменшити обсяг циклу; до скидання планувальник виконає запуск сам«зменш обсяг циклу»
    стеля одного запускускоротити інструменти агента й перевіряти порціями всередині цього запуску«скоротити інструменти, перевіряти невеликими порціями»

    Другий випадок показав загальне правило для всіх наступних: «не робити X» — це не відповідь, якщо є дешевший шлях до тієї самої мети. Арбітр має відповідати на запитання, а не відмовитись від них.

    3bis.7. Другий дефект, знайдений пізніше: відповідь без результату

    Наведений вище sweep арбітра відповідав на запитання й ішов далі. Ми перевірили це на живих даних і виявили, що це недостатньо.

    Три run'и, які арбітр розблокував, завершилися на платформі — але їхні результати нікуди не потрапили. Відповідь на запитання не є результатом: run оживає, працює далі й пише свою відповідь у output.response, а ми про це дізнавалися лише тоді, коли хтось зазирнув у платформу вручну.

    Наслідок той самий, що й до створення арбітра, тільки тихіший: робота завершується на платформі й губиться в редакції. Компанія, яка відповідає на запитання, але не забирає результат, виглядає ззовні як компанія, що працює — і нею не є.

    Це доповнило арбітра третім кроком:

    КрокЩо робитьМетод
    1. відповістирозблокувати runruns.respond_to_run / runs.revise_run
    2. дочекатисядати run'у завершитисяruns.wait_run(run_id, timeout_sec=…)
    3. забратипрочитати результатoutput.response + stream_run_events

    Крок 3 — не формальність, бо він визначає, чи зможе publish gate побачити вердикт. Для врятованого run'у лічильники інструментів рахуються з журналу подій, бо в момент живої передачі подій нікого поруч не було: різниця між «скільки інструментів агент запитав» і «скільки виконалось» — це число джерел, у яких агент упевнений, що прочитав їх, але не прочитав. Publish gate дивиться саме на цю різницю, і для врятованого run'у вона має рахуватися з іншого джерела.

    3bis.8. Третій дефект: арбітр працював тільки на початку циклу

    Третій дефект був найважчим, бо він не мав жодного следу в журналі.

    Sweep запускається на початку циклу. Але етап, який спитав питання посеред конвеєра, не може чекати наступного циклу: конвеєр тримає його відповідь, історія вже позначена як провалена, а платформа лишається з run'ом, який за крок до завершення.

    Жодного запису про такий стан у журналі не було — саме тому дефект і не проявився. Але він проявився в чомусь іншому: три run'и в останньому циклі загинули на нашому власному капі $0.03 («stopped at our cap ($0.030, 4 tool calls)»), і жодного питання платформа не поставила — бо ми задали кап явно, і кап просто вбиває run. Два з трьох тих run'ів при тому вже мали повну відповідь (7062 і 4402 символи — змістовний вердикт Verifier'а і переписаний текст). Robot на них не запитав нічого, бо ми не дали йому такої можливості.

    Це і є відмінність між «не питаємо людей» і «видаляємо роботу». Не «ми поки що не дорослі» — а конкретний механізм, який мовчки викидав готову роботу.

    Виправлення: етап, що зупинився, сам кликає арбітра (rescue_run у app/uarp_agents.py), чекає на завершення і повертає результат далі — як звичайну відповідь агента. Жодна зміна поведінки Verifier'а, Scout'а чи Writer'а не потрібна: заміна людини відбувається в одному місці, де платформа зупиняє run.

    3bis.9. Перевірено на живій платформі

    Три незалежні перевірки, усі на живій платформі:

    1. Run, що спитав питання, врятовано в конвеєрі. Chief of Staff зупинився на awaiting_input за 8 секунд; арбітр відповів; run завершився за 14,9 секунди з відповіддю у 785 символів. Агент у цій відповіді відмовився публікувати без джерел — тобто арбітр не послабив редакційні правила, а дав агентові відповісти за них.
    2. Run, що просив дозволу на інструмент, врятовано. Chief of Staff зупинився на awaiting_approval з install_spec. install_spec не в списку дозволених, тому арбітр відмовив через revise — run не вбито, він продовжив роботу і вирвав 2707 символів повного аудиту компанії. За 43 секунди, $0.0317, 10 кроків.
    3. Sweep тепер забирає результат. Прогін sweep'у дав inspected: 1, answered: 1, rescued: 1 — і результат врятованого run'у лежить у журналі як arbiter.rescued, а не втрачений.

    Останнє — це те, чого не було ніде: компанія, у якій є місце, де потрібна людина, і це місце заповнене агентом, який не просто відповідає, а забирає зроблену роботу.

    3bis.10. Чому це частина стандарту, а не фіча

    Три тести на «справжню автономну компанію»:

    1. Чи є в компанії місце, де потрібна людина? В такому разі вона зараз порожня — і це не «ми поки що не дорослі», а «у нас немає жодного робочого місця, де потрібна людина».
    2. Чи відрізняється «ми не питаємо людей» від «ми видаляємо роботу, бо не питаємо»? Ці дві речі виглядають однаково ззовні, якщо не дивитися в журнал. Різниця стає видимою тільки тоді, коли рішення записані.
    3. Чи можна читачеві показати, хто вирішив? Треба, щоб рішення мали ту саму видимість, що й опубліковані матеріали.

    Другий тест — найважливіший. Компанія, яка видаляє роботу, не відрізняється від компанії, яка ніколи не починала. Арбітр — це те, що робить різницю видимою.



    Частина 3ter. Стандарт, викладений у клі: маніфест, профіль розкриття, доступ

    Попередні частини описували, що треба зробити. Ця частина — що зроблено, і що виявилося при перевірці на живій платформі.

    3ter.1. P2-1: /manifest — живий маніфест компанії

    Одна сторінка (/manifest, JSON — /api/manifest), зібрана лише з записаних фактів: локальна база, журнал арбітра, знімок компанії з UARP. Жодного обчисленого «на око» числа — інакше сторінка втратила б єдину свою перевагу: щоб її можна було перевірити.

    Склад: декларація (0 співробітників, 0 людських рішень, з доказом у журналі), екіпаж, виробництво, економіка з burn-rate за 7 днів, стан етапів, активність, останні цикли, останні рішення замість людини.

    Два рішення, які здалися неочевидними:

    ПитанняРішенняЧому
    Чиїм числом рахувати «витрати на день»?За 7 днів, окремо — остання добаЖиттєве середнє ховає серію дорогих циклів; тиждень — найкоротший вікно, у якому циклів уже більше одного
    Хто такий «співробітник»?0, і це твердження, а не метафора«Людських рішень: 0» рахується з журналу: 8 питань платформи, 1 за правилом, 3 за політикою, 4 агентом-арбітром, 1 врятана робота

    Помилка, яку я зробив і виправив. Перша версія сторінки брала назву компанії зі знімка платформи — і показувала «Snaga Marketing». Це назва тенанта: юридична та платіжна особа, під якою працює редакція. Показувати її як назву редакції — це або вимагати, щоб хтось повірив, що ми пишемо рекламу для маркетингової агенції, або прикидатися чимось іншим. Тепер обидві назви на сторінці й підписані: редакційно це AI Newsroom, договірна сторона — Snaga Marketing, і співробітників немає в обох. Другий урок того самого розділу: агенти в маніфесті тепер мають платформні імена (Chief of Staff, а не «Editor-in-Chief») — маніфест, який перейменовує тих, кого перелічує, не є маніфестом.

    3ter.2. P2-2: Disclosure Profile 1.0 — стандарт як специфікація

    /api/disclosure-profile (JSON) і /disclosure-profile (людське читання). Ідентифікатор ai-newsroom-disclosure-profile/1.0.

    Специфікація складається з чотирьох обов'язкових клауз, чотирьох заборон і п'яти обов'язкових артефактів:

    КлаузЩо вимагаєПоверхняРеалізація
    what_was_doneНазвати, що текст згенеровано автоматично, і назвати агента, що перевіривсторінка статтіapp/disclosure.py:ARTICLE_LABEL
    who_is_responsibleНазвати відповідальну сторону; якщо це процес — зробити його перевірюванимсторінка статтіapp/disclosure.py:ACCOUNTABILITY
    what_is_not_confirmedПоказати зафіксовану невизначеність, а не приховувати їїсторінка статтіapp/disclosure.py:build_disclosure
    where_to_checkДати читачеві місце, де можна перевіритисторінка статтіджерела, вердикт, журнал

    Кожен пункт має surface і implemented_by. Це головна деталь: специфікація, яку не можна звірити з кодом, — це декларація. Тому кожна вимога профлю — це посилання на конкретне місце в репозиторії, а тест перевіряє, що вимога справді реалізована. Заборони теж не декларативні: no_bare_ai_label (узагальнена мітка «AI-generated» заборонена — §1.5), no_claim_of_human_review (не можна писати «перевірено людиною»), no_verdict_without_evidence (вердикт без цитати й джерела не рахується), no_silent_edits (жодна правка без запису в журналі).

    Обов'язкові артефакти: sources, verifier_notes, provenance, corrections_log, decision_journal — тобто те, що робить відповідальність не словом, а посиланням.

    Профіль спирається на чотири опубліковані дослідження (§1.5): Altay & Gilardi 2024, Toff & Simon 2025, Ali 2025, Schilke & Reimann 2025. Тобто стандарт не вигаданий — він відповідає на те, що вже виміряли, і заповнює прогалину, яку ці роботи залишають відкритими: усі чотири вимірюють ефект розкриття, але жодна не дає специфікації, яку можна перейняти.

    3ter.3. P2-6: доступ — і помилка, яка змусила переписати правило

    Пропозиція P2-6 була простою: «MCP_API_KEY порожній; /api/feed віддає headline/dek публічно; /api/articles — публічно». Найперша реалізація пішла очевидним шляхом: закрити API, сховати відхилене. Це була б помилка, і я її зробив.

    Бо ця редакція публікує відхилене навмисно. /rejected у головному меню, описане як «контроль якості», і кожна відхилена стаття читається на /article/{id}. Редакція, яка ховає свої відхилення, просить довіри замість того, щоб показати, чому її заслуговує. Якщо API сховає те, що сторінки показують, сайт почне суперечити сам собі — і читач, який відкрив би /api/articles?status=rejected, побачив би менше, ніж читач зі звичайного браузера.

    Тому правило переписане:

    API віддає рівно те, що віддають публічні сторінки, і нічого більше.

    СтатусСторінкаAPI
    publishedтактак
    rejected/rejected, /article/{id}так
    retracted/article/{id}так
    new, selected, error, duplicate, updatedнітільки з admin-токеном
    невідомий статус—400, а не тихий published

    Це закрило справжню діру, якої в початковій пропозиції не було: роботу в процесі (new, selected, error) не показує жодна сторінка — отже, не мав показувати й API. Перевірено на живому сервері: published → 200, rejected → 200, new → 403, невідомий статус → 400.

    Другий наслідок: is_admin лишається відкритим, коли admin-токен не налаштований (це зручність локальної демки — кнопка запуску мусить працювати без церемонії), але читання незавершеного беклогу вимагає справжнього токена і не наслідує цю зручність. Запустити цикл і прочитати чужу роботу — різні речі.

    3ter.4. Дірка в панелі керування, знайдена на живій платформі

    Окрема знахідка, якої не було в жодній пропозиції.

    Платформа зареєструвала MCP-сервер редакції з auth: {"type": "none"} — на публічний Cloudflare-тунель. Сире POST /mcp/ без заголовка Authorization повертало 200 і перелічувало 4 інструменти редакції. Тобто будь-хто, хто дізнається URL, міг запускати редакційні цикли й скасовувати run'и. Панель керування редакцією, відкрита без пароля, — це не «незручність», а діра.

    Механізм платформи довелося прочитати в коді SDK, бо документація про це мовчить:

    І дві деталі, кожна з яких коштувала невдалого запуску:

    1. prefix не можна задавати вручну. prefix="Bearer" дає Bearer<ключ> без пробілу → 401. Типовий префікс уже дає правильний Authorization: Bearer <ключ>, тож поле краще не чіпати.
    2. Без явного prefix платформа надсилає токен у правильному форматі — і тест платформи повертає ok: true, tool_count: 4.

    scripts/secure_mcp.py робить це так, щоб не можна було лишити систему в гіршому стані: створює новий сервер, просить платформу перевірити його і лише після успішної перевірки видаляє старий. Якщо тест не пройшов — старий сервер лишається на місці, і робота не зупиняється.

    Але захист, який можна зняти тим самим скриптом, яким його зняли, — це не захист. Тому scripts/register_mcp.py (саме він створив дірку) тепер відмовляється її створювати: --no-auth приймається лише для URL, недоступного ззовні (localhost або приватна підмережа), а --api-key-ref має називати змінну MCP_*. Інший шлях — знову відкрити панель — тепер закритий.

    Перевірено на живій платформі: tools/list з ключем → 200, без ключа → 401.

    3ter.4bis. Четверте місце, де інструмент може загубитися

    Захист панелі виявився не кінцем історії. Сервер був захищений, платформа бачила його інструменти, test_mcp_server повертав ok: true, tool_count: 4 — і агент усе одно не міг його викликати.

    Причина виявилася в тому, що «підключити сервер до агента» і «агент бачить інструменти» — це два різні записи, і обидва треба заповнити:

    МісцеЩо цеХто пише
    mcp_servers.assigned_agent_idsсервер призначено агентуset_agent_mcp_servers, PATCH /mcp/servers/{id}
    agents.mcp.servers + allowed_toolsагент бачить інструментиPATCH /agents/{id}
    agents.get_agent_capabilities().toolsпідтвердження, що вони в маніфестіплатформа

    Друге місце ми не заповнювали. Сервер був призначений, set_agent_mcp_servers повертав connected: [] (бо вже було), а маніфест агента показував 81 інструмент платформи й жодного нашого. Сервер був встановлений, підключений, active і проходив власний тест — і був недоступний.

    Три деталі, кожна з яких коштувала пошуку:

    1. PATCH /agents/{id} приймає mcp цілком: підполе, якого тіло не містить, скидається до типового, а не зберігається.
    2. transport в agent'овому схемі пишеться як http, а в реєстрі серверів — як streamable_http. PATCH з іншим написанням → 422.
    3. env у тілі PATCH — це секрет, і він мусить бути справжнім ключем, а не заглушкою: сервер перевіряє Authorization: Bearer <key>.

    Після PATCH маніфест став 85 інструментів, з яких чотири — наші.

    Фінальна перевірка — не «сервер відповідає», а «агент викликав»:

    Агента попросили викликати list_newsroom_cycles з limit=2. Він викликав і повернув два останні цикли з реальними даними: c86ce8bb… (124 матеріали, 2 нові історії, 1 відхилено, $0.145871) та 66441bd7… (125 матеріалів, 1 опубліковано, $0.095288).

    Тому companion.attach_mcp_to_agent() робить PATCH і потім перевіряє маніфест: якщо чотирьох інструментів там немає, він повертає false, а не «успіх». Перевірка, що не читає результат, — це не перевірка.

    Загальне правило, яке це дало. Платформа не має одного «увімкнено» для зовнішнього інструмента. Їх щонайменше три, і кожне може бути заповнене, поки решта — ні. Тому «сервер зареєстровано» ніколи не є достатньою відповіддю на запитання «чи може агент цим скористатися». Єдина достатня відповідь — агент викликав інструмент і отримав результат.

    3ter.4ter. P2-3: головний редактор, який сам наймає

    P2-3 пропонував зробити Chief of Staff EIC-роллю в циклі. Виявилося, що це не потребувало нового агента: Chief of Staff уже існує, і платформа вже описує його словами «веде компанію замість вас: планує роботу, роздає її команді й приносить те, що потребує вашого рішення». Опис був завжди правильним — пайплайн просто ніколи його не викликав.

    Тому зроблено дві речі, і обидві — на тому самому агенті.

    Перше: редакційна двері. Крок EIC стоїть між Scout'ом і Writer'ом. Він бачить сюжет, роздуми Scout'а, останні біти й те, що редакція нещодавно опублікувала, і відповідає одним з трьох:

    ДіяЩо означаєСтан сюжету
    runписати заразselected → пише Writer
    holdварта, але не зараз — у наступному циклі буде зновулишається held
    skipне варта уваги читача заразrejected

    Третя дія — головна. Без неї «EIC» — це просто run з додатковим кроком: рішення, яке не може сказати «ні», не є рішенням. hold відрізняється від skip тим, що нічого не губить: сюжет повертається в чергу.

    EIC не публікує. Його відповідь — це рішення про те, чи запускати написання. Єдинією дверима до читача лишається publish gate. Це той самий принцип, що й в арбітрі: агент-рішення не підмінює gate, а готує до нього рішення, яке людина не ухвалювала б.

    Друге: самонайм. Раз на 25 циклів EIC дивиться на власні числа редакції — що опубліковано, що відхилено, що оновлено, які біти покриті — і може запропонувати нову роль. Пропозиція не виконується автоматично: роль має бути зі закритого списку (News Editor, News Fact-Checker), ту саму роль не можна найняти двічі, а створення відбувається з явним набором never_allow — зокрема на весь agent factory і на ask_user.

    Правило вузьке навмисно: кожна його пунктна — це межа, а не дозвіл.

    ПунктЯк реалізованоДе
    Роль — зі закритого спискуHIREABLE_ROLES; невідома роль → refusedapp/eic.py
    Одну роль — один разlist_hired_roles()app/db_sqlite.py
    Новий агент не може нікого найнятиnever_allow на create_agent, update_agent, self_improve_agent, evaluate_agentapp/eic.py
    Новий агент не питає людинуnever_allow на ask_userapp/eic.py
    Новий агент не має exec-інструментівnever_allow на code_interpreter, run_command, notebook_run_cellapp/eic.py
    Найм і відмова — у журналіhired_agents + подія eic.staffingapp/db_sqlite.py

    Асиметрія — це і є дизайн. Writer і Verifier уже мають інструменти agent factory, але з never_allow, які їх знімають. Найнятий агент отримує те саме при створенні, а не «потім поправимо». Тому зростати може рівно один — EIC, — і кожне зростання залишає запис.

    Перевірено на живій платформі, двома способами.

    1. EIC ухвалив справжнє редакційне рішення. На сюжеті про накопичення ракет і дронів (ГУР, 21.10) EIC відповів run, priority high, кут «означає арсенал для нарощування реактивних БпЛА для української ППО цієї зими — за поясненням ГУР, без власних оцінок», $0.017111. Рішення лежить у журналі як eic.decided і видно на /editor та /api/editor. Пізніше той самий сюжет був відхилений Verifier'ом (revise, confidence 0.82) — тобто EIC і Verifier не дублюють один одного: перший вирішує «варта грошей», другий — «чи це готово до публікації».
    2. Найнятий агент не може нарощувати екіпаж. Запитали створеного «News Fact-Checker» () викликати create_agent — платформа відповіла: інструмент create_agent у цьому запуску не наданий. Агент відповів прямо: «Не можу виконати — інструмент create_agent у цьому запуску не наданий. Доступні лише list_agents і find_agents… створити агента я не можу і не буду вдавати, що це зробив» ($0.006774, run ). Кількість агентів лишилась 6. Того самого агента попросили створити Chief of Staff — і він створив його за один крок (, потім зупинений).

    Знахідка, яка варта окремої нотатки. Маніфест здібностей не відображає never_allow: в агента, якого ми найняли, він усе одно показує ask_user, code_interpreter і run_command. Тобто never_allow — це runtime-політика, а не видалення з маніфесту. Читати маніфест, щоб перевірити права агента, — недостатньо; єдине достатнє доказ — поведінка (агент спробував і не зміг). Це той самий урок, що й §3ter.4bis, зворотним боком: там платформа показувала інструмент, якого агент не бачив; тут платформа показує інструмент, якого агент не має.

    Найм записується у дві таблиці: подія eic.staffing у щоденному журналі (його читає сторінка /editor) і таблиця hired_agents — бо факт, що компанія виросла на роль, не перестає бути правдою через тиждень, як подія в журналі.

    3ter.4quater. U, V, W, X: чотири місця, де компанія говорила про себе неправду

    Чотири дефекти з аудиту мають одну спільну форму: система виглядала здоровою рівно тоді, коли вона не працювала. Кожен із них — це не помилка в розрахунку, а помилка в тому, що система робила з відсутністю даних.

    U. Watchdog, який не міг побачити платформу, але казав «все добре»

    SDK UARP розділяє помилки на два дерева: APIError — це HTTP-відповіді (409, 429, 404), а UarpError — це транспорт (APIConnectionError, APITimeoutError, ConfigError). APIConnectionError — не підклас APIError. Це один рядок у uarp_sdk, але він визначив поведінку всієї редакції.

    Захисний код мав вигляд except APIError: continue. Тобто він ловив кожну помилку HTTP і жодної помилки з'єднання. А помилка з'єднання — це саме той випадок, коли платформа недоступна.

    Наслідок: під час збою UARP watchdog проходив усі три статуси, нічого не знаходив і писав у журнал циклу stuck: 0 — тобто «застряглих немає». Сторінка /cycles показувала це як бейдж «watchdog: 0 застрягли». Журнал циклу — це аудит-слід автономної компанії, і в ньому записувалося неправдиве твердження: компанія, яка не могла нічого побачити, виглядала здоровою. Мовчки й назавжди — бо watchdog стоїть перед кожним циклом, і збій платформи не зупиняє цикл.

    Той самий баг був у чотирьох місцях: watchdog.py, uarp_arbiter.py (де знову except APIError: continue — арбітр мовчки пропускав чергу, у якій чекали на людину), uarp_company.py (дашборд) і eic.py (найм).

    Як виправлено. Створено app/uarp_errors.py — одне місце, де SDK_ERRORS = (APIError, UarpError), і error_text(), який форматує помилку, не припускаючи, з якого дерева вона походить (HTTP-дерево має status/code/reason, транспортне — ні, і exc.status на помилці з'єднання кинуло б AttributeError всередині обробника помилки). Тепер звірка має два поля: checked_statuses і unreadable_statuses, і властивість complete.

    Результат тепер говорить саме те, що знає:

    {
      "stuck": 0,
      "checked_statuses": [],
      "unreadable_statuses": [
        {"status": "awaiting_approval", "error": "APIConnectionError: ..."},
        {"status": "awaiting_input",    "error": "APIConnectionError: ..."},
        {"status": "running",            "error": "APIConnectionError: ..."}
      ],
      "complete": false,
      "status": "unreachable",
      "reason": "the platform could not be read for: awaiting_approval, awaiting_input, running"
    }

    Перевірено на живій платформі зі справжнім недоступним хостом: до зміни було stuck: 0 без жодної помилки, після — status: unreachable і перелік трьох непрочитаних станів. Той самий звіт пише в summary циклу (з помилкою в summary["errors"]), і сторінка /cycles більше не показує «watchdog: 0 застрягли», коли перевірка не відбулася.

    V. Таймер, який вигадував час циклу, який не запуститься

    На кожній сторінці редакції висить таймер «Наступне оновлення». Він обіцяв читачеві час наступного циклу.

    Коли платформа не відповідала, код брав початок останнього циклу, додавав інтервал і видавав результат за розклад з running: true. Тобто таймер, який тикав до конкретної хвилини, показував час, коли циклу не буде.

    Розрізнення, якого не було: «платформа відповіла, і розкладу немає» (редакція справді простоїй) і «платформа не відповіла» (редакція не знає, коли наступний цикл). Обидва випадки давали однаковий next_cycle_at — вигаданий.

    Як виправлено. native_schedule() тепер запам'ятовує причину невдачі (last_read_error()) і відрізняє її від «розкладу немає». /api/schedule у такому разі відповідає status: "unreachable", next_cycle_at: null, running: false і reason — і не називає жодного часу. Випадковий таймер на випадок, коли платформа недоступна, прибрано: він працює лише тоді, коли платформа відповіла і розклад існує, але ще не повідомив про себе. Фронтенд тепер показує «розклад недоступний» замість «планувальник не запущений» — бо це різні речі, і перша чесніша за другу.

    W. Черга, яка вимірювала себе неправильно

    У живій базі було 312 історій зі статусом new при 398 історіях загалом. Але пайплайн обирає лише історії, молодші за вікно свіжості (120 хвилин), і таких було 3. Тобто 309 історій чекали в черзі вічно — їх не прочитає жоден цикл — а черга показувала себе як у сто разів більша, ніж вона є.

    Як виправлено. Дві речі.

    Перше: hold перестав бути new. Коли EIC каже «варта, але не зараз», сюжет повертався в чергу зі статусом new. Але пул candidates — це найновіші N історій, тому відкладений сюжет витіснявся новими прибуттями і не повертався ніколи. hold був обіцянкою, яку пайплайн не міг виконати. Тепер у сюжета окремий статус held, і get_new_stories читає held попри вікно свіжості та ставить їх на початок пулу.

    Друге: черга рахує те, з чого можна працювати. Нова функція count_new_stories() повертає три числа: new (скільки позначено), readable (скільки ще можна взяти) і stale (скільки не можна). Сторінка /queue показує саме readable як «у черзі», а stale окремо — щоб було видно, що забуте, а не приховано.

    Третє: застрілілі історії йдуть у stale. retire_stale_stories() переводить історії, які перевищили вікно свіжості, зі new у stale — але не чіпає held, бо hold означає «поверніться до цього», а таймер не має права скасувати рішення EIC. Факт побачення не зникає: історія лишається в базі, просто її статус більше не «нова».

    На живій базі: 312 → 3 у черзі, 309 переведено в stale, загальна кількість історій не змінилась (398).

    X. Цикл, який приніс 119 історій і не показав жодної

    Цей дефект знайдений не в коді, а на живому циклі — і саме так, як мають знаходитись дефекти, пов'язані з правдою: не через читання, а через запуск.

    Живий цикл о 02:20 показав у summary:

    {
      "fetched": 119,
      "new_stories": 0,
      "duplicates": 0
    }

    119 принесених історій, 0 нових, 0 дублікатів. Числа не сходилися: 119 розійшлися з нулем. Причина — в одному рядку upsert_story(), який повертав None, коли історія з таким самим хешем уже була в базі. А пайплайн рахував лише "new" і "duplicate":

    result = upsert_story(story, settings.db_path)
    if result == "new":
        summary["new_stories"] += 1
    elif result == "duplicate":
        summary["duplicates"] += 1
    # а "seen" — тобто "ми це вже знаємо" — падав у нікуди

    Третій випадок не був рідкісним. RSS повторює те, що редакція вже має, завжди — і живий цикл показав: усі 119 принесених історій уже були в базі, і жодна не була новою. Отже, типовий цикл завжди показував «119 принесено, 0 нових, 0 дублікатів» — тобто три числа, які разом дають нуль, при 119 на вході.

    Чому це гірше, ніж просто неправильне число. Помилка в підрахунку дає неправильне число — його можна помітити. Тут було повна відповідь з правильними наївними числами: fetched 119 — правда, new_stories 0 — правда, duplicates 0 — правда. Але сума трьох правдивих чисел не дорівнює числу, яке стоїть поруч. Звіт був неповним — а неповний звіт виглядає як повний. Ніхто, читаючи «RSS: 119, нових: 0, дублікатів: 0», не помічає, що 119 історій зникли між рядками. Так само, як «watchdog: 0 застрягли» не помічається, коли watchdog узагалі не міг нічиого побачити.

    Як виправлено. Третій випадок тепер має ім'я, і воно не порожнє.

    Перше: upsert_story() повертає "seen", а не None. Значення, яке не можна покласти в кошик, — це діра в звіті, тому третій випадок дістав ім'я. Це змінило контракт у app/db_sqlite.py і app/db_d1.py — обидва бекенди повертають одні й ті самі три слова, тому звіт однаковий, яка б база не стояла.

    Друге: кожна принесена історія потрапляє рівно в один кошик. Нова — new_stories, близький дублікат — duplicates, уже відома — already_seen. Статус, якого ніхто не знає, потрапляє у свій кошик unaccounted, а не в already_seen: назвати невраховану історію «вже бачили» — це теж брехня, і весь сенс цього обліку в тому, щоб звіт казав, що справді сталося.

    Третє: цикл перевіряє себе. Після підрахунку:

    accounted = (
        summary["new_stories"] + summary["duplicates"] + summary["already_seen"]
    )
    summary["fetched_accounted"] = accounted
    if accounted != summary["fetched"]:
        summary["errors"].append(
            {"stage": "fetch", "error": f"..."}
        )

    Тобто цикл, у якому рахунок не сходиться, сам про це каже — і сторінка /cycles показує бейдж «рахунок не сходиться» замість тихих нулів.

    Перевірено на живому циклі після зміни:

    {
      "fetched": 119,
      "new_stories": 0,
      "duplicates": 0,
      "already_seen": 119,
      "unaccounted": 0,
      "fetched_accounted": 119
    }

    119 = 0 + 0 + 119. Книги сходяться. І тепер видно те, чого раніше не було видно: редакція вже знала всі 119 принесених історій. Це не дефект пайплайна — це факт про стрічку. Але раніше цей факт не існував у журналі циклу: він був присутній у базі й відсутній у звіті.

    Що з цього випливає для стандарту

    Чотири дефекти — це один дефект у чотирьох місцях, і він вартий окремої формули для автономної компанії:

    Відсутність даних — це дані. Компанія, яка не знає, не має права виглядати так, наче знає.

    Watchdog, який не бачить платформи, має сказати «не бачив», а не «немає застряглих». Таймер, який не знає розкладу, має сказати «не знаю», а не показати вигаданий час. Черга, з якої нічого не можна взяти, має показувати нуль, а не триста.

    Четвертий дефект — X — це те саме правило, застосоване до арифметики замість відсутності. «119 принесено, 0 нових, 0 дублікатів» — це не брехня, а дірка: три правдиві числа, які не складаються в четверте правдиве число, яке стоїть поруч. Найгірше тут те, що повний звіт виглядає як повний, тому ніхто не помічає.

    Тому правило стандарту має два кінці, і обидва потрібні:

    Відсутність даних — це дані. І дані, які не сходяться, — теж дані: неповний звіт гірший за відсутній, бо неповний виглядає як повний.

    Це не «красивіше повідомлення». Це єдина різниця між компанією, яка контролює себе, і компанією, яка лише виглядає, що контролює себя. І вона проявляється саме тоді, коли щось зламалося — тобто саме тоді, коли їй найбільше потрібна.

    3ter.5bis. Сем'я з семи дверей: коли Verifier помиляється, хто це помічає

    Попередня секція (§3ter.4quater) розібрала чотири місця, де компанія про себе говорила неправду. Ця — про інший клас дефектів: місця, де редакція мовчала про те, що вона знала.

    Знайдено в аудиті одного робочого циклу й перевірено на живій платформі. Сім дефектів, сім різних механізмів, але одна причина: етап, який впав або сказав «не знаю», все одно видавав вердикт — і код вірив цьому вердикту.

    Y-bis. План покриття, який не можна було порушити, бо його не існувало

    Coverage Editor — агент, який вирішує, про що має бути цикл. Він існував, він читав портфель, він писав «war, economy, tech» і rationale. Але його план нічого не забороняв, бо сторони говорили різними мовами.

    Слово «бейт» означало одне в pipeline.py і інше в coverage.py. Але гірше: ключ, за яким кандидат дізнавався, свого бейту, — topic_hint — не існував на жодному кандидаті. upsert_story такого поля не писав, select_candidates такого поля не читав. Тому:

    Тобто порушення власного плану покриття було фізично недетектоване. Не «рідко виявлялося» — виявлятися не могло.

    Це той самий дефект, що §3ter.4quater X, тільки в іншому місці й з іншою доказовою базою: там звіт циклу показував нуль замість ста, тут перевірка показувала «добре» замість «немає що перевіряти».

    Як виправлено. Створено app/beats.py — єдина словникова таблиця бейтів, з якої читають і пайплайн, і coverage, і класифікатор (раніше вона була продубльована в двох файлах і розійшлася). Кожен кандидат тепер отримує бейт перед тим, як його побчить Coverage Editor (label_candidates), а оцінка Scout'а має пріоритет над регулярним виразом (apply_candidate_beats) — але тільки для відомого кандидата й тільки для бейту зі словника. apply_plan став самодостатнім: він підписує кандидатів сам, якщо їх не підписали, тому перевірка більше не залежить від того, хто викликав.

    Перевірено на живій платформі. Перший же цикл після виправлення показав Coverage Editor'а, який раніше ніколи не працював у продакшені (0 записів stage='coverage' у базі — і це був не збій, а наслідок дефекту F9: insert_run не передавав db_path, тому записи йшли в іншу базу). Він вирішив: war, economy, tech, з обґрунтуванням, що в портфелі по одному матеріалу на 25 публікацій в economy і tech, а culture і sports цього циклу взагалі без кандидатів. План був дотриманий: compliance.honoured = true, scout_beat = "war".

    І найважливіше — на тих самих 16 кандидатах регулярний вираз помилився 5 разів:

    КандидатРегулярний виразОцінка Scout'а
    «Через удари дронів на Харківщині загинули 2 людини…»economywar← «удари» → economy
    «У жовтні кількість втрат російських окупантів…»techwar
    «Магнітні бурі в Україні: вчені оприлюднили прогноз»politicsother
    «Зірки, які пережили рак: Камалія…»politicsculture
    «"Бабине літо" повернулось…»politicsother

    Тобто Scout не просто дублює регулярний вираз — він виправляє його там, де вираз помиляється на 31% вибірки. Регулярний вираз лишився як страховка (щоб кандидат без оцінки Scout'а все одно мав бейт), але рішення ухвалює агент, а не re.search.

    Це і є відповідь на питання «навіщо нам Coverage Editor, якщо є регулярний вираз». Регулярний вираз — це grep. Редактор — це рішення.

    Z-bis. Три обійні воріт, які всі виглядали однаково

    _publish_gate — це єдині двері до читача. Після цього класу аудиту їх стало три замість одного, і кожна з них мала одну й ту саму помилку: етап, який впав, все одно віддавав вердикт, а код вірив цьому вердикту.

    1. Корекція. check_article мав умову «вердикт каже correct», «впевненість вища за поріг» і «є текст». Жодної перевірки, що перевірка завершилася. А перевірка, впала на ліміті витрат платформи, все одно повертає розбірливий JSON. Отже, впала перевірка могла опублікувати корекцію — а впала перевірка з вердиктом retract могла зняти вже опубліковану статтю зі сторінки читача.
    1. **verifier_2. Якщо повторна перевірка не розпарсилася, пайплайн мовчки залишав перший вердикт — тобто публікував на силі доревізійного** вердикту. Редакція, яка переписала статтю після перевірки, публікувалася за вердиктом тексту, який уже не існував.
    1. **update_writer.** Те саме для оновлень: якщо переписувач впав, оновлення все одно йшло далі.

    Як виправлено. _publish_gate тепер приймає allowed_verdicts (бо корекція — це не стаття: її вердикти correct/correction/retract, а не publish) і список провалених етапів. Провалений етап блокує публікацію, незалежно від того, що вердикт каже. Тобто запитання «що вердикт?» більше не єдине — запитання тепер «який вердикт і чи всі етапи, які його дали, живі».

    Плюс окремо: якщо переписувач впав, немає чого ревізувати — тому оновлення не переписується, а вердикт на перепис не береться.

    Перевірено тестами (tests/test_gate_bypasses.py, 11 тестів): добра корекція публікується; впала перевірка не публікує нічого; впала перевірка не відкликає статтю; голодна перевірка блокується; низька впевненість блокується; порожній текст блокується; причина блокування записується; набір вердиктів корекції — окремий.

    AA-bis. db_path, який не доходив до бази

    insert_run і insert_article викликалися без db_path. Фасад app/db.py — це імпорт-зі-зіркою, тож функції експортуються автоматично, але аргумент за замовчуванням — це аргумент, який перекриває те, що просили.

    Наслідок: Coverage Editor, створюваний раз на цикл, записувався в базу за замовчуванням, а не в ту, з якою працює редакція. Тому в живій базі було 0 записів stage='coverage' за 89 циклів. Редакція, яка 89 разів вирішувала, про що їй писати, не мала жодного сліду цих рішень.

    Це той самий клас, що X і V: система, яка не записує те, що робить, не може себе перевірити. Виправлено — db_path передається в insert_run і insert_article; додано set_run_story і list_story_runs, щоб зв'язок «історія → які етапи її опрацювали» був у базі, а не в пам'яті.

    AB-bis. Цикл, що падав на одній історії

    Один story, який кинув виняток, збивав увесь цикл: cycle.finished не записувався, і в журналі з'являвся розрив — 105 cycle.started і 103 cycle.finished. Причина не в самій історії, а в тому, що цикл не мав except навколо story-циклу.

    Виправлено: except Exception навколо опрацювання історії, finally — звільнення claim'у. Одна історія, що впала, більше не тягне за собою цикл; її помилка потрапляє в summary["errors"] як запис, а не як відсутність запису.

    Це та сама думка, що в §3ter.4quater X, але навпаки: там компанія виглядала повною, будучи неповною. Тут компанія виглядала неповною — і була неповною по-справжньому. Обидві половини тепер закриті: неповний цикл доживає до кінця, а неповний звіт рахує себе.

    AC-bis. Два верифікатори, які не згодні

    Жива база показала історію 108 з двома writer-запусками, двома verifier-запусками і двома verifier_2 за чотири хвилини, і вони не згодні: один сказав publish/0.86, другий — revise/0.72 з п'ятьма обов'язковими правками, у тому числі неправильною цифрою тарифу. Стаття опублікувалася на першому вердикті, а суперечність лежала в таблиці runs, де її не побачить жоден читач.

    Причина — не в логіці вердиктів. Причина в тому, що два цикли робили одну історію одночасно: планувальник стріляв цикл, людина натискала кнопку, і кожен дістав свій idempotency_key, тому десятихвилинний кошик, який зупиняє другий цикл, між тригерами не працював.

    Виправлено: claim на історію береться перед витратою грошей і перед EIC, і звільняється в finally. Другий цикл тепер бачить, що історію вже роблять, і залишає її. Записи в runs_json статті тепер містять prior — усі попередні запуски по цій історії, — тому суперечність, якщо вона виникне, буде видна на сторінці статті, а не тільки в базі.

    AD-bis. Правки, які випали разом із відхиленою статтею

    Це дефект, знайдений вже під час виправлення AC-bis, і він виявився глибшим за те, що здавалося.

    Коли одна статті виграє, а інша відхиляється, відкидаються й усі правки, які суворіша перевірка вимагала від відхиленого тексту. Але обидві статті описують ту саму подію. Тобто правка, яка була справедливою для відхиленого тексту, може бути справедливою й для опублікованого — а вона зникла разом із відхиленою статтею.

    Живий запис це показує прямо: опублікована стаття 25 каже «мит до 100% на товари з країн, які купують російські енергоносії», а п'ять правок відхиленої статті 26 — серед них «виправити опис закону Грема: 500% на товари з РФ; 100% — на товари п'яти найбільших покупців…» — зникли. Помилка залишилася на сторінці, а інструкція, як її виправити, лежала в таблиці runs, яку читач не бачить.

    Перша спроба виправлення зробила це навпаки. Вона читала всі зафіксовані вердикти, а не фінальний вердикт іншого ланцюга. У звичайному flow (verifier(revise) → revision → verifier_2(publish)) перший вердикт уже застосований — переписувач вніс його правки, а повторна перевірка перевірила результат. Тому корекція про «незастосовані правки» з'явилася б на кожній доревізійній статті — і перелічувала б правки, які стаття вже містить.

    Тому правило тепер таке:

    Незастосована правка — це правка фінального вердикту іншого ланцюга, яка стосується цього тексту.

    Три наслідки, кожен з яких перевірено тестом:

    1. Читається фінальний вердикт кожного ланцюга (run_seq), а не всі підряд: попередні вердикти ланцюга вже відповіли на себе власною ревізією.
    2. Правка publishable-вердикту враховується лише якщо вона обов'язкова, а не «можна було б додати контекст». Розрізняє це серйозність (severity) в Verifier'а: low — це побажання, medium/high — це дефект. У живій базі 31 publishable-вердикт і жодного з них не має обов'язкової правки, тож цей випадок — рідкість, і саме тоді, коли читач має дізнатися.
    3. Правка не публікується просто тому, що її вимогили. Відхилений текст і опублікований — різні тексти про ту саму подію, і зауваження могли стосуватися речення, якого в опублікованій статті немає. Тому перед публікацією корекції зауваження перевіряються проти опублікованого тексту окремо, агентом (conflict_check), і публікуються лише ті, що підтвердилися.

    Третя умова — це те, що робить механізм безпечним. Без неї редакція казала б читачеві, що стаття хибна там, де вона права: хибне твердження, опубліковане від імені точності, — воно гірше за мовчання, яке замінило. Перевірка закривається в бік відсутності корекції: впала перевірка, голодна перевірка, нерозбірливий JSON — корекція не публікується. Ціна пропущеної корекції менша за ціну вигаданої.

    Перевірено тестами (tests/test_conflict_fixes.py, 21 тест): звичайний flow не породжує корекції; суперечність породжує; зауваження, що не стосується тексту, не породжує; впала й голодна перевірка не породжують; помилка перевірки записується, а не ковтається.

    3ter.5ter. Стаття 25: коли Verifier помиляється, а коли — ні

    Клас §3ter.5bis вимагає відповіді на практичне питання: що робити, коли агент-верифікатор помиляється? Бо агент помиляється — і це не гіпотеза, а зафіксований факт.

    Жива стаття 25 (історія 108, Портніков про санкції) мала два протилежні вердикти двох verifier_2, і останній вимагав виправити опис закону Грема так: «500% на товари з РФ; 100% — на товари п'яти найбільших покупців російських енергоносіїв і п'яти країн-фасилітаторів обходу».

    Здавалося, що це вигадка верифікатора: у статті було написано «мит до 100% на товари з країн, які купують російські енергоносії», і це речення дослівно збігалося з джерелом (espreso.tv). Два підстави сумніву: «цитата з джерела, значить, правильно» — і «помилка верифікатора, значить, його треба заблокувати».

    Перевірка на авторитетних джерелах (Pub. L. 119-111, §§112–113; 22 U.S.C. 10812 і 10813; CRS Legal Sidebar LSB11474; указ Президента від 16 жовтня 2026) дала відповідь, яка не збігалася ні з одним із двох припущень:

    ПитанняЗаконДжерело
    Мито на товари з Росіїдо 500% ad valorem, на усі товари§112
    Мито на товари з країн, що купують РФ-енергодо 100%, але лише для п'яти найбільших імпортерів і п'яти фасилітаторів обходу§113(c)(1), §113(c)(2)
    Ставка для фасилітаторівокремої ставки немає — той самий §113, стеля 100%§113(c)(2)
    Чи діють мита заразні: указ від 16.10.2026 §4(a) відкладає §§112–113Білий дім

    Тобто верифікатор був правий, а стаття — неповна. Речення статті було вірним щодо своєї цифри (100%) і неправдивим щодо обсягу: воно подавало правило, яке зачіпає «країни, які купують російські енергоносії», без «п'ять найбільших», і зовсім не згадувало 500% на товари з самої Росії.

    І водночас джерело теж було неповним — воно пересказувало закон так само, як стаття. Отже, стаття була вірна своєму джерелу, а джерело — неповне щодо закону. Це не випадок «журналіст перекрутив» і не «редакція вигадала».

    Що зроблено. Це випадок correct, а не retract: фактична підстава статті правильна, неточна побудова твердження, яку треба публічно виправити. Ретракція тут була б брехнею — вона сказала б читачеві, що журналіст вигадав те, що сам закон каже інакше.

    І ось тут стає видно, навіщо потрібен постпублікаційний аудит у циклі (§3ter.4ter, дефект I). Верифікатор до публікації мав ту саму проблему: він бачив лише те, що стосується статті, і не мав доступу до повного тексту закону. Після публікації аудит працює з самим джерелом, із законом, із контекстом — і може знайти те, чого передпублікаційна перевірка фізично не бачила.

    Перший автоматичний аудит на живій платформі (стаття 18, «Венс: Україна не повинна вступати до НАТО») виявив справжню помилку — перекручену пряму мову (заміна «Ми» на «США» всередині лапок) і неіснуюче слово «капітація» замість «капітуляція» — і опублікував корекцію, явно відрахувавши, що це не ретракція: «суть матеріалу правильна».

    І тут важлива чесність звіту. Перший аудит статті 25 повернув вердикт ok з упевненістю 0.7 — не тому, що все добре, а тому, що espreso.tv віддав HTTP 403 і перевірка не змогла підтвердити ставку. Тобто агент не став вигадувати помилку, а чесно сказав, що не зміг перевірити. Це те саме правило, що й в §3ter.4quater: звіт каже «не знаю», а не «добре». І саме цей «не знаю» доповнив дослідження на авторитетних джерелах, яке довело: верифікатор був правий, а стаття — неповна.

    Це і є та відповідь на питання §3ter.5bis. Верифікатор помиляється — і це не привід йому не вірити. Привід — друга перевірка, з іншим доступом і з правом опублікувати корекцію. Редакція, яка не перевіряє себе після публікації, не має права на публікацію.

    І друге, що видно з цього випадку: «не зміг перевірити» — це теж результат. Агент, який не зміг дістатися першоджерела, не повинен ані мовчати, ані вигадувати. Він має сказати «не зміг» — і тоді починається наступна ланка роботи: дослідження на авторитетних джерелах, де першоджерело доступне.

    3ter.5quater. Двадцять п'ять дефектів, знайдені на живій платформі

    §3ter.5ter показав, що аудит може бути правим, а система — брехати про те, що вона його зробила. Цей розділ — те саме, але в узагальненому вигляді: це двадцять п'ять окремих дефектів, знайдені на живій платформі, кожен з яких виглядав як робочий код.

    Спільна їхня природа: вони всі — про те, як система говорить про себе. Кожен записує «перевірка пройшла» там, де перевірка не дійшла, «думка підтверджена» там, де її не читали, «зауваження перевірено» там, де відповідь прийшла в іншому форматі. Жоден із них не падає. Усі вони тихо брешуть.

    Дефект 1. Забіг, обірваний на кроці, записувався як «виконано»

    Платформа зупиняє прогін на бюджеті кроків, ставить output.truncated і видає continuation_token — і більше не стрімить продовження нам. Відповідь живе у run.output.response, але ми її не читали. Наш обгорткове записував status: completed, response: "", parsed_json: NULL.

    Наслідок: articles_needing_audit шукає correction_check із status='completed'. Живий прогін 443 (аудит статті 25) витратив чотири кроки на чотири джерела, уперся в стелю і не сказав нічого — а система записала це як «аудит пройдено». Стаття з неправильною цифрою мита лежала далі неперевіреною, і ніхто про це не знав.

    Лікування: прогін, що впирається в стелю, відновлюється через continue_run, а відповідь читається з run.output; прогін, який і після відновлення не відповів, записується як incomplete, а не completed. Стеля відновлень — дві: прогін, який продовжується безкінечно, — це прогін, що не сходиться.

    Дефект 2. Аудит мав недостатньо місця, щоб сказати

    Навіть якби дефект 1 був виправлений, аудит усе одно не відповів би: на звичайній стелі етапу він витратив усі чотири кроки на читання і не дійшов до відповіді. Перевірка, яка не може завершитися, — це не перевірка; вона просто не існує.

    Лікування: аудит отримав власний бюджет (AUDIT_MAX_COST_USD, AUDIT_MAX_TOOL_CALLS, AUDIT_MAX_STEPS) — як і друга перевірка, бо вони обидві мусять прочитати джерела і відповісти.

    Дефект 3. Друга перевірка відповідала іншою формою — і читач її не бачив

    Промпт просить issues. Жива відповідь написала findings; пояснила кожен зауваження в reason, а не в problem; поклала джерело в source, а не в evidence; і не поклала жодного URL у sources. Читач розумів лише issues — і повертав порожній вердикт.

    Це не «агент помилився». Це агенту сказали не те, як його читати. Імена полів — це угода, а не контракт; читач, який наполягає на одному імені, викидає справжні відповіді. Живий агент підтвердив два зауваження з первинним джерелом і цитатою держсекретаря — і читач це не побачив.

    Лікування: відповіді читаються з будь-якої форми (issues, findings, items, results), а кожне поле — з першого знайденого імені (problem|reason|explanation, evidence|source|sources|url, quote|text|excerpt, finding|number|index|id).

    Дефект 4. Агент перенумерував зауваження — і ми йому вірили

    Жива відповідь прийшла у зворотному порядку і пронумерувала 1..5 за своїм порядком. Тобто її «finding: 2» вказувало на наше зауваження 4. Ми довіряли номеру — бо промпт просить саме його — і мали б закрити підтверджене зауваження не проти того рядка.

    Ключ, який можна перевірити, — цитата: це фрагмент опублікованого тексту, який ніхто не має підстав переписувати. Номер — лише запасний варіант, і тільки тоді, коли відповідь сама не перенумерувала зауваження (перевіряється тим, чи збігається її номер із зауваженням, на яке вказує її ж цитата).

    Дефект 5. Прапорець, написаний рядком, читався як підтвердження

    bool("false") — це True. Відповідь із "confirmed": "false" была б прочитана як підтвердження, і редакція опублікувала б корекцію за зауваження, яке друга перевірка щойно відкинула.

    Лікування: прапорець читається як булево, рядок, число чи українське «так»/«ні»; те, що прочитати не вдалося, — це відсутність відповіді, а не відповідь.

    Дефект 6. У публічну корекцію йшов текст, написаний для редакції

    Аудит пише поле fix як замальовку для себе: «звірити ставку з текстом закону; якщо підтвердження немає — прибрати цифру». Це не речення для читача. А ми саме це і публікували.

    Лікування: текст корекції пише друга перевірка — вона перевіряла первинне джерело, і саме її формулювання є твердженням про факт. Аудитовий fix лишається в записі, де й належить бути, — у журналі зауважень.

    Дефект 7. Шкала severity була вигадкою агента

    Це був найглибший дефект, бо він робив решту ланки мертвою. 39 із 43 зауважень аудиту повернулися як low. Друга перевірка запитується лише про зауваження серйозніші за поріг — отже, вона майже ніколи не викликалася, і справжнє зауваження лежало на сторінці без відповіді.

    Причина не в лінь і не в помилковості агента: промпт ніколи не пояснював, що означають ці слова. Агент вигадав власну шкалу, у якій майже все — low.

    Лікування: шкалу визначено прямо, і визначено через питання, яке має сенс: «чи змінило б це те, у що повірить читач?» low — ні; medium — читач дістав би хибне уявлення про факт (неправильна цифра, неправильна атрибуція, пропущена суттєва частина закону, згадка однієї країни замість іншої); high — хибне уявлення про те, що сталося або хто це зробив; critical — насильство, смерті, війна, великі гроші.

    Після цього шкала запрацювала одразу: аудит статті 25 підняв до medium зауваження про пропущену іранську складову (і залишив стилістичні low), друга перевірка викликалася вперше, і зауваження було відкинуте з обґрунтуванням і посиланням на первинні документи.

    Дефект 8. Відмова не відрізнялася від відсутності відповіді

    Три різні речі звучать однаково, якщо не розрізняти їх: «зауваження підтверджене», «зауваження відкинуте» і «ми не змогли перевірити». Перша веде до публічної корекції, друга закриває зауваження, третя лишає його відкритим — і відкрите зауваження видно читачеві.

    Агент, який відповів без поля confirmed, нічого не вирішив. Прочитати його як «не підтверджено» — значило закрити зауваження, з яким друга перевірка погодилася.

    Дефект 9. Підтвердження мало бути про текст, який є на сторінці

    Відповідь може підтвердити те, чого в статті немає: обрізаний фрагмент речення, твердження, якого матеріал не робив. Таке підтвердження — не підтвердження цієї статті, а підтвердження чогось іншого.

    Лікування: цитата підтвердження має бути дослівно в опублікованому тексті (заголовок, dek або тіло). Не знайшена — зауваження лишається відкритим.

    Дефект 10. Джерела з виправлення не потрапляли до читача

    Підтверджене зауваження мало створити корекцію, але без перевірюваних посилань. Джерела лежали в полях, які ми не читали, а коли лягли в evidence — у полі, яке містило два URL в одному рядку, розділені крапкою з комою.

    Лікування: витягуються всі URL — з поля підтвердження, з верхнього рівня, поодинці й парою.

    Дефект 11. Арбітр відповідав усім однією мовою

    Арбітр — це агент, який замінює людину, коли платформа спиняє run і чекає на відповідь. Він був зроблений для одного агента: Verifier зупиняється й просить текст, який він має перевіряти. І далі арбітр відповідав всем цією однією мовою.

    Це видно в живому записі. Chief of Staff спитав: «З чого почати зараз — що для вас пріоритет?» — і отримав: «У контексті немає редакційних даних: ні тексту матеріалу, ні джерел із посиланнями, ні вердикту фактчекінгу… матеріал відхилити». Це не відповідь. Це відмова написати статтю на запитання, яке про статтю не питало.

    Сам Chief of Staff записав у своєму run, що саме він отримав:

    Замість вашого вибору система повернула текст, схожий на відмову редакційного агента… Це не ваша відповідь і не мій висновок — це чужий службовий текст, який прийшов на місце відповіді.

    Але найгірше було не те. **Три run'и Strategic'а висіли в awaiting_input у момент, коли цей рядок писався** — і арбітр відповідав на них «Потрібних даних у контексті немає. Поверни verdict reject…».

    Питання було: «Публікація брифінгу заблокована 41 цикл поспіль: у середовищі немає жодного конектора до зовнішнього каналу… Який варіант обираєте, щоб розблокувати місію?»

    Матеріал був готовий і перевірений. Блокувало не брак даних, а відсутність каналу. Слово «немає» у фразі «немає конектора» правило «відсутні дані» прочитало як «немає даних» — і працююча система отримала відмову.

    Лікування: арбітр знає, хто питає.

    Правило «немає даних» тепер працює лише для матеріалу. Для компанійного питання воно не спрацьовує, бо питання не про матеріал.

    І відповідь на питання про публікацію — канал, який уже є. Ця редакція публікує на власному сайті; зовнішній конектор до Telegram чи CMS не є умовою публікації. Це й було причиною 41 циклу без руху: місія чекала на канал, який не потрібен, бо чекала на людину.

    Канал перевіряється перед тим, як бути названим. Перша версія правила відповідала адресою з SITE_URL — http://127.0.0.1: — а сайт слухає на 8077. Новаторіа відповідала б mission на адресу, яка повертає порожнечу. Тепер правило перевіряє, чи відповідає адреса, і не називає канал, який не відповідає.

    Дефект 12. Два блокери інфраструктури, які маскував дефект 11

    Коли арбітр нарешті відповів Strategist'у його мовою, той відповів у відповідь — і назвав два справжні блокери. Це і є головний результат дефекту 11: не «відмова більше не звучить», а «блокування тепер має справжню причину, яку можна полагодити».

    Блокер 1. Адреса, яку дає арбітр, принципово недосяжна для того, хто питає.

    Правило відповідало http://127.0.0.1:. Це адреса цього комп'ютера. Агент, який питає, працює на машинах платформи, і 127.0.0.1 там — це loopback платформи, а не редакція. Стратег перевірив це сам:

    fetch http://127.0.0.1: → «The domain 127.0.0.1 is not on the tenant egress allowlist. This is a permanent configuration error»

    Правило мало рацію, що канал є, і помилялося в тому, який це канал. Канал, який відкриває mission, — це публічний тунель.

    Блокер 2. У Strategist'а не було інструментів, які вимагала місія.

    MCP-сервер редакції був приєднаний лише до Chief of Staff. Місія ж вимагала run_newsroom_cycle — і її власний текст: «Виклич MCP-інструмент run_newsroom_cycle з limit=1» — звертався до Strategist'а, у якого цього інструмента не було. Тридцять тіків поспіль mission падала на власній інструкції.

    Я перевірив перелік доступних пакетів можливостей (усі 34) — жодного з run_newsroom_cycle, list_newsroom_cycles, get_newsroom_cycle.

    І третє, що виявилося між ними: тунель вів у порт, де нічого не слухало. scripts/companion.py піднімав сервер на 8000, а сайт слухав на 8077. Тунель був живий, MCP-панель відповідала 502, а платформа мала зареєстрований URL від попереднього тунеля, який у логу не зустрічався жодного разу. Кожен файл був правий про свій номер порту й неправий про чужий.

    Лікування:

    Дефект 13. Рядок циклу, який пережив свого виконавця

    Тринадцятий дефект знайдений не аналізом, а наслідком: я сам перезапустив сервер, поки цикл ішов.

    Рядок циклу пишеться running до того, як починається потік, який робить роботу, і закривається, коли той потік завершується. Перезапуск — деплой, падіння, kill — вбиває потік і залишає рядок, який каже running, назавжди.

    І рядок бреше двічі. Журнал циклів показує цикл у роботі, якого вже немає, — і _fresh_running_cycle перевикористовує цей мертвий цикл наступні двадцять хвилин. Тобто після кожного перезапуску редакція відмовлялася б починати роботу: жодного циклу, жодної помилки, нічого.

    Це не теорія. scripts/companion.py перезапускає сервер регулярно — це його робота.

    Лікування: при старті рядки, чиї виконавці померли, закриваються — до того, як хто-небудь їх перевикористає. Цикл отримує статус failed і причину («the newsroom restarted while this cycle was running»), а не просто зникає.

    Перевірено на живій базі: цикл ae25fec0…, який завис у running після перезапуску, при наступному старті сервера отримав

    [startup] closed 1 cycle(s) orphaned by a restart

    і рядок перейшов у failed з причиною. Новий цикл почався відразу.

    Дефект 14. Історія, яку вбив цикл, зникла з черги назавжди

    Чотирнадцятий дефект знайдений тим самим способом: я подивився на чергу після того, як виправив тринадцятий. І побачив у базі 70 історій у стані new — і при цьому жодної не могли взяти.

    Цикл забирає історію, над якою працює, і відпускає її, коли закінчує. Але цикл, який помер, забирає історію з собою.

    І ось що при цьому ламається. Токен претензії має строк дії — він справді закінчується. Але статус історії — ні: він лишається selected. А selected — це статус, який не шукає ні один запит. Scout просить status IN ('new', 'held'). Історія в selected не в цей список — тож Scout її не бачить, і вона не new — тож її ніщо інше не підхопить.

    Історія в черзі й поза чергою водночас. Її рахують як роботу в процесі — і над нею більше неможливо працювати. Ніколи.

    Це не гіпотетична втрата. Це сталося з новиною про перебої зі світлом у Києві та восьми областях (історія 525): редакція вже відібрала її, почала над нею працювати — і втратила разом із циклом. Історія досі в базі. Редакція більше її не бачить.

    Лікування: строк дії претензії поважається повністю. Коли він спливає, історія повертається в new, а претензія знімається. Це робиться двічі: при старті сервера і на початку кожного циклу — бо черга читається саме там, і прибирання після того, як Scout уже подивився, не допоможе нічого.

    Перевірено на живій базі: історія 525 була selected із претензією, що спливла о 09:22:42. Після прибирання

    [startup] closed 1 cycle(s) orphaned by a restart [startup] returned 1 story(ies) lost to a dead cycle

    історію 525 видно в черзі Scout знову: 525 visible to the Scout: True при 72 запропонованих історіях.

    Дефект 15. Журнал редакції падав рівно тоді, коли в ньому було що показати

    П'ятнадцятий дефект знайдений перевіркою маршрутів після того, як смерть циклу перестала бути рідкістю: checked 53 routes, 52 ok, 1 failed — /cycles → 500.

    Причина — у шаблоні:

    {% if s.remaining_budget_usd is not none %} … {% endif %} — ось весь guard, і ось у чому він неправийний.

    У Jinja2 невизначений атрибут — це не none. Перевірка проходить, а шаблон намагається відформатувати те, чого немає. Вся сторінка падає.

    А хто мав summary? Ніякий невдалий цикл. У невдалого циклу summary порожній — нема чого про роботу, яка не відбулася, повідомляти. А журнал показує невдалі цикли поруч із вдалими — це і є його сенс.

    Тобто журнал роботи редакції ставав нечитабельним рівно тоді, коли цикл падав. А цикли тепер падають регулярно: редакція закриває ті, що залишилися від перезапустів, — і в них summary немає. Сторінка, яка мала показати «чому редакція зупинилася», ламалась, коли в неї було що показати.

    Лікування: guard має питати обидва питання — чи значення існує і чи воно не none: {% if s.remaining_budget_usd is defined and s.remaining_budget_usd is not none %}. Те саме для трьох confidence у цьому ж шаблоні.

    І друге — причину падіння треба показувати. Журнал показував failed без жодного пояснення: читач бачив, що цикл впав, і не бачив чому. Тепер під заголовком цикла стоїть рядок «причина» з текстом помилки.

    Перевірено на живій платформі: /cycles → 200, 4 невдалі цикли, кожен із причиною:

    причина the newsroom restarted while this cycle was running причина the newsroom stopped while this cycle was running

    Результат на живій платформі

    Свідченням є не тест, а те, що змінилося на платформі.

    Перед правкою: **3 run'и висіли в awaiting_input** (усі від Strategic'а), і кожен цикл завершувався тим самим.

    Після правки — resolve_blocked_runs відповів на всі 4 заблоковані run'и, жодного з яких не впало, і всі 4 завершилися після відповіді. Три Strategic'а відповіли так:

    Головне: ви маєте рацію, і я помилявся. Канал існує — це власний сайт редакції. Визначення публікації я не змінюю, зовнішнього токена не чекаю.

    А далі — і це те, чого не міг зробити старий арбітр — розібралися у справжній причині блокування:

    fetch http://127.0.0.1: → «The domain 127.0.0.1 is not on the tenant egress allowlist. This is a permanent configuration error»

    Тобто 41 цикл блокування мав не ту причину, яку фіксував старий арбітр своєю відмовою. Причина — не «немає каналу», а конфігурація egress плюс відсутність інструмента запису в сесії Strategist'а. Це два конкретні дефекти інфраструктури, які тепер видно, замість одного невидимого, який маскував їх.

    Обидва полагоджено — і перевірено на живій платформі, а не на тестах.

    Спершу канал. Публічний тунель переведено на порт, де реально слухає сайт, зареєстровано на платформі, і дозволений список egress tenantu доповнено його хостом. Перевірка — запит самого Strategist'а, з платформи:

    fetch https://…trycloudflare.com/ → «Fetched 2000 chars» — і далі він сам прочитав сторінку: «Це жива стрічка AI Newsroom — 26 матеріалів…». Той самий запит до 127.0.0.1 і далі відмовляється.

    Далі інструменти. Стратег отримав чотири інструменти редакції, і місія — виконана:

    Новий цикл запущено: ae25fec0ce234080869932113259b128 — статус running, без wait, повторів не робив. Останній завершений цикл: 4f634b7801e040908ce21286af00ac2e — status: completed, published: 1 — story 456 / article 84, «Кличко: кількість постраждалих від атаки на Київ 20 жовтня зросла до 21…», cost_usd: 0.142509

    Тобто mission нарешті отримала саме ті поля, які її опис вимагав (cycle_id, published, updated, rejected, cost_usd) — з справжнього циклу, а не з пам'яті агента. Запис в інструментах підтверджує: run_newsroom_cycle, list_newsroom_cycles, get_newsroom_cycle — три виклики, жодного вигаданого числа.

    І mission нарешті пройшла. Об'єктив, який тридцять тіків поспіль падав із budget_exhausted, тепер:

    БулоСтало
    missionfailedcompleted
    об'єктивfailed, strikes=1completed, **verified: true**
    abort_reasonbudget_exhaustednull
    root causesbudget_exhaustednull

    Це mission перевірена платформою, а не mission, яка просто перестала сваритися. І журнал сервера показує, що вона йшла через публічний тунель: POST /mcp/ 200 OK з 162.243.186.39 — адреса Cloudflare.

    І за цим — реальний цикл, який пройшов на живій платформі після перезапуску сервера з новим кодом:

    Значення
    цикл0a5743a99c1f42a092c0d6cd23ae69d0
    статус**completed**
    зібрано121
    нових історій20
    опубліковано0 (одну статтю відхилено на вердикті revise)
    вартість$0.181243
    помилок0

    Тобто конвеєр не просто «не падає» — він працює: забирає історію, пише, перевіряє, відхиляє те, що не проходить, і каже за це в цифрах.

    Двадцять п'ять дефектів, шість з яких — це один і той самий class. Запис у базі повинен означати те, що він каже. Рядок running, який нікуди не веде; рядок verified, який ніхто не перевіряв; рядок published, який означає «опубліковано» лише в базі, а не на сайті; історія в selected, яку неможливо взяти в роботу, бо цей статус не шукає ніжто; журнал циклів, який падає, коли в ньому є запис про падіння; і канал, який мертвий, у той час як запис про нього каже mcp_ok: true. Кожен раз це повертало редакцію в режим, де вона виглядала працюючою.

    Сімнадцятий дефект — іншого класу, але з тієї ж родини: не «запис бреше», а дія не знає, до кого вона адресована. Редакція вбивала всі процеси, схожі на її тунель, — і одного разу вбила свій.

    Восимнадцятий — ще один клас: висновок поспішає. Редакція засуджувала канал за першу невдалу перевірку й забирала його раніше, ніж довела, що заміна існує.

    Дев'ятнадцятий — найтихіший з усіх, бо зовні він виглядає як «Cloudflare нас трохи душить». Насправді редакція не читала власний запис: супервізор приймав рішення за копією в пам'яті, а не за тим, що лежало на диску. Тому вона затерла власну працюючу адресу — і далі щохвилини просила тунель, який їй був не потрібен, бо вона вже не знала, що в неї є.

    Двадцятий — перший, у якому провинилася не ми. Уперше mission провалилася не через запис, не через дію і не через канал, а через перевіряча: суддя не вмістив свій вердикт у 1024 токени, бо ми дали йому критерію, яка вимагала зіставляти рядки, а не знаходити факт.

    Двадцять другий — перший, у якому провинилася не платформа, а ми самі, і не в коді, а в налаштуванні: клієнт SDK був зібраний з max_retries=0. Тому одна мить поганої мережі — DNS, що не розв'язався, — переривала арбітрові та вартові обходи, і редакція записувала «платформа недоступна» сорок разів поспіль, хоча ім'я платформи розв'язувалося правильно через хвилину. Ми не побачили різниці між помилкою та відповіддю — і записали помилку як відповідь.

    Двадцять третій — те саме, але з боку каналу, і в коді, який ми написали після лікування двадцятого другого. Перевірка каналу робила один запит, тому мить поганої мережі двічі записала в журнал mcp_ok: false про тунель, який обслуговував запити, а лічильник ударів, який мав відрізняти «мить» від «каналу», рахував перевірки замість спроб — тобщо рахував удачу. Ми виправили систему, яка бреше про платформу, і не помітили, що та сама система бреше і про себе.

    Після п'ятнадцяти правок повний ланцюг відпрацьвав на живій платформі і став публічною корекцією. На статті 84 («Кличко: кількість постраждалих … зросла до 21») аудит знайшов дві помилки medium (дату зрізу даних подано як «станом на ранок 21 жовтня», хоча джерело опублікували о 22:47 20 жовтня; і матеріал не відмежив подію 20 жовтня від окремого удару 21 жовтня, про який на тій самій сторінці вже було повідомлення). Друга перевірка — інший агент, який статтю не писав і не пропускав — підтвердила обидві з посиланням на первинне джерело, і корекція з'явилася на сторінці з текстом для читача й перевірюваним посиланням.

    І одразу після цього — на статті 25 — друга перевірка відкинула зауваження аудиту, посилаючись на §2(b)–(c) указу Президента від 16.10.2026 і на CRS Legal Sidebar LSB11474. Зауваження було закрите як rejected, а не залишене відкритим: це рішення, а не відсутність рішення. Саме так має працюти редакція, яка перевіряє себе.

    Чому це частина стандарту

    Усі двадцять п'ять дефектів — про одне: запис у базі повинен означати те, що сталося насправді. Не «статус успішний», а «перевірка завершилася»; не «думка підтверджена», а «друга перевірка погодилася»; не «зауваження закрите», а «хтось ухвалив рішення»; не «канал живий», а «канал відповів»; не «тунель мій», а «цей конкретний процес — мій»; не «каналу немає», а «канал не відповів тричі поспіль».

    І друге, що з'явилося з вісімнадцятого: перша невдача — це припущення, а не вирок. Система, яка руйнує те, що працює, за першою ж невдалою перевіркою, зрештоє руйнує все — і робить це тихо, бо виглядає при цьому справною. Тому жодна дія не забирає те, що працює, раніше, ніж доведено, що заміна існує.

    І третє, з дев'ятнадцятого: копія — не запис. Найстрашніший дефект той серії — не той, де запис бреше, а той, де запис не читають. Уявіть систему, яка вирішує, чи треба їй щось робити, за копією стану в пам'яті замість того, що лежить на диску. Вона не «помиляється» — вона завжди працює з тим, що було колись, і ніколи не дізнається, що світ змінився. Тому в автономній компанії правило одне: рішення читають запис, а копія може тільки додати те, чого в записі немає.

    І четверте, з двадцятого: перевіряч — теж ресурс, і його бюджет — не наш. Коли ми даємо судді більше тексту для роздумів, ми не робимо перевірку точнішою — ми робимо її дорожчою за число, яке вона може пережити. Правило: дай судді факт, а не рядок для зіставлення.

    І п'яте, з двадцять першого: запис триваліший за процес, який його породжує. Робота, яка залишає слід у базі, мусить залишати слід і в записі, який про неї розповідає — і записати його тоді, коли робота стала тривкою, а не тоді, коли процес дожив до кінця. Бо процес може не дожити: перезапуск, краш, kill. Стаття, що вийшла, і цикл, що її опублікував, повинні говорити одне й те саме — інакше журнал автономної компанії перестає бути її пам'яттю і стає списком того, що не вдалося.

    І шосте, з двадцять другого: помилка — це не відповідь. Система, яка не розрізняє їх, має два шляхи: або здатися на першій помилці й бреше про платформу («недоступна»), або повторювати все підряд і тягне час на те, що вже ніколи не стане відповіддю. Перше — тиша, друге — дурне очікування. Обидва — неправда. Тому мережева помилка — це привід спробувати ще раз, а відповідь — це те, що повтор не змінить. І «непрочитано» має означати «не прочитано після кожної спроби», а не «не прочитано з першого дотику».

    І сьоме, з двадцять другого, але вже з іншого боку: тест, який залежить від порядку, нічого не перевіряє. Коли я виправив лікування на помилку мережі, я прогнав набір тестів — і він впав. Виявилося, що причина була не в лікуванні, а в тому, що схема бази створювалася тим файлом тестів, який випадково опинився першим. На порожній базі двадцять тестів падали з «no such table: events», а на копії живої бази — проходили всі, бо таблиці там уже були. Тобто набір тестів перевіряв не редакцію, а випадковий порядок відкриття файлів.

    Це той самий class, що й решта: запис, який не читають, і тест, який не читають. Тест, що каже «усе гаразд» на копії живої бази, не каже нічого про те, що станеться на порожній. А журнал, який читають тільки тоді, коли він уже повний, — це не журнал.

    Лікування: схема створюється один раз, для всього набору, у базі, яку називає середовище. tests/__init__.py викликає той самий init_db, що й застосунок на старті, з тими самими міграціями. І набір тестів, запущений без NEWS_DB_PATH, більше не пише в <data>/news.db — він бере свою базу в тимчасовій теці. Тому тест, що псує базу, не може псувати редакцію, а тест, що падає, падає з причиною, а не з порядку.

    І восьме, з двадцять третього: прилад, який вимірює, сам має терпти до миті. Вимірювальний прилад, який ламається від одного дотику розв'язувача, не вимірює — він генерує події. Канал ми перевіряємо найчастіше й найприскіпливіше, і одна невдала перевірка — це завжди «засудити те, що працює» плюс «затерти власну адресу». Тому «не відповів» має означати «не відповів тричі поспіль», а лічильник має рахувати спроби, а не перевірки.

    І стандарт тут один: замкнена двері. Аудіт, друга перевірка, публікація корекції — кожна ланка має право сказати «я не змогла», і це «не змогла» має бути видимим, а не замінятися тихим «усе добре». Система, яка не може сказати «не знаю», змушена брехати — і бреше в бік «все гаразд», бо це дешевше.

    Тому в журналі зауважень чотири стани, і кожен означає щось різне: confirmed (другий агент погодився — публічна корекція), rejected (другий агент не погодився — рішення ухвалене), not_escalated (за серйозності не дійшло до другої перевірки — теж рішення), open (ніхто не ухвалив рішення — і це видно).

    Дефект 16. Канал, який помирає разом із процесом, який його підняв

    Шістнадцятий дефект знайдений не тестом, а запитанням до власного журналу: чому файл <data>/cloudflared.log останній раз оновлювався о 09:32:31Z, а сама редакція — працювала далі?

    Канал редакції — це Cloudflare quick tunnel. Він потрібний тому, що 127.0.0.1 на машинах платформи — це loopback платформи, а не цей сервер. Єдиний спосіб, яким агент платформи може відкрити редакцію, — публічна адреса.

    І ось що виявилося в лозі тунеля:

    2026-10-21T08:47:35Z INF Registered tunnel connection connIndex=0 ...
    2026-10-21T09:32:31Z INF Initiating graceful shutdown due to signal terminated ...
    2026-10-21T09:32:31Z INF Tunnel server stopped

    Канал помирав разом із процесом, який його підняв. cloudflared було запущено з виводом у pipe, а pipe читав daemon-тред. Daemon-тред зупиняється, коли зупиняється процес. Pipe закривався, cloudflared писав у закритий pipe — і вимикався сам. Канал жив рівно стільки, скільки жив той процес, який його запустив.

    І ніхто про це не дізнався. Файл <data>/companion_state.json увесь цей час казав "mcp_ok": true і вказував на hostname, який відповідав 530. Платформа показувала MCP-сервер як active — на тому ж hostname'і. Кожен тік Strateg'а падав з error 1033 (код платформи для «тунель мертвий»), поки власний запис редакції про її канал казав: все добре.

    Тобто це той самий class, що й п'ятнадцять попередніх, тільки на животі: запис у базі має означати те, що сталося насправді. Тут — запис у файлі стану про канал.

    Лікування — три речі, і кожна з них потрібна:

    1. Тунель має пережити той, хто його підняв. Вивод — у файл, а не в pipe; start_new_session=True, щоб тунель не був дитиною запускача й не отримував його сигналів. Перевірено на живій платформі: підпроцес піднімає тунель і негайно виходить — тунель лишається живий.
    2. Запис має казати правду. mcp_ok пишеться з живої перевірки каналу, а не «як було востаннє, коли хтось подивився». Тунель, який не відповідає, записується як мертвий.
    3. Канал має наглядати хтось, хто завжди живий. Це сервер — він єдиний процес у редакції, який живий завжди. Він перевіряє канал щохвилини, піднімає новий, якщо старий мертвий, і каже платформі про нову адресу.

    І третє має ще дві частини, без яких воно не працює:

    І четверта частина, про яку треба сказати окремо, бо вона з'явилася під час лікування: тунель, який ще піднімається, не можна вбивати наступним. Quick tunnel відповідає за кілька секунд, і протягом цього вікна наступна перевірка знаходить тунель, який ще не обслуговує запити. Без паузи супервізор піднімає другий тунель, а перший убиває. Редакція дивилася, як її власний канал обертається раз на хвилину, і кожне обертання ламало реєстрацію попереднього. Тепер тунель, що піднімається, отримує час.

    І ще одне, знайдене вже на живій платформі: тест не має права писати в живий файл стану. Тест, що викликав refresh_channel() без власного шляху, записав у <data>/companion_state.json тестовий hostname — і наступна перевірка супервізора прочитала його, не знайшла за ним тунеля, підняла новий і вбила робочий. Канал редакції зруйнував її власний тестовий набір. Тепер це заборонено правилом, а не пам'яткою.

    Результат на живій платформі: канал піднімається сам

    Тунель убито — і супервізор підняв новий сам, без людини:

    before: https://.trycloudflare.com
    killed. waiting...
    t=80s new url: https://.trycloudflare.com

    Після цього канал став сталим — той самий PID через кілька циклів перевірки, mcp_ok: true, і жодного обертання:

    t=15s  pid=52458 True
    t=45s  pid=52458 True
    t=75s  pid=52458 True
    t=105s pid=52458 True

    І вся ланка реєстрації — одне й те саме ім'я на шести місцях:

    місцезначення
    тунель відповідає{"status":"ok"}
    сайт через тунель<title>Стрічка — AI Newsroom</title>
    MCP-сервер платформиhttps://…/mcp/ — тест: connected, 4 tools
    allowlist тенантаодин host — той самий
    запис Strateg'атой самий URL
    запис Chief of Staffтой самий URL

    А тіки Strateg'а, які раніше падали з error 1033, завершуються:

    completed | 01a156f2 | 2026-10-21T10:19:26
    completed | 01a156f2 | 2026-10-21T10:13:26
    completed | 01a156f2 | 2026-10-21T10:07:26

    Уточнення до дефекту 16: правило про канал було написано тричі

    При лікуванні шістнадцятого дефекту з'ясувалося, що сама архітектура запису була розсипаною на три незалежні місця:

    Тобто правило «запис про канал» було написано тричі, і кожне — трохи інакше. Коли я зібрав усе в app/channel.py, з'ясувалося, що три правила з трьох файлів кажуть про allowlist, і всі три виводять host по-своєму. Різниця в один символ — і агент може викликати сервер, але не може відкрити сайт.

    Лікування: одне місце (app/channel.py) — одна реалізація, і всі інші файли її викликають. Це не косметика: дві копії одного правила розходяться, а розходження в правилі про доступ — це доступ.

    Дефект 17. Редакція зруйнувала власний канал своїм тестовим набором

    Сімнадцятий дефект знайдений випадково — і це, власне, говорить про нього найбільше. Після того, як шістнадцятий дефект був виправлений, я запустив тести, щоб перевірити, що нічого не зламалося. Тести пройшли. Потім я пішов перевірити канал — і його не було.

    $ pgrep -fl cloudflared
    (немає нічого)
    $ curl https://.trycloudflare.com/health
    Error 1033: Cloudflare is currently unable to resolve it

    Канал зник під час прогону тестів. Не «раніше», не «потім» — під час.

    Причина — у stop_tunnel(). Тунель шукали за шаблоном і вбивали за тим самим шаблоном:

    subprocess.run(["pkill", "-f", " ".join(TUNNEL_COMMAND)])

    TUNNEL_COMMAND — це cloudflared tunnel --url http://127.0.0.1: --no-autoupdate. pkill -f за шаблоном убиває кожен процес, у командному рядку якого є цей рядок. Не той, який наш. Усіх, схожих на нього.

    І тест test_a_started_tunnel_survives_its_starter — той самий, що перевіряє, чи переживає тунель свого запускача — піднімав справжній тунель. Його start_tunnel() спершу робив stop_tunnel(). Тобто:

    1. тест піднімає свій тунель;
    2. start_tunnel() викликає stop_tunnel();
    3. stop_tunnel() убиває всі тунелі, що відповідають шаблону;
    4. серед них — живий канал редакції, через який читачі читали стрічку і через який платформа запускала редакцію.

    Після цього запис про канал продовжував брехати: mcp_ok: true, бо остання перевірка була до вбивства. А платформа й далі показувала MCP-сервер як active — на hostname, що відповідав 1033.

    Тестовий набір редакції зруйнував канал редакції. Це не метафора: це буквально те, що сталося, на живій платформі, о 10:28:40Z — за чотири хвилини до того, як я це помітив.

    І тут є ще один шар, який варто назвати окремо, бо він найстрашніший. Після вбивства каналу супервізор (який тепер працює) підняв новий тунель — самостійно, без людини. Тобто система виглядала справною: канал живий, mcp_ok: true, платформа каже active. Але старий hostname, на якому трималися запис платформи, allowlist тенанта й записи обох агентів, був уже мертвий, і після перенесення вони вказують на новий — але той перенесення забув на момент, коли я читав журнал.

    Тобто дефект 17 — це не «тест убив тунель». Це: редакція не мала поняття, який тунель її. Вона знала, що є тунелі, — але не знала, який з них її. А всяка дія, яка має сказати «ось мій канал», мусила сказати це про конкретний процес, а не «про всі, схожі на нього».

    Лікування — власність, а не шаблон.

    БулоСтало
    Як знайти свій тунельpgrep -f за шаблономpid, записаний при старті (<data>/tunnel.pid)
    Як перевірити, що це мійніякps -p <pid> -o command= — збігається з cloudflared tunnel?
    Як зупинитиpkill -f — убиває усіхos.kill(pid, SIGTERM) → за потреби SIGKILL — тільки свій
    Кому це шкодиложивому каналу редакціїнікому

    І тест дістав власний pid-файл — у тимчасовій теці. Тест больше не може забрати канал у редакції, навіть якщо він піднімає справжній тунель.

    Але головне — захист від повторення, бо цей дефект уже повторився двічі: один раз тест записав у живий companion_state.json, другий раз тест убив живий тунель. Обидва рази — «тест не має права чіпати живу редакцію». Тепер це правило, а не пам'ятка: тест читає власний код і перевіряє, що жоден виклик start_tunnel/stop_tunnel не ходить без pid_path=, і жоден виклик refresh_channel/ensure_channel не пише в живий файл стану.

    Перевірено на живій платформі:

    $ .venv/bin/python -m unittest discover -s tests -t .   # 628 тестів
    OK
    $ pgrep -fl cloudflared
    52795 cloudflared tunnel --url http://127.0.0.1: --no-autoupdate
    $ curl -s https://.trycloudflare.com/health
    {"status":"ok"}

    Той самий PID до і після повного прогону тестів. Раніше це було не можливо: прогін тестів зупиняв канал, і супервізор піднімав новий — тобто кожен прогон тестів переписував адресу, за якою платформа керує редакцією.

    Загальне правило, яке з цього випливає: будь-яка дія, яка вбиває або перезаписує щось у спільному середовищі, має бути обмежена правом власності. Шаблон — це припущення («усі, схожі на нього»). Право власності — це факт («цей конкретний процес — мій»). Редакція, яка б'є чужі процеси, зрештою б'є і свої.

    Дефект 18. Редакція засудила канал, який відповідав, і забрала його в обмін на відмову

    Восімнадцятий дефект знайдено на живій платформі, о 10:50:29Z, і він найпростіший з усіх — бо він не «не помітив», а помітив неправильно.

    Ось що показав журнал:

    10:50:29Z  "mcp_ok": false,  "tunnel_failure": "failed"
    10:50:53Z  "mcp_ok": true

    Двадцять чотири секунди. Запис пояснив собі, що канал мертвий, — і потім, через двадцять чотири секунди, вирішив, що живий. Але в ці двадцять чотири секунди той самий тунель:

    Тобто редакція засудила канал, який відповідав. Не помилка констатації — помилка висновку: одна невдала перевірка дорівнює «каналу немає».

    А що робить супервізор із «каналу немає»? Піднімає новий. А новий тунель — це новий hostname, а новий hostname — це перереєстрація каналу на платформі, в обох агентів і в allowlist тенанта. Тобто одна повільна відповідь робила три переписування адреси, за якою плаформа керує редакцією, — і кожне з них створювало вікно, у якому плаформа вказувала на двері, яких ще нема.

    Лікування — поспільність, а не одноразовість. Тунель засуджується не першою невдалою перевіркою, а третьою поспіль. Лічильник скидається в нуль щойно тоді, коли тунель відповів, — тобто правило каже «поспіль», а не «загалом». А різні збої — різні за вагою: процес, який зник (pid мертвий), — це факт і важить вдвічі; перевірка, яка не встигла (таймаут), — це припущення і важить одиницю. Тому канал, який реально втратив тунель, засуджується за дві перевірки, а канал, який один раз не відповів, — не засуджується взагалі.

    БулоСтало
    Що означає одна невдала перевірка«каналу немає»«одна перевірка не вдалася»
    Коли тунель замінюєтьсяпісля першої невдачіпісля трьох поспіль (дві, якщо процес мертвий)
    Як це видно в записіmcp_ok: falsetunnel_strikes: 2, tunnel_condemned: false
    Чи скидається лічильник—так, щойно тунель відповів
    Хто читає записусіусі — і він каже словами, чи засуджений канал

    І запис тепер говорить словами: tunnel_condemned. Щоб читач не мусив знати порог, щоб зрозуміти, що означає запис.

    Але справжня знахідка — в наступному кроці. Коли супервізор усе ж підняв новий тунель, він спершу вбив старий — а потім Cloudflare відповів «ні»:

    2026-10-21T11:10:05Z INF Requesting new quick Tunnel on trycloudflare.com...
    quick tunnel provisioning failed with status 429

    Тобто редакція забрала власний робочий канал і отримала відмову. Тунель, який працював, був зупинений до того, як стало відомо, чи дадуть новий. Cloudflare rations quick tunnels — і квота рахує кожен запит, який надходить від кого завгодно, включно з тестовим набором редакції. Коли сервіс каже «ні», це факт про час запиту, а не про запит.

    Тому правило тепер таке: старий тунель зупиняється тільки після того, як новий назвав свою адресу. Редакція ніколи не лишається з порожнею адресою через відмову сервісу. І запис, який був до спроби, не затирається невдачею заміни: у ньому залишається та адреса, яка в редакції є.

    І ще одна дрібниця, яка виявилася під час розслідування: відмова сервісу приходить у лог після того, як процес уже мертвий. Перевірка «процес помер → причина відома» читала лог надто рано і записувала відмову сервісу як звичайну поломку. Тепер лог перечитується після того, як процес завершився, і причина визначається з нього.

    Перевірено:

    $ .venv/bin/python -m unittest discover -s tests -t .   # 737 тести
    OK

    І на живій платформі: запис після однієї невдалої перевірки каже "tunnel_strikes": 1, "tunnel_condemned": false — і тунель не замінюється.

    Загальне правило, яке з цього випливає: перша невдача — це припущення, а не вирок. Система, яка за першою невдачею руйнує те, що працює, зрештою руйнує все. І жодна дія не має забирати те, що працює, раніше, ніж доведено, що заміна існує.

    Дефект 19. Запис, який читає супервізор, був копією, а не записом

    Дев'ятнадцятий дефект знайдено на живій платформі, о 11:12:06Z, і він знову про запис — але з іншого боку. Дефект 18 був про те, що запис неправильно описував світ. Дефект 19 — про те, що запис узагалі не читався.

    Ось що показав журнал cloudflared:

    2026-10-21T11:12:06Z INF Requesting new quick Tunnel on trycloudflare.com...
    quick tunnel provisioning failed with status 429
    2026-10-21T11:13:07Z INF Requesting new quick Tunnel on trycloudflare.com...
    quick tunnel provisioning failed with status 429
    2026-10-21T11:14:07Z INF Requesting new quick Tunnel on trycloudflare.com...
    quick tunnel provisioning failed with status 429
    ... і так далі, щохвилини, до 11:24

    Вісім відмов за дванадцять хвилин. Cloudflare ration quick tunnels, а квота рахує кожен запит від кого завгодно. Редакція витратила власну квоту на те, щоб повторно почути те саме «ні», — і кожна повторна відмова з'їдала квоту, яка потрібна для наступної спроби.

    А правило, яке саме від цього мало захистити, було написане, протестуване і мертве. rate_limited_recently() перевіряє вміст запису, і запис справді містив tunnel_failure: "rate_limited". Але той запис, який перевірявся, — не був тим записом, який лежав на диску.

    Причина — одна рядок:

    current = dict(state) if state is not None else read_state(path)

    Супервізор носить state від ітерації до ітерації. На першій ітерації він передає {} — порожній словник. state is not None — істина для порожнього словника. Тобто на першій ітерації код не читав диск узагалі: він узяв порожній словник, побачив tunnel_url: "" і вирішив, що в редакції немає каналу. А на наступній ітерації носив у пам'яті вже свою помилку — і щоразу повторював її.

    Це пояснює все, що бачили наживо:

    СимптомПричина
    Вісім відмов щохвилини, попри cooldown 300 свідмова була невидима — читалась копія, а не запис
    tunnel_strikes: 8 про канал, якого ніколи не булолічильник рахував невдачі тунелю, якого не існувало
    Запис без tunnel_url, хоча тунель відповідавперша ітерація не прочитала диск і затерла реальну адресу
    Платформа вказувала на мертвий hostnameадресу в записі не було — оновлювати було нічого

    Останній рядок — найстрашніший. Тунель factory-efficient-muscle-brook живий і відповідав увесь цей час (pid 54304, GET /health 200 OK, POST /mcp/ 200 OK), а запис казав tunnel_url: "". Редакція затерла власну працюючу адресу і після цього далі просила новий тунель, бо їй здавалося, що в неї нічого немає.

    Лікування — читати запис, а не копію. Тепер запис на диску читається завжди, а стан у руках — лише додає ключі, яких на диску немає:

    current = read_state(path)
    if state:
        merged = dict(state)
        merged.update(current)
        current = merged

    І три наслідки, які вимагали окремих правил:

    1. Відмова, яку записав стартер, не затирається. Стартер пише її після того, як виклик прочитав запис. Тому на ключах tunnel_failure, tunnel_failure_detail, tunnel_failure_at переможує диск — це новіша факта, а копія в руках старіша за неї.
    2. Канал, якого ніколи не було, не має засудів. Відмова — це факт про сервіс, а не поломка каналу. Запис tunnel_strikes: 8 про канал, який не існував, — це запис, який бреше про річ, якого немає.
    3. Те, що повертається, — це те, що записано. Раніше refresh_channel повертав свою локальну копію, а на диск записував інше. Читач, який дивиться на повернуте значення, і читач, який відкриває файл, могли дійти різних висновків про один і той самий канал.

    А ще один дрібний, але важливий наслідок того самого розслідування: причина збою тепер називає, який це збай. Запис раніше казав tunnel_failure: "failed" і про повільну відповідь на перевірку, і про мертвий процес — а це різні факти з різними наслідками. Тепер це probe_failed (відповідь не встигла) і process_gone (процес зник). Останній важить вдвічі й засуджує канал за дві перевірки; перший — за три. Запис, який не розрізняє ці два випадки, не може сказати читачеві, що саме робити.

    І тонкість, яка випливла одразу після цього: причина на записі має відповідати своєму моменту. Відмова сервісу, записана під час цього ж виклику, — новіша за все, що виклик знав, і вона має перемогти. Але причина з попередньої перевірки описує інший момент, і її не можна нести далі: вона «заморозила» б запис на факті, який уже минув. Тому порівняння — «не старший за цю перевірку», а не «новіший»: запис зберігає цілі секунди, і відмова, записана під час виклику, та перевірка, яка її побачила, зазвичай потрапляють у ту саму секунду — а відмова є конкретнішим фактом з двох і не має програвати в нічию.

    Перевірено:

    $ .venv/bin/python -m unittest discover -s tests -t .   # 737 тести
    OK

    І на живій платформі: замість восьми відмов на хвилину — одна за п'ятнадцять хвилин (coolback), а запис знову каже правду:

    "tunnel_url": "https://.trycloudflare.com",
    "mcp_ok": true,
    "tunnel_strikes": 0,
    "tunnel_condemned": false

    А платформа знову вказує на живий тунель, і POST /mcp/ відповідає 200 OK.

    Загальне правило, яке з цього випливає: копія — не запис. Система, яка приймає рішення за копією, рано чи пізно вирішує за тим, що було колись. А в автономній компанії це не «трохи запізно» — це нескінченна відмова, яка щохвилини витрачає ресурс, який потрібен для відновлення. Запис на диску — це єдине місце, де живе правда; усе інше — похідні.

    Дефект 20. Суддя не вмістив вердикт у 1024 токени

    Двадцятий дефект знайдено на живій платформі, о 11:01:26Z, і він перший у своєму класі: це не «запис бреше» і не «дія не знає, до кого адресована». Це — суддя не встиг відповісти.

    Ось що показав запис об'єктив mission:

    result:      judge_error: judge_response_truncated:
                 verdict cut off at max_tokens=1024 (finish_reason=length)
    abort_reason: verifier_error

    І — ось що лежало в output_summary:

    run_newsroom_cycle: викликано рівно 1 раз
    cycle_id нового циклу: 5287e42c8cd24eb3a4b63c2d95219648
    published: 0
    updated: 0
    rejected: 1
    cost_usd: 0.179644
    cycle_id попереднього завершеного: 2dbcd60f91654e09891ddd42b5b0c472

    Відповідь була правильною. Абсолютно. Перший рядок — точно той, що потрібен; cycle_id — на місці; всі чотири числа — на місці. А mission все одно провалився, бо суддя не вмістив свій вердикт у 1024 токени.

    Зверніть увагу на abort_reason: verifier_error. Це не judge_rejected і не budget_exhausted — перший випадок, коли провалився не агент і не бюджет, а перевіряч. Це важлива різниця: попередні вісімнадцять дефектів були дефектами нашої системи, а цей — інструмента, яким ми перевіряємо себе.

    Причина — довжина, а не зміст. Ось повна історія об'єктив mission, з довжиною опису й першою критерією:

    ЧасДовжина описуПерша критеріяВердикт
    09:05Z463Викликано run_newsroom_cycle рівно один разall_objectives_verified
    09:35Z463те самеjudge_rejected (зміст)
    10:35Z463те самеjudge_rejected (зміст)
    11:01Z1059Відповідь містить рядок «run_newsroom_cycle: викликано рівно 1 раз»**judge_response_truncated**
    11:31Z1059те самеjudge_rejected (SSRF)

    Довжина 1059 з'явилася разом із першою критерією, яка вимагала перевірити точний запис рядка. І саме на ній суддя обрізав вердикт. Усі інші вердикти — і judge_rejected, і успішний — були читабельними вердиктами, тобто суддя встиг думати й встиг написати.

    Логіка проста, і вона пояснює все: суддя розмірковує і пише вердикт у тих самих 1024 токенах. Критерія «у відповіді є рядок «...»» вимагає від судді думати — це питання на зіставлення, а не на пошук. Критерія «run_newsroom_cycle викликано рівно один раз» вимагає знайти — це перевірка факту. Перше з'їдає бюджет вердикту; друге — ні.

    Лікування — коротка критерія, довідка адреси. Правило тепер таке:

    БулоСтало
    Довжина опису1059829
    Перша критерія«Відповідь містить рядок «...»»«Викликано run_newsroom_cycle рівно один раз»
    Що має зробити суддязіставити два рядкизнайти факт
    Опис кажеяк написатиде написати

    І ще один висновок із цієї серії, який варто назвати окремо. Mission о 11:31Z упала не через суддю — вона упала через канал:

    Error: SSRF blocked: DNS for
    "https://.trycloudflare.com/mcp/"
    resolves to a private/internal address

    Тобто агент усе одно не зміг нічого — платформа вказувала на мертвий hostname, тому що дефект 19 знецінив власну адресу. Відповідь агента і тут була правильною («run_newsroom_cycle: викликано рівно 1 раз»), а провалився канал. Коли канал повернули до життя, причина зникла.

    Це і є висновок цієї серії: три різні симптоми — вісімнадцять (defект 18), відмова щохвилини (defект 19) і збереження мертвої адреси (дефект 19 у 11:31Z) — мали одну причину в нашому коді, і жодного разу провинилася платформа. Ми виправляли не нас — ми виправляли власну сліпоту пляму**.

    Перевірено на живій платформі 12:09Z. Mission 01a15e5d — перша після лікування — завершилася **all_objectives_verified. Агент відповів так, як його просили, але словами**, а не рядком для зіставлення:

    run_newsroom_cycle викликано рівно один раз (limit=1, без wait=True, без повторів з новим ключем). Новий цикл: **cycle_id 1c582d01a65846a1a534893e3bbbb39c**, статус completed … reused: true — це та сама одинока спроба, а не дві.

    Суддя прочитав це за секунди і написав **verified. Одинадцять відмов поспіль закінчилися першою читабельною** відповіддю.

    І рядок, який тепер читається як доказ, а не як переповідь:

    ЧасДовжина описуПерша критеріяВердикт
    11:31Z1059«Відповідь містить рядок «...»»judge_rejected (SSRF)
    12:09Z829«Викликано run_newsroom_cycle рівно один раз»**all_objectives_verified**

    Одинадцять judge_rejected поспіль і один all_objectives_verified — і між ними змінилися рівно дві речі: довжина опису (1059 → 829) і те, що критерія просить (зіставити два рядки → знайти факт). Ні платформа, ні модель, ні розклад не змінилися. Змінився текст, який читає суддя.

    Перевірено:

    $ .venv/bin/python -m unittest discover -s tests -t .   # 737 тести
    OK

    І на живій платформі: розклад перестворено з новою об'єктив mission (schedule_id ), критерії — 43, 30 і 89 символів, опис — 829.

    Загальне правило, яке з цього випливає: суддя — це теж ресурс, і його бюджет — не наш. Коли ми даємо перевірячу більше тексту для роздумів, ми не робимо перевірку точнішою — ми робимо її дорожчою за числом, яке вона може пережити. Правило просте: дай судді факт, а не рядок для зіставлення. Якщо критерія вимагає від перевіряча думати, а не знаходити — вона надто довга.

    Дефект 21. Підсумок циклу жив у пам'яті, а робота — у базі

    Цикл записує свій підсумок рівно один раз — наприкінці, у finish_cycle. Усе, що до того, жило лише в пам'яті пайплайна.

    А перезапуск — деплой, краш, kill — забрав пам'ять із собою. Стаття, яку цикл опублікував, лишалася в базі; а цикл, який її опублікував, сказав failed і не мав підсумку взагалі.

    Це не гіпотеза. У живому записі було шість таких циклів зі ста п'яти, і один із них — c3685460, 11:44–11:48 — закрився перезапуском із помилкою *«the newsroom restarted while this cycle was running». Стаття 87 опублікована о 11:48:01**, усередині того циклу. Читач побачив новину. Журнал циклів сказав, що цикл впав, і не мав чого показати.

    Робота була тривкою. Запис про роботу — ні. І той, хто читав журнал циклів — чи людина, чи агент, що питає редакцію «що вона зробила» — отримував відповідь «цикл впав» і не міг дізнатися, що новина дійшла до читача.

    Лікування — чекпойнт, а не запис наприкінці. Підсумок пишеться в рядок циклу під час роботи: після фетчу і після кожної історії. Рядок і далі каже running — цикл ще йде, а чекпойнт це не фініш. Змінюється одне: рядок уже знає правду про все, що зроблено.

    Окремо — і це теж було дефектом, а не дріницею: finish_cycle з summary=None стирала summary_json. Тобто навіть якби чекпойнт був, закриття без підсумку знищило б його. Тепер summary=None означає «фінального підсумку не було» — і не означає «стерти підсумок». Фінальний підсумок, коли він є, замінює чекпойнт; коли його немає — чекпойнт лишається.

    Чекпойнт пишеться в момент рішення, а не наприкінці історії. Стаття вже в базі; від цього моменту запис циклу мусить із нею погоджуватися. Смерть за наступну секунду не повинна мати змогу залишити опубліковану статтю й цикл, що каже, що нічого не опублікував.

    Відновлення шести втрачених записів. Коли лікування прийшло, шість циклів уже мали порожній підсумок, і лікування їх не рятує — воно рятує наступні. Але робота в них була, і вона лишилася в базі: статті, історії, запуски, their costs. Підсумки були перебудовані з того, що вціліло, і в кожному записано, як він відновлений:

    recovered.how: rebuilt from the durable record after the restart erased the summary

    Там, де вціліло нічого, не вигадано нічого: запис каже, що відновлювати не з чого, і нічого не заявляє.

    Чому це частина стандарту. Двадцять перший дефект — це дефект тривалості запису. Новина, яка вийшла, і цикл, який вийшов, повинні говорити одне й те саме. Якщо робота тривка, а її опис — ні, то журнал більше не є журналом: він стає списком того, що не вдалося. А журнал автономної компанії — це не звіт про спроби, це її пам'ять.

    Правило, що з цього випливає: запис триваліший за процес, який його породжує. Будь-яка робота, яка залишає слід у базі, має залишати слід і в записі, який про неї розповідає — і записати його тоді, коли робота стала тривкою, а не тоді, коли процес дожив до кінця.

    Перевірено на живій платформі 12:02Z. Цикл 1c582d01, який ішов у момент перезапуску, мав у своєму рядку вже підсумок —

    {"fetched": 113, "new_stories": 1, "duplicates": 1, "already_seen": 111,
     "unaccounted": 0, "fetched_accounted": 113, ...}

    — задовго до того, як він завершився. До лікування цей рядок був порожнім до самого кінця, а після перезапуску — порожнім назавжди. Тепер запис про роботу з'являється тоді, коли робота стає тривкою.

    Дефект 22. Момент поганої мережі не є відповіддю

    Арбітр і вартовий обидва обходять платформу перед кожним циклом. Арбітр — щоб відповісти на те, що чекає на людину. Вартовий — щоб знайти запуски, які застрягли. І обидва здавалися на першій же помилці, і записували статус, який не вдалося прочитати, як «непрочитаний» — факт про платформу, який ішов у підсумок циклу, а звідти — у звіт редакції про саму себе.

    Але помилка з'єднання — це не відповідь. Це мережа, яка погано живе секунду, і це рівно той випадок, коли треба запитати ще раз. А 404 — відповідь: речі там немає, і другий запит нічого не змінить.

    У живому записі було сорок помилок арбітру і жодного успішного обходу, усі з одним повідомленням:

    APIConnectionError: Connection error: [Errno 8] nodename nor servname
    provided, or not known

    — тоді як ім'я самої платформи розв'язувалося правильно через хвилину. Редакція вирішила, що платформа недоступна, на підставі моменту, який уже минув. І вирішила це сорок разів.

    Лікування — одне місце, яке знає, що можна повторити. Питання «це мить чи відповідь?» тепер має одну відповідь — is_transient() у app/uarp_errors.py — і обидва обходи користуються нею.

    ПомилкаПовторювати?Чому
    APIConnectionError (DNS, з'єднання відхилено)такце мить; ім'я платформи розв'язується за секунду
    429, 500, 502, 503, 504такінша сторона просить прийти за мить
    404ніце відповідь: речі немає
    409ніце факт про стан світу — треба прочитати й вирішити
    400ніце помилка самої редакції; той самий кривий запит знову
    ConfigErrorнібракує ключа чи URL — так буде завжди

    Повтор відступає: 1 с, потім 2 с. Затримка потрібна, бо помилки, які варто повторити, — це ті, де інша сторона просить трохи часу. Відступ — це різниця між «спробуй ще раз» і «стукай у двері».

    І найголовніше: тепер «непрочитано» означає «платформа лишилася недоступною через кожну спробу» — а це вже факт про платформу, а не про мить, що минула.

    Перевірено на живій платформі 12:13Z. Обхід арбітру, який раніше падав щоразу, пройшов:

    inspected: 0  answered: 0  failed: 0  rescued: 0
    complete: True  unreadable_statuses: []

    І те саме — у підсумку живого циклу 8eb06575 (12:21Z):

    arbiter:  {inspected: 0, answered: 0, failed: 0, rescued: 0,
               complete: True, unreadable_statuses: []}
    watchdog: {stuck: 0, cancelled: 0,
               complete: True, unreadable_statuses: []}

    Тобто обидва обходи тепер кажуть правду — і кажуть її саме тоді, коли їм є що сказати.

    І окремо — перевірка самого механізму на випадковому DNS-бліпі. Той самий виклик, що падає двічі з трьох:

    retry 1: APIConnectionError
    retry 2: APIConnectionError
    succeeded on attempt 3 -> ['run-1', 'run-2']

    А той, що падає завжди, тепер каже після трьох спроб — і тоді повідомляє про помилку:

    gave up after 3 attempts — Connection error: [Errno 8] nodename nor
    servname provided, or not known

    Старий код на тому самому виклику здавався після першої спроби — тобто «непрочитано» означало «не прочитано з першого дотику».

    Чому це частина стандарту. Двадцять другий дефект — це дефект різниці між помилкою та відповіддю. Система, яка не розрізняє їх, має два шляхи: або вона здається на першій помилці й бреше про платформу («недоступна»), або вона повторює все підряд і тягне час на те, що вже ніколи не стане відповіддю. Перше — тиша, друге — дурне очікування. Обидва — неправда.

    Правило, що з цього випливає: помилка мережі — це не відповідь, а привід спробувати ще раз; відповідь — це те, що повтор не змінить.

    Дефект 23. Канал, який не відповів один раз, вважався мертвим

    Той самий дефект, що й у двадцять другому, — але з боку каналу, і він був у коді, який писали після лікування арбітру.

    Наглядач каналу перевіряє, чи живий тунель, і записує результат. Одна перевірка — один запит. І ось що це означало на живій платформі:

    часщо показала перевіркащо було насправді
    12:32:09Zmcp_ok: false, tunnel_failure: failedтунель обслуговував запити
    12:32:36Zmcp_ok: trueте саме
    12:35:04Zmcp_ok: false, tunnel_failure: failedте саме
    12:35:17Zmcp_ok: trueте саме

    Повідомлення було одне й те сале: ConnectError: [Errno 8] nodename nor servname provided. А десяток перевірок за секунду після цього відповідав за 0,12–0,28 секунди. Ім'я розв'язувалося правильно — .trycloudflare.com → 104.16.230.132.

    Тобто канал був живий, а запис двічі сказав, що він мертвий. І гірше: запис **tunnel_strikes залишався 0, бо лічильник рахує перевірки, а не спроби. Тобто той самий лічильник, який існував саме для того, щоб відрізняти «мить» від «каналу», рахував удачу**.

    Лікування — повтор усередині самої перевірки. Питання «чи живий канал?» ставиться один раз, але відповідь шукається тричі, з паузою 1 с, потім 2 с. Одна невдача — мить. Три поспіль — канал. І лічильник ударів тепер рахує щось осмислене.

    Це той самий принцип, що й у двадцять другому, — тільки тепер він поширився на ту частину системи, яку ми перевіряли самі. Колись ми виправляли «факт про платформу» й не подумали, що такий самий факт про себе ми пишемо в кожну перевірку каналу.

    Перевірено на живій платформі. Після правки: перевірка, яка раніше могла впасти з першого дотику, повертає mcp_ok: true — бо перша спроба, що впала, більше не є вироком.

    Чому це частина стандарту. Двадцять третій дефект — це дефект симетрії. Ми виправляли «система бреше про платформу» й не помітили, що система бреше і про себе. А канал — це те, що ми перевіряємо найчастіше і найприскіпливіше: одна невдала перевірка каналу — це ті два «страшні дефекти» з вісімнадцятого серії (засудити те, що працює, і затерти власну адресу), тільки завжди. Канал не гинув — але міг загинути від одного дотику розв'язувача.

    Правило, що з цього випливає: прилад, який вимірює, сам має терпти до миті. Вимірювальний прилад, який ламається від одного кліку, не вимірює — він генерує події.

    Дефект 24. Інструмент, який не вміщається в бюджет платформи, не повертає результат

    Місія о 12:39Z провалилася з вердиктом судді:

    judge_rejected: Agent explicitly states it cannot provide the numbers: tools returned only the running cycle, no published/updated/rejected/ cost_usd values were reported.

    Агент не помилявся. Платформа обрізає результат MCP-інструмента до 2000 символів і додає позначку … [+N chars]. У поточковому записі самої платформи (export_run_events) видно, що саме доставлено:

    викликдоставленоприхованоусього
    run_newsroom_cycle20156 8198 834
    list_newsroom_cycles (limit=10)201627 71029 726
    list_newsroom_cycles (limit=2)20159 88011 895
    get_newsroom_cycle20156 8208 835

    Чотири різні виклики — одна й та сама перша сторінка. Перелік містив десять циклів. Агент бачив один — той, що саме працював. І він сказав правду: «інструменти повернули лише цей самий цикл».

    А ось чому це не «просто завеликий відповідь», а дефект із конкретною причиною. Платформа склеює блоки вмісту інструмента без жодного роздільника і обрізає вже склеєний текст. Тому інструмент, який повертає список, дає по одному блоку на елемент, приліплені один до одного. Перший елемент витрачає весь бюджет — і всі інші елементи зникають до того, як агент їх побачить. Перевірка це підтвердила: list(limit=2) дав рівно 8835 + 3060 = 11 895 — сума двох блоків, обрізана на 2000.

    Тобто формат результату — це не стиль, а механіка доставки. І поки ми повертали повні підсумки циклів, ми віддавали агентові примітку з обрізаним краєм, яка виглядала як відповідь.

    Лікування — три кроки, і перший важливіший за два інші.

    1. Перелік — це дайджести, а не підсумки. Питання mission'а: скільки опубліковано, скільки оновлено, скільки відхилено, скільки коштувало. Заголовки статей, міркування EIC, черга, покриття — усе це редакційна проза, яка сама й забивала бюджет.
    2. Перелік — це один рядок JSON, а не список. Один блок вмісту приходить цілим або не приходить узагалі. Список — це багато блоків, склеєних у ряд, де перший блок вирішує, чи побачить агент решту.
    3. Якщо доводиться щось відкинути — це сказано в результаті. Перелік, що тихо відкинув цикл, повторив би ту саму брехню в новій формі: читач, який не знає, що щось відрізали, вірить, що бачив усе.

    Перевірено на живій платформі. Після правки list_newsroom_cycles повертає один блок у 1696 символів, без позначки обрізання, і агент бачить усі десять циклів. get_newsroom_cycle — 1768 символів, цілим.

    Чому це частина стандарту. Двадцять четвертий дефект — це дефект форми, а не змісту. Дані були правильними, інструмент працював, сервер відповідав 200 OK, а агент все одно не бачив відповіді. Ми будували систему, яка розповідає агенту, що вона бачила, — і не помітили, що те, що вона розповідала, було обрізане на 96%.

    Правило, що з цього випливає: відповідь має вміщатися в те, що може прочитати той, хто її читає. Те, що не вміщається, — це не відповідь, а початок відповіді. І найважливіше: обрізаний результат має сам про себе сказати, що він обрізаний — інакше той, хто читає, найменше зможе запідозрити, що чогось не бачив.

    Дефект 25. Лікування зупинилося на половині, а guard рахував не те

    Місія о 13:32Z після правки пройшла: all_objectives_verified, «All 1 objectives verified». Але перш ніж рахувати перемогу, варто було запитати: що саме платформа доставила цього разу. Поточковий запис (export_run_events) відповів:

    викликблоківсимволівобрізано
    run_newsroom_cycle1139ні
    list_newsroom_cycles11682ні

    Перелік виправився. Запуск — ні. І саме він у пам'ятці про провали: objective каже «Виклич MCP-інструмент run_newsroom_cycle з limit=1» першим. Тобто інструмент, який mission викликає першим, лишився невиліченим — і це був недолік форми, а дефект змісту.

    Ось у чому він полягав. run_newsroom_cycle повертав весь запис циклу разом із підсумком. Для завантаженого циклу 8eb06575… це:

    Тобто reused не доходив до агента ніколи. А reused — це відповідь на питання «це одна спроба чи дві?», тобто рівно те, про що objective просить агента відзвітувати: «reused=true — це та сама однока спроба, а не дві». Інструмент не давав агентові побачити поле, без якого той не міг скласти речення, яке від нього чекали. Результат, що ховає відповідь на запитання, — це не менший результат, а хибний.

    Це й була справжня вага дефекту 24. Ми його назвали «перелік не вміщається в бюджет» — і виправили перелік. А поломка була в чотирьох інструментах, а ми починили два. Місія пройшла не тому, що поломку виправили, а тому, що цього разу цикл почався заново (reused=false, 139 символів) і в.result просто вмістився. Наступний виклик у тому самому десятихвилинному вікні пішов би шляхом повторного вживання — і приніс би 27 607 символів, з яких агент побачив би перші 2 015. Тобтоmission, що пройшла, була щасливим випадком, а не доказом.

    Другий дефект у тому ж місці — guard, що рахував не те, що треба. Захист watchdog'а лімітував звіт десятьма записами («кожен запис близько 150 символів, тож тринадцять вміститься, а чотирнадцятий — ні»). Але десять записів із справжніми id запусків і агентів коштують 2 651 символ — тобто той захист пропускав звіт за межу бюджету, а примітка «показано 10 з 20» обрізалася першою з усіх полів, бо стояла в кінці. Захис, який вимірює не те, гірший за захис, якого немає: він виглядає так, ніби проблему вирішили.

    Лікування — три кроки, і перший з них про інструмент, а не про результат.

    1. Правило бюджету живе в одному місці — app/mcp_budget.py. Кожен інструмент, що має вміститися, міриться ним. Бо до того кожен із чотирьох виростив своє приватне правило «що вміщається», і два з чотирьох були хибними. Спільна константа — це не DRY, це єдина правда про обмеження платформи.
    2. Вимірюється результат, а не кількість записів. Пакувальник вимірює відрендерений результат і відкидає записи з кінця, поки він не вміститься. Примітка про відкинуте живе всередині вимірюваного результату — бо примітка, додана після вимірювання, може сама висунути результат за межу, і тоді рівно те, що каже «я щось відкинув», обрізається першим.
    3. Результат відповідає на те, про що його запитали. Запуск віддає cycle_id, статус, **reused** і лічильники завершеного циклу. Проза лишається там, де вона й була, — у записі циклу, який get_newsroom_cycle дістає за id.

    Перевірено на живій платформі. Усі чотири інструменти повертають один блок, без позначки обрізання:

    інструментсимволівобрізано
    run_newsroom_cycle (новий цикл)254ні
    run_newsroom_cycle (повторне вживання)253ні
    list_newsroom_cycles (limit=10)1699ні
    get_newsroom_cycle (важкий цикл)1768ні
    run_watchdog203ні

    І reused тепер видно: {"cycle_id":"6b1f2e66…","status":"running",…, "reused":true,…}.

    Доказ на найгіршому вході — місія о 14:02Z. Місія о 13:32Z пройшла, але вдало: вона почала новий цикл, а новий цикл дає короткий результат. Наступна, о 14:02Z, впала на шляху повторного вживання — і ось що платформа доставила насправді:

    {"cycle_id":"d1cbc28239604e52988f38bbcbd179f2","status":"failed",…,
     "reused":true,"published_count":0,"updated_count":0,"rejected_count":0,
     "cost_usd":0.0,"error":"the newsroom restarted while this cycle was running"}

    337 символів. Один блок. Без обрізання. І reused стоїть на символі 189 — там, де він стояв би, якби результат не був обрізаний на 27 591 символі.

    Агент прочитав це і відзвітував правильно:

    run_newsroom_cycle викликано рівно один раз (limit=1, без wait=True). Новий цикл: d1cbc28239604e52988f38bbcbd179f2, reused=true — це та сама одинока спроба, а не дві.

    Вердикт: all_objectives_verified, «All 1 objectives verified».

    Тобто єдине відрізнення між 13:32Z і 14:02Z — те, що другий прогін упав на гірший вхід. І саме він довів, що поломку виправили.

    Чому це частина стандарту. Двадцять п'ятий дефект — це дефект про лікування, і це рідший случай, ніж перші двадцять три. Двадцять четвертий сказав: «відповідь не вміщається в бюджет читача». Двадцять п'ятий каже: «ти починив не те, і ти навіть не перевірив, що починив усе». Два з чотирьох інструментів лишилися зламаними, один guard рахував записи замість символів, а mission, яка пройшла, не доводить нічого — вона просто вдало вперлась у короткий результат.

    І найважливіше: успішний прогін — не доказ. Місія о 13:32Z повернула all_objectives_verified, і це було не доказом того, що поломку виправили: вона впала на новому циклу, де результат навпаки був малий. Доказом є замір результату на найгіршому вході — а ми його не зробили, доки не запитали поточковий запис платформи знову. Правило, що з цього випливає: після лікування треба перевірити не «чи пройшло», а «що саме платформа доставила» — і перевірити на найгіршому вході, а не на вдалішому.

    3ter.5. Чому це частина стандарту, а не фіча

    Вісім речей, зроблених у цій сесії, — сторінка, специфікація, правило доступу, головний редактор і чотири правдиві звіти — пов'язані однією думкою, і вона та сама, що в §3bis.10.

    Твердження, яке не можна перевірити, — це не доказ, а декларація. Манифест без посилань на записані факти — це реклама. Профіль розкриття без implemented_by — це папір. Заява «ми не віддаємо незавершене», яку можна обійти, — це не безпека, а бажбан.

    Тому кожна з восьми речей побудована так, щоб її можна було перевірити сторонньою людиною без довіри до нас: числа манифесту рахуються з бази, клаузи профілю — з коду, правило доступу — з поведінки ендпоінта, захист MCP — з коду платформи, рішення EIC і найм — з журналу, у якому видно і причину, і ціну, а звіти watchdog, розкладу й черги — з того, що вони кажуть, коли не знають, а звіт циклу — з того, що його числа сходяться.

    І третій наслідок, який важливіший за реалізацію. Коли ми сховали б відхилене, ми б покращили видимість і зруйнували довіру. Найпривабливіша версія будь-якої з цих восьми речей — та, що робить компанію красивою для себе. Правильна — та, що робить її перевірюваною для когось іншого.

    Але є один наслідок, який важливіший за всі вісім разом. Компанія, яка сама наймає собі агентів, — це компанія, у якій може з'явитися агент, який ніхто не замовляв. Тому право на зростання не можна дати «загалом» — можна дати лише через закритий список ролей, одна роль — один агент і never_allow на весь agent factory при створенні. Саме це робить самонайм можливим, не перетворюючи його на ризик: компанія може змінити себе, але не може змінити правило, за яким вона це робить. Асиметрія — «EIC може нарощувати екіпаж, нарощений агент — ні» — це не деталь реалізації; це те, що робить можливим автономну компанію взагалі.


    Частина 4. Стратегічна рамка: нова гілка автономних компаній

    4.1. Де наша справжня диференціація

    Ми не конкуруємо з людськими редакціями за швидкість (Acute виграє за рахунок Brave→live за 90 секунд) і не за обсяг (Chronic Internet: 6 086 статей). Ми конкуруємо за довіру.

    Наш унікальний актив — перевірюваний процес:

  • Verifier-зауваження з цитатою, проблемою і виправленням;
  • Scout-роздуми, key_facts, uncertainties;
  • run_id кожного етапу + сирий JSON моделі;
  • журнал рішень з вартістю;
  • журнал циклів з доказом автономності;
  • публічні корекції й ретракції;
  • budget guard, watchdog, idempotency.
  • Жоден конкурент цього не показує. Machine Herald заявляє «cryptographically signed» — і не показує протокол. Chronic Internet має «Copy Desk quality gate» — і не показує критеріїв. Ми — едині, хто показує.

    Це і є позиція: не «AI-контент», а «доказовно агентський контент». AI — деталь реалізії, а не бренд.

    4.2. Стандарт, який ми можемо запропонувати

    Три речі, які разом утворюють стандарт:

  • Розкриття, прив'язане до дії, а не до бренду. «AI-generated» — маркування. «Текст згенеровано автоматично; перевірив Verifier-агент; людей не залучено; ось джерела, ось вердикт, ось виправлення» — стандарт якості. Перший, хто опублікує такий профіль, задає норму.
  • Кожна стаття має перевірюваний ланцюг походження. Підпис + публічний ключ + сторінка provenance + ланцюг версій. Machine Herald обіцяє це — ми реалізуємо.
  • Відповідальність机器ни. Schilke & Reimann: розкриття еродить довіру без відповідальності. У нас немає людини — але є процес, який відповідає. Формулювання «Відповідає редакційний контур, і ось він повністю видимий» — це те саме, що «Final accountability lies with the editor», але сильніше: відповідальний невидимий редактор, а видимий протокол.
  • 4.3. Що це означає для категорії «автономна компанія»

    За BCG, AI-only фірма з'явиться в digital-native секторах, і бар'єри — не модель, а координація, пам'ять і правосуб'єктність. Жодна юрисдикція не визнає AI особою.

    Тому перша AI-редакція у світі має зробити дві речі одночасно:

  • довести, що агентна операція може бути медіа (транспарентність, корекції, аудит, якість);
  • показати, що вона тримає себе в руках (бюджет-гард, watchdog, idempotency, автоматичний аудит, budget-aware planner).
  • І третя, яку конкуренти не роблять: випустити специфікацію, за якою це можна повторити. Стандарт — це не те, що ми перші зробили; це те, що ми перші описали. Методологія + публічний аудит + (P2-2) disclosure profile = інваріант, який інші можуть прийняти. Саме це робить гілку, а не один продукт.


    Джерела

    Портали й дизайн

  • Guardian — 20 років Techmeme, і полеміка про design · Techmeme: 20 years of consistency
  • INMA — Boston Globe: content, community, live blogs
  • Nine.com.au relaunches in biggest overhaul yet — Mediaweek
  • RightNow.Live — Live Coverage Strategy
  • Reuters Institute — Walking the notification tightrope (2025) · Press Gazette
  • AI-розкриття і довіра

  • Ali — Transparency and Disclosure of AI-Use in Newsrooms (N=580, 2025)
  • Russell et al. — AI use in American newspapers is widespread, uneven, and rarely disclosed (arXiv 2510.18774) · dashboard
  • Bien-Aimé et al. — Who Wrote It? (PsyPost summary)
  • Sergaš et al. — Prompt to Press (ACM IUI '26 Companion)
  • Provenance

  • IPTC — Start signing your news content using C2PA in 5 steps
  • EBU — France Télévisions leads the way with operational adoption of C2PA
  • IPTC — Stamping Your Content Accelerator
  • AWS — CBC/Radio-Canada Content Credentials
  • IPTC — C2PA and Media Provenance updates (PDF)
  • Автономні компанії

  • BCG — Why CEOs Need to Prepare for AI-Only Rivals (2025)
  • Chariot AI — What is a Zero-Human Company?
  • ApolloSpace AI — The native service company is the real AI business model
  • Forbes — The Rise Of Autonomous Businesses
  • Empire Flippers — Building an 8-Figure Autonomous Business (Swan AI)
  • Конкуренти

  • Machine Herald · Chronic Internet LLC
  • Індустрія

  • EBU News Report 2025 (EBU) · Nieman Lab (серпень 2025) · TeleFlash (arXiv 2510.01193) · Oxford Internet Institute / Reuters Institute (Hermida & Simon) · INMA (HT Media)