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

РЕВЬЮ И ВЕРДИКТ

01Финальный разбор мегаотчёта и девяти исходных файлов. Всё, что сказано про наш код, проверено чтением дерева C:\Users\olegc\git\v\vibevm с файл:строка. Всё, что сказано про чужие системы, взято либо из вкопированного в наше же дерево первоисточника (refs/**), либо помечено как непроверенное.

0. Итог одним абзацем

02Отчёт добротный, числа сходятся, но он ставит не тот диагноз и потому прописывает лекарство, которое в одном месте опаснее болезни. Главная ошибка: предложено «сделать читателя каталога терпимым», при том что каталог читают, чтобы переписать — на шести путях. Терпимый читатель без сохранения незнакомого превращает громкий отказ в тихое удаление чужих данных: то есть ровно в тот отказ, который сам же отчёт называет худшим. Второе: половина предложения выведена из противоречия «шлагбаум против декларации», которого нет — это не две роли, а один целый u32, в который вторая роль физически не влезает; а утверждение «crates.io добавляет поля без поднятия версии» ложно, и опровержение лежит в нашем же дереве, в refs/src/cargo/ — там версия 2 введена именно ради добавленного поля. Третье, и самое дорогое: восемь участников обсуждали эволюцию схемы и ни разу — эволюцию смысла: у нас нет ни отметки об отзыве версии, ни места под подпись, ни идентичности алгоритма у content_hash, который спека объявляет ключом связи каталога с локом. Вот эти три вещи и есть настоящие «механизмы, окно которых закрывается», а не терпимость, которую можно включить когда угодно. Практический вывод: из восьми пунктов принять как есть — один, изменить — пять, отвергнуть — два; и начать не с них, а с четырёх мелких необратимых вещей, которые никто не назвал.

1. Проверка семи разрешений

1.1. Объединение полутегированное (5.1) — верно, но неполно

03Факт подтверждён дословно: crates/vibe-index/src/types/repomd.rs:42#[serde(untagged)]; :44-50 — вариант Directory несёт kind: DirectoryTag и доккомментарий прямо объясняет, что тег стоит там намеренно; :51-54 — вариант File тега не несёт. Разрешение фактически право.

04Неполнота в трёх местах.

05Первое. Отчёт сохранил слабый довод и выбросил сильный. Он тегирует ради «однозначности разбора» — а разбор и так однозначен, наборы ключей не пересекаются. Настоящая причина названа в файле 08 и потеряна при сведении: RepomdFileEntry — единственный тип каталога, который молча глотает добавленные поля. Это первый пункт из «четырёх мест, где старый читатель разберёт и поймёт неверно», которые тот же отчёт объявляет самой ценной находкой серии. Разрешение 5.1 и находка №1 стоят в одном документе и не разговаривают друг с другом.

06Второе, механическое и решающее. Починка «поставить хотя бы deny_unknown_fields на плечи» (файл 08, §5.1) невозможна: deny_unknown_fields — атрибут контейнера, и serde не даёт его ни на untagged-перечислении, ни на отдельных его плечах. Значит, единственный способ прекратить молчаливое глотание — перестать быть untagged. Это делает рекомендацию №1 не вопросом вкуса, а единственным выходом.

07Третье. Не назван побочный эффект: тест repomd.rs:132 утверждает assert!(!json.contains("kind")) для файлового плеча. Переход на явный тег ломает его — мелочь, но она показывает, что «один атрибут» на деле «один атрибут плюс золотой тест плюс перезапись всех существующих repomd.json».

1.2. «14 против 21» (5.2) — верно, и я это воспроизвела; но решение шире, чем 21

08Своя перепись по crates/vibe-index/src/types/: 35 вхождений skip_serializing_if, гистограмма — Option::is_none ×14, Vec::is_empty ×12, BTreeMap::is_empty ×2, <Тип>::is_empty ×7. Значит:

09коллекций           12 Vec + 2 BTreeMap            = 14   ← число свода
+ вложенных структур  7 (<Тип>::is_empty)                  
                                                   = 21   ← число замера ✓
+ Option<T>          14 (absent ⟺ None, схлопывания нет)
                                                   = 35   ← вся поверхность

10Оба числа воспроизводятся, разрешение верно. Но вывод «решение принимается по 21» неверен. Решение принимается по 35, потому что предложенное правило («отсутствие = не записано; известное пустое пишем явно») обязано что-то сказать про все 35 полей — а для 14 Option-полей оно сказать ничего не может, поскольку null в том же предложении отвергнут. Правило, которое покрывает 21 из 35 и объявляет себя единым, — не единое правило.

11И ещё одна деталь, которой нет ни у кого: 4 поля-коллекции уже сегодня пишутся всегда, без пропуска-когда-пусто — Repomd.files (repomd.rs:34), RequiresAnyEntry.one_of (relations.rs:59), PackageEntry.versions (aggregate.rs:32), NameEntry.packages (aggregate.rs:75). То есть обе конвенции в формате уже сосуществуют, несогласованно, и никто этого не заметил.

1.3. «5 против 4 против 3» (5.3) — верно, и я могу это доказать арифметикой, которой не привёл никто

12Числа в отчёте связаны и сходятся только при пяти словарях:

1315 структур в types/        (Repomd, VersionEntry, 6× relations,
                             5× content, PackageEntry, NameEntry)
+ 2 структуры в index/inverted.rs   (CapabilityRow :66, PurlRow :75)
= 17 структур
+ 1 объединение             (RepomdFileEntry :43)
= 18 «файлов каталога»                         ← совпало ✓
+ 5 словарей                (PackageKind, NamingConvention,
                             DeliveryMode, DirectoryTag, BindingSite)
= 23 типа на проводе                           ← совпало ✓

14Пятый — BindingSite в crates/vibe-index/src/index/inverted.rs:89. Кто считал только types/, получил 4; кто выкинул одновариантный DirectoryTag — 3. Разрешение «расхождение периметра, не факта» верно.

15Но оно похоронило живой дефект. Именно пятый словарь — единственный, который записан дважды по разные стороны границы публикатор/потребитель, с разными правилами сериализации и без теста на расхождение:

16пишет   crates/vibe-index/src/index/inverted.rs:88   rename_all = "kebab-case"
читает  crates/vibe-registry/src/index_client/wire.rs:93  rename_all = "lowercase"

17Сегодня они совпадают по случайности: оба варианта — однословные (Package, Subskill), а для однословных kebab и lowercase дают одно и то же. Добавьте вариант из двух слов — писатель выдаст boot-snippet, клиент будет ждать bootsnippet, и разбор упадёт у потребителя, а не у нас. Паритет-тесты в дереве есть ровно для двух вещей: content_hash (crates/vibe-index/tests/content_hash_parity.rs) и PackageKind (types/kinds.rs, types/mod.rs). Для BindingSite — нет.

18Отчёт использует расхождение словарей как аргумент за общий чекер (п. 8) и иллюстрирует его прозой («four kinds» при шести вариантах). Настоящая иллюстрация — вот эта, и она на проводе.

1.4. Роль версии: шлагбаум против декларации (5.4) — неполно, и в одном месте неверно

19Это разрешение просили проверить строже всего. Проверила. Оно не выдерживает в трёх точках.

20(а) Это не две роли, а один целый u32. Контракт, который отчёт сам цитирует (старшая — отказ, младшая — предупреждение, нет — считать первой), уже совмещает обе роли в одном номере: старшая часть — шлагбаум, младшая — декларация возможностей. Значит, «одним целым числом обе роли не сыграть» — верно, но это утверждение не про роли, а про число компонент. У нас schema_version: u32 (repomd.rs:21, entry/mod.rs:44) — одна компонента, поэтому декларации некуда лечь. Настоящий вопрос:

21Сколько компонент у версии?
├── одна (как сейчас)  → только шлагбаум. Это не выбор роли, это арифметика.
└── две (major.minor)  → обе роли сразу, как в цитируемом же контракте.

22И этот выбор необратим ровно в том смысле, о котором вся глава 5.6: 1 можно потом прочитать как 1.0, а 2 уже нельзя превратить в 1.1. То есть решение о числе компонент — механизм с закрывающимся окном, и его в списке механизмов нет. Никто не предложил разделить версию.

23(б) Фактическая посылка ложна, и опровержение лежит в нашем дереве. Утверждение «crates.io добавляет поля без поднятия версии» неверно. В refs/src/cargo/crates/cargo-util-schemas/src/index.rs:70-73 написано прямым текстом: Version 2 schema adds the features2 field. Version 3 schema adds artifact, bindep_targes, and lib. То есть версия у ближайшего двойника поднималась именно ради добавленных полей — дважды.

24Почему? Потому что features2 (там же, :22-28) отделён от features не для красоты: «versions older than 1.19 will fail to load due to not being able to parse the new syntax». Ничего существующего не изменилось — но читатель, который проигнорирует новое поле, разрешит фичи неправильно и молча.

25Отсюда правило, которого нет ни у отчёта, ни у одного из воркеров:

26

Версию поднимают тогда, когда документ перестаёт быть самодостаточным для читателя, отбрасывающего непонятое. Не «когда меняется смысл существующих данных» (это уже, оказывается, слишком узко) и не «на каждое добавление» (это слишком широко).

27Проверьте на наших случаях: добавить homepage — документ остаётся самодостаточным, не поднимаем. Добавить седьмой вид пакета — читатель, который его не знает, не сможет даже категоризировать запись, поднимаем. Разница операциональна, чего нельзя сказать ни про одну из двух формулировок в отчёте.

28(в) Есть третья роль, и это та, которую версия играет у нас сегодня. Отчёт пишет: «версия — ярлык, а не переключатель: ни одного сравнения». Это занижено. Смотрим код:

  • 29memory.rs:270-273load_from кладёт schema_version: manifest.schema_version в память и больше нигде к нему не обращается;
  • memory.rs:242write_to штампует schema_version: Repomd::SCHEMA_VERSION, то есть собственную константу писателя, а прочитанное значение выбрасывает.

30Значит, поле не инертно — оно перезаписывается. Сегодня оно не описывает данные, оно описывает, кто последним трогал файл. Это третья, никем не названная роль: версия как штамп писателя / провенанс. Для полностью пересоздаваемого производного каталога это не дефект, а осмысленный режим — «этот каталог собран более старым мной, пересобери». Прежде чем менять роль, стоит понять, что нынешняя — не пустое место.

31(г) Рекомендация («шлагбаум на уровне записи») тем не менее правильная — и у двойника она реализована буквально: index.rs:87pub v: Option<u32>, доккомментарий :66-68: «If this is None, it defaults to version 1. Entries with unknown versions are ignored». Но там же названа цена, которой в отчёте нет: «This is honored as of 1.51, so unfortunately older versions will ignore it, and potentially misinterpret version 2 and newer entries». Первая версия шлагбаума не защищает никого — это надо написать в решении, а не узнать потом.

32Итог по 5.4: вывод сохраняется, обоснование надо переписать целиком.

1.5. Отзыв Avro-в-git (5.5) — верно; но выброшен полезный остаток

33Посылка «схема и данные в одной истории git» действительно ложна, и это проверяемо: в дереве нет ни одного repomd.json (find . -name repomd.json — пусто вне target/), каталог живёт в отдельном репозитории (публикационная цель — https://github.com/vibespecs, crates/vibe-core/src/manifest/project.rs:410), а машинно-читаемой схемы для каталога нет вовсе: в schemas/ лежат семь JTD-схем, и все семь — отчёты команд (init_report, install_plan, install_report, list_report, registry_publish_report, registry_sync_report, uninstall_report).

34Но вместе с Avro выброшен единственный переносимый его остаток, который назвал файл 07: сделать схему машинно-читаемым артефактом и опубликовать её рядом с данными. Способность в доме есть и работает — crates/vibe-cli/resources/package-tree.schema.v1.json с золотым тестом crates/vibe-cli/tests/tree_json.rs. В предложении §6 такого пункта нет вообще. Разрешение верное, сведение потеряло вывод.

1.6. Механизмы против обязательств (5.6) — граница проведена не там

35Второе несущее разрешение. Тест сформулирован правильно — «закрывается ли окно вместе с публикацией» — но список заполнен по интуиции, а не этим тестом. Применяю тест к каждой строке.

36ЗАЯВЛЕНО КАК МЕХАНИЗМ            применяю тест            куда на самом деле
─────────────────────────────────────────────────────────────────────────────
тег на объединении               окно закрывается ✓        МЕХАНИЗМ
запасное значение в словарях     окно закрывается ✓        МЕХАНИЗМ (но см. 4.2)
решённое значение «отсутствия»   окно закрывается ✓        МЕХАНИЗМ
версия с контрактом              окно закрывается ✓        МЕХАНИЗМ
терпимый читатель                НЕ закрывается ✗          в любой момент

37Терпимый читатель стоит не в той колонке. Ослабление читателя не ломает ни одних существующих данных и не требует ничьего согласия: его можно включить сегодня, через год, через пять лет. Он не механизм с окном — он самая обратимая вещь в списке. А поставлен он туда, где создаёт ощущение срочности, и именно на этом ощущении держится вся глава 6.

38Симметрично, в колонке обязательств («потом») стоит «никогда не переименовывать» — а его несущий механизм, #[serde(alias = "…")], тоже бесплатен в любой момент и в обоих форматах (файл 07, §6). В сведении он потерян целиком.

39Чего в колонке механизмов не хватает — четыре вещи, у которых окно действительно закрывается:

  1. 40Место под подпись и признак «обязан понять». Поиск по crates/vibe-index/src и crates/vibe-core/src/manifest даёт ноль вхождений signature, signed_by, gpg, minisign, sigstore. Спека честно описывает два слоя целостности (repomd.json::files[*].sha256 — на файлы индекса, content_hash — на содержимое пакета, vibevm/vibespecs/modules/vibe-index/PROP-005-package-index.xml:306), но сам repomd.json не покрыт ничем: корень доверия — «доверяй гит-хосту». Это допустимое решение — но оно нигде не записано как решение, а мирроры в дизайне уже есть. И вот ловушка, которой не увидел никто: как только читатель становится терпимым, будущее поле signature можно молча проигнорировать — а это ровно downgrade-атака. Терпимость и будущая аутентичность прямо конфликтуют, и примирить их можно только одним способом: решить заранее, какие поля читатель обязан понять, а какие вправе выбросить. Множества «обязан понять» у нас нет и предложение его не заводит.
  2. Отметка об отзыве версии (tombstone). Поиск даёт ноль вхождений yank, withdraw, revoke, tombstone. У двойника это поле есть с 2014 года: refs/src/cargo/crates/cargo-util-schemas/src/index.rs:30-34pub yanked: Option<bool>, «This was added in 2014. Everything in the crates.io index has this set now». Механика окна здесь та же, что в собственном доводе отчёта про «отсутствие»: как только читатель приучен понимать «пакета нет в каталоге» как «его не существует», отличить «отозван» от «никогда не индексировался» становится нельзя — отсутствие уже занято.
  3. Идентичность алгоритма у content_hash. См. находку 2.6 ниже.
  4. Число компонент версии. См. 1.4(а).

1.7. Строгость по пространству имён, а не по автору (5.7) — верно, лучшее из семи; две оговорки

41Довод «из файла нельзя узнать, кто его написал» правилен и проверяем: единственный провенанс-маркер — [origin] (crates/vibe-core/src/manifest/document.rs, секция origin), и он про факт публикации, а не про намерение автора.

42Оговорка первая, механическая: правило нереализуемо в заявленной форме. deny_unknown_fields и #[serde(flatten)] в serde несовместимы, а «заповедник» — это по сути flatten-ловушка. Значит, заповедник обязан быть явным именованным полем (tool: BTreeMap<String, toml::Value> или подобным), иначе deny_unknown_fields отвергнет [tool.x] до того, как до него дойдёт дело. Файл 09 это заметил для манифеста; при сведении оговорка потерялась, и в п. 7 предложения заповедник выглядит бесплатным.

43Оговорка вторая, важнее: правило говорит «машинный файл → незнакомое поле игнорировать». Но наш машинный файл читают, чтобы переписать. Игнорировать ≠ сохранить. См. находку 2.1 — это и есть главный пропуск всей серии.

2. Что пропустили все восемь

2.1. Терпимость без сохранения — это не терпимость, это тихое удаление

44Самая дорогая находка. Пункт 3 предложения снимает deny_unknown_fields с типов каталога. Проверим, что произойдёт.

45Каталог читается ради перезаписи. Пути (все, вне тестов):

46add.rs:51      load_from  →  add.rs:122       write_to
remove.rs:35   load_from  →  remove.rs:57     write_to
reindex.rs:218 load_from  →  reindex.rs:281   write_to
serve.rs:77    load_from  →  packages.rs:256  write_to   (upsert)
                          →  packages.rs:304  write_to   (delete_version)
                          →  packages.rs:333  write_to   (delete_package)

47Шесть. (Седьмой write_toinit.rs:48 — только пишет, не читает; именно поэтому шесть, а не семь. Число из отчёта подтверждается, но только с названным периметром.)

48Теперь механика. Index::load_from (memory.rs:262) читает by-name/*.json в типизированную память; write_to (memory.rs:161) выписывает всё заново из этой памяти. Сегодня незнакомое поле даёт отказ разбора — громко, заметно, без потери данных на диске. После пункта 3 незнакомое поле будет прочитано, проигнорировано и не записано обратно. То есть следующий же vibe-index add сотрёт всё, что дописал более новый писатель.

49Отчёт называет молчаливую неправоту худшим видом отказа (это его вывод из разбора аварии Cloudflare и его же «самая ценная находка серии»). Пункт 3 вводит именно такой отказ — своим собственным лекарством.

50Починка существует и стоит одного поля: терпимость обязана идти в паре с захватом, #[serde(flatten)] extra: Map<String, Value> на типах read-modify-write. И — важное следствие — из-за несовместимости flatten с deny_unknown_fields выбирать приходится потипно: нельзя быть строгим и захватывающим одновременно.

51Почему пропустили все. Число «6 путей перезаписи» кочевало по всем девяти файлам как усилитель тяжести нынешней строгости («сервер не запустится»), и ни разу — как ограничение на лекарство.

2.2. Каталог — не один формат, а два, и терпимость нужна только одному

52primary::read (index/primary.rs:75) и primary::parse (:84) имеют ноль вызовов во всём дереве. by-cap/by-purl при каждой записи стираются подчистую (memory.rs:176-178, inverted.rs:264). Отсюда:

53КАТАЛОГ
├── читается-и-переписывается            ← только здесь живут все проблемы
│     by-name/<name>.json   (NameEntry → PackageEntry → VersionEntry)
│     repomd.json           (Repomd)
└── только пишется, назад не читается    ← здесь проблем нет вообще
      primary.jsonl(.gz), by-cap/*.jsonl, by-purl/*.jsonl

54Следствия, которых нет ни у кого: (1) круг типов, которым нужна терпимость + захват, — три, а не двадцать три; (2) экспортные типы могут остаться строгими даром, потому что round-trip у них не бывает; (3) deny_unknown_fields на VersionEntry (entry/mod.rs:38) кусается не потому, что VersionEntry — строка primary.jsonl, а потому что он вложен в by-name. Все восемь считали каталог одним монолитом на 23 типа и оценивали работу соответственно.

2.3. Ближайший двойник лежит в этом же дереве. Непрочитанный

55Файл 06 (§4) написал: crates.io — «наш случай почти один-в-один… именно этот сосед должен был быть главным зеркалом», и честно добавил, что механику процитировать не может, доступа в сеть нет. Механика лежала на диске:

56refs/src/cargo/crates/cargo-util-schemas/index.schema.json   (7,4 КБ, JSON Schema)
refs/src/cargo/crates/cargo-util-schemas/src/index.rs        (Rust-типы, serde)

57Четыре веб-исследования и четыре разбора по дереву — и первоисточник структурного двойника пролежал непрочитанным. Что он решает (всё цитируемо локально):

58
вопрос из отчёта что делает двойник ссылка
терпимый или строгий читатель IndexPackage без deny_unknown_fields index.rs:6-9
роль версии v: Option<u32>; нет → 1; неизвестная → запись игнорируется index.rs:87, :66-68
поднимать ли на добавление да, поднимали дважды, ради добавленных полей index.rs:70-73
закрытый словарь вид зависимости — свободная строка, не enum: kind: Option<Cow<str>>, «"dev", "build", and "normal"» index.rs:113
отзыв версии yanked: Option<bool>, с 2014 index.rs:30-34
отсутствие = умолчание? нет, потипно: features со значением по умолчанию {}, features2 нуллабельный, default_features с умолчанием true index.schema.json, index.rs
стабильность поля во времени pubtime «should be the original publish time and not changed on any status changes» index.rs (док к pubtime)

59Отдельно отмечу строку про вид зависимости: файл 08 доказывал, что открытый словарь-строка «сдаёт exhaustiveness — наш главный козырь». Двойник — на Rust, на том же serde, с тем же компилятором — выбрал строку. Это не значит, что он прав, а мы нет; это значит, что довод «так никто не делает» неверен, и решать надо по существу.

2.4. Формат невоспроизводим, и это решение никто не принимал

60memory.rs:249 — при каждой записи generated_at: Utc::now(). При этом primary.jsonl сделан намеренно байт-детерминированным: gzip_deterministic с mtime=0 (primary.rs:61-65) и два теста на это (primary.rs:172, :200). То есть на одном слое за побайтовую воспроизводимость боролись, а слоем выше её уничтожили — и никто из восьми этого не заметил.

61Три следствия, ни одно не прослежено:

  1. 62Каталог нельзя проверить пересборкой. Самая дешёвая проверка целостности и согласованности из всех возможных («собери заново, сравни байты») недоступна, потому что байты гарантированно разные.
  2. Гит пухнет без информационного прироста. JSON-в-гите с полной перезаписью означает, что каждый reindex коммитит весь файл. Именно объём репозитория, а не эволюция схемы, в своё время выдавил crates.io с git-клона на sparse-HTTP — файл 06 упомянул этот переход как «отдельную главу» и не связал.
  3. Внутренняя рассинхронизация меток времени. NameEntry::new(name, self.generated_at) (memory.rs:200) штампует файлы by-name прочитанной ранее меткой, тогда как repomd.json получает свежий Utc::now(). Две метки в одном каталоге принадлежат разным моментам по конструкции.

2.5. Правило подъёма версии нигде не написано — а без него шлагбаум не сработает никогда

63Пункт 5 предложения делает версию шлагбаумом. Нигде — ни в предложении, ни в спеке — не сказано, когда писатель её увеличивает. Шлагбаум без правила подъёма не опускается ни разу.

64Заодно поправлю утверждение, которое отчёт принял от воркера без проверки: «спека противоречит и самой себе — два взаимоисключающих утверждения». Прочитала обе строки дословно:

  • 65PROP-005-package-index.xml:302 — «readers of v2 written by an old vibevm gracefully ignore unknown fields», @status:impl/done;
  • :624 — «Old consumers parsing a higher schema must surface a "your vibevm is older than this index; please upgrade" message — not silently parse the subset they understand», @status:impl/done.

66Это не взаимоисключающие утверждения, если читать первое как режим аддитивного изменения при неизменной версии, а второе — как режим поднятой версии. Сложенные вместе, они дают ровно тот контракт-шлагбаум, к которому отчёт приходит в главе 6. Проблема не в противоречии, а в том, что ни одна из строк не определяет, какое изменение обязывает поднять версию, — поэтому спор между ними неразрешим, а детектор «higher schema» из второй строки не может сработать, так как сравнения нет нигде. Трещин две (спека↔код и мёртвое сравнение), а не три; зато настоящая дыра — отсутствие правила — не названа никем.

67Правильная формулировка правила выведена выше из features2 (см. 1.4(б)).

2.6. Производные поля — вот где живёт «смертельный слом», и туда никто не посмотрел

68Отчёт (глава 4, п. 9) утверждает: единственный смертельный слом RPM за двадцать лет — переопределение смысла существующего тега. И после этого ни один из восьми не спросил, где такой тег есть у нас. Он есть, и не один:

69
поле смысл — это вычисление, живущее только в коде где
content_hash обход дерева + список исключений + нормализация путей crates/vibe-index/src/content_hash.rs:29,40-60
latest_stable «максимальная версия с пустым pre» types/entry/aggregate.rs:47-55
package_count / version_count пересчёт memory.rs:247-248
files[*].size / sha256 / entries пересчёт memory.rs:178-240
files_count пересчёт types/entry/mod.rs:117

70Самое опасное — первое. Список исключений зашит константой:

71// crates/vibe-index/src/content_hash.rs:29
const SHIPPABLE_EXCLUDES: &[&str] =
    &[".git", ".vibe", "target", "node_modules", ".vibeignore"];

72Добавьте туда dist или .venv — и каждый content_hash в мире изменит значение при том же имени, том же типе и той же версии схемы. А спека объявляет content_hash ключом связи каталога с локфайлом (PROP-005-package-index.xml:304: «content_hash is the join key between the index and the lockfile»). Существующий паритет-тест (crates/vibe-index/tests/content_hash_parity.rs) сторожит совпадение двух копий алгоритма между собой, а не стабильность алгоритма во времени — это разные вещи, и вторую никто не сторожит.

73Механизм с закрывающимся окном: идентичность алгоритма должна ехать в самой строке (sha256: → что-то вроде sha256-v1:) или в соседнем поле. Задним числом это не внедряется — старые строки уже без метки, и отличить «посчитано старым алгоритмом» от «посчитано новым» будет нельзя.

74И общее правило, которого нет ни у кого: для каждого производного поля должно быть написано, кто выигрывает при расхождении — записанное значение или пересчёт. Сегодня не написано ни для одного, а терпимый читатель делает это хуже, а не лучше: он с радостью примет протухшее денормализованное значение.

2.7. «Внешних потребителей ноль» — недоказуемо по конструкции, и на этом стоит вся стратегия

75Это молчаливая предпосылка всех девяти файлов и обеих несущих глав. Она непроверяема, и непроверяема намеренно.

76Дефолтный реестр, засеваемый на каждой свежей машине, — публичная организация на GitHub плюс зеркало:

77crates/vibe-core/src/manifest/project.rs:410  DEFAULT_REGISTRY_URL = "https://github.com/vibespecs"
crates/vibe-core/src/manifest/project.rs:417  DEFAULT_REGISTRY_REF = "main"
crates/vibe-core/src/manifest/project.rs:424  DEFAULT_REGISTRY_GITVERSE_URL = "https://gitverse.ru/vibespecs"
crates/vibe-core/src/global_registry.rs:106-127  засеваются обе, без спроса

78Статический файл в публичном гит-репозитории, отдаваемый по raw-URL, не даёт телеметрии — и отчёт сам этому радуется (глава 4, п. 12: версию в заголовке протокола отвергли, чтобы зеркала оставались тупым файловым сервером). Значит, честная формулировка не «потребителей ноль», а «ноль потребителей, о которых мы бы когда-нибудь узнали». Цена ошибки при этом асимметрична: если предпосылка неверна, мы не узнаем об этом никогда, потому что отказ произойдёт молча и на чужой машине.

79Справедливости ради — в пользу предпосылки: в этом репозитории один тег (pre-cultural-refactor), релизных тегов нет; свидетельств выпущенных бинарников на руках нет. И repomd.json в дереве отсутствует, то есть каталог здесь ещё не лежит. Так что предпосылка правдоподобна. Но план обязан назвать, сколько стоит, если она ложна, — и не называет.

80Ещё одно, чего не сказал никто: в гите публикация необратима. «Мы ещё не опубликовались» перестаёт быть правдой в момент первого пуша в публичный main, и откатить это нельзя — история хранит каждое состояние вечно.

2.8. Канонизация — решение, которого никто не рассмотрел, и оно снимает половину спора

81Спор «пусто против отсутствует» ведётся семантически: что означает отсутствие. Есть чисто механическое правило, которое решает вопрос вообще без семантики:

82

Ровно одна последовательность байт на одно состояние. Два каталога, описывающие одно и то же, обязаны быть побайтово одинаковыми.

83Что оно даёт: (а) вопрос «пусто или отсутствует» решается указом, в любую сторону, и перестаёт иметь значение; (б) вынуждает решить вопрос 2.4 (generated_at); (в) делает «пересобрал и сравнил» настоящей проверкой; (г) минимизирует шум в гите; (д) машинно проверяется — то есть это куда лучшее правило для чекера из п. 8, чем большинство предложенных.

84Никто из восьми его не предложил, потому что все искали смысл отсутствия, а не форму записи.

2.9. Три пункта предложения взаимно несовместимы под кодогенерацией

85Исходный вопрос владельца — описать формат схемами и генерировать код. Наш язык схем и его свойства зафиксированы в дереве: crates/vibe-wire/src/lib.rs:17-45 — JTD, генератор jtd-codegen 0.4.1, и прямым текстом: «the generator cannot emit it» про deny_unknown_fields, плюс «JTD's own additionalProperties works the opposite way». Там же (lib.rs:49-53) — генератор переименовывает snake_case в camelCase и требует ручных rename.

86Складываем с предложением:

87п.1  тегированное объединение   JTD умеет размеченные объединения    → ✓ и это ДЕЙСТВИТЕЛЬНО
                                                                        разблокирует кодоген
п.2  запасное значение в словарях  JTD-enum — закрытое множество строк,
                                    catch-all в нём выразить нечем     → ✗ придётся сделать
                                                                        словарь строкой,
                                                                        то есть сдать
                                                                        exhaustiveness
п.4  «известное пустое пишем явно»  в JTD нет «пропусти когда пусто»;
                                    необязательное вернётся Option<T>  → ✗ решение примет
                                                                        генератор, не мы
п.8  один чекер                    строгость генератор не умеет вовсе  → строгость останется
                                                                        только у рукописных

88То есть принять п. 1 + п. 2 + курс на кодогенерацию одновременно означает отказаться от закрытых Rust-перечислений — ровно от того, что файл 08 называет нашим главным козырем. Ни один из восьми не поставил эти пункты в одну комнату.

89(Свойства JTD я вывожу из заметок в нашем же vibe-wire плюс общих знаний; считать непроверенным до часового спайка против jtd-codegen 0.4.1.)

2.10. Мелкое, но показательное: внутренний документ уже разошёлся с кодом

90campaigns/packages-2026-09/harvest/a6-wire-format-census.md:35 утверждает, что у VersionEntry 13 полей с пропуском-когда-пусто. В коде их 18 (types/entry/mod.rs, строки 59, 67, 70, 72, 74, 76, 78, 84, 87, 90, 93, 96, 99, 102, 105, 108, 111, 114). Остальные числа того же документа сходятся. Это тот самый дрейф документации от кода, ради которого предлагается п. 8 — и он уже случился внутри самих материалов расследования.

3. Числа, проверенные по дереву

91Проверила пять из семи. Все пять сходятся; у трёх обнаружился существенный периметр, который надо называть вслух.

92№1. «deny_unknown_fields в 15 местах» — СОШЛОСЬ. Ровно 15 в crates/vibe-index/src, все в types/: repomd.rs:15; entry/mod.rs:38; entry/content.rs:18, 37, 60, 73, 92; entry/relations.rs:14, 29, 42, 57, 63, 76; entry/aggregate.rs:21, 64. По всей первой стороне (crates/ + xtask/) — 63, что совпадает с «~63 места» из crates/vibe-wire/src/lib.rs:22. Не покрыты намеренно: RepomdFileEntry (serde не позволяет под untagged), CapabilityRow (inverted.rs:65), PurlRow (inverted.rs:74).

93№2. «21 поле со схлопыванием» — СОШЛОСЬ. 35 вхождений skip_serializing_if в types/, гистограмма: Option::is_none 14, Vec::is_empty 12, BTreeMap::is_empty 2, <Тип>::is_empty 7. Коллекций 12+2 = 14; плюс 7 вложенных структур = 21. Оба числа отчёта воспроизводятся точно. Периметр: есть третье защитимое число — 23, если считать вложенными структурами ещё и Option<WorkspaceOriginEntry> (:67) и Option<BootSnippetEntry> (:114); но для этого вопроса они не считаются, у Option «отсутствует» и «None» — биекция без схлопывания. 21 верно.

94№3. «6 путей перезаписи» — СОШЛОСЬ, но только с названным периметром. Вызовов write_to вне тестов семь: add.rs:122, init.rs:48, reindex.rs:281, remove.rs:57, packages.rs:256, :304, :333. Из них init.rs:48 — только запись, без предварительного чтения. Пути «прочитал-изменил-переписал» — шесть. Полный список с парами load_fromwrite_to — в находке 2.1.

95№4. «23 типа / 18 файлов каталога / 5 словарей» — СОШЛОСЬ, и три числа замыкаются только вместе. Разбор — в разделе 1.3. Пятый словарь — BindingSite (index/inverted.rs:89); варианты «4» и «3» получаются вычитанием периметра.

96№5. «4 места, где старый читатель разберёт и поймёт неверно» — СОШЛОСЬ, все четыре на месте. (1) repomd.rs:42-55untagged без deny, лишние ключи глотаются; строгость здесь невозможна в принципе (см. 1.1). (2) memory.rs:270-273schema_version копируется и не сравнивается; сильнее того, memory.rs:242 перезаписывает его константой писателя. (3) inverted.rs:65-66, 74-75CapabilityRow и PurlRow, единственные типы каталога без deny_unknown_fields. (4) crates/vibe-core/src/manifest/subskill.rs:145matches!(self, DeliveryMode::LazyPush | DeliveryMode::LazyPull), не исчерпывающий match: четвёртый режим доставки молча получит «описание не требуется» без ошибки компиляции.

97Не проверяла: «86 полей» целиком (грубый счёт даёт 71 именованное поле в types/ + 12 в двух строках inverted.rs — сходится по порядку, точную сверку не делала).

4. Вердикт по восьми пунктам предложения

981. Тегированное объединение — ПРИНЯТЬ, заменив обоснование. Форма A (#[serde(tag = "kind")]). Обоснование не «однозначность разбора» (она и так есть), а два других: тип становится выразим в языке схем — это и был исходный блокер; и это единственный способ прекратить молчаливое глотание добавленных полей, потому что строгость на untagged серде не даёт. Одним коммитом с правкой золотого теста repomd.rs:132.

992. Запасное значение в каждом закрытом словаре — ИЗМЕНИТЬ, резко сузив. «Каждый» — неверно. Критерий: ветвится ли на этом значении потребитель, и переписывается ли из него каталог.

100DirectoryTag      один вариант, это и есть тег          → НЕТ, бессмысленно
NamingConvention  исчерпывающий match строит пути;
                  у неизвестного нет безопасного
                  поведения                             → НЕТ, громкий отказ верен
PackageKind       примыкает к идентичности; и
                  #[serde(other)] здесь ЗАПРЕЩЁН —
                  он теряет исходную строку, а
                  by-name переписывается (2.1)          → ОТЛОЖИТЬ до решения
                                                          по кодогену; если делать,
                                                          то Unknown(String)
DeliveryMode      подсказка, не идентичность            → ДА, Unknown + мягкая
                                                          деградация
BindingSite       сначала устранить дублирование (1.3);
                  на стороне писателя это экспорт,
                  запас нужен в клиенте                 → ДА, в клиенте

1013. Терпимый читатель каталога — ПРИНЯТЬ ТОЛЬКО В ПАРЕ с сохранением незнакомого, и только на трёх типах. В одиночку пункт превращает громкий отказ в тихое удаление (2.1). Терпимость + flatten-захват на NameEntry / PackageEntry / VersionEntry; экспортные типы (CapabilityRow, PurlRow, строки primary.jsonl) можно оставить строгими даром, у них не бывает round-trip (2.2). Строгость и захват на одном типе несовместимы — решение потипное.

1024. «Отсутствие = не записано; известное пустое пишем явно» — ОТВЕРГНУТЬ в этой формулировке; ЗАМЕНИТЬ на канонизацию. Правило покрывает 21 поле из 35 и оставляет 14 без ответа, потому что null в том же предложении отвергнут. Заявление «одно правило, выживающее в обоих форматах» не выполняется. Двойник принимает решения потипно (см. таблицу в 2.3), и это не небрежность, а норма. Плюс «писать пустое явно» добавит до 21 ключа в каждую запись и раздует primary.jsonl. Взамен — правило из 2.8: одна байтовая форма на одно состояние. Оно решает тот же спор, машинно проверяемо и попутно чинит 2.4.

1035. Версия — шлагбаум на уровне записи — ПРИНЯТЬ, дополнив тремя вещами. Направление верное, и двойник его реализует буквально (index.rs:87). Не хватает: (а) написанного правила подъёма (формулировка — 1.4(б)); (б) решения перестать затирать прочитанное значение константой писателя (memory.rs:242) — иначе шлагбаум сравнивает поле с самим собой; (в) отдельной константы «максимальная понимаемая версия», чтобы шлагбаум был одним сравнением, а не рассуждением. И записать вслух, что первая версия шлагбаума не защищает никого.

1046. Версия в vibe.toml, отсутствие ≠ «считать первой» — ОТВЕРГНУТЬ в текущем обосновании, ПЕРЕФОРМУЛИРОВАТЬ. Эталон выбран неверно. vibe.lockпересоздаваемый черновик: его собственный комментарий говорит «earlier versions are not read… the next vibe install regenerates it» (crates/vibe-core/src/manifest/lockfile.rs:44-46), а шлагбаум там — !=, точное равенство (lockfile.rs:430). Переносить калитку точного равенства, спроектированную для файла, который выбрасывается при каждой установке, на рукописный файл, который обязан жить годами, — категориальная ошибка. Двойник же для машинно-написанного индекса делает ровно наоборот: v: Option<u32>, отсутствие ⇒ 1. Сначала надо ответить, внешняя ли vibe.toml поверхность (см. 5.2); версия — следствие этого ответа, а не наоборот.

1057. Заповедник для чужих ключей — ПРИНЯТЬ, но как явное поле. deny_unknown_fields и flatten несовместимы; заповедник обязан быть именованным полем (tool: BTreeMap<String, toml::Value>), иначе он объявлен и не работает. Плюс тест round-trip: [tool.x] обязан пережить цикл чтение→запись. Механизм сохранения комментариев в доме уже есть (crates/vibe-core/src/manifest/mod.rs, merge_preserving_comments).

1068. Один чекер на три формата — ПРИНЯТЬ, изменив состав правил. Лучшее правило из всех девяти файлов — сверка deny_unknown_fields с тем, что обещает спека (файл 09, R5): оно поймало бы заглавную находку автоматически. Взять. Добавить: канонизация/детерминизм (2.4, 2.8); паритет для каждого продублированного словаря, включая rename_all — сегодня паритет есть только у content_hash и PackageKind, а сломан именно BindingSite (1.3); «нет производного поля без записанного правила пересчёта» (2.6). Обоснование «иначе три набора правил разойдутся» оставить, но иллюстрацию сменить с прозы на BindingSite — она на проводе.

5. Ответы на четыре вопроса владельца

Вопрос 1 — чем служит версия каталога?

107Поставлен неверно. Правильный вопрос: сколько компонент у нашей версии? Ответ «шлагбаум или декларация» не выбирается — он вычисляется из числа компонент (1.4(а)).

108Рекомендация. Оставить одну компоненту и сделать шлагбаум на уровне записи, как у двойника: нет версии → считать 1; выше понимаемой → пропустить запись с одним сообщением, остальное прочитать; ниже → читать. В том же абзаце написать правило подъёма: поднимаем, когда документ перестаёт быть самодостаточным для читателя, отбрасывающего непонятое.

109Но решить это надо сейчас и осознанно, потому что 1 ещё можно потом прочитать как 1.0, а 2 в 1.1 уже не превратить. Если есть хоть какая-то вероятность, что декларация возможностей понадобится, — заводить две компоненты надо до первой публикации, а не после.

110И отдельно: перестать затирать прочитанную версию константой писателя. Пока write_to штампует своё, версия описывает писателя, а не данные.

Вопрос 2 — манифест пакета и манифест проекта: один формат или два?

111Поставлен на шаг позже, чем нужно. Сначала: является ли рукописный vibe.toml вообще внешней поверхностью чтения?

112Код говорит, что нет: Manifest::read / Manifest::parse_str (crates/vibe-core/src/manifest/document.rs) — единственные двери, и их зовёт тот же бинарник, что и писал; внешняя поверхность — каталог, и клиент уже читает его терпимо (crates/vibe-registry/src/index_client/wire.rs:14-16).

113Рекомендация. Оставить каталог единственной внешней поверхностью и записать это как решение — сейчас это случайность реализации, а не выбор. Тогда вопрос «один формат или два» растворяется, и вместе с ним растворяются манифестные половины вопросов 1 и 3. Если же владелец хочет, чтобы чужой инструмент парсил именно vibe.toml пакета, — тогда честный ответ «два», и опубликованная копия должна быть сгенерированной, а не рукописной; но это отдельное проектное решение, и принимать его надо явно, а не через версию поля.

Вопрос 3 — принимаем ли, что отсутствие означает «не записано»?

114Поставлен как вопрос о смысле; полезнее поставить как вопрос о форме.

115Рекомендация — три уровня:

  1. 116Глобально принять не смысл, а канонизацию: одна байтовая форма на одно состояние (2.8). Это то единственное, что действительно должно быть общим для трёх форматов.
  2. Для каталога — оставить как есть (пусто опускаем). Каталог пишет машина, она всегда знает значение каждого поля; состояние «не записано» там законно не наступает. Заодно не раздуваем каждую запись 21 ключом. Но привести к одной конвенции 4 поля-коллекции, которые сегодня пишутся всегда (1.2), — иначе канонизация невозможна.
  3. Различимость «не сказано» сохранить только там, где она реальна — в рукописном манифесте. Это разделение сделал файл 07; при сведении его схлопнули в одно правило, и правило перестало быть верным для обеих сторон.

117Двойник, напомню, принимает эти решения потипно и не стесняется.

Вопрос 4 — с какого момента формат считается опубликованным?

118Дата — не то, что здесь решается.

119Рекомендация. Определить публикацию не датой, а наблюдаемым действием с именем: первый пуш каталога в публичную ветку, на которую третья сторона может приколоться. И принять два честных следствия: (а) по этому определению часы могут пойти в день, когда дефолтный реестр наполнится, — то есть, возможно, раньше, чем кажется; (б) сигнала о появлении читателя не будет никогда, архитектура его не предусматривает (2.7).

120Поэтому не покупать аргумент про свободу по полной цене. Обязательства (вечные псевдонимы, список отставленных имён) действительно можно отложить — их несущий механизм #[serde(alias)] бесплатен в любой момент. А четыре необратимые вещи надо сделать независимо от даты, потому что их закрывает само отсутствие: место под подпись и множество «обязан понять»; отметка об отзыве версии; идентичность алгоритма у content_hash; число компонент версии.

6. Порядок работ

121Первым — дёшево, необратимо, ничьих решений не требует:

  1. 122BindingSite: один источник + паритет-тест на пару (вариант, wire-строка), включая rename_all. Живой дефект, полчаса работы (inverted.rs:88index_client/wire.rs:93).
  2. content_hash: вписать идентичность алгоритма и добавить золотой тест на фиксированное дерево-образец. Окно закрывается; поле — ключ связи с локфайлом.
  3. Решить вопрос воспроизводимости (generated_at, memory.rs:249): если каталог обязан быть воспроизводимым — убрать Utc::now() и добавить тест «пересобрал → байт в байт»; если нет — записать это как решение, а не оставлять как случайность рядом с намеренно детерминированным gzip.
  4. Назвать множество «обязан понять» — до того, как читатель станет терпимым. После этого подпись уже не приделать безопасно.

123Вторым — после ответа владельца по вопросу 2 и после часового спайка по JTD:

  1. 124Тег на объединении (п. 1), одним коммитом с золотым тестом.
  2. Терпимость плюс захват на NameEntry/PackageEntry/VersionEntry; экспортные типы не трогать.
  3. Версия-шлагбаум + написанное правило подъёма + константа «максимум понимаемого» + прекратить затирать прочитанное значение.
  4. Отметка об отзыве версии — если каталог вообще будет описывать отзыв.

125Третьим: чекер (п. 8) с правилами из раздела 4 — но только после того, как хоть одно из правил станет истинным, иначе первый прогон будет красным по всем строкам.

126Чего НЕ делать:

  • 127Не снимать deny_unknown_fields раньше, чем появится захват незнакомого. Это превратит громкий отказ в тихое удаление на шести путях.
  • Не ставить #[serde(other)] на PackageKind. Он теряет исходную строку, а by-name перезаписывается — одно add, и правда уничтожена.
  • Не вводить версию в vibe.toml, пока не решён вопрос внешней поверхности, и не копировать туда калитку != из локфайла.
  • Не раздавать запасное значение всем пяти словарям — двум оно вредно, одному бессмысленно.
  • Не описывать каталог JTD-схемой раньше спайка: пункты 1, 2 и 4 под кодогенерацией конфликтуют между собой (2.9), и узнать это лучше за час, чем за спринт.
  • Не считать «полигон» бесплатным. Каталог дёшев только снаружи; внутри строгое обратное чтение означает, что старый сервер не поднимется на каталоге, написанном новым.

7. Чего я не проверила

  • 128Не запускала cargo build и cargo test. Всё сказанное о поведении выведено из чтения кода, а не из прогона.
  • Поведение serde untagged (лишние ключи не мешают матчу) и несовместимость deny_unknown_fields с flatten — из документации и общего знания serde, не из эксперимента. Оба утверждения несущие; проверяются пятью строками теста.
  • Свойства JTD и jtd-codegen 0.4.1 (закрытые enum, отсутствие «пропусти-когда-пусто», Option<T> у необязательных) — из заметок в crates/vibe-wire/src/lib.rs плюс общих знаний. Непроверено, нужен спайк.
  • Не проверяла ни одной веб-цитаты из файлов 02, 03, 04 — читала их выборочно и в сеть не ходила. Всё про Meta, LinkedIn, Discord, Cloudflare, Homebrew, NuGet, Maven, Debian, RPM у меня осталось непроверенным.
  • Всё про crates.io/cargo, что я утверждаю, взято только из вкопированной в наше дерево копии refs/src/cargo/crates/cargo-util-schemas/. Насколько эта копия свежа относительно верхнего течения — не проверяла.
  • Не проверяла, опубликован ли фактически каталог в github.com/vibespecs — установила только, что это дефолтная публикационная цель в коде и что в этом репозитории repomd.json нет.
  • Число «86 полей» сверила лишь по порядку величины, не поштучно.
  • Не проверяла кросс-платформенную стабильность сортировки в content_hash (files.sort() идёт по PathBuf до нормализации \/). Подозрение на расхождение Windows/Unix есть, доказательства нет — Path::cmp сравнивает по компонентам, что скорее спасает; нужен тест, а не рассуждение. Отдельно реален to_string_lossy() (content_hash.rs:53): два разных не-UTF-8 имени файла могут дать один хэш.
  • Не считала манифесты vibe.toml в дереве и не проверяла число 172 из файла 09.
  • Не смотрела, что происходит при повторной публикации одной и той же (group, name, version) с другими байтами — правила неизменяемости опубликованной версии я в спеке не искала, а это, возможно, ещё одна дыра того же класса, что отметка об отзыве.

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/11-fable-review-and-verdict

.md.xmlllms.txt