Skip to content

feat: add workspace permission controls and live access recovery - #621

Draft
appflowy wants to merge 198 commits into
mainfrom
codex/oodrive-permissions-implementation
Draft

appflowy wants to merge 198 commits into
mainfrom
codex/oodrive-permissions-implementation

Conversation

@appflowy

@appflowy appflowy commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Related PRs

Oodrive permissions (plan AppFlowy-IO/AppFlowy-Cloud-Premium#1367):

Description

Web follows the workspace, space and page permissions the server enforces (server #1370, plan #1367; Desktop #1466 uses the same words). All strings exist in English and French.

  • Permissions (Settings > Permissions): "Who can create spaces" offers Owners and members, Owners and selected members, Owners only, in that order, plus the Selected members and groups list (names, emails, group sizes, a Not eligible badge). Changes apply at once with a toast; a narrower rule asks "Change who can create spaces?" first. Members see "You can't create spaces in this workspace."
  • Access to all spaces (People > Groups > group > General, summarized in Permissions): Off, Can view or Can edit for every current and future public and custom space; private spaces and restricted pages are excluded. Granting or raising asks "Give {group} access to all spaces?".
  • Manage space: Members have Name | Access | Role with Space default ({level}), Can view, Can comment, Can edit (owners: Full access); General has "Who can add pages to this space": Only space owners, or Space members who can edit (Custom) / Workspace members who can edit (Public). A refused change keeps the confirmed value and names what failed ("Couldn't change access for {name}. {reason}").
  • Create space: a definitive refusal replaces the form with a summary, "You can't create spaces in this workspace.", "Ask a workspace owner if you need a new space." and Close, releasing the draft; only an ambiguous outcome (timeout, network loss, 5xx after send) keeps a resumable request with Check again and Discard. An oversized published copy says "This page is too large to duplicate at once." (HTTP 413, code 1016).
  • Workspace creation: unselected people see "Your administrator hasn't allowed you to create workspaces." on the unavailable Create and Import entries; an account without a workspace lands on "You're not in a workspace yet" with its invitations (Join), Check again and Sign out.
  • Old servers stay usable: a missing capability route (404/405) means legacy behaviour; creation stays enabled, the server decides, and workspace creation uses the legacy POST /api/workspace without a receipt.
  • Live changes: an open page becomes "No access to this page" when access is removed and recovers without a reload when it returns; search, recent and mention results retire with the revision; unsent edits are kept, never uploaded without current access, and offered Try again / Discard changes.

204 commits over the first draft (30544159), including the merge of Web main up to 05604b32 (#618, #619, #620, #623, #626, #628, #629, #630): 248 review findings across the three repositories were fixed in waves with independent reviews (103 on Web: 98 in round 1, 5 in round 1b), then a gallery design review and the Desktop-parity follow-ups.

Found while recording the videos, both fixed:

  • A view-only reader saw "Changes to 1 page are waiting for edit access." on opening a page whose history had deletions, without editing anything.
    • Cause: a Yjs diff always carries the full deletion history, so the reply to the server was never empty.
    • Fix: Web no longer queues replies that only repeat what the server has already sent (8468aff5, 0c8da2e4, 86d828cd). Editors stop re-sending those deletions on every open.
    • Testing: a differential fuzz test compared the new update reader with Yjs on about a million updates without a mismatch.
  • The owner's Permissions panel got HTTP 400 and never listed the selected members and groups. The server rejected the restore stamp that Web main now adds to every request; this is fixed in server #1370 (65bc42360).

Validation

  • Full Jest (jsdom, mocked HTTP), behind a network guard that blocks every non-loopback connection:
    • 782/782 suites and 13,076/13,076 tests at b59969d6, after the merge of main.
    • 785/785 suites and 13,137/13,137 tests with the reader fix; the app source is identical to this head.
  • ESLint over the whole app (.eslintignore.web) at b59969d6 and on every file changed since, plus tsc --noEmit --project tsconfig.web.json on this head's app source: exit 0; git diff --check clean.
  • Gallery and videos on an isolated stack (own GoTrue, Redis, PostgreSQL and MinIO; synthetic @demo.test data; Chromium 145 headless), with a server built from #1370's head 65bc42360:
    • The capture spec passed 8/8. Every current screenshot matched the committed one: 19 byte-identical, 4 within noise.
    • The five video tests passed twice back to back, then once more at this head.
    • Provenance, hashes and per-video checks are in the gallery README and manifest.
  • Not run: remote CI (the Web CI build jobs skip drafts), other browsers and operating systems, the mobile layout, the Worker/search/AI services on the gallery stack, a screen-reader session.

Known limits

  • "Invited as a guest" reads role ("Owner" | "Member" | "Guest") on each pending invitation of GET /api/workspace/invite?status=Pending; older servers omit it and the line is simply not shown.
  • On a legacy server an interrupted workspace creation can only be discarded (no receipt).
  • Two behaviours follow Web main's design and were left as main has them:
    • New pages, including a new space's first page, always use atomic creation, with no fallback for servers without the atomic routes.
    • An interrupted page creation can be resumed but not discarded.
  • A gallery rerun reproduces the screens, not always the bytes; failure toasts are not captured. A pre-existing lint error in src/vite-env.d.ts:42 is excluded by .eslintignore.web.

Gallery and provenance · Capture manifest · First-draft validation record · Compatibility guide

Videos

Five Playwright tests recorded on the same stack. Every step is checked on screen and on the server, and each test puts back what it changes. Captions and the pointer dot are test overlays. The videos play in real time, with no speed-up. Click a preview for the MP4.

1. Two groups, two levels in HR. The HR group can edit and the Users group can view. Olivia changes Users to Can comment and back, and the server confirms each change.

Two groups, two levels in HR

2. A view group reads but can't edit. Alex reaches HR only through the Users group (Can view). The page opens read-only, typing changes nothing, and no "waiting for edit access" notice appears.

A view group reads but can't edit

3. An edit group edits, and the edit is kept. Sam reaches HR only through the HR group (Can edit). He adds a sentence, the server keeps it across a reload, and he deletes it again.

An edit group edits, and the edit is kept

4. Who may create workspaces. Sam sees Create and Import workspace unavailable, with the reason. Jordan, who has no workspace, sees the reason and his guest invitation. Olivia, the one selected creator, creates a workspace, which the test deletes afterwards.

Who may create workspaces

5. Who may create spaces. Olivia's panel lists the selected Alex and Super_Users, and she narrows space creation to Owners only. Alex's Create is then refused with Close, and New page shows Create space as unavailable. The test held back Alex's live update so that the refusal at Create is visible.

Who may create spaces

Feature Preview

Real Web app against a real server on an isolated stack with synthetic data; the gallery README says what each image does not prove.

Permissions, as the owner: Alex and the Super_Users group selected; the Access to all spaces summary follows below.

Permissions as the owner, with the selected members and groups

Narrowing asks first (cancelled; the policy stayed unchanged).

Confirmation before narrowing who can create spaces

People > Groups > HR > General: the Access to all spaces menu, and the question before giving HR Can edit (cancelled).

Menu Confirmation
Group dialog with the Access to all spaces menu open Confirmation before giving HR access to all spaces

Manage space · HR > Members: Olivia (space owner) shows Full access; Alex keeps the space default.

Manage space members with the Access menu of Alex open

Two groups, two access levels (Manage space · HR > Members): the HR group has Can edit and the Users group has Can view. Every person and group row has its own Access menu (Space default, Can view, Can comment, Can edit) in Custom spaces; someone in both groups gets the higher level, and space owners always have Full access.

Manage space members: the HR group has Can edit and the Users group has Can view

Manage space · HR > General: "Who can add pages to this space: Only space owners".

Manage space General tab with who can add pages

Create space refused after the owner narrowed the rule: summary, answer and Close; no Marketing space existed afterwards.

Create space refused: a summary of the request and the answer, with Close

Workspace menu without permission to create workspaces.

Workspace menu with Create workspace unavailable

Signed in without a workspace: a pending guest invitation ("Invited as a guest") with Join, the reason creation is unavailable, Check again and Sign out.

Account-only page with a pending invitation

An open page loses access when its group's access is removed (can_read: false, no editor, no Share); the next capture shows it recovering without a reload.

Open page after its group access was removed

Checklist

  • English and French strings, component and regression tests included; gallery, manifest and provenance updated.
  • Account-only guest capture retaken against the final server head.
  • Cross-browser and operating-system acceptance; connected acceptance against the final server build; remote CI before leaving draft.

🤖 Generated with Claude Code

appflowy and others added 30 commits October 12, 2026 07:49
Background refreshes (focus, visibility, websocket, permission hints) used to
drop the capability to undefined, which unmounted the Permissions panel's space
creation section and discarded typed search text, pickers and loaded rows.
The hook now keeps the last confirmed capability for the same scope while a
refresh is in flight or after a failed refresh, exposes `revalidating`, and
drops it only for an unsupported route or a scope change. Admission stays
fresh: `canCreateSpace` is true only for a current ready answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refresh that finished after another tab rotated the shared token used to
throw and route every caller to invalidToken(), signing all tabs out. The
refresh now adopts the stored rotation for the same account, treats sign-out,
another account and a server switch as a superseded session (never applied,
never signed out), and only ends the session when GoTrue rejects the refresh
token itself; offline, timeouts and 5xx keep the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A workspace permission hint received while the current user was still loading
was recorded as processed and then skipped as a retained old-transport message
once the session became identified, so open capability caches were not
re-probed. Nothing is recorded until the session is identified; messages
processed by a previous session are still fenced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts discardable

A definitive rejection of the create request (400/403/422, validation, B denial) now
forgets the saved request, keeps the fields editable and rotates the client-owned IDs,
instead of freezing the dialog behind a Retry that resent the identical payload.
Ambiguous outcomes keep a resumable request with plain-language "Check again" and a
confirmed "Discard". A committed space whose people could not all be added offers
"Try again" or "Add later", and Close no longer traps the user.

Unreadable saved data reports the translated recovery error instead of a raw
SyntaxError, and both the dialog and the sidebar offer Discard. The sidebar entry is
styled like the other sidebar actions. Creation on a server without the capability
route lets the server decide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The account key for app_view_cache and workspace_database_catalog became
server scoped without a schema change, so rows written by earlier builds were
never read, refreshed or evicted again. Version 14 deletes only those
pre-scope rows and keeps current server-scoped entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d-page rule

SpaceSidebarActions and ViewActionsPopover called useSpaceActionPermissions next to
useViewActionPermissions, which already owns that hook, so every hover, menu open and
permission notification issued two identical GET /spaces/{id}/permission requests.
useViewActionPermissions now exposes canOpenManageSpace and canEditSidebar from its single
read and the second call is gone.

On a server without the structured space routes (404/405) the sidebar policy does not
exist, so direct-child creation falls back to the old Write-only rule instead of hiding
the space "+" button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rity

Duplicate places the copy beside its source and Move adds the page under the chosen
destination, and the server requires the destination's Write plus, for a space root,
its sidebar authority (P). The page menus only checked the source page, so editors
were offered Duplicate in owners-only spaces and Move into any space root, then got a
server rejection.

A new usePageDestinationAccess hook reuses useViewActionPermissions for the destination.
The sidebar and page-header menus hide Duplicate while the parent cannot accept a new
page, and Move keeps its button disabled with a visible reason until the selected
destination is confirmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The access-override preflight fetched the space capability, then the
roster, then sent the PATCH: three sequential round trips before a
member's access could change. Both reads are independent, so they now
run concurrently. The capability result is still checked first, so a
lost management right keeps reporting the clearer "can't be changed"
error, and no write is ever sent unless both reads allow it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is unavailable

New Page listed every space and only learned from the server that a member without Write,
or without sidebar authority in an owners-only space, could not add a page there. Each
space is now checked when the dialog opens (one space-permission read per space, bounded
concurrency, re-checked on permission notifications without blanking results). Spaces
that cannot accept a page are disabled with the reason, Add stays disabled while the
selected space is checked, and a note explains when no space is available. Unchecked
spaces (transient errors, old servers) stay selectable and the server decides.

The disabled "Create a new space" link and "Duplicate Space" item explained themselves
only in a title attribute that disabled controls never show, and mislabelled failures as
loading. Both now show the reason inline (checking, denied, or a failed check with
Try again). A server without the capability route keeps both entry points enabled and
the server decides, instead of a permanent "ask your administrator" dead end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server pushes a PermissionChanged notification to every workspace
member, including the person who just changed something in this dialog,
and it does so before returning the HTTP response. Manage Space reacted
to every such notification by blanking the whole dialog (roster,
capabilities and settings) and reloading it, so each change was
followed by a full reload, and the success toast of the change was
dropped because the reload superseded it.

A notification now rechecks settings that are already on screen in the
background: the last confirmed data stays visible, controls whose
authority is being rechecked are disabled, and the name being typed is
not interrupted. If the recheck fails, the data stays visible but every
control stays blocked (fail closed). Opening the dialog and type changes
still load from blank. A settings change the server accepted is still
confirmed when the notification superseded its response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A creation request ID was saved before admission and kept after every
failure, so a 400 (name too long), a 403 or an offline/denied pre-check
left a read-only "Resume" request that could never succeed or be
discarded. The request is now saved only after fresh admission passes,
immediately before the keyed POST. Definitive answers (validation,
denial, conflict, seat limits, 400/401/403/409/422, or a first send that
reaches a node without the keyed route) release it; only outcomes that
may have committed keep it for a replay with the same request ID.
Corrupt stored values report a translated message and can be discarded.

A server without the capability route is treated as a legacy server for
every hosting mode: creation stays available and uses the original
POST /api/workspace without a request ID, so the client never advertises
creation and then calls a keyed route the server lacks. Replays skip the
client admission check (the server resolves receipts first) and never
fall back to the unkeyed route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every focus, visibility change, socket transition and permission hint
reset the capability to loading, so Create flickered to disabled and the
dialog's Create button toggled mid-typing. The last confirmed answer now
stays visible while it is rechecked (only the first answer shows
loading), focus/visibility rechecks wait 30 seconds after a confirmed
answer, socket transitions only recheck once the socket is open, and a
failed background read keeps the confirmed answer unless the browser is
offline.

A lower policy revision used to become a permanent error that every
Retry repeated. A background read with an older revision is now ignored,
while an explicit Check again accepts it, so a server restored to an
older revision cannot wedge the control. The hook also reports a legacy
server and the progress of an explicit check.

The notice explains a denial in plain words without a retry link (a
denial is a decision and refreshes on its own) and offers Check again
only when the answer could not be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the caller aborted an import while the task was being created or
before multipart completion, the next activity check threw the
"Your account or server changed" message, so a deliberate cancel looked
like an account switch. An abort within the same account and server now
raises an AbortError, whatever the transport reported, while a real
context change keeps its own error. The created task is still cancelled
by the admitting account, which is why the task request itself is not
aborted mid-flight: its ID is needed for that cleanup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A finished upload always gets the "Uploaded successfully" confirmation.
  A host callback used to replace it, so the empty-account page left the
  importer open at 100% with no message. Hosts are still notified and
  can supply their own confirmation text.
- The direct /import page no longer locks a denied, unchecked or offline
  user in an unclosable modal with disabled drop zones: the importer can
  be closed, and the page offers Import workspace again (same source) or
  Open AppFlowy.
- After a failed upload the retained file shows "Upload failed" and an
  "Upload again" button, instead of a second "Retry" beside the access
  check with a different meaning.
- A cancelled upload is not reported as a failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Empty-account page (shown when the account has no workspace):
- Branded layout titled "You're not in a workspace yet" that shows the
  signed-in email and how to get invited.
- Lists pending invitations (GET /api/workspace/invite?status=Pending,
  the route Desktop uses) with Join, which needs no creation permission,
  and any existing workspaces with Open.
- The most useful enabled action is primary. A denial is explained as
  text and hides Create/Import instead of showing disabled buttons with a
  tiny Retry link; loading and unreadable answers keep them disabled with
  the reason beside them.
- One Check again refreshes workspaces, invitations and creation access,
  reports a failed refresh, and finishes an unconfirmed creation request.
- Sign out uses the app's standard sign-out (clear the saved redirect,
  /login?force=true), so an earlier session's redirect cannot follow the
  next sign-in.
- A received import says the new workspace is still being processed.
- The create dialog opens with the suggested name; a dialog kept mounted
  while closed no longer resets it to an empty string.

Creation dialog, shared by the switcher and the empty-account page:
- An unconfirmed request shows "We couldn't confirm that the workspace
  was created." with Check again (same request ID, never a duplicate)
  and Discard. A created workspace that failed to open offers Open
  workspace and Dismiss. An unreadable saved request is explained with
  Discard instead of silently disabling Create.

Workspace switcher menu:
- The access notice and its Retry button lived inside the Radix menu,
  where Tab is suppressed, and disabled items are skipped by arrow keys.
  Unavailable Create/Import entries are now focusable, aria-disabled
  menu items whose reason is announced and shown in a tooltip, and
  Check again is a real menu item shown only when the answer could not
  be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useSearchAccessScope treated window focus, tab visibility, online/offline,
every WebSocket readyState transition and every token refresh as permission
invalidations, so open search results, AI summaries, recent pages and mention
suggestions blanked and re-requested on each alt-tab or reconnect (R37), and
in-flight Ask AI / create-page attempts were abandoned by them.

The hook now separates:
- hard invalidation (scope changes, isCurrent() false synchronously):
  permission and share hints, sign-in/sign-out, and a different session or
  account, also from another tab. A routine refresh keeps GoTrue's session_id
  and is no longer a session change; tokens without that claim stay
  conservative.
- isSessionCurrent(): the session part only, so work that already reached
  the server can finish after a permission hint.
- refreshKey: the socket reopened after a disconnect (missed hints), for
  background revalidation that keeps the current snapshot.
- online: browser connectivity for offline states.

Only rendering assigns the live scope; invalidation can no longer be undone
by a pending effect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both Settings suites stubbed workspace creation as always allowed, so
removing the gate on Manage Data's Import button would have passed. The
new cases mount the panel with a denied, loading and unreadable answer
(button disabled, no import, reason shown, Check again only for an
unreadable answer) and with an allowed or legacy-server answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Search dialog fixes on top of the scope hook:

- Ask AI (R34): once the chat page is created the attempt runs to the end.
  A permission hint re-reads the parent before attaching context (sources
  outside the fresh, permission-filtered tree are dropped) and still opens
  the chat; closing the dialog shows "AI chat created" with an Open action
  instead of orphaning the page; only another session stops silently. The
  button stays busy for the attempt's life, so one request cannot create two
  chats, and a permission change before creation says "try again".
- Ask AI destination (R43, Search part): the same Write + "who can add pages"
  rule as other direct-child creation is checked before creating; denials
  (and server 1012/403 refusals) explain in plain words; older servers
  without the routes and transient probe failures let the server decide.
- Recent pages (R35, R38): loading, offline, failed (with Try again) and
  empty ("No recent pages yet.") are distinct; clearing the query reuses the
  list loaded for this dialog opening and scope instead of re-fetching; a
  read that settles while a query is typed is kept; a reconnect revalidates
  in the background.
- Best matches (R39, R37): offline shows "You're offline" instead of "No
  search results", runs the skipped query on reconnect, and keeps loaded
  results with Ask AI disabled and explained; a socket reconnect
  revalidates every loaded page and the AI summary in the background and
  swaps them together, keeping the current response meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ages

- Cache (R40): entries belong to the access scope object instead of a cache
  instance recreated on every open, so a reopened picker within an unchanged
  permission/session scope shows its suggestions at once and revalidates
  them. A permission or session change (also while closed) or a remount
  still starts empty, and a retired scope frees its slots in the shared map.
  A transient background-refresh failure keeps the cached list; RetryLater
  and other failures still drop it.
- Row-only RetryLater (R41): people and page results from the parallel
  request passed their own authority check and stay visible; only the rows
  wait for the server's cooldown (partial rows read before the update are
  dropped) with a "Refreshing suggestions..." status instead of a bare
  skeleton, then the full search runs again.
- Create page (R36): the user's own new page is linked even when a
  permission hint lands during creation, as long as the same picker opening
  is active and the editor is editable; otherwise a toast offers to open it.
  A closed editor or another session is never touched. Repeated clicks no
  longer create duplicates, and failures (denied vs. other) are reported.
- Routine events (R37): a reconnect revalidates in the background without
  clearing, and the highlighted option follows its suggestion by key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page-creation policy (sidebar_edit_policy) was a full-width native
select under a bold question and a three-sentence explanation, styled
unlike every other control in the General tab, with capitalisation that
contradicted the rows above it and a defensive sentence about spaces and
workspaces.

It is now a row in its own card, below the access cards, built like the
other access rows: icon, "Who can add pages to this space", a one-line
description that wraps, and a ghost dropdown with "Only space owners" /
"Anyone who can edit" using the shared Web/Desktop vocabulary. The row
is shared by Create and Manage Space and only reports an actual change,
so opening the menu or picking the current value never sends a request.
A policy from a newer server is shown neutrally ("Other setting") and
kept as received in unrelated saves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per-person access in a Custom space was a native select stacked under
the Role dropdown with two grey lines ("Space default: ...",
"Assignment access: ...") inside a column headed Role. The select
snapped back to the old value while three requests ran, a failure
reloaded the whole dialog, and success gave no feedback.

- The member table is now Name | Access | Role. Access is a compact
  menu like the General tab's: "Space default (Can edit)", "Can view",
  "Can comment", "Can edit". The trigger shows what the person or group
  gets now, e.g. "Can edit · default". One explanation lives in an info
  tooltip on the Access header.
- Owners show "Full access" as plain text. Rows that cannot be changed
  say why in a tooltip: people who only have shared pages, access that
  comes from the space settings, and rows whose account details did not
  load (previously a silently disabled control).
- A choice is shown immediately with a spinner until the server
  answers, then confirmed with an "Access updated" toast. A failure
  restores the last confirmed value, shows the error, and reconciles
  only the list in the background; the dialog is not reloaded. The
  result survives the server's permission notification arriving first.
- Copy uses the shared vocabulary without internal terms (override,
  inherited membership, assignment, roster). Servers without per-person
  access keep the Name | Role table and no longer show a message about
  what the server lacks. People who only have shared pages are told
  where that access is managed instead of being pointed to General.
- The connected Playwright checks read the menu's data-value instead of
  the removed select.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Manage Space switches to UUID routes when the server supports
per-person access, but the suite only asserted those mocks were not
called. With member_access_overrides_supported, the tests now prove
that adding a person goes through addSpaceMemberByUuid with only the
role (so they keep the space default and no level is frozen), that a
candidate without a UUID is refused instead of falling back to the
legacy route, that role changes and removal use the UUID routes
without an access level, that a missing member UUID is reported
without calling the server, and that group add/promote payloads carry
no access level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The component tests stub t(), so nothing checked the real strings behind
the Access menu and the page-creation row. This test loads en.json and
fr-FR.json through i18next and asserts the shared Web/Desktop
vocabulary, the composed "Space default (Can edit)" / "Can edit ·
default" labels, a French translation with matching placeholders for
every string these controls can show, and that none of them contains
internal permission terms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Current servers report "Owners and selected members" as owners_only in the
two-mode space_creation_policy field, so clients pinned to two modes keep
reading workspace settings, and carry the exact policy in
exact_space_creation_policy. Send the exact field (plus the two-mode field
older servers understand) and confirm the change from the exact field,
falling back to the two-mode field on servers that predate it. Otherwise
choosing selected creators failed as unconfirmed, and Owners only could be
confirmed while selected creators were still active.

Validation and confirmation errors now use short translated messages, with
English and French copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…oads moving

- Objects whose last full admission (fresh edit access, no quarantine) still
  holds send edits to sibling tabs and the server immediately again; other
  edits persist and are checked first. The permission gate exposes a
  synchronous peek with a background refresh before expiry.
- A transient access-check failure retries with backoff and wakes the socket
  owner instead of leaving the row idle.
- Focus, visibility, online and socket-open only re-check access; a real
  permission change aborts only uploads whose fresh check is denied, and a
  check superseded by a permission change is rescheduled.
- An explicit retry reports released/denied/unknown objects and wakes the
  socket owner, which may be another tab; a tab now drains edits released by a
  retry elsewhere instead of keeping a stale in-memory block.
- Rows written before outbox entries recorded their server are adopted by the
  matching account and workspace and sent only after a fresh check (a denial
  quarantines them); rows of another server are never replayed.
- Full syncs no longer issue one permission probe per item (which could drop
  items silently); quarantined objects are excluded and logged.
- Add discardPermissionBlockedEdits for the paused-edits Discard action.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eeting usable

When the member has no private space, "Start transcribing" silently created a Private
space with no check of space creation (B). A denied member got a server error and the
already reserved Meet window was closed, so they could not even join the meeting.

The launcher now reads the space-creation decision first. If it is denied, the meeting
opens without a notes page and a message explains why; a server without the capability
route, or a failed check, lets the server decide. Any notes-page failure after the
meeting is known now opens the meeting in the reserved window instead of closing it,
and keeps the reminder so transcription can be retried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Record that a server without the space-creation capability route keeps Create space,
Duplicate Space and calendar meeting spaces available and decides itself, how New Page,
Duplicate and Move check the destination, and how definitive versus ambiguous creation
outcomes are recovered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p dialog

Space creation:
- Policy is chosen with option cards (no native radios) that save immediately,
  show the pending choice, confirm with a toast that offers Undo, and keep the
  confirmed policy on failure. The third card explains when an older server
  can't choose members.
- Selected members and groups appear only while "Owners and selected members"
  is active; otherwise one muted line counts the saved selections.
- Adding uses an inline picker modelled on the People search: avatar, name
  and email, groups with member counts, one-click Add. Guests, Owners,
  pending invitations and accounts without a stable ID are never offered.
- Rows show avatar + name over email, a "Not eligible" badge with a reason,
  and an icon Remove with an accessible name; removal toasts offer Undo when
  a fresh grant would be accepted. Removed entries are hidden and raw IDs are
  never shown. Every keyset page is read so hidden entries can't cut the list.
- Rows and the directory revalidate in the background: permission changes no
  longer wipe the roster, and failures get their own Retry while mutation
  errors use toasts.
- Guests and Members never request the group directory, so a 403 can't
  surface as a connection problem; real errors say what failed.

Access to all spaces:
- Configured per group in People > Groups > group > General (also for
  directory-managed groups, whose identity stays read-only) with an
  Off / Can view / Can edit menu that saves immediately with a toast and asks
  for confirmation when granting or raising access.
- Permissions shows a read-only summary of covered groups instead of a second
  searchable, paginated group list with per-row Save buttons.

Copy follows the shared vocabulary in plain words, with French for every new
key and for the shared Retry, Can view, Can edit and Loading strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Manage groups" in the Permissions summary now switches Settings to People on
the Groups tab, where access to all spaces is configured; choosing a menu item
manually still opens People on Members. The menu entry reads "Permissions"
(one line in the 228px menu) and uses the app's lock icon instead of a lucide
glyph with a different stroke.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
appflowy and others added 30 commits October 15, 2026 02:03
- Three branch tests mocked runtime-config with a closure over a variable
  declared later in the file. Main's server restore boundary reads the base
  URL while modules load, so the mock now falls back to the default until
  the variable exists.
- Main's atomic-creation test mocked the sync outbox without the branch's
  sign-out hooks: invalidToken now stops uploads (setCurrentSession,
  clearDrainConfig) instead of purging unsent edits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ShareViewItem (main): the shared Add menu now renders the branch's
  permission hook and duplicate-destination check, so the suite mocks the
  app event emitter and keeps the duplicate check (a More-menu action that
  is mocked out) out of the permission calls it inspects.
- usePageOperations.publish (main): an authored initial page still uses the
  composed refresh path, but Create space now always passes the branch's
  workspace check as the activation. A new test stops an authored space
  once its workspace is left while the request is pending.
- usePageOperations.addPageRefusal (branch): members creating under Shared
  with me are no longer refused locally (main #629, the server decides);
  guests still get the coded denial without a toast, also there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the merge, a missing default first page (a document without authored
content) is added through main's atomic default-page creation under the
requested ID, while authored first pages keep the page-view request and its
returned-ID check. The existing recovery cases now carry authored content so
they keep testing the page-view path, and a new case checks that a default
first page is created atomically with the saved ID.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's getErrorMessage (#618) never shows an Error's message unless the error
carries a public user_error envelope, so every plain sentence the permission
flows throw ("Your administrator hasn't allowed you to create workspaces.",
"We couldn't confirm that the workspace was created.", context changes,
first-page problems) turned into generic guidance.

PlainError marks translated client copy as public the same way main's
ClientValidationError does, and the branch's translated throws (and its
SpaceCreationFirstPageError and InvalidSpaceCreationIntentError) use it.
Server, library and diagnostic text still never reaches the interface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
These branch tests asserted the raw message of a mocked failure, which base's
getErrorMessage used to show. After the merge the shared renderer shows the
catalog's words for known codes (1008, 1012, 1068, HTTP 403/429), generic
guidance for unknown client errors, and "Error <code>: ..." for unmapped
codes; the tests now expect exactly that. Two Check-again toasts wait for the
second call, since the first attempt already showed the same generic text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both sides decide failure messages: the branch with plain copy that hides
server text (changeFailureReason, ManageSpace's read toasts, New page), main
with public server or catalog messages (#618). The merged order is: billing
remedies; the branch's words for lost rights and things that changed
elsewhere (and, in New page, a refused destination); then a public message
from the server or the shared catalog; then the copy an error raised by this
client carries; then the screen's own fallback. Server text is never shown.

New page also reads a refusal through main's atomic creation, which keeps the
server's answer as the cause of its pending command, and shows the reviewed
pending guidance when a creation could not be confirmed.

Tests: change-failure precedence cases; two branch tests now expect the
catalog's words for an HTTP 500 ("AppFlowy isn't available right now. Try
again later.") instead of the generic reason; New page covers an atomic
refusal and an unconfirmed atomic creation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's journal kept every failed page creation pending, so a page the server
refused for lack of permission stayed in the pending list with only a retry
that could never succeed, next to the branch's plain refusal. A definitive
refusal of a never-committed default page (first try or explicit retry) now
releases the saved command and answers with the refusal itself, matching the
branch's rule that definitive failures release drafts. Ambiguous outcomes
and committed receipts stay pending for an explicit retry, and the pending
list reports a released refusal instead of calling it pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved Search to the shared getErrorMessage with an import at the top of
the file, while the branch already imported getAPIErrorCode from the same
module, so the merged file failed import/no-duplicates. Use one import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builds of the permissions PR (#621, 3054415) created Dexie version 13 with
sync_permission_blocks, while main released its own version 13 with
atomic_creation. The merge kept main's version 13 and declared the denial
table from version 14 on. Dexie runs every version from the stored one up and
deletes the tables a step doesn't declare, so a PR-build database lost every
durable denial at the version 13 step and got an empty table back at 14.

Version 13 now declares both tables. Main's version 13 databases gain the
denial table at that step and PR-build databases gain atomic_creation; both
keep their rows. The upgrade test covers the PR-build path, including the
version 15 cache cleanup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reens

After the merge, changeFailureReason and Manage Space's read toasts still
returned Error.message for any client error without an API code. Since
PlainError and ClientValidationError already reach the interface through the
shared renderer (publicErrorMessage), that step only showed text nobody
reviewed: a guard's "A valid member UUID is required" or a TypeError's
"Cannot read properties of undefined". Main's #618 rejects exactly that.

Both helpers now end with the public message or their own fallback. Tests
use PlainError where the real services throw it (stale dialogs and stale
capabilities) and expect generic copy for raw errors, as main's Manage Space
test did for a failed type change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merged permission screens prefer the shared error catalog's words over
their own translated fallbacks, but main's userError, userErrorDetail and
atomicCreation keys had no French. Without a French server catalog, a French
reader saw "AppFlowy isn't available right now. Try again later." where the
branch used to say "Une erreur s'est produite. Réessayez."

userError now carries the Cloud catalog's reviewed French
(libs/app-error/resources/locales/fr.json, #1369), whose English matches the
Web catalog exactly, so Web and server say the same thing. The two detail
strings and the atomic creation copy get natural French. A test checks that a
French reader gets French for a server failure on a permission screen, and
that every key in these namespaces has French with the same placeholders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main's #630 scopes every IndexedDB name, lock, channel and localStorage key
to the server restore generation, and #626 keeps atomic page commands in the
generation-scoped database. The branch's space and workspace creation drafts
live in sessionStorage, which survives the reload a restore triggers, and
their keys carried no generation. After a restore the previous generation's
draft was offered for Resume against the restored server, where its first
page found no saved atomic command and would get a new request ID.

Both draft keys now go through serverRestore.cacheKey, so the initial
generation keeps its existing keys and a restored tab starts clean. Tests
cover a draft saved before a restore: it is not offered, a new request is
allowed, and the earlier draft is left untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…moval

"Also remove page copies from this browser" is meant for shared computers,
but after a server restore the merged cleanup only touched the current
restore generation. The account's copies from before the restore (per-object
databases, rows in the old shared cache, checkpoint keys) stayed on the
device, although nothing ever reads or uploads them again.

Sign-out cleanup now visits every restore generation found by listing
IndexedDB and localStorage, with the same rules in each: the account's copies
and pre-scope copies go, objects with unsent edits in that generation's own
outbox stay, and nothing is removed from a generation whose outbox cannot be
read. An earlier generation's shared cache is opened as stored, never
upgraded. The outbox lookup bounds name maxKey once per key, which
spec-conforming IndexedDB requires (the old bounds failed under
fake-indexeddb).

Tests cover cleanup under a non-initial generation with real IndexedDB, an
unreadable earlier outbox, a sign-in during cleanup, the generation key
helpers, and the broadcast channel and leader lock names of a restored tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The merge relaxed main's per-login check to a same-account check for
ordinary requests (682be4a) and moved the storage-event generation bump into
token-storage.ts, but the guards themselves had no test: removing the request's
account check, the stale refresh-token pre-check or the storage listener left
every suite green.

New cases: a request is refused (and nobody is signed out) when another
account signs in right after this tab stored its refreshed token; refreshing a
token that was already replaced fails as a changed session without asking
GoTrue, so no caller ends the current login; and a storage event invalidates
captured work even when another tab switches away and back to the identical
token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0dd193e guards the missing selected workspace in Shared with me, but every
test provided one, so throwing on it left the suite green. A shared row of an
account that has no selected workspace now renders and offers neither Add nor
Move to trash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opying

Sign-out cleanup listed every IndexedDB name and rebuilt its generation's
array for each one, which is quadratic on devices with one database per
document. Names are now appended in place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Still images cannot show a flow: a save confirmed by the server, a refusal
after a race, an edit that survives a reload. permission-videos.capture.spec.ts
records five flows as real Playwright tests on the gallery's synthetic Demo
workspace (same seed, stack and network guard), each with its assertions, and
is opt-in behind APPFLOWY_PERMISSION_VIDEOS=1 so ordinary runs skip it:

- groups-two-levels: the HR group can edit HR and the Users group can view
  it; the owner switches Users to Can comment and back from the UI
- view-group-reads-only and edit-group-edits: through those groups a member
  can only read, or edits and the server keeps the edit
- workspace-creation-control: who may create workspaces
- space-creation-control: the owner narrows who can create spaces

Each test records one window per person and writes a timeline of its
captions and passed checks. The caption banner and the pointer dot are drawn
by the harness (headless video has no cursor) and documented as such.
permission-videos-encode.py joins a test's windows in the order they were on
screen and encodes the MP4 and GIF stored in the evidence folder's videos/.

The gallery spec and the videos now share one browser-session helper, so
their isolation is the same, and the gallery gains a capture of HR's two
group levels with nothing open. The seed's removal of a person's own space
membership never matched on this server: it looked for the source "direct",
which the server does not send. It now uses the sources the Web client
treats as a person's own row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The integration branch merged main, so the screenshot gallery ran again on
b59969d against server 805c9bab0, twice, each time on a freshly seeded
stack, to show what the merge changed on these screens.

Seven of the eight capture tests passed and their screens did not change:
14 images came out byte for byte the same, sign-out-remove-data.png differs
only in the date it shows and was retaken, and permissions-member-read-only.png
(2 pixels off by 1 of 255) was kept. A capture of HR's two group levels with
nothing open was added: the HR group has Can edit, the Users group Can view.

The owner's Permissions test failed both times. The merged client adds
server_restore_id to every Cloud request and the server's space creators
list rejects unknown query fields with 400, so the selected members and
groups never load. Its six images were not retaken and are marked as showing
the panel before the merge. The README also describes a second defect seen
in the videos (a reader is told about waiting changes he never made), adds a
Videos section for the five recordings, and the manifest records the runs,
hashes, sizes, durations and the server state read back afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…traffic

edit-group-edits had no finally, so a failure after Sam typed left his
sentence in Onboarding checklist: the next run failed its first check and
gallery captures of that page showed the stray line until the stack was
purged. The test now runs in try/finally. After a failure it closes its
windows and the owner puts the seeded line back through Cloud's web-update
API (restorePageText deletes the extra blocks the way the app does), then
reads the page back. Its server checks compare the page's lines, so a half
typed or empty leftover line is caught as well as the whole sentence.

view-group-reads-only proves read-only access partly by absence (no title
input, no Add icon or cover buttons, no block handles). edit-group-edits now
finds those controls with the same locators and hovers, as their positive
control. view-group-reads-only also moves the pointer dot to the margin
after the click, so the line stays readable while Alex types.

The network guard only covered the app's HTTP requests: Chromium's own
background traffic reached Google, and WebSockets were not filtered (the
realtime relay connected any URL). Chromium now starts with its proxy at
the closed port 127.0.0.1:9 (it never proxies loopback, so the stack is
reached directly), WebSockets outside the stack are closed, and the relay
only takes the Cloud socket. Both WebSocket rules are URL patterns rather
than functions, so the other sockets keep connecting directly instead of
being relayed through the test process. The seed also refuses ports 4001
and 4002.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An independent review of the videos found that the editing video left
Sam's sentence behind when it failed, that the README overstated the
network guard and claimed every change was undone (a page's edit history
is not), that the reader video's absence checks had no positive control,
and that its pointer dot covered the line while Alex typed. The harness
commit before this one fixes those; this one records the result.

The reader and editor videos were recorded again with that harness, in
two consecutive runs of all five tests on a freshly reset stack (5 of 5
each); the files are from the second run, so the reader still shows the
waiting-changes notice that follows an editing run. The other three
videos show nothing the fixes change and are still the files of run D.

The screenshot spec ran once more as a validation, with its images kept
out of the worktree: 7 of 8 passed as in the refresh, and 16 of its 17
images came out byte for byte the same; the other differs in 2 pixels by
1 of 255, so no image was replaced. Socket sampling during these runs saw
only loopback peers.

The README and the manifest now say which guard each run had, add a
Review fixes section, and record the new runs, hashes, durations and the
server state read back afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A view-only reader who opened a page with deletions in its history saw
"Changes to 1 page are waiting for edit access" without editing anything.
The reply to the server's state vector is built with
Y.encodeStateAsUpdate, which, like yrs encode_diff, always writes the
complete delete set. For any page with a deletion the reply was never
the empty update the protocol already skips. With permission checks
every reply goes through the outbox, so a reader got a quarantined
'manifest' row and the notice, and an editor re-sent the whole delete
set on every open, reconnect and sibling-tab sync request.

A state vector cannot show which deletions the server holds, but the
server's own bytes can: it answers a state-vector request with an Update
(its diff, written with its complete delete set) before sending its own
state vector, and only server Updates carry a message id. Record the
deletions of those Updates per Y.Doc, dropped when the collab version or
restore generation changes, and treat a reply as a no-op only when it
holds no struct and every deletion in it came from the server. A local
deletion or insertion is never covered, so it is still kept: blocked for
a reader, sent for an editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the reader-notice fix found three ways a view-only reader can
still see "waiting for edit access" without editing:
- A reply to a request that is behind the server repeats structs the
  server holds: a sibling tab that missed a relayed update, or a re-sync
  whose Manifest a live update overtook. Only structless replies were
  checked.
- A sibling tab's request that arrives before this tab's first server
  answer was judged against an empty record.
- A false row already queued, also by the earlier build, stayed until
  Discard changes, because waiting rows are never replaced.
Recording was also costly on heavily edited pages: every live deletion
re-sorted and copied the client's whole range list.

The record now keeps the struct ranges as well as the deletions of every
server Update (one with a stream message id). It reads them in a single
pass over the lib0 v1 bytes, leaves out the gaps that Skip structs mark,
and merges them in place. A reply is a no-op only when the server has
proven every struct and every deletion in it.

The record restarts at each state-vector request this context sends, so
a server that lost data since an earlier exchange gets it again in the
next reply. While that request is unanswered, a request that lists
clients cannot be the server's answer, which starts with an Update. Its
reply would reach only the server, and the reply to the Manifest that
follows covers it, so it is not queued. When the record grows, queued
manifest replies of the same collab generation that it shows to be
redundant are deleted, waiting ones included. They hold nothing the
server lacks, so no edit is lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Opening a database view that still has a legacy view-keyed cache queued
Y.encodeStateAsUpdate(legacy, stateVector(canonical)) whenever it was
longer than two bytes. That diff always carries the legacy copy's
complete delete set, so a cache with any deletion queued a no-op on
every open. A reader got a waiting row and the "waiting for edit
access" notice, and an editor uploaded the same deletions again.

Compare the canonical document's snapshot and pending state before and
after the merge, and queue the bytes only when something changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The last recording captioned two defects instead of failing: the owner's
Permissions panel could not list the selected members and groups (the server
refused the server_restore_id query parameter, fixed in server 65bc42360),
and a reader was told that changes to a page were waiting for edit access
when he had made none (fixed by the three cherry-picked Web commits). Both
are fixed, so a caption would now only hide a regression.

space-creation-control now asserts that Olivia's panel lists Alex and the
Super_Users group and shows no load error. view-group-reads-only watches
the reader's window from the moment it opens: a MutationObserver records
any appearance of the pending-edits notice (it is in the DOM only while
shown), so a notice that comes and goes between two checks still fails the
test. It is checked after the read-only checks and again at the end, after
the last hold, so the whole time on screen is covered.

The gallery spec needs no change: its owner test already asserted the
selected rows, which is why it failed against the old server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The last recording showed two defects of the merged code, captioned on
screen: the owner's Permissions panel could not list the selected members
and groups, and a reader was told that changes were waiting for edit access
when he had made none. Both are fixed (server 65bc42360; Web 541acb82,
dbd74da9 and d13d06e0, cherry-picked here), so the media should show the
fixed behaviour and no defect captions.

All five videos were recorded again with the spec that now asserts both
fixes: runs H and I passed 5 of 5 each, back to back on the same database
after a fresh seed, so in run I the reader opened a page whose history has
the editing test's deletions and saw no notice. The files are run I's,
encoded with the committed encoder; nothing was cut beyond the documented
window joins, sped up or retouched, and the captions remain harness
overlays. Only loopback peers were seen in any run.

The screenshot spec passed 8 of 8 on a freshly seeded stack (run G3),
including the owner's Permissions test that failed before. Of the 23 current
images, 19 came out byte for byte the same and 4 differ only by
anti-aliasing noise (at most 90 pixels, by at most 8 of 255), so no image
was replaced; the six images of the owner's test are now verified on this
code. The README and manifest replace the defect sections and the "not
retaken" notes with one "Found while recording, fixed" note, and record the
new server binary (65bc42360, sha256 a491ff45...cac7), the Web commit the
media were recorded at, and each video's duration, size and hashes, all
written by a script from the run outputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since dbd74da9, a context that had sent its state-vector request dropped
every request that listed clients until a server Update arrived, on the
grounds that it came from a sibling tab and the reply to the server's
answer would cover it. That answer does not always come. The server
answers for a collab it does not hold with a bare request and no Update,
and an answer can be lost: the tab that owns the socket closes before
relaying it, or the server's read fails and it sends nothing. The
server's repair request for a refused edit, also a bare request that
lists clients, was then dropped for the rest of the exchange. The drain
deletes an edit's row once it is sent, so the edit reached the server
only at the next re-sync, and the editor was not told.

A sibling tab's request cannot be told apart from a repair request, so
both are answered again. A reply queued for a sibling before the server
answers is deleted by the existing cleanup once server Updates prove
its content; a reader can see the notice until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d13d06e0 queues a legacy view-cache merge only when it changed the
canonical copy, comparing its snapshot and pending state before and
after. Yjs keeps the store's pendingStructs object when more structs
have to wait and replaces only its update bytes, so a legacy cache that
added structs the canonical copy cannot integrate yet was judged
unchanged and its bytes were not queued.

Compare the pending update bytes instead. A local legacy edit always
integrates and changes the snapshot, so no edit was skipped before; the
merge now queues whenever it changed anything, as intended.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The record that lets a reader skip no-op manifest replies, and lets the
cleanup delete queued ones, takes any Update with a message id as the
server's own. Nothing but server bytes carries one today: local fan-out
and sends are built by buildUpdateMessage, and an HTTP sync result
carries the server's message id on the server's diff. A later change
that put a message id on local bytes would make a tab skip or delete a
manifest reply that holds them. Say so at both places, and test that
fan-out, sends and manifest replies never carry one.

Also record an accepted risk: a server Update sent before the latest
state-vector request can be applied after it (a Database message
waiting in the per-object queue, a replay after a reset, a late HTTP
result or relay) and is recorded like any other. Only if the server
lost that data in between does the exchange leave it out, and the next
exchange sends it again; the local document keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The walker's struct lengths for strings are the decoded string lengths,
as in Yjs. Yjs writes a string that starts inside a surrogate pair
whenever a diff starts inside a string item, but the walker suites run
under jsdom, where lib0 falls back to a polyfill that throws on a lone
surrogate, so that case was never exercised. A browser has native text
codecs and never throws.

Run a small suite in the Node environment: every diff of a page with
emoji, a ZWJ sequence, a byte order mark, lone surrogates and items
split inside a pair, from every clock, reads as the clock ranges and
delete set Yjs holds, and a server diff that starts inside a pair
proves its own clocks and no earlier one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant