Repository navigation
fix(mobile): keep cached state with its community - #8349
wesbillman wants to merge 11 commits into
Conversation
jedwards27
left a comment
There was a problem hiding this comment.
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:
- Legacy community A has channel UUID
Xmarked read. - Community B independently has channel UUID
Xand no scoped read-state entry yet. - Opening B falls back to A's legacy map and imports A's timestamp.
- 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, cleangit 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 Reviewremained in progress, not failed. - Independent local Flutter execution on the reviewer host did not reach tests because the
objective_cnative hook invokedxcrun, 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
deea487 to
992adf9
Compare
|
🤖 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
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. |
🔐 Codex Security Review
|
jedwards27
left a comment
There was a problem hiding this comment.
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_cnative-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
left a comment
There was a problem hiding this comment.
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
992adf9collision mitigation were inspected;git diff --check 7852b167...2381106passed. - 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-pathexited 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
2381106 to
67a11c7
Compare
|
🤖 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 2. Sections: declining. This isn't a new loss of unsynced edits. The sections cursor isn't persisted ( |
jedwards27
left a comment
There was a problem hiding this comment.
: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,readremoves 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 inVISION_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 inchannel_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-132restricts 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/libfound no remaining reachable Pulse production reference.
Validation at matching head
- PASS: clean detached checkout;
git rev-parse HEADmatched 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.
|
🤖 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).
Either way, the only possible loss is a group, star or mute edit that never reached a relay before the upgrade.
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
left a comment
There was a problem hiding this comment.
: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-108permanently deletes the sole legacy section snapshot before ownership or relay recovery. The regression atmobile/test/features/channels/channel_sections/channel_sections_storage_test.dart:45-59protects deletion, not recovery.mobile/lib/features/channels/channel_stars/channel_stars_storage.dart:76-86and 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:
- Obtain explicit review from the
VISION_MOBILE.mdowner 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 - 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
_servesThisCommunityfencing 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
jedwards27differs from authorwesbillman. - 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
left a comment
There was a problem hiding this comment.
: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:
- obtain explicit review from the
VISION_MOBILE.mdowner 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 - 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 reviewerjedwards27, basec0ab7894…, unchanged head67a11c7d…; 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.
Duplicate same-head reconsideration review submitted concurrently by the same reviewer identity; canonical integrated A Team verdict is review 5551373051.
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>
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>
67a11c7 to
68dc440
Compare
jedwards27
left a comment
There was a problem hiding this comment.
: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-80say 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:
- 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
- obtain an explicit decision from the current
VISION_MOBILE.mdowner 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
jedwards27differs from PR authorwesbillman; local checkout and live PR head both equal68dc440b…, the worktree is clean, andgit diff --check 2de484ba…HEADpasses. - 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.
Summary
One identity can be paired with several communities, but mobile keyed several caches by pubkey only. Mobile keeps a single
ProviderScopeand 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.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:
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.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
flutter analyzeis clean, and the full mobile suite passes atdeea4878d(3340 passed, 4 skipped).🤖 Generated with Claude Code