feat(moderation): thread locks and native comment removal (17c-2) master
The moderation state the bridge owns, backed by one table. LOCK. An announced Lock from the community that owns a post's mapping is recorded in object_moderation; a native comment in that thread is REFUSED. The refusal is permanent (ErrPermanentEvent carrying parent-locked) so the event dead-letters and the cursor advances — a transient error would park every other native user behind one locked thread, retrying a decision only a moderator can change. It runs after the depth cap and before the actor mint, so nothing is created for a comment that will never federate, and it leaves the rev gate un-advanced so the byte-identical retry after an Undo{Lock} is admitted rather than swallowed as a stale replay. The lock is per-THREAD, not per-parent. Checking only the direct parent stops nothing, because replying under an existing comment is how conversations continue. The thread root is carried on resolvedThread and on the stored snapshot (the edit path rebuilds from state), and both parent and root are asked in one statement. Roots for fediverse content come from a new ap_objects.thread_root_at_uri (migration 026), recorded where materialize.resolveReplyRefs already resolves reply.root at commit time — zero extra reads on the consumer's most common write, versus a repo read (a REPEATABLE READ tx, head read and MST walk) per comment. It belongs on ap_objects, unlike the lock, precisely because the materializer re-supplies it on every re-put and a repin therefore cannot lose it. This also closes structurally, rather than by policy, the risk that an unresolvable root parks a whole community: an empty root now requires a dead-ended walk, which requires depth > 0, which requires an outbound row, which only native content has. COMMENT REMOVAL. An announced Delete WITH a summary records removed_at, code and reason bound to the announcing community; Undo clears them. Nothing Coves-visible is written — the removal lexicon is post-scoped and the comment-subject extension is Coves-owned and unlanded — and the test asserting that says inline that it is the assertion which must change when the extension lands, so the gap is a contract with an expiry rather than an absence nobody checks. Post removal state stays in the community repo record: duplicating it would make two sources of truth for what acceptrec already makes atomic. A summary-LESS announced delete records nothing. Lemmy spells a moderator removal with a summary and a self-delete without one, so writing moderator-discretion for it would assert a moderator acted when none did. It is still TAKEN rather than declined, because the v1 path it would otherwise fall into soft-deletes our own mapping and tombstones our own AP id, leaving the object permanently unmoderatable. The two shapes now fork with distinct reasons and counters: previously one reason and one counter meant an operator asking "did a moderator remove this?" read an answer that could not distinguish yes from no. Every cycle bite-proven, including the ruling-C scope control (routing a post removal through the comment writer) and the ownership conjunct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>