diff --git a/publication.json b/publication.json index 64312f9..b074049 100644 --- a/publication.json +++ b/publication.json @@ -573,7 +573,17 @@ "revision_path": "standards/security-layer-model/v0.7/revisions/{revision}/index.html", "review_interval": "3m", "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", diff --git a/tests/test_publication.py b/tests/test_publication.py index 2f79ba2..769a966 100644 --- a/tests/test_publication.py +++ b/tests/test_publication.py @@ -321,6 +321,53 @@ class HistoricalRevisionTest(unittest.TestCase): self.assertEqual("Original Founder", meta["decided_by"]) 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): with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tools/build_site.py b/tools/build_site.py index a08900e..61a0472 100644 --- a/tools/build_site.py +++ b/tools/build_site.py @@ -123,12 +123,11 @@ def load_manifest(path: Path) -> dict[str, Any]: def _restore_historical_revisions( document: dict[str, Any], repo: Path, output: Path, current_revision: str, + current_source_digest: str, ) -> None: """Retain explicitly pinned served revision bytes even in a clean build.""" for historical in document.get("historical_revisions", []): revision = historical["revision"] - if revision == current_revision: - raise ValueError("historical revision must differ from current revision") source = (repo / historical["source_path"]).resolve() page = (repo / historical["rendered_page_path"]).resolve() for artifact in (source, page): @@ -137,10 +136,14 @@ def _restore_historical_revisions( source_bytes, page_bytes = source.read_bytes(), page.read_bytes() if hashlib.sha256(source_bytes).hexdigest() != historical["source_digest"]: 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"]: raise RuntimeError("historical rendered page digest mismatch") 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") page_text = page_bytes.decode("utf-8") source_revision = SOURCE_REVISION.search(page_text) @@ -254,9 +257,9 @@ def build( revision = meta.get("revision") or meta.get("version") if not revision: 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() + _restore_historical_revisions(document, repo, temporary, revision, source_digest) + source_revision = _source_revision(repo, source) reviewed = meta.get("last_reviewed") or meta.get("updated") interval = document.get("review_interval") or meta.get("review_interval") if not reviewed or not interval: