Дата: 20 жовтня 2026 · Автор: дослідження + аудит коду AI Newsroom
Обсяг: 3 частини — як виглядають портали 2025–2026 → що в нас → що змінити, щоб стати першою AI-редакцією у світі.
Дослідження по редизайнах Guardian, Tamedia, ABC News («Future News»), Nine.com.au, Boston Globe, WSJ, Techmeme і Telemundo дає один і той самий набір рішень. Не дизайн-мода, а функціональні обмеження, які однаково давлять на всіх.
| # | Патерн | Що це означає на практиці |
|---|---|---|
| 1 | Breaking news — це стан інтерфейсу, а не окрема сторінка | Telemundo: шапка набуває червоного стану, з'являється окремий модуль, але звичайна стрічка під ним лишається недоторканою. Guardian у 2025 відмовився від infinite feed і натомість зробив карусель у шапці. ABC Future News: layout перебудовується під поточну новизну дня. |
| 2 | Live-блог — це продукт, а не фіча | Boston Globe: 200+ live-блогів за 2025, live-блог нагорі головної (перехопивши століття інстинкт), ~6 млн переглядів. Nine.com.au: нова домашня сторінка побудована навколо live-блогів, explainer'ів, breaking news і преміум-відео. |
| 3 | Редакційне бандлування | Tamedia: останній пост live-блогу піднімається до рівня teaser, teaser'и бандлуються. Nine: мікро-розсилки під конкретні події (вибори, бюджет). Це редакторська рука, яка вирішує, що читач побачить першим. |
| 4 | Типографічна контрастність замість одноманітної сітки карток | Guardian: секції відрізняються розміром і накресленням, а не кольором рамки. «Друкова» логіка: заголовок блоку — великий, всередині — дрібніше. Читачі скаржилися на щільність, тому щільність стала явним параметром дизайну. |
| 5 | Content signposting | ABC: формат + біт + час читання + джерела видно на картці, до кліку. Читач має зрозуміти, що йому зараз дадуть. |
| 6 | Ручне оновлення зі лічильником на вкладці | Замість різкого авто-вставлення. Класика: сторінка оновлюється, але лічильник «3 нові» на табі змушує читача самого натиснути. Ми вже це зробили тостом. |
| 7 | AI-розкриття — це дилема, а не галочка | Див. 1.5: це найважливіший висновок дослідження для нас. |
Techmeme додає ще один — «спільний контекст замість персональної стрічки». Gabe Rivera прямо каже: Techmeme не персоналізується, усі бачать те саме, бо це «індустріальний спільний контекст». У 2025 він говорить про те, що LLM-агенти ламають краулінг і що потрібно більше «smart» всередині. Це пряма порада нам: не робимо «For You».
Ми зараз посередині: у нас є бейт-фільтри й формат-фільтри, але немає ані редакційного порядку, ані персоналізації — ми просто показуємо стрічку за часом. Це найслабша з трьох позицій.
Reuters Institute, Digital News Report 2025 (Nic Newman):
Висновок для нас: алерти — не канал зростання, а канал відтоку. Наш поточний підхід (тизер + клік) правильний. Масштабувати «повідомлення» не варто.
C2PA у 2025 вийшла з експерименту в операційний режим:
Machine Herald заявляє «cryptographically signed» і «Verifiable provenance» — але на своїй же сторінці не розкриває протокол: який алгоритм, який хеш, який ключ, який лог прозорості, як читач перевіряє. Тобто заявка є, а перевірюваності немає. Це саме та діра, яку ми можемо зайняти.
Це найважливіший блок для нас, бо він прямо суперечить нашій поточній реалізації.
| Дослідження | N | Результат |
|---|---|---|
| Altay & Gilardi, PNAS Nexus 2024 | 4 976 | Узагальнена мітка «AI-generated» знижує сприйману точність і намір поділитися — навіть для правдивного контенту. Шкода зникає, коли мітка визначена вузько («AI wrote a first draft»). |
| Toff & Simon, IJPP 2025 | — | Розкриття списку джерел здебільшого компенсує штраф за довіру. |
| Ali & IIU, 2025 (preregistered) | 580 | 4-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 2026 | 683 | Усі 7 task-specific атрибутів розкриття негативні за довірою (−0.23…−0.62), найгірше карається fact-checking. ⚠️ |
| Morosoli et al., AIES 2025 | 207 | Ефект залежить від теми. |
| Licenji & Hoxha, Frontiers in AI 2026 | 47 оглядів | Немає систематичного штрафу за provenance; доказова база тонка/null. Пропонують Cue–Inference–Target. |
| Bien-Aimé et al., Communication Reports 2025 | 269 | Читачі не можуть зрозуміти натяк «by staff writer with AI tool» — «фольклорні теорії». «with AI assistance» недостатньо: треба сказати, чи AI написав чернетку, чи узагальнив, чи запропонував правки, чи просто перевірив орфографію. |
| Russell et al., arXiv 2510.18774 | 186 тис. статей | 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: міткує лише там, де читач може бути суттєво введений в оману. |
Синтез — це і є наш стандарт:
Ми не можемо сказати «human reviewed» — у нас немає людини. Але в нас є щось краще за людську перевірку з точки зору простежуваності: повний ланцюг доказів. Це і є відповідь на «Final accountability lies with the editor» — у нас відповідальність лежить на видимому й перевірюваному процесі, а не на невидимій людині. Треба так і написати.
| Джерело | Факт |
|---|---|
| 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»: продаємо результат, який сам себе забезпечує. Потрібен агентний ОС: планувальник, пам'ять, інструменти, шар дозволів. |
| Forbes | Bolt і 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.
Chronic Internet LLC (NewsAnarchist) — найближчий аналог:
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 доменів.
Що у нас є, чого немає в жодного з них:
runs — повний аудит.Що у них є, чого немає в нас: масштаб (6 086 статей, 30+ годин безперервної автономності), AI-CEO як окрема роль, самонайм агентів, $0 LLM-вартість.
Сильні сторони — те, чого немає в ринку:
| # | Можливість | Стан |
|---|---|---|
| 1 | Verifier-зауваження з цитатою, проблемою та виправленням | ✅ повністю, у публічному UI |
| 2 | Scout-роздуми (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 |
| 9 | Real-time homepage | ✅ ticker + polling + toast (Phase 1) |
| 10 | SEO/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, одразу йде. Дослідження каже: замість каяття — конкретика процесу. |
| C | key_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 не показує | Аудит-слой неповний: ми не показуємо, що було відхилено. |
| F | scout.total_candidates завжди — | article.html:268 | Кількість кандидатів не персиститься. |
| G | verifier_2 / update_verifier_2 мовчки залишають перший вердикт, якщо другий не розпарсився | pipeline.py:1134-1136, 1002-1005 | Редакція публікує на силі доревізійного вердикту. Це реальна діра в гарантії якості. |
| H | Verdict reject без required_fixes → Writer отримує «виправляй ніщо» | pipeline.py:1094 | Порожній цикл ревізії з повною вартістю. |
| I | Немає автоматичного пост-публікаційного аудиту в циклі | check_article() лише ручний | Корекції з'являються тільки якщо хтось натисне. Автономна редакція має перевіряти себе сама. |
| J | risk зберігається і показується, але нічого не блокує | LOW_RISK не використовується | Поле, що виглядає як гарантія, але не є нею. |
| K | REJECTING_VERDICTS не використовується | pipeline.py:42 | Будь-яка помилка у вердикті = тихий reject. |
| L | dateModified не враховує corrected_at | web.py:501-553 | Google не бачить корекцію. |
| M | Немає articleBody, wordCount, inLanguage у JSON-LD | web.py:501-553 | Неповна розмітка для Google News. |
| N | Відсутні ethicsPolicy / correctionsPolicy / publishingPrinciples | JSON-LD publisher | Обов'язково за правилами Google News для AI-контенту. |
| O | /decisions, /operations, /cycles, /company індексуються і в sitemap | robots.txt:1227 | Внутрішні дашборди в пошуку. |
| P | Тема-ленти → /search?q=, немає тег-сторінок | article.html | Немає внутрішньої перелінковки. |
| Q | diversity override може підмінити сильну breaking-історію на слабшу | pipeline.py:799-853 | Ризик дляBreaking-якості. |
| R | Forced story_id оминає фільтри й баланс | pipeline.py:713-715 | Ручний режим небезпечний. |
| S | is_news_candidate / is_ukraine_related пропускаються для forced | pipeline.py:800 | Ручний публікація може пройти поза темою. |
| T | tests/ | Регресії ловляться. | |
| U | APIError і показує stuck: 0 | app/uarp_errors.py, app/watchdog.py | Під час збою UARP ми виглядаємо здоровими. Тепер звіт каже, які стани не прочитано. Див. §3ter.4quater. |
| V | /api/schedule мовчки підставляє вигаданий next_cycle_atstatus: unreachable | app/native_schedule.py, app/web.py | UI показує впевнений таймер для циклу, який не запуститься. Тепер таймер не називає час, якого не знає. Див. §3ter.4quater. |
| W | status='new'held + stale + правдива черга | app/db_sqlite.py, app/pipeline.py | Backlog накопичується; прибирається лише вікном свіжості. Тепер черга рахує те, з чого можна працювати: 3, а не 312. Див. §3ter.4quater. |
| X | MCP_API_KEY порожній | .env, scripts/secure_mcp.py | |
| Y | rescue_run | uarp_arbiter.py, uarp_agents.py | Етап, що зупинився, сам отримує відповідь від арбітра, чекає на run і забирає результат. |
| Z | arbiter.rescued | uarp_arbiter.py | Результат врятованого run'у пишеться в журнал і видно на /arbiter. |
| AA | /manifest | app/manifest.py | Одна сторінка з доказом, що компанія без співробітників. Див. §3ter. |
| AB | app/disclosure_profile.py | Стандарт, який можна перейняти. Див. §3ter. | |
| AC | app/access.py | app/access.py | API віддає рівно те, що сторінки. Див. §3ter. |
| AD | allowed_tools | scripts/companion.py | Сервер був підключений, active і проходив тест — а агент не міг його викликати. Див. §3ter.4bis. |
| AE | app/eic.py, app/pipeline.py | Редакція без головного редактора — конвеєр. Тепер рішення «писати / відкласти / не писати» ухвалює агент, а не правило в коді. Див. §3ter.4ter. | |
| AF | app/eic.py, app/db_sqlite.py | Роль — зі закритого списку, одна роль — один агент, never_allow на весь agent factory. Див. §3ter.4ter. | |
| AG | already_seen + самоперевірка рахунку | app/db_sqlite.py, app/pipeline.py | upsert_story повертав None для вже відомої історії, і пайплайн її не рахував. Звіт був повний, але не сходився. Див. §3ter.4quater. |
| Очікування читача 2026 | Ми зараз | Розрив |
|---|---|---|
| Breaking як стан інтерфейсу | Є alert-банер + ticker | ⚠️ частково: немає червоного стану шапки |
| Live-блог нагорі | Є тільки для найсвіжіших | 🔴 немає live-блогу як продукту |
| Редакційне бандлування | Немає | 🔴 |
| Типографічна контрастність | Одинкова сітка карток | 🔴 |
| Content signposting | Є: формат, біт, джерело, час, confidence | ✅ |
| Клік vs. сповіщення | Тост + клік | ✅ |
| Task-specific AI-розкриття | Узагальнена мітка «AI-generated» | 🔴 пряме порушення доказів |
| Джерела видно зразу | Є, але внизу сторінки | ⚠️ Toff & Simon: джерела компенсують штраф — їх треба показувати рано |
| Відповідальність названа | «Людське редагування: немає» | ⚠️ Schilke: без відповідальності розкриття еродить довіру |
| Provenance | Відсутній | 🔴 |
| Автономний пост-аудит | Відсутній | 🔴 |
| Невизначеність показується | Прихована | 🔴 |
| Персоналізація | Немає (і не треба) | ✅ свідомо |
| Спільний контекст | Є | ✅ |
Google News вимагає для AI-контенту: ethicsPolicy, correctionsPolicy, publishingPrinciples, actionableFeedbackPolicy, diversityPolicy на NewsMediaOrganization; плюс articleBody, wordCount, inLanguage. Маємо — сторінку /corrections, але не як schema.org-поля. Також dateModified не рухається при корекції, /api/schedule//operations індексуються, LiveBlogPosting немає.
Це не косметика. За 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 (зараз публікує на старому вердикті);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-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-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–2 53 178 $0.012–0.019 3–4 7 21 $0.019–0.023 5+ 1 13 $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-тести на Зроблено: 309 тестів (_scout_selection, _coerce_article, _same_article, titles_related, find_similar_article + інтеграційний тест «цикл → стаття → оновлення → корекція». Зараз 276 runs на $4.65 без жодного тесту.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. Приватність і доступність. Зроблено, але правило переписане: API віддає рівно те, що віддають публічні сторінки (опубліковане, відхилене, відкликане — усі три сторінки публічні), а роботу в процесі (MCP_API_KEY порожній; /api/feed віддає headline/dek публічно; /api/articles — публічно.new, selected, error) — ні, бо її не показує жодна сторінка. MCP-панель захищено ключем. Див. §3ter.3 і §3ter.4.
Цей розділ з'явився пізніше за аудит, але змінює його висновок.
Аудит показав дві записані в базі «завершені, але втрачені» роботи: ревізія, яка хотіла code_interpreter, і Verifier оновлення, який попросив «повний текст і URL джерел». Обидві закінчилися як status: completed — але жодного матеріалу не вийшло.
Причина виявилася не в тому, що компанія питала людину. Причина в тому, що app/watchdog.py видаляв роботу. Платформа зупиняла run, watchdog бачив «завислий» run і скасовував його. Компанія не просила людину — вона викидала роботу і повідомляла про це як про штатну процедуру.
Це важлива різниця у формулюванні. «Автономна компанія, яка просить людину» — це компанія з людиною. «Автономна компанія, яка видаляє роботу, бо людини немає» — це злана імітація.
| Статус | Що робить платформа | Механізм заміни |
|---|---|---|
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: він відмовляє в інструменті, але віддає агенту зворотний зв'язок, який той читає як результат своєї спроби.
Перша версія діагнозу: «промпт обрізається, Verifier не бачить текст, тому й питає». Це була б зручна і красива історія — і вона була неправильною.
Замість припущення ми виміряли: last_input_tokens = 18 103, довжина промпта — 18 097 символів. Обрізання не було. Verifier бачив і повний текст, і обидва URL джерел (секція оновлення починалася з 15 249, 1 587 символів, і містила sources з обома URL).
Справжня причина була простішою і гіршою: агент справді хотів інформації, і в компанії не було способу її дати. Не збій платформи, а відсутність інтерфейсу.
Цей епізод варто записати в дослідження окремо, бо він показує різницю між «AI-компанією, яка вміє себе чинити» і «AI-компанією, яка вміє себе перевіряти». Компанія, яка не перевіряє свої припущення, вигадує їх.
app/uarp_arbiter.py — агент, який відповідає там, де відповідала б людина. Правило — перше, агент — друге.
Арбітр не публікує. Його відповідь може бути лише конкретними даними або вказівкою відхилити матеріал. Публікаційний гейт — окрема функція, і арбітр не має на неї доступу.
Перший запуск арбітра в реальному середовищі дав три реальні поломки, жодна з яких не проявилася б у тестах:
| # | Дефект | Наслідок | Виправлення |
|---|---|---|---|
| 1 | Кап $0.01, а один крок Chief of Staff'а коштує $0.0128 (42k токенів — платформа пересилає весь каталог інструментів на кожному проході) | кожен запуск арбітра зупинявся на капі й записувався як failed | кап $0.04 |
| 2 | options з питання не передавалися арбітру | арбітр обирав між чотирма варіантами, не бачачи їх | options у промпт і в Decision |
| 3 | never_allow на інструменті не створює питання людині | хибна гіпотеза: «заблокуємо інструмент — і змусимо агента спитати» | перевірено на живій платформі: агент бачить, що інструмент недоступний, і працює далі |
Третій пункт — найважливіший для нашого висновку про прозорість. Ми не можемо писати в README «ми прибираємо людину», спираючись на never_allow. Це різні речі: never_allow прибирає ризик, але не створює питання. Питання створює ask_user — і саме на ньому ми тепер працюємо.
Три run'и, які чекали на людину, отримали відповідь від арбітра і завершилися самі:
status: completed.status: completed.status: completed.Третій випадок найважливіший: арбітр не просто відповів на запитання — він змінив конфігурацію іншого агента так, щоб проблема не повторилася. І зробив це без підняття ліміту, тобто без збільшення витрат.
Усі рішення записані в журнал подій (arbiter.decided) і видно на /arbiter та /api/arbiter.
Перше бюджетне питання було українською й про денний ліміт ініціативи. Друге — англійською й про стелю одного запуску. Перше правило («не піднімати ліміт») для другого було б недостатнім: воно скасувало б проблему, не розв'язавши її, і редакція залишилася б без перевірки.
Тому правило розрізняє не мову, а наслідок:
| Питання | Дешевший шлях | Відповідь арбітра |
|---|---|---|
| денний ліміт ініціативи вичерпано | зменшити обсяг циклу; до скидання планувальник виконає запуск сам | «зменш обсяг циклу» |
| стеля одного запуску | скоротити інструменти агента й перевіряти порціями всередині цього запуску | «скоротити інструменти, перевіряти невеликими порціями» |
Другий випадок показав загальне правило для всіх наступних: «не робити X» — це не відповідь, якщо є дешевший шлях до тієї самої мети. Арбітр має відповідати на запитання, а не відмовитись від них.
Наведений вище sweep арбітра відповідав на запитання й ішов далі. Ми перевірили це на живих даних і виявили, що це недостатньо.
Три run'и, які арбітр розблокував, завершилися на платформі — але їхні результати нікуди не потрапили. Відповідь на запитання не є результатом: run оживає, працює далі й пише свою відповідь у output.response, а ми про це дізнавалися лише тоді, коли хтось зазирнув у платформу вручну.
Наслідок той самий, що й до створення арбітра, тільки тихіший: робота завершується на платформі й губиться в редакції. Компанія, яка відповідає на запитання, але не забирає результат, виглядає ззовні як компанія, що працює — і нею не є.
Це доповнило арбітра третім кроком:
| Крок | Що робить | Метод |
|---|---|---|
| 1. відповісти | розблокувати run | runs.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'у вона має рахуватися з іншого джерела.
Третій дефект був найважчим, бо він не мав жодного следу в журналі.
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.
Три незалежні перевірки, усі на живій платформі:
awaiting_input за 8 секунд; арбітр відповів; run завершився за 14,9 секунди з відповіддю у 785 символів. Агент у цій відповіді відмовився публікувати без джерел — тобто арбітр не послабив редакційні правила, а дав агентові відповісти за них.awaiting_approval з install_spec. install_spec не в списку дозволених, тому арбітр відмовив через revise — run не вбито, він продовжив роботу і вирвав 2707 символів повного аудиту компанії. За 43 секунди, $0.0317, 10 кроків.inspected: 1, answered: 1, rescued: 1 — і результат врятованого run'у лежить у журналі як arbiter.rescued, а не втрачений.Останнє — це те, чого не було ніде: компанія, у якій є місце, де потрібна людина, і це місце заповнене агентом, який не просто відповідає, а забирає зроблену роботу.
Три тести на «справжню автономну компанію»:
Другий тест — найважливіший. Компанія, яка видаляє роботу, не відрізняється від компанії, яка ніколи не починала. Арбітр — це те, що робить різницю видимою.
Попередні частини описували, що треба зробити. Ця частина — що зроблено, і що виявилося при перевірці на живій платформі.
/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») — маніфест, який перейменовує тих, кого перелічує, не є маніфестом.
/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. Тобто стандарт не вигаданий — він відповідає на те, що вже виміряли, і заповнює прогалину, яку ці роботи залишають відкритими: усі чотири вимірюють ефект розкриття, але жодна не дає специфікації, яку можна перейняти.
Пропозиція 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-токен не налаштований (це зручність локальної демки — кнопка запуску мусить працювати без церемонії), але читання незавершеного беклогу вимагає справжнього токена і не наслідує цю зручність. Запустити цикл і прочитати чужу роботу — різні речі.
Окрема знахідка, якої не було в жодній пропозиції.
Платформа зареєструвала MCP-сервер редакції з auth: {"type": "none"} — на публічний Cloudflare-тунель. Сире POST /mcp/ без заголовка Authorization повертало 200 і перелічувало 4 інструменти редакції. Тобто будь-хто, хто дізнається URL, міг запускати редакційні цикли й скасовувати run'и. Панель керування редакцією, відкрита без пароля, — це не «незручність», а діра.
Механізм платформи довелося прочитати в коді SDK, бо документація про це мовчить:
env під env_key і шифрується платформою на спостіганні і не повертається;api_key_ref має називати змінну оточення MCP_*;auth описує, куди кладе секрет (type, header, prefix);PATCH ігнорує auth — сервер, створений з auth: none, лишається відкритим назавжди. Захист означає створити новий і видалити старий.І дві деталі, кожна з яких коштувала невдалого запуску:
prefix не можна задавати вручну. prefix="Bearer" дає Bearer<ключ> без пробілу → 401. Типовий префікс уже дає правильний Authorization: Bearer <ключ>, тож поле краще не чіпати.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.
Захист панелі виявився не кінцем історії. Сервер був захищений, платформа бачила його інструменти, 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 і проходив власний тест — і був недоступний.
Три деталі, кожна з яких коштувала пошуку:
PATCH /agents/{id} приймає mcp цілком: підполе, якого тіло не містить, скидається до типового, а не зберігається.transport в agent'овому схемі пишеться як http, а в реєстрі серверів — як streamable_http. PATCH з іншим написанням → 422.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, а не «успіх». Перевірка, що не читає результат, — це не перевірка.
Загальне правило, яке це дало. Платформа не має одного «увімкнено» для зовнішнього інструмента. Їх щонайменше три, і кожне може бути заповнене, поки решта — ні. Тому «сервер зареєстровано» ніколи не є достатньою відповіддю на запитання «чи може агент цим скористатися». Єдина достатня відповідь — агент викликав інструмент і отримав результат.
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; невідома роль → refused | app/eic.py |
| Одну роль — один раз | list_hired_roles() | app/db_sqlite.py |
| Новий агент не може нікого найняти | never_allow на create_agent, update_agent, self_improve_agent, evaluate_agent | app/eic.py |
| Новий агент не питає людину | never_allow на ask_user | app/eic.py |
| Новий агент не має exec-інструментів | never_allow на code_interpreter, run_command, notebook_run_cell | app/eic.py |
| Найм і відмова — у журналі | hired_agents + подія eic.staffing | app/db_sqlite.py |
Асиметрія — це і є дизайн. Writer і Verifier уже мають інструменти agent factory, але з never_allow, які їх знімають. Найнятий агент отримує те саме при створенні, а не «потім поправимо». Тому зростати може рівно один — EIC, — і кожне зростання залишає запис.
Перевірено на живій платформі, двома способами.
run, priority high, кут «означає арсенал для нарощування реактивних БпЛА для української ППО цієї зими — за поясненням ГУР, без власних оцінок», $0.017111. Рішення лежить у журналі як eic.decided і видно на /editor та /api/editor. Пізніше той самий сюжет був відхилений Verifier'ом (revise, confidence 0.82) — тобто EIC і Verifier не дублюють один одного: перший вирішує «варта грошей», другий — «чи це готово до публікації».) викликати 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 — бо факт, що компанія виросла на роль, не перестає бути правдою через тиждень, як подія в журналі.
Чотири дефекти з аудиту мають одну спільну форму: система виглядала здоровою рівно тоді, коли вона не працювала. Кожен із них — це не помилка в розрахунку, а помилка в тому, що система робила з відсутністю даних.
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 застрягли», коли перевірка не відбулася.
На кожній сторінці редакції висить таймер «Наступне оновлення». Він обіцяв читачеві час наступного циклу.
Коли платформа не відповідала, код брав початок останнього циклу, додавав інтервал і видавав результат за розклад з running: true. Тобто таймер, який тикав до конкретної хвилини, показував час, коли циклу не буде.
Розрізнення, якого не було: «платформа відповіла, і розкладу немає» (редакція справді простоїй) і «платформа не відповіла» (редакція не знає, коли наступний цикл). Обидва випадки давали однаковий next_cycle_at — вигаданий.
Як виправлено. native_schedule() тепер запам'ятовує причину невдачі (last_read_error()) і відрізняє її від «розкладу немає». /api/schedule у такому разі відповідає status: "unreachable", next_cycle_at: null, running: false і reason — і не називає жодного часу. Випадковий таймер на випадок, коли платформа недоступна, прибрано: він працює лише тоді, коли платформа відповіла і розклад існує, але ще не повідомив про себе. Фронтенд тепер показує «розклад недоступний» замість «планувальник не запущений» — бо це різні речі, і перша чесніша за другу.
У живій базі було 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).
Цей дефект знайдений не в коді, а на живому циклі — і саме так, як мають знаходитись дефекти, пов'язані з правдою: не через читання, а через запуск.
Живий цикл о 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.4quater) розібрала чотири місця, де компанія про себе говорила неправду. Ця — про інший клас дефектів: місця, де редакція мовчала про те, що вона знала.
Знайдено в аудиті одного робочого циклу й перевірено на живій платформі. Сім дефектів, сім різних механізмів, але одна причина: етап, який впав або сказав «не знаю», все одно видавав вердикт — і код вірив цьому вердикту.
Coverage Editor — агент, який вирішує, про що має бути цикл. Він існував, він читав портфель, він писав «war, economy, tech» і rationale. Але його план нічого не забороняв, бо сторони говорили різними мовами.
Слово «бейт» означало одне в pipeline.py і інше в coverage.py. Але гірше: ключ, за яким кандидат дізнавався, свого бейту, — topic_hint — не існував на жодному кандидаті. upsert_story такого поля не писав, select_candidates такого поля не читав. Тому:
[other] для кожного кандидата;Тобто порушення власного плану покриття було фізично недетектоване. Не «рідко виявлялося» — виявлятися не могло.
Це той самий дефект, що §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 людини…» | economy | war | ← «удари» → economy |
| «У жовтні кількість втрат російських окупантів…» | tech | war | |
| «Магнітні бурі в Україні: вчені оприлюднили прогноз» | politics | other | |
| «Зірки, які пережили рак: Камалія…» | politics | culture | |
| «"Бабине літо" повернулось…» | politics | other |
Тобто Scout не просто дублює регулярний вираз — він виправляє його там, де вираз помиляється на 31% вибірки. Регулярний вираз лишився як страховка (щоб кандидат без оцінки Scout'а все одно мав бейт), але рішення ухвалює агент, а не re.search.
Це і є відповідь на питання «навіщо нам Coverage Editor, якщо є регулярний вираз». Регулярний вираз — це grep. Редактор — це рішення.
_publish_gate — це єдині двері до читача. Після цього класу аудиту їх стало три замість одного, і кожна з них мала одну й ту саму помилку: етап, який впав, все одно віддавав вердикт, а код вірив цьому вердикту.
check_article мав умову «вердикт каже correct», «впевненість вища за поріг» і «є текст». Жодної перевірки, що перевірка завершилася. А перевірка, впала на ліміті витрат платформи, все одно повертає розбірливий JSON. Отже, впала перевірка могла опублікувати корекцію — а впала перевірка з вердиктом retract могла зняти вже опубліковану статтю зі сторінки читача.verifier_2. Якщо повторна перевірка не розпарсилася, пайплайн мовчки залишав перший вердикт — тобто публікував на силі доревізійного** вердикту. Редакція, яка переписала статтю після перевірки, публікувалася за вердиктом тексту, який уже не існував.update_writer.** Те саме для оновлень: якщо переписувач впав, оновлення все одно йшло далі.Як виправлено. _publish_gate тепер приймає allowed_verdicts (бо корекція — це не стаття: її вердикти correct/correction/retract, а не publish) і список провалених етапів. Провалений етап блокує публікацію, незалежно від того, що вердикт каже. Тобто запитання «що вердикт?» більше не єдине — запитання тепер «який вердикт і чи всі етапи, які його дали, живі».
Плюс окремо: якщо переписувач впав, немає чого ревізувати — тому оновлення не переписується, а вердикт на перепис не береться.
Перевірено тестами (tests/test_gate_bypasses.py, 11 тестів): добра корекція публікується; впала перевірка не публікує нічого; впала перевірка не відкликає статтю; голодна перевірка блокується; низька впевненість блокується; порожній текст блокується; причина блокування записується; набір вердиктів корекції — окремий.
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, щоб зв'язок «історія → які етапи її опрацювали» був у базі, а не в пам'яті.
Один story, який кинув виняток, збивав увесь цикл: cycle.finished не записувався, і в журналі з'являвся розрив — 105 cycle.started і 103 cycle.finished. Причина не в самій історії, а в тому, що цикл не мав except навколо story-циклу.
Виправлено: except Exception навколо опрацювання історії, finally — звільнення claim'у. Одна історія, що впала, більше не тягне за собою цикл; її помилка потрапляє в summary["errors"] як запис, а не як відсутність запису.
Це та сама думка, що в §3ter.4quater X, але навпаки: там компанія виглядала повною, будучи неповною. Тут компанія виглядала неповною — і була неповною по-справжньому. Обидві половини тепер закриті: неповний цикл доживає до кінця, а неповний звіт рахує себе.
Жива база показала історію 108 з двома writer-запусками, двома verifier-запусками і двома verifier_2 за чотири хвилини, і вони не згодні: один сказав publish/0.86, другий — revise/0.72 з п'ятьма обов'язковими правками, у тому числі неправильною цифрою тарифу. Стаття опублікувалася на першому вердикті, а суперечність лежала в таблиці runs, де її не побачить жоден читач.
Причина — не в логіці вердиктів. Причина в тому, що два цикли робили одну історію одночасно: планувальник стріляв цикл, людина натискала кнопку, і кожен дістав свій idempotency_key, тому десятихвилинний кошик, який зупиняє другий цикл, між тригерами не працював.
Виправлено: claim на історію береться перед витратою грошей і перед EIC, і звільняється в finally. Другий цикл тепер бачить, що історію вже роблять, і залишає її. Записи в runs_json статті тепер містять prior — усі попередні запуски по цій історії, — тому суперечність, якщо вона виникне, буде видна на сторінці статті, а не тільки в базі.
Це дефект, знайдений вже під час виправлення AC-bis, і він виявився глибшим за те, що здавалося.
Коли одна статті виграє, а інша відхиляється, відкидаються й усі правки, які суворіша перевірка вимагала від відхиленого тексту. Але обидві статті описують ту саму подію. Тобто правка, яка була справедливою для відхиленого тексту, може бути справедливою й для опублікованого — а вона зникла разом із відхиленою статтею.
Живий запис це показує прямо: опублікована стаття 25 каже «мит до 100% на товари з країн, які купують російські енергоносії», а п'ять правок відхиленої статті 26 — серед них «виправити опис закону Грема: 500% на товари з РФ; 100% — на товари п'яти найбільших покупців…» — зникли. Помилка залишилася на сторінці, а інструкція, як її виправити, лежала в таблиці runs, яку читач не бачить.
Перша спроба виправлення зробила це навпаки. Вона читала всі зафіксовані вердикти, а не фінальний вердикт іншого ланцюга. У звичайному flow (verifier(revise) → revision → verifier_2(publish)) перший вердикт уже застосований — переписувач вніс його правки, а повторна перевірка перевірила результат. Тому корекція про «незастосовані правки» з'явилася б на кожній доревізійній статті — і перелічувала б правки, які стаття вже містить.
Тому правило тепер таке:
Незастосована правка — це правка фінального вердикту іншого ланцюга, яка стосується цього тексту.
Три наслідки, кожен з яких перевірено тестом:
run_seq), а не всі підряд: попередні вердикти ланцюга вже відповіли на себе власною ревізією.severity) в Verifier'а: low — це побажання, medium/high — це дефект. У живій базі 31 publishable-вердикт і жодного з них не має обов'язкової правки, тож цей випадок — рідкість, і саме тоді, коли читач має дізнатися.conflict_check), і публікуються лише ті, що підтвердилися.Третя умова — це те, що робить механізм безпечним. Без неї редакція казала б читачеві, що стаття хибна там, де вона права: хибне твердження, опубліковане від імені точності, — воно гірше за мовчання, яке замінило. Перевірка закривається в бік відсутності корекції: впала перевірка, голодна перевірка, нерозбірливий JSON — корекція не публікується. Ціна пропущеної корекції менша за ціну вигаданої.
Перевірено тестами (tests/test_conflict_fixes.py, 21 тест): звичайний flow не породжує корекції; суперечність породжує; зауваження, що не стосується тексту, не породжує; впала й голодна перевірка не породжують; помилка перевірки записується, а не ковтається.
Клас §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.5ter показав, що аудит може бути правим, а система — брехати про те, що вона його зробила. Цей розділ — те саме, але в узагальненому вигляді: це двадцять п'ять окремих дефектів, знайдені на живій платформі, кожен з яких виглядав як робочий код.
Спільна їхня природа: вони всі — про те, як система говорить про себе. Кожен записує «перевірка пройшла» там, де перевірка не дійшла, «думка підтверджена» там, де її не читали, «зауваження перевірено» там, де відповідь прийшла в іншому форматі. Жоден із них не падає. Усі вони тихо брешуть.
Платформа зупиняє прогін на бюджеті кроків, ставить 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. Стеля відновлень — дві: прогін, який продовжується безкінечно, — це прогін, що не сходиться.
Навіть якби дефект 1 був виправлений, аудит усе одно не відповів би: на звичайній стелі етапу він витратив усі чотири кроки на читання і не дійшов до відповіді. Перевірка, яка не може завершитися, — це не перевірка; вона просто не існує.
Лікування: аудит отримав власний бюджет (AUDIT_MAX_COST_USD, AUDIT_MAX_TOOL_CALLS, AUDIT_MAX_STEPS) — як і друга перевірка, бо вони обидві мусять прочитати джерела і відповісти.
Промпт просить 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).
Жива відповідь прийшла у зворотному порядку і пронумерувала 1..5 за своїм порядком. Тобто її «finding: 2» вказувало на наше зауваження 4. Ми довіряли номеру — бо промпт просить саме його — і мали б закрити підтверджене зауваження не проти того рядка.
Ключ, який можна перевірити, — цитата: це фрагмент опублікованого тексту, який ніхто не має підстав переписувати. Номер — лише запасний варіант, і тільки тоді, коли відповідь сама не перенумерувала зауваження (перевіряється тим, чи збігається її номер із зауваженням, на яке вказує її ж цитата).
bool("false") — це True. Відповідь із "confirmed": "false" была б прочитана як підтвердження, і редакція опублікувала б корекцію за зауваження, яке друга перевірка щойно відкинула.
Лікування: прапорець читається як булево, рядок, число чи українське «так»/«ні»; те, що прочитати не вдалося, — це відсутність відповіді, а не відповідь.
Аудит пише поле fix як замальовку для себе: «звірити ставку з текстом закону; якщо підтвердження немає — прибрати цифру». Це не речення для читача. А ми саме це і публікували.
Лікування: текст корекції пише друга перевірка — вона перевіряла первинне джерело, і саме її формулювання є твердженням про факт. Аудитовий fix лишається в записі, де й належить бути, — у журналі зауважень.
severity була вигадкою агентаЦе був найглибший дефект, бо він робив решту ланки мертвою. 39 із 43 зауважень аудиту повернулися як low. Друга перевірка запитується лише про зауваження серйозніші за поріг — отже, вона майже ніколи не викликалася, і справжнє зауваження лежало на сторінці без відповіді.
Причина не в лінь і не в помилковості агента: промпт ніколи не пояснював, що означають ці слова. Агент вигадав власну шкалу, у якій майже все — low.
Лікування: шкалу визначено прямо, і визначено через питання, яке має сенс: «чи змінило б це те, у що повірить читач?» low — ні; medium — читач дістав би хибне уявлення про факт (неправильна цифра, неправильна атрибуція, пропущена суттєва частина закону, згадка однієї країни замість іншої); high — хибне уявлення про те, що сталося або хто це зробив; critical — насильство, смерті, війна, великі гроші.
Після цього шкала запрацювала одразу: аудит статті 25 підняв до medium зауваження про пропущену іранську складову (і залишив стилістичні low), друга перевірка викликалася вперше, і зауваження було відкинуте з обґрунтуванням і посиланням на первинні документи.
Три різні речі звучать однаково, якщо не розрізняти їх: «зауваження підтверджене», «зауваження відкинуте» і «ми не змогли перевірити». Перша веде до публічної корекції, друга закриває зауваження, третя лишає його відкритим — і відкрите зауваження видно читачеві.
Агент, який відповів без поля confirmed, нічого не вирішив. Прочитати його як «не підтверджено» — значило закрити зауваження, з яким друга перевірка погодилася.
Відповідь може підтвердити те, чого в статті немає: обрізаний фрагмент речення, твердження, якого матеріал не робив. Таке підтвердження — не підтвердження цієї статті, а підтвердження чогось іншого.
Лікування: цитата підтвердження має бути дослівно в опублікованому тексті (заголовок, dek або тіло). Не знайшена — зауваження лишається відкритим.
Підтверджене зауваження мало створити корекцію, але без перевірюваних посилань. Джерела лежали в полях, які ми не читали, а коли лягли в evidence — у полі, яке містило два URL в одному рядку, розділені крапкою з комою.
Лікування: витягуються всі URL — з поля підтвердження, з верхнього рівня, поодинці й парою.
Арбітр — це агент, який замінює людину, коли платформа спиняє 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 на адресу, яка повертає порожнечу. Тепер правило перевіряє, чи відповідає адреса, і не називає канал, який не відповідає.
Коли арбітр нарешті відповів 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 від попереднього тунеля, який у логу не зустрічався жодного разу. Кожен файл був правий про свій номер порту й неправий про чужий.
Лікування:
Тринадцятий дефект знайдений не аналізом, а наслідком: я сам перезапустив сервер, поки цикл ішов.
Рядок циклу пишеться 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 з причиною. Новий цикл почався відразу.
Чотирнадцятий дефект знайдений тим самим способом: я подивився на чергу після того, як виправив тринадцятий. І побачив у базі 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 запропонованих історіях.
П'ятнадцятий дефект знайдений перевіркою маршрутів після того, як смерть циклу перестала бути рідкістю: 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, тепер:
| Було | Стало | |
|---|---|---|
| mission | failed | completed |
| об'єктив | failed, strikes=1 | completed, **verified: true** |
abort_reason | budget_exhausted | null |
| root causes | budget_exhausted | null |
Це 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 (ніхто не ухвалив рішення — і це видно).
Шістнадцятий дефект знайдений не тестом, а запитанням до власного журналу: чому файл <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, що й п'ятнадцять попередніх, тільки на животі: запис у базі має означати те, що сталося насправді. Тут — запис у файлі стану про канал.
Лікування — три речі, і кожна з них потрібна:
start_new_session=True, щоб тунель не був дитиною запускача й не отримував його сигналів. Перевірено на живій платформі: підпроцес піднімає тунель і негайно виходить — тунель лишається живий.mcp_ok пишеться з живої перевірки каналу, а не «як було востаннє, коли хтось подивився». Тунель, який не відповідає, записується як мертвий.І третє має ще дві частини, без яких воно не працює:
active на двері, які відкриваються в 530.І четверта частина, про яку треба сказати окремо, бо вона з'явилася під час лікування: тунель, який ще піднімається, не можна вбивати наступним. 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
При лікуванні шістнадцятого дефекту з'ясувалося, що сама архітектура запису була розсипаною на три незалежні місця:
scripts/companion.py мав власну копію логіки тунеля, власну копію patch_mcp, власну копію allow_site_on_tenant, власну копію attach_mcp_to_agent і власні mcp_api_key / mcp_server_id;<data>/companion_state.json напряму, через свій власний COMPANION_STATE_PATH.Тобто правило «запис про канал» було написано тричі, і кожне — трохи інакше. Коли я зібрав усе в app/channel.py, з'ясувалося, що три правила з трьох файлів кажуть про allowlist, і всі три виводять host по-своєму. Різниця в один символ — і агент може викликати сервер, але не може відкрити сайт.
Лікування: одне місце (app/channel.py) — одна реалізація, і всі інші файли її викликають. Це не косметика: дві копії одного правила розходяться, а розходження в правилі про доступ — це доступ.
Сімнадцятий дефект знайдений випадково — і це, власне, говорить про нього найбільше. Після того, як шістнадцятий дефект був виправлений, я запустив тести, щоб перевірити, що нічого не зламалося. Тести пройшли. Потім я пішов перевірити канал — і його не було.
$ 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:. pkill -f за шаблоном убиває кожен процес, у командному рядку якого є цей рядок. Не той, який наш. Усіх, схожих на нього.
І тест test_a_started_tunnel_survives_its_starter — той самий, що перевіряє, чи переживає тунель свого запускача — піднімав справжній тунель. Його start_tunnel() спершу робив stop_tunnel(). Тобто:
start_tunnel() викликає stop_tunnel();stop_tunnel() убиває всі тунелі, що відповідають шаблону;Після цього запис про канал продовжував брехати: 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 до і після повного прогону тестів. Раніше це було не можливо: прогін тестів зупиняв канал, і супервізор піднімав новий — тобто кожен прогон тестів переписував адресу, за якою платформа керує редакцією.
Загальне правило, яке з цього випливає: будь-яка дія, яка вбиває або перезаписує щось у спільному середовищі, має бути обмежена правом власності. Шаблон — це припущення («усі, схожі на нього»). Право власності — це факт («цей конкретний процес — мій»). Редакція, яка б'є чужі процеси, зрештою б'є і свої.
Восімнадцятий дефект знайдено на живій платформі, о 10:50:29Z, і він найпростіший з усіх — бо він не «не помітив», а помітив неправильно.
Ось що показав журнал:
10:50:29Z "mcp_ok": false, "tunnel_failure": "failed"
10:50:53Z "mcp_ok": true
Двадцять чотири секунди. Запис пояснив собі, що канал мертвий, — і потім, через двадцять чотири секунди, вирішив, що живий. Але в ці двадцять чотири секунди той самий тунель:
200 OK на /health;POST /mcp/ 200 OK від Cloudflare;pgrep -fl cloudflared показував його PID.Тобто редакція засудила канал, який відповідав. Не помилка констатації — помилка висновку: одна невдала перевірка дорівнює «каналу немає».
А що робить супервізор із «каналу немає»? Піднімає новий. А новий тунель — це новий hostname, а новий hostname — це перереєстрація каналу на платформі, в обох агентів і в allowlist тенанта. Тобто одна повільна відповідь робила три переписування адреси, за якою плаформа керує редакцією, — і кожне з них створювало вікно, у якому плаформа вказувала на двері, яких ще нема.
Лікування — поспільність, а не одноразовість. Тунель засуджується не першою невдалою перевіркою, а третьою поспіль. Лічильник скидається в нуль щойно тоді, коли тунель відповів, — тобто правило каже «поспіль», а не «загалом». А різні збої — різні за вагою: процес, який зник (pid мертвий), — це факт і важить вдвічі; перевірка, яка не встигла (таймаут), — це припущення і важить одиницю. Тому канал, який реально втратив тунель, засуджується за дві перевірки, а канал, який один раз не відповів, — не засуджується взагалі.
| Було | Стало | |
|---|---|---|
| Що означає одна невдала перевірка | «каналу немає» | «одна перевірка не вдалася» |
| Коли тунель замінюється | після першої невдачі | після трьох поспіль (дві, якщо процес мертвий) |
| Як це видно в записі | mcp_ok: false | tunnel_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 — і тунель не замінюється.
Загальне правило, яке з цього випливає: перша невдача — це припущення, а не вирок. Система, яка за першою невдачею руйнує те, що працює, зрештою руйнує все. І жодна дія не має забирати те, що працює, раніше, ніж доведено, що заміна існує.
Дев'ятнадцятий дефект знайдено на живій платформі, о 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
І три наслідки, які вимагали окремих правил:
tunnel_failure, tunnel_failure_detail, tunnel_failure_at переможує диск — це новіша факта, а копія в руках старіша за неї.tunnel_strikes: 8 про канал, який не існував, — це запис, який бреше про річ, якого немає.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.
Загальне правило, яке з цього випливає: копія — не запис. Система, яка приймає рішення за копією, рано чи пізно вирішує за тим, що було колись. А в автономній компанії це не «трохи запізно» — це нескінченна відмова, яка щохвилини витрачає ресурс, який потрібен для відновлення. Запис на диску — це єдине місце, де живе правда; усе інше — похідні.
Двадцятий дефект знайдено на живій платформі, о 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:05Z | 463 | Викликано run_newsroom_cycle рівно один раз | all_objectives_verified |
| 09:35Z | 463 | те саме | judge_rejected (зміст) |
| 10:35Z | 463 | те саме | judge_rejected (зміст) |
| 11:01Z | 1059 | Відповідь містить рядок «run_newsroom_cycle: викликано рівно 1 раз» | **judge_response_truncated** |
| 11:31Z | 1059 | те саме | judge_rejected (SSRF) |
Довжина 1059 з'явилася разом із першою критерією, яка вимагала перевірити точний запис рядка. І саме на ній суддя обрізав вердикт. Усі інші вердикти — і judge_rejected, і успішний — були читабельними вердиктами, тобто суддя встиг думати й встиг написати.
Логіка проста, і вона пояснює все: суддя розмірковує і пише вердикт у тих самих 1024 токенах. Критерія «у відповіді є рядок «...»» вимагає від судді думати — це питання на зіставлення, а не на пошук. Критерія «run_newsroom_cycle викликано рівно один раз» вимагає знайти — це перевірка факту. Перше з'їдає бюджет вердикту; друге — ні.
Лікування — коротка критерія, довідка адреси. Правило тепер таке:
run_newsroom_cycle рівно один раз» — це факт, який можна знайти;run_newsroom_cycle викликано рівно один раз, і наведи cycle_id нового циклу».| Було | Стало | |
|---|---|---|
| Довжина опису | 1059 | 829 |
| Перша критерія | «Відповідь містить рядок «...»» | «Викликано 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_id1c582d01a65846a1a534893e3bbbb39c**, статусcompleted…reused: true— це та сама одинока спроба, а не дві.
Суддя прочитав це за секунди і написав **verified. Одинадцять відмов поспіль закінчилися першою читабельною** відповіддю.
І рядок, який тепер читається як доказ, а не як переповідь:
| Час | Довжина опису | Перша критерія | Вердикт |
|---|---|---|---|
| 11:31Z | 1059 | «Відповідь містить рядок «...»» | judge_rejected (SSRF) |
| 12:09Z | 829 | «Викликано 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.
Загальне правило, яке з цього випливає: суддя — це теж ресурс, і його бюджет — не наш. Коли ми даємо перевірячу більше тексту для роздумів, ми не робимо перевірку точнішою — ми робимо її дорожчою за числом, яке вона може пережити. Правило просте: дай судді факт, а не рядок для зіставлення. Якщо критерія вимагає від перевіряча думати, а не знаходити — вона надто довга.
Цикл записує свій підсумок рівно один раз — наприкінці, у 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, ...}
— задовго до того, як він завершився. До лікування цей рядок був порожнім до самого кінця, а після перезапуску — порожнім назавжди. Тепер запис про роботу з'являється тоді, коли робота стає тривкою.
Арбітр і вартовий обидва обходять платформу перед кожним циклом. Арбітр — щоб відповісти на те, що чекає на людину. Вартовий — щоб знайти запуски, які застрягли. І обидва здавалися на першій же помилці, і записували статус, який не вдалося прочитати, як «непрочитаний» — факт про платформу, який ішов у підсумок циклу, а звідти — у звіт редакції про саму себе.
Але помилка з'єднання — це не відповідь. Це мережа, яка погано живе секунду, і це рівно той випадок, коли треба запитати ще раз. А 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
Старий код на тому самому виклику здавався після першої спроби — тобто «непрочитано» означало «не прочитано з першого дотику».
Чому це частина стандарту. Двадцять другий дефект — це дефект різниці між помилкою та відповіддю. Система, яка не розрізняє їх, має два шляхи: або вона здається на першій помилці й бреше про платформу («недоступна»), або вона повторює все підряд і тягне час на те, що вже ніколи не стане відповіддю. Перше — тиша, друге — дурне очікування. Обидва — неправда.
Правило, що з цього випливає: помилка мережі — це не відповідь, а привід спробувати ще раз; відповідь — це те, що повтор не змінить.
Той самий дефект, що й у двадцять другому, — але з боку каналу, і він був у коді, який писали після лікування арбітру.
Наглядач каналу перевіряє, чи живий тунель, і записує результат. Одна перевірка — один запит. І ось що це означало на живій платформі:
| час | що показала перевірка | що було насправді |
|---|---|---|
| 12:32:09Z | mcp_ok: false, tunnel_failure: failed | тунель обслуговував запити |
| 12:32:36Z | mcp_ok: true | те саме |
| 12:35:04Z | mcp_ok: false, tunnel_failure: failed | те саме |
| 12:35:17Z | mcp_ok: true | те саме |
Повідомлення було одне й те сале: ConnectError: [Errno 8] nodename nor servname provided. А десяток перевірок за секунду після цього відповідав за 0,12–0,28 секунди. Ім'я розв'язувалося правильно — → 104.16.230.132.
Тобто канал був живий, а запис двічі сказав, що він мертвий. І гірше: запис **tunnel_strikes залишався 0, бо лічильник рахує перевірки, а не спроби. Тобто той самий лічильник, який існував саме для того, щоб відрізняти «мить» від «каналу», рахував удачу**.
Лікування — повтор усередині самої перевірки. Питання «чи живий канал?» ставиться один раз, але відповідь шукається тричі, з паузою 1 с, потім 2 с. Одна невдача — мить. Три поспіль — канал. І лічильник ударів тепер рахує щось осмислене.
Це той самий принцип, що й у двадцять другому, — тільки тепер він поширився на ту частину системи, яку ми перевіряли самі. Колись ми виправляли «факт про платформу» й не подумали, що такий самий факт про себе ми пишемо в кожну перевірку каналу.
Перевірено на живій платформі. Після правки: перевірка, яка раніше могла впасти з першого дотику, повертає mcp_ok: true — бо перша спроба, що впала, більше не є вироком.
Чому це частина стандарту. Двадцять третій дефект — це дефект симетрії. Ми виправляли «система бреше про платформу» й не помітили, що система бреше і про себе. А канал — це те, що ми перевіряємо найчастіше і найприскіпливіше: одна невдала перевірка каналу — це ті два «страшні дефекти» з вісімнадцятого серії (засудити те, що працює, і затерти власну адресу), тільки завжди. Канал не гинув — але міг загинути від одного дотику розв'язувача.
Правило, що з цього випливає: прилад, який вимірює, сам має терпти до миті. Вимірювальний прилад, який ламається від одного кліку, не вимірює — він генерує події.
Місія о 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_cycle | 2015 | 6 819 | 8 834 |
list_newsroom_cycles (limit=10) | 2016 | 27 710 | 29 726 |
list_newsroom_cycles (limit=2) | 2015 | 9 880 | 11 895 |
get_newsroom_cycle | 2015 | 6 820 | 8 835 |
Чотири різні виклики — одна й та сама перша сторінка. Перелік містив десять циклів. Агент бачив один — той, що саме працював. І він сказав правду: «інструменти повернули лише цей самий цикл».
А ось чому це не «просто завеликий відповідь», а дефект із конкретною причиною. Платформа склеює блоки вмісту інструмента без жодного роздільника і обрізає вже склеєний текст. Тому інструмент, який повертає список, дає по одному блоку на елемент, приліплені один до одного. Перший елемент витрачає весь бюджет — і всі інші елементи зникають до того, як агент їх побачить. Перевірка це підтвердила: list(limit=2) дав рівно 8835 + 3060 = 11 895 — сума двох блоків, обрізана на 2000.
Тобто формат результату — це не стиль, а механіка доставки. І поки ми повертали повні підсумки циклів, ми віддавали агентові примітку з обрізаним краєм, яка виглядала як відповідь.
Лікування — три кроки, і перший важливіший за два інші.
Перевірено на живій платформі. Після правки list_newsroom_cycles повертає один блок у 1696 символів, без позначки обрізання, і агент бачить усі десять циклів. get_newsroom_cycle — 1768 символів, цілим.
Чому це частина стандарту. Двадцять четвертий дефект — це дефект форми, а не змісту. Дані були правильними, інструмент працював, сервер відповідав 200 OK, а агент все одно не бачив відповіді. Ми будували систему, яка розповідає агенту, що вона бачила, — і не помітили, що те, що вона розповідала, було обрізане на 96%.
Правило, що з цього випливає: відповідь має вміщатися в те, що може прочитати той, хто її читає. Те, що не вміщається, — це не відповідь, а початок відповіді. І найважливіше: обрізаний результат має сам про себе сказати, що він обрізаний — інакше той, хто читає, найменше зможе запідозрити, що чогось не бачив.
Місія о 13:32Z після правки пройшла: all_objectives_verified, «All 1 objectives verified». Але перш ніж рахувати перемогу, варто було запитати: що саме платформа доставила цього разу. Поточковий запис (export_run_events) відповів:
| виклик | блоків | символів | обрізано |
|---|---|---|---|
run_newsroom_cycle | 1 | 139 | ні |
list_newsroom_cycles | 1 | 1682 | ні |
Перелік виправився. Запуск — ні. І саме він у пам'ятці про провали: objective каже «Виклич MCP-інструмент run_newsroom_cycle з limit=1» першим. Тобто інструмент, який mission викликає першим, лишився невиліченим — і це був недолік форми, а дефект змісту.
Ось у чому він полягав. run_newsroom_cycle повертав весь запис циклу разом із підсумком. Для завантаженого циклу 8eb06575… це:
reused стояв на символі 27 591.Тобто reused не доходив до агента ніколи. А reused — це відповідь на питання «це одна спроба чи дві?», тобто рівно те, про що objective просить агента відзвітувати: «reused=true — це та сама однока спроба, а не дві». Інструмент не давав агентові побачити поле, без якого той не міг скласти речення, яке від нього чекали. Результат, що ховає відповідь на запитання, — це не менший результат, а хибний.
Це й була справжня вага дефекту 24. Ми його назвали «перелік не вміщається в бюджет» — і виправили перелік. А поломка була в чотирьох інструментах, а ми починили два. Місія пройшла не тому, що поломку виправили, а тому, що цього разу цикл почався заново (reused=false, 139 символів) і в.result просто вмістився. Наступний виклик у тому самому десятихвилинному вікні пішов би шляхом повторного вживання — і приніс би 27 607 символів, з яких агент побачив би перші 2 015. Тобтоmission, що пройшла, була щасливим випадком, а не доказом.
Другий дефект у тому ж місці — guard, що рахував не те, що треба. Захист watchdog'а лімітував звіт десятьма записами («кожен запис близько 150 символів, тож тринадцять вміститься, а чотирнадцятий — ні»). Але десять записів із справжніми id запусків і агентів коштують 2 651 символ — тобто той захист пропускав звіт за межу бюджету, а примітка «показано 10 з 20» обрізалася першою з усіх полів, бо стояла в кінці. Захис, який вимірює не те, гірший за захис, якого немає: він виглядає так, ніби проблему вирішили.
Лікування — три кроки, і перший з них про інструмент, а не про результат.
app/mcp_budget.py. Кожен інструмент, що має вміститися, міриться ним. Бо до того кожен із чотирьох виростив своє приватне правило «що вміщається», і два з чотирьох були хибними. Спільна константа — це не DRY, це єдина правда про обмеження платформи.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_watchdog | 203 | ні |
І 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, і це було не доказом того, що поломку виправили: вона впала на новому циклу, де результат навпаки був малий. Доказом є замір результату на найгіршому вході — а ми його не зробили, доки не запитали поточковий запис платформи знову. Правило, що з цього випливає: після лікування треба перевірити не «чи пройшло», а «що саме платформа доставила» — і перевірити на найгіршому вході, а не на вдалішому.
Вісім речей, зроблених у цій сесії, — сторінка, специфікація, правило доступу, головний редактор і чотири правдиві звіти — пов'язані однією думкою, і вона та сама, що в §3bis.10.
Твердження, яке не можна перевірити, — це не доказ, а декларація. Манифест без посилань на записані факти — це реклама. Профіль розкриття без implemented_by — це папір. Заява «ми не віддаємо незавершене», яку можна обійти, — це не безпека, а бажбан.
Тому кожна з восьми речей побудована так, щоб її можна було перевірити сторонньою людиною без довіри до нас: числа манифесту рахуються з бази, клаузи профілю — з коду, правило доступу — з поведінки ендпоінта, захист MCP — з коду платформи, рішення EIC і найм — з журналу, у якому видно і причину, і ціну, а звіти watchdog, розкладу й черги — з того, що вони кажуть, коли не знають, а звіт циклу — з того, що його числа сходяться.
І третій наслідок, який важливіший за реалізацію. Коли ми сховали б відхилене, ми б покращили видимість і зруйнували довіру. Найпривабливіша версія будь-якої з цих восьми речей — та, що робить компанію красивою для себе. Правильна — та, що робить її перевірюваною для когось іншого.
Але є один наслідок, який важливіший за всі вісім разом. Компанія, яка сама наймає собі агентів, — це компанія, у якій може з'явитися агент, який ніхто не замовляв. Тому право на зростання не можна дати «загалом» — можна дати лише через закритий список ролей, одна роль — один агент і never_allow на весь agent factory при створенні. Саме це робить самонайм можливим, не перетворюючи його на ризик: компанія може змінити себе, але не може змінити правило, за яким вона це робить. Асиметрія — «EIC може нарощувати екіпаж, нарощений агент — ні» — це не деталь реалізації; це те, що робить можливим автономну компанію взагалі.
Ми не конкуруємо з людськими редакціями за швидкість (Acute виграє за рахунок Brave→live за 90 секунд) і не за обсяг (Chronic Internet: 6 086 статей). Ми конкуруємо за довіру.
Наш унікальний актив — перевірюваний процес:
Жоден конкурент цього не показує. Machine Herald заявляє «cryptographically signed» — і не показує протокол. Chronic Internet має «Copy Desk quality gate» — і не показує критеріїв. Ми — едині, хто показує.
Це і є позиція: не «AI-контент», а «доказовно агентський контент». AI — деталь реалізії, а не бренд.
Три речі, які разом утворюють стандарт:
За BCG, AI-only фірма з'явиться в digital-native секторах, і бар'єри — не модель, а координація, пам'ять і правосуб'єктність. Жодна юрисдикція не визнає AI особою.
Тому перша AI-редакція у світі має зробити дві речі одночасно:
І третя, яку конкуренти не роблять: випустити специфікацію, за якою це можна повторити. Стандарт — це не те, що ми перші зробили; це те, що ми перші описали. Методологія + публічний аудит + (P2-2) disclosure profile = інваріант, який інші можуть прийняти. Саме це робить гілку, а не один продукт.
Портали й дизайн
AI-розкриття і довіра
Provenance
Автономні компанії
Конкуренти
Індустрія