Repository navigation
Retention mechanics: closure, per-version deadlines, holds, deletion tombstones - #13
Merged
Merged
Conversation
…, narrow holds, deletion tombstones Findings are cleared twelve months after closure. Signed authorization evidence is deleted at a retain_until fixed at closure: six years after closure, except r2 and earlier acceptances, which keep their promised three years from acceptance (never less than three, whatever the configuration). A job that can still run is never closed or deleted. An administrator can place a narrow preservation hold on one job (stated purpose, mandatory expiry, recorded setter); a damaged hold preserves. Deleting a job first writes an identifier-only tombstone; a restored backup's rows are deleted again on the next maintenance run. Schema 6. Refs CompleteTech-LLC/complete.tech#17 (gap 6, mechanics). Co-Authored-By: Claude Code <noreply@anthropic.com>
# Conflicts: # integrations/wordpress/ct-scan.php
…protect live ids Independent review findings, all fixed with tests: - (blocker) a zero or garbage date was parsed into year -1 and deleted evidence on the first run: dates must now be real, post-2020 and consistent; anything doubtful is kept, and deletion also requires the stored date to be at least the derived one and three years after creation. - (high) a tombstone could delete the request of a live job reusing a recycled id: post matching now needs an opaque identity recorded at creation and no live job row using that request or job id. - (high) a trashed request outlived its deleted job with the contact details: requests are deleted in any post status. - (medium) holds are honoured by tombstone cleanup, persist for a three-day grace after expiry, and releases are audited and refuse nonexistent holds; r2-and-earlier is recognised by version rather than one string; unparseable versions get the longer period; work per run is bounded; reopening clears closure; the admin queue shows the deletion date. Schema 6 (unreleased) gains identity and hold-release columns. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ed-hold findings, far-future dates Verification review of the previous fixes found: the three-year creation floor made every real r2 job (created moments after its acceptance) permanently undeletable and 50 of them could stall all deletion; restored requests of jobs that predate the identity were never re-deleted; the duplicate-event path did not set the identity meta; a hold with a zero expiry protected evidence but not findings; step 2 and the tombstone post loop could starve; five-digit years defeated the string comparisons; a re-hold kept the old release audit. The floor now allows a one-day gap, doubtful rows are re-examined at most daily, identities are backfilled (adopting what the request carries), the post loop is a single SQL join on the identity excluding live ids, years above 2200 are rejected. Co-Authored-By: Claude Code <noreply@anthropic.com>
…tion outside the queue lock Merge of the admission/evidence/control fixes (strict by default, egress gate, summary with limits) plus: - evidence_retention_years is floored at six for non-legacy versions; words, null, booleans, 0 or small values mean six, never less. - Tombstones store a keyed MAC of the scope instead of the plain hostname hash. - Deletion needs closed_at >= created_at and a sane clock (refused while the server clock is earlier than a stored job timestamp). - Retention no longer runs under the queue lock: its own lock, throttled on the worker poll, each deletion takes the queue lock for one job without waiting and re-checks from a fresh read, so Stop is never queued behind a batch. Co-Authored-By: Claude Code <noreply@anthropic.com>
This was referenced Oct 22, 2026
…ock skew; shared budget Review fixes for the retention mechanics (data deletion; every change keeps data longer, never shorter): - A stored retain_until earlier than the date required today (the period derived under the current configuration, never less than three years after creation) is extended to that date under the queue lock (compare-and-set) instead of making the job undeletable forever; the job is deleted when the later date arrives. Raising evidence/legacy retention after closure is honoured; lowering it never shortens a stored date. - A legacy acceptance dated more than a day before its record was created gets three full years from creation: later than the promise, and finite. - Releasing a hold ends it now (purpose kept, releaser recorded) so the documented three-day grace applies; releasing an expired hold never lengthens it. - Deletions stop while the PHP and database clocks disagree by more than five minutes; the clock problem is logged once per occurrence, not every run. - One budget of 50 deletion attempts per run, shared by the evidence step and both tombstone loops; a refused restored-post deletion is counted and skipped. - A restored job row's post is deleted only if it carries the tombstone's identity (recycled post ids); a due job whose post carries another identity is kept whole. - Retention on the admin path catches exceptions like the claim path; on the claim path it runs only on polls that hand out no job. - Tests: config raised after closure (extended, then deleted at the new date, not shortened when lowered), legacy accepted-before-created deadline, release grace, clock skew (both directions) and log-once, shared budget, recycled post ids, foreign identity, leap-day bases, claim latency. Docs updated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…orts a skipped run The non-legacy case whose extended date has already come needs a closure more than eight years ago, which the 2020 trust floor (correctly) refuses; use a legacy acceptance raised from three to five years instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in the merged #10/#11/#12 review fixes (probe busy-retry, list-of-pairs evidence comparison, control_admitted and 409 already_admitted, fail-closed ct_scan_control_required(), terms_policy docs) under the retention branch. Conflict: the schema's required jobs columns now list both control_admitted and the retention/hold/identity columns. The schema version moves to 7 so an install that recorded 6 before control_admitted existed re-runs the upgrade. Retention coherence with the control gate: awaiting_control (pending or checking) is not terminal and is never closed; a restored row recognised by a tombstone is no longer deleted while it holds a lease (leased/running scan or a control check in progress), so the one-at-a-time gate is never freed while its traffic may be in flight. Tests cover both. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tombstone fixes
- ct_scan_admin_set_hold refuses ('hold_active') while an unreleased hold
still protects the job (incl. grace), and never lets a new hold end earlier
than a released one; the write is a compare-and-set on the hold values read
under the queue lock ('hold_changed' otherwise). The admin notice explains it.
- A job without an opaque identity is kept (decision and due query) until the
backfill gives it one; the backfill also covers empty identities.
- Retention deletion requires the linked post to carry exactly the job's
non-empty identity; an empty post identity keeps everything.
- Tombstone candidates exclude held, leased and recently failed rows in SQL
and are paged by key, so skipped rows cannot starve later restored rows; a
failed restored-row deletion is retried after a day.
- Docs: hold replacement rule, identity requirement, salt rotation and the
far-future timestamp halt.
- Tests for each.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d rows Excluding rows whose retention deletion had failed (retention_checked_at) stopped the tombstone loop from finishing a job whose post deletion failed on the previous run (integration test). Failed rows are now attempted again as before; fairness comes from paging from where the previous run stopped, wrapping around once, so rows that keep failing cannot starve later ones. Held and leased rows stay excluded in SQL. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…post paging - Blocker: a stale "Release hold" form could end a newer hold. The form now carries the hold's identity (purpose, expiry, setter, when set) and the release is a compare-and-set on those values with hold_released_at IS NULL; a mismatch or 0 rows returns hold_changed with a plain admin message. - Identity backfill adopts or writes a request post's identity only when the post's _ct_scan_job_id names the job; otherwise both stay unchanged (kept). The backfill pages from a stored cursor so such rows cannot starve it. - Restored-post tombstone loop: the job-id match is in SQL, candidates are paged by tombstone id from a stored cursor with wrap-around, and the job-row loop leaves a fifth of the shared budget for it. - The clock sanity check runs before closure stamping, so an untrusted clock's "now" is never stamped. - Retention years above 100 are clamped (logged once) so derived dates stay below the 2200 validity bound; floors unchanged. - Tests: stale release replay, backfill ownership, refused/foreign restored posts vs an older one (wrap-around), no stamping on a bad clock, year cap; the starvation fixture uses a non-colliding id range and asserts inserts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tone loop honours deadlines - Compare the tombstone identity and job id with post meta as binary strings, so differing table collations (error 1267) can no longer silently disable the deletion of restored request posts. - Check $wpdb->last_error after every retention/tombstone query: a failed query stops that step (records kept, logged once until a clean run) instead of being read as "no candidates" or "no live record". - The tombstone loop deletes a recognised job row only after the retention deadline that applies today (stored retain_until, never shortened, and the date required under the current configuration) has passed, so a live row whose deletion failed part-way is protected by a period raised since. - Tests: tombstones identity column in another collation; failing candidate and live-lookup queries; failed deletion under a raised period. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CompleteDotTech
marked this pull request as ready for review
October 23, 2026 21:02
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs CompleteTech-LLC/complete.tech#17 (gap 6, retention mechanics). Targets
main(#10, #11 and #12, which it was stacked on, are merged; main merged in, schema 7). Data-deletion code: please review slowly. Scanning stays disabled by default.What changes (owner decisions of 2026-10-22)
completed,failed,failed_unknown,cancelled,expired,blocked) it getsclosed_at; accepted-but-unverified requests close when they expire. A job that can still run is never closed or deleted; a reopened job's stamp is cleared.retain_until, fixed at closure, may only be extended (never shortened): 6 years after closure; r2 and earlier (YYYY-MM-DD-rN<=2026-10-21-r2) keep their promised 3 years from acceptance; never < 3 years whatever the config; unparseable versions get the longer period.retain_untilis readable per accepted version for the contract archive (issue 11).Fail-closed behavior
Anything doubtful (bad/zero dates, missing base, stored date earlier than derived, hold present/damaged, deletion step failing) keeps the record and retries; contact fields are scrubbed before the link is dropped; a failed post deletion leaves the job row.
Tests
retention.php(new, ~70 assertions: closure, deadlines on both sides of each period, version rules, floors, zero/garbled dates, tampered dates, reopen, holds incl. grace/release/non-admin, tombstones incl. identity, recycled ids, trash status, bounded batches) plus the adaptedintegration.phpretention test. Mutation-checked; independent review found a blocker (zero date => immediate deletion), 2 highs (tombstone vs recycled ids; trashed post survives), and mediums (hold vs tombstone, legacy versions, lock cost, hold lapse/audit); all fixed with tests. CI on the head: see checks.Reviewer: please attack
Any input that deletes before the promised period or deletes a live job's records; hold bypasses; tombstone matching; what survives deletion (post meta,
_ct_terms_snapshot, control evidence, accepted-copy status, nonces).Not done / needs the owner
Second independent review (after the first fixes)
Verified the blocker/high fixes and found further problems, all fixed in the last commit with tests: the three-year creation floor would have made every real r2 job undeletable (and 50 of them could stall all deletion); jobs that predate the identity were never recognised on restore (identities are now backfilled, adopting what the request carries; the duplicate-event path sets the meta); a hold with a zero expiry protected evidence but not findings; two loops could starve (doubtful rows are re-examined at most daily; the restored-post loop is one SQL join excluding live ids); years above 2200 are rejected; a re-hold keeps no stale release audit.
Fail-closed review round 2 (coordinator findings)
Fixed in this stack, each with tests that fail without the fix (mutation-checked):
egress_enforced: true(loader refuses otherwise); docs/README/example config state it; unverified nftables egress sketch added (not a claim of enforcement). In-flight bound is exactly 10 s from the grant (no grace). Authorization read from the DB, not the object cache. A completed result needs >= 1 admitted request. Failed schema upgrade: oneerror_log, 5-minute back-off, plugin 0.2.0. Special-purpose IP ranges listed explicitly (CGNAT, documentation, benchmarking,3fff::/20, 6to4, NAT64, ...), tested independent of the interpreter'sipaddress.terms_policy, strict by default;legacyonly for r2-or-earlier); the loader refuses to enable without an explicit entry for the served version.ct_scan_target_summary()is the one canonical summary and now includes limits and period.closed_at >= created_atand a sane clock; retention runs outside the queue lock (own lock, throttled, one short queue lock per deletion) so Stop never queues behind a batch.Third review fixes (head after 36aba06)
evidence_retention_years/legacy_retention_yearsafter closure used to make every closed job undeletable (stored date < derived). Nowretain_untilis extended to the required date (compare-and-set, under the queue lock) and the job is deleted when that date arrives; lowering the config never shortens it. A legacy acceptance dated > 1 day before its record's creation gets 3 full years from creation (later than the promise, and finite).hold_untilto now (purpose and releaser kept), so the documented 3-day grace applies; releasing an already expired hold never lengthens it.UTC_TIMESTAMP()disagree by > 5 minutes (in addition to the backward-clock checks); the problem is logged once per occurrence./claimretention runs only when no job is handed out, so a lease is never delayed.2026-10-21versions and calendar validation of version strings.Fourth review fixes (f2a33e6, 97f834d)
ct_scan_admin_set_holdnow refuses (hold_active, explained in the admin notice) while an unreleased hold still protects the job (unexpired or in grace), and a released hold in grace can only be replaced by one that ends later. The write is a compare-and-set on the hold values read under the queue lock (hold_changedotherwise). To change a hold: release it (grace still protects), then place the new one. Refusal was chosen over extend-in-place because it needs no schema change and never mixes two purposes in one record.wp_salt('secure_auth')rotation vs tombstone matching, and the far-future-timestamp halt (logged once).Fifth review fixes (2a62ccf)
ct_scan_admin_release_holdis a compare-and-set onhold_reason,hold_until,hold_set_at,hold_set_by,hold_released_at IS NULL; a mismatch or 0 rows returnshold_changed("reload the queue"). Test: release H1, place a 730-day H2, replay H1's release: H2 unchanged and the due job kept._ct_scan_job_idnames the job; otherwise both stay unchanged (job kept). Paged from a stored cursor.Duplicate entry).2026-10-21versions, calendar validation of version strings, backup rotation / archive / mail / unsigned leads, 6-year/3-year periods, 730-day hold cap and 3-day grace, 120-day tombstone retention.Sixth review fixes (5e0e2af)
_ct_scan_identity/_ct_scan_job_idmeta with the tombstone asCAST(... AS BINARY), so a tombstones table in another collation thanwp_postmetacannot raise error 1267. Audit: this was the only cross-table text comparison in the PR (the job-row loop joins on numeric ids and datetime; the scope MAC is compared in PHP).$wpdb->last_error: a failure stops that step and keeps the records (never "no candidates" / "no live record"), logged once until a clean run.retain_untiland the date required under current config). A live row whose deletion wrote its tombstone and then failed part-way is therefore protected by a period raised since. A row with no derivable deadline cannot be such a row (retention only deletes rows with valid dates, unchanged by a failed deletion), so the tombstone decides there. Documented in docs/WORDPRESS.md.retention_checked_at(instead of a futureretain_until) to reach only the tombstone loop.tombstone_postswarnings, no SQL errors.