feat(canvas): add version history with atomic restore - #6780
wpfleger96 wants to merge 48 commits into
Conversation
b939919 to
d508818
Compare
Base upstream/main@583af0229; 11 commits of PR block#6780 cherry-picked -x, zero conflicts. Canvas surface byte-identical to PR head; gates green (fmt, buzz-cli 371, buzz-sdk 266, buzz-db 111, clippy clean, desktop 5563). buzz-relay mesh_demo failure proven inherited at pristine upstream tip (504!=200 timing race), recorded not owned.
Base upstream/main@583af0229; 11 commits of PR block#6780 cherry-picked -x, zero conflicts. Canvas surface byte-identical to PR head; gates green (fmt, buzz-cli 371, buzz-sdk 266, buzz-db 111, clippy clean, desktop 5563). buzz-relay mesh_demo failure proven inherited at pristine upstream tip (504!=200 timing race), recorded not owned.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Two blockers at bc07f7c10ef84b2798b28702399e7cf553aace98:
-
Reset canvas UI state when
channelIdchanges.ChannelCanvaskeepsisEditing,draft,editBaseRevision, andshowHistoryin component-local state, but the same unkeyed component instance receives a newchannelIdwhen the still-mounted management sheet switches channels. The query and mutation correctly retarget to the new channel, while the editor state does not. A concrete disclosure path is: start creating canvas A, type A-specific text, switch to canvas-less channel B, then save. The retaineddraftis submitted through B's mutation with the retained"none"precondition, so A's text becomes B's canvas. Key the subtree by channel or synchronously reset/close edit, draft, base revision, history selection, and mutation state on channel changes; add a switch-while-creating regression. -
Do not collapse an accepted publish and a failed verification read into one generic failure. Desktop submits successfully and obtains
result.event_id, then propagates any error fromcurrent_canvas_head_ancestry; CLI restore similarly hasour_idafter accepted submit, then can fail its head re-read before printing it. Desktop invalidates canvas/history only on mutation success and leaves the editor open on error, so retrying after reconnect encounters a stale-precondition conflict against the already-accepted write with no surfaced recovery ID. Preserve the durable accepted state: return/print the known event ID, invalidate/refetch, and expose an explicit “published, verification unavailable” outcome. Cover a successful submit followed by a failed verification query.
Evidence: ChannelCanvas.tsx, ChannelManagementSheet.tsx, canvasHooks.ts, canvas.rs, and channels.rs.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
One blocker at 37601b9b5e624c75918e9fab69b6674825a6f506:
Invalidate canvas caches when an accepted write is reported as superseded. set_canvas publishes the event first, then returns CANVAS_SUPERSEDED if its verification read sees an unrelated head. That revision is durable and recoverable from history, but useSetCanvasMutation invalidates channel-canvas and channel-canvas-history only in onSuccess. The supersession marker rejects the shared save/restore mutation, so neither cache is invalidated and the UI can keep stale current/history data precisely when it tells the user to reload and restore the retained revision.
Move invalidation to onSettled, or explicitly invalidate this recognized durable-supersession outcome. Add regression coverage where setCanvas rejects with the supersession marker after publication and assert that both query keys invalidate/refetch for the save and restore paths.
The two blockers from the prior head are fixed: channel changes now remount the canvas subtree, and a failed post-write verification read now returns durable success with verified: false. The security/trust pass found no blocker.
Evidence: canvas.rs, canvasHooks.ts.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested at 5db94ff72dc8d6aa3f85714fea379068e46f8e84. The append-only history, keyset pagination, timestamp discipline, edit-start snapshot, create guard, channel-switch remount, and settled-outcome cache invalidation are sound. Three user-facing gaps remain:
-
[Medium] Preserve the accepted-but-unverified outcome on restore. Normal save inspects
SetCanvasResult.verifiedand shows the recovery notice, butCanvasHistoryPanel.handleRestorediscards the same result and collapses the selected row. Reproduction: the relay accepts the restore, then the post-write head query fails. The restore is durable, but the user gets no confirmation or History guidance; if cache refetch also fails, the old canvas can remain visible and invite a retry. Surfaceverified: falsenon-destructively for restore just as save does, and add a causal restore regression mirroring the unverified-save test. -
[Medium] Do not report a transitive descendant as supersession.
canvas_write_survivedrecognizes only our event or a head whose directexpected-revisionis ours; Desktop and CLI each pass only that one parent tag (Desktop, CLI). If accepted writes A (ours), B (expected-revision=A), then C (expected-revision=B) all land before A's verification read, C is a legitimate transitive descendant but A is reported asCANVAS_SUPERSEDED/ CLI exit 5. That tells the user to recover a save which remains in the accepted chain. Traverse ancestry with an explicit bound/cycle guard, or otherwise make classification and user guidance truthful; cover A→B→C at the Desktop and CLI seams. -
[Medium, accessibility] Announce mutation outcomes and restore focus. Loading, error, unverified-save, and restore states are inserted as plain
<p>elements without status/alert semantics (save, restore). Successful save/restore also removes the focused control. A keyboard or screen-reader user can receive neither an announced result nor a useful focus destination. Add appropriate live-region semantics and move focus to the resulting canvas/history notice, with interaction coverage. This is required by the repository's WCAG 2.1 AA product target.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested at 599cac2cf5b6f1ea854dea7e5171f6a7aa0699bb. The earlier restore-verification, transitive-ancestry, cache-invalidation, and accessibility/focus gaps are fixed. One UI recovery defect remains:
- [Medium] Reset a rejected save before opening the next edit session.
handleStartEditingresets the draft/base/notice but notsetCanvasMutation. TanStack Query retains the mutation'serror, and the editor renders that error whenever it opens (lines 172–177). Reproduction: let a save reject (stale/superseded/network), click Cancel, then Edit canvas. The prior alert immediately reappears before the user attempts the new session.CanvasHistoryPanelalready avoids the analogous cross-selection leak withrestoreMutation.reset(). Reset this mutation when starting a new canvas edit session, and cover reject → cancel → re-edit.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested at e18a8c292df72e2b689fc6955b51538d69d410dc. The append-only revision model, deterministic pagination, timestamp discipline, ancestry classification, durable-unverified handling, cache invalidation, and prior recovery fixes are sound. Two blockers remain:
-
[High] Pin conflict and post-write verification reads to the writer. Desktop now reads the head before publishing and reads ancestry immediately afterward through generic
query_relay(canvas.rslines 75–99); CLI restore does the same (channels.rslines 417–466). That endpoint routes catch-all/queryreads throughquery_events_routed(bridge.rslines 1421–1429), which may serve the read replica when bounded-staleness routing is enabled. The DB contract explicitly says reads influencing a write must use the writer (buzz-db/src/lib.rslines 1254–1289). Reproduction: enable replica routing with ordinary replication lag, save or restore a canvas, and let the immediate verification query hit the replica before the accepted event arrives. The successful query returns the old ancestry, soclassify_post_writereportsCANVAS_SUPERSEDEDeven though no competitor exists. A stale pre-write read can likewise validate against the wrong head. Add a writer-pinned/fenced query path for preconditions and read-after-write verification, and cover a lagging-replica sequence. Display-only history can remain routed. -
[Medium] Confirm before restore overwrites the shared head. Expanding an old revision exposes “Restore this revision,” and its first activation immediately calls the shared write mutation (
CanvasHistoryPanel.tsxlines 89–105, lines 188–211). An accidental mouse, touch, or keyboard activation while inspecting the adjacent diff changes the channel-wide canvas for everyone. History makes recovery possible, but only by finding the displaced head and performing another shared write. Require an explicit confirmation that identifies the selected revision and says it will become current, or provide an equivalent reversible undo flow. Update the E2E journey, which currently codifies immediate mutation (channels.spec.tslines 3185–3195).
CI is green for this exact head. Per the automation’s read-only policy, I did not execute PR code locally.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested at exact head a0fb8e8a2df43e7f0080f8f4f362db008a112ffc. The prior writer-pinning, restore-confirmation, channel-switch, cache-invalidation, and accessibility issues are fixed. Two blockers remain:
-
[High] Keep persisted empty canvases reachable for read-only members.
ChannelManagementSheetdefines existence from trimmed content and only renders the Canvas ingress whenhasCanvas || canEditNarrative(ChannelManagementSheet.tsxlines 307–312, lines 819–829). A persisted empty revision still exists and has history, but a member without edit permission loses the only ingress and cannot view that shared state or its history. This occurs after intentionally saving or restoring an empty revision. Determine existence fromeventId !== nullfor ingress while retaining content-based preview behavior, and cover the read-only empty-canvas case. -
[High] Do not advance an accepted boundary timestamp beyond the relay’s ingest ceiling. The SDK accepts a head exactly at
client_now + 900and returnshead + 1(builders.rslines 632–640); its boundary test explicitly expectsnow + 901(lines 3125–3137). The authoritative relay rejects timestamps more than 900 seconds from relay time (ingest.rslines 2224–2230). An authenticated writer can therefore publish an accepted head at the ceiling, after which first-party save/restore immediately constructs a rejected event; refreshing the boundary head can keep writes unavailable, and ordinary client/server skew worsens it. Make the relay authoritative for head advancement, or reserve advancement plus clock/network margin in the client limit, and add an ingest-level boundary regression.
Current exact-head CI is otherwise green. Review used GitHub metadata, diff, and exact-head source only; no PR code was executed.
… failure After a save or restore settles with verified:false, the mutation's onSettled fires invalidation which triggers a background refetch. When that refetch fails the query enters an error+data state: TanStack Query v5 retains the last successful data alongside the new error. The prior unconditional error guards in both components would return the full destructive error branch, replacing the unverified-save/restore notice and unmounting the cached canvas or history panel. Fix: gate the full error return on data === undefined (no cached data, i.e. a genuine first-load failure). When data is defined alongside an error, remain in the normal render path and show a separate non- destructive refresh warning (channel-canvas-refresh-error / channel-canvas-history-refresh-error) which clears when the next refetch succeeds. Add CanvasRefetchErrorRecovery.test.mjs covering: - save verified:false + refetch failure: notice, canvas, and warning all visible; full error state absent - save recovery: warning clears, notice persists after successful refetch - restore verified:false + refetch failure: history panel stays mounted, restore notice and rows visible, both canvas and history warnings shown - initial-error no-data: full error state still fires when no cache exists Reverting either component's data === undefined guard turns the corresponding scenario(s) red. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
getCanvasCallCount and getCanvasHistoryCallCount were scaffolding variables never read by any assertion — remove them. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…factor The origin/main Postgres-test isolation refactor (#6730) extracted the local test DATABASE_URL fallback into crate::test_support::database_url(). The merge conflict resolution in bridge.rs left two raw TEST_DB_URL references behind; replace them with the new helper to restore compilation. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…irm-time
Without this fix the mutation's CAS guard could silently advance past
the head the user saw when they clicked Restore. Sequence:
1. User opens the restore confirmation dialog (head = A).
2. A background refetch succeeds and installs head C.
3. User confirms — handleRestore read currentRevision from the live
render, submitting expectedRevision: C instead of A.
The relay CAS check then allowed a write the user never approved against
the current head. Fix: capture {revision, frozenExpectedRevision} together
at dialog-open (setConfirmRevision) and pass the frozen value through
handleRestore so the mutation always submits the head the user saw.
Adds a mounted regression: open confirm at head A, successful refetch
installs CONCURRENT head C, confirm — assert set_canvas receives
expectedRevision: A. Revert-causality verified: restoring the live
currentRevision read turns exactly this test red (4/5 pass, new test
fails with actual=CONCURRENT expected=HEAD).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Four items from the rebase round: 1. Delete five stale canvas CI steps from _ci-relay.yml whose selectors used the old `::tests::` module path. After #6730 renamed all modules to `postgres_tests`, the first step matched zero tests and exited 4. All five tests already run and pass in the new discoverable PostgreSQL Domain lane (396/396 at this head). Deletion confirmed by check-postgres-test-discovery.py: all five tests remain discoverable. 2. Add `disabled={restoreMutation.isPending}` to CanvasHistoryPanel row buttons. Without this guard, clicking another row during a pending restore calls `restoreMutation.reset()`, unobserving the running mutation so a subsequent rejection never reaches `restoreMutation.error` and isPending clears, permitting a second concurrent restore. Regression test: CanvasRestorePendingGuard.test.mjs (mounted, real QueryClient, deferred IPC) — revert-red confirmed. 3. Reconcile ambiguous CLI restore submits in `cmd_restore_canvas`. If A(expected=H) commits but the response is lost, B(expected=A) commits, and the retry of identical A sees RevisionMismatch. The CLI now catches a conflict-shaped relay error and walks the writer-pinned ancestry: A reachable → Conflict (superseded, preserved); A absent → genuine Relay error; ancestry read fails → Other with explicit unknown-outcome naming our_id. Three new CLI unit tests cover all three cases. 4. Fix JSON-only stdout for the already-current short-circuit. The bare `println!("revision ... is already the current revision")` violated VISION.md:163. Now emits `{event_id, accepted:true, message:"already-current"}` on stdout and moves the human-readable note to stderr. Two unit tests cover the short-circuit exit path. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Rustc rejects doc comments (///) on function parameters. Move the event_reachable description into the function's doc block so the CLI test code compiles. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… tests
- Desktop test: rewrite CanvasRestorePendingGuard.test.mjs to enact the
actual failure mode — reject the deferred IPC while pending, dispatch a
second-row click, assert set_canvas stays at exactly 1 call and the
rejection is visible under the originating row. Revert-red confirmed:
removing disabled={restoreMutation.isPending} from the row button makes
the secondRowIsDisabled assertion fail.
- CLI conflict fixture: rework conflict_relay to model the submit_stored_event
same-byte retry seam — attempt 1 returns 503 (retried by with_retry_body,
A is persisted), attempt 2 returns canonical 409. Reachable case now
returns B(exp=A) as head + A in the stream (not A-as-head), binding the
concurrent-write ancestry shape described by the fixture comment.
- CLI classifier: require status == 409 on the conflict match arm to prevent
any non-409 body lookalike from entering reconciliation. Add
non_409_lookalike_with_conflict_phrase_is_not_reconciled test: relay
captures event ID and returns it as reachable in ancestry walk, so removing
the status guard produces CliError::Conflict instead of Relay{500} —
oracle confirmed. Revert-red confirmed.
- CLI absent case: return original CliError::Relay unchanged (no fabricated
body); test now asserts status == 409 and body contains original phrase.
- CLI unknown outcome: use CliError::DeliveryUnknown (exit 2,
delivery_unknown) instead of CliError::Other (exit 4, error); update
affected test expectation.
- P3 JSON: add out: &mut dyn Write parameter to cmd_restore_canvas (matches
existing warn_sink pattern); already-current short-circuit writes to out;
normal success path also writes to out. Replace two non-asserting tests
with single already_current_restore_emits_json_with_required_fields test
that captures output via Vec<u8>, parses JSON, and asserts event_id,
accepted, and message fields. All callers updated.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
- Route verification-read-fails branch through injected out writer so cmd_restore_canvas captures all stdout; assert event_id/accepted/message in restore_succeeds_when_verification_read_fails. - Scope pending-guard alert query to the originating <li> and assert errorText contains the rejection message; narrow invariant-3 comments to match what the test actually exercises (row expand buttons, not the Restore action button). - Update conflict_relay doc block: attempt 1 returns retryable HTTP 503, not a dropped TCP connection. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…on in 409 reconciliation canvas_write_survived checks whether A is an ancestor of the live head, but it returns false for two distinct cases: (1) A genuinely absent, and (2) A present in the stream but not an ancestor (e.g. an unconditional legacy write B becomes head with no expected-revision link back to A). This caused two wrong outcomes after a lost-response 409: - Legacy write B (no expected-revision) becomes head, A retained in stream: predicate false → original 409 rejection propagated; caller cannot tell A committed. - Tagged write B(expected=A) becomes head: predicate true → CliError::Conflict exit 5 advising re-restore, inconsistent with the accepted-submit path treating the identical descendant chain as success. Fix: establish A's persistence via a separate writer-pinned IDs lookup (fetch_canvas_event_exists), then apply the four-way classification: - survived (ancestor of head): accepted JSON, exit 0 - persisted but head unrelated: supersession naming A - genuinely absent: original 409 relay error - either read fails: DeliveryUnknown naming A Tests: corrected the descendant-ancestor regression (now expects Ok) and added the legacy-supersession regression. Both new/corrected tests confirmed red against the unfixed logic before fixing. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Mechanical conflict resolution: origin/main added react-day-picker (status indicators, #7112) while this branch replaced date-fns with diff (canvas diff view). Merged result keeps both react-day-picker and diff, removes date-fns from the desktop importer. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…event.rs The rebase replay incorrectly placed an inline definition of insert_reaction_event_with_thread_metadata inside the impl Db block (after insert_event's closing brace). This function is defined in crate::reaction and re-exported; it should not exist as a duplicate in event.rs. Applied a clean 3-way merge of the branch against the merge base and origin/main to produce the correct combined state. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2297a39 to
d3be6bd
Compare
…iation The two load-bearing guarantees on fetch_canvas_event_exists were not falsifiably covered: - An existence-query Err(_) must become DeliveryUnknown (not the original 409 relay error silently misclassified as absent). - The IDs filter must carry consistency=strong to prevent replica-lag from hiding a durable event and producing false absence. Added two tests with confirmed revert-reds: - conflict_shaped_submit_with_failed_existence_read_returns_unknown_outcome: ancestry succeeds with A unreachable (ExistenceReadFails scenario), IDs query returns 500 → asserts DeliveryUnknown naming A's ID. Red: changing Err(_) arm to return the original 409 drops to 13/14. - conflict_shaped_submit_existence_read_carries_strong_consistency: captures the post-conflict IDs query body via the extended conflict_relay fixture and asserts ids=[A], kinds=[40100], #h=[channel], limit=1, consistency=strong. Red: removing consistency=strong from fetch_canvas_event_exists drops to 13/14. The existing ConflictScenario::ReadFails scenario only fails the first ancestry query; neither guarantee was reachable through it. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Review clear for the agreed corrective scope. The remaining CLI restore-recovery blocker from review 5097047079 is resolved at a2a138f357b94c374aa1f917216e44c0d6764140 (base 7a9a5233d9d755e715be0c585cf7850e935d28cf). No actionable blockers found in this source-only re-review.
cmd_restore_canvasnow returns accepted JSON when the submitted revision survives in the live head’s ancestry. Otherwise, a separate writer-pinned ID lookup distinguishes retained-but-superseded history from a genuinely rejected write. Failed reconciliation preserves an explicit unknown outcome and the submitted revision ID (crates/buzz-cli/src/commands/channels.rs:480–533, 614–632).- Traced the production transport, strong-read routing, SDK ancestry, and relay/DB CAS integration. The previously reviewed canvas CAS, legacy serialization, desktop mutation handling, and ancestry semantics remain intact across the rebase. The command-level regression fixtures cover descendant success, legacy supersession, absence, both reconciliation-read failures, and the exact strong-consistency ID filter (
channels.rs:4005–4184, 4297–4547). - Validation was immutable source and test-text inspection only, with independent regression and backend integration reviews. No checkout, build, tests, or PR code execution. The descendant-output test checks JSON field presence rather than exact values; production currently emits the correct values. This is not a runtime/CI pass or an approval.
Resolve three conflicts introduced while PR #6780 was parked: - Justfile: preserve branch's buzz-sdk --lib addition alongside main's buzz-acp, buzz-media/buzz-admin, and extended buzz-relay nextest filter additions. - scripts/run-tests.sh: same — buzz-sdk run_test_step alongside main's buzz-acp, buzz-media, buzz-admin, and relay handler module additions. - crates/buzz-relay/src/handlers/ingest.rs: preserve branch's canvas_revision_spec parse and CAS dispatch arm; accept main's workflow_deletion arm (which precedes is_replaceable) and the !workflow_deletion guard on side-effects. Dispatch order: workflow_deletion → is_replaceable → is_parameterized_replaceable → canvas CAS → generic append. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…-history * origin/main: fix(mobile): avoid opening empty threads on message tap (#7756) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Review clear for the merge delta
No actionable introduced/regressed defect found at head 57c4b8b2d09dccb6a1bc92cd7d5d6aa23984d3b1 against base/merge-base a61239f0d8036aff58176f5c0ce7f080c66e21b7.
- Previously cleared behavior is preserved. Since review 5121883622 at
a2a138f357b94c374aa1f917216e44c0d6764140, the branch adds two main merges and no non-merge commits outside main. The CLI restore-reconciliation fix, SDK ancestry/timestamp helpers, native canvas commands, desktop history/confirmation/pending-operation handling, and settled-outcome invalidation are byte-identical to that reviewed head. - The merge seams were reviewed, not merely assumed safe. The production conflict resolution retains canvas CAS and unconditional legacy writes alongside workflow deletion; the new workflow arm requires kind 5 and cannot capture kind 40100. The shared query dispatch still routes first-party canvas write-influencing reads to the writer. Inspected DB integration, command registrations/closed sets, and both test-gate resolutions; the SDK and ACP test steps are both retained. No broader redesign or renewed hardening gate is requested.
- Validation is source-only. Exact-object comparison, conflict-resolution inspection, and test-source review ran on the pinned Blox host, with an independent metadata lane. No checkout, build, test execution, or live workflow was performed. The one-time exact-head CI snapshot recorded 55 successful checks, 27 skipped, and one cancelled Codex Security Review job, with none pending/failing. The cancelled job is a validation limitation, not an all-green claim.
This is a clear corrective review, not a GitHub approval or runtime certification.
The HTTP bridge mapped every IngestError::Rejected to 400 BAD_REQUEST, including canvas CAS precondition failures (RevisionMissing, RevisionMismatch, SupersedeFailed). The CLI reconciliation branch in channels.rs:482 gates on status == 409; this made the carefully reviewed four-way reconciliation path permanently dead code against the live relay. Introduce IngestError::CanvasConflict for the three canvas CAS cases and map it to 409 CONFLICT in the HTTP bridge. The message body is unchanged (the desktop TypeScript layer matches by message text, not status code; Gurney verified the desktop conflict UX passes live at 400). Generic rejections remain 400. All IngestError consumers updated: HTTP bridge (409), WS handlers (OK false/invalid — unchanged semantics), conformance sanitized_reason (Invalid), command_executor test helper (exhaustive match). No changes under crates/buzz-cli. Add a wire-pinning bridge test (canvas_cas_conflict_yields_409_through_ http_bridge) that drives the real HTTP router through a three-step CAS race and asserts 409 + canonical body. Mutation oracle: reverting CanvasConflict -> BAD_REQUEST makes both assertions fail. This pins the status the CLI fixtures assert against the status the relay emits. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…-history * origin/main: feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Review clear for the corrective delta, with one non-blocking note
No merge-blocking defect found at HEAD 5147bb3b24cf2329562c71364368c3da643278a9 against BASE 77729abfb692b25a0f4ec4a69add86af2e32c0dd. This review covers the correction and merge interactions since the prior clear review at 57c4b8b2d09dccb6a1bc92cd7d5d6aa23984d3b1, preserving the established append-only history, atomic restore, legacy-write compatibility, writer-consistent reconciliation and durable-outcome contract.
- The HTTP correction reaches its consumer. The three canvas CAS rejection outcomes become
CanvasConflict, HTTP returns 409 with the original conflict text, and the existing CLI 409/“canvas changed” reconciliation path is reachable. WebSocket rejection semantics and conformance classification remain unchanged. Reviewed the router-driven PostgreSQL regression and the merge's opt-in read-state snapshot routing; normal canvas query routing is preserved. Canvas DB storage, SDK, native/desktop and CLI feature implementations are byte-identical to the previously cleared head. - [P3, non-blocking] Log the actual conflict status. The new 409 response uses
SubmitOutcome::Rejected, whose terminal attribution branch still hard-codesstatus = 400u16. Submit a canvas with a stale expected revision: the client correctly receives 409, but the correspondingHTTP bridge requestlog records 400. This misclassifies conflict requests during operational triage; it does not break wire behavior, reconciliation or persistence. Derive the logged status from the existing response tuple rather than adding another status owner. The new regression asserts the wire status/body, not this attribution field. - Validation is source-only. Exact-object comparison, changed producer/consumer tracing, merge-seam review and test-source inspection ran on the pinned Blox host with independent source and metadata lanes. No checkout, build, test execution or live workflow was performed. The single exact-head CI snapshot at
2026-09-22T17:02:12Zwas nonterminal: 40 successful, 26 skipped and 6 in progress, none failed. No CI reruns or monitoring.
This is a clear corrective review, not a GitHub approval, all-green CI claim or runtime certification.
SubmitOutcome::Rejected hardcoded status = 400u16 in its terminal log arm. After the CanvasConflict fix, that variant now also carries 409 responses — so canvas CAS conflicts were correctly delivered as 409 to clients but misattributed as 400 in relay logs. Fix: bind response in the Rejected logging arm and log response.0.as_u16() so the logged status matches the actual response. Update the variant doc comment to name both outcome classes. Add T3c (canvas_cas_conflict_logs_status_409_not_400): log-capture test that asserts the terminal attribution line logs status=409 for a canvas CAS conflict, with a generic-rejection 400 control in the same run. Revert-red confirmed: restoring status = 400u16 fails the 409 assertion while the 400 control passes. Tighten the wire-pinning body oracle (MINOR): parse the response JSON and assert the exact canonical error field value instead of a substring check. Correct the mutation-oracle comment which incorrectly claimed both assertions would fail on revert — only the status assertion changes on a status-revert; the body-envelope assertion is independent. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…-history * origin/main: fix(admin): allow cold storage worker DB startup (#7770) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
🤖 Adds append-only channel canvas history, restore, and relay-authoritative optimistic concurrency so stale edits cannot silently replace a newer canvas.
What this adds
buzz canvas history --channel …,buzz canvas get --channel … --revision <id>, andbuzz canvas restore --channel … --revision <id>use keyset pagination over the retained revision stream.expected-revisiontags, bounded ancestry classification, and shared timestamp discipline keep first-party writes ahead of the head they read without propagating poisoned future timestamps.Authoritative concurrency contract
Tagged canvas writes use compare-and-swap semantics in the relay and database:
expected-revision=nonesucceeds only when no live canvas exists.created_at DESC, id ASC.The database serializes tagged writes, unconditional kind
40100writes, and canvas soft deletion on the same(community, kind, channel)advisory key. Untaggedbuzz canvas setremains an unconditional write, but participates in that serialization boundary so it cannot race a tagged save or deletion. Deletion classification is derived inside the datastore transaction; unrelated event kinds do not take the canvas lock.Read and timestamp consistency
Precondition and post-write ancestry reads request strong consistency from
/query, pinning them to the writer when replica routing is enabled. Display-only history remains replica-eligible.First-party canvas writers stamp
created_at = max(now, head + 1)with a conservative client skew limit. The relay applies a canvas-specific future-timestamp ceiling before persistence, preventing an accepted boundary head from making subsequent first-party writes invalid while preserving the broader ingest limit for other event kinds.Durable outcomes and recovery
After an accepted publish, a failed verification read is reported as durable success with
verified: falserather than a failed save. Desktop and CLI preserve the new event ID and direct users to History when verification is unavailable. A successful read walks the bounded, cycle-guardedexpected-revisionancestry chain so transitive descendants are not misclassified as supersession. Canvas caches invalidate for every settled mutation outcome, including a retained but superseded revision.The 409 reconciliation path in
cmd_restore_canvasnow separately establishes persistence evidence from ancestry classification.canvas_write_survivedchecks reachability only; a secondary writer-pinned IDs lookup distinguishes "A absent" from "A present but superseded by a legacy write." The four-way outcome (survived / persisted-but-superseded / genuinely-absent / read-fail) is now correct for all lost-response shapes including unconditional legacy writes.