Мой продукт — программа для ретуши и плагин для Photoshop с локальной нейросетью. Чтобы выпуская программу и обеспечить её безопасность, я выстроила десять слоев безопасности: защищённый канал между плагином и системной службой, регистрация устройства, подпись артефактов с меткой времени, аттестация сборки, канал обновлений, шифрование модели, водяной знак. Части написаны на C++ и Go, живут в разных процессах и ставятся разными установщиками.

Каждая такая граница — это не модуль, а договорённость двух сторон: одно имя службы, одна версия протокола, один набор маршрутов, одна строка подтверждения. Сейчас 54 связи на этих границах держат 188 сравнений значений (468 мест в коде) и 105 сравнений множеств. Компилятор не видит ни одного из них: стороны написаны на разных языках.

Я назвала такие места швами — точками, где разные компоненты должны точно совпадать:

  • маршрут, который вызывает клиент и регистрирует сервер;

  • заголовок с версией протокола;

  • поля DTO в Go и TypeScript;

  • переменные окружения, которые читает сервис и которые указаны в .env.example;

  • правило округления цены, реализованное на двух языках.

Типы, тесты и линтеры хорошо работают внутри одного языка, но такие договорённости обычно не проверяют. Они держатся на памяти разработчиков.

Инструмент называется Crossweft (уток, weft, — поперечная нить в ткани: она проходит через все продольные и держит полотно вместе).

С чего все началось

Разработку я начала в январе этого года. За это время я потратила на подписки больше $9 000 — по самым скромным прикидкам это больше миллиарда токенов. И всё это время я не понимала, почему у меня не сходится безопасность. Сама программа работала прекрасно, а соединить слои защиты не получалось. Я даже сделала отдельное приложение, которое следило за «сердцебиением» слоёв, чтобы свести их вместе, — но и это было безуспешно.

Под капотом у программы четыре части: клиент, системная служба, бэкенд и установщики. И вот три случая, после которых у меня наконец сложилось понимание, как свести агентскую разработку воедино…

Один идентификатор — три копии. Имя службы определяли в трёх почти одинаковых функциях. На чистой машине служба ещё не была установлена, поиск заканчивался ошибкой, и пустая машина выглядела как старая несовместимая установка — установщик останавливался с кодом 76. Исправила одну копию — две другие остались сломанными.

Две половины одной зависимости. Сборка брала библиотеку из одного каталога, а связанные с ней инструменты — из другого. Конфигурация только предупреждала, и проблема проявилась гораздо позже, там, где причину искать было уже трудно.

Сторож, который ничего не сторожил. Валидатор одного из правил безопасности из-за ошибки в пути несколько недель проверял ноль файлов и каждый раз говорил OK. Проверка проходила именно потому, что не выполнялась.

Причина у всех трёх одна: у договорённости две или три стороны, а их совпадение держится на внимании людей.

При чём тут AI-агенты

Значительную часть кода у меня пишут агенты, в основном Claude Code и Codex. С межкомпонентными связями они справляются хуже людей.

Типичный сценарий: агент меняет Go-структуру, тесты зелёные, агент пишет «Готово» — и даже не открывает TypeScript-интерфейс на другой стороне.

Причины одни и те же:

  • Локальные правки. Вторая сторона шва лежит в другой папке, на другом языке, в другом процессе.

  • Ограниченный контекст. Агент не держит в голове весь продукт, а компилятор не скажет ему, что где-то есть клиент, зависящий от изменённой структуры.

  • Узкое «готово». Для агента это «все проверки, которые я вижу, прошли». Если ни одна не смотрит на шов, расхождение незаметно до поломки.

Зато агенты хорошо выполняют точные локальные инструкции. Если сразу после правки сказать, какой файл на другой стороне открыть и что там должно совпасть, агент это сделает. А если не отпускать его, пока стороны расходятся, ошибка не попадёт в коммит.

Идея: каждая связь сама говорит, как её проверять

Правило простое: каждая связь между компонентами объявляет, как её стороны согласуются. Если договорённость поддерживается вручную с обеих сторон, у неё должен быть сторож — проверка, которая читает обе стороны.

Связи описываются в JSON рядом с кодом. В карте есть блоки — программы, сервисы, хранилища, конфиги — и связи между ними: HTTPS, named pipe, файлы, генерация кода, переменные окружения. Каждое утверждение карты привязано якорем к конкретному литералу в коде: если код переехал, карта не продолжает молча описывать прошлое — проверка падает.

У каждой связи есть поле contract.enforcement:

Способ

Кто держит равенство сторон

Чего требует проверка

shared-code

символ, который импортируют обе стороны

defined_in указывает символ

generated

генератор из одного источника

defined_in указывает источник

schema-tests

общие тестовые векторы

defined_in указывает векторы

duplicated

никто, копия руками

сторож, читающий файл на каждой стороне

convention

никто, договорённость

сторож, читающий файл на каждой стороне

none

никто

сторож, читающий файл на каждой стороне

Уже сама классификация полезна: вопрос «где я полагаюсь на чью-то память?» превращается в список, который можно посчитать. Сторож, читающий одну сторону, не засчитывается: инструмент проверяет, что он читает файлы двух разных концов связи.

Три сторожа: join, set и pair

Сторожа намеренно простые: регулярные выражения, сравнение значений или множеств. Проверка занимает миллисекунды, не требует сборки и работает с любым языком.

join — значение совпадает везде. Версия API в TypeScript-клиенте и в Go-сервере:

{"id": "api-version", "link": "web-orders", "points": [
  {"path": "web/src/api.ts", "side": "web", "regex": "API_VERSION = \"([^\"]+)\""},
  {"path": "server/main.go", "side": "api", "regex": "APIVersion = \"([^\"]+)\""}]}

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

set — состав совпадает. Поля TypeScript-интерфейса и JSON-теги Go-структуры; статусы, которые пишет Python-воркер, и константы в Go; переменные окружения в коде и в .env.example. Намеренное различие вносится в allow с причиной, а ставшая ненужной запись allow сама роняет проверку.

pair — дублированная логика без одного значения. Например, округление цены на Go и TypeScript. Участки помечаются crossweft:begin price-rounding / crossweft:end price-rounding, Crossweft хэширует оба. Изменился любой — проверка падает, пока кто-то не перечитает двойника и не выполнит crossweft attest price-rounding --reason "...". Причина попадает в lock-файл и в ревью.

Самый короткий для агента путь из красной проверки — аттестовать изменённую сторону, не трогая вторую. Поэтому если с прошлой аттестации поменялся только один участок, attest отказывает: сначала приведите в порядок двойника, а если он действительно уже эквивалентен — скажите это явно флагом --other-side-unchanged.

Есть проверки крупнее. Маршруты сервера должны быть на карте — Go chi разбирается напрямую (а также Express и Fastify, FastAPI, Flask, gin и echo), остальное описывается регулярным выражением. Каждый вызов вида /v1/... в клиенте должен объясняться связью. Блок может отправлять данные, только если создал или получил их. Каждая папка с кодом должна быть на карте.

Если встроенных видов мало, репозиторий добавляет свои плагинами. У меня их 13: один сверяет 916 мест, где Go-сервер обращается к SQL, со 161 таблицей и 276 триггерами схемы, другой — 185 маршрутов сервера, третий — 27 пар «ожидающий ≥ работа + запас» для таймаутов. Плагин, который не импортировался, упал или ничего не сравнил, — ошибка, а не зелёный результат.

Что видит агент

Сама проверка — половина решения. Важно, чтобы агент узнал о шве сразу после правки.

Для этого три хука:

  • Старт сессии — короткое описание: в репозитории есть карта швов, сколько их и как работать.

  • После каждой правки — если файл лежит на шве, агенту называют вторую сторону и сторожа.

  • Перед завершением — crossweft check; если шов расходится, хук не даёт закончить и объясняет, что делать.

После правки Go-сервера в демо агент видит:

crossweft: server/main.go is part of 3 cross-component seam(s). Keep both sides in agreement:
- link web-orders (web -> api, https; Orders API [duplicated]): you changed its to side.
  Re-read: web/src/api.ts. Guarded by: join:api-version, join:version-header,
  set:order-fields, set:web-statuses, pair:price-rounding.
...
Run `crossweft check` before you finish.

Поднял версию API в Go и пытается закончить — хук возвращает его:

crossweft check fails:
- join:api-version disagrees: web@web/src/api.ts:3='2026-09-01'; api@server/main.go:14='2026-10-01'
  [server/main.go:14] (key: join:api-version:b5dfb7e5)
A seam is out of agreement: bring the other side in line with the one you changed
(`crossweft impact <file>` names it).

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

У stop-хука две типичные беды — бесконечный цикл и тихий пропуск. Он возвращает агента, только пока тот продвигается (набор падающих ключей меняется), и не больше четырёх раз подряд. Нет прогресса — агент может остановиться, но человек получает явное сообщение, что проверка красная.

Для Claude Code есть плагин с хуками и skill. Для Codex — skill-плагин. Любой агент с поддержкой MCP может подключить crossweft mcp: проверка, анализ влияния правки и «какой файл перечитать» как инструменты только на чтение. Адаптеры хуков для Gemini CLI, Copilot и Cursor пока экспериментальные: проверена форма вывода, а не полный сценарий.

Проверки, которые не могут молча пройти

Третья история — валидатор, который неделями сканировал ноль файлов. Не каждое правило сводится к сравнению двух значений: иногда нужно доказать, что у константы один источник. Такие правила живут в маленьких скриптах, и именно они ломаются незаметно.

Мета-раннер crossweft validators считает валидатор провалившимся, если тот вернул ненулевой код, не вывел SCANNED: files=<n> items=<n> или вывел нули, не прошёл self-test с подложенной ошибкой (строка SELF-TEST: checks=<n>) или не уложился в таймаут. Честно неприменимый валидатор выводит APPLICABILITY: <причина> — это SKIP, а не PASS.

Тот же принцип у самого Crossweft. Три исхода, а не два: 0 — сошлось, 1 — расхождения, 2 — проверка не состоялась (модель не читается, ничего не просканировано). Код 2 никогда не выглядит как успех.

Реестр расхождений, который не гниёт

Не каждое расхождение можно исправить в том же PR. Тогда его записывают как finding: ключ, владелец, следующий шаг. Пока проблема воспроизводится, проверка проходит; когда её исправили, запись становится протухшей и сама роняет проверку. Сломанные якоря finding’ом не прикрыть: карта не имеет права описывать код неправильно.

Для внедрения в репозиторий, где расхождения уже накопились, есть crossweft baseline: он записывает их как открытые findings с владельцем. Проверка зеленеет, новый дрейф её роняет, а исправленное само требует закрытия.

Что это дало: цифры

С середины июля установщик ни разу не доводил системную службу до запуска на чистой виртуальной машине. Проверяемые швы я добавила 26–28 сентября, 29 сентября служба впервые запустилась. Это совпадение по времени, а не доказательство причины: параллельно чинилось и другое.

Ошибки все равно остались но их характер изменился. Три из первых падений 28 сентября были именно швами: код выхода 76 (имя службы в трёх копиях), отказ сервера, потому что загрузчик регистрировался по версии артефакта, а запрашивался по версии протокола, и HTTP 500, потому что два пути публикации убирали старый пакет, а третий — нет. Теперь каждое закрыто отдельным сторожем. Две следующие проблемы были уже обычными багами, а не «одним значением в нескольких местах».

Что показывает карта (пересчитано по истории репозитория):

27.09

29.09

4.10

Блоки / связи

130 / 160

134 / 166

138 / 192

join-сторожа

10

220 (529 точек)

261 (644 точки)

set-сторожа

4

125

138

Реестр расхождений

58, все открыты

106: 93 закрыто, 10 открыто, 3 приняты

146: 117 закрыто, 25 открыто, 4 приняты

Первый проход по правилу «у каждой связи объявлен способ согласования» сделали сами агенты: за день добавили 185 join- и 107 set-сторожей и нашли 15 настоящих расхождений в коде. Из 93 закрытых расхождений у 91 есть исправляющий коммит, и в 58 из них сообщение называет идентификатор из карты.

Карта без проверки устаревает за неделю. Первый замер через неделю показал 96 проблем, и 94 из них — устаревшая карта, а не баги кода. Поэтому карта проверяется на каждой остановке агента. Валидаторов стало 50 вместо 4 в июне, и каждый доказывает, что действительно что-то просканировал.

Выход в свет

Когда безопасность у меня наконец сошлась, я поняла: наверняка есть куча людей с такими же проблемами. У всех, кто пишет продукт на нескольких языках вместе с агентами, швы расходятся одинаково. Значит, это может пригодиться не только мне. Так Crossweft стал отдельным открытым инструментом: командой crossweft, плагином для Claude Code и Codex и сервером MCP для остальных агентов.

А разве такого ещё нет?

Каждая часть по отдельности существует. Сочетания и правила «каждая связь объявляет способ согласования, и каждый рукописный шов имеет сторожа» я не нашла.

Инструмент

Что делает

Чем отличается

Google LINT.IfChange, ifttt-lint, if-changed

«изменил блок — измени и тот файл»

проверяют, что дифф задел файл; join сравнивает значения на каждом прогоне

clevis

равенство значения в разных файлах

нет множеств, участков кода и карты

Pact, buf, oasdiff

контрактные тесты и совместимость схем

правильный ответ, если схема есть; Crossweft строит сторожей из OpenAPI и proto для рукописной стороны

ArchUnit, dependency-cruiser, import-linter

архитектурные правила

внутри одного языка

archagent

архитектурные инварианты и skills для агентов

ближе всех по духу, но структура, а не значения между языками

GitNexus, codegraph

граф кода и анализ влияния для агентов, MCP

граф для запросов, а не падающая проверка; дополняют друг друга

fiberplane/drift, Swimm

документация, привязанная к коду

хэш падает на любую правку; якорь — только когда исчез сам факт

Подход придуман не мной. Бригитта Бёкелер описывает harness engineering: «направляющие» до того, как агент пишет код, и «датчики» после; детерминированные проверки против проверок языковой моделью. OpenAI пишет о том же в Harness engineering: leveraging Codex in an agent-first world. Crossweft — детерминированный датчик для межкомпонентных швов, чей вывод говорит агенту, что именно чинить.

Ограничения

  • Регулярки, а не разбор кода. Переформатировали объявление — проверка громко упадёт, регулярку придётся поправить. Но слишком широкий шаблон может пропустить реальное расхождение, поэтому значения стоит просматривать, а поведение проверять тестами.

  • Честность способа согласования проверяет человек. Агенты строят карту, Crossweft не даёт им придумать связь или пропустить папку, но shared-code это или две копии — иногда знает только разработчик.

  • Внедрение стоит труда. На маленьком проекте выгода невелика; окупается там, где языков и процессов много.

  • Пользователь пока один — я. Полностью проверена работа с Claude Code; остальные агенты — через MCP и skill, адаптеры их хуков экспериментальные.

  • check --changed ускоряет pre-commit, но пока не тогда, когда правка задела роутер или клиент.

Попробовать

Быстрее всего — сломать демо (Go-API, TypeScript-клиент, Python-воркер):

pip install "git+https://github.com/AnastasiyaW/crossweft.git@v0.2.0"
git clone --branch v0.2.0 --depth 1 https://github.com/AnastasiyaW/crossweft
cd crossweft/examples/polyglot-shop && crossweft check      # RESULT: PASS
# поменяйте API_VERSION только в web/src/api.ts:
crossweft check                                            # join:api-version disagrees

Каждая строка таблицы проверена на копии демо: проверка падает с кодом 1 и называет нужного сторожа.

Изменение

Кто ловит

поднять API_VERSION только в web/src/api.ts

join:api-version

добавить JSON-поле только в Go-структуру Order

set:order-fields

добавить статус только в Python-воркер

set:worker-statuses

читать в воркере переменную, которой нет в .env.example

set:env-worker-vars

вызвать /v1/refunds из клиента

route:client-unexplained

изменить округление в server/pricing.go

pair:price-rounding

В своём репозитории: crossweft init (init --example — чтобы сначала увидеть рабочий шов), дальше агент строит карту по skill, crossweft check его ведёт. В Claude Code: /plugin marketplace add AnastasiyaW/crossweft, затем /plugin install crossweft@crossweft. В CI: uses: AnastasiyaW/crossweft@v0.2.0 или crossweft check --format sarif для GitHub code scanning.

Репозиторий Crossweft охраняет собственные швы Crossweft’ом: версию в трёх файлах, события хуков, команды из документации.

Crossweft распространяется под Apache-2.0. Issues и pull requests — в github.com/AnastasiyaW/crossweft. Английская версия — happyin.work/blog/crossweft-seams.