The field was documented but runMergeLaneStorageSuite() never accepted or
used it. Remove the orphaned JSDoc line.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The shared mergeLaneStorage suite was missing two tests that existed in the
original per-lane files: idsFromStore returns the set of IDs where the value
field is true, and idsFromStore returns an empty set for all-false/empty
stores. The idsFromStore helper was advertised in the usage comment but never
added to the config destructure or exercised by the suite.
Add idsFromStore to the suite's config parameter, add the two parameterized
tests, pass starredChannelIdsFromStore / mutedChannelIdsFromStore from the
respective wrappers, and remove the stale isSubsumedBy line from the usage
comment (it was never wired).
Stars and mutes storage suites now each register 47 tests, matching the
original per-lane count.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The shared wholeBlobSync suite's "second edit queued during confirmation
survives" test minted the peer winner with an auto-generated peer-N id,
which sorts lexicographically above evt-N. remoteAdvancedSince therefore
returned false and edit-2 published without either foldSupersedingAttemptWinner
repair path firing — both could be removed without failing the test.
Restore the explicit "0-peer-winner" id (lexicographically < evt-N), matching
the original channelSectionsSync.test.mjs fixture and the other two collision
tests in the same shared file. This makes remoteAdvancedSince return true so
the stale-gen adoptRemote repair path (line 401) fires on confirmation, and
the pre-publish repair path (line 538) acts as the safety net for the retry
case. Removing both fold call sites now fails the regression as required.
Updated inline comments to describe the corrected execution path. No change
to assertions (publishCalls=2, adopted=0, pendingStore=null, outbox=null)
or to the fetch sequence.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The consolidated runWholeBlobSyncSuite had the 'second edit queued during
confirmation survives' test mock inverted from the original: it returned
empty for both fetchCalls 1 and 2, then hung at fetchCalls 3 (the retry
pre-pub). The original hung at fetchCalls 2 (the debounce confirmation),
matching the invariant's intent of queuing edit-2 while edit-1's
confirmation is paused.
The broken mock caused an infinite while-loop spin (releaseConfirmation
stayed null because fc=3 was never called — it required a second
fireDelay to fire the retry timer). Node's test runner detected the
event loop draining with the Promise still pending and reported
'Promise resolution is still pending but the event loop has already
resolved'.
Fix: restore the original call sequence (fc=1 empty pre-pub, fc=2
confirmation hangs) and the publishEvent mock (publishCalls=1 stores
winner without explicit id so peer-N > evt-N — remoteAdvancedSince
returns false for edit-2; publishCalls=2 stores the actual signed event
so confirmRetainedHead returns confirmed and discardPending fires rather
than adoptRemote). Expand assertions to match the original: stale-gen
adopt check, pendingStore check, and outbox clearance on confirmation.
All 315 sidebar tests pass.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Replace channels["toggled"] bracket notation with channels.toggled
dot notation on the write assignment — biome complexity/useLiteralKeys
flagged the single-word string key.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The undecryptable-live-event test fires a live-event callback that spawns
a dangling async chain (void decryptAndParse().then(...)). When placed
mid-suite, trailing microtasks from that chain could interfere with
subsequent timer-based tests. Moving it last keeps the suite hermetic.
Also removes the console.log debug calls that were left in from development.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Remove unused isSubsumedBy/idsFromStore imports from stars/mutes storage
wrappers (not consumed by runMergeLaneStorageSuite), drop RELAY_KEY from
sections/sort sync wrappers (not consumed by the suite), remove unused
makeStore function from sections sync wrapper (superseded by
makeSectionsStore), and remove the makeStore parameter from
runWholeBlobSyncSuite (declared but never accessed in the body).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Extract merge-lane storage helpers to mergeLaneStorage.shared.ts:
- channelStarsStorage.ts + channelMutesStorage.ts now delegate
readStore, writeOutbox, readOutbox, clearOutbox, reclaimSubsumedOutbox,
makeStorageKey, and claimLegacy to the shared module.
- channelSectionsStorage.ts imports claimLegacy from the shared module,
removing the inline duplicate (sections' readChannelSectionsStore and
outbox keep whole-blob watermark wiring).
Parameterize remaining test mirrors:
- mergeLaneStorage.shared.test.mjs: 47-invariant storage suite covering
all stars/mutes storage behaviors. channelStarsStorage.test.mjs and
channelMutesStorage.test.mjs become 32-line wrappers.
- mergeLaneHook.shared.test.mjs: shared hook suite. useChannelStars and
useChannelMutes tests become 22-line wrappers.
- wholeBlobSync.shared.test.mjs: 17-invariant shared whole-blob sync
suite. channelSectionsSync.test.mjs and channelSortSync.test.mjs
call runWholeBlobSyncSuite + retain their lane-specific tests
(serialized generations, ambiguous ACK, malformed payload,
unsupported-version, reconnect adopts, durable outbox, failed publish).
Every named regression from all prior review rounds survives identically,
relocated to the shared suite or kept in the lane file.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Extract mergeLaneSync.shared.test.mjs as a runMergeLaneSyncSuite()
parameterized function covering all 14 merge-lane invariants (observe
high-water, destroy cancel, generation CAS A-succeeds/A-fails, bounded
backoff, retention-confirmation loser, boot-seed guard suite, failed
fetch retain, unreadable head retain, unsupported schema retain, and
timestamp clamp). Replace the 668-line channelStarsSync.test.mjs and
channelMutesSync.test.mjs with thin 14-line wrappers that call the
shared suite with lane-specific config (Manager, outbox reader,
watermark key, entry factory, publish/getPending/fetchRemote adapters).
Every named regression survives identically — test titles are prefixed
with the lane label so each pass/fail maps to its originating invariant.
Total: 316 pass (was 315; one test was added by the shared teardown
loop). Net test-file delta: -1300 lines.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The four sidebar sync engines (sections, sort, stars, mutes) shared
nearly all implementation. The two whole-blob lanes (sections + sort)
carried the same generation-CAS, outbox, watermark, retained-head
confirmation, prior-gen fold, and bounded-retry machinery; the two
merge lanes (stars + mutes) differed only in store type and the
merge/subsumption functions.
Extract:
- wholeBlobSyncManager.ts: parameterized by WholeBlobLaneConfig<S>
(kind, dTag, parse, serializePayload, writeOutbox, clearOutbox,
storesEqual, isLocalNonEmpty). Preserves all pass-2/pass-3/r7
invariants. ChannelSectionSyncManager and ChannelSortSyncManager
are now thin wrappers (~75 lines each) that pass lane config and
expose their named API methods.
- mergeLaneSyncManager.ts: parameterized by MergeLaneConfig<S>
(kind, dTag, parse, serializePayload, mergeWithRemote, isSubsumedBy,
storesEqual, observe, writeOutbox, clearOutbox, isLocalNonEmpty).
ChannelStarSyncManager and ChannelMuteSyncManager are thin wrappers
(~90 lines each).
All 315 sidebar lib tests pass at this commit with no behavior change.
Each lane wrapper still exposes its named public API (publishSections,
publishSortPrefs, publishStars, publishMutes, fetchRemoteSections,
subscribeToSections, etc.) so no hook changes are required.
Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
Signed-off-by: Will Pfleger <wpfleger96@gmail.com>
P1.1: Both whole-blob storage event handlers (sections + sort) replaced React
state unconditionally from the shared scoped cache, bypassing the
hasPendingEdit() guard that applyRemote uses for relay arrivals. A peer window
writing B1 while A has a pending edit (A1) caused the updater for A2 to derive
from B1, stranding A1's outbox record (Carl r8).
Fix: check hasPendingEdit() in both storage handlers; defer the peer cache
write until the pending edit's debounced publish-or-adopt cycle completes.
When nothing is pending the peer write applies immediately, preserving
cross-window state mirroring for the idle case.
P1.2: parseChannelSectionPayload did not check version; relay decryptAndParse
called it bare, so a future {version: 2} relay head parsed as v1 state instead
of triggering the retain/retry path. Sort, stars, and mutes all reject non-v1
in their parsers; sections was the odd one out.
Fix: add version !== 1 guard at the top of parseChannelSectionPayload. The
local-storage parse path (parseRaw) already checked version before calling the
parser, so no local-storage read is affected.
Tests: two new hook regression tests (sections + sort) for P1.1 using
StorageEvent dispatch; one new manager regression test and one new storage unit
test for P1.2; existing parser tests updated to include version: 1 in
previously version-less test fixtures.
Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
Signed-off-by: Will Pfleger <wpfleger96@gmail.com>
Track attempt id → generation in ambiguousAttemptIds (Map<string,number>)
instead of a plain Set. foldSupersedingAttemptWinner now requires that the
mapped generation is strictly less than pendingGeneration, making it
explicit that the fold only applies when a newer edit's baseline was
poisoned by an older generation's unconfirmed attempt — not when the
current generation's own in-flight attempt is the baseline.
The generation invariant is structurally guaranteed (publishBaseline is
frozen before the attempt id is known), but the explicit guard provides
defense in depth against future code changes and makes the constraint
self-documenting.
Add two regressions per lane (sections + sort):
- Single-edit confirmation-retain path: ACK resolves, confirmation
returns retain, retry sees peer winner → adopt, do not republish.
Companion test proving correct adopt behavior in the no-second-edit path.
- Queued-during-confirmation: second edit queued after ACK but before
confirmRetainedHead resolves; stale-gen adoptRemote folds winner into
baseline; second edit's pre-publish check publishes above true head.
Mutation: reverting adoptRemote fold fails this test.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
When a second edit is queued after a publish ACK but before
confirmRetainedHead resolves, pendingGeneration bumps while the first
edit's confirmation fetch is in flight. The second edit's publishBaseline
is frozen against the attempted (non-retained) event id. When confirmation
returns the peer's same-second lower-id winner via adoptRemote with stale
gen, the phantom baseline causes the second edit's pre-publish check to see
the winner as having advanced past our attempt and adopt it away — the
user's second edit is silently lost (Carl r6 P1).
Extract foldSupersedingAttemptWinner (shared helper, both managers): when
publishBaseline.eventId is one of our own attempted ids and the remote is
the same-second lower-id peer winner, fold the winner into the baseline.
The pre-publish check then sees equality and publishes above the true
retained head. Scoped to same-second lower-id only — a strictly-later
remote is a genuine advance that the pending edit must still adopt
(pass-2 invariant). Invoke from adoptRemote stale-gen path (confirmation
variant) and from fetchOwnBlobBeforePublish (confirmation-retain/retry
variant). Both managers (sections, sort) mirrored.
Add one regression per manager: pauses the confirmation fetch, queues the
second edit during the pause, then releases with the peer winner. Asserts
the second edit publishes above the winner and is confirmed retained rather
than adopted away. Mutation: reverting the adoptRemote fold fails the test.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The pass-3 catch rolled back the just-written scoped copy on any failure.
If the legacy delete succeeded but the confirmation read then threw, legacy
was already gone, so the rollback destroyed the only surviving copy —
permanent preference loss.
Roll back only when the legacy key is provably still importable (keeping the
scoped copy then would let a second relay scope double-import it). If legacy
is gone, the scoped copy is the sole copy and is kept; if the probe itself
throws, keep it too, favoring no-data-loss. The read-time ownership gate
keeps a kept-but-unproven copy hidden while legacy remains, so it never seeds
early. Residual: legacy-delete and catch-probe both throw, then a relay
switch after partial recovery can let both scopes carry the value — bounded
and deliberately preferred over data loss.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The migration wrote the scoped key before deleting the legacy key. If the
legacy delete threw and the rollback delete also threw, the scoped copy
survived and the next read gave it precedence, exposing legacy prefs to
seed-publish while the legacy key was still importable by another relay.
Move the ownership check into the reader: a scoped copy is exposed only
while no importable legacy key remains, so a half-migrated copy can never
be read as owned. The claim completes inline once storage recovers.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Thufir's r6 review found the legacy→scoped migration exposed legacy data
to bootstrap even when the scoped write threw or the legacy delete did not
take, wrapped in a swallowed try/catch. On any storage failure the legacy
key survived while the relay still received the legacy preferences and
could seed-publish them; a second relay later read the same legacy key and
seeded it too — so "globally one-time" held only on the happy path.
migrateLegacyStore now exposes migrated data only when ownership is proven:
write the scoped key, delete the legacy key, and confirm the delete took.
On a scoped-write throw, a legacy-delete throw, or a delete that silently
does not take, roll back the partial scoped copy and return DEFAULT_STORE,
leaving the migration retryable. An empty store cannot seed-publish
(bootstrap's non-empty-local gate), so DEFAULT is safe.
The identical flaw existed in all three lanes with a legacy primary-cache
migration — sections (pre-existing on main), stars, and mutes. Sort has no
such migration and is untouched. Concurrent claimants are safe: every
window resolves the same app-wide active community, so at any instant they
scope to the same relay and any race migrates the identical value to the
identical key; a second relay only enters play after a community switch,
which is strictly after the first scoped read consumed the legacy key.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Carl r6 raised two P1 correctness gaps.
Whole-blob lanes (sections, sort) treated a resolved publishEvent as proof
of retention, but the relay OKs a superseded NIP-33 write as a no-op
(Duplicate). Two windows stamping the same created_at both get OK while
only the lower event id is retained, so the loser recorded a nonexistent
head as its baseline and its next edit adopted the true winner away. After
ACK the lanes now fetch the authoritative head and only clear/fold/discard
on an exact event-id match; a different readable head is adopted, an
unprovable/unreadable/own-prior head is retained and retried.
The stars/mutes primary cache was pubkey-only while its outbox was
relay-scoped, so a first visit to relay B seed-published relay A's
preferences. The cache key is now relay-scoped like sections/sort, with a
one-time legacy migration that deletes the pubkey-only key so no other
relay can import it.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The rev parse bound used Number.isSafeInteger, which accepts
Number.MAX_SAFE_INTEGER itself. The click path mints rev = maxRev + 1,
so an accepted boundary rev overflows to an unsafe value that never
advances again — recreating the same-second toggle wedge the bound
exists to prevent. Reject rev >= Number.MAX_SAFE_INTEGER (exclusive,
normalize to 0) so maxRev + 1 always stays safe. updatedAt keeps its
inclusive bound: the click path takes Math.max(now, observed), never
increments, so it cannot overflow.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Extends the retain-on-unreadable pattern to the two remaining overwrite
paths and hardens the rev/timestamp parse boundary.
- All four lanes tri-state the pre-publish read: a THROWN fetch (timeout /
auth / socket) now retains and retries instead of falling through to
publish, so an edit during a transient outage can no longer sign above a
stale watermark and erase an unseen newer head.
- The merge lanes (stars/mutes) retain when the existing head fails
decryption/JSON/schema parsing rather than publishing the local store
over it — a max-merge is only safe once both operands were read, matching
the whole-blob lanes.
- The rev/timestamp parsers require Number.isSafeInteger: an unsafe rev is
normalized to 0 and an unsafe timestamp entry is dropped, so a malformed
huge-rev true entry can no longer wedge later false toggles forever.
Retain reuses the existing bounded-backoff retry and generation CAS, so a
later readable/successful head resumes normal resolution. Regressions cover
all four lanes for the failed-fetch and unreadable-head paths and the
rev-wedge P2 case in both storage suites.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Close the pass-3 review blocker and Carl's four P1s on the channel
sections/sort/stars/mutes relay sync, all in the sidebar lib:
- Gate the legacy-consumed marker on a proven v2 transfer. writeOwnOutbox
now returns whether the fresh key's setItem succeeded; the whole-blob
hooks write the marker only when durable, so a quota failure leaves the
legacy record replayable instead of silently suppressing the only copy.
- Reconnect wakes the existing pending edit (retryPendingPublish) rather
than re-queueing via publish*(), which reset the frozen baseline and
published a stale edit over an advanced remote.
- Merge lanes clear an own outbox only after an authoritative retained-head
fetch proves the head subsumes the attempted store; a bare EVENT OK on a
superseded NIP-33 write no longer drops the loser's durable click.
- An unreadable/unsupported pre-publish head retains the pending edit and
retries rather than overwriting state the client could not inspect.
- Document the accepted mixed-fleet residual (a same-second old-build click
reads rev 0 and loses to an earlier new-build rev, healing on the next
later-second click) and pin it with a test; no protocol change.
Regressions added for each item, mutation-verified where the fix lives in
the hook.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The legacy v1 shared outbox key is never deleted (it is mutable and a
concurrently-live old build may still rewrite it), so the whole-blob
resume path re-read it on every boot and republished the stale blob above
the current relay head forever. A found relay head never stopped it: a
fresh manager has no lastPublishedStore and queueing the replay freezes
publishBaseline to the just-fetched head.
Distinguish compatibility replay from permanently pending intent with a
durable per-value consumption marker, whole-blob lanes only. resumeWholeBlobOutbox
excludes the legacy record when its exact raw matches the stored marker;
a live old build rewriting the key stores a different raw and is replayed
again. The hook transfers the intent into its own v2 key (synchronous
publish) BEFORE writing the marker, so a crash in that gap replays the
blob once more rather than losing it.
Merge lanes (stars/mutes) need no marker but gained a head-subsumed gate
so a lingering legacy key does not re-drive an identical boot-time publish.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The per-window token/recheck reclamation still performed a non-atomic
compare-then-delete on a mutable foreign key: a live owner could rewrite
its key between the reclaim decision-read and the removeItem, and the
whole-blob `queuedAt <= head.created_at` gate dropped same-second and
legacy `queuedAt=0` records that had not provably lost LWW.
Records are now write-once: a key is `<prefix>:<pubkey>:<relay>:<nonce>:<seq>`
and is never rewritten. A new edit writes a new key (next zero-padded seq)
then deletes its own older keys (write-before-delete, so a crash leaves at
least one record). Foreign reclamation reads an immutable record, proves it
reclaimable against durable relay evidence, and deletes it with no recheck.
Whole-blob supersession is strict (`queuedAt < head.created_at`); replay runs
before reclamation in every hook so a same-second record is consumed into
pending first; the legacy v1 shared key is only ever replayed, never deleted.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
localStorage has no atomic compare-and-delete or transactional
read-modify-write, so a single outbox key shared across every window
could not be mutated safely: one window's read-then-write or
read-then-remove races a peer's write in the gap and drops its
still-unpublished edit. A per-write ownership token narrowed that window
but could not close it — the token proves what was read, not what is
still stored at the destructive op.
Key the outbox per window instead: <prefix>:<pubkey>:<relay>:<nonce>,
where the nonce is minted once and parked in sessionStorage. Each window
is the sole writer of its own key, so a hot-path write is one
unconditional setItem — the write race is designed out, not guarded.
Resume enumerates every window's key: merge lanes (stars/mutes) fold all
records order-independently; whole-blob lanes (sort/sections) replay the
max-queuedAt record with a nonce tiebreak. Redundant foreign keys are
reclaimed at boot, gated on durable relay evidence (merge: head subsumes;
whole-blob: head created_at supersedes) and re-read immediately before
removal so a live peer's fresh write in the recheck gap survives.
Reclamation runs only on a successful head fetch, never on a failed one.
The token contract is deleted entirely — ownership is the key.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sections, stars, mutes, and sort each persist an unpublished edit under one
localStorage outbox key per identity+relay, but generation ownership is only
in-memory per window. Without cross-window ownership, one window's completing
publish could clear a peer window's still-unpublished edit, and (on the merge
lanes) a peer's write could overwrite an edit before it published.
Every outbox write now mints an ownership token and stores a {store, token}
envelope; a completing publish compare-and-clears only when the stored token
still matches its own, so a peer's newer write survives an older window's ACK.
Stars and mutes additionally read-merge-write both the durable outbox and the
main store via their per-entry mergeStores, so two windows editing different
channels both survive; sort and sections replace whole-blob with LWW resolution
matching the relay. Sort also gains the durable outbox + bounded retry it
previously lacked, and stars/mutes/sort now clamp publish created_at inside the
relay's future-drift window like sections. The envelope reader tolerates a
legacy token-less entry so an outbox written by a prior build still resumes.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Carl's re-review found three edit-loss paths remaining after the
stars/mutes rev-merge landed.
[P2] Stars, mutes, and sort stamped createdAt = lastRemoteCreatedAt + 1
uncapped, so a self-authored head accepted near the relay's +900s drift
boundary wedged every later publish until wall time caught up. Extract
the sections clamp into a shared clampPublishCreatedAt in
sidebarSyncWatermark.ts (all four surfaces already import that module)
and wire sections/stars/mutes/sort to it.
[P1] Sort preferences never got the durable lane: doPublish only logged
on failure, destroy() discarded the pending edit, and a live remote
cancelled the debounce with the edit stranded. Port the sections lane —
durable outbox + bootstrap resume, generation/CAS ownership, single-
flight + completion re-drive, 2s->30s backoff, 60s reconcile loop, and
pending-aware remote application. Sort stays whole-blob LWW; a lost head
is adopted at pre-publish rather than republished.
Tests: a clamp test per surface, and sort lifecycle coverage — outbox
resume after destroy-inside-debounce, retry without a later edit,
overlapping-generation safety, live-remote-during-debounce adopt, and a
hook-level pending-defer test.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The hard-eviction-branch test asserted only the rev tuple outcome on two
one-entry stores while its comment claimed the >500-entry eviction/remount
setup. Build the real fixture: >500 equal-updatedAt entries so the bound
evicts the target by the id tiebreak, then a remounted rev-1 click merged
against the retained rev-100 remote, asserting the deterministic rev-100
outcome in both merge orders. Stars and mutes.
The unobserved-future mixed-fleet residual was stated but never exercised
directly: the fast-clock suites cover only the observed-future fix. Add a
click with an empty high-water at t, then a genuinely unobserved
opposite-value head at t+300 that wins on the primary updatedAt key. Both
hook suites.
Test-only; no production source changes.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Replace the LWW register plus ownership/dirty-set/canonical-supersession
machinery with a per-entry Lamport `rev` and a single max-merge on every
path, mirroring the read-state data model. Each entry carries an additive
optional `rev` (missing implies 0; payload stays `version: 1` so older
builds keep parsing our blobs). One `mergeStores` orders by
updatedAt then rev then the starred/muted-true leaf, and ends in the
500-entry bound.
Clicks stamp `updatedAt = max(now, localEntry?.updatedAt ?? 0,
maxUpdatedAtSeen(id))` and mint `rev = max(localEntry.rev, maxRevSeen(id))
+ 1`, so a click strictly dominates every state its replica has observed
and cannot lose to a same-second remote. The sync managers hold a
per-channel two-field high-water map fed by a single `observe()` on every
ingest path. Stars sync keeps the generation-CAS + single-flight lane and
bounded-backoff retry plus a durable outbox so an in-flight publish can
never clear a newer pending edit; mutes sync mirrors it. Remote ingestion
never touches the pending lane. Deleted: mergeApplyingRemote,
mergeStoresWithTie, mergeCanonicalSupersession, dirtyChannelIds,
lastAppliedRemoteTs/lastAppliedEventId, and the event-clock branch.
Sections and sort are untouched.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Round-4 client-side convergence fixes for channel-sections/stars/mutes sync,
closing two silent edit-loss variants that survived publish serialization.
Ambiguous-ACK fold: a publish whose ACK is lost may still have been accepted
by the relay. Retain each attempt's signed id; when a later cycle's pre-publish
fetch returns a head whose id matches a prior attempt, fold it forward as our
own accepted predecessor and publish above it instead of adopting it away and
erasing the queued edit. A head the relay never accepted can never surface by
id, so the fold is proof-gated on an exact id match.
Canonical-supersession dirty overlay (stars + mutes): a lower-id canonical
correction that arrives after a same-second local click must not clobber the
click. Apply the correction to the prior remote layer, then overlay entries
changed locally since that layer; never cancel a pending publish merely because
a correction arrived.
Client-only: loss discovery relies on the existing pre-publish fetch, live
subscription, and reconcile loop rather than a relay conflict signal.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Each prior round patched one cross-generation interleaving and opened
another a layer deeper. Kill the race class structurally instead.
Sections: serialize publish cycles (one in-flight at a time; a newer edit
queued mid-cycle defers and the completion re-drives it). The per-edit
pre-publish baseline is frozen at queue time, so a genuine remote observed
during the debounce window still adopts, while our own accepted head is
folded forward via canonicalMax so a stale generation's own write is never
mistaken for a competing remote and adopted away. Dual generation guards in
doPublish (post-fetch and pre-publish) stop a stale generation signing or
publishing after a newer edit exists.
Stars/mutes: scope mergeApplyingRemote (remote-wins on entry-tie) and the
pending-publish cancel to fire only on a canonical supersession of an
already-applied same-timestamp larger-id head. Every other application
(bootstrap/live/newer-timestamp) keeps local-wins mergeStores and does not
cancel the pending publish, so a later same-second local click is no longer
clobbered by an older remote entry that decrypts late.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Two convergence holes one layer under the pass-1 fixes:
Sections: the pre-publish head check compared the fetched head against
the mutable lastRemoteCreatedAt, which a live event observed during the
debounce window already advanced to that same head — equality fell
through to publish and the local blob overwrote a remote that became
head after the edit was queued. Freeze a canonical head baseline
(created_at, id) at publishSections and compare the fetched head against
that generation baseline instead, adopting when the head advanced.
Stars/mutes: applyRemote admits the canonical lower-id winner but then
mergeStores resolved equal per-entry updatedAt as local/prev-wins, so a
stale larger-id value delivered first survived and undid the winner. Add
mergeApplyingRemote which resolves an entry-timestamp tie toward the
canonical incoming blob while keeping strictly-newer local entries.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
useChannelStars, useChannelMutes, and useChannelSortPreference carried the
same inverted equal-timestamp comparator as channel sections: applyRemote kept
the largest event id, opposite the relay/database canonical order (created_at
DESC, id ASC -> lowest id wins). Two devices writing the same second could
leave the UI showing an event the relay did not store.
Apply a strictly-lower id and ignore ids >= the last applied, matching the
sections fix and the relay winner across all four 30078 sidebar surfaces. Each
hook gains a regression test: larger-then-lower id delivery at equal timestamp,
lower-id store wins (mutation-checked - reverting >= to <= fails each).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Three cross-layer races defeated the one-convergence-mechanism design:
- An older in-flight publish unconditionally cleared pending state on
completion, erasing a newer edit queued mid-flight. Each pending edit now
carries a monotonic generation; a completion clears pending/outbox/retry only
via compare-and-swap on the generation it published.
- Hook-level remote application (bootstrap/live/periodic) cancelled the pending
publish's timers without deciding supersession, stranding the durable outbox
and clobbering the optimistic edit. applyRemote now defers entirely to a
pending edit, whose own debounced publish converges via publish-or-adopt; the
manager's adopt path clears pending before write-through so the winning remote
still applies.
- The equal-timestamp tie-break kept the largest event id, opposite the
relay/database canonical order (created_at DESC, id ASC → lowest id wins).
applyRemote now applies a strictly-lower id and ignores ids >= the last
applied, so the UI converges on the event the relay actually stored.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Channel-section sidebar state diverged between a user's devices and
sometimes never self-healed. Four client-side gaps fed the divergence:
- A local edit that lost whole-blob LWW was silently republished as remote
content while the UI kept showing the edit. Now the manager adopts the
winning remote head (writes it through to state + storage, advances the
watermark) and skips publishing, unifying with the relay's OK-false
conflict path as one convergence mechanism.
- Edits made inside the 2s publish debounce were dropped on quit or
community switch. A durable localStorage outbox persists every edit
synchronously and resumes it on next mount; adopt clears the outbox so a
superseded edit can never be replayed.
- A skewed remote head could push the published createdAt past the relay's
future-drift window and wedge all later publishes. createdAt is now
clamped inside that window.
- Stale-at-open state waited for a reconnect that a healthy socket never
fires. A reconciliation loop periodically refetches the head (steady 60s,
backoff on failure) and refreshes on window visibility.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
## Why
Finish the replaceable-event slice of [tracker
#2](https://github.com/TheSentinel454/buzz/issues/2) and [domain issue
#6](https://github.com/TheSentinel454/buzz/issues/6) without disturbing
the runtime/store boundary established by #6660 and #6668. PR #6700 has
merged; this PR now targets current main containing its replacement lock
and transaction observability.
## What
- Move `Db::replace_addressable_event`, its SQL, lock/transaction
instrumentation, and focused addressable/parameterized replacement tests
from `lib.rs` to `replaceable.rs`
- Preserve the transaction-required parameterized API, replacement
ordering, rollback semantics, mention indexing, and exactly one
datastore span per public operation
- Keep implementation and focused PostgreSQL tests co-located; follow-up
review removed the dedicated replaceable ownership source guard as low
value
## Stack
- Exact base: main at f249710331
- Exact head: codex/issue-6-finish-replaceable-store at
da018405cc
- Tracker: https://github.com/TheSentinel454/buzz/issues/2
- Domain: https://github.com/TheSentinel454/buzz/issues/6
- Test/span acceptance: https://github.com/TheSentinel454/buzz/issues/17
and https://github.com/TheSentinel454/buzz/issues/19
## Non-goals
- No SQL, schema, retry, timeout, lock ordering, transaction boundary,
or client-visible behavior changes
- No store traits, domain-handle redesign, `PgExecutor` migration, raw
pool accessor, new crate, or directory reorganization
- No changes to, retargeting of, or merge action on PR #6700
## Risk Assessment
Low. This is a mechanical ownership move with unchanged signatures and
SQL. The primary review risk is losing or nesting instrumentation,
covered by exact moved PostgreSQL tests and the cumulative
production-diff audit.
## Blox Verification
Author workstation: `buzz-tornquist-issue-2-store-stack` (`2046520`),
exact head `2de5444e14606ce6912d1bb932bc88bffa379a77`.
- `cargo fmt --all --check` — passed
- `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings` —
passed
- `cargo test -p buzz-db --quiet` — 108 passed, 200 ignored; both
integration source guards passed
- Native PostgreSQL: `cargo test -p buzz-db replaceable::tests:: --
--ignored --test-threads=1` — 11 passed
- `cargo test -p buzz-relay --lib
api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo --
--exact --test-threads=1` — passed
- Full serial relay suite was also exercised: 908 tests passed but the
existing load-sensitive mesh demo test returned 504 under suite load,
matching PR #6700's documented baseline; it passed in isolation and this
diff does not touch that subsystem
Independent exact-head Blox review: `buzz-tornquist-pr-6777-review`
(`2047422`) at `2de5444e14606ce6912d1bb932bc88bffa379a77`.
- Review findings — none (critical, important, or minor)
- `cargo fmt --all --check` and the ownership guard — passed
- `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings` —
passed
- `cargo test -p buzz-db --quiet` — 108 passed, 200 ignored; source
guards passed
- Native PostgreSQL moved suite — 11 passed
- `cargo test -p buzz-relay --lib` — 909 passed, 48 ignored
Generated with Codex
## Superseded pre-comment restack verification
PR #6700 merged before publication completed. This layer was restacked
onto current main through the exact parent named above; the final
cumulative tip is 2ddcc8a29f95c8c8c9cb87bb6cf59335f2190fae. Cumulative
author gates passed: formatting and diff checks; buzz-db and buzz-relay
all-target clippy with -D warnings; DB lib 111 passed / 200 ignored;
ownership 22/22; observability 1/1; the full isolated PostgreSQL domain
matrix; and relay lib 910 passed / 49 ignored.
- Workstation: `buzz-tornquist-pr-6777-final-review` (`2057617`), fresh
shallow checkout
- Base: `f24971033178926153b49d320bd876d15d9cb2bf`
- Head: `ffbeaaf00810aa359ab85818ff4820f92263e45f`
- Findings: none
Reviewed `base..head` for the replaceable-store extraction. The
addressable replacement SQL, stale/duplicate outcomes, advisory-lock
ordering, transaction rollback/commit boundaries, mention-index
atomicity, and public `Db` signature are retained in `replaceable.rs`.
The PR #6700 transaction timer remains around the same logical
transaction through `TransactionTimer::observe`, and the public
operation has exactly one datastore span. Focused addressable and
parameterized tests moved with the implementation; the ownership guard
excludes duplicates from `lib.rs`.
Verification: format and diff checks passed; `buzz-db --all-targets`
clippy passed with `-D warnings`; DB lib tests passed (111 passed, 200
PostgreSQL tests ignored); ownership (1/1) and observability (1/1)
guards passed; all 11 replaceable PostgreSQL tests passed on native
PostgreSQL 17 with migrations 1-32 successful; relay lib test target
compiled successfully. Final worktree was detached at the exact head and
clean.
Complete evidence archive SHA-256:
`e37f92bbde4e2343196085095b3093b1bc78196be58bc86826325e0e1639d7ea`.
## Comment-addressed restack
Review follow-up removed the dedicated
`replaceable_store_has_single_ownership` source test as requested. No
production code changed.
- Exact base: `f24971033178926153b49d320bd876d15d9cb2bf`
- Exact head: `da018405cc83605362125c2d5e5a3f91492431ac`
- Final cumulative tip: `6fa2f104d42c6ba85bdf62e7ccb74ceaf4a84f67`
- Focused PR verification: formatting and diff checks passed; strict
`buzz-db`/`buzz-relay` Clippy passed; DB lib 111 passed / 200 ignored;
all 11 replaceable PostgreSQL tests passed.
- Cumulative Blox gate: formatting and diff checks; strict
`buzz-db`/`buzz-relay` Clippy; DB lib 111 passed / 200 ignored;
ownership 21/21; observability 1/1; every moved PostgreSQL test; relay
lib 910 passed / 49 ignored.
- Independent re-review at this exact head: no findings; fresh
exact-parent/head Blox review passed fmt/diff, strict `buzz-db` Clippy,
DB lib 111 passed / 200 ignored, manual one-owner/one-span replacement
checks, observability, 11 replaceable PostgreSQL tests, and relay
compilation.
---------
Signed-off-by: tornquist <tornquist@squareup.com>
## Why
Long community hostnames can make the native Community select exceed the
Admin feedback filter grid, pushing the Received and Status controls
outside the filter bar and creating horizontal overflow.
## What
- Allow filter labels and selects to shrink within their grid tracks
- Add a browser regression test covering an unusually long community
hostname
## Risk Assessment
Low — this is limited to Admin feedback filter sizing and preserves the
existing desktop and mobile grid layouts.
## Before
The Community select expands past the filter bar and pushes the Received
and Status controls outside it.

## After
Long community names remain contained, keeping all four controls inside
the filter bar.

## References
- Verified on Blox at the local checkout revision: Admin checks passed
and all 9 Playwright tests passed
Generated with Codex
## Summary
- Stop replaying the selected-agent pulse when a remembered agent
remains addressed across a send.
- Preserve the existing pulse for genuine selections and shake feedback
for failed sends.
### Related issue
Reported in Buzz:
buzz://message?channel=2745947d-f905-4f31-8a3f-fc4485d3c36c&id=0cffab376186be9cc4e7f29027352cc1c7941aead48b3fed948f36181f4a59af
### Testing
- `cd desktop && pnpm test` (5,556 passed)
- `cd desktop && pnpm typecheck`
- `cd desktop && pnpm check`
- `cd desktop && pnpm build:e2e`
- `cd desktop && pnpm exec playwright test
tests/e2e/persistent-agent-audience.spec.ts --grep 'always-mentioned
agents remain selected|failed always-mentioned send'` (2 passed)
This changes animation timing only; the regression is covered by
Playwright assertions on the pulse and shake versions, while a static
screenshot is unchanged.
Signed-off-by: Matt Toohey <contact@matttoohey.com>
## Summary
- Treat the sidebar temp-channel icon as a fallback behind working,
mute, numeric unread, and thread-unread indicators.
- Keep the header affordance and the sidebar temp icon when it is the
only row status.
- Extend the ephemeral-channel browser scenario across working, idle,
collapsed-sidebar, and unread states.
## Before / After
| Before: five temp icons compete with duration and unread states |
After: all five duration and unread states remain clear |
| --- | --- |
| 
| 
|
## Testing
- `node --import ./test-loader.mjs --experimental-strip-types --test
"src/**/*.test.mjs"` (5,556 passed)
- `tsc --noEmit -p desktop/tsconfig.json`
- `biome check desktop/src/features/sidebar/ui/SidebarSection.tsx
desktop/tests/e2e/channels.spec.ts`
- `vite build --mode e2e`
- Isolated Playwright run of `create ephemeral stream shows sidebar and
header affordances` (1 passed)
## Duplicate check
No matching open PR or issue found for `ephemeral sidebar icon`.
Signed-off-by: Jitter <d14dfe033ef0f809866f9f984de04821b0d900d7652fd85a54776ee40ca3a68f@buzz.block.builderlab.xyz>
Co-authored-by: Jitter <d14dfe033ef0f809866f9f984de04821b0d900d7652fd85a54776ee40ca3a68f@buzz.block.builderlab.xyz>
## Summary
Pasting a link while composer text is selected now turns that text into
a hyperlink instead of replacing it — the behavior people expect from
every other editor.
- `resolveExactLinkPaste` recognizes a clipboard payload that is exactly
one supported link (bare or `<…>`-wrapped `http(s)`, or a Buzz deep
link, canonicalized through the existing composer-link attribute
resolver) and returns the href to apply.
- The paste handler applies that href as a `link` mark over the current
selection, collapses the caret to the end of the newly linked range,
clears stored marks so subsequent typing isn't linked, and refocuses the
editor.
Edge cases handled:
- **No link mark available / selection can't carry marks** — falls
through to the existing replace-with-node path rather than silently
dropping the paste.
- **Selection contains a composer message-link chip** — keeps the old
replacement behavior instead of nesting a link over a chip.
- **Whitespace in the clipboard payload** — `unwrapExactHttpLink` now
rejects any text containing whitespace, so multi-line or "text plus URL"
pastes are not treated as an exact link.
- **Empty selection (caret paste)** — unchanged.
- **Idempotent re-paste** of an already-wrapped link is consumed rather
than doubling up.
## Testing
`desktop/src/features/messages/lib/composerMessageLinkNode.test.mjs`
gains coverage for exact-link resolution (per-shape accept/reject cases,
Buzz canonicalization) and for the paste handler: linking selected text,
canonicalizing Buzz links over a selection, idempotent wrapped-link
paste, fall-through when the selection can't carry marks, all-selection
collapse, chip-containing selections, and preserved caret-paste
behavior.
Full pre-push gate ran green on this branch: `file-size-check`,
`desktop-check`, `desktop-typecheck`, and `desktop-test` (5425 tests, 0
failures).
## Demo
Pasting an `https://` URL over selected composer text turns the selected
words into a hyperlink (instead of replacing them); text typed
immediately after is not linked:
https://github.com/user-attachments/assets/bbc93574-dd9d-4b1b-bad0-53307b6e887c
---------
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
## Buzz Desktop release v0.5.20
- **Frozen main:** `8471049c430073474939336dfc6aa98272bc8762`
- **Reviewed candidate:** `95154bee4034ca7a40b33095c2ddbde8c9aa1614`
- **Previous desktop release:** `desktop-v0.5.19`
- **Proposed immutable tag:** `desktop-v0.5.20`
This PR may be **squash merged** after the Desktop Release Candidate
check and all protected-branch checks pass. Merging authorizes
publication of the exact reviewed candidate; later or unrelated changes
on `main` cannot alter it.
The checked-in changelog accounts for every non-merge commit in the
release range. The Desktop tag points to the reviewed candidate commit,
not the later squash commit. Publication remains bound to that immutable
candidate tag.
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
## Summary
- Supersedes #1913 with a KLIPY-hosted URL implementation.
- Adds KLIPY GIF search and trending results to desktop message and
forum composers.
- Keeps selected GIFs hosted by KLIPY; Buzz stores only the external URL
and media metadata (no imeta tag, since relays only accept hash-backed
local `/media/` entries).
- Aligns the Emoji/GIF picker with Buzz's standard segmented control,
theme surfaces, and motion behavior.
## Relay-to-provider boundary
- The relay proxies KLIPY search/share so the `BUZZ_KLIPY_API_KEY` never
reaches the desktop; the key stays server-side behind a redacted `Debug`
impl.
- The dedicated GIF `reqwest` client sets `redirect::Policy::none()`.
Because the API key rides in the request path, following a provider
`3xx` could replay a key-bearing URL to an attacker-chosen host (an
SSRF/key-disclosure primitive). With redirects disabled, a `3xx` returns
as a non-success status that the handlers map to a generic `502`; the
`Location` target is never read or forwarded.
- Admission reuses the established NIP-98, membership, replay, and
per-pubkey rate-limit gates, with an upstream response-size cap and
allowlisting so KLIPY error bodies never cross the relay boundary.
## Accessibility
- Under `prefers-reduced-motion: reduce`, the picker grid renders a
static provider poster (a normalized `jpg` asset) instead of the
animated preview, or a named static placeholder when no poster is
available. It reacts to preference changes while mounted.
`no-preference` keeps the animated preview.
- Selected GIFs carry their title through `ImetaMedia`'s `displayLabel`,
so the composer thumbnail, preview dialog, editor, lightbox, and remove
control all derive one non-empty accessible name instead of an empty
`Attachment ` label. Ordinary hashed uploads keep their existing
hash-derived names.
---------
Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
**Category:** fix
**User Impact:** Disabling automatic agent mentions now keeps one-time
agent mentions out of the next message draft.
**Problem:** A successfully sent inline agent mention was treated as
eligible for post-send restoration even when **Automatically mention
agents** was disabled, so the agent immediately reappeared in the
composer. **Solution:** Gate the send-success restoration path on the
live preference while leaving explicitly pinned agents and enabled
automatic mentions unchanged.
<details>
<summary>File changes</summary>
**desktop/src/features/messages/ui/MessageComposer.tsx**
Skips the automatic post-send mention restoration path when the
preference is disabled.
**desktop/tests/e2e/persistent-agent-audience.spec.ts**
Reproduces disabling the toggle before sending an inline agent mention
and verifies the next composer is empty while the outgoing mention still
reaches the agent.
</details>
## Reproduction steps
1. Enable **Automatically mention agents** in the composer mention
options.
2. Disable it again.
3. Compose and send `@Agent test` using the inline mention picker.
4. Confirm the message still mentions the agent, but the cleared
composer does not repopulate `@Agent`.
5. Re-enable the preference and repeat; confirm the mention is restored
for the next message.
## Testing
At `a179a2d00a6ac5cf0bc4a1c7c1ef3d5dfa9d0875`:
- Full desktop unit suite: 5,501 passed.
- Desktop check and typecheck passed in the pre-push hooks.
- Desktop file-size ratchet passed in the pre-push hooks.
- Full `persistent-agent-audience.spec.ts` Playwright file: 17 passed,
including preference off (empty composer), preference on (restored
mention), and the existing address-undo journey.
## Visual validation
| Automatic mentions off | Automatic mentions on |
| --- | --- |
| After sending a one-time mention, the outgoing message is preserved
and the next composer stays empty. | After sending, the agent remains
addressed and is restored in the next composer. |
| 
| 
|
<details>
<summary>Original regression</summary>
With automatic mentions disabled, sending `@Alia test` still repopulated
`@Alia` in the cleared composer.

</details>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
The desktop release tooling hardcoded one contributor's personal
identity into every release candidate commit.
`scripts/prepare-desktop-release.sh` committed the candidate with a `git
-c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com'`
override, and `scripts/desktop_release.py` `validate` required the
candidate author to be exactly `Wes
<wesbillman@users.noreply.github.com>` plus a matching `Signed-off-by`
trailer. That leaked from Wes's working setup into the validation
contract in #3568, so a release cut by any other operator was falsely
attributed to and signed off by Wes (as happened on #6828).
## Change
- `prepare-desktop-release.sh`: drop the `-c` identity overrides so `git
commit -s` uses the operator's own configured identity to author and
sign off the candidate. The automation `Co-authored-by` trailer is
unchanged.
- `desktop_release.py` `validate`: replace the exact-Wes checks with
structural ones — the commit author must be non-empty, the body must
contain a `Signed-off-by` trailer whose name and email match the commit
author (honest DCO), and the existing automation `Co-authored-by` regex
check stays. Failure messages remain specific.
- `test-desktop-release-candidate.sh`: the fixture candidate now commits
under the harness's own identity, and a new negative case rewrites the
author to a mismatched identity and asserts the validator rejects it.
Release authorization is bound to the merged PR via the GitHub API in
`scripts/verify-desktop-release-merge.sh`, never the commit author
field, so this does not weaken the trust model. `RELEASING.md` and
`.github/workflows/desktop-release-candidate.yml` reference no author
identity and need no change.
Verified locally: `scripts/test-desktop-release-candidate.sh` passes,
including the new sign-off/author-mismatch rejection.
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
## Problem
Every `docker.yml` run on `main` has failed since
[#6781](https://github.com/block/buzz/pull/6781) merged. That PR added a
"Create deployment eligibility predicate" step to the `merge` job, which
runs `jq` against
`$GITHUB_WORKSPACE/scripts/create-deployment-eligibility-predicate.jq`.
But the `merge` job has no `actions/checkout` step — the workspace is
empty, so `jq` can't open the file and exits `2` (`jq: Could not open
... No such file or directory`). The `build` and `qualify` jobs each
check out the source; `merge` never needed one until this step was
added.
## Fix
- `.github/workflows/docker.yml`: add `actions/checkout` (same pinned
SHA as the other jobs, `df4cb1c` / v6.0.3) as the first step of the
`merge` job.
- `scripts/test-relay-image-eligibility-workflow.sh`: guard the
regression by asserting the `merge` job checks out the source before
building the predicate.
## Verification
CI cannot exercise the `merge` job on a pull request — the job is gated
`if: github.event_name != 'pull_request'`, so it only runs on push to
`main`. The proof is the root cause (missing checkout for a step that
reads a repo file) plus the eligibility workflow test:
`scripts/test-relay-image-eligibility-workflow.sh` passes with the fix
and fails with the new guard's specific message (`merge job must check
out the source before building the eligibility predicate`) when the
checkout is removed.
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
## Buzz Desktop release v0.5.19
- **Frozen main:** `ee6ca5fa28bce04dfecb6717de65b08a57f2ac47`
- **Reviewed candidate:** `ab691bcdaeccaa6698a2199beaba3f6e93daae81`
- **Previous desktop release:** `desktop-v0.5.18`
- **Proposed immutable tag:** `desktop-v0.5.19`
This PR may be **squash merged** after the Desktop Release Candidate
check and all protected-branch checks pass. Merging authorizes
publication of the exact reviewed candidate; later or unrelated changes
on `main` cannot alter it.
The checked-in changelog accounts for every non-merge commit in the
release range. The Desktop tag points to the reviewed candidate commit,
not the later squash commit. Publication remains bound to that immutable
candidate tag.
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Release Automation <release-automation@users.noreply.github.com>
Require `BUZZ_RELAY_PRIVATE_KEY` on every relay startup and remove the
shared public fallback key completely.
For local development, `just bootstrap` now generates a random key once
in the gitignored `.env` file. Re-running bootstrap preserves that key,
so relay-authored events retain the same identity across restarts. The
local relay recipes reload `.env`, while isolated CI and E2E launchers
pass explicit per-run keys.
Startup fails immediately when the key is missing or invalid, before
connecting to Postgres or Redis. Deployed relays continue to receive
their stable key from the existing chart-managed secret.
Validated with:
- `./scripts/test-ensure-local-relay-key.sh`
- `cargo test -p buzz-relay --bin buzz-relay`
- `cargo clippy -p buzz-relay --bin buzz-relay -- -D warnings`
- `cargo fmt --all -- --check`
- `shellcheck scripts/ensure-local-relay-key.sh
scripts/test-ensure-local-relay-key.sh`
Signed-off-by: Jordan Mecom <jm@squareup.com>
## Summary
- replace the generated Nest's unconditional human author/sign-off rules
with portable guidance that separates authorship, material
co-authorship, DCO certification, and cryptographic signing
- defer attribution to repository-local policy, forbid inferred or
guessed identities, and require inspection of every outgoing commit
- bump the Nest template version so existing installations refresh, with
regression coverage for fresh generation and upgrade preservation
### Related issue
None found. Related runtime identity work exists in #6177, but this PR
is intentionally limited to the generated Nest guidance and its refresh
behavior.
### Testing
- `bin/just desktop-tauri-clippy`
- `bin/just desktop-tauri-test`
- `bin/just file-size-check`
- `git diff --check`
- pre-push hooks: `push-head-scope`, `branch-skew`, `file-size-check`,
and `desktop-tauri-checks`
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
**Category:** fix
**User Impact:** Users can place `@Agent` mentions inline anywhere in a
message—even when that agent is also persistently addressed—so separate
agents can receive separate instructions in the same message.
**Problem:** Selecting an agent from the composer mention picker could
turn the selection into persistent addressing instead of leaving an
inline `@Agent` at the cursor. That made persistent addressing and
inline composition mutually exclusive: once the persistent behavior took
over, users lost the clear `@Agent A do X, @Agent B do Y` message
structure they previously had.
**Solution:** Make inline mentioning and persistent addressing
independent behaviors:
- Ordinary mention selections always insert `@Agent` inline at the
current cursor position.
- This remains true when the agent is already persistently addressed;
the persistent audience never blocks or replaces an inline mention.
- If **Automatically mention agents** is enabled, a successfully sent
inline mention may additionally make that agent persistent for later
messages. Existing saved preferences remain respected.
- The dedicated automatic-mention control and primary+Shift+Enter
shortcut continue to add or remove persistent addressing directly.
- For users without a saved preference, **Automatically mention agents**
now defaults off.
<details>
<summary>File changes</summary>
**desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.test.mjs**
Updates preference coverage for the default-off behavior while
preserving explicit saved choices.
**desktop/src/features/messages/lib/autoPinMentionedAgentsPreference.ts**
Defaults automatic post-send persistence off when no valid preference
exists.
**desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs**
Verifies ordinary picker selections insert inline mentions without
changing or pulsing the persistent audience, including when the selected
agent is already persistently addressed.
**desktop/src/features/messages/ui/useAgentAddressLockPicker.ts**
Keeps ordinary mention selection on the existing inline insertion path
while preserving the separate persistent-address controls and shortcut.
**desktop/tests/e2e/persistent-agent-audience.spec.ts**
Covers separately targeted inline instructions to two agents, signed
outgoing recipients, default-off persistence, and explicit opt-in
persistence scenarios.
</details>
## Reproduction steps
### Inline mentions without persistence
1. Open a channel with at least two available agents and leave
**Automatically mention agents** disabled.
2. Use the composer mention picker to insert the first agent, type an
instruction, then insert a second agent and type a different
instruction.
3. Confirm the draft reads like `@Agent A review this, @Agent B test
that` and neither agent appears in the persistent addressed-agent
controls.
4. Send the message and confirm both agents are recipients while neither
remains persistently addressed for the next draft.
### Inline mentions with persistence
1. Enable **Automatically mention agents**, then mention an agent inline
and send successfully.
2. Confirm that agent becomes persistently addressed for later messages.
3. In a new draft, select the same agent from the mention picker again.
4. Confirm a new inline `@Agent` is inserted at the cursor while the
agent remains persistently addressed.
5. Confirm the dedicated automatic-mention control and
primary+Shift+Enter shortcut can still add or remove persistent
addressing directly.
## Demo
- **Before:** Persistent addressing could consume an ordinary picker
selection, preventing users from placing that agent inline in the
message.
- **After:** Ordinary selection always produces an inline `@Agent`;
persistence is a separate optional behavior that can coexist with inline
mentions.
### Related issue
N/A — reported through the Buzz feature room.
### Testing
At `a4579c0a666f3644abe44271614186d7b9356bf5`:
- Post-push hooks passed desktop check, desktop typecheck, and the full
desktop unit suite.
- The two persistence-focused Playwright smoke scenarios passed after
explicitly opting into **Automatically mention agents**.
- The one-time multi-agent targeting and persistent-address shortcut
Playwright scenarios passed on the feature changes before the final
rebase; CI is validating the rebased branch.
---------
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
## Why
Phase 1 of progressive delivery needs an explicit eligibility boundary
before deployment automation can safely consume relay artifacts. Today
the canonical Docker workflow can publish independently of the same-SHA
CI result, the relay does not expose its source/build identity, and the
Helm chart only renders mutable tags.
The existing `ghcr.io/block/buzz-staging-dev` workflow in #6709 remains
a manual pre-merge preview lane. It is deliberately not a canonical
promotion input.
## What changed
- Compile the full source SHA and GitHub Actions build identity into
canonical relay binaries and expose it at `/_status`.
- Gate creation of tagged multi-architecture manifests on the latest
exact-SHA `CI` push run succeeding on `main` or `release`.
- Preserve the signed SLSA provenance and add a signed, release-only
deployment-eligibility predicate with source, build run, qualifying CI
run, and exact compatible Buzz chart version.
- Keep failed or pending builds untagged and without an eligibility
predicate; preview-package artifacts are excluded by package and signer
contract.
- Add optional `image.digest` support to Buzz chart `0.1.8`, retaining
the existing tag fallback.
- Document verification and the intentional boundary between image/chart
compatibility and backwards-compatible database migrations.
## Risk and rollout
Canonical tag publication now waits for same-SHA CI and fails closed
when the newest matching run fails. Per-architecture blobs may remain
untagged after a failed qualification; they are not promotion inputs.
Debug manifests are also CI-gated but do not receive the release
eligibility predicate.
There are no migration or schema-orchestration changes. The companion
BuilderBot infrastructure PR will pin a separately verified canonical
main digest and expose a read-only running-deployment inspection
command. Signed eligibility is available only for artifacts published
after this workflow lands.
## Verification
- Blox: workflow contract fixtures, including stale-green rejection and
predicate structure
- Blox: `actionlint` for `ci.yml` and `docker.yml`
- Blox: 46 Helm unit tests, chart lint, and production/quickstart render
fixtures
- Blox: relay status unit test, workspace Rust formatting, and workspace
all-target clippy
- Live evidence: exact-SHA CI selector chose successful run
`32857345153` for source `9d1e4b257657f382d3111ce748f3da8d063b7671`
- Existing canonical digest provenance verified with `gh attestation
verify`
## References
- #6709 — developer-preview precursor; intentionally separate from
canonical eligibility
- `docs/deployment-identity.md`
*Generated with Codex*
---------
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
**Category:** improvement
**User Impact:** Agent addressing now stays visible, editable, and
consistent across messages, so users can keep talking to the same agents
without rebuilding the audience for every send.
**Problem:** Agent addressing was transient and easy to lose track of.
Inline mention text, persistent audience state, restored drafts, and
sent-message rendering could drift apart, making it unclear who would
receive the next message.
**Solution:** The composer now treats addressed agents as a persistent
audience represented by ordinary editable mention chips and a
synchronized compact avatar tray. Eligible agent mentions can become
persistent immediately, addressing survives sends without flashing
empty, and removing or explicitly unpinning an agent updates that state
without disturbing unrelated draft text. `Cmd+Shift+M` / `Ctrl+Shift+M`
provides a fast way to toggle the current recipient or select the best
eligible agent.
- Promotes eligible inline agent mentions into persistent addressing
when the preference is enabled; human mentions remain unchanged.
- Keeps inline chips, restored drafts, persistent recipient state, and
the avatar tray synchronized.
- Clears an agent's persistent address when its final chip is removed,
while explicit unpinning preserves unrelated draft text.
- Restores persistent mentions optimistically during send and restores
the full draft on failure, avoiding an empty-composer flash.
- Deduplicates inline mentions and tag-backed address prefixes in sent
messages.
- Toggles the current addressed agent with `Cmd+Shift+M` /
`Ctrl+Shift+M`, or selects the best eligible agent when none is
addressed.
- Ranks shortcut selection by recent explicit stream mentions, then
active/member/runnable/stable fallbacks; DM participant fan-out does not
affect recency.
- Adds focused avatar feedback for entry/exit, send bounce, and failure
shake, with reduced-motion and narrow-layout support.
- Applies the same behavior to main and thread composers while
preserving draft restoration, channel switching, and send-race
correctness.
### Screenshots
#### Light theme
The compact tray keeps persistent recipients visible alongside editable
mention chips.

#### Dark theme
Persistent addressing remains clear and legible in the dark composer
treatment.

### Reproduction steps
1. Open a channel with an eligible agent and enable persistent agent
addressing.
2. Insert an agent mention and confirm it appears as a normal editable
chip while the compact avatar tray reflects the same persistent
recipient.
3. Send several messages and confirm the agent remains addressed, the
mention restores without an empty-composer flash, and sent messages do
not duplicate the addressed agent.
4. Remove the agent's final mention chip and confirm its persistent
address clears. Add unrelated draft text, explicitly unpin the agent,
and confirm that text remains intact.
5. Press `Cmd+Shift+M` on macOS or `Ctrl+Shift+M` on Windows/Linux.
Confirm it toggles the current addressed agent or, when none is
selected, chooses the most relevant eligible agent.
6. Verify recent explicit channel/thread mentions influence shortcut
selection, while DM participant fan-out does not.
7. Force a failed send and confirm the addressed avatars shake and the
full draft is restored; confirm successful sends use the subtler bounce
feedback.
8. Repeat in the thread composer, after switching channels, in dark
theme, with reduced motion enabled, and at a narrow composer width.
<details>
<summary>File changes</summary>
**desktop/src/features/channels/ui/ChannelPane.tsx**
Feeds recent explicit agent mentions into the composer for contextual
shortcut selection.
**desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts**
and tests
Show tag-backed addressed agents only when they are not already
represented by inline mention text.
**desktop/src/features/messages/lib/mentionCandidates.ts**,
**mentionRanking.ts**, **mentionSuggestionMapping.ts**,
**recentMentionPubkeys.ts**, and tests
Model agent eligibility and activity, derive explicit mention recency,
and rank the best shortcut recipient.
**desktop/src/features/messages/lib/useActiveAgentPubkeys.ts**,
**useDefaultAgentSuggestion.ts**, and **useMentions.ts**
Expose active-agent and recent-recipient context to mention consumers.
**desktop/src/features/messages/ui/ComposerAddressControls.tsx** and
tests
Render the compact persistent-recipient tray with enter, exit, send,
failure, layout, and reduced-motion states.
**desktop/src/features/messages/ui/MessageComposer.tsx** and
**MessageComposer.types.ts**
Connect editable mention text, persistent addressing, shortcut
selection, draft restoration, and send feedback.
**desktop/src/features/messages/ui/MessageRow.tsx**
Avoids duplicating addressed agents already rendered as inline mentions.
**desktop/src/features/messages/ui/MessageThreadPanel.tsx**
Brings the same recency and persistent-addressing behavior to thread
composers.
**desktop/src/features/messages/ui/useAgentAddressLockPicker.ts**,
**useAutoPinMentionedAgents.ts**, **useAlwaysAddressShortcut.ts**,
**useMentionSendFlow.ts**, and tests
Synchronize mention chips with persistent audience state, promote
eligible mentions, handle shortcut toggling/default selection, and
preserve recipients through send success or failure.
**desktop/src/features/messages/ui/useComposerPasteHandler.ts** and
**useMentionSendFlow.types.ts**
Keep the composer implementation focused by extracting paste and
send-flow boundaries.
**desktop/src/shared/lib/keyboard-shortcuts.ts**
Documents `Cmd+Shift+M` / `Ctrl+Shift+M` as the agent-address shortcut.
**desktop/tests/e2e/persistent-agent-audience.spec.ts**
Exercises persistence, selection, removal, sends, failures, restored
drafts, themes, reduced motion, and narrow layouts.
</details>
---------
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
## Summary
- navigate across image occurrences that are currently rendered inside
the nearest explicit open-thread DOM scope
- preserve rendered DOM order, duplicate occurrence identity,
hidden-spoiler exclusion, preview-image entry parity, and the exact
thumbnail return target
- keep adjacent timeline messages and unrelated surfaces as separate
galleries
- make Copy and Download follow the current gallery item, including when
a link-preview image opens the gallery first and navigation reaches
Markdown media
## Scope contract
This is intentionally a **rendered-media gallery only**. Gallery
membership comes from mounted image triggers under the nearest explicit
thread scope. Collapsed or unloaded descendants are excluded until the
thread UI renders them. There is no canonical thread fetch, descendant
projection, depth override, or synchronous message reparse in this
change.
`ChannelPane.tsx` remains unchanged from `main`;
`useSearchHighlightProps` and both timeline/thread prop spreads are
preserved.
## Verification
- rebased onto `origin/main` at
`113a33b7e49b7173ee1767c49ef2f49c63803034`
- `git diff --check`
- differential file-size check
- full Desktop check (Biome plus text/pubkey policy checks; only
pre-existing informational diagnostics)
- Desktop TypeScript typecheck
- Desktop production build and rebuilt E2E build
- full Desktop JS units: `5,476/5,476` passed
- full Desktop Tauri workspace tests passed (one documented native
performance test ignored)
- rebuilt image gallery Playwright spec: `14/14` passed
- search-highlight smoke cases: `5/5` passed
- reduced diff: 6 files, `+417/-41`; production `+78/-41`, E2E `+339/-0`
The four added E2E cases cover cross-message thread navigation and
return targeting, rendered-only exclusion of collapsed descendants,
preview-first current-item actions, and adjacent-message isolation.
---------
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>