Preserve served immutable revision across unchanged source rebuilds
Some checks failed
Build and publish policy-nexus image / build-and-push (push) Failing after 1m41s

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
tegwick 2026-10-11 02:15:29 +02:00
parent 5c41564f04
commit 6d2c7eef77
3 changed files with 66 additions and 6 deletions

View file

@ -573,7 +573,17 @@
"revision_path": "standards/security-layer-model/v0.7/revisions/{revision}/index.html", "revision_path": "standards/security-layer-model/v0.7/revisions/{revision}/index.html",
"review_interval": "3m", "review_interval": "3m",
"lifecycle": "superseded", "lifecycle": "superseded",
"successor": "/standards/security-layer-model/v0.8/" "successor": "/standards/security-layer-model/v0.8/",
"historical_revisions": [
{
"revision": "0.7",
"source_path": "docs/evidence/2026-10-11-layer-v07-immutable-publication/security-layer-model-v0.7.md",
"source_digest": "8155e7b123be1e84377d8278525b1bad8961007b6bb8ff012dd01ba24ff8065b",
"source_revision": "ce198fc2905687ea90a2892346b6860281ac87f8",
"rendered_page_path": "docs/evidence/2026-10-11-layer-v07-immutable-publication/security-layer-model-v0.7.html",
"rendered_page_digest": "7d7a38a8c3b9fff65b46280fcd5723eff77dc9d0784f871bb35f718bc4e8c0e7"
}
]
}, },
{ {
"id": "netkingdom-security-layer-model-v0.8", "id": "netkingdom-security-layer-model-v0.8",

View file

@ -321,6 +321,53 @@ class HistoricalRevisionTest(unittest.TestCase):
self.assertEqual("Original Founder", meta["decided_by"]) self.assertEqual("Original Founder", meta["decided_by"])
self.assertEqual("2026-08-18", meta["date"]) self.assertEqual("2026-08-18", meta["date"])
def test_same_current_frozen_render_survives_new_head_and_superseded_notice(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
manifest, source, page, original = self.prepare(root)
source.write_bytes(page.with_suffix(".md").read_bytes())
data = json.loads(manifest.read_text())
data["documents"][0].update(lifecycle="superseded", successor="/standards/example/v2/")
manifest.write_text(json.dumps(data))
output = root / "clean"
with mock.patch.object(build_site, "_source_revision", return_value="b" * 40):
build_site.build(manifest, output, as_of=dt.date(2026, 8, 18))
frozen = output / "standards/example/v1/revisions/draft-1/index.html"
current = output / "standards/example/v1/index.html"
self.assertEqual(original, frozen.read_bytes())
self.assertIn('policy-source-revision" content="' + "a" * 40, frozen.read_text())
self.assertNotIn("Superseded.", frozen.read_text())
self.assertIn("Superseded.", current.read_text())
self.assertIn('policy-source-revision" content="' + "b" * 40, current.read_text())
self.assertEqual(source.read_bytes(), page.with_suffix(".md").read_bytes())
def test_same_current_frozen_render_rejects_current_source_change(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
manifest, source, page, _original = self.prepare(root)
source.write_bytes(page.with_suffix(".md").read_bytes() + b"Changed current bytes.")
with self.assertRaisesRegex(RuntimeError, "same-current historical source differs"):
build_site.build(manifest, root / "clean")
self.assertFalse((root / "clean").exists())
def test_frozen_current_revision_accepts_source_version_frontmatter(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
manifest, source, page, original = self.prepare(root)
history = page.with_suffix(".md")
history.write_bytes(history.read_bytes().replace(b'revision: "draft-1"', b'version: "draft-1"'))
source.write_bytes(history.read_bytes())
data = json.loads(manifest.read_text())
historical = data["documents"][0]["historical_revisions"][0]
previous_digest = historical["source_digest"]
historical["source_digest"] = hashlib.sha256(history.read_bytes()).hexdigest()
original = original.replace(previous_digest.encode(), historical["source_digest"].encode())
page.write_bytes(original)
historical["rendered_page_digest"] = hashlib.sha256(original).hexdigest()
manifest.write_text(json.dumps(data))
build_site.build(manifest, root / "clean")
self.assertEqual(original, (root / "clean/standards/example/v1/revisions/draft-1/index.html").read_bytes())
def test_changed_historical_page_fails_before_replacing_output(self): def test_changed_historical_page_fails_before_replacing_output(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)

View file

@ -123,12 +123,11 @@ def load_manifest(path: Path) -> dict[str, Any]:
def _restore_historical_revisions( def _restore_historical_revisions(
document: dict[str, Any], repo: Path, output: Path, current_revision: str, document: dict[str, Any], repo: Path, output: Path, current_revision: str,
current_source_digest: str,
) -> None: ) -> None:
"""Retain explicitly pinned served revision bytes even in a clean build.""" """Retain explicitly pinned served revision bytes even in a clean build."""
for historical in document.get("historical_revisions", []): for historical in document.get("historical_revisions", []):
revision = historical["revision"] revision = historical["revision"]
if revision == current_revision:
raise ValueError("historical revision must differ from current revision")
source = (repo / historical["source_path"]).resolve() source = (repo / historical["source_path"]).resolve()
page = (repo / historical["rendered_page_path"]).resolve() page = (repo / historical["rendered_page_path"]).resolve()
for artifact in (source, page): for artifact in (source, page):
@ -137,10 +136,14 @@ def _restore_historical_revisions(
source_bytes, page_bytes = source.read_bytes(), page.read_bytes() source_bytes, page_bytes = source.read_bytes(), page.read_bytes()
if hashlib.sha256(source_bytes).hexdigest() != historical["source_digest"]: if hashlib.sha256(source_bytes).hexdigest() != historical["source_digest"]:
raise RuntimeError("historical source digest mismatch") raise RuntimeError("historical source digest mismatch")
# A served revision can still be the document's current content revision.
# Freeze its old render/provenance only if today's source bytes are exact.
if revision == current_revision and historical["source_digest"] != current_source_digest:
raise RuntimeError("same-current historical source differs from current source")
if hashlib.sha256(page_bytes).hexdigest() != historical["rendered_page_digest"]: if hashlib.sha256(page_bytes).hexdigest() != historical["rendered_page_digest"]:
raise RuntimeError("historical rendered page digest mismatch") raise RuntimeError("historical rendered page digest mismatch")
meta, _ = split_frontmatter(source_bytes.decode("utf-8")) meta, _ = split_frontmatter(source_bytes.decode("utf-8"))
if meta.get("id") != document["id"] or meta.get("revision") != revision: if meta.get("id") != document["id"] or (meta.get("revision") or meta.get("version")) != revision:
raise ValueError("historical source id/revision mismatch") raise ValueError("historical source id/revision mismatch")
page_text = page_bytes.decode("utf-8") page_text = page_bytes.decode("utf-8")
source_revision = SOURCE_REVISION.search(page_text) source_revision = SOURCE_REVISION.search(page_text)
@ -254,9 +257,9 @@ def build(
revision = meta.get("revision") or meta.get("version") revision = meta.get("revision") or meta.get("version")
if not revision: if not revision:
raise ValueError(f"{source}: revision or version is required") raise ValueError(f"{source}: revision or version is required")
_restore_historical_revisions(document, repo, temporary, revision)
source_revision = _source_revision(repo, source)
source_digest = hashlib.sha256(source.read_bytes()).hexdigest() source_digest = hashlib.sha256(source.read_bytes()).hexdigest()
_restore_historical_revisions(document, repo, temporary, revision, source_digest)
source_revision = _source_revision(repo, source)
reviewed = meta.get("last_reviewed") or meta.get("updated") reviewed = meta.get("last_reviewed") or meta.get("updated")
interval = document.get("review_interval") or meta.get("review_interval") interval = document.get("review_interval") or meta.get("review_interval")
if not reviewed or not interval: if not reviewed or not interval: