VibeVM
Contents
On this page
en
Publisher
org.vibevm.core
Version
1.0.0latest
Audiences
Reading time
12 min
Rendered
Read aloud
never

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 два факта взаимоисключающи:

  • 07FORWARD-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Приёмы выводятся из него как «где живёт данная форма неведения»:

13
Форма неведения Место, которое даёт выживший формат Следствие для нас
Неизвестное поле «проигнорировано» (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. Сводка несогласий (консолидированно)

  1. 37RepomdFileEntry — не «без признака»: тег kind вшит в вариант Directory намеренно (repomd.rs:44-49); свод перепутал симптом и причину.
  2. Закрытых словарей 4, не 5 — по декомпозиции самого свода число не бьётся.
  3. Спека противоречит себе (FORWARD-COMPAT :302 vs NEVER-SILENT-SCHEMA :624), оба impl/done; код делает третье; schema_version не сравнивается нигде — свод увидел одну трещину из трёх.
  4. Толерантность клиента неполна (ломается на новом значении kind/ BindingSite, а не на поле) и случайна (codegen не умеет deny, vibe-wire/lib.rs:26-43; покрывает отчёты, не каталог).
  5. Avro-в-git растянут и противоречит собственному тезису свода о смерти «свойств отношений» на диске.
  6. Главное: стратегический шаг сведён к «построй аппарат терпимости сейчас». Я считаю правильным обратное: при читателях=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: статическая проверка совместимости в сборке [ИЗ СВОДА]), и ей не нужен ни сервер, ни телеметрия.

For an agent

This page has a machine mirror. The citation carries the version rather than latest, so what an agent quotes does not move under it.

spec://org.vibevm.core/vibevm@1.0.0/research/schema-evolution-2026-08/06-glm-neighbours-and-principles

.md.xmlllms.txt