Files
buzz/PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md
Will Pfleger b6a26556ec feat(canvas): add version history with atomic restore (#6780)
🤖 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 restore** — `buzz 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.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
2026-09-23 11:03:54 -04:00

4.1 KiB

title, tags, status, created
title tags status created
Replica Full-Read Routing — Caller Classification
relay
replica-routing
consistency
active 2026-08-28

Replica Full-Read Routing — Caller Classification

Db::query_events always reads the writer pool. Db::query_events_routed (and its _bounded / count / feed siblings) opt a read into replica routing: the read may be served from a read replica when BUZZ_REPLICA_READ_MAX_AGE_MS is set, subject to the soundness predicate RoutePredicate::for_query derives from the query shape. The seam fails closed to the writer at every error and is a genuine no-op until the budget is configured (crates/buzz-db/src/lib.rs).

Routing rule

If a read's result influences a write or a permission decision, it reads from the writer. A replica can lag behind the caller's own just-committed write; a read that gates the next write against that lag would make a wrong decision (e.g. validate a save against a stale head, or miss the caller's own accepted event during post-write verification and report a false conflict). Every such read stays on query_events / writer.

Display reads — a page the user scrolls, a count on a badge, a history list — tolerate bounded staleness and take the routed path.

Adding, removing, or reclassifying a caller requires updating the table below; the query_events_routed doc-comment points here.

Client-carried intent: the consistency extension field

Some write-influencing reads are issued by clients (Desktop, CLI) through the HTTP /query bridge, and are indistinguishable by query shape from display reads — a kind-40100 limit:1 read serves both get_canvas (display) and a canvas save's head precondition. The client therefore signals intent with the consistency extension field on the raw filter:

  • "consistency": "strong" → the bridge serves that filter from the writer (query_events), never a replica.
  • absent → the default routed path.
  • any other value → 400 Bad Request (fails loud, never silently degrades).

The field only ever forces the writer, which is always the sound direction; there is deliberately no inverse "force replica" value, so it cannot be sprayed to bypass the replica-staleness guard on reads that should not. Parsed in crates/buzz-relay/src/api/bridge.rs (extract_consistency).

Caller classification table

Rows below are every query_events_routed / query_events_routed_bounded call site at head, plus the writer-pinned canvas row this change adds. (The count and feed routed families — count_events_routed, get_events_by_ids_routed, query_feed_*_routed, and the get_channel_window cursor/head reads — are all display or bounded-count surfaces on the routed path; they carry their own soundness notes at their definitions in crates/buzz-db/src/lib.rs and are out of scope for this table.)

Caller / path label Pool Justification
bridge_query (default /query filter) routed Display reads over the HTTP bridge; bounded staleness acceptable.
bridge_query + consistency: strong writer Client-declared write-influencing read (canvas save precondition, restore precondition, post-write ancestry verification).
req_historical (WS REQ historical page) routed Display backfill of a subscription; per-row re-filter absorbs a briefly-stale row.
bridge_thread_aux (AuxReader::Routed, thread aux page) routed Thread reply hydration; display, post-verified against the fence wall.
bridge_count_fallback (query_events_routed_bounded) routed (bounded arm) COUNT fallback that materializes rows; bounded arm only, never covered.
count_req_fallback (query_events_routed_bounded) routed (bounded arm) WS COUNT fallback that materializes rows; bounded arm only.

The writer row is the only write-influencing entry; every other caller is a display or count surface that tolerates bounded staleness. Client canvas reads that gate a write set consistency: strong (Desktop desktop/src-tauri/src/commands/canvas.rs, CLI crates/buzz-cli/src/commands/channels.rs) so they land on the writer row.