Keeping 50+ Version-Bearing Files in Sync, Atomically
A monorepo the size of Synoema accumulates version numbers the way a ship accumulates barnacles. The Rust workspace has one. The MCP workspace has another. Four npm platform packages each carry their own. The VSCode extension has its own scheme with the pre-release suffix stripped because the Marketplace forbids it. Documentation badges, README lines, serverInfo strings, HTTP user-agent headers — every one of them is a place where a number can drift out of alignment.
On 2026-04-14, exactly that happened. A version mismatch between lang/Cargo.toml and three of the four npm/platforms/*/package.json files triggered an npm resolve failure the moment a user ran npx @delimitter/synoema-mcp. The binary existed. The wrapper existed. The platform resolver looked at the optional-dependencies table, saw a version that no longer matched anything in the registry, and crash-looped. This article is what we built so that does not happen again.
The problem, in one paragraph
Before the fix, the Synoema monorepo had 50+ version-bearing files spread across seven categories: Cargo workspace manifests, inter-crate path dependencies, npm packages, the VSCode extension, documentation badges, runtime strings compiled via env!("CARGO_PKG_VERSION"), and release directory names. Six of them were separately editable. Bumping the language version meant doing a careful sweep, hoping to catch every literal, and praying CI would save you if you missed one. It usually didn't. The 2026-04-14 incident was the third such crash in a year.
The solution, in one number
One source of truth: lang/Cargo.toml, the key [workspace.package].version. Everything else cascades from that value. A bump script reads it, writes it to every other place it needs to appear, and a verify script refuses to let a commit through if any of those derived values drift out of alignment.
To verify it right now:
grep -m1 '^version' lang/Cargo.toml
# -> version = "0.1.0-beta.1"
Every other version-bearing file in the repository either inherits this value automatically (Cargo workspace inheritance, env!("CARGO_PKG_VERSION") for runtime strings) or is written from it by tooling. No manual edits to derived values — ever. Hand-edited version bumps are the drift surface; we removed it.
Taxonomy: five classes of versioned thing
Not every versioned entity in the monorepo follows the same rules. A research corpus does not bump every time the compiler does. The Cranelift library version has its own cadence. Licence files are not versioned at all — they are registered and checked for presence. To reason about 100+ entities without drowning, we split them into five classes:
| Class | What it covers | Sync mechanism | Example entities |
|---|---|---|---|
| A — Monolithic | User-facing ecosystem components that must all carry the same version | Cascades from lang/Cargo.toml via workspace inheritance, CI bumps, and manual sync; enforced by verify-versions.sh | Lang crates, MCP workspace, npm main + 4 platform packages, VSCode extension, doc badges, lang/wasm, lang/package.json dep range |
| B — Linked | Entities tied to the lang version with an explicit latency rule | Per-entity tag or manifest; release checklist + sno doctor warnings | Finetune corpora, embedder packs (≤ 1 lang-version behind), RAG index manifests, GGUF naming, article snapshots |
| C — Independent | Entities with their own lifecycle and scheme | Per-entity SemVer; detection-by-failure or dedicated audit pass | sno packages (sno.toml), SKILL.md bundles, Cranelift family pin, TLS exception deps, research papers, OpenSpec changes |
| D — Implicit ABIs | Contracts whose breakage surfaces at runtime or link time | Mostly undeclared; detection-by-failure (segfault, wasm-validate reject, JSON-RPC 400) | JIT tagged-pointer ABI, WASM v3 codegen constants, MCP protocol version literal, GBNF grammars, FFI surface (196 extern "C" fns), diagnostic JSON schema |
| E — Infrastructure & policy | Licences, SPDX headers, CI workflows, hot-file manifests, resource registries | File-presence checks, SPDX header scanner, verify-docs.sh URL allow-list | Root and per-directory LICENSE, SPDX headers on every .rs/.ts, RESOURCES.yaml, hot-files table in SYNC_PROTOCOL.md |
Class A is where the cascade lives and where most of the mechanical work happens. Class B is where release discipline matters — corpora must be revalidated against the compiler before they ship. Class C is where "you do what the tool tells you" works: semver parse errors, resolver failures, and sno pkg add diagnostics catch mistakes at the point of use. Class D is the scariest bucket — most of those contracts have no literal version string, and a break is detected only when something crashes. Class E is about presence and policy rather than version numbers as such.
The cascade, in a diagram
Bumping the lang version touches a lot of files, but the relationships between them form a clean tree. Here is what flows from where:
lang/Cargo.toml [workspace.package].version = "0.1.0-beta.1"
|
|-- workspace inheritance (automatic, zero edits)
| |
| +-- lang/crates/synoema-codegen/Cargo.toml
| +-- lang/crates/synoema-core/Cargo.toml
| +-- lang/crates/synoema-eval/Cargo.toml
| +-- lang/crates/synoema-lsp/Cargo.toml
| +-- lang/crates/synoema-parser/Cargo.toml
| +-- lang/crates/synoema-repl/Cargo.toml
| +-- lang/crates/synoema-types/Cargo.toml
| +-- lang/crates/synoema-lexer/Cargo.toml
| +-- lang/crates/synoema-context/Cargo.toml
| +-- lang/crates/synoema-embed/Cargo.toml
| +-- lang/crates/synoema-diagnostic/Cargo.toml
|
|-- inter-crate path-dep literals (cargo requires explicit for pre-release)
| +-- each lang/crates/*/Cargo.toml version = "0.1.0-beta.1"
|
|-- mcp/Cargo.toml (separate workspace, manual literal)
| +-- mcp/synoema-mcp/Cargo.toml (workspace-inherited)
|
|-- lang/wasm/Cargo.toml (standalone package, manual literal)
|
|-- npm/synoema-mcp/package.json (CI-bumped from git tag)
| +-- npm/platforms/darwin-arm64/package.json
| +-- npm/platforms/darwin-x64/package.json
| +-- npm/platforms/linux-x64/package.json
| +-- npm/platforms/win32-x64/package.json
| +-- optionalDependencies table (must match all 4 platforms)
|
|-- vscode-extension/package.json (Marketplace-stripped: X.Y.Z only)
|
|-- lang/package.json (dep range ^X.Y.Z)
|
|-- documentation badges (manual edits, verify-versions checks)
| +-- README.md:6
| +-- docs/versioning.md:6
| +-- docs/mcp.md:6
| +-- docs/mcp.md:323,327,331 (3 release-download URLs)
| +-- docs/mcp.md:578 (serverInfo example literal)
| +-- docs/install.md:6
| +-- context/PROJECT_STATE.md:6
|
|-- context/METRICS.md (auto-regenerated by scripts/metrics.sh)
|
+-- runtime strings (automatic, via env!("CARGO_PKG_VERSION"))
+-- sno / synoema binary --version output
+-- MCP serverInfo JSON-RPC response
+-- LSP serverInfo
+-- HTTP user-agents in pkg-install and rag-install
+-- sno build-index metadata.synoema_version field
The branches on the left are automatic — workspace inheritance and env! macro expansion require zero edits. The branches on the right are where a human or a bump script has to write the new value. That is exactly the surface we automated.
Enforcement: verify at pre-commit, block on drift
Every commit on beta-1 runs through scripts/verify-versions.sh. It implements 12 rules, returns exit 0 when everything aligns, and refuses to let a commit through when anything drifts. Today, on HEAD, it exits 0 with 12 of 12 rules passing.
scripts/verify-versions.sh --quiet
# -> exit 0 on HEAD: 12 / 12 rules passing
The 12 rules check, in order: MCP workspace sync, README badge, docs/versioning.md current-version line, docs/mcp.md badge plus three download URLs plus the serverInfo example, npm main package, npm platform packages, npm optionalDependencies consistency, VSCode extension stripped version, lang/package.json dependency, inter-crate path-dep literals, hardcoded literal bans in .rs/.ts/.js, and lang/wasm crate version.
Each rule is a grep plus a comparison. A violation prints a clear "expected X, found Y at path:line" message and returns non-zero. The pre-commit hook runs the script silently and fails fast; CI runs it as a required status check on every pull request.
When you actually need to bump — say, to release 0.2.0-beta.1 — you don't hand-edit anything. You run the bump script:
scripts/bump-version.sh 0.2.0-beta.1 --note "Stage 4 initiative close"
The bump script takes an atomic snapshot of every file it is about to touch, performs the full cascade (workspace, inter-crate literals, MCP, npm main and platforms, optionalDependencies, VSCode extension, lang/package.json dep range, documentation badges, release-download URLs, serverInfo example, PROJECT_STATE version line, lang/wasm crate, METRICS.md regeneration), runs verify-versions.sh at the end to confirm the cascade landed cleanly, and rolls back the snapshot if anything fails mid-flight. For npm-only patches — a wrapper fix that does not need a new binary — a thinner script just bumps the NPM_N counter without touching the language version:
scripts/npm-bump-version.sh 0.1.0-beta.1.1 # npm wrapper fix, same binary
The split exists because the npm scheme X.Y.Z-STAGE.LANG_N.NPM_N lets us publish wrapper fixes without re-spinning the entire language release. Every platform package and the optionalDependencies table get the same NPM_N; everything else stays frozen.
Why this matters for LLM-generated code
Synoema is built on the premise that LLM-generated code should be cheaper, faster, and more reliable than LLM-generated Python or TypeScript. Most of our claims about that rely on stable tooling. An AI agent calling Synoema through npx @delimitter/synoema-mcp expects the MCP binary to respond to initialize with a matching protocol version. A user-agent header like sno/0.1.0-beta.1 (rag-install) tells the RAG manifest server what to return. The MCP tool schemas, the diagnostic JSON shape, the GBNF grammars used for constrained decoding — all of them are ABIs that other software depends on.
Version drift breaks those chains at runtime. The 2026-04-14 npm crash would have been invisible to anyone running cargo test locally; it only surfaced when a real user ran the published package on a real machine. That is a failure mode you cannot accept in tooling that markets itself on reliability. Tight enforcement at pre-commit time means the first time a mismatch can ship is never — not at release-candidate, not at tag push, not at npm publish.
For LLM agents specifically, the system gives one extra property: predictable serverInfo. When an agent asks an MCP server for its version, the string it gets back is bit-identical to what the install command wrote to disk, to what the HTTP user-agent reports, and to what the sno --version CLI prints. Every surface an agent can introspect carries the same value. That is boring and necessary.
For contributors
If you add a new version-bearing file — a new npm package, a new documentation badge, a new crate in a separate workspace — the registry needs to learn about it. The registry lives at context/VERSIONS.md. The rules live at context/RULES.md §9. Any new entity must be placed in exactly one of the five classes and sync-listed in the registry; verify-versions.sh must then be updated to cover it if the entity is Class A (monolithic).
For end-user release policy — what the version string means, the SemVer scheme, the pre-release suffix convention — the human-facing document is docs/versioning.md. That is where you look first if you are integrating Synoema into a downstream pipeline and need to know when a MAJOR bump might affect you.
Links
- context/VERSIONS.md — canonical registry of every versioned entity in the monorepo, grouped into five classes
- context/RULES.md §9 — versioning rules §9.1 through §9.7, including the machine-checkability matrix
- scripts/verify-versions.sh — 12-rule pre-commit and CI drift detector
- scripts/bump-version.sh — atomic cascade with snapshot and rollback
- scripts/npm-bump-version.sh — thin
NPM_N-only wrapper bump for npm patches - docs/versioning.md — end-user policy document