release: web/package-lock.json is a version carrier

The ESLint layer added an npm lockfile that repeats the web package
version at its root (the root object and packages[""]); npm ci tolerates
a drift there, the P9 "carriers in sync" contract does not. The lockfile
joins VERSION_CARRIER_SPANS, sync_release_metadata rewrites both root
entries and version_carrier_desyncs checks them; dependency versions
inside the lockfile are untouched. ARCHITECTURE and DEVELOPMENT name the
new carrier.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-09-05 16:18:42 +00:00 • committed by Anton Razzhigaev
parent 83f64a2d85
commit 841cf121c9
4 changed files with 58 additions and 2 deletions

View file

@ -1601,7 +1601,7 @@ Bounded foreground `run_command`/`run_script` processes ride the in-memory `_act
## 10. Key Invariants
1. **Constitution and identity persist.** `BIBLE.md` is never deleted; `identity.md` remains a physical file even when its content evolves.
2. **Release metadata has one projection.** `VERSION` is canonical; `ouroboros/tools/release_sync.py::version_carrier_desyncs()` and `sync_release_metadata()` keep the PEP 440 form in `pyproject.toml` and the editable root entry in `uv.lock`, plus the author-facing version in `web/package.json`, `web/modules/api_types.js::GATEWAY_CONTRACT_VERSION`, the README badge, and this document's header. Changelog prose remains deliberate. Pull requests into `ouroboros` leave these carriers byte-identical to their target; integration assigns the release version.
2. **Release metadata has one projection.** `VERSION` is canonical; `ouroboros/tools/release_sync.py::version_carrier_desyncs()` and `sync_release_metadata()` keep the PEP 440 form in `pyproject.toml` and the editable root entry in `uv.lock`, plus the author-facing version in `web/package.json` and both root entries of `web/package-lock.json`, `web/modules/api_types.js::GATEWAY_CONTRACT_VERSION`, the README badge, and this document's header. Changelog prose remains deliberate. Pull requests into `ouroboros` leave these carriers byte-identical to their target; integration assigns the release version.
3. **Configuration and messaging have single owners.** `ouroboros/config.py` is the one IMPORT surface for paths and settings, and the vocabularies live in its leaves — `settings_defaults` (keys, shipped defaults and the retired-key lists), `settings_scales` (the closed clamps), `model_slots` (slot resolution and the frozen model target), `review_model_routes` (the API-pinned reviewer lists), `runtime_limits` (numeric knobs and their clamps) and `settings_integrity` (the verified read primitive) — so a new key belongs to a leaf, never to the facade; messages go through `supervisor/message_bus.py`; concurrent state transitions use the owning file lock.
4. **The attempt ledger is monetary authority.** `state/usage_attempts.jsonl` records every physical model send. State, task, event, and UI totals are projections carrying attempt identity; unknown or unresolved cost never becomes false zero.
5. **Packaged bootstrap is manifest-bound.** A packaged install verifies `repo.bundle` and its manifest once, then runs the managed checkout. Restart preserves its local tip; only explicit update applies an approved exact SHA.

View file

@ -1043,7 +1043,7 @@ preserve authorship, and run the normal final exact-candidate gate.
A pull request into `ouroboros` leaves every version carrier byte-identical to
its target: `VERSION`, `pyproject.toml`, the editable root version in
`uv.lock`, `web/package.json`,
`uv.lock`, `web/package.json`, `web/package-lock.json` (both root entries),
`web/modules/api_types.js::GATEWAY_CONTRACT_VERSION`, the README badge and
latest Version History row, the named direct-download links in README and both
install pages, and the Architecture header. At integration,

View file

@ -58,6 +58,14 @@ _ARCH_HEADER_RE = re.compile(
re.MULTILINE | re.IGNORECASE,
)
# web/package-lock.json (npm lockfileVersion 3) repeats the package version twice at the
# top: the root object and its packages[""] entry; both are carriers (npm ci tolerates a
# drift, the P9 "carriers in sync" contract does not).
_WEB_LOCK_VERSION_RE = re.compile(
r'(^\{\s*"name"\s*:\s*"[^"\n]*",\s*"version"\s*:\s*")([^"\n]*)(")'
r'|(^\s*""\s*:\s*\{\s*"name"\s*:\s*"[^"\n]*",\s*"version"\s*:\s*")([^"\n]*)(")',
re.MULTILINE,
)
_UV_LOCK_ROOT_RE = re.compile(
r'^(\[\[package\]\]\nname = "ouroboros"\nversion = ")([^"]+)'
r'("\nsource = \{ editable = "\." \})',
@ -166,6 +174,9 @@ VERSION_CARRIER_SPANS: Tuple[VersionCarrierSpan, ...] = (
"web_package_version", "web/package.json",
re.compile(r'^\s*"version"\s*:\s*"[^"\n]*"', re.MULTILINE),
),
VersionCarrierSpan(
"web_package_lock_version", "web/package-lock.json", _WEB_LOCK_VERSION_RE,
),
VersionCarrierSpan(
"gateway_contract_version", "web/modules/api_types.js",
re.compile(r"GATEWAY_CONTRACT_VERSION\s*=\s*'[^'\n]*'"),
@ -426,6 +437,7 @@ def version_carrier_desyncs(
pyproject_text: str = "",
uv_lock_text: str = "",
web_package_text: str = "",
web_package_lock_text: str = "",
readme_text: str = "",
arch_text: str = "",
api_types_text: str = "",
@ -453,6 +465,11 @@ def version_carrier_desyncs(
match = re.search(r'"version"\s*:\s*"([^"]+)"', web_package_text)
if not match or match.group(1).strip() != version:
desync.append(f'web/package.json (expected "version": "{version}")' if detailed else "web/package.json")
if web_package_lock_text:
found = [m.group(2) or m.group(5) for m in _WEB_LOCK_VERSION_RE.finditer(web_package_lock_text)]
if len(found) != 2 or any(v.strip() != version for v in found):
desync.append(f'web/package-lock.json (expected both root "version" entries = "{version}")'
if detailed else "web/package-lock.json")
if readme_text:
badge_token = f"version-{_shields_escape(version)}-green"
if extract_readme_badge_version(readme_text) != version or badge_token not in readme_text:
@ -502,6 +519,7 @@ def check_worktree_version_sync(repo_dir) -> str:
pyproject_text=_read("pyproject.toml"),
uv_lock_text=_read("uv.lock"),
web_package_text=_read("web/package.json"),
web_package_lock_text=_read("web/package-lock.json"),
readme_text=_read("README.md"),
arch_text=_read("docs/ARCHITECTURE.md"),
api_types_text=_read("web/modules/api_types.js"),
@ -571,6 +589,18 @@ def sync_release_metadata(repo_dir: str) -> List[str]:
web_package.write_text(new_text, encoding="utf-8")
changed.append("web/package.json")
web_lock = root / "web" / "package-lock.json"
if web_lock.exists():
text = web_lock.read_text(encoding="utf-8")
new_text = _WEB_LOCK_VERSION_RE.sub(
lambda m: (f"{m.group(1)}{version}{m.group(3)}" if m.group(1) is not None
else f"{m.group(4)}{version}{m.group(6)}"),
text,
)
if new_text != text:
web_lock.write_text(new_text, encoding="utf-8")
changed.append("web/package-lock.json")
api_types = root / "web" / "modules" / "api_types.js"
if api_types.exists():
text = api_types.read_text(encoding="utf-8")

View file

@ -63,6 +63,13 @@ def _make_repo(tmp_path: Path, version: str = "4.99.1") -> Path:
'{\n "name": "ouroboros-web",\n "version": "0.0.0"\n}\n',
encoding="utf-8",
)
(web / "package-lock.json").write_text(
'{\n "name": "ouroboros-web",\n "version": "0.0.0",\n "lockfileVersion": 3,\n'
' "packages": {\n "": {\n "name": "ouroboros-web",\n "version": "0.0.0",\n'
' "devDependencies": {"eslint": "10.10.0"}\n },\n "node_modules/eslint": {\n'
' "version": "10.10.0"\n }\n }\n}\n',
encoding="utf-8",
)
modules = web / "modules"
modules.mkdir()
(modules / "api_types.js").write_text(
@ -160,6 +167,25 @@ class TestSyncReleaseMetadata:
text = (repo / "web" / "package.json").read_text()
assert '"version": "1.2.3-rc.4"' in text
def test_syncs_web_package_lock_root_versions_only(self, tmp_path):
repo = _make_repo(tmp_path, "1.2.3-rc.4")
changed = sync_release_metadata(str(repo))
assert "web/package-lock.json" in changed
text = (repo / "web" / "package-lock.json").read_text(encoding="utf-8")
assert text.count('"version": "1.2.3-rc.4"') == 2 and '"version": "10.10.0"' in text
def test_web_package_lock_participates_in_desync_checks(self, tmp_path):
repo = _make_repo(tmp_path, "1.2.3-rc.4")
sync_release_metadata(str(repo))
lock = repo / "web" / "package-lock.json"
lock.write_text(lock.read_text(encoding="utf-8").replace('"version": "1.2.3-rc.4"', '"version": "1.2.3-rc.3"', 1),
encoding="utf-8")
desync = version_carrier_desyncs(
"1.2.3-rc.4", detailed=True,
web_package_lock_text=lock.read_text(encoding="utf-8"),
)
assert desync == ['web/package-lock.json (expected both root "version" entries = "1.2.3-rc.4")']
def test_web_package_json_participates_in_desync_checks(self, tmp_path):
repo = _make_repo(tmp_path, "1.2.3-rc.4")
sync_release_metadata(str(repo))