Libra CodeHub

CedarCopilot/cedar-mail

Branch: staging

fix(documents): make the editor honour a refused save

merged#2690CedarCopilot

CedarCopilot wants to merge 2 commits into staging from worktree-agent-a967add8afe5341bc

Live on prod, no production signal yetTimeline and evidence
  1. Opened
    Sep 6, 2026, 9:02 AM
  2. Sep 6, 2026, 9:30 AM
  3. Merged
    Sep 6, 2026, 10:28 AM
  4. Live on prod
    Sep 6, 2026, 10:28 AM
  5. Observed 46 hours
    Sep 6, 2026, 10:28 AM
  6. Pipelines steady after this deploy
    Sep 6, 2026, 10:28 AM
  7. Unobserved

    Live on prod, no production signal yet

    Sep 8, 2026, 8:50 AM

Behaviors Libra is checking

SaveStatusBadge displays a refused save as a red Not saved state with the server rejection reason in its tooltip instead of implying that the save is retrying.Inconclusivelow confidence

Verification reached its budget before a verdict.

prod, checked Sep 8, 2026, 2:48 AM
When a save is refused, CedarYjsProvider leaves its last synced state vector unchanged and retains the edits as dirty so the next user edit resends the refused bytes.Inconclusivelow confidence

Verification reached its 130s outer budget before the verifier returned. Libra is keeping this intent verifying until the next check.

prod, checked Sep 8, 2026, 3:50 AM
A deterministic refused save emits a distinct refused flush error with hook reasons and does not automatically re-arm the debounce retry until the user edits the document again.Inconclusivehigh confidence

No production telemetry tied to the intended CedarYjsProvider.flush behavior was found since 2026-09-06T17:28:27Z. Exact files.applyUpdate/flush queries returned 0 OTEL spans and CloudWatch searches returned 0 matching logs, so success/error volume, latency, refused=true.

prod, checked Sep 8, 2026, 4:49 AM
During deploy skew, a client treats an applyUpdate response that omits persisted as a legacy successful response and only treats an explicit persisted=false as a refusal.Inconclusivelow confidence

Verification reached its 130s outer budget before the verifier returned. Libra is keeping this intent verifying until the next check.

prod, checked Sep 8, 2026, 5:50 AM
The files.applyUpdate operation returns persisted=false and ordered hook rejections when a save hook refuses the write, while successful saves return persisted=true.Inconclusivelow confidence

Verification reached its 130s outer budget before the verifier returned. Libra is keeping this intent verifying until the next check.

prod, checked Sep 8, 2026, 6:50 AM
PlaybookDocument processes playbook.compile hook payloads on failed or refused flushes so compile errors and warnings remain available in the editor instead of being cleared.Inconclusivelow confidence

Verification reached its 130s outer budget before the verifier returned. Libra is keeping this intent verifying until the next check.

prod, checked Sep 8, 2026, 7:50 AM

Libra has verdicts on 0 of 7 tracked behaviors on prod; 7 are still being checked. Libra checks hourly for 3 days after each deploy.

The bug

PR #2686 taught applyUpdate to report persisted (and rejections) so callers could tell a real save from one a hook refused. writeDocumentTool was updated. The tRPC route the browser calls was not.

files.applyUpdate dropped both fields. A refused save therefore reaches the browser looking exactly like a clean one , a 200 carrying the row's unchanged version/yjsRevision and three empty byte buffers. There is nothing else on the wire that separates the two.

So CedarYjsProvider.flush() took the success path and advanced lastSyncedStateVector to targetSV, cleared dirty, and wrote the new state vector to IndexedDB.

The consequence is worse than a lost save. Because the next update is encoded as Y.encodeStateAsUpdate(ydoc, lastSyncedStateVector), every later delta is computed against a state the server never reached. The refused bytes are not merely dropped , they become unre-sendable: Yjs holds the arriving items as pending structs it cannot integrate, because the items they depend on were never accepted. The tab believes it is in sync, and goes on rendering a document the server does not have and never will.

This also makes a comment in document-saving/types.ts false. It says the client "will re-send the delta on the next forceFlush after the user fixes the errors". It would not have.

Evidence

Live. Xander's agenda document 134bad3c-f206-41d1-9c62-4f43a6c6763d: the server blob holds 6 rows under "You promised", his browser shows 8 , one of them (2118f67f, "Resend Sep 3 invite and lock in Garcia and Dart") a task whose status is done. The reconciler removes done tasks from the agenda, so its presence in the tab is state the server does not have.

Reachable today, in the playbook editor. playbook.compile is the one hook that returns abortPersist on the human save path (agenda.integrity stopped doing so in #2686 and only ever fired on agent origin). A playbook that fails to compile is refused , and before this PR that refusal was reported as a success. PlaybookDocument.handleFlushEnd set saveState = 'success', and the provider advanced past the refused bytes. Fixing the errors afterwards then saved an incomplete document: everything typed in the refused batch was already stranded.

In a test. Both new provider tests fail on staging and pass here. The first is the one that matters , it replays the second flush's payload onto a fresh Y.Doc and asserts it still carries the refused edit:

ā— re-sends refused bytes on the next flush when the server did not persist
    expect(serverView.getMap('state').get('refused')).toBe('1')
    Expected: "1"
    Received: undefined

The fix

Server , files.applyUpdate returns persisted and rejections. The mutation's return type is now written out as an exported ApplyUpdateResponse rather than inferred, so dropping either field again is a compile error in files.ts instead of a silent one in the browser. Verified by deleting the line and watching tsc fail with TS2741: Property 'persisted' is missing. This replaces an earlier source-text guard test, which the compiler does strictly better.

Client , on result.persisted === false, CedarYjsProvider:

  • leaves lastSyncedStateVector where it is, so the refused bytes ride every future delta;
  • keeps dirty true (so hasPendingLocalChanges() and the editor's isDirty stay honest);
  • does not persist the state vector to IndexedDB;
  • reports a FlushError carrying error (the rejection reasons), plus hooks and rejections.

persisted is optional in the client's wire type on purpose: a tab holding cached JS can outlive the server deploy that added the field, and reading its absence as a refusal would fail every save. Only an explicit false is a refusal.

Why report, and not retry

A refusal is deterministic , re-POSTing identical bytes gets refused identically. So the refusal path deliberately

Show production surfaces and changed-file mapping

Production surfaces

Libra has not measured any production surfaces for this change yet.

Changed files → surfaces

  • apps/mail/modules/agentCanvas/components/SaveStatusBadge.tsxno production surface mapped
  • apps/mail/modules/documents/playbook/PlaybookDocument.tsxno production surface mapped
  • apps/mail/modules/documents/yjs/CedarYjsProvider.tsno production surface mapped
  • apps/mail/modules/documents/yjs/index.tsno production surface mapped
  • apps/mail/modules/documents/yjs/providerRegistry.tsno production surface mapped
  • apps/mail/modules/files/store/documentSaveLogSlice.tsno production surface mapped
  • apps/mail/tests/modules/files/yjs/CedarYjsProvider.test.tsno production surface mapped
  • apps/server/src/services/document-saving/index.tsno production surface mapped
  • apps/server/src/trpc/routes/files.tsno production surface mapped