G1-NEIGHBOURS — реестры пакетов как данные в покое
01Второй независимый разбор по теме G1-NEIGHBOURS. Предмет — наш каталог
пакетов (JSON-файлы в git-репозитории, которые читают чужие инструменты) на
фоне выживших реестров-соседей. Это задача на суждение, не на постройку: ни одна
строка кода не правилась. Всё, что проверено чтением нашего дерева, помечено
[ИЗ ДЕРЕВА] с файл:строка; всё из FINDINGS-DIGEST.md — [ИЗ СВОДА];
всё из моих знаний о чужих системах — [ПО ПАМЯТИ, НЕ ПРОВЕРЕНО] (без
выдуманных версий, дат и номеров issue, доступа в сеть нет).
0. Что я перепроверил в дереве и где свод неточен
02Прежде чем отвечать на вопросы — фиксирую расхождения со сводом, потому что на них держат часть дальнейшего.
031. Объединение RepomdFileEntry — не «без признака». Свод [ИЗ СВОДА]
утверждает: «Общего поля-признака нет; читатель угадывает по набору ключей.»
Код [ИЗ ДЕРЕВА: crates/vibe-index/src/types/repomd.rs:42-55] показывает
другое: enum и правда #[serde(untagged)], но вариант Directory несёт
явный дискриминатор kind: DirectoryTag, а комментарий в строках 46–48
прямо говорит, что этот тег добавлен намеренно, «чтобы матчер untagged
различал однозначно». То есть «угадывания по ключам» здесь нет — автор
встроил тег в более тяжёлый вариант. Реальная хрупкость иная и тоньше: у
варианта File { size, sha256 } тега нет, поэтому добавление третьего
варианта без kind может попасть под тень формы {size, sha256}. Свод
описал симптом, но неверно назвал причину — а рекомендация «тегировать каждое
объединение» (п. 1 рекомендации свода) для этого места на 90 % уже выполнена,
не хватает тега только в варианте File.
042. «15 мест deny_unknown_fields» — верно. Пересчитал в
crates/vibe-index/src/types/: repomd (1) + relations (6) + entry/mod
(1) + content (5) + aggregate (2) = 15 [ИЗ ДЕРЕВА]. Совпало.
053. «5 закрытых словарей» — я насчитал 4. В типах каталога строковых
enum-словарей ровно четыре: PackageKind
([ИЗ ДЕРЕВА: crates/vibe-index/src/types/kinds.rs:21-34]),
NamingConvention (kinds.rs:88-112), DeliveryMode
(content.rs:53-57), DirectoryTag (repomd.rs:75-77). relations.rs
содержит только структуры. Пятого в vibe-index/src/types/ я не нашёл и
выдумывать не буду — возможно, свод считал по более широкому срезу или считал
дубликат DeliveryMode из vibe-core (subskill.rs:120). Это не критично,
но число в своде не воспроизводится по его же декомпозиции.
064. Спека противоречит не только коду, но и себе — и это сильнее, чем в своде.
Свод [ИЗ СВОДА] поймал трещину: код имеет deny_unknown_fields, а спека
обещает «читатели спокойно игнорируют незнакомые поля». Я нашёл, что в самой
PROP-005 два факта взаимоисключающи:
- 07
FORWARD-COMPAT[ИЗ ДЕРЕВА: vibevm/vibespecs/modules/vibe-index/PROP-005-package-index.xml:302]: «readers of v2 written by an old vibevm gracefully ignore unknown fields», статусimpl/done. NEVER-SILENT-SCHEMA[ИЗ ДЕРЕВА: PROP-005-package-index.xml:624]: «Old consumers parsing a higher schema must surface a "please upgrade" message — not silently parse the subset they understand», статусimpl/done.
08Это одно и то же событие (старый читатель видит запись новой версии) с двумя
противоположными предписаниями. Код [ИЗ ДЕРЕВА: entry/mod.rs:38] делает
третье: жёсткая ошибка разбора (ни «игнор», ни «аккуратное сообщение об
апгрейде»). И главное — поле schema_version [ИЗ ДЕРЕВА: entry/mod.rs:44]
нигде не сравнивается (только пишется как константа = 1, mod.rs:124),
поэтому детектор «higher schema» из NEVER-SILENT-SCHEMA в принципе не может
сработать. Свод увидел одну трещину; их три — и одна внутри самого документа.
095. Толерантность клиента — не «уже правильны», а неполна и притом случайна.
Свод [ИЗ СВОДА] выставляет клиентский путь как имеющийся плюс. По коду:
view-структуры в index_client/wire.rs терпимы к полям (NameEntryView
читает только version, остальное игнорирует; комментарий
[ИЗ ДЕРЕВА: crates/vibe-registry/src/index_client/wire.rs:14-16, 36-39]).
Но те же структуры декодируют kind: PackageKind (wire.rs:57,85) и
BindingSite (wire.rs:94) как закрытые enum без #[serde(other)] —
значит, они ломаются на новом значении словаря. Это именно та неудача,
которую сам свод (Часть 3/4) называет более медленной и дорогой: «поле
можно пропустить, значение словаря обязано лечь в типизированную ячейку»
[ИЗ СВОДА]. Мы «починили поля и оставили Values открытыми». Дополнительно:
терпимость vibe-wire — не решение, а неспособность генератора выдать
строгость ([ИЗ ДЕРЕВА: crates/vibe-wire/src/lib.rs:26-43] — «the generator
cannot emit it … until 2026-08-06 nobody had chosen either»), задним числом
ратифицированная решением владельца. И покрывает она сейчас только отчёты
команд ([ИЗ ДЕРЕВА: schemas/*.jtd.json] — 7 схем *_report/*_plan, ни
одной для каталога), а не сам формат каталога — VersionEntry/Repomd всё
ещё hand-written.
1. Один принцип, из которого следуют приёмы (вопрос 1)
10Свод перечисляет приёмы поодиночке: тегировать объединения, писать пустое
явно, держать catch-all у словарей, версионный контракт, «новый путь, не
новая версия», зарезервированные _-ключи, схема-вместе-с-данными (Avro).
За ними — один принцип:
11Долговечность формата — это дисциплина адресованного неведения: каждой форме незнания читателя (неизвестное поле, значение, версия, форма, схема, наличие) отводится определённое место, где оно может приземлиться, а не становиться ошибкой разбора.
12Приёмы выводятся из него как «где живёт данная форма неведения»:
| Форма неведения | Место, которое даёт выживший формат | Следствие для нас |
|---|---|---|
| Неизвестное поле | «проигнорировано» (tolerant reader, без deny) |
у нас — ошибка [ИЗ ДЕРЕВА: entry/mod.rs:38] |
| Неизвестное значение словаря | catch-all Other / свободная строка |
у нас — ошибка, #[serde(other)] нет нигде [ИЗ ДЕРЕВА] |
| Неизвестная версия | правило: major→отказ, minor→warn, нет→1 [ИЗ СВОДА, PEP 629] |
у нас — ярлык без сравнения [ИЗ ДЕРЕВА: entry/mod.rs:44,124] |
| Неизвестная форма | новый путь, старый остаётся валидным [ИЗ СВОДА] |
не заложено |
| Чужое расширение | зарезервированный неймспейс (_) [ИЗ СВОДА] |
нет вовсе (deny_unknown_fields убивает любое чужое поле) |
| «Не записано» vs «известно-пусто» | различие absence/empty [ИЗ СВОДА] |
у нас слитo (skip_serializing_if = is_empty [ИЗ ДЕРЕВА: entry/mod.rs:70-79]) |
| Неизвестная схема писателя | приложенная схема (Avro) [ИЗ СВОДА] |
см. §3 — не переносится |
14Королларий, отличающий выживших: они назначают границу сознательно и пишут
её (двухуровневый словарь NuGet, нормативный контракт PEP 629 — оба [ИЗ
СВОДА]). Homebrew [ИЗ СВОДА] провалился не оттого, что не знал приёма, а
оттого, что никогда не провёл границу. Наш случай (§0.4) — тот же грех в
острой форме: граница есть в спеке, но две разные и обе «impl/done».
15Из принципа сразу следует проверка любого решения: «куда приземлится незнакомое?» Если ответ «никуда, ошибка» — формат хрупок в будущем. Вся наша каталожная машина сегодня отвечает «ошибка» на пять строк таблицы из семи.
2. Чем наш случай отличен по существу (вопрос 2) — 4 отличия
16Свод сравнивает приёмы; я сравниваю структурные условия, потому что именно они делают чужой приём неприменимым.
17Отличие 1 — Автор один, и это инструмент, а не открытая человеческая подача.
PyPI/Debian/npm [ПО ПАМЯТИ, НЕ ПРОВЕРЕНО] принимают манифесты от тысяч
независимых людей, пишущих任意 инструментами произвольный по качеству
метаданные; терпимость их реестров в первую очередь держит неправильный
человеческий ввод. Наш каталог пишет одна машина — vibe-index add /
сканер ([ИЗ ДЕРЕВА: crates/vibe-index/src/scanner/org_walk.rs:203,
scanner/from_github.rs, cli/add.rs:85]); hand-authoring исключён. Значит,
главное давление, породившее терпимость PyPI, у нас отсутствует:
tolerant-reader покупает нам меньше страховки, чем им. Строгость на входной
двери (для opечаток) имеет смысл для vibe.toml (пишут руки), но для каталога
(пишет машина) выгода strict-режима резко падает. Это подпирает рекомендацию
свода №4, но даёт ей причину, которой у свода нет.
18Отличие 2 — На критическом пути чтения нет живого сервера. Потребитель
берёт статический файл по raw-URL или git clone
([ИЗ ДЕРЕВА: PROP-005-package-index.xml:66] — raw-URL fetchable без clone;
:598 — резолвер делает HTTP GET <index_url>/repomd.json; сервер
vibe-index опционален). PyPI/Maven/npm [ПО ПАМЯТИ, НЕ ПРОВЕРЕНО] — живые
реестры: они могут серверно торговаться (Accept: vnd…v2, deprecation-заголовки,
shadow-serve, переводят старое в новое адаптером на лету). У нас
посредника нет — совместимость обязана жить в самом файле. Это
ключевой факт: он обесценивает весь класс «сервер-versioning» и делает
правильным рычагом именно §5 («новый путь»). Свод этот факт упоминает мимоходом;
по мне он — ось всей темы.
19Отличие 3 — Читателей сегодня ноль, и они перечислимы (кривая стоимости
перевёрнута). Сам свод [ИЗ СВОДА] фиксирует: внешних потребителей
пока ноль, и что переименование поля в PyPI обошлось дёшево именно потому,
что читателей было трое и их обзвонили. Зрелые соседи (заморозка навсегда,
новый-путь-без-переиспользования, зарезервированный неймспейс) — это ответы на
условие «я не могу достучаться до своих читателей». Мы — можем: мы в
окне раннего-PyPI, не зрелого. Значит, оптимальная стратегия сейчас —
потратить это окно (мигрировать, править, даже ломать-и-переименовывать,
пока это бесплатно), а аппарат терпимости строить готовым к заморозке, но
не замораживать. Преждевременная заморозка — единственное преимущество,
которое у нас есть перед зрелыми соседями, мы про́дадим задаром. Это моё
самое сильное расхождение с позицией свода (см. §7).
20Отличие 4 — Каталог и описываемый им артефакт делят одну git-историю.
В RPM/Debian [ПО ПАМЯТИ, НЕ ПРОВЕРЕНО] индекс — производный взгляд над
пулом бинарных артефактов; пул и индекс разделены, можно оставить старый пул
и добавить новый индекс. У нас «артефакт» — это git-репозиторий пакета
(source_url/source_ref/content_hash ссылаются на него,
[ИЗ ДЕРЕВА: entry/mod.rs:55-57]), а метаданные и репозиторий движутся
вместе в одной истории. Это меняет экономику §5: нельзя «заморозить
старый пул артефактов и выпустить новый индекс» — форма и описываемое
сцеплены. Зато git даёт бесплатную неизменяемость старого пути через
branch/tag-policy (см. §5), чего у живого сервера нет.
3. Где свод натянул аналогию (вопрос 3)
21Аналогия Avro-в-git внутренне противоречива самому тезису свода. Свод
[ИЗ СВОДА]: Avro дорог в сети тем, что кладёт схему писателя в каждое
сообщение, но «в git-репозитории это стоит указателя». Звучит — но Avro
работает не транспортом схемы, а согласованием схемы писателя со
схемой читателя в момент чтения (promotions, разрешение псевдонимов) [ПО
ПАМЯТИ, НЕ ПРОВЕРЕНО] — а это отношение между двумя схемами, разрешаемое
живым читателем по правилам. И ровно отношения, по собственному тезису
свода (Часть 4: «Файл — это ответ, у которого не было вопроса»; всё, что
свойство отношений, «умирает при соприкосновении с файлом в git» [ИЗ
СВОДА]), на диске не выживают. Указатель в git дёшево доставляет схему
писателя — согласен, — но примирение схем всё равно требует читателя,
знающего правила, что схлопывается обратно до «tolerant reader» (который нам
и так нужен) плюс «схема лежит в git» (что у нас уже есть — это спека).
Avro добавляет ничего поверх «файл + толерантный читатель»; притягательность
аналогии (дешёвый транспорт схемы) реальна, а её плата (автоматическая
совместимость) не переносится. Растянуто ровно в том, что mattered.
22Бонус — Cloudflare как «ближайший аналог».[ИЗ СВОДА] называет разбор
аварии Cloudflare «ближайшим аналогом нашего случая». Но (а) урок аварии —
«ужесточить приём собственных сгенерированных файлов как пользовательский
ввод» [ИЗ СВОДА] — толкает к большей строгости на сгенерированных
файлах, что в напряжении с тезисом о tolerant-reader, который свод строит;
(б) отказ Cloudflare — молчаливый mis-score у развёрнутого бинарного парсера с
лимитом размера [ИЗ СВОДА]; наш же аналог (deny_unknown_fields) даёт
жёсткую**, а не молчаливую ошибку — противоположный режим отказа. Авария
поддерживает «статическая проверка в сборке» (что свод тоже держит), но не
«толерантный читатель», и звать её «ближайшим аналогом» — преувеличение
посадки.
4. Чего не хватает в списке соседей (вопрос 4)
23В теме соседи перечислены, но анализ в Части 2 свода дан лишь для
PyPI/PEP, NuGet и Homebrew. Самый близкий нам структурный двойник назван в
теме, но не разобран:
- 24Индекс crates.io
[ПО ПАМЯТИ, НЕ ПРОВЕРЕНО]— это тоже JSON-in-git (исторически git-репозиторий индекса, который клиент клонировал; позже — sparse-HTTP). Один инструмент пишет (publish), один известный клиент читает (cargo); формат индекса версионируется, и сопровождавшим приходилось проводить миграции формата индекса (переход git-clone→sparse — отдельная глава). Это наш случай почти один-в-один: «машинный индекс в git/HTTP, читаемый чужим инструментом, с эволюцией формата». Он ближе, чем PyPI (открытая человеческая подача) или NuGet (живой сервер). Деталей механики версионирования я по памяти цитировать не могу — но утверждаю, что именно этот сосед должен был быть главным зеркалом, а не PyPI. PyPI ценен как авария (PEP 714), crates.io — как рабочая миграция формата того же класса, что и наш.
25Два дополнительных, под наши подслючаи:
- 26Helm
index.yaml[ПО ПАМЯТИ, НЕ ПРОВЕРЕНО]— сгенерированный индекс chart-репозитория, статический файл, читаемый Helm; несёт полеapiVersion— прямой аналог нашего вопроса «версия как контракт». Ближе к каталогу, чем реестры с живым сервером. - Nix
flake.lock[ПО ПАМЯТИ, НЕ ПРОВЕРЕНО]— машинный lockfile в git с версиионированной схемой (version/nodes); хорошая параллель к нашемуvibe.lock(который, в отличие от каталога, версию проверяет и отвергает[ИЗ ДЕРЕВА: crates/vibe-core/src/manifest/lockfile.rs:430],CURRENT_SCHEMA_VERSION = 5:50).
5. «Ломающее изменение — новый путь» — конкретно для git (вопрос 5)
27«Путь» в нашем случае — это путь файла в git-репозитории индекса, он же
raw-URL: repomd.json, primary.jsonl, by-name/<name>.json
([ИЗ ДЕРЕВА: PROP-005-package-index.xml:66, 118-205]). «Новый путь» для
ломающего изменения = публиковать каталог v2 по другому пути, оставив v1
замороженным на старом:
- 28вариант пути: подкаталог
v2/repomd.json(или суффиксrepomd.v2.json, или ветка/тегindex-v2); - старый потребитель идёт по
repomd.json— там по-прежнему валидный v1; - новый потребитель идёт по
v2/repomd.json.
29Что это стоит. (1) Двойная запись на каждом reindex — писать приходится
оба дерева; by-name/primary фан-out зеркалится под v2/. (2)
Обнаружимость — как новый клиент узнаёт, что есть v2? Указателем в v1
(например, поле successor в repomd.json) — но это поле само должно быть
forward-совместимым в v1, что создаёт проблему самозагрузки (первое такое
поле нельзя ввести «новым путём», его надо ввести как добавление поля — а
это требует толерантного читателя v1, которого у нас пока нет, §0.5). (3)
Дилемма стороны записи: v1 перестаёт принимать новые пакеты → старые клиенты
видят честно-устаревший, но валидный снимок; или v1 продолжает зеркалить →
вы поддерживаете двух писателей. Это и есть настоящая цена.
30Что происходит с тем, кто ходит по старому пути через год. Он получает
замороженный v1 навсегда — работает, но перестаёт видеть пакеты,
опубликованные после разреза. Это приемлемо тогда и только тогда, когда v1
честно помечен «frozen, superseded». Молчаливая остановка обновлений (старый
путь отвечает, но данные не растут) — это тихое устаревание, ровно то, что
RFC 9413 [ИЗ СВОДА] клеймит как закрепление дефекта.
31Почему нам это дешевле, чем PyPI/NuGet (отличие 2 + 4 из §2): у живого сервера «заморозить старый путь» = держать работающий adapter бесконечно; у нас «заморозить старый путь» = branch policy / тег git'а по нулевой маржинальной цене (raw-URL, приколотый к тегу, неизменяем даром). Наше структурное преимущество — git хранит каждую версию навсегда бесплатно, — надо эксплуатировать явно: «новый путь» = «новый тег/ветка», а старая замораживается policy, не кодом сервера.
6. Двухуровневый словарь NuGet — на наших настоящих именах (вопрос 6)
32NuGet [ИЗ СВОДА]: документированные ресурсы вечны, недокументированные
удаляются свободно, и это написано. У нас аналогичная граница — не
«документировано/нет» (расширительного неймспейса у нас ещё нет), а
«ответвляется ли потребитель на этом значении»:
- 33Верхний уровень (вечные): значение, на котором потребитель ВЕТВИТСЯ —
маршрутизация, материализация, boot-linking. Должны быть закрытыми enum'ами,
версионируемыми, без переиспользования имён, с catch-all
unknown. kind: PackageKind[ИЗ ДЕРЕВА: kinds.rs:21-34; entry/mod.rs:46]— flow/feat/stack/tool/mcp/lang; в PROP-008 §2.3 это «метаданные», но реально ведёт именование и маршрутизацию.delivery: DeliveryMode[ИЗ ДЕРЕВА: content.rs:53-57]— eager/lazy-push/lazy-pull; определяет, как материализуются subskills.naming: NamingConvention[ИЗ ДЕРЕВА: kinds.rs:88-112; repomd.rs:24]— fqdn/kind-name/name/kind-name; определяет path-mapping.format: PackageFormat[ИЗ ДЕРЕВА: crates/vibe-core/src/manifest/package.rs:155]— simple/normal; определяет boot-linking.
- 34Нижний уровень (свободные к эволюции/удалению, несут «неизвестное»): значение, которое потребитель только показывает или использует как подсказку.
boot_snippet.category[ИЗ ДЕРЕВА: content.rs:99-100]— это ужеOption<String>, а не enum, хотя морально это тот же маленький закрытый набор («foundation / flow / stack / user-override», комментарий вcontent.rs:96-98). Проект уже применил двухуровневую интуицию здесь — ad hoc, не назвав:categoryоставлен строкой именно потому, что это hint-порядка, а не идентичность.description,keywords,homepage[ИЗ ДЕРЕВА: entry/mod.rs:75-79]— display-only; кандидаты в нижний уровень / на удаление без шума.BindingSite[ИЗ ДЕРЕВА: index_client/wire.rs:94]— клиентский enum package/subskill; может быть нижним.
35Пробел, который вскрывает упражнение. У NuGet нижний уровень — это место
(ресурс, который можно удалить). У нас места нет: deny_unknown_fields
значит, что чужому/расширительному ключу негде жить. Ближе всего в
экосистеме — _-заповедник PyPI/NuGet [ИЗ СВОДА], которого у нас нет.
Принять двухуровневую модель для нас буквально означает: (а) назвать, какие
поля верхние, какие нижние; (б) дать нижнему уровню дом — зарезервированный
_-namespace либо явный #[serde(other)]/catch-all на верхних enum'ах —
чтобы у неведения был адрес. Это и есть замыкание на принцип §1.
36Свидетельство, что границу не проводили сознательно. delivery — строгий
3-вариантный enum (content.rs:53), а boot_snippet.category — тот же класс
маленького закрытого набора, но свободная строка (content.rs:100). Одно и то
же правило не применяется; значит, разделительная линия никогда не была
решением. Это homebrew-симптом в нашем коде — не отсутствие приёма, а
отсутствие проведённой границы.
7. Сводка несогласий (консолидированно)
- 37
RepomdFileEntry— не «без признака»: тегkindвшит в вариант Directory намеренно (repomd.rs:44-49); свод перепутал симптом и причину. - Закрытых словарей 4, не 5 — по декомпозиции самого свода число не бьётся.
- Спека противоречит себе (
FORWARD-COMPAT:302 vsNEVER-SILENT-SCHEMA:624), обаimpl/done; код делает третье;schema_versionне сравнивается нигде — свод увидел одну трещину из трёх. - Толерантность клиента неполна (ломается на новом значении
kind/BindingSite, а не на поле) и случайна (codegen не умеетdeny,vibe-wire/lib.rs:26-43; покрывает отчёты, не каталог). - Avro-в-git растянут и противоречит собственному тезису свода о смерти «свойств отношений» на диске.
- Главное: стратегический шаг сведён к «построй аппарат терпимости сейчас». Я считаю правильным обратное: при читателях=0 сегодня максимально дёшево ломать и переименовывать, а аппарат строить готовым к заморозке, но не замораживать. Окно (как у раннего PyPI с его тремя читателями) закроется, и тогда вступят техники зрелых соседей — но не сейчас.
8. Чего я бы добавил к рекомендации свода
- 38JTD-codegen уже частично построен (
vibe-wire,schemas/,tools/jtd-codegen)[ИЗ ДЕРЕВА], но покрывает отчёты команд, а не каталог. Раз владельцу хочется «схемы → код», первый честный шаг — описать каталог JTD-схемой и прогнать через тот же генератор. Это немедленно выставит вопрос «что делает генератор с неизвестными полями» — и ответ сегодня «терпит, потому что не умеет строго» (lib.rs:26-43). То есть решение о tolerant-vs-strict для каталога невозможно отложить: сам переход на codegen его навязывает. deny_unknown_fieldsсовместим с forward-compat только при наличии зарезервированного неймспейса. Минимальная обратимо-совместимая правка — не сниматьdeny, а добавить один одобренный escape-хэтч (x-*или_-ключи), как у PyPI/NuGet[ИЗ СВОДА]. Тогда строгость (для опечаток вvibe.toml) и forward-compat (для каталога) не конфликтуют — они разнесены по пространству имён, что совпадает с рекомендацией свода №4 («разделять пространством имён»), но доведённой до конкретного механизма.- Заморозить список отставленных имён полей сейчас, пока переименование бесплатно (рекомендация №6 свода верна, но её надо делать до первого внешнего читателя — то есть сейчас, а не «когда понадобится»).
- Контракт версии — внедрить как проверку в сборке, а не как runtime-ветку:
статически гарантировать, что minor-бамп вводит только необязательные поля,
major-бамп — только по новому пути (§5). Переносимая практика из свода
(
Часть 4: статическая проверка совместимости в сборке[ИЗ СВОДА]), и ей не нужен ни сервер, ни телеметрия.