Journal creation-date corrections across SQL and content with crash recovery
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
This commit is contained in:
tegwick 2026-10-30 20:05:11 +01:00
parent fd80975d37
commit fb4baf6712
6 changed files with 420 additions and 1 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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.

View file

@ -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"

View file

@ -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()

View file

@ -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.