Repository source: docs/plans/history_purge/history_purge_step4_implementation_summary.md
Debug mode: enabled.
Effort study: railspike-effort / v3/history_purge; ledger, standing P2 enrollment.
Extended estimates: enabled.
Cycle estimate: 0.80
Entry — 2026-10-30 UTC
Step 3 explicitly approved ("approved step 3"). Branch feat/history-purge from main at 4abda3db; planning commit 7dfc7118. Scope v1/A, baseline 2e17c971, R1/P2, Claude single exposed rater. Step 4 entry whole-cycle 0.80 reaffirmed. Original stage work S1–S7/v1: 0.15 / 0.10 / 0.20 / 0.10 / 0.10 / 0.10 / 0.15, non-additive; Stage 7 owns shared acceptance and closeout. All stages pending at entry. No measured labor or accepted outcome.
Environment limit found at entry. Step 2 and Step 3 name a QDB-derived repository copy for the sizing gate and acceptance. No such copy exists in this or any sibling checkout: the largest local repository has 814 posts and 122 commits, and the local_repository_qdb copies hold 21 posts. Stage 1 will measure on a synthetic repository shaped like QDB (tens of thousands of post files across thousands of commits) and record it as synthetic, not QDB-derived. This weakens the evidence for the known-environment condition and is reported in the handoff; it does not change scope.
— Claude · 2026-10-30 UTC · Model: claude-fable-5-1
Stage 1 - Sizing gate and purge planner
Original estimate: 0.15 Retrospective stage-work assessment: 0.20
Changes:
src/ForumRewrite/Retraction/HistoryPurgePlanner.php:plan()builds the worklist from accepteddeletereceipts (post and signature candidates intersected with one history walk ofrecords/posts), counts total and affected commits, lists legacy posts lackingCreated-Atand arecords/post-timestamps/record, and reports blockers (missing_git,dirty_tree,interrupted_import,legacy_timestamps_missing,no_targets).timeDryRun()clones with--no-hardlinksand runs the real rewrite in the clone, returning clone and rewrite seconds, total, rewritten and remaining commit counts. The legacy check streams post bytes from onegit cat-file --batchprocess instead of opening each file.src/ForumRewrite/Retraction/HistoryRewrite.php: the shared rewrite primitive.rewrite(root, paths)streamsgit fast-export --all --no-data --show-original-idsthrough a path filter intogit fast-import --force --export-markson the same repository and returns the old-to-new commit map for every commit. Path validation is restricted torecords/; a.git/history-rewritemarker refuses concurrent rewrites.remainingCommits()counts commits on any ref still touching the paths.tests/HistoryPurgePlannerTest.php,tests/HistoryRewriteTest.php(registered intests/run.php);tests/Support/history_purge_sizing_fixture.phpbuilds a synthetic QDB-shaped repository withgit fast-import.
Sizing gate (synthetic repositories from the fixture; WSL2, 12 cores, Git 2.34.1; small ~150-byte posts; 10 deletions spread across history so nearly every commit is rewritten):
100,000 posts, 5,010 commits, 60 MB
.git.filter-branch --index-filter: 2,215 of 5,010 commits rewritten after 17.5 minutes, about 2 commits per second and slowing as the index grew; stopped, projected over 40 minutes. fast-export engine:plan()8.1 s, clone 23.6 s, rewrite 14.1 s, reflog expiry plusgc --prune=now4.5 s; 0 commits touching targets afterward.100,000 posts, 100,010 commits (one commit per post, the organic shape since each signed post is one commit), 115 MB
.git. fast-export engine:plan()32.0 s, clone 20.5 s, rewrite 19.0 s, reflog expiry plus gc 15.4 s; 0 commits touching targets afterward.filter-branchnot attempted; projected hours.With blob data piped instead of
--no-data, the same rewrites took 31 s and 72 s;--no-datareferences existing blobs by id and makes rewrite cost independent of post size.Environment limit stands: no QDB-derived copy exists locally, so these are synthetic figures, not the Step 2 known-environment replay. Stage 7 still owes a run on real receipts.
Engine decision: fast-export stream rewrite, built in Git, in place. The user confirmed the switch on 2026-10-31 ("switch to fast-export"). This is a third engine, not either of the two the Step 3 plan named; the Option A contract (command surface,
Key: valuecommit map, mandatory rebuild, verification, runbook) is unchanged, and the horizon-squash fallback is not needed. The map comes from fast-export original ids joined with fast-import marks, which removes the Stage 3 "map capture" risk. Commit count, order, messages, authors and dates are preserved; a commit that only touched a purged path becomes an empty commit so the map stays one to one; the HEAD tree id is unchanged when the targets were already deleted at HEAD. Signed tags are stripped and commit signatures are dropped by fast-export; the application does not sign commits. Old objects remain in the pack until Stage 3 expires reflogs and prunes.Verification:
php tests/run.php HistoryRewriteTest HistoryPurgePlannerTest: 5 run, 5 passed. Covers worklist and blockers, no-receipt repository reportingno_targets, dry run leaving the source untouched, every old commit mapped to a distinct new commit on live refs, purged paths absent from every ref, surviving blobs and receipt bytes identical, dates and messages preserved, empty commit retained, path and concurrency refusals.Dry runs on both synthetic repositories above:
remaining_commits0 andrev-list --all -- <target>empty after gc; source repositories unchanged.Full suite
php tests/run.php: 4 failures, none in this stage's files.LocalAppSmokeTestprivate lobby and forte session tests and the repository archive filename test fail identically on a clean HEAD worktree (inherited).WriteApiSmokeTestfailed on a different gpg detach-sign command in each of two runs, which is environment flakiness, not a code path this stage touches. The first full run also sawReadModelBuilderTimingTestandPlatformDocsStaticTestfailures that the runner records as long-standing.Not applicable: UI, themes, roles, migration and deployment; nothing user-facing changed.
Notes:
plan()at 100k commits spends most of its 32 s in the history walk; acceptable for an operator preview, revisit only if Stage 6 shows it matters.Stage 3 material revision (new understanding, not changed scope): original 0.20 (0.15–0.25) revised to 0.15 (0.12–0.18) because the rewrite primitive and map capture now exist; Stage 3 keeps lock, ref snapshot, restore-on-failure, reflog expiry, prune and verification. Whole-cycle 0.80 reaffirmed independently, not by summing stage deltas.
Estimate details (extended): original scope S1/v1 planner plus timed dry run, 0.15 (0.10–0.20). Retrospective 0.20: the gate needed a working engine to time, so the rewrite primitive and commit-map capture moved forward from Stage 3; two engines were measured instead of one. No measured labor claimed.
— Claude · 2026-10-31 UTC · Model: claude-fable-5-1
Stage 2 - Commit map record contract
Original estimate: 0.10 Retrospective stage-work assessment: 0.10
Changes:
docs/specs/history_rewrite_record_v1.md: the record. Pathrecords/history-rewrites/history-rewrite-<YYYYMMDD>T<HHMMSS>Z.txt, headersFormat: history-rewrite-v1,Rewritten-At,Reason: purge,Receipt-Count,Part,Partsin fixed order; body ofOld-Commit/New-Commitblocks, strictly ascending by old identity, one to one, identity mappings omitted. Size bound: at most 100,000 blocks per file (about 10.7 MB, under the 16 MiB record limit); a larger run continues in-<part>files. Instance-local:ArchiveRecordCatalogdoes not list the directory, so exports and imports ignore it, as Step 2 requires.src/ForumRewrite/Retraction/CommitMap.php:encode(rewrittenAt, receiptCount, map)returns path => bytes for every part and self-validates;decode(bytes, path?)is the strict validator and refuses a filename that disagrees with its headers;write(root, files)refuses to overwrite, which is the "second run in the same second" rule;records(root)lists parts in resolution order;resolve(root, sha)applies every record in order and returns the newest identity, or null when no record names it. Resolution scans record bytes for the block rather than parsing 100,000 mappings per lookup, with a format-line check and a shape check on the hit.GenericTextRecordParserandRecordLimitsreused; no new parser.src/ForumRewrite/Canonical/SourcePathValidator.php: the record path is a valid canonical record path, so/source/blobcan show it once committed.src/ForumRewrite/Activity/ActivityService.php: the file role label "history rewrite record" for the purge commit in Activity, beside the other record families.tests/CommitMapTest.php, registered intests/run.php.
Verification:
php tests/run.php CommitMapTest: 4 run, 4 passed. Covers round trip with sorting and dropped identity mappings, an empty run, the 100,000-block split into parts with correctPart/Parts, sixteen malformed variants rejected (format, reason, timestamp, leading-zero count, part out of range, header order, extra header, identity mapping, duplicate old, duplicate new, out of order, uppercase hex, short hash, unknown block field, unterminated block, CRLF), path mismatch, many-to-one and negative count refused, chained resolution across two records to the latest identity, case-insensitive lookup, unmapped and malformed identities returning null, stray files in the directory ignored, overwrite refused.php tests/run.php CommitMapTest HistoryRewriteTest HistoryPurgePlannerTest ActivityItemLinkTest ActivityProjectionTest: 12 run, 12 passed for the activity suites on the label change, 9 run, 9 passed for the purge suites. No test exercisesSourcePathValidatordirectly; the new pattern is asserted inCommitMapTest.Not applicable: UI, themes, roles, migration and deployment; no read-model schema change; no dependency change.
Notes:
Routes consult the map only after Git reports a commit unknown (Stage 5), so a live identity is never redirected; the spec states this.
Stage 4 will call
encodewith theHistoryRewrite::rewritemap andwritebefore committing; Stage 3 keeps the engine free of record knowledge.
Estimate details (extended): original 0.10 (0.08–0.12). Retrospective 0.10: the strict codec was the driver as planned; the part split was the only addition beyond the Step 3 sketch, forced by the 100,010-commit shape Stage 1 measured against the 16 MiB record limit. Whole-cycle 0.80 reaffirmed. No measured labor claimed.
— Claude · 2026-11-01 UTC · Model: claude-fable-5-1
Stage 3 - Rewrite engine with restore and verification
Original estimate: 0.20, revised at Stage 1 to 0.15 Retrospective stage-work assessment: 0.15
Changes:
src/ForumRewrite/Retraction/HistoryPurge.php:run(root, databasePath, plan)takes the sharedforum-rewrite.lockand callsexecute(root, plan). Phases: snapshot every ref, the HEAD symref and every blob ever stored at a target path into a.git/history-purge/journal (Key: valuetext);HistoryRewrite::rewrite; verify that no commit on any ref touches a target and that every pre-rewrite ref is mapped (annotated tags excepted); on any failure up to here, restore the snapshotted refs, clear the journal and throw "Purge aborted before any history was discarded; original refs restored". Then the point of no return: remove any target still in the working tree, reset the index, delete stale pseudo-refs (ORIG_HEAD,FETCH_HEAD,MERGE_HEAD,CHERRY_PICK_HEAD,REBASE_HEAD,REVERT_HEAD), deleterefs/original/*, expire all reflogs including HEAD's,gc --prune=now; finallylingering()askscat-file --batch-checkwhether any old target blob or any changed old commit id still exists anywhere in the object database. An empty answer clears the journal; otherwise the journal stays at phaselingering, the result reportsverified: falsewith the ids, andrestore()refuses because old history is gone and the backup is the rollback.interrupted(root)reads the journal;restore(root)puts the refs back when the journal shows a phase before prune.execute()restores an interrupted run itself before purging, so no new CLI verb is needed for recovery.src/ForumRewrite/Retraction/HistoryPurgePlanner.php: new blockerinterrupted_purgewhile a journal exists, so the nextpreviewsays so.tests/HistoryPurgeTest.php, registered intests/run.php.HighTrafficSafety::beforeWriteis deferred to Stage 4, where the map record is the first write that changes served content; the rewrite itself leaves the HEAD tree unchanged.
Verification:
php tests/run.php HistoryPurgeTest HistoryPurgePlannerTest HistoryRewriteTest CommitMapTest: 13 run, 13 passed. The fixture has a side branch, a lightweight tag, a staleORIG_HEAD, an edited then deleted post with its signature, and a local receipt. Covered: full purge withverifiedtrue, three target blobs and four changed commits absent from the object database (cat-file -efails for each),fsck --unreachable --no-reflogsempty, receipt and surviving post byte-identical, dates, messages, HEAD tree and symref unchanged, every ref on its mapped commit, clean working tree, journal gone, a second plan reportingno_targets; an injected failure after rewrite restoring refs exactly with the prose still in history and a following purge succeeding; a purged blob deliberately kept alive in a.keeppack failing verification with the id reported, the planner reportinginterrupted_purge,restore()and a rerun both naming the backup; a simulated kill at phaserewritewith moved and stray branches restored byrestore(), and a journal atverify-refsrestored automatically byexecute()before a successful purge.Scale (synthetic fixture, same machine as Stage 1, in
phpwith phase timings from the result): 100,000 posts / 5,010 commits: plan 4.9 s; execute 6.4 s total (snapshot 0.2, rewrite 3.8, verify refs 0.3, prune 2.0, verify objects 0.1); verified, 0 commits touching targets afterward. 100,000 posts / 100,010 commits: plan 12.8 s; execute 35.2 s total (snapshot 2.1, rewrite 17.8, verify refs 3.0, prune 10.2, verify objects 2.1); verified. Both faster than the Stage 1 figures for the same shapes, which were taken with a cold cache; still synthetic, not the Step 2 known-environment replay.Not applicable: UI, themes, roles, migration and deployment. Full suite not rerun this stage; inherited failures as recorded at Stage 1.
Notes:
The verifier proves absence by object id, which also catches an object kept alive by another pack, an alternate or an index entry; it cannot see other clones, which the runbook (Stage 6) names.
run()holds the write lock for the whole purge, including gc; at the 100k-commit shape that is about half a minute of paused writes plus Stage 4's rebuild.
Estimate details (extended): revised original 0.15 (0.12–0.18). Retrospective 0.15: lock, snapshot, restore and two-phase verification as planned; the journal for interrupted runs and the pseudo-ref cleanup were the additions, offset by the engine already existing. Whole-cycle 0.80 reaffirmed. No measured labor claimed.
— Claude · 2026-11-01 UTC · Model: claude-fable-5-1
Stage 4 - Commit the map and rebuild
Original estimate: 0.10 Retrospective stage-work assessment: 0.10
Changes:
src/ForumRewrite/Retraction/HistoryPurge.php:run(root, databasePath, plan, artifactRoot?)is now the operator-facing purge. Underforum-rewrite.lockandPublicationLock, the same pairdelete-recordholds, it callsexecute(), encodes the map withCommitMap::encode(receipt count from the plan), callsHighTrafficSafety::beforeWritefor the record path, writes the parts and commits them with the fixed subjectRecord history purge <Rewritten-At>using the application identity thatSignedPostTransactionuses. ThenStaticArtifactInvalidator::invalidateAllGeneratedPresentation(when an artifact root is given), a fullReadModelBuilder::rebuild, truncation ofactivity_commit_manifest_cache.sqlite3beside the database (every row is keyed by an old commit id),ReadModelStaleMarker::clear, andStaticArtifactBuilder::buildfor the artifact root, the sequencedelete-recorduses. A commit failure reports the written record paths and asks for a manual commit then./v3 rebuild; a refresh failure marks the read model stale with reasonhistory_purge_refresh_failedand reports "site update incomplete. Run ./v3 rebuild", matching the retraction reference's wording. The map is committed even when object verification found lingering ids, because the refs have already moved;verifiedstays false in the result for the CLI to report.tests/HistoryPurgeTest.php:testRunCommitsTheMapRebuildsAndClearsCaches.
Verification:
php tests/run.php HistoryPurgeTest HistoryPurgePlannerTest HistoryRewriteTest CommitMapTest: 14 run, 14 passed. The new test rebuilds a real read model before the purge, seeds the manifest cache with the surviving post's old commit and sets a stale marker; afterrun(): HEAD is the record commit with the fixed subject on top of the mapped old HEAD, the commit touches only the record, the working tree is clean, the decoded record equals the changed part of the map withReceipt-Count: 1,CommitMap::resolvemaps the old HEAD and the surviving post's old commit, the rebuiltactivityrow for the surviving post carries the mapped commit, thecommitstable holds no pre-purge identity and exactly one more row (the record commit) with every row present in Git, the manifest cache row is gone, the stale marker is cleared, and the purged blobs are absent. A second repository with a directory where the database should be exercises the refresh failure: the record is still committed, the error names./v3 rebuild, and the stale marker readshistory_purge_refresh_failed.Static artifact invalidation and build are exercised only by the artifact-root branch, which the test leaves null (no presentation root in a unit fixture); Stage 7 covers it through the CLI on a full local instance.
Not applicable: UI, themes, roles, migration, deployment; no read-model schema change.
Notes:
The rebuild is the direct
ReadModelBuilder::rebuildused bydelete-record, not the candidate-and-promote path of./v3 rebuild, because the promoter takesforum-rewrite.lockitself and the purge already holds it.
Estimate details (extended): original 0.10 (0.08–0.12). Retrospective 0.10: ordering with the existing builders was the driver as planned; the manifest-cache truncation and the refresh-failure contract were small. Whole-cycle 0.80 reaffirmed. No measured labor claimed.
— Claude · 2026-11-01 UTC · Model: claude-fable-5-1
Stage 5 - Source routes resolve old identities
Original estimate: 0.10 Retrospective stage-work assessment: 0.08
Changes:
src/ForumRewrite/Http/SourceFileController.php:blob()andcommit()consultCommitMap::resolveonly after Git reports the identity unknown, then proceed with the mapped identity. Both responses carryX-Forum-Resolved-Commit: <new>; the commit text additionally has aRequested-Commit: <old>line afterCommit:so a reader sees the redirect happened. Blob bytes are unchanged. The retracted-path check still runs before any lookup, so a purged path returns the deletion notice at any identity, as today. Resolution errors degrade to the ordinary 404.Application::sourceCommitDetailsneeded no change: the controller resolves before calling it, and the memoizedsourceCommitFileskeeps one identity per request.tests/SourceRouteCommitMapTest.php, registered intests/run.php.
Consumer inventory from Step 2, each classified:
/source/blob/{commit}/...and/source/commits/{commit}: resolved through the map (this stage).Activity feed source links, Forte detail manifests, RSS, the
commitstable and the commit-manifest cache: rebuilt or truncated by Stage 4's mandatory rebuild; they hold new identities only.Image backup manifests (
repository_head): documented.ImageRestorerefuses a manifest whose head is not the repository's current revision, so a pre-purge image backup restores only onto the pre-purge repository backup. The runbook (Stage 6) orders a fresh image backup after the purge.Interrupted import journals (
.git/instance-import/pending.json,run.commit): refusal. The planner blocks a purge while one exists; afterwards none can reference a vanished commit.Archive snapshots (
RepositorySnapshotrevision inside exported archives): documented. A pre-purge archive names a pre-purge head; exports are history-free and import does not resolve the revision against the live repository, so nothing breaks, and the archive's revision simply predates the purge.Bookmarks and external links: resolved through the routes above.
Verification:
php tests/run.php SourceRouteCommitMapTest: 1 run, 1 passed. The test builds a read model on the parity fixture plus a secret and a surviving post, deletes the secret with a local receipt, confirms the old blob link is live, runsHistoryPurge::run, then checks: old blob link for the surviving file returns identical bytes (lowercase and uppercase identity), the new identity works directly, the old commit link showsCommit: <new>thenRequested-Commit: <old>, the new commit link has noRequested-Commit, the purged path returns 410 without prose at the old identity, the new identity and the current path, unknown identities stay 404, malformed stay 400, and the committed record is served at/source/current/.php tests/run.php SourceRouteCommitMapTest FeatureFlagChangePageTest ActivityItemLinkTest RootApprovalPageTest StandardActivityPaginationTest HistoryPurgeTest HistoryPurgePlannerTest HistoryRewriteTest CommitMapTest: 39 run, 39 passed (the source-route neighbours plus the purge suites).Response headers cannot be observed in CLI tests; the
Requested-Commitbody line is asserted instead.Not applicable: UI, themes, roles, migration, deployment.
Estimate details (extended): original 0.10 (0.08–0.12). Retrospective 0.08: the memoization risk did not materialize because resolution happens before the shared helper is called. Whole-cycle 0.80 reaffirmed. No measured labor claimed.
— Claude · 2026-11-01 UTC · Model: claude-fable-5-1
Stage 6 - CLI, runbook and concept entries
Original estimate: 0.10 Retrospective stage-work assessment: 0.10
Changes:
scripts/purge_history.phpand thepurge-historycase plus usage line inv3:preview [--repository-root=] [--database-path=] [--static-html-root=] [--skip-dry-run] [--json]andrun --confirm=<repository_root> [...] [--json]. Options are validated before any work; unknown commands and options printError: ...and the usage to stderr with exit 1;-h/--helpexit 0. Paths fall back toFORUM_REPOSITORY_ROOT,FORUM_DATABASE_PATH,FORUM_STATIC_HTML_ROOT, then the local bootstrap defaults, likedelete-recordandretraction-sweep. Preview prints receipts, paths, commit counts, legacy posts, blockers in plain words, and the timed dry run on a temporary clone that is removed afterwards; exit 0 means run would proceed, 1 means blocked. Run refuses without--confirmnaming the real path of the repository, refuses over blockers, callsHistoryPurge::runwith the static root, prints counts, the record commit, phase times,Verifiedor the lingering-object recovery text, the list of copies the purge cannot reach, and the fresh-image-backup reminder; exit 1 when not verified.--jsonemits the plan or the result without the map.docs/runbooks/history_purge.md: what the purge reaches and does not, backup first, the pause, preview, run, verify, fresh image backup, mirrors must re-clone rather than pull, and recovery for each failure the engine distinguishes.docs/reference/v3_cli.md: new section after the sweep.docs/reference/author_post_retraction.mdanddocs/reference/retraction_sweep.md: the "history is not purged" statements now point at the command and runbook.Concept entries:
docs/concepts/history_purge.md(created),docs/concepts/post_retraction.md(created; none existed),docs/concepts/source_history_links.md(created, describes the map resolution).tests/PurgeHistoryCommandTest.php, registered intests/run.php.
Verification:
php tests/run.php PurgeHistoryCommandTest HistoryPurgeTest HistoryPurgePlannerTest HistoryRewriteTest CommitMapTest SourceRouteCommitMapTest: 17 run, 17 passed. Through the./v3dispatcher: help exit 0, no-argument usage exit 1, unknown option and unknown command withError:and usage on stderr and no stack trace,--confirmrejected on preview, missing repository root, run without or with a wrong--confirmrefused with the exact confirm hint and nothing changed; preview on an empty repository blocked with the "nothing to purge" wording and exit 1; preview on the fixture listing both target paths, the dry-run line with 2 of 2 commits rewritten and 0 remaining, the next-step line, the--jsonform, history still intact and no dry-run directory left;run --confirmexit 0 with the complete/verified output, record commit at HEAD, purged path gone from history, read model file andstatic/index.htmlbuilt; a second preview and run refusing with nothing to purge.Relative links in the runbook, the three concept entries, the three references and the spec all resolve (checked with a script over the touched files).
php tests/run.php PlatformDocsCatalogTest PlatformDocsPageTest PlatformDocsStaticTest: 29 run, 28 passed;PlatformDocsStaticTest::testStaticReleaseIncludesCompleteInventoryAndSafeAssetsis the runner's long-standing failure (failing since 2026-10-26, before this branch) and its assertions aboutauthor_post_retraction.mdconcern an ordered list this stage did not touch. The published docs catalog is a curated list; the runbook is reachable from the retraction references and the CLI reference.Navigation icons: not applicable, no navigation destination added. UI, themes, roles, migration, deployment: not applicable.
Notes:
retraction-sweep migratestyle refusal was not needed; there is no previous purge format.The dispatcher test runs a full purge with a static build on a two-post repository, so the artifact-root branch of
HistoryPurge::runthat Stage 4 left untested is now covered.
Estimate details (extended): original 0.10 (0.08–0.12). Retrospective 0.10: documentation breadth was the driver as planned. Whole-cycle 0.80 reaffirmed. No measured labor claimed.
— Claude · 2026-11-01 UTC · Model: claude-fable-5-1
Stage 7 - Acceptance on the QDB-derived copy and closeout
Original estimate: 0.15 Retrospective stage-work assessment: 0.15
Changes: none to application code; this stage is evidence and closeout. All acceptance runs used throwaway clones (
git clone --no-hardlinks) under the session scratchpad; no sibling checkout's repository was modified, and the clones were removed afterwards.Correction to the Step 4 entry note: QDB-derived copies do exist in sibling checkouts (
local_repository_qdb, 14,887 imported quotes in 37 commits), and one local repository (v3-codex/state/local_repository, 699 posts, 87 commits) holds real signed retraction acceptances. The entry note had looked at the wrong directories. Both were cloned and used below, so the Step 2 known-environment replay ran on QDB-derived content with the real command, not only on synthetic fixtures. The copies are small in commit count (archive imports of 500 quotes per commit), so the 100,010-commit synthetic shape from Stages 1 and 3 remains the evidence for a one-commit-per-post history.Known-environment replay, clone with real receipts (699 posts, 87 commits, 3 acceptance files of which 2 are current-format
deletereceipts; the third is aVersion: 1legacy acceptance the removal spec declares unsupported, so the planner ignores it):preview: 2 receipts, 3 paths (a reply, a thread and its.asc), 87 commits with 3 touching the paths, dry run on a clone 0.2 s clone + 0.1 s rewrite, 87 of 87 mapped, 0 remaining; exit 0.run --confirm: complete and verified in 4.5 s wall including rebuild and static build; 57 of 87 commits changed identity (the 30 before the first affected commit kept theirs and are omitted from the record, as specified); 3 blobs removed; record with 57 mappings andReceipt-Count: 2.After:
git log --allempty for all 3 paths;fsck --unreachable --no-reflogsempty; working tree clean; 88 commits (87 + record); the old HEAD object no longer exists andCommitMap::resolvemaps it to HEAD~1; all 10 receipt, request and signature files byte-identical by SHA-256; a read model rebuilt from the untouched original compared table by table with the purged clone's: identical exceptactivity.action_keyfor feature-flag-change rows, which embeds the commit id (path@commit) and correctly carries the new one, andactivity_counts.commits87 to 88 for the record commit. Posts, threads, profiles, labels, reactions and approvals identical.
Known-environment replay, QDB-derived clone (14,887 quotes, 37 commits): a real receipt was created with
./v3 delete-record thread-20100101061704-qdb-301132(5.4 s including rebuild).preview: 1 receipt, 1 path, 38 commits with 2 touching it, dry run 1.0 s clone + 0.2 s rewrite, 0 remaining.run --confirm: complete and verified in 10.5 s wall including rebuild and static build (rewrite 0.2 s, prune 0.7 s); 4 of 38 commits changed; 1 blob removed; receipt byte-identical; read model identical except the commit count;.git17 MB to 6.9 MB after prune; 14,886 posts in the tree.Old-link resolution over HTTP (
./v3 startagainst the purged real-receipt clone with anonymous reading enabled, checked with curl):/source/blob/<old HEAD>/<surviving post>200 withX-Forum-Resolved-Commit: <new>and bytes identical to the working-tree file and to the new-identity response; the new identity serves 200 without the header;/source/commits/<old HEAD>200 with the header,Commit: <new>thenRequested-Commit: <old>;/source/blob/<old HEAD>/<purged reply>410 "This post was retracted by an operator."; the purged permalink/posts/<id>renders the retraction notice; unknown commit 404; malformed 400; the committed record served at/source/current/records/history-rewrites/.... A first attempt returned 401 everywhere because the clone's profile is approved-members-only; the server was restarted withFORUM_APPROVED_MEMBERS_ONLY=false, which is what the route tests do.Interrupted-run restore through the CLI: on a second clone, a journal at phase
rewritewith the ref snapshot plus a moved stray branch;previewreported "an earlier purge did not finish (stopped at phase rewrite before any history was discarded; run restores the original refs first)";run --confirmprinted "Restored the refs of an earlier interrupted purge before starting", purged, and left no stray ref or journal.Image backup after purge:
./v3 images verifywith a manifest naming the pre-purge HEAD was refused with "Image backup and repository revision do not match. Restore the matching repository snapshot first."; a manifest naming the post-purge HEAD passed the revision check (it then failed on the synthetic archive's empty object set, as expected for a hand-made archive). This confirms the runbook's instruction to take a fresh image backup.Export and import unaffected: in the full suite,
ArchiveExportTest,RepositoryContentExportTest,ContentImport*,ImportAdmissionTest,RepositoryArchiveImportCommandTest, the retraction, removal receipt, read-model and source-route suites and the new suites: 178 passing results, 2 failures discussed below.Full suite
php tests/run.php(run while the acceptance clones, a local server and purges competed for the machine): 1737 run, 1730 passed, 7 failed. Five are the runner's long-standing failures (LocalAppSmokeTestprivate lobby, Forte session and archive filename,PlatformDocsStaticTeststatic release,ReadModelBuilderTimingTestbounded counts), all predating this branch.GpgHomeCleanerTest::testDisposeStopsAgentAndRemovesHomeandArchiveExportTest::testBusyConflictAndFailureResponsesDoNotLeakPathsAndRetrySucceeds(a 7-second lock-wait timing assertion) failed under that load; both pass in isolation on this branch,ArchiveExportTestpasses 11/11 on a cleanmainworktree, and the pair passes 13/13 when rerun together. Neither touches code this branch changed. Not an unqualified full-suite pass; the five inherited failures stand.Step 2 success criteria, each checked:
Every accepted
deletereceipt's paths absent fromgit log --alland from a dangling-object scan, receipts/requests/signatures byte-identical: passed on both clones (above) and inHistoryPurgeTest.Surviving records byte-identical and a full rebuild producing the same posts, labels, reactions and approvals: passed; the only read-model differences are commit identities and the commit count.
Old
/source/bloband/source/commitslinks resolving, purged path returning the notice: passed over HTTP and inSourceRouteCommitMapTest.previewon a QDB-derived copy reporting a dry-run time, recorded figure in this summary with the engine decision it drove: the QDB-derived clone reports 1.0 s + 0.2 s; the engine decision (Stage 1) was driven by the synthetic 5,010 and 100,010-commit shapes, recorded there, because the QDB-derived copies have too few commits to separate the engines.Interrupted
runleaves original refs intact and the nextpreviewsays so: passed through the CLI and inHistoryPurgeTest.Runbook states backup prerequisite, pause and unreachable copies, linked from the post retraction reference: passed (Stage 6; links verified).
Release condition (Step 3): Stage 1 gate passed with the engine decision recorded; Stage 7 acceptance passes as above; zero dependency delta (built-in Git only).
Not done: no live qdb.us run, by scope. A one-commit-per-post QDB history was only measured synthetically.
Estimate details (extended): original 0.15 (0.12–0.20). Retrospective 0.15: large-copy runs and evidence capture as planned; the HTTP and image checks were the extra items, offset by the acceptance being scriptable. No measured labor claimed.
— Claude · 2026-11-01 UTC · Model: claude-fable-5-1
Closeout and study outcome
Commits on
feat/history-purgefrommainat4abda3db: planning7dfc7118; stagesca583ae9(S1),a7e72b65(S2),42339396(S3),ecf9c1e3(S4),9b2d8280(S5),db58dfda(S6), and this Stage 7 commit. Seven of seven stages complete locally; 33 files, about 2,900 lines added.Delivered as planned under Option A / scope v1: receipt-driven worklist, in-place built-in Git rewrite under the shared lock with preview and confirmed run, committed
Key: valuehistory rewrite record, mandatory rebuild, source-route resolution of old identities, verification by object id before success with restore before the point of no return, runbook, references and concept entries. The one deviation is the engine: a fast-export stream instead of either engine the plan named, user-confirmed at Stage 1, with the contract unchanged.Original projection 0.80 (Step 1, reaffirmed at every checkpoint). Retrospective scope assessment: 0.80. Independently judged, not the sum of stage work. Stage originals S1–S7 0.15 / 0.10 / 0.20 (revised 0.15 at Stage 1) / 0.10 / 0.10 / 0.10 / 0.15; retrospectives 0.20 / 0.10 / 0.15 / 0.10 / 0.08 / 0.10 / 0.15, non-additive. Single exposed Claude rater; no measured labor; pristine intake remains missing as recorded at Step 1.
Verification: focused and neighbouring suites pass at every stage; full suite 1730 of 1737 with five inherited failures and two load-sensitive tests that pass in isolation; acceptance on a real-receipt clone and a QDB-derived clone, HTTP checks, interrupted-run and image-backup replays as recorded above.
Pending: maintainer comfortable-fit judgment and acceptance; merge; any production run (explicitly out of scope). Technical completion does not increment the study's accepted outcomes.
— Claude · 2026-11-01 UTC · Model: claude-fable-5-1
Follow-up after closeout — run confirmation (2026-11-01)
Request: the user asked that
runnot require another parameter, confirm interactively instead, and offer a flag to skip the confirmation. Same cycle, same unmerged branch; a usability correction to Stage 6, not new scope.Changes:
scripts/purge_history.phpdrops--confirm=<repository_root>. After the blocker check and before anything changes,runwrites the repository path, the path count and the commit count to stderr and asks the operator to typeyes; any other answer cancels with "Cancelled; nothing changed." and exit 1.--yesskips the question. Without a terminal on stdin and without--yesthe run is refused before any work, so pipingyesinto it does not count. The prompt goes to stderr so--jsonoutput stays clean. Usage text, thev3help line, the CLI reference, the runbook and the concept entry updated; preview's next-step line now names the bare command.Verification:
php tests/run.php PurgeHistoryCommandTest: 2 run, 2 passed, now covering--yesrejected on preview,--confirmrejected as unknown, run without a terminal refused with the--yeshint,echo yes |refused the same way, and the full purge through the dispatcher with--yes. The interactive path was driven on a pseudo-terminal withscript: answeringnoprinted the cancellation and left history intact; answeringyespurged and verified.Estimate: no change to the cycle's 0.80 projection or 0.80 retrospective assessment; recorded as a correction, not a new request with its own score.
— Claude · 2026-11-01 UTC · Model: claude-fable-5-1
Follow-up after closeout — run progress reporting (2026-11-01)
Request: the user asked for progress and status reporting during
runso it does not sit silent while working. Same cycle and branch; a usability correction to Stages 3, 4 and 6.Changes:
HistoryRewrite::rewriteaccepts a callback invoked every 1,000 commits streamed.HistoryPurge::executeandrunaccept a progress closure and report each phase: lock wait, snapshot, rewrite start with the commit total, a running count every 5,000 commits or two seconds, rewrite time and ref verification, the point of no return before reflog expiry and prune, the object check with its counts, the verification result, record commit with the mapped count and resulting id, page retirement, read-model rebuild (the builder's own batch progress is forwarded, as./v3 rebuildprints it), and static page rebuild (the static builder's progress forwarded).runnow callsStaticArtifactBuilder::buildFromReadModelinstead ofbuild, which had rebuilt the read model a second time. The CLI prints each line to stderr as[elapsed s] message, so stdout and--jsonoutput are unchanged.Verification:
php tests/run.php PurgeHistoryCommandTest HistoryPurgeTest HistoryPurgePlannerTest HistoryRewriteTest CommitMapTest SourceRouteCommitMapTest: 17 run, 17 passed, with the dispatcher test now asserting the phase lines on stderr and the timestamp format. Watched on synthetic repositories: a 20,005-commit shape showed the counter at 5,000-commit steps during a 4.8 s rewrite (that fixture has no instance record, so the rebuild stopped with the documented "site update incomplete. Run ./v3 rebuild" message after the record commit); a 5,006-commit shape with an instance record completed end to end in 8.7 s with every phase line present.Estimate: no change to the cycle scores; recorded as a correction.
— Claude · 2026-11-01 UTC · Model: claude-fable-5-1
Follow-up after closeout — receipts without history in preview (2026-11-01)
Request: the user read a preview showing "Accepted delete receipts: 2" above two paths that were one post record and its detached signature, and asked whether there was really only one receipt. There were two: the second named a post with no file anywhere in that repository's history, so it contributed no path, and the preview did not say so.
Changes:
HistoryPurgePlanner::planreturnsreceipts_without_history, the IDs of accepted delete receipts whose candidate paths never appear in history (a post already purged, or never committed here).previewprints them under the receipt count with "nothing to purge for them", labels the path count as counting a record and its signature separately, and includes the list in--json.Verification:
php tests/run.php HistoryPurgePlannerTest PurgeHistoryCommandTest HistoryPurgeTest: 10 run, 10 passed; the planner test adds a receipt for a never-committed post and asserts it is counted and listed without adding targets. The user's local repository, which they had purged twice by then, previews as 2 receipts, both listed as having no post file left, 0 paths.Estimate: no change; correction to Stage 6 output.
Follow-up after merge — deployment settings (2026-11-01)
Found by: merging
feat/history-purgeonto the currentmain, which since the Step 4 baseline gainedDeploymentSettings(cyclev3/deployment_config) andDeploymentSettingsTest::testNoCodeReadsDeploymentSettingsDirectlyFromTheEnvironment, a rule that no script readsFORUM_REPOSITORY_ROOT,FORUM_DATABASE_PATHorFORUM_STATIC_HTML_ROOTwithgetenv().Change:
scripts/purge_history.phpresolves its three path defaults throughDeploymentSettings::value(), likescripts/rebuild_read_model.php. Precedence is unchanged for the environment (environment first, then the checkout's private file, then the bootstrapped default), so the command now also honors the private file. Explicit--repository-root=,--database-path=and--static-html-root=options still win.Verification:
php tests/run.php DeploymentSettingsTest PurgeHistoryCommandTest HistoryPurgeTest: 17 run, 17 passed. The mergedmainfull suite otherwise shows only failures that predate the merge: five long-standing ones (threeLocalAppSmokeTest,PlatformDocsStaticTest,ReadModelBuilderTimingTest) and three that fail identically on the other checkout'smainbefore the merge (ApprovalSeedCommandTest::testSeedRetiresCachedPagesAndReleaseBeforeServingUpdatedState,ReactionRecordFormatParityTest,ReadModelSchemaTest).Estimate: no change; integration correction after merge.
— Claude · 2026-11-01 UTC · Model: claude-fable-5-1