Journal creation-date corrections across SQL and content with crash recovery
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled
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:
parent
fd80975d37
commit
fb4baf6712
6 changed files with 420 additions and 1 deletions
176
coulomb_social/apps/spaces/date_backfill_journal.py
Normal file
176
coulomb_social/apps/spaces/date_backfill_journal.py
Normal 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
|
||||
46
docs/release/source-date-journal-rehearsal-2026-10-30.json
Normal file
46
docs/release/source-date-journal-rehearsal-2026-10-30.json
Normal 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
|
||||
}
|
||||
50
docs/release/source-date-journal-review-2026-10-30.md
Normal file
50
docs/release/source-date-journal-review-2026-10-30.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
133
tests/test_date_backfill_journal.py
Normal file
133
tests/test_date_backfill_journal.py
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue