Skip to content

fix(chat): persist the viewer's EditChat grant - #1526

Merged
bmc08gt merged 2 commits into
code/cashfrom
fix/viewer-state-permissions-persistence
Sep 22, 2026
Merged

bmc08gt merged 2 commits into
code/cashfrom
fix/viewer-state-permissions-persistence

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

ViewerState.permissions.canEdit arrived in #1524 and is mapped from the proto correctly, but it never reached the database. Anything gated on it sees false, always.

ChatEntityMapper wrote mute and version only, and rebuilt ViewerState without permissions, so the grant came back at its false default on every read. That would be survivable if screens read the fetch that produced it, but they don't — ChatViewModel observes observeMetadata, which is DB-backed. The server's answer was already gone by the time anything looked.

The same function had a second problem:

if (mute == null && viewerStateVersion == 0L) return null

A chat with no mute at version 0 collapsed to a null ViewerState outright, discarding the grant for exactly the common case — a group nobody has muted. Adding a column without fixing this would not have been enough.

The change

can_edit on chat_metadata (schema 37), carried through ChatEntityMapper, ChatMetadataDao and ChatMetadataDataSource, and counted in the emptiness test above. clearViewerState drops it with the rest.

updateViewerStateIfNewer takes canEdit as a required parameter rather than a defaulted one. A default would deny silently on any caller that forgot, which is the failure this PR exists to fix.

Why the migration re-arms the version

Migration36To37 zeroes viewer_state_version. The column arrives as 0 for every existing row, and the gate only writes when the incoming version beats the stored one. A chat whose viewer state hasn't otherwise changed reports the version it already reported, loses the gate, and holds the grant at 0 until something unrelated moves it — so without the re-arm the fix would appear to work on new chats and fail on every chat that predates the upgrade.

Nothing is lost by re-arming: every column that gate protects is server-owned and re-sent by the next fetch. The cost is a window between migration and that fetch in which an out-of-order stream event could apply; it closes at the first feed load, before the stream opens.

I considered relaxing the gate to >= instead, which would also let an equal-version fetch fill the column. I left it alone — the > is deliberate and documented on the DAO, and it governs mute as well as this.

Notes

Found while building the group-chat Edit UI (#1525), where the overflow item is gated on this field and so never rendered. The two PRs touch disjoint files and can land in either order; #1525 stays inert until this one merges.

ViewerState.permissions.canEdit is mapped from the proto into the domain model
but never reached the database. ChatEntityMapper wrote mute and version only,
and rebuilt ViewerState without permissions, so the grant came back false on
every read. Screens take viewer state from the DB-backed observeMetadata rather
than the fetch that produced it, so the server's answer was already gone by the
time anything read it.

The same function collapsed a chat to a null ViewerState when it held no mute
at version 0, which discarded the grant for any chat nobody had muted.

Adds can_edit to chat_metadata (schema 37), carries it through the mapper, the
DAO and ChatMetadataDataSource, and counts it in the emptiness test.

Migration36To37 zeroes viewer_state_version. The column arrives as 0 for every
existing row, and updateViewerStateIfNewer only writes when the incoming version
beats the stored one; a chat whose viewer state had not otherwise changed would
report the version it already reported, lose the gate, and hold the grant at 0
until something unrelated moved it. Nothing is lost by re-arming, because every
column that gate protects is server-owned and re-sent by the next fetch.
@bmc08gt bmc08gt self-assigned this Sep 22, 2026
@github-actions github-actions Bot added the type: fix Bug fix label Sep 22, 2026
The server bumps ViewerState.version only when the viewer's state changes,
so a chat nobody has ever muted stays at version zero indefinitely. The
gate on updateViewerStateIfNewer is a strict >, which made 0 > 0 false and
rejected every payload such a chat ever sends.

That was invisible while the gate only guarded mute columns, because the
rejected payload said nothing new about them. It stopped being invisible
once canEdit rode the same gate: the grant arrives on those zero-versioned
payloads too, so it could never be stored. On a test account the server
returns canEdit=true for a group at version zero and the edit affordance
stayed hidden on it, while a group at version 7 showed it.

A stored zero means nothing has ever been applied to the row, so anything
the server sends beats it. Ordering is lost for that first write, but two
payloads at version zero describe the same unchanged state; the strict gate
resumes as soon as a non-zero version lands. Rows that predate this all sit
at zero already, because the 36-to-37 migration rearmed them, so they pick
the grant up on the next fetch without another migration.
@bmc08gt
bmc08gt merged commit c76f5c7 into code/cash Sep 22, 2026
3 checks passed
@bmc08gt
bmc08gt deleted the fix/viewer-state-permissions-persistence branch September 23, 2026 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant