Skip to content

fix(mobile): keep cached state with its community - #8349

Open
wesbillman wants to merge 11 commits into
mainfrom
carl/mobile-sections-community-scope
Open

wesbillman wants to merge 11 commits into
mainfrom
carl/mobile-sections-community-scope

Conversation

@wesbillman

@wesbillman wesbillman commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

One identity can be paired with several communities, but mobile keyed several caches by pubkey only. Mobile keeps a single ProviderScope and doesn't remount on a community switch, so one community's data showed up in another, and some of it was published to the other community's relay.

  • Channel groups: the second community showed the first community's groups.
  • Read marks (contexts, publishable set, NIP-RS client and slot IDs): the first community's context IDs were merged into, and published to, the second community's read-state slot.
  • Inbox done/unread rows and thread follows: one shared 500-entry cap, so activity in one community evicted another's entries.
  • Channel stars and mutes: union-merged across communities and published to every relay.

Every one of these is now keyed by pubkey + normalized community relay, the same way channel sort already is.

Switching communities. The relay session rebuilds for the new community before the previous community's managers are retired. Each manager (groups, read state, stars, mutes) now checks that the session still serves its own community before it publishes, merges a fetch, or handles a live event. Previously, a pending publish flushed on dispose, or an in-flight fetch, could cross into the next community.

Old pubkey-only entries:

  • Inbox and thread follows are local only, so every community starts from the old entry, and the old entry is kept. The IDs are event IDs, so another community's IDs never match anything.
  • Read marks come over as local-only marks, never publishable. Channel IDs can repeat across communities, so the first community to open takes the channel and activity: marks. Marks keyed by event ID (msg:, thread:, thread-activity:) seed every community. Tradeoff: a later community loses its channel marks older than the 7-day relay fetch window. Slot and client IDs seed every community, and the old entry is kept.
  • Stars and mutes go to the first community that opens. They're published whole, so copying them would push one community's IDs to another community's relay.
  • Groups are dropped and refetched, since copying them is the original bug.

This PR also removes the Pulse feed (features/pulse/ and the Nostr filters only it used). Nothing in the app navigates to it anymore, and its providers were among the community-unscoped caches.

Related issue

None found.

Testing

  • Each affected store has a regression test through the real provider that switches A → B → A. For groups, read marks, stars and mutes, the test sets a value in A, switches to B while A's change is still pending, and checks two cases: B's data arriving live and B answering A's in-flight fetch. It asserts that nothing sent to B contains A's IDs, B shows only its own value, and A comes back unchanged. Each guard was removed in turn to confirm a test fails without it, except two that the current switch order never reaches: the groups merge guard and the read-state live-event guard.
  • Migration tests cover each kind of old entry.
  • flutter analyze is clean, and the full mobile suite passes at deea4878d (3340 passed, 4 skipped).
  • Not yet exercised in the app.

🤖 Generated with Claude Code

@wesbillman
wesbillman marked this pull request as ready for review October 15, 2026 02:31
@wesbillman
wesbillman requested a review from a team as a code owner October 15, 2026 02:31

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES

Reviewed: 8222e99915c7c4a3311e32c5dee989df88c49a48..deea4878da157cc7fe03fba5d2f979ceb7ec86fa (exact clean head deea4878da157cc7fe03fba5d2f979ceb7ec86fa)

Risk: critical — this changes persisted read state at the community/tenant boundary, plus relay-session retirement and migration behavior.

Blocking finding

ReadStateStorage.read seeds the legacy pubkey-wide context map into every community that does not yet have a scoped map (mobile/lib/shared/read_state/read_state_storage.dart:98-115). That map can contain raw channel IDs and activity:<channelId> contexts (mobile/lib/shared/read_state/read_state_format.dart:17-27), while channel UUIDs are explicitly not globally unique: the schema permits the same UUID in two communities (migrations/0001_initial_schema.sql:64-70,97; migrations/0027_channels_id_lookup_index.sql:35-39).

A real failure sequence is:

  1. Legacy community A has channel UUID X marked read.
  2. Community B independently has channel UUID X and no scoped read-state entry yet.
  3. Opening B falls back to A's legacy map and imports A's timestamp.
  4. B's messages can render already read/hidden locally.

Making migrated contexts non-publishable prevents relay contamination, but not local cross-community UI contamination. The added migration test currently codifies the unsafe behavior by expecting channel-1 in both communities (mobile/test/features/channels/read_state/read_state_manager_test.dart:308-339). This conflicts with the PR's isolation goal and VISION.md:52-56.

Author action: migrate ambiguous broad/channel contexts to at most one community, or drop them. Duplicate only context forms proven community-unique. Add a production-seam collision regression where A and B share channel UUID X and B does not inherit A's channel/activity timestamp.

Verification owner: author + exact-head mobile full-suite CI (just mobile-install mobile-check mobile-test).

Other traced contracts

No additional concrete defect was found in scoped stars/mutes/groups/sections, inbox/thread-follow cap isolation, A→B→A restoration, retired-manager fetch/live/publish fencing, relay teardown/reconnect, or Pulse removal. The contradiction between review lanes was resolved against the schema's explicit collision contract: “local-only” is insufficient when the local context key itself is tenant-ambiguous.

Validation and confidence gaps

  • Exact-head/source checks: git rev-parse HEAD, clean git status, gh pr view, focused diff and call-path review — pass at the SHA above.
  • CI at review time: Clients/Mobile, Mobile Swift, aggregate Mobile, Semgrep, zizmor, DCO, and Desktop Release Candidate — successful. Run Codex Security Review remained in progress, not failed.
  • Independent local Flutter execution on the reviewer host did not reach tests because the objective_c native hook invoked xcrun, which exited 69 due to an unaccepted Xcode license. This is a reviewer-tooling confidence gap, not author rework. Full mobile CI is green, but no reviewer-owned real-device A→B→A or legacy-migration smoke was observed.
  • Confidence-gap owner: reviewer tooling / Mobile QA or release validation. Author action for this gap: none.

— :bot: Jude’s code review agent

@wesbillman
wesbillman force-pushed the carl/mobile-sections-community-scope branch from deea487 to 992adf9 Compare October 15, 2026 22:14
@wesbillman

Copy link
Copy Markdown
Collaborator Author

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

@jedwards27 thanks, the collision contract is real. I tried your suggestion (first community claims channel and activity: marks, event-ID marks seed every community) in 992adf9, then reverted it in 2381106. It costs more than the bug it prevents:

  • It doesn't stop the collision. Before this PR, the merged pubkey-wide marks were published into every community's read-state slot (read_state_manager.dart publish path at 8222e9991). So for a colliding channel X, B's relay copy already holds A's mark, and B's startup fetch brings it back whatever local migration does. Which community "claims" is also just whichever happens to be active on the first launch after the upgrade.
  • It loses unreads broadly. A community that doesn't claim has no channel marks older than the 7-day fetch window, and body.dart:179 then seeds every such channel as read at its latest message. So every unread from while the user was away in that community disappears. Copying the marks risks a wrong mark only in a channel whose ID collides, and collisions need a client-chosen ID that matches another community's. Dropping them affects every channel of every community after the first.

Copied marks stay local-only (never publishable), so this PR adds no new relay contamination. If you'd still prefer the stricter migration, a better option is to keep copying but let the relay's own mark for a channel replace a copied one even when it's lower. Happy to do that as a follow-up.

@wesbillman
wesbillman deployed to codex-review October 15, 2026 22:16 — with GitHub Actions Active
@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 15, 2026
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 2de484baf7bf379753c3073f40da3a178b2918a0...68dc440b3932adfd2d8022e411a64b6b3cdd309d.
A new review must complete for this exact range. When manual authorization
is required, a user with write access must comment exactly
@buzz-security-review 68dc440b3932adfd2d8022e411a64b6b3cdd309d to authorize a new review.
Any previous review applies only to its recorded range.

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES

Reviewed: 7852b1670c7588875fc43ef2613c8348893d5ab6..238110601344154f72bbe8e4fa87c23d45ab1f17 (exact clean head 238110601344154f72bbe8e4fa87c23d45ab1f17)

Risk: critical — persisted read state and migration at the community/tenant boundary.

Blocking findings

The author's substantive response at #8349 (comment) was reconsidered; it does not clear the defects.

1. Tenant-ambiguous legacy read state is copied into every community

ReadStateStorage.read still falls back to the legacy pubkey-wide context map for every community without scoped state (mobile/lib/shared/read_state/read_state_storage.dart:98-115). That map can contain raw channel IDs and activity:<channelId> contexts (mobile/lib/shared/read_state/read_state_format.dart:17-27). Channel IDs are explicitly not globally unique: the schema permits the same UUID in different communities (migrations/0001_initial_schema.sql:64-70,97; migrations/0027_channels_id_lookup_index.sql:35-39). The migration test currently codifies B inheriting A's channel-1 mark (mobile/test/features/channels/read_state/read_state_manager_test.dart:308-339).

A clean B relay does not make those copied local marks safe: A's timestamp can still hide B's colliding channel messages before or without an authoritative B mark. Historical relay pollution makes attribution harder, but does not justify adding local contamination to communities whose relay was not polluted. The current comment that another community's IDs “simply never match” is contradicted by the repository's explicit collision contract. This violates the PR's isolation claim, VISION.md:52-56, and the mobile requirement that one relay not degrade another community's experience (VISION_MOBILE.md:10-13,53-56).

The response also identifies a genuine competing harm: first-community claim can discard older unread history for later communities. That means the migration policy needs an explicit fail-safe product decision; silently choosing cross-tenant false-read risk is not a sound default.

Author action: adopt and test a migration policy that does not seed tenant-ambiguous raw channel / activity:<channelId> contexts into every community, or obtain an explicit product decision accepting that trust tradeoff and narrow the PR's isolation claim accordingly. Add a same-channel-ID collision regression proving the chosen behavior. Event-ID-derived contexts may be treated separately when their global uniqueness is established.

Verification owner: author for code/test; product owner for any intentional isolation exception; reviewer rechecks the replacement exact head.

2. Channel-section migration deletes unsynced local organization before recovery

ChannelSectionsStorage.read unconditionally removes the legacy sections snapshot before establishing its owning community or confirming an authoritative relay copy (mobile/lib/features/channels/channel_sections/channel_sections_storage.dart:102-106). The manager allows section creation, renaming, ordering, and assignments while remote sync is disabled: those edits persist locally (channel_sections_manager.dart:208-302,529-535), while markDirty returns immediately when _remoteEnabled is false (channel_sections_manager.dart:305-317). On upgrade, an offline or previously unsynced user can therefore permanently lose section names, order, and channel assignments—even with only one community. The new storage test proves deletion, not safe ownership or recovery.

Author action: retain a recoverable legacy snapshot until ownership/reconciliation is established and the scoped copy is durably stored, without publishing it into the wrong community. Add offline single-community upgrade coverage preserving unsynced edits and ambiguous multi-community coverage proving no cross-community assignment or destructive deletion.

Verification owner: author + exact-head mobile CI; Mobile QA for an offline upgrade smoke test with non-default section names, order, and assignments.

Other traced contracts

No additional concrete defect was found in scoped groups/stars/mutes, inbox/thread-follow cap isolation, A→B→A restoration, retired-manager fetch/live/publish fencing, relay teardown/reconnect, or removal of the unreachable Pulse feature.

Validation and confidence gaps

  • Exact-head/source checks: clean worktree, matching live PR head, git diff --check, full migration/call-path trace, repository vision/schema contract review — pass at the SHA above.
  • The focused Flutter tests did not reach execution because this review host's unaccepted Xcode license caused the objective_c native-assets hook to fail while discovering the SDK. This is a reviewer-tooling confidence gap, not author rework. Author action: none. Verification owner: reviewer tooling plus exact-head mobile CI.
  • Exact-head mobile and Mobile Swift jobs were still running at final review; no failed check was observed. These gates remain CI-owned and are not the reason for this verdict.
  • No reviewer-owned real-device A→B→A or legacy migration journey was observed.

Residual risk: the broader cache isolation change is substantial; exact-head CI and native/mobile validation remain external gates, while the concrete migration defect above requires author or product-owner action.

— :bot: Jude’s code review agent

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES

Reviewed: 7852b1670c7588875fc43ef2613c8348893d5ab6..238110601344154f72bbe8e4fa87c23d45ab1f17 (exact clean head 238110601344154f72bbe8e4fa87c23d45ab1f17)

Risk: critical — this changes destructive persistence migration and cached unread state at the community/tenant boundary.

Blocking findings

1. Tenant-ambiguous legacy read marks are still copied into every community

ReadStateStorage.read falls back to the same pubkey-wide legacy context map for every community without scoped state (mobile/lib/shared/read_state/read_state_storage.dart:98-115). That map includes raw channel IDs and activity:<channelId> (mobile/lib/shared/read_state/read_state_format.dart:17-27), and both directly decide whether messages and badges are read (mobile/lib/shared/read_state/message_read_state.dart:4-25; mobile/lib/features/channels/unread_badge/observed_unread_event.dart:130-146). The migration test now requires a second community to inherit channel-1 (mobile/test/features/channels/read_state/read_state_manager_test.dart:308-339).

Channel UUIDs are explicitly not globally unique: (community_id, id) is the key, and same-ID collisions are a required isolation case (migrations/0001_initial_schema.sql:64-70,97; migrations/0027_channels_id_lookup_index.sql:35-39). If A and B both contain UUID X, A's legacy timestamp can therefore hide newer B messages locally. Marking the copy non-publishable prevents a new relay write; it does not prevent B's UI from consuming A's timestamp.

I reconsidered the author's response at #8349 (comment). It identifies a real migration tradeoff: first-claim ownership can lose historical unread intent in later communities, and old versions may already have contaminated some relay slots. Neither makes fan-out safe for an unpolluted B slot. A lower authoritative relay mark cannot repair a B relay with no mark or the pre-fetch hidden-message window. The current assertion that another community's IDs “simply never match” is contradicted by the schema and by the community-isolation contract (VISION.md:52-56; VISION_MOBILE.md:10-13,53-56).

Author action: choose a fail-safe policy that does not apply tenant-ambiguous channel/activity: marks to multiple communities, or obtain the mobile vision owner's explicit approval for a different product contract and update the PR/product contract accordingly. Add a production/provider-seam regression with A and B sharing channel UUID X that proves the chosen user-visible unread behavior. Preserve event-ID-keyed contexts only where their uniqueness is established.

Verification owner: author + mobile vision owner for any intentional tradeoff; exact-head mobile CI for the collision regression; Mobile QA for visible A→B→A unread behavior.

2. Channel-section migration permanently deletes unsynced local organization

ChannelSectionsStorage.read removes the legacy snapshot before establishing an owning scoped copy or confirming that the relay has one (mobile/lib/features/channels/channel_sections/channel_sections_storage.dart:102-106). This is not merely disposable cache: section creation, renaming, ordering, and assignments persist locally first (channel_sections_manager.dart:208-303), while markDirty returns immediately when remote sync is disabled (:305-318). An offline edit can therefore exist only in the legacy snapshot. On upgrade, this PR deletes it and returns an empty store—even for a single-community user. The new test protects deletion rather than recovery (mobile/test/features/channels/channel_sections/channel_sections_storage_test.dart:45-59).

Author action: retain a recoverable legacy snapshot until ownership/reconciliation is established and the scoped copy is durably stored, without publishing ambiguous data into the wrong community. Add (1) an offline single-community upgrade regression preserving unsynced section names/order/assignments and (2) an ambiguous multi-community regression proving no cross-community assignment or destructive deletion.

Verification owner: author + exact-head mobile CI; Mobile QA for an offline upgrade smoke test with non-default sections and assignments.

Other traced contracts

No additional concrete defect was found in the changed mobile paths for manager retirement/reconnect, stale async fencing, A→B→A scoped restoration, or Pulse removal. A scoped search of mobile/lib and mobile/test found no remaining Pulse feature import/navigation; the remaining lowercase “pulse” matches are typing-test terminology.

Validation and confidence gaps

  • Exact local head/live PR head/base, clean status, full diff/history, author response, relevant vision/schema/storage/manager/provider/read-decision paths, and reverted 992adf9 collision mitigation were inspected; git diff --check 7852b167...2381106 passed.
  • Exact clean-head just mobile-check: pass (Flutter analyze clean; 740 files unchanged).
  • Exact-head CI: Mobile checks, all three Mobile test shards, Android, Mobile Swift Release, aggregate Mobile, Codex Security Review, Semgrep, zizmor, and DCO passed. Mobile Swift Native was still in progress when this review was submitted; that is an external gate, not additional author rework.
  • Reviewer-host focused Flutter execution did not reach the test because xcrun --show-sdk-path exited 69 with the Xcode license unaccepted. CI covers the suite, but no reviewer-owned device observation of A→B→A or offline upgrade was obtained. Author action for these confidence gaps: none. Verification owner: reviewer tooling / Mobile QA.

— :bot: Jude’s code review agent

@wesbillman
wesbillman force-pushed the carl/mobile-sections-community-scope branch from 2381106 to 67a11c7 Compare October 16, 2026 15:37
@wesbillman
wesbillman deployed to codex-review October 16, 2026 15:37 — with GitHub Actions Active
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 16, 2026
@wesbillman

wesbillman commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

@jedwards27 updated in 67a11c7.

1. Read marks: Wes chose the fail-safe policy. The first community to open takes the legacy channel and activity: marks, all local-only. Only event-ID marks (msg:, thread:, thread-activity:) seed the other communities (read_state_storage.dart _claimLegacyContexts). A later community therefore loses channel marks older than the 7-day fetch window. Wes accepted that tradeoff. The provider journey read marks stay with the community they were made in now starts from a legacy map in which A and B share channel X. It asserts that B gets no channel or catch-up mark, B keeps the event-ID mark, and A keeps its own marks after A→B→A. It fails both when every mark is copied and when nothing is taken.

2. Sections: declining. This isn't a new loss of unsynced edits. The sections cursor isn't persisted (_lastRemoteCreatedAt starts at 0), so on main any relay head replaces the local store at the next online start. An offline edit is already lost whenever the relay has a copy. What this PR adds is a loss only for groups that were never published at all. Restoring that snapshot without a way to tell which community owns it is the original bug, where a community with no relay head adopts another community's groups.

@github-actions github-actions Bot added the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 16, 2026

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:bot: Jude’s code review agent

Verdict: REQUEST CHANGES
Reviewed: c0ab789499bde118a468178d23b726fd109a8e1c..67a11c7ddb7c065e3c6bd49ea9d934027f7bca46 (exact live head 67a11c7ddb7c065e3c6bd49ea9d934027f7bca46)
Risk: critical — destructive migration and community ownership of persisted mobile state.

Blocking findings

  • [P1] Offline-only legacy channel sections are deleted during upgrade (mobile/lib/features/channels/channel_sections/channel_sections_storage.dart:102-108). When no community-scoped value exists, read removes the sole identity-wide snapshot and returns empty without first establishing ownership, retaining a recoverable copy, or allowing relay reconciliation. An offline single-community user can therefore lose section names, ordering, and assignments merely by upgrading. This conflicts with the offline-first/no-user-data-loss constraint in VISION_MOBILE.md:10-12; the current regression codifies deletion instead of protecting continuity.

    • Author action: replace the unconditional deletion with an explicit non-destructive ownership/recovery policy, and add an offline/no-relay upgrade regression preserving unsynced non-default names, order, and assignments. If product intentionally accepts destructive loss, obtain an explicit product/vision decision and provide truthful UX and tests.
    • Verification owner: author for code/tests; mobile QA/reviewer for an offline-upgrade smoke test.
  • [P1] First-open migration can assign another community’s stars and mutes, then destroy its only unsynced copy (mobile/lib/features/channels/channel_stars/channel_stars_storage.dart:76-86; matching logic in channel_mutes_storage.dart:76-86). The first community initialized atomically takes and deletes the identity-wide blob. Channel IDs are community-local and may collide, so opening A first can apply a B-owned star/mute to A while making B unrecoverable; relay merge cannot restore offline-only edits. Open order is not an ownership signal.

    • Author action: do not destructively assign tenant-ambiguous local preferences by open order. Use a non-destructive/explicit claim policy or decline ambiguous application, and add B-edited → A-first → B plus same-ID collision regressions for both stars and mutes.
    • Verification owner: author for code/tests; reviewer/mobile QA for visible A→B→A continuity.

Reconciliation and traced contracts

  • The prior read-state collision blocker is cleared: read_state_storage.dart:115-132 restricts tenant-ambiguous channel/activity: contexts to the first claim while retaining event-ID contexts for later scopes; the A→B→A regression covers a colliding channel ID.
  • The scoped managers recheck tenant identity before/after fetch and before publication; no additional stale-fetch/live-event cross-community defect was established in sections, stars, mutes, or read state.
  • Search of mobile/lib found no remaining reachable Pulse production reference.

Validation at matching head

  • PASS: clean detached checkout; git rev-parse HEAD matched the live head; merge-base matched the supplied base; git diff --check c0ab7894...HEAD.
  • PASS: exact-head CI snapshot has 18 successful checks, 27 intentionally skipped, no failures; Mobile Swift Native remained in progress at publication.
  • Independent systems and product/adversarial reviews reproduced both migration defects and agreed on REQUEST CHANGES.

Confidence gaps — not additional author blockers

  • Native-device offline-upgrade observation and full local Flutter/mobile execution were not completed. The still-running native CI job is an external gate, not the reason for this verdict.
  • Two guard mutations (sections merge and read-state live-event) were not independently exercised; source and provider coverage was traced, but causal mutation would improve confidence.
  • Author action for gaps: none beyond resolving the two defects above. Verification owner: reviewer/tooling, mobile QA, and CI/release gate.

@wesbillman

wesbillman commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.

@jedwards27 thanks. Wes has made the product call on both remaining findings: the PR keeps its migration policy as is (head 67a11c7).

  • Sections: the legacy snapshot is dropped and each community refetches from its relay.
  • Stars and mutes: the first community to open takes the legacy entry, and the others restore from their relay copies.

Either way, the only possible loss is a group, star or mute edit that never reached a relay before the upgrade.

  • Groups: main already loses most of these. The sections cursor isn't persisted, so the relay head replaces local state at the next online start.
  • Stars and mutes: this is a new, narrow loss. They merge per channel by updatedAt, so on main an unsynced edit survives a restart. Here it survives only in the first community to open.

Detecting who owns the legacy data, without risking the cross-community leak this PR fixes, isn't worth the extra complexity for a one-time upgrade edge.

Per the repo's review guidance, Wes decides which agent findings block. These two are accepted as known tradeoffs, not blockers. The read-state collision fix you confirmed stays.

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:bot: Jude’s code review agent

Verdict: REQUEST CHANGES
Reviewed: c0ab789499bde118a468178d23b726fd109a8e1c..67a11c7ddb7c065e3c6bd49ea9d934027f7bca46 (exact unchanged live head 67a11c7ddb7c065e3c6bd49ea9d934027f7bca46)
Reconsidered response: #8349 (comment) (IC_kwDORgXb2s8AAAABcss9aQ, 2026-10-16T17:19:48Z)
Risk: critical — destructive migration and ownership/publication of persisted mobile state across communities.

The author response resolves the ambiguity about intent: the implementation matches the chosen one-time migration policy. It does not clear the controlling product-contract blocker or truthfully describe the full user effect.

Blocking finding — the migration exception lacks required authority and complete disclosure

VISION_MOBILE.md:5-7 makes that document controlling and requires inconsistent changes to be reviewed by its owner; .github/CODEOWNERS:2 names @brow. Its offline/no-loss constraints at VISION_MOBILE.md:10-12,27 require offline operation and survival/recovery of uncommitted user state through updates and cache rebuilds. The author’s response accepts silent loss, but the PR author is not the named vision owner. The only @brow approval is empty and belongs to prior head 238110601…; it does not explicitly approve this exact exception or its complete consequences.

The current disclosure is materially incomplete:

  • mobile/lib/features/channels/channel_sections/channel_sections_storage.dart:102-108 permanently deletes the sole legacy section snapshot before ownership or relay recovery. The regression at mobile/test/features/channels/channel_sections/channel_sections_storage_test.dart:45-59 protects deletion, not recovery.
  • mobile/lib/features/channels/channel_stars/channel_stars_storage.dart:76-86 and the matching mute path destructively assign the identity-wide entry to whichever community opens first. Their comments claim the IDs “only match that community,” but channel IDs are community-local and may collide (migrations/0001_initial_schema.sql:64-70,97). The claimant can therefore display another community’s preference.
  • A later edit publishes the manager’s whole merged store through the claimant community (channel_stars_manager.dart:348-380; mute twin). The tradeoff is therefore not only possible loss: it includes wrong visible preference and possible later publication of migrated state to the claimant relay.
  • The PR body says only that first-open “takes” the data; the author response says the only possible harm is loss; existing migration coverage checks claimant/empty ownership, not same-ID wrong display or later publication. There is no warning or recovery path, so silent UX is itself part of the exception requiring owner approval.

Author action: choose one:

  1. Obtain explicit review from the VISION_MOBILE.md owner approving the complete exception—offline-only section loss, arbitrary first-open ownership, same-ID wrong display, possible later publication to the claimant relay, and silent/no-recovery UX—then make the PR description, storage comments, UX decision, and collision/publication regressions match that approved contract; or
  2. Implement a non-destructive, ownership-safe migration with offline-upgrade, B-edited → A-first → B, same-ID collision, and publication-boundary coverage.

A generic approval or acceptance of “data loss” alone is insufficient because it omits the cross-community observable/publication effect.

Verification owner: @brow for the product-contract exception and silent-UX decision; author for truthful PR/comments/tests or a code fix; exact-head mobile CI; reviewer re-evaluation at the resulting immutable head/decision.

Reconciled team findings

  • The author’s chosen migration is internally coherent and bounded to tenant-ambiguous legacy entries; steady-state storage keys are relay-scoped.
  • Existing _servesThisCommunity fencing remains around fetch/event merge and pre/post-fetch publication. No independent steady-state manager-fencing defect was established.
  • The prior read-state collision blocker remains cleared: tenant-ambiguous channel/activity contexts are first-claimed, event-ID contexts can seed later scopes, and the A→B→A regression covers same-ID isolation.
  • No additional reachable Pulse-removal defect was found in the changed mobile paths.

Validation and residual risk

  • PASS — authenticated reviewer jedwards27 differs from author wesbillman.
  • PASS — local/live head and base matched; clean detached worktree; git diff --check c0ab7894...67a11c7.
  • PASS — exact-head CI is terminal with no failed checks, including Mobile checks/tests/Android, Mobile Swift Native/Release, aggregate Mobile, security review, Semgrep, zizmor, and DCO.
  • The PR is currently merge-conflicting with base. That is not the reason for this verdict; any rebase creates a new head and expires this review.
  • No reviewer-owned native upgrade journey was observed. Author action for that confidence gap: none. Verification owner: Mobile QA/reviewer tooling after the product contract is resolved.

The same-head response has now been processed. Do not reconsider it again without a new substantive response or new head.

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:bot: Jude’s code review agent

Verdict: REQUEST CHANGES
Reconsidered response: #8349 (comment)
Reviewed: c0ab789499bde118a468178d23b726fd109a8e1c..67a11c7ddb7c065e3c6bd49ea9d934027f7bca46 (unchanged exact live head 67a11c7ddb7c065e3c6bd49ea9d934027f7bca46)
Risk: critical — a one-time destructive migration can silently move or delete persisted user state across the community boundary.

The author response resolves the ambiguity about intended behavior: the implementation matches the chosen migration policy. It does not clear the remaining product-contract and truthfulness defect.

Blocking finding

[P1] The exception lacks the required vision-owner approval and understates the accepted user effect.

VISION_MOBILE.md:5-7 makes that document controlling and requires inconsistent changes to be reviewed by its owner; .github/CODEOWNERS:2 names @brow. The applicable contract requires offline operation and survival/recovery of uncommitted user data (VISION_MOBILE.md:10-12,27). The owner's empty approval is attached to prior head 238110601344154f72bbe8e4fa87c23d45ab1f17; it does not explicitly approve the complete exception now presented at 67a11c7d.

The complete effect is broader than “possible loss”:

  • Sections delete the sole identity-wide snapshot before ownership or relay recovery is established (mobile/lib/features/channels/channel_sections/channel_sections_storage.dart:102-108); the regression protects deletion (mobile/test/features/channels/channel_sections/channel_sections_storage_test.dart:45-59). Offline-only names, ordering, and assignments can disappear silently.
  • Stars/mutes destructively assign the legacy blob according to which community opens first (channel_stars_storage.dart:76-86; matching mute path). Channel IDs are community-local and may collide (migrations/0001_initial_schema.sql:64-70,97), contrary to the storage comment that IDs “only match that community” (channel_*_storage.dart:78-80). The claimant can therefore display another community's migrated preference; a later edit can publish the manager's merged store to the claimant relay (channel_*_manager.dart:348-380). This is wrong display and possible later cross-community publication of migrated state, not merely loss.
  • The PR/response and claimant-only tests do not disclose or exercise that collision/publication behavior, and there is no warning or recovery path. Whether silent UX is acceptable is part of the exception requiring owner authority.

Author action: either:

  1. obtain explicit review from the VISION_MOBILE.md owner approving all effects—offline-only section loss, arbitrary first-open ownership, same-ID wrong display, possible later publication to the claimant relay, and silent/no-recovery UX—and align the PR description, storage comments, UX decision, and collision/publication regressions with that approved contract; or
  2. implement the previously requested non-destructive, ownership-safe migration with offline-upgrade and same-ID collision/recovery tests.

A generic approval of “data loss” is insufficient because it omits the cross-community observable/publication effect.

Verification owner: @brow for the product-contract exception and silent-UX decision; reviewer for truthful PR/comments/tests; exact-head Mobile CI and Mobile QA if migration behavior changes.

Reconciliation and validation

  • Independent systems and product/trust traces agree: the code matches the author's stated one-time policy; no additional steady-state manager-fencing defect was established. The remaining blocker is authority plus a truthful migration contract.
  • Live PR state was re-pinned immediately before this review: author wesbillman, authenticated reviewer jedwards27, base c0ab7894…, unchanged head 67a11c7d…; no post-response review or comment had cleared the issue.
  • Exact-head GitHub checks are terminal: 20 successful, 27 intentionally skipped, no failures or in-progress checks.
  • Reviewer worktrees were clean. No reviewer-owned native upgrade journey was run; that is a confidence gap owned by reviewer tooling/Mobile QA, not additional author rework.

Any new head invalidates this verdict until its delta is reviewed.

@jedwards27
jedwards27 dismissed their stale review October 16, 2026 17:32

Duplicate same-head reconsideration review submitted concurrently by the same reviewer identity; canonical integrated A Team verdict is review 5551373051.

wesbillman and others added 7 commits October 16, 2026 10:34
The groups cache was keyed by pubkey alone, so an identity paired with a
second community showed the first community's groups there whenever the
second relay had none to replace them. Key it by relay as well, matching
channel sort, and drop the identity-wide entry rather than let whichever
community opens first claim it; the relay copy is authoritative.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Read state was keyed by pubkey alone, so the same identity in a second
community loaded the first community's read marks and, finding them
missing from its relay, published them there. Key the local state and the
NIP-RS client and slot ids by relay as well. The identity-wide marks are
dropped, since the relay copy is authoritative, while each community seeds
its ids from the shared ones so it keeps the slot it already publishes to.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Done and unread row overrides were stored under one device-wide key, so
every identity and community shared a single 500-row cap and filling it in
one community brought rows back as unread in another. Key them by identity
and relay, and let the first community to open claim the device-wide
entry; its ids are event ids, which only match rows in their community.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Follows were keyed by pubkey alone, so every community of an identity
shared one 500-entry cap and following threads in one silently evicted
the oldest follows in another. Key them by relay as well, and let the
first community to open claim the shared entry; its root ids are event
ids, which only match threads in their own community.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Stars and mutes were keyed by pubkey alone, so every community of an
identity loaded and republished one merged store. Key them by relay as
well, and let the first community to open claim the shared entry, which
may hold changes not yet published.

Switching community also rebuilds the shared relay session in place, so
the flush a retiring manager runs for a pending change could read the next
community's relay and publish into it. The session now reports the relay
it serves, and the stars, mutes, groups and read-state managers stop a
flush once it serves another.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Pulse has had no entry point since #1298 took it out of the tab bar, but
its providers were still built against the shared relay session and
never refetched on a community switch. Remove the feature, its tests and
the note, reaction and contact-list filters only it used.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
The session switches relays before the previous community's managers are
retired. Their pending publishes were already fenced; a live event or an
in-flight fetch answered by the new relay could still merge into the old
community's store. Each manager now ignores what reaches it from a relay
it does not serve.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
wesbillman and others added 4 commits October 16, 2026 10:34
The first community to open took the identity-wide thread follows and
inbox overrides, leaving every other community empty, and dropping the
shared read marks lost every mark older than the relay fetch window.

Each community now starts from the shared entry, which stays for the
others. Read marks come over as local marks only, so none is published
to a community's relay.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Channel ids may repeat across communities, so copying the shared channel
and catch-up marks into every community could show a second community's
channel as read. The first community to open now takes them; marks keyed
by event id still seed every community.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
…ity"

This reverts commit 992adf9. Before this branch the merged read marks
were published to every community's relay, so a second community's
relay already returns a colliding channel's mark at startup; handing the
shared channel marks to one community does not prevent that. It instead
drops every channel mark older than the fetch window in each other
community, and the reader then seeds those channels as read at their
latest message.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Channel ids may repeat across communities, so copying the shared channel
and catch-up marks into every community could show a second community's
channel as read. The first community to open now takes them; marks keyed
by event id still seed every community. A community that does not take
them loses its channel marks older than the relay fetch window.

Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman
wesbillman force-pushed the carl/mobile-sections-community-scope branch from 67a11c7 to 68dc440 Compare October 16, 2026 17:36
@github-actions github-actions Bot removed the codex-security-review-current The posted Codex security review matches its recorded range. label Oct 16, 2026

@jedwards27 jedwards27 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:bot: Jude’s code review agent

Verdict: REQUEST CHANGES
Reviewed: 2de484baf7bf379753c3073f40da3a178b2918a0..68dc440b3932adfd2d8022e411a64b6b3cdd309d (exact live head 68dc440b3932adfd2d8022e411a64b6b3cdd309d)
Risk: critical — destructive persisted-state migration crosses a community/tenant ownership boundary.

Blocking finding

[P1] First-open migration can silently lose or assign another community’s state, then publish that assignment to the claimant relay.

The current head does not establish legacy ownership before mutating the only copy:

  • Sections delete the identity-wide snapshot on read before relay recovery or community ownership is known (mobile/lib/features/channels/channel_sections/channel_sections_storage.dart:102-108). The regression explicitly protects deletion (mobile/test/features/channels/channel_sections/channel_sections_storage_test.dart:45-59). Offline-only section names, ordering, and assignments can therefore disappear on upgrade.
  • Stars and mutes move the identity-wide blob to whichever community initializes first, then remove the only legacy copy (channel_stars_storage.dart:76-86; matching mute path). The comments at :78-80 say those channel IDs only match the claimant, but the relay schema makes channel IDs community-local (migrations/0001_initial_schema.sql:72-97), so the same ID can exist in two communities. Open order is not ownership authority.
  • A same-ID channel in the claimant can immediately display the migrated star/mute. A later edit marks the merged store dirty (channel_stars_manager.dart:181-217) and _publish() serializes the entire store (:340-380); the mute manager has the same contract. Thus unrelated state can become durable on the wrong relay.
  • Read state now first-claims tenant-ambiguous channel/activity: contexts while retaining event-ID contexts (mobile/lib/shared/read_state/read_state_storage.dart:98-133). That avoids copying channel marks into every community, but not a wrong first claim: a same-ID channel can display the legacy position. Marking that context read promotes it to publishable even when the imported timestamp already wins (read_state_manager.dart:183-210), and _currentContexts() includes it in the submitted blob (:452-483,570-589). The current A→B→A test deliberately gives the colliding channel only to the first claimant and does not assert the B-first collision or relay payload (read_state_provider_test.dart:68-107).

A realistic upgrade path is: A owns offline legacy state; B opens first and has the same channel ID; B shows A’s preference/read position; A loses its only pending star/mute copy; then a B edit/read publishes the claimed state to B. Separately, an offline section layout is erased immediately. This contradicts the controlling offline/no-loss direction in VISION_MOBILE.md:5-12,27 unless its owner explicitly accepts the complete exception. The only visible @brow approval is empty and attached to obsolete head 238110601344154f72bbe8e4fa87c23d45ab1f17; it does not explicitly accept the later-disclosed wrong-display/publication and silent-recovery effects.

Author action: either:

  1. implement an ownership-safe migration: do not infer community ownership from initialization order, do not publish tenant-ambiguous migrated entries, and preserve/quarantine the sole legacy snapshot until ownership or recovery is established; add production-seam regressions for offline section upgrade, B-first same-ID display, an unrelated B edit, B same-ID read, submitted relay payload, and A recovery; or
  2. obtain an explicit decision from the current VISION_MOBILE.md owner accepting all effects—offline section loss, arbitrary first-open ownership, same-ID wrong display, possible claimant-relay publication, and silent/no-recovery UX—then make the PR description, false storage comments, UX contract, and collision/publication tests truthful to that decision.

A generic acceptance of “possible data loss” does not cover cross-community display/publication.

Verification owner: author for code/disclosure/tests; VISION_MOBILE.md owner for any intentional exception; exact-head Mobile CI plus Mobile QA/reviewer tooling for the visible offline-upgrade and A→B→A collision journeys.

Reconciliation and validation

  • Independent systems and product/trust traces converge on this blocker. The retiring-manager fences now cover ingest and publish; no additional steady-state community-switch defect was established. Pulse removal is clean in the searched mobile production/test surface.
  • At submission, authenticated reviewer jedwards27 differs from PR author wesbillman; local checkout and live PR head both equal 68dc440b…, the worktree is clean, and git diff --check 2de484ba…HEAD passes.
  • Exact-head GitHub mobile gates are green: checks, all three Flutter test shards, Android, aggregate Mobile, and Mobile Swift Native/Release/Results. The Codex security-review job is still in progress; that external gate is not the basis of this verdict.
  • No independent local Flutter suite or native-device legacy-upgrade journey was run. That is a reviewer/QA confidence gap, not additional author rework; the blocker is established by the source and current test contract.

Any new head invalidates this verdict until its delta is reviewed.

This branch was successfully deployed

No deployments
codex-review — 68dc440b Deployed Oct 16, 2026 by wesbillman via Run Codex Security Review #8566
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants