fix(update-tx): explicit null schema stamp reads corrupt, not legacy-unstamped

F3 adversarial fix-round 2, claim 1 (HIGH): read_update_tx_strict used a
plain .get(), so a marker carrying an explicit _schema_version: null was
indistinguishable from the accepted pre-7.0 UNSTAMPED form and read valid —
rollback would then interpret and destructively act on a damaged stamp. A
dict.get sentinel now distinguishes key ABSENCE (legacy, valid) from a stored
null (corrupt, like every other non-integer stamp; no writer ever stamps
null). Pin: null-stamped marker reads ("corrupt", {}) and the direct
rollback entry point refuses typed with the marker byte-identical, HEAD
unmoved and dirty owner work untouched; None joined the invalid-stamp loop.

Protected surface supervisor/update_merge.py: delta sanctioned by this
fix-round and minimal (one sentinel + docstring).
This commit is contained in:
Ouroboros 2026-09-01 08:24:34 +00:00
parent d1d131dfe1
commit 9daf100bfd
2 changed files with 46 additions and 9 deletions

View file

@ -53,6 +53,10 @@ UPDATE_TX_MARKER_NAME = "ouroboros-update-tx.json"
# the stamp is one additive key its reader ignores.
UPDATE_TX_SCHEMA_VERSION = 1
# ``dict.get`` sentinel distinguishing a MISSING ``_schema_version`` key (the
# accepted pre-7.0 unstamped form) from an explicit stored ``null`` (corrupt).
_SCHEMA_STAMP_ABSENT = object()
def managed_update_constitution_present(ref: str = "HEAD") -> bool:
"""Whether *ref* keeps the non-empty regular BIBLE.md blob required by P4."""
@ -217,12 +221,14 @@ def read_update_tx_strict() -> Tuple[str, Dict[str, Any]]:
``"future"``. A marker that exists but is unreadable/invalid is ``corrupt`` — callers
MUST fail closed (block mutative update/commit ops) rather than treat it as ``absent``.
Schema admission (F14 N−1 shim): an UNSTAMPED marker is the accepted pre-7.0
form and reads ``valid`` — the boot finalizer must drive a transaction the
N−1 updater recorded. An integer stamp above ``UPDATE_TX_SCHEMA_VERSION`` is
``future`` (recorded by a newer release; the raw tx is returned as evidence
but no caller may interpret or overwrite it); a non-integer stamp is
``corrupt``."""
Schema admission (F14 N−1 shim): an UNSTAMPED marker — the key ABSENT —
is the accepted pre-7.0 form and reads ``valid``: the boot finalizer must
drive a transaction the N−1 updater recorded. An integer stamp above
``UPDATE_TX_SCHEMA_VERSION`` is ``future`` (recorded by a newer release;
the raw tx is returned as evidence but no caller may interpret or
overwrite it); ANY present non-integer stamp — an explicit ``null``
included — is ``corrupt`` (no writer ever stamps ``null``, so it is a
damaged stamp, not the legacy unstamped form)."""
import json
path = _update_tx_marker_path()
@ -236,8 +242,8 @@ def read_update_tx_strict() -> Tuple[str, Dict[str, Any]]:
return "corrupt", {}
from ouroboros.contracts.schema_versions import SCHEMA_VERSION_KEY
stamp = raw.get(SCHEMA_VERSION_KEY)
if stamp is not None:
stamp = raw.get(SCHEMA_VERSION_KEY, _SCHEMA_STAMP_ABSENT)
if stamp is not _SCHEMA_STAMP_ABSENT:
if isinstance(stamp, bool) or not isinstance(stamp, int) or stamp < 1:
return "corrupt", {}
if stamp > UPDATE_TX_SCHEMA_VERSION:

View file

@ -86,7 +86,7 @@ def test_invalid_stamps_are_corrupt_and_newer_stamps_are_future(tmp_path, monkey
repo, head = _init_repo(tmp_path)
_point_at(monkeypatch, tmp_path, repo, head)
marker = update_merge._update_tx_marker_path()
for bad_stamp in ("1", True, 0, -3):
for bad_stamp in ("1", True, 0, -3, None):
atomic_write_json(marker, {SCHEMA_VERSION_KEY: bad_stamp, "phase": "x"})
assert update_merge.read_update_tx_strict()[0] == "corrupt", bad_stamp
atomic_write_json(
@ -97,6 +97,37 @@ def test_invalid_stamps_are_corrupt_and_newer_stamps_are_future(tmp_path, monkey
assert status == "future" and tx["phase"] == "x" # raw evidence returned
def test_explicit_null_stamp_is_corrupt_and_rollback_refuses_untouched(tmp_path, monkeypatch):
"""Adversarial fix-round 2, claim 1: an explicit ``_schema_version: null``
is a DAMAGED stamp, not the legacy unstamped form (only key ABSENCE is) —
it reads ``corrupt`` and the direct rollback entry point refuses typed
BEFORE any marker write or destructive reset/checkout/clean."""
from ouroboros.utils import atomic_write_json
repo, head = _init_repo(tmp_path)
_point_at(monkeypatch, tmp_path, repo, head)
cur = _git(repo, "rev-parse", "HEAD").stdout.strip()
marker = update_merge._update_tx_marker_path()
atomic_write_json(
marker,
{
SCHEMA_VERSION_KEY: None, "phase": "rolling_back",
"pre_update_sha": cur, "pre_update_branch": head,
},
trailing_newline=True,
)
before = marker.read_bytes()
assert update_merge.read_update_tx_strict() == ("corrupt", {})
dirty = repo / "uncommitted.txt"
dirty.write_text("owner work the rollback must not clean away\n")
ok, msg = update_merge.rollback_managed_update("null_stamp_probe")
assert ok is False and "pre_update_sha" in msg # refuses on the empty corrupt tx
assert marker.read_bytes() == before # byte-identical, never re-phased
assert _git(repo, "rev-parse", "HEAD").stdout.strip() == cur
assert dirty.read_text() == "owner work the rollback must not clean away\n"
# ------------------------------------ N−1 fixtures through the boot finalizer