feat: moderation surface integration (Stage 4) — all 8 tasks complete #57

Merged
starsetbyte merged 18 commits from feat/moderation-surface-integration into main 2026-07-06 00:54:59 +00:00
Owner

Stage 4: Surface Integration — first user-visible moderation behavior

46 files, +3,068/-185 lines, 413 tests, 0 failures

What this does

Wires the Stage 1–3 moderation foundation (resolver, labels, UI wrappers) into all six rendering surfaces. Users now see moderation decisions everywhere — covers, badges, ghost rows, avatar swaps, and filtered rows.

Tasks completed

# Task Commit
1 Read-only ModerationPrefs sync from server be33d42
2 Timeline moderation — filter/cover/badge 2cb9c15
3 Thread moderation — focused covers, parent placeholders, ghost replies e796443
4 Embeds/quotes/media — MediaCover gates, quote-embed decisions 9351d3f
5 Profile — avatar/banner covers, status banner, label badge 702780c
6 Search — post moderation, actor avatar exemption 3872614
7 Notifications — author viewer facts, Room v11 labels, filtered rows 1fafbbe
8 Final review — DropFilteredList/PreserveOverridableList contract, regression tests, cleanup d2fe4ef

Semantic rule (locked by contract docs + 3 regression tests)

  • withModeration (DropFilteredList): passive surfaces — timeline, custom feeds, notifications. Drops ALL ContentList.filter rows.
  • withModerationPreserve (PreserveOverridableList): directed surfaces — profile author feed, search posts. Drops only noOverride items; overridable rows stay with moderation attached.

What this does NOT do (Stage 5+)

  • No putPreferences anywhere in moderation code (contract-tested)
  • No labeler settings UI, no atproto-accept-labelers header
  • No mute/block/hide actions (PostOverflowMenu exists as a stub)
  • No server preference writes of any kind

Schema

Room v11 — one additive column: notifications.labelsJson TEXT DEFAULT NULL. Migration MIGRATION_10_11 registered.

Known gaps

  • Third-party labeler labels don"t arrive until Stage 5"s atproto-accept-labelers header
  • Quoted-media moderation uses quoted decision (fixed in 6891da9)
  • Deferred Task 4 finding documented in docs/superpowers/plans/2026-07-05-moderation-stage-4-deferred.md

Manual smoke matrix (14 points)

See docs/superpowers/plans/2026-07-05-moderation-stage-4-surface-integration.md §Manual on-device test matrix.

## Stage 4: Surface Integration — first user-visible moderation behavior **46 files, +3,068/-185 lines, 413 tests, 0 failures** ### What this does Wires the Stage 1–3 moderation foundation (resolver, labels, UI wrappers) into all six rendering surfaces. Users now see moderation decisions everywhere — covers, badges, ghost rows, avatar swaps, and filtered rows. ### Tasks completed | # | Task | Commit | |---|------|--------| | 1 | Read-only ModerationPrefs sync from server | `be33d42` | | 2 | Timeline moderation — filter/cover/badge | `2cb9c15` | | 3 | Thread moderation — focused covers, parent placeholders, ghost replies | `e796443` | | 4 | Embeds/quotes/media — MediaCover gates, quote-embed decisions | `9351d3f` | | 5 | Profile — avatar/banner covers, status banner, label badge | `702780c` | | 6 | Search — post moderation, actor avatar exemption | `3872614` | | 7 | Notifications — author viewer facts, Room v11 labels, filtered rows | `1fafbbe` | | 8 | Final review — DropFilteredList/PreserveOverridableList contract, regression tests, cleanup | `d2fe4ef` | ### Semantic rule (locked by contract docs + 3 regression tests) - **`withModeration` (DropFilteredList):** passive surfaces — timeline, custom feeds, notifications. Drops ALL `ContentList.filter` rows. - **`withModerationPreserve` (PreserveOverridableList):** directed surfaces — profile author feed, search posts. Drops only `noOverride` items; overridable rows stay with moderation attached. ### What this does NOT do (Stage 5+) - No `putPreferences` anywhere in moderation code (contract-tested) - No labeler settings UI, no `atproto-accept-labelers` header - No mute/block/hide actions (PostOverflowMenu exists as a stub) - No server preference writes of any kind ### Schema Room v11 — one additive column: `notifications.labelsJson TEXT DEFAULT NULL`. Migration `MIGRATION_10_11` registered. ### Known gaps - Third-party labeler labels don"t arrive until Stage 5"s `atproto-accept-labelers` header - Quoted-media moderation uses quoted decision (fixed in `6891da9`) - Deferred Task 4 finding documented in `docs/superpowers/plans/2026-07-05-moderation-stage-4-deferred.md` ### Manual smoke matrix (14 points) See `docs/superpowers/plans/2026-07-05-moderation-stage-4-surface-integration.md` §Manual on-device test matrix.
docs: Stage 4 plan, remaining-work roadmap, and debugging handoff
All checks were successful
peregrine-ci / assembleDebug Build succeeded (2s)
bd5536a39d
Opening commit of the Stage 4 (surface-integration) branch: the three
forward-looking moderation planning docs, with Cassie's 2026-07-05
decisions folded in (read-only prefs in Stage 4; feeds drop filtered
posts; search actors exempt; hide-post write deferred to the post-gateway
feat/moderation-actions branch; canonical branch sequence).

- 2026-07-05-moderation-stage-4-surface-integration.md — executable plan,
  8 tasks (prefs read-plumbing → timeline → thread → embeds/quotes/media
  → profile → search → notifications → verification), test-first, with
  review gates and a 14-point on-device matrix.
- 2026-07-05-moderation-remaining-work.md — Stages 5-7 roadmap, dependency
  graph, risk register, branch plan, resolved-decisions log.
- 2026-07-05-moderation-debugging-handoff.md — 5-layer bug triage guide
  for a future model debugging moderation work.

No code changes. Planning artifacts only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwZsggZas5gok3eaVgdg5p
starsetbyte changed target branch from feat/moderation-ui-wrappers to main 2026-07-05 13:42:03 +00:00
docs: reflect Stages 1-3 merged to main; Stage 4 no longer stacked
All checks were successful
peregrine-ci / assembleDebug Build succeeded (3s)
39359ff03e
PR #53 and #54 both merged to main during authoring (2026-07-05). Update
the state-of-the-world, branch-plan, topology, and stacked-PR risk notes
so the Stage 4 execution docs describe cutting from main directly rather
than a stack that no longer exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwZsggZas5gok3eaVgdg5p
Adds the typed read-side Preference variants (adultContentPref,
contentLabelPref, mutedWordsPref, hiddenPostsPref, labelersPref) and a new
ModerationPrefsRepository that syncs a live StateFlow<ModerationPrefs> from
app.bsky.actor.getPreferences, wired into AppViewModel's auth-state lifecycle
(sync on Authenticated, reset on Unauthenticated). This is read-only plumbing
— no putPreferences path exists anywhere in moderation code (locked in by
ModerationReadOnlyContractTest) — so later tasks (timeline, thread, profile,
search, notifications) can resolve moderation decisions against the user's
real preferences instead of ModerationPrefs.DEFAULT.

Also updates FeedModelsTest's "unknown preferences fall back" fixture: it
previously used adultContentPref as a stand-in for an unhandled preference
type, which no longer holds now that adultContentPref is a real typed
variant. Swapped in a genuinely-unhandled $type to preserve the test's
original intent (unknown fallback coexisting with savedFeedsPrefV2).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwZsggZas5gok3eaVgdg5p
Two review findings on be33d42 (Task 1, read-only ModerationPrefs plumbing):

- ModerationPrefsRepository.sync() unconditionally emitted
  DEFAULT.copy(userDid = did) before fetching, then only logged on failure
  without restoring anything. A transient network error during any re-sync
  (e.g. activity recreation) silently wiped a fully-populated snapshot back
  to bare DEFAULT, contradicting the function's own contract. Now sync()
  captures the pre-call snapshot and, on failure, restores it only when it
  belonged to the same did (the re-sync case) — a cold start or an account
  switch correctly keeps the DEFAULT.copy(userDid = did) already emitted,
  so a failure can never resurrect a different account's settings under a
  new did.

- AppViewModel's init block launched an untracked child coroutine per
  Authenticated emission (`launch { sync() }`) with nothing to cancel a
  prior one. A straggling sync() from a previous/logged-out account could
  complete after a later reset() or a newer account's sync(), leaking
  stale moderation settings across accounts. Switched authState.collect to
  collectLatest, which cancels-and-joins the previous action before
  running the next — a stale sync() can never win a race against a newer
  auth-state transition (sync() already rethrows CancellationException).

Added 4 tests to ModerationPrefsRepositoryTest covering: same-account
re-sync failure restores the prior snapshot; cold-start failure leaves
DEFAULT tagged with the new did (not the pre-call snapshot); account-switch
failure does not leak the previous account's settings; reset() always
yields bare DEFAULT. No dedicated AppViewModel-level test was added for the
race itself — AppViewModel has no existing test scaffolding (no
AppViewModelTest.kt), and standing one up just to assert collectLatest's
well-documented cancel-and-join semantics was judged disproportionate;
the fix is a two-line, well-understood operator swap.

./gradlew test: 382 tests, 0 failures, 0 errors, 1 skipped (was 378/0/0/1).
./gradlew assembleDebug: BUILD SUCCESSFUL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwZsggZas5gok3eaVgdg5p
Every feed row (Following timeline + custom/pinned feed tabs) now computes a
ModerationDecision at mapping time (ViewModel, after cachedIn), drops filter
verdicts before they reach Compose, covers blur verdicts with ContentCover,
blurs the avatar when called for, and shows a LabelBadge that opens a
screen-hoisted LabelsBottomSheet.

- ModerationResolver.decideEmbeddedQuote: the post's own quote as its own
  non-downgraded decision (distinct from decidePost's downgraded merge).
- TimelineItem gains moderation/quotedModeration; TimelineItem.moderated() and
  Flow<PagingData<TimelineItem>>.withModeration() apply verdicts after
  cachedIn so prefs updates re-map cached pages without restarting paging.
- FeedViewModel/CustomFeedViewModel inject ModerationPrefsRepository +
  ModerationResolver and pipe posts through withModeration().
- PostCard covers PostBody+EmbedView only (header/stats/actions stay visible),
  blurs the avatar, and renders a label badge wired to a hoisted sheet in
  FeedHostScreen (mirrors the existing RepostBottomSheet pattern).
- LabelsBottomSheet gets an explicit rememberModalBottomSheetState(), matching
  RepostBottomSheet's convention.
- mediaUi/EmbedView wiring is deferred to Task 4 — quotedModeration is
  accepted by PostCard but unused until then.

New: FeedModerationMappingTest (mapping-time decision + filter-predicate
coverage on real PostEntity round-trips), ModerationSurfaceContractTest
(source-reading guard: PostCard/EmbedView/FeedRoute/ThreadScreen/SearchScreen/
NotificationsScreen/ProfileRoute must never resolve moderation themselves).
Extended ModerationResolverSubjectTest with decideEmbeddedQuote coverage.

391 tests, 0 failures, 0 errors, 1 skipped (baseline 382/0/0/1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwZsggZas5gok3eaVgdg5p
feat: thread moderation — focused cover, parent placeholders, muted-reply ghost rows
All checks were successful
peregrine-ci / assembleDebug Build succeeded (1m12s)
e7964432fa
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwZsggZas5gok3eaVgdg5p
starsetbyte changed title from docs: moderation Stage 4 plan + remaining-work roadmap + debugging handoff to feat: moderation surface integration (Stage 4) — WIP, Tasks 1-3/8 2026-07-05 21:08:09 +00:00
withModeration (drop-all): timeline + custom feeds — passive surfaces
where filtered content should never appear.

withModerationPreserve (drop-noOverride-only): profile author feed + search
posts — directed surfaces where the user intentionally navigated. Muted
authors' posts now appear on their own profile (with covers/badges)
instead of silently vanishing. Only blocks and forced hides are dropped.
docs: DropFilteredList/PreserveOverridableList contract docs + regression tests
All checks were successful
peregrine-ci / assembleDebug Build succeeded (1m0s)
d2fe4ef95d
withModeration (DropFilteredList): drops all ContentList.filter rows —
for passive surfaces (timeline, custom feeds, notifications).

withModerationPreserve (PreserveOverridableList): drops only noOverride
filtered rows — for directed surfaces (profile author feed, search).

3 regression tests lock the contract: muted-author kept by preserve,
blocked-author dropped by preserve, muted-author dropped by drop-all.
starsetbyte changed title from feat: moderation surface integration (Stage 4) — WIP, Tasks 1-3/8 to feat: moderation surface integration (Stage 4) — all 8 tasks complete 2026-07-06 00:17:14 +00:00
docs: Stage 4 post-recap + Stage 5 plan refresh for merge-readiness
All checks were successful
peregrine-ci / assembleDebug Build succeeded (2s)
a8f75d9f6d
docs: Stage 5 resume-prompt handoff for future sessions
All checks were successful
peregrine-ci / assembleDebug Build succeeded (3s)
c654f9047c
docs: resolved Task 4 deferred finding, expanded Stage 5 handoff with full task briefs
All checks were successful
peregrine-ci / assembleDebug Build succeeded (3s)
cb2e768894
Merge branch 'main' into feat/moderation-surface-integration
All checks were successful
peregrine-ci / assembleDebug Build succeeded (3s)
0658523ecc
Sign in to join this conversation.
No description provided.