Follow-up from PR 63 (round-4 approval): the DID-mismatch guard added in Publisher::delete_post() (PR 63 round-2 commit dc5f926) defends the disconnect → reconnect-to-different-DID flow for the soft-cleanup path. The hard-delete path is uncovered.
Threat
Site is connected to DID A. Author publishes post 42 under DID A. Post::META_TID and Post::META_DID = A are persisted. Operator disconnects, reconnects to DID B. Later, the post is permanently deleted (Trash → Delete Permanently, or wp post delete --force). Atmosphere::on_before_delete() captures TIDs and schedules atmosphere_delete_records — by the time that cron fires, the post row and its meta are gone. Publisher::delete_post_by_tids() builds the applyWrites#delete batch from the captured TIDs and issues against DID B. AT Proto returns 200 idempotently. The record on DID A is permanently orphaned.
Same threat model as delete_post(), but the cron event carries TIDs only — no post meta to read at fire time — so the guard delete_post() uses cannot be applied without plumbing.
Affected code
includes/class-atmosphere.php — Atmosphere::on_before_delete() (around line 575) reads Post::META_TID / Document::META_TID / comment TIDs and schedules atmosphere_delete_records with just those values.
includes/class-publisher.php:1199-1240 — Publisher::delete_post_by_tids( $bsky_tids, $doc_tid, $comment_tids ) builds the applyWrites batch and ships it, no DID-mismatch check.
Suggested shape
Capture provenance at schedule time, gate at fire time:
- In
Atmosphere::on_before_delete(), read Post::META_DID and Document::META_DID alongside the TIDs.
- Extend the
atmosphere_delete_records cron event signature to carry origin DIDs as a fourth array argument keyed by collection (e.g. array( 'bsky' => 'did:plc:A', 'doc' => 'did:plc:A', 'comments' => array( 'tid1' => 'did:plc:A', ... ) )). Existing schedulers pass an empty array for legacy entries already in the cron queue at upgrade time.
- Update
Publisher::delete_post_by_tids() to accept the origin-DID map. For each per-collection delete in the writes array, compare its origin DID against get_did(). Skip writes whose origin DID is non-empty and differs from current; log skipped writes via log_cron_error() with an atmosphere_did_mismatch op tag.
- Return success for the writes that DID get issued; surface the skipped set in result data so the cleanup action's listeners can observe partial-success vs full-success vs full-skip.
- Backfill — same legacy-fallback concern as the post-side: pre-PR-63 hard-deletes already in the cron queue have no origin DID. Treat empty origin DID as "no provenance, fall through" to match the
delete_post() legacy-fallback behavior, and rely on the upgrade-time META_DID backfill (P3 polish item) to populate provenance for future scheduled events.
- Tests: hard-delete after reconnect skips the wrong-DID writes; hard-delete before any DID rotation succeeds entirely; partial skip (bsky on A, doc on A, but comment on B somehow) cleanly skips the comment.
Priority
P2 — requires a specific account-rotation sequence + permanent-delete (rather than trash) to exploit. Pairs with the Comment::META_DID follow-up since they share a threat model and ideally land together.
Related
- Closed by PR 63 for
Publisher::delete_post(). This is the hard-delete-path sibling.
- See also the
Comment::META_DID follow-up for the parallel comment-side gap.
Follow-up from PR 63 (round-4 approval): the DID-mismatch guard added in
Publisher::delete_post()(PR 63 round-2 commitdc5f926) defends the disconnect → reconnect-to-different-DID flow for the soft-cleanup path. The hard-delete path is uncovered.Threat
Site is connected to DID A. Author publishes post 42 under DID A.
Post::META_TIDandPost::META_DID = Aare persisted. Operator disconnects, reconnects to DID B. Later, the post is permanently deleted (Trash → Delete Permanently, orwp post delete --force).Atmosphere::on_before_delete()captures TIDs and schedulesatmosphere_delete_records— by the time that cron fires, the post row and its meta are gone.Publisher::delete_post_by_tids()builds theapplyWrites#deletebatch from the captured TIDs and issues against DID B. AT Proto returns 200 idempotently. The record on DID A is permanently orphaned.Same threat model as
delete_post(), but the cron event carries TIDs only — no post meta to read at fire time — so the guarddelete_post()uses cannot be applied without plumbing.Affected code
includes/class-atmosphere.php—Atmosphere::on_before_delete()(around line 575) readsPost::META_TID/Document::META_TID/ comment TIDs and schedulesatmosphere_delete_recordswith just those values.includes/class-publisher.php:1199-1240—Publisher::delete_post_by_tids( $bsky_tids, $doc_tid, $comment_tids )builds theapplyWritesbatch and ships it, no DID-mismatch check.Suggested shape
Capture provenance at schedule time, gate at fire time:
Atmosphere::on_before_delete(), readPost::META_DIDandDocument::META_DIDalongside the TIDs.atmosphere_delete_recordscron event signature to carry origin DIDs as a fourth array argument keyed by collection (e.g.array( 'bsky' => 'did:plc:A', 'doc' => 'did:plc:A', 'comments' => array( 'tid1' => 'did:plc:A', ... ) )). Existing schedulers pass an empty array for legacy entries already in the cron queue at upgrade time.Publisher::delete_post_by_tids()to accept the origin-DID map. For each per-collection delete in the writes array, compare its origin DID againstget_did(). Skip writes whose origin DID is non-empty and differs from current; log skipped writes vialog_cron_error()with anatmosphere_did_mismatchop tag.delete_post()legacy-fallback behavior, and rely on the upgrade-time META_DID backfill (P3 polish item) to populate provenance for future scheduled events.Priority
P2 — requires a specific account-rotation sequence + permanent-delete (rather than trash) to exploit. Pairs with the
Comment::META_DIDfollow-up since they share a threat model and ideally land together.Related
Publisher::delete_post(). This is the hard-delete-path sibling.Comment::META_DIDfollow-up for the parallel comment-side gap.