Journal attachment intent before copy and record accepted retention policy
Some checks failed
Use-case regression gate / usecase-regressions (push) Has been cancelled

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a18e2d-f513-7742-b8e7-12af0b744ff3
This commit is contained in:
tegwick 2026-10-31 12:54:44 +01:00
parent f70708839c
commit f306dbfa26
5 changed files with 242 additions and 10 deletions

View file

@ -288,6 +288,7 @@ def _recovery_dir():
if path.is_symlink():
raise PageOpsError("Content paths must not be symlinks")
path.mkdir(parents=True, exist_ok=True)
_fsync_directory(path.parent)
return path
@ -660,7 +661,7 @@ _BODY_ASSET = re.compile(
)
def _copy_assets(source_space, dest_space, dest_page, visual, body, on_created=None):
def _copy_assets(source_space, dest_space, dest_page, visual, body, before_create=None):
"""Copy local Visual and Markdown link/image destinations as one staged set."""
from .assets import asset_parts, open_local_asset
@ -691,14 +692,25 @@ def _copy_assets(source_space, dest_space, dest_page, visual, body, on_created=N
)
dest_name = f"{dest_page}{suffix}-{parts[-1]}"
with source_file:
destination = assets_dir(dest_space) / dest_name
# The enclosing content writer lock serializes asset creation.
# Refuse existing entries before recording cleanup authority,
# then persist intent before any partial destination can exist.
try:
destination.lstat()
except FileNotFoundError:
pass
else:
raise FileExistsError("Destination attachment already exists")
if before_create is not None:
before_create(dest_name)
with open_local_asset(dest_space, dest_name, create=True) as stream:
created.append(assets_dir(dest_space) / dest_name)
created.append(destination)
shutil.copyfileobj(source_file, stream)
stream.flush()
os.fsync(stream.fileno())
_fsync_directory(destination.parent)
copied[path] = f"assets/{quote(dest_name)}"
if on_created is not None:
on_created(dest_name)
suffix = (f"?{parsed.query}" if parsed.query else "")
suffix += f"#{parsed.fragment}" if parsed.fragment else ""
return copied[path] + suffix

View file

@ -0,0 +1,58 @@
{
"schema": "coulomb.content-lifecycle-validation.v1",
"recorded_at": "2026-10-31T11:54:24.215512+00:00",
"status": "source_fix_verified",
"workplan_task": "CSOC-WP-0013-T05",
"bug": "A process exit during attachment creation left bytes absent from the copy/move cleanup journal; the orphan blocked retry.",
"regression_observed_before_fix": {
"baseline": "f70708839cd5ca43310f6891c8d94aaff915139c",
"operations": [
"copy_page",
"transfer_page"
],
"interruption": "partial first attachment flushed, immediate os._exit(86)",
"result": "both failed because the partial destination survived recovery"
},
"fix": [
"Refuse existing attachment entries before recording cleanup authority.",
"Persist intended new attachment name before exclusive creation under the content writer lock.",
"Flush recovery and attachment directory entries before destination publication."
],
"focused_cases": {
"total": 29,
"abrupt_child_process_exit": 14,
"existing_destination_collision": 8,
"prior_retention_and_recovery": 7
},
"full_gate": {
"command": "make check",
"tests": 546,
"failures": 0,
"errors": 0,
"skipped": 0,
"warnings": 69,
"junit_sha256": "dd3d0433c8109f00f6dddd119cfdf8281915f94da2da1f5e6009c31cfe76c65b",
"log_sha256": "eb59a394c95c5307c17069874640d1655ef61c5aa7bb426ef6d21db4929f748e"
},
"files": [
{
"path": "coulomb_social/apps/spaces/pageops.py",
"sha256": "055ef25b5efc75798cf17ffa68739de4d4099d2cab4d041e7c7eacdfaf43803e"
},
{
"path": "tests/test_content_recovery.py",
"sha256": "84a25a22a3cbda397e39c0508db8ea5aa90e6c467ccfa7da2b568d174d1ec449"
}
],
"policy": "User accepted retain-all default and separately reviewed manual cutoff; keep live page and all history of deleted pages.",
"live_purge_authorized": false,
"live_purge_executed": false,
"production_deployed": false,
"productive_acceptance": false,
"limits": [
"Child process termination on synthetic local files is proved; physical power-loss durability is not experimentally proved.",
"Cooperating application/import writers must use the content writer lock.",
"Release delivery and productive copy/move review remain open."
],
"policy_decision_id": "c3a191f2-ce06-483f-82d7-ef8c54ea0d5e"
}

View file

@ -3,11 +3,12 @@
Review on `https://app.coulomb.social`, using a disposable page in an intended
space and a second destination space. Use an explicitly chosen reader and an
ungranted same-tenant member; keep their identities and real notes private.
The storage release is deployed and independently verified at Helm revision 18,
image digest `512819463a1902b43a615beee14be7bf97e6a05e016385082b9380692ad1a8cd`
(source 17d0eb6). See the [release evidence](../release/storage-release-review-2026-10-31.md). Record the
actual release/date with each verdict. Later source changes are not implicitly
part of that image. Automated checks do not supply these verdicts.
The last independently verified application release is Helm revision 19, image
`522bcd7fce92436765a60c1e4afa2598f3cc40913352153ff1c4319a99ca92ed`
(source 23ffe2c). See the [release return](../release/identity-export-release-return-2026-10-31.md).
Record the actual release/date with each verdict. Later source changes, including
the attachment interruption fix below, are not implicitly part of that image.
Automated checks do not supply these verdicts.
| Journey | Review steps | Expected result |
| --- | --- | --- |
@ -47,3 +48,35 @@ Review the body-missing records as source exceptions; never substitute an empty
page. Card samples disable resource loading and interaction, so image/resource
fidelity needs the separate authorized asset review. Then apply UC-003–006 above
to disposable pages on the exact intended release.
## Revision and interrupted-operation lifecycle — WP-0013-T05
Policy accepted by Bernd on 2026-10-31 (decision c3a191f2-ce06-483f-82d7-ef8c54ea0d5e): retain every page revision by default.
There is no scheduled revision purge. A separately reviewed manual purge may remove
only inactive revisions older than an explicit timezone-aware cutoff. Keep current
page bytes and all retained revisions of deleted pages. Validate every revision
hash before deleting any candidate. Backup copies have their own T04 retention
obligations; purging a local revision does not remove it from a backup. Policy
acceptance and any concrete deletion authorization are separate decisions.
Policy acceptance is recorded. No cutoff has been selected
and no productive purge has run. Local history/retention does not replace independent
paired recovery or the requirement to preserve original source creation dates.
Source investigation reproduced a process-crash gap in attachment copy/move:
partial attachment bytes could exist before their name entered the recovery journal.
The corrected source records the intended new attachment before exclusive creation,
after refusing any existing entry, under the existing content writer lock. Attachment
bytes and directory entries are flushed before publishing the destination page.
Recovery discards unconfirmed destinations or finishes a confirmed move, preserving
the source unless a matching destination is confirmed. It never grants destination
access through an old address. All application/import writers must use the same
content lock; raw concurrent filesystem edits are outside that serialization model.
Synthetic subprocesses terminate abruptly at 14 copy/move interruption points,
including first/second partial attachments, page publication, alias publication and
source deletion. Eight collision cases preserve existing files, directories and
symlinks. Existing retention/incoming-address permission tests remain required.
Evidence: `../release/content-lifecycle-validation-2026-10-31.json`. This is source
and isolated-fixture evidence; the fix still needs release delivery and productive
review. Do not kill or mutate the productive application to perform this review.

View file

@ -156,3 +156,119 @@ def test_purge_stops_before_deleting_when_a_revision_hash_does_not_match(trees):
with pytest.raises(pageops.PageOpsError, match="integrity"):
pageops.purge_revisions(older_than=past)
assert len(list((trees / ".revisions" / "source" / "notes").glob("*.md"))) == 2
@pytest.mark.parametrize('operation,phase', [
(operation, phase)
for operation in ('copy_page', 'transfer_page')
for phase in ('before_asset_create', 'during_first_asset', 'during_second_asset',
'after_assets', 'after_page', 'published')
] + [('transfer_page', phase) for phase in ('aliased', 'source_deleted')])
def test_process_death_during_copy_or_move_is_recoverable(trees, operation, phase):
import subprocess
import sys
source = _notes('Retained content [attachment](assets/second.txt)')
(pageops.assets_dir('source') / 'second.txt').write_bytes(b'synthetic attachment')
original = Path(source.path).read_bytes()
child = r"""
import os, sys
from django.conf import settings
settings.configure(CONTENT_ROOT=sys.argv[1])
from coulomb_social.apps.spaces import pageops, assets
phase = sys.argv[3]
def stop():
os._exit(86)
original_open = assets.open_local_asset
def opening(*args, **kwargs):
if kwargs.get('create') and phase == 'before_asset_create':
stop()
return original_open(*args, **kwargs)
assets.open_local_asset = opening
original_copy, count = pageops.shutil.copyfileobj, 0
def copying(source, destination):
global count
count += 1
if phase == 'during_first_asset' or (phase == 'during_second_asset' and count == 2):
destination.write(source.read(4))
destination.flush()
os.fsync(destination.fileno())
stop()
original_copy(source, destination)
pageops.shutil.copyfileobj = copying
original_write = pageops.write_page
def writing(*args, **kwargs):
if phase == 'after_assets':
stop()
result = original_write(*args, **kwargs)
if phase == 'after_page':
stop()
return result
pageops.write_page = writing
original_record = pageops._write_record
def recording(record):
result = original_record(record)
if record['phase'] == phase:
stop()
return result
pageops._write_record = recording
original_delete = pageops.delete_page
def deleting(*args, **kwargs):
original_delete(*args, **kwargs)
if phase == 'source_deleted':
stop()
pageops.delete_page = deleting
getattr(pageops, sys.argv[2])('source', 'notes', 'destination', 'notes')
"""
result = subprocess.run([sys.executable, '-c', child, str(trees), operation, phase],
capture_output=True, timeout=15)
assert result.returncode == 86, result.stderr.decode()
assert pageops.recover_interrupted_content() == {'cleared': 1, 'held': 0}
published = phase in {'published', 'aliased', 'source_deleted'}
if published and operation == 'transfer_page':
assert not Path(source.path).exists()
assert pageops.page_aliases('source')['notes'] == ['destination', 'notes']
else:
assert Path(source.path).read_bytes() == original
assert pageops.page_aliases('source') == {}
assert pageops.read_page('destination', 'notes').exists == published
if not published:
assert not list(pageops.assets_dir('destination').iterdir())
copied = getattr(pageops, operation)('source', 'notes', 'destination', 'notes')
assert copied.exists
destination = pageops.assets_dir('destination')
assert (destination / 'notes-diagram.png').read_bytes() == b'synthetic image'
assert (destination / 'notes-second.txt').read_bytes() == b'synthetic attachment'
assert not list((trees / '.recovery').glob('*.json'))
assert pageops.recover_interrupted_content() == {'cleared': 0, 'held': 0}
@pytest.mark.parametrize('operation', ['copy_page', 'transfer_page'])
@pytest.mark.parametrize('kind', ['file', 'directory', 'symlink', 'dangling_symlink'])
def test_existing_attachment_never_becomes_recovery_cleanup_authority(trees, operation, kind):
source = _notes()
original = Path(source.path).read_bytes()
destination = pageops.assets_dir('destination') / 'notes-diagram.png'
if kind == 'file':
destination.write_bytes(b'existing attachment')
elif kind == 'directory':
destination.mkdir()
else:
target = (pageops.assets_dir('source') / 'diagram.png' if kind == 'symlink'
else trees / 'missing')
destination.symlink_to(target)
before = destination.lstat()
with pytest.raises(pageops.PageOpsError, match='Cannot copy'):
getattr(pageops, operation)('source', 'notes', 'destination', 'notes')
assert destination.lstat() == before
if kind == 'file':
assert destination.read_bytes() == b'existing attachment'
assert Path(source.path).read_bytes() == original
assert not pageops.read_page('destination', 'notes').exists
assert pageops.recover_interrupted_content() == {'cleared': 0, 'held': 0}

View file

@ -196,13 +196,26 @@ Captured-source audit and private source/product cards cover 245 records/242 bod
```task
id: CSOC-WP-0013-T05
status: todo
status: progress
priority: high
state_hub_task_id: "c2986a84-4da2-5fb3-b021-7bbe5104fe47"
```
Explicit lifecycle portion of T02: review the existing revision purge/retention policy and failed multi-file copy/move recovery, preserve source/incoming-address authorization, and obtain the required operational/product acceptance. Reuse existing source and crash-recovery tests; investigate concrete gaps before adding implementation. No live purge is authorized by task creation. Close with the agreed policy and evidence for the intended lifecycle.
2026-10-31 lifecycle investigation: actual child-process termination reproduced an
orphaned attachment in both copy and move: destination bytes were created before
the cleanup journal recorded the filename. Corrected source persists intent before
exclusive creation, refuses existing destination entries before recording cleanup
authority, and flushes attachment/recovery directory entries before publication.
Fourteen abrupt-exit scenarios exercise rollback/finish/retry across the multi-file
operation; eight collision cases preserve existing entries. This advances T05 from
todo to progress. Evidence: docs/release/content-lifecycle-validation-2026-10-31.json.
The review guide separates retention-policy acceptance, exact purge authorization,
release delivery and productive review. User accepted the retain-all/manual-cutoff policy; decision c3a191f2-ce06-483f-82d7-ef8c54ea0d5e. No cutoff,
production purge, image deployment or user acceptance is inferred. The current
verified release remains revision19/image522bcd7f until a new owner return.
## 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.