From 8a976fbc47fa1f02fd060ca29de8037eb1146a29 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:11:36 +0000 Subject: [PATCH 1/2] Number staged candidates past every bound version apply_updates always named a candidate active+1, but /admin/validate binds each version to its first checksum permanently (and persists the binding). Stage, validate, then stage a different edit produced the same version with a new checksum, and every later stage was refused with 422 "already bound" until the abandoned candidate was reproduced exactly. Staging now numbers past every version the lifecycle has bound under the active version's date. Also turns a non-finite number (JSON Infinity) in a numeric field into a 422 instead of an uncaught OverflowError 500. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lj4neZ1qzw7tpiCJ8oedxJ --- src/cerberus/app.py | 4 ++- src/cerberus/control/admin_fields.py | 33 ++++++++++++++++++----- src/cerberus/control/lifecycle.py | 6 +++++ tests/test_admin_fields.py | 40 ++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/src/cerberus/app.py b/src/cerberus/app.py index b91b273..2834654 100644 --- a/src/cerberus/app.py +++ b/src/cerberus/app.py @@ -557,7 +557,9 @@ async def admin_config_stage(request: Request) -> JSONResponse: }, ) try: - candidate = apply_updates(lifecycle.active.config, updates) + candidate = apply_updates( + lifecycle.active.config, updates, taken_versions=lifecycle.known_versions + ) except ValueError as exc: return JSONResponse(status_code=422, content={"staged": False, "error": str(exc)}) except ValidationError as exc: diff --git a/src/cerberus/control/admin_fields.py b/src/cerberus/control/admin_fields.py index b034202..ea142b6 100644 --- a/src/cerberus/control/admin_fields.py +++ b/src/cerberus/control/admin_fields.py @@ -19,6 +19,7 @@ import re import time import uuid +from collections.abc import Iterable from pathlib import Path from typing import Any @@ -115,18 +116,33 @@ def build_schema(config: CerberusConfig) -> dict[str, Any]: return {"sections": [_SECTION], "fields": fields, "version": config.metadata.version} -def _next_version(current: str) -> str: +def _next_version(current: str, taken: Iterable[str] = ()) -> str: + """The next unused ``.N`` under the current version's date. + + Every version /admin/validate has seen is bound to its first checksum for + good, so numbering from the active version alone would hand a second, + different edit the number an abandoned candidate already holds — and every + later stage would collide with it too. + """ + m = _VERSION_RE.match(current) if m is None: raise ValueError(f"unrecognized version format: {current!r}") - return f"{m.group(1)}.{int(m.group(2)) + 1}" + prefix, highest = m.group(1), int(m.group(2)) + for version in taken: + other = _VERSION_RE.match(version) + if other is not None and other.group(1) == prefix: + highest = max(highest, int(other.group(2))) + return f"{prefix}.{highest + 1}" -def apply_updates(config: CerberusConfig, updates: dict[str, Any]) -> CerberusConfig: +def apply_updates( + config: CerberusConfig, updates: dict[str, Any], *, taken_versions: Iterable[str] = () +) -> CerberusConfig: """Apply an allow-listed set of edits on top of the active config and - return a new, re-validated CerberusConfig with its version auto-bumped — - never the same version string bound to two different byte-for-byte - documents (the exact drift this session found and flagged earlier).""" + return a new, re-validated CerberusConfig with its version auto-bumped past + every version in ``taken_versions`` — never the same version string bound + to two different byte-for-byte documents.""" allowed = editable_keys(config) rejected = set(updates) - allowed @@ -145,6 +161,9 @@ def apply_updates(config: CerberusConfig, updates: dict[str, Any]) -> CerberusCo else: coerced = value _set_path(dumped, key, coerced) + except OverflowError as exc: + # int(float("inf")): JSON accepts Infinity, and it must be a 422, not a 500 + raise ValueError(f"{key!r} must be a finite number") from exc except (KeyError, TypeError) as exc: # allow-listed keys are built from real provider names (editable_keys), # so this only fires if a provider name itself contains "." — an @@ -152,7 +171,7 @@ def apply_updates(config: CerberusConfig, updates: dict[str, Any]) -> CerberusCo # clean rather than let the traversal crash the request raise ValueError(f"cannot resolve editable field {key!r}: {exc}") from exc if updates: - dumped["metadata"]["version"] = _next_version(config.metadata.version) + dumped["metadata"]["version"] = _next_version(config.metadata.version, taken_versions) return CerberusConfig.model_validate(dumped) diff --git a/src/cerberus/control/lifecycle.py b/src/cerberus/control/lifecycle.py index 9a27814..e402051 100644 --- a/src/cerberus/control/lifecycle.py +++ b/src/cerberus/control/lifecycle.py @@ -40,6 +40,12 @@ def active(self) -> ConfigDocument: def shadow(self) -> ConfigDocument | None: return self._shadow + @property + def known_versions(self) -> frozenset[str]: + """Every version bound to a checksum, active or not.""" + + return frozenset(self._version_checksums) + def validate(self, path: str) -> ConfigDocument: """Validate and permanently bind a version to its first observed checksum.""" diff --git a/tests/test_admin_fields.py b/tests/test_admin_fields.py index c33beb1..615b794 100644 --- a/tests/test_admin_fields.py +++ b/tests/test_admin_fields.py @@ -146,3 +146,43 @@ async def test_stage_validate_activate_loop_changes_live_behavior(env, tmp_path) active = await client.get("/admin/config/active") assert active.json()["providers"]["alpha"]["quota_cooldown_seconds"] == 45 + + +def test_version_skips_numbers_already_bound(config): + taken = {"cerberus-2026-07-16.2", "cerberus-2026-07-16.3", "cerberus-2026-01-01.9"} + updated = apply_updates(config, {"providers.alpha.quota_cooldown_seconds": 5}, taken_versions=taken) + assert updated.metadata.version == "cerberus-2026-07-16.4" + + +def test_non_finite_number_is_a_value_error(config): + with pytest.raises(ValueError, match="finite"): + apply_updates(config, {"providers.alpha.quota_cooldown_seconds": float("inf")}) + + +@pytest.mark.asyncio +async def test_restaging_after_an_abandoned_candidate_validates(env, tmp_path): + """Stage, validate, change your mind, stage again: the second candidate + must get a fresh version rather than the one the first is bound to.""" + + raw = raw_config("cerberus-2026-07-16.1") + raw["state"] = {"path": str(tmp_path / "state.sqlite3")} + doc = load_config_document(write_config(tmp_path, "cfg.yaml", raw)) + app = create_app(doc, http_transport=httpx.MockTransport(ok_upstream)) + key = "providers.alpha.quota_cooldown_seconds" + async with app.router.lifespan_context(app): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + first = (await client.post("/admin/config/stage", json={"updates": {key: 100}}, headers=CSRF)).json() + assert (await client.post("/admin/validate", json={"path": first["path"]}, headers=CSRF)).status_code == 200 + + second = (await client.post("/admin/config/stage", json={"updates": {key: 200}}, headers=CSRF)).json() + assert second["version"] == "cerberus-2026-07-16.3" + validated = await client.post("/admin/validate", json={"path": second["path"]}, headers=CSRF) + assert validated.status_code == 200, validated.text + + infinite = await client.post( + "/admin/config/stage", + content='{"updates": {"%s": Infinity}}' % key, + headers={**CSRF, "content-type": "application/json"}, + ) + assert infinite.status_code == 422 From a2881660714301e7a5480f7317ac28920aee689d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 09:14:50 +0000 Subject: [PATCH 2/2] Use a localhost Host in the re-staging test Keeps the test valid once loopback trust also requires a local Host. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Lj4neZ1qzw7tpiCJ8oedxJ --- tests/test_admin_fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_admin_fields.py b/tests/test_admin_fields.py index 615b794..3a0fc28 100644 --- a/tests/test_admin_fields.py +++ b/tests/test_admin_fields.py @@ -171,7 +171,7 @@ async def test_restaging_after_an_abandoned_candidate_validates(env, tmp_path): key = "providers.alpha.quota_cooldown_seconds" async with app.router.lifespan_context(app): transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client: first = (await client.post("/admin/config/stage", json={"updates": {key: 100}}, headers=CSRF)).json() assert (await client.post("/admin/validate", json={"path": first["path"]}, headers=CSRF)).status_code == 200