Skip to content

feat(canvas): add version history with atomic restore - #6780

Open
wpfleger96 wants to merge 48 commits into
mainfrom
duncan/canvas-version-history
Open

wpfleger96 wants to merge 48 commits into
mainfrom
duncan/canvas-version-history

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 25, 2026

Copy link
Copy Markdown
Member

🤖 Adds append-only channel canvas history, restore, and relay-authoritative optimistic concurrency so stale edits cannot silently replace a newer canvas.

What this adds

  • Desktop history and restore — browse revisions with author, timestamp, preview, and diff; restore an older revision as a new signed head after explicit confirmation. Persisted empty canvases remain reachable, mutation outcomes are announced accessibly, and focus moves to a stable destination after save or restore.
  • CLI history and restorebuzz canvas history --channel …, buzz canvas get --channel … --revision <id>, and buzz canvas restore --channel … --revision <id> use keyset pagination over the retained revision stream.
  • SDK canvas builders — optional expected-revision tags, 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=none succeeds only when no live canvas exists.
  • An event ID succeeds only when it names the canonical live head.
  • A byte-identical replay succeeds only when that event is already the canonical live head.
  • A successful candidate must sort strictly ahead of the current head under created_at DESC, id ASC.
  • Malformed preconditions and stale or non-advancing writes are rejected before event rows, mentions, or fan-out are committed.

The database serializes tagged writes, unconditional kind 40100 writes, and canvas soft deletion on the same (community, kind, channel) advisory key. Untagged buzz canvas set remains 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: false rather 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-guarded expected-revision ancestry 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_canvas now separately establishes persistence evidence from ancestry classification. canvas_write_survived checks 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.

@wpfleger96
wpfleger96 requested a review from a team as a code owner August 25, 2026 15:43
@wpfleger96 wpfleger96 changed the title feat: add canvas version history with optimistic concurrency feat: add channel canvas version history and restore Aug 25, 2026
@wpfleger96
wpfleger96 force-pushed the duncan/canvas-version-history branch from b939919 to d508818 Compare August 25, 2026 20:12
@wpfleger96 wpfleger96 changed the title feat: add channel canvas version history and restore feat: add client-side channel canvas version history and restore Aug 25, 2026
mfethe1 added a commit to mfethe1/buzz that referenced this pull request Aug 26, 2026
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.
mfethe1 added a commit to mfethe1/buzz that referenced this pull request Aug 26, 2026
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.
mfethe1 added a commit to mfethe1/buzz that referenced this pull request Aug 26, 2026

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Two blockers at bc07f7c10ef84b2798b28702399e7cf553aace98:

  1. Reset canvas UI state when channelId changes. ChannelCanvas keeps isEditing, draft, editBaseRevision, and showHistory in component-local state, but the same unkeyed component instance receives a new channelId when 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 retained draft is 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.

  2. 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 from current_canvas_head_ancestry; CLI restore similarly has our_id after 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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. [Medium] Preserve the accepted-but-unverified outcome on restore. Normal save inspects SetCanvasResult.verified and shows the recovery notice, but CanvasHistoryPanel.handleRestore discards 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. Surface verified: false non-destructively for restore just as save does, and add a causal restore regression mirroring the unverified-save test.

  2. [Medium] Do not report a transitive descendant as supersession. canvas_write_survived recognizes only our event or a head whose direct expected-revision is 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 as CANVAS_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.

  3. [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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. [Medium] Reset a rejected save before opening the next edit session. handleStartEditing resets the draft/base/notice but not setCanvasMutation. TanStack Query retains the mutation's error, 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. CanvasHistoryPanel already avoids the analogous cross-selection leak with restoreMutation.reset(). Reset this mutation when starting a new canvas edit session, and cover reject → cancel → re-edit.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. [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.rs lines 75–99); CLI restore does the same (channels.rs lines 417–466). That endpoint routes catch-all /query reads through query_events_routed (bridge.rs lines 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.rs lines 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, so classify_post_write reports CANVAS_SUPERSEDED even 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.

  2. [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.tsx lines 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.ts lines 3185–3195).

CI is green for this exact head. Per the automation’s read-only policy, I did not execute PR code locally.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. [High] Keep persisted empty canvases reachable for read-only members. ChannelManagementSheet defines existence from trimmed content and only renders the Canvas ingress when hasCanvas || canEditNarrative (ChannelManagementSheet.tsx lines 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 from eventId !== null for ingress while retaining content-based preview behavior, and cover the read-only empty-canvas case.

  2. [High] Do not advance an accepted boundary timestamp beyond the relay’s ingest ceiling. The SDK accepts a head exactly at client_now + 900 and returns head + 1 (builders.rs lines 632–640); its boundary test explicitly expects now + 901 (lines 3125–3137). The authoritative relay rejects timestamps more than 900 seconds from relay time (ingest.rs lines 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.

Duncan and others added 11 commits September 3, 2026 09:12
… 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>
…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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_canvas now 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.

Duncan and others added 2 commits September 21, 2026 11:50
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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Duncan and others added 2 commits September 22, 2026 12:13
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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-codes status = 400u16. Submit a canvas with a stale expected revision: the client correctly receives 409, but the corresponding HTTP bridge request log 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:12Z was 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.

Duncan and others added 2 commits September 22, 2026 13:36
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>

This branch was successfully deployed

No deployments
codex-review ac21a649 Deployed Sep 22, 2026 by wpfleger96 via Run Codex Security Review #5107
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.

3 participants