diff --git a/coulomb_social/apps/spaces/date_backfill_journal.py b/coulomb_social/apps/spaces/date_backfill_journal.py new file mode 100644 index 0000000..5155c63 --- /dev/null +++ b/coulomb_social/apps/spaces/date_backfill_journal.py @@ -0,0 +1,176 @@ +"""Recoverable date correction primitive for a future supervised executor. + +Caller MUST fence every content/SQL writer and verify a fresh immutable backup +before calling. This module neither grants admission nor proves that fencing. +Pending journals require reconciliation before writers may resume. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import re +import tempfile +from pathlib import Path + +from django.db import transaction + +from .bubble_transfer import source_timestamp +from .date_backfill import prepare_index_date_patch +from .models import Space + + +def _digest(data): + return hashlib.sha256(data).hexdigest() + + +def _sync_directory(path): + fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(fd) + finally: + os.close(fd) + + +def _replace(path, data): + previous = path.stat() + fd, temporary = tempfile.mkstemp(prefix=".date-patch-", dir=path.parent) + try: + with os.fdopen(fd, "wb") as stream: + os.fchown(stream.fileno(), previous.st_uid, previous.st_gid) + os.fchmod(stream.fileno(), previous.st_mode & 0o777) + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + _sync_directory(path.parent) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def _index(root, plan): + # Refuse symlinks at every component, not just at index.md. + root = Path(root).absolute() + if any(p.is_symlink() for p in [root, *root.parents]): + raise ValueError("Real content root required") + slug = plan["selection"]["space_slug"] + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", slug): + raise ValueError("Invalid destination slug") + path = root / "spaces" / slug / "pages" / "index.md" + if any(p.is_symlink() for p in [path, *path.parents]): + raise ValueError("Symlink destination refused") + return path.resolve() + + +def _check_pages(index, plan, permitted_index_hashes): + expected = {p["slug"]: p["observed_sha256"] for p in plan["pages"]} + if len(expected) != len(plan["pages"]) or "index" in expected: + raise ValueError("Ambiguous observed pages") + paths = list(index.parent.glob("*.md")) + if any(p.is_symlink() or not p.is_file() for p in paths): + raise ValueError("Real page files required") + observed = {p.stem: _digest(p.read_bytes()) for p in paths} + index_hash = observed.pop("index", None) + if observed != expected or index_hash not in permitted_index_hashes: + raise ValueError("Observed page bytes changed") + + +def _target(plan): + selected = plan["selection"] + return Space.objects.select_for_update().get( + pk=selected["space_id"], tenant_id=selected["tenant_id"], slug=selected["space_slug"]) + + +def _journal_path(path): + path = Path(path).absolute() + if (any(p.is_symlink() for p in [path, *path.parents]) + or path.parent.stat().st_mode & 0o077): + raise ValueError("Private real journal directory required") + return path.resolve() + + +def apply_date_patch(root, plan, journal, *, backup_sha256, checkpoint=lambda phase: None): + """Single attempt. Never automatically retry a pending or completed journal. + + backup_sha256 binds the caller's verified fresh-backup receipt, not its data. + The journal contains private original bytes and must stay outside public content. + checkpoint is a rehearsal fault injector; production callers leave it unset. + """ + if not re.fullmatch(r"[a-f0-9]{64}", backup_sha256): + raise ValueError("Verified backup receipt digest required") + index = _index(root, plan) + journal = _journal_path(journal) + if journal.is_relative_to(Path(root).resolve()): + raise ValueError("Journal must be outside served content") + original = index.read_bytes() + patched = prepare_index_date_patch(plan, original) + record = { + "schema": "coulomb.source-date-journal.v1", "state": "pending", + "plan": plan, "backup_receipt_sha256": backup_sha256, + "original": base64.b64encode(original).decode(), + "patched_sha256": _digest(patched), + } + # Durable intent is created before either store changes. Exclusive creation + # prevents accidental replay after a crash or a successful earlier attempt. + with transaction.atomic(durable=True): + target = _target(plan) + prior = source_timestamp(plan["observed_sql_created_at"]) + desired = source_timestamp(plan["source_created_at"]) + if target.created_at != prior: + raise ValueError("Observed SQL date changed") + _check_pages(index, plan, {_digest(original)}) + fd = os.open(journal, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(json.dumps(record, sort_keys=True).encode()) + stream.flush() + os.fsync(stream.fileno()) + _sync_directory(journal.parent) + checkpoint("intent") + if Space.objects.filter(pk=target.pk, created_at=prior).update(created_at=desired) != 1: + raise ValueError("SQL precondition changed") + checkpoint("sql") + _replace(index, patched) + checkpoint("file") + checkpoint("commit") + record["state"] = "completed" + _replace(journal, json.dumps(record, sort_keys=True).encode()) + + +def reconcile_date_patch(root, journal): + """With writers STILL fenced, use committed SQL to reconcile pending bytes. + + Unknown SQL/file state refuses rather than overwriting a later edit. A lost + commit reply is resolved by the persisted SQL date. Keep the journal as proof. + """ + journal = _journal_path(journal) + if journal.stat().st_mode & 0o077: + raise ValueError("Private journal required") + record = json.loads(journal.read_bytes()) + if record["schema"] != "coulomb.source-date-journal.v1" or record["state"] != "pending": + raise ValueError("Pending journal required") + plan = record["plan"] + index = _index(root, plan) + if journal.is_relative_to(Path(root).resolve()): + raise ValueError("Journal must be outside served content") + original = base64.b64decode(record["original"], validate=True) + patched = prepare_index_date_patch(plan, original) + if _digest(patched) != record["patched_sha256"]: + raise ValueError("Journal patch binding changed") + with transaction.atomic(durable=True): + target = _target(plan) + prior = source_timestamp(plan["observed_sql_created_at"]) + desired = source_timestamp(plan["source_created_at"]) + _check_pages(index, plan, {_digest(original), _digest(patched)}) + if target.created_at == desired: + result, data = "completed", patched + elif target.created_at == prior: + result, data = "rolled_back", original + else: + raise ValueError("Unknown SQL state; manual reconciliation required") + _replace(index, data) + record["state"] = result + _replace(journal, json.dumps(record, sort_keys=True).encode()) + return result diff --git a/docs/release/source-date-journal-rehearsal-2026-10-30.json b/docs/release/source-date-journal-rehearsal-2026-10-30.json new file mode 100644 index 0000000..b614dc6 --- /dev/null +++ b/docs/release/source-date-journal-rehearsal-2026-10-30.json @@ -0,0 +1,46 @@ +{ + "schema": "coulomb.source-date-journal-rehearsal.v1", + "status": "synthetic_crash_recovery_verified", + "sources_sha256": { + "coulomb_social/apps/spaces/date_backfill.py": "84ce5d3369389e1af9ab999aad3804456fd2f66e654e290496be6930cd4885e4", + "coulomb_social/apps/spaces/date_backfill_journal.py": "f3eea06fb9f444d183c06f72ec95dd05e2be576cc8d40f93c935eea819f6d1be", + "tests/test_date_backfill_journal.py": "1d372817d7a72e5b04a53a7aa8a01ed7df10320d7304ed975abaef9fd3965c32", + "tests/conftest.py": "f59a53fa0b1db1eb642ce174da5e2c42ae34faffc00c23debdd5cc83a256a1a6" + }, + "full_gate": { + "command": "make check", + "tests": 328, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "postgres_focused": { + "version": "16.14", + "command": "pytest tests/test_date_backfill_journal.py tests/test_source_date_backfill.py with disposable PostgreSQL settings", + "tests": 30, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + "real_child_process_exit_phases": [ + "intent", + "sql", + "file", + "commit" + ], + "verified": [ + "committed_sql_drives_reconciliation", + "unrelated_page_hashes_preserved", + "unrelated_sql_fields_preserved", + "later_edits_refused", + "unknown_sql_date_refused", + "replay_refused", + "private_journal_and_symlink_guards", + "file_mode_preserved" + ], + "production_access": false, + "production_writes": false, + "recovered_real_pair_rehearsed": false, + "supervised_live_executor_prepared": false, + "disposable_postgres_stopped": true +} diff --git a/docs/release/source-date-journal-review-2026-10-30.md b/docs/release/source-date-journal-review-2026-10-30.md new file mode 100644 index 0000000..a77b3b9 --- /dev/null +++ b/docs/release/source-date-journal-review-2026-10-30.md @@ -0,0 +1,50 @@ +# Creation-date cross-store recovery journal + +Prepared source only. No productive date correction, deployment or acceptance. +WP-0014-T04 still requires a supervised, backup-bound, quiesced executor and +productive postflight. The sealed storage release proposal is unchanged. + +`date_backfill_journal.py` adds a correction primitive to the existing +byte-preserving patch compiler. Its caller must first fence **all** SQL and +content writers and verify a fresh immutable paired backup/readback. A backup +receipt SHA256 is required and retained in the journal; the primitive does not +verify the receipt, its freshness, its content binding or the writer fence. It is +not exposed through a management command or product route. + +The operation locks the selected SQL row and checks its tenant, slug and observed +creation date, plus the exact selected-space page inventory and hashes. It writes +an exclusive, private, fsynced intent containing the original index bytes, plan +and backup-receipt digest before either store changes. Then it updates only +SQL `created_at`, atomically replaces the index with its compiled patch, fsyncs +the file and parent, commits SQL, and marks the journal completed. Replacements +preserve file ownership and mode. Symlink destinations, ambiguous page inventories, +drift and journal replay refuse. Journal parents must be private; journals must +stay outside served content because they contain original private page bytes. + +On interruption, keep writers fenced. `reconcile_date_patch` locks the row and +uses its committed date: the original date restores original index bytes, while +the corrected date finishes the patch. Both the index and every other observed +page must still match their permitted hashes. Unknown SQL state or later edits +refuse without overwriting those edits. A pending intent survives an uncertain +commit result. Keep the terminal journal as evidence; do not retry the original +plan automatically. A malformed/partially created journal requires manual +inspection before writers resume. PostgreSQL/file durability depends on the +underlying storage honoring fsync. + +Validation: `tests/test_date_backfill_journal.py` terminates actual child processes +at durable intent, uncommitted SQL, replaced file and committed SQL. After death, +the tests reconcile against persisted SQL and verify untouched page hashes. +Other cases check successful correction, unrelated SQL fields, replay refusal, +later-edit refusal, unknown SQL date, private permissions and symlinks. Together +with the existing date planner/compiler tests, 30 focused cases pass on both +SQLite and disposable PostgreSQL 16.14. The PostgreSQL instance used synthetic +data, a temporary Docker container and a loopback-only port; it did not connect +to any production/recovered database or provider. The full `make check` gate is +recorded in the companion evidence JSON. + +Remaining wrapper requirements: bind exact reviewed source/plan/target and fresh +backup/readback hashes; verify the external writer fence and full live SQL/content +baseline; supervise the privileged action; refuse outstanding journal state; +retain the journal on durable private storage; perform full unrelated-row/file +postflight and resolve every pending state before resuming writers. Rehearse that +wrapper against the independently recovered real pair before productive use. diff --git a/tests/conftest.py b/tests/conftest.py index 3609b64..3133098 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,8 @@ def django_db_modify_db_settings( """ from django.conf import settings + if settings.DATABASES["default"]["ENGINE"] != "django.db.backends.sqlite3": + return directory = tmp_path_factory.mktemp("csoc-test-db") settings.DATABASES["default"].setdefault("TEST", {})["NAME"] = str( directory / "test.sqlite3" diff --git a/tests/test_date_backfill_journal.py b/tests/test_date_backfill_journal.py new file mode 100644 index 0000000..799ddbb --- /dev/null +++ b/tests/test_date_backfill_journal.py @@ -0,0 +1,133 @@ +"""Real process exits across the SQL/filesystem boundary; synthetic data only.""" + +import hashlib +import json +import os +import subprocess +import sys + +import pytest +from django.conf import settings + +from coulomb_social.apps.spaces.bubble_transfer import source_timestamp +from coulomb_social.apps.spaces.date_backfill_journal import apply_date_patch, reconcile_date_patch +from coulomb_social.apps.spaces.models import Space +from tests.test_source_date_backfill import patch_fixture + +pytestmark = pytest.mark.django_db(transaction=True) + + +@pytest.fixture +def prepared(tmp_path): + plan, original = patch_fixture() + space = Space.objects.create(tenant_id="tenant:synthetic", slug="accepted", title="Local title") + plan["selection"]["space_id"] = str(space.pk) + plan["proposed_changes"][0]["space_id"] = str(space.pk) + Space.objects.filter(pk=space.pk).update( + created_at=source_timestamp(plan["observed_sql_created_at"])) + root = tmp_path / "content" + pages = root / "spaces/accepted/pages" + pages.mkdir(parents=True) + (pages / "index.md").write_bytes(original) + (pages / "index.md").chmod(0o640) + for page in plan["pages"]: + (pages / (page["slug"] + ".md")).write_bytes(b"Synthetic edited bytes") + private = tmp_path / "private" + private.mkdir(mode=0o700) + plan_path = private / "plan.json" + plan_path.write_text(json.dumps(plan)) + return root, plan, private / "journal.json", plan_path, original, space + + +def test_success_preserves_other_bytes_and_sql_fields_and_refuses_replay(prepared): + root, plan, journal, _, original, space = prepared + before = Space.objects.values().get(pk=space.pk) + apply_date_patch(root, plan, journal, backup_sha256="a" * 64) + after = Space.objects.values().get(pk=space.pk) + assert after.pop("created_at") == source_timestamp(plan["source_created_at"]) + before.pop("created_at") + assert after == before + assert json.loads(journal.read_bytes())["state"] == "completed" + assert journal.stat().st_mode & 0o777 == 0o600 + assert (root / "spaces/accepted/pages/index.md").stat().st_mode & 0o777 == 0o640 + assert (root / "spaces/accepted/pages/copy.md").read_bytes() == b"Synthetic edited bytes" + assert original in (root / "spaces/accepted/pages/index.md").read_bytes().replace( + b"source_created_at: 1700000000123\n", b"", 1) + with pytest.raises(ValueError): + apply_date_patch(root, plan, journal, backup_sha256="a" * 64) + + +@pytest.mark.parametrize("phase", ["intent", "sql", "file", "commit"]) +def test_actual_process_death_reconciles_from_committed_sql(prepared, phase): + root, plan, journal, plan_path, original, space = prepared + code = ''' +import json, os, sys +from django.conf import settings +settings.DATABASES["default"] = json.loads(sys.argv[1]) +import django +django.setup() +from coulomb_social.apps.spaces.date_backfill_journal import apply_date_patch +from pathlib import Path +apply_date_patch(Path(sys.argv[2]), json.loads(Path(sys.argv[3]).read_bytes()), + Path(sys.argv[4]), backup_sha256="a"*64, + checkpoint=lambda phase: os._exit(73) if phase == sys.argv[5] else None) +''' + result = subprocess.run( + [sys.executable, "-c", code, json.dumps(settings.DATABASES["default"], default=str), + str(root), str(plan_path), str(journal), phase], + env={**os.environ, "DJANGO_SETTINGS_MODULE": "coulomb_social.settings.test"}, + capture_output=True, timeout=30) + assert result.returncode == 73, result.stderr.decode() + assert json.loads(journal.read_bytes())["state"] == "pending" + committed = phase == "commit" + assert reconcile_date_patch(root, journal) == ("completed" if committed else "rolled_back") + space.refresh_from_db() + assert space.created_at == source_timestamp( + plan["source_created_at"] if committed else plan["observed_sql_created_at"]) + data = (root / "spaces/accepted/pages/index.md").read_bytes() + assert (data != original) == committed + for page in plan["pages"]: + assert hashlib.sha256((root / "spaces/accepted/pages" / ( + page["slug"] + ".md")).read_bytes()).hexdigest() == page["observed_sha256"] + with pytest.raises(ValueError, match="Pending journal"): + reconcile_date_patch(root, journal) + + +@pytest.mark.parametrize("drift", ["index", "other_page", "sql"]) +def test_recovery_refuses_later_edits_or_unknown_sql(prepared, drift): + root, plan, journal, _, _, space = prepared + + def interrupt(phase): + if phase == "file": + raise RuntimeError("interrupted") + + with pytest.raises(RuntimeError): + apply_date_patch(root, plan, journal, backup_sha256="a" * 64, checkpoint=interrupt) + if drift == "sql": + Space.objects.filter(pk=space.pk).update(created_at=source_timestamp(1600000000000)) + else: + path = root / "spaces/accepted/pages" / ( + "index.md" if drift == "index" else "copy.md") + path.write_bytes(b"later local edit") + before = journal.read_bytes() + with pytest.raises(ValueError): + reconcile_date_patch(root, journal) + assert journal.read_bytes() == before + if drift != "sql": + assert path.read_bytes() == b"later local edit" + + +def test_private_directory_and_destination_symlinks_refused_before_writes(prepared): + root, plan, journal, _, _, _ = prepared + journal.parent.chmod(0o755) + with pytest.raises(ValueError, match="Private"): + apply_date_patch(root, plan, journal, backup_sha256="a" * 64) + assert not journal.exists() + journal.parent.chmod(0o700) + index = root / "spaces/accepted/pages/index.md" + held = index.with_suffix(".held") + index.rename(held) + index.symlink_to(held) + with pytest.raises(ValueError, match="Symlink"): + apply_date_patch(root, plan, journal, backup_sha256="a" * 64) + assert not journal.exists() diff --git a/workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md b/workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md index c5c1f56..fbcd2ae 100644 --- a/workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md +++ b/workplans/CSOC-WP-0014-cohort-transfer-bubble-offramp.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: coulomb-social created: "2026-10-08" -updated: "2026-10-29" +updated: "2026-10-30" origin: residual origin_ref: CSOC-WP-0008 state_hub_workstream_id: "db6b85dc-f2f0-5283-ac52-3a6af28eb5b5" @@ -164,6 +164,18 @@ docs/release/source-date-backfill-rehearsal-2026-10-29.json and its companion re note. Production is untouched. A supervised quiesced executor with a durable cross-store recovery journal and fresh backup binding remains required. +2026-10-30: Added a durable cross-store date journal primitive. Intent is fsynced +before SQL/file mutation; pending recovery uses committed SQL to restore or finish +the byte-preserving index patch and refuses later edits. Private journal storage, +complete selected-page hashes, SQL row locking, replay refusal and file ownership/ +mode preservation are enforced. Actual child-process exits at intent/SQL/file/ +commit boundaries recover correctly on both SQLite and disposable PostgreSQL; +30 focused planner/journal cases pass. No productive/recovered dataset was changed. +Review/evidence: docs/release/source-date-journal-review-2026-10-30.md and +source-date-journal-rehearsal-2026-10-30.json. The supervised wrapper, verified +writer fence, fresh backup binding, recovered-real-pair rehearsal and productive +postflight remain required; this primitive does not establish those prerequisites. + ## Completion Finish when every task above has its stated evidence and acceptance, or hand off any newly discovered residual to a live work record first. Cross-owner dependencies remain their existing canonical records.