fix(moderation): close the legacy lock paths and split the moderation seam (17c-2 review) master
Two-stream review of 17c-2. The lock closed the bypasses it targeted, but the path protecting content that already exists was both incomplete and untested. WALK. walkThreadRoot stopped at the outbound/fediverse boundary without consulting ap_objects.thread_root_at_uri on the very row it was reading past, so a native comment written before the root column, hanging under a Lemmy comment materialized after it, resolved to that comment and missed the post's lock. It now reads the mapping at that boundary; a mapping that names no thread still yields the object itself, but a mapping that cannot be READ is an error, because guessing a boundary off a failed lookup is how a locked thread quietly becomes an unlocked one. COVERAGE. The migration path had none: every fixture created comments through the current path, so walkThreadRoot, both forks of the dead end, and the re-materialization invariant were unreachable from any test. Legacy rows are now built by SUBTRACTION from real ones, and the helper refuses to strip a field that is not present — so if the current path ever stops writing the root, those tests fail loudly instead of asserting nothing against modern rows. The climb is exercised over more than one hop, since a one-hop fixture passes against an implementation that only ever reads the parent. SEAM. ObjectModeration was embedded in store.APObjects, which handed SetLock and SetRemoval to the vote refresher, the echo classifier, the materializer and the outbound enqueuer — interfaces they hold only to resolve strongRefs. It is standalone now, with HandlerOptions.Moderation defaulting from a type assertion on opts.Objects, so no call site changed and no test file was touched. main.go passes it explicitly rather than relying on the assertion for the store every moderation decision lands in, and a failed assertion is a retryable error, never a skip: a lock we cannot record reads downstream as "no lock". An Undo of a native comment removal now clears the tombstone and the soft delete as the post restore already did. Without it a comment suppressed by the pre-17c-1 path could never be restored, and moderateAnnouncedDelete would decline forever on IsDeleted() — permanently unmoderatable by the community's own legitimate restore, the same failure RESTORE-1 fixed for posts. Honesty: the summary-less counter and reason no longer narrate a motive the bridge cannot verify (an Announce's inner actor is unauthenticated, and a truthful author self-delete is dropped as an echo before this branch); the lifted counter only counts an actual reversal; the dead-end message carries which of its two causes fired, since a looping chain is corrupt state rather than an old row; and the retry comment states the real recovery path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>