Skip to content

BUZZ-271: Bound live-session revalidation - #8241

Open
bradseiler wants to merge 20 commits into
seiler/invite-history-repairfrom
codex/buzz-271-draft-prep
Open

bradseiler wants to merge 20 commits into
seiler/invite-history-repairfrom
codex/buzz-271-draft-prep

Conversation

@bradseiler

@bradseiler bradseiler commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Close admitted root and huddle-audio sockets when Redis misses a ban or membership change. Recheck durable state with bounded batches, concurrency, deadlines and a fail-closed database-error grace period. Timeouts retain the existing session policy.

Preserve verified owner delegation through scans, including NIP-FI token-owner admission when NIP-OA is disabled. Both real root and audio enrollment paths record that admission source. The new database regression admits both paths, scans them and then removes the owner without a Redis notification; it does not exercise JWT verification or transport.

Recovered corrections retry transient root AUTH policy-read failures, prevent stale-target scan starvation and report the captured owner safely. Common mesh and fixture fixes already merged on main are no longer duplicated in this layer.

Related issue

BUZZ-271. Stack: #8529 → #8241 → #8239 → #8235. Invitation revocation #8237 and workflow-owner bans #8242 are merged.

Testing

Current head d91fe8c485821dafe6b0296ba62691254352fb63 in native stack 8725, rebased by Brad through GitHub onto main 44b3c4823. Brad owns further ref updates.

The explicit AuthorizationUnavailable access-log label is authorization_unavailable; its close frame is unchanged. The selected outage-grace regression reads the result through production server_close_reason.

Independent review clears this correction and the restack. All 20 carried patches are unchanged in range-diff. The prior candidate passed relay clippy and 14 live-authorization/close-reason tests; a label mutation failed. That evidence carries over by unchanged patch review, and current-head hosted runtime CI is now terminal and green.

Historical private-relay tests are not current-head runtime acceptance. Huddle recovery during a continuing writer outage and human cross-pod verification remain separate follow-ups.

Normal pre-push hooks passed on integrated head 7ee3cf92f59a96349099d48fc115e5b8bb6a9716, including the branch-skew guard, unit tests, desktop checks/tests, Tauri checks, mobile checks/tests and file-size checks. These hooks validate the checked-out top tree; hosted CI remains the gate for each individual PR head.

Current-head hosted CI is terminal and green, including runtime unit and PostgreSQL jobs. McKeon independently cleared all four current heads: carried patches range-diff unchanged (8/20/21/6), with no overlap between the newer main delta and stack files. The separate main-only security-review authorization check remains failed for this stacked base; no bypass or retarget was performed.

Historical local validation at integrated head 7ee3cf92f5: buzz-db/buzz-relay clippy passed with all targets and warnings denied; package tests passed 2,035/2,035; desktop build, Tauri check/tests, web build and mobile tests passed. The full PostgreSQL lane finished 1,503/1,505, with two failures still unresolved in that run: cluster_global_sample_writer_fails_closed_when_activity_is_masked and hanging_redis_peer_times_out_before_the_health_listener_binds. The runner constructed a postgresql:// URI; the fence test requires literal postgres://, explaining its prefix panic. The Redis test exceeded its scheduling bound at 8.03 seconds; that local failure remains unexplained. No full local just ci pass is claimed. These receipts remain attributed to the historical head.

The separate security-review workflow only authorizes PRs targeting main. Do not retarget or retry this stacked PR to bypass that restriction.

Human review and verification remain required. Existing review decisions are preserved. No merge, deployment, production repair or human acceptance was performed.

Generated with Codex

TheSentinel454 added a commit that referenced this pull request Oct 29, 2026
## Summary

A workflow whose saved owner is banned from the community can no longer
create runs through any trigger: webhook, channel-event, cron, or signed
command trigger. Every one of those doors already calls
`WorkflowEngine::check_owner_authority`, which re-checks the owner's
channel role. That gate now also reads the owner's authoritative
restriction state. A ban denies the run, and a failed lookup fails
closed. Unbanning restores admission.

On the webhook door the denial is the same generic 404 as before, and no
run is queued. On the event and cron paths, a ban denial is logged at
WARN at most once per workflow every ten minutes, and at DEBUG in
between. A failed lookup and every other denial still warn every time.
The handler-local ban lookup in `api/bridge.rs` is removed because the
shared gate covers it.

This is a point-in-time check: a ban committed after the lookup can race
with run creation. It does not cancel existing runs. Community timeouts
do not block runs in this change.

This PR also carries the shared mesh receive fix needed for independent
validation (see Shared carry).

### Related

BUZZ-272.

- Invitations: [#8237](#8237).
- Route coverage: [#8239](#8239).
- Live-session revalidation:
[#8241](#8241).
- Staging runbook: [#8235](#8235).

### Testing

Postgres lane:
- `on_event_denies_run_while_owner_banned`: a banned owner's
channel-event trigger creates no run, and after unban it creates one.
- `cron_tick_skips_banned_owners_without_consuming_the_claim`: one tick
skips a banned owner and an agent whose human owner is banned, while an
unbanned workflow on the same tick still fires. After unban, a tick for
the same scheduled instant fires both, so the denial did not consume
their scheduled-fire claim.
- `on_event_denies_run_when_restriction_lookup_fails`: the event path
fails closed when the restriction read errors, and fires once the read
succeeds.
-
`webhook_admission_enforces_owner_ban_tenant_scope_and_lookup_failure`:
the HTTP door returns the same generic 404 for a wrong secret, a banned
owner, a forbidden role and a failed restriction lookup, with no run
created. It also covers another tenant's allowed control and unban.

Unit lane:
- `ban_denial_warns_once_per_interval_while_other_denials_always_warn`.

Results:
- At `81c367f48`, CI passed: Unit Tests 572/572, PostgreSQL Tests
1169/1169 and Relay E2E. A local-relay E2E also passed:
  - unbanned webhook: 202 and one run;
  - banned webhook: generic 404 and no run;
  - banned event trigger: no run;
  - after unban: one run.
- At `72c8d8bbc` (merged with main), CI passed: Unit Tests (including
all 171 buzz-workflow unit tests), PostgreSQL Tests 1227/1227 (all four
ban tests listed by name), and both `Build (linux/amd64|arm64)` image
jobs, which build with `--locked`. A Blox run at the same head also
passed `cargo metadata --locked`, fmt and clippy, and the buzz-workflow
unit tests passed there with no database reachable.
- Not covered: the signed trigger has no dedicated test. It calls the
same gate, which was confirmed by reading the code.

### Shared carry

#8237, #8239, #8241 and #8242 each carry the same mesh-receive fix, boot
and scraper regressions, and push-lease fixture correction. Whichever
lands first owns them, and the others drop them on their next main
merge.

### What got simpler

The ban check now lives in the one gate every run-creation door already
shares, so there is one fewer handler-local branch. Cron, event and
signed triggers get the same protection without per-door code. The cron
loop body is now `cron_tick(now)`, so a test can drive one tick without
the 60-second sleep.

buzz-workflow's infra-free unit tests ran in no CI lane. `just
test-unit` and its cargo fallback in `scripts/run-tests.sh` now run
them.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: seiler <seiler@squareup.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@bradseiler
bradseiler force-pushed the codex/buzz-271-draft-prep branch from 541284c to c0c22bb Compare October 30, 2026 01:23
@bradseiler
bradseiler changed the base branch from main to seiler/invite-history-repair October 30, 2026 01:23
@bradseiler
bradseiler added this pull request to stack #8725 October 30, 2026 01:23
@bradseiler
bradseiler marked this pull request as ready for review October 30, 2026 01:36
@bradseiler
bradseiler requested a review from a team as a code owner October 30, 2026 01:36

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Automated review at exact head c0c22bbf808eaed1042d556867415ec813237c2a (base 425d89b4d412adce216e5f58bc985743b0da311b). Requesting changes for two blocking findings, both about how the scan behaves when the writer is slow.

IMPORTANT (correctness): a timed-out batch keeps its writer connection busy for up to two minutes. revalidate_live_authorization_sessions (state.rs:979) wraps each batch in a 1-second tokio::time::timeout, but live_authorization_state_batch (live_authorization.rs:71) runs on an ordinary pooled writer connection with no statement-level timeout. When the wrapper fires, the future is dropped and the PoolConnection goes back through sqlx-core 0.9's Floating::return_to_pool. That runs an untimed ping() (pool/connection.rs:314), which has to wait for the abandoned statement to finish first. The writer's session statement_timeout defaults to 2 minutes (buzz-db/src/runtime/mod.rs:750). So a slow writer turns each timed-out batch into a connection held for up to 120 s, and the next scan 5 s later starts four more. The documented "at most 4 queries at a time" bound covers the futures, not the database work. Under the conditions this timeout exists for, the scan can starve the serving writer pool. Fix: bound the statement on the server, e.g. run the batch in a transaction with SET LOCAL statement_timeout just under the wrapper deadline. Also make a checkout that is timed out or cancelled by the scan close its connection (with a bounded close) instead of returning it to the pool.

IMPORTANT (design): closing sessions on a failed lookup makes writer saturation worse. Once a target's last good check is more than 10 s old, the failure branch (state.rs:1141) closes every session it holds, and it does so for pool timeouts and query timeouts as well as hard outages. While the writer is down, no new ban or removal can commit. So in an outage, failing closed only protects against a revocation committed in the few seconds before the outage whose Redis fan-out was also missed. The cost is that every root and audio socket on every pod closes together. In the saturation case the loop compounds itself: scan timeouts close sockets without jitter, the clients reconnect together, and each admission now makes an extra writer read (the captured-owner membership read in final_admission_check). That puts more load on the writer that was already the problem. ARCHITECTURE.md acknowledges the reconnect herd but doesn't mitigate it. Please either keep sessions through lookup failures and apply the verdict on the next successful snapshot, or limit fail-closed to a much longer outage window and jitter the closes. Whichever you pick, state the security/availability tradeoff in ARCHITECTURE.md.

MINOR (non-blocking):

  • relayAuthPolicy.ts:34 copies four relay error strings into SERVER_UNAVAILABLE_AUTH_REPLIES, with no shared constant or contract test. If a relay message is reworded, the desktop silently goes back to capped retries.
  • The (None, true) invariant violation in final_admission_check (auth.rs:323) returns "internal error checking relay membership". The desktop now retries that message forever, even though here it signals a server bug rather than an outage.
  • admission_db_error_class (auth.rs:236) is identical to live_authorization_db_error_class (state.rs:1218); keep one.
  • The useKnownAgentPubkeys.test.mjs observer-race fix doesn't touch this feature and would be cleaner in its own PR.
  • ARCHITECTURE.md:283 records workstation timings (70 ms, 129 ms) that will go stale. The batch, concurrency, and deadline bounds are the durable facts.

Checked and fine: users and community_bans are keyed by (community_id, pubkey), so batch rows are unique per target and the incomplete-result guard won't misfire. Scans can't overlap (MissedTickBehavior::Skip plus a sequential await). An admitted owner-derived session always has its captured owner equal to the stored owner, because owner_link_denial refuses admission when the link doesn't land. Every predicate is community-scoped. The test database URL edits are already on main. We ran the PR's live_revalidation E2E at this head against an isolated Postgres and Redis. Member and NIP-OA owner-linked root and audio sockets closed with 1008 access revoked about 4.5 s after database-only bans, and the clear bystander stayed usable. With the ban table made unavailable, the remaining sockets closed with authorization unavailable about 10 s later.

matt2e added a commit that referenced this pull request Nov 2, 2026
Units 2-5 of the 23-30 Oct desktop Playwright flake audit. All four are
test-only. origin/main (1eb67bb, now including #8680) hasn't touched
these specs since fae3f03. Of the open PRs, #8622 adds an unrelated
test at the end of navigation.spec, and #7812 (stale since 10-06) adds
tests elsewhere in three of them. #8680 didn't touch them, but it
carried navigation:472 mode A's product half (eb88a98), which is not
part of this commit. The code confirms each cause, so none is dropped.

navigation.spec.ts:472, "composer Buzz chip labels wrap without
orphaning their icons"

Runs: 11 jobs in the window, 6 after the earlier fixes, and 4 of the 7
main runs that carry every fix. Run 40970053997 (Smoke shard 7) failed
all three attempts.

Mode A: jobs 122971493553, 123066839967, 123572467962, 123819180597
and retry 2 of 123784825042. Signature: fragmentStep is null at :634.

Cause: rows key on renderKey = localKey ?? id. On main,
reconcileChannelWindowMessages keeps only pending sends and thread
replies from the cache, so a head refetch or live echo after the ack
drops the localKey. The key flips and the row remounts, losing the
220 px width the test set in an earlier evaluate.

Change: re-land the test half of closed PR #6852, which never merged.
Set the width and measure in one expect.poll, and set the width again
before each fragment measurement.

Mode B: jobs 123573056202, 123784199542, 123784825042 (twice),
123804428893, 123818728963, 123819543461, and 122924164125 (the
Infinity variant). Signature: the |tooltipCenter - cursorX| < 1 poll
at :667 receives 81.35 or Infinity. In failing attempt 40970053997 the
post-send settle (scrollTop 200, 296, 260, 302) took its last step
21 ms or less before focus, and the tooltip read closed 12 ms after
it. In the passing retry 40980513346, scrolling had stopped at least
15 ms before focus.

Cause: Radix Tooltip 1.2.16 closes when an ancestor of its trigger
scrolls (a capture-phase window listener). Reopening by pointer needs
a pointermove while hasPointerMoveOpenedRef is false, then the chip
provider's 500 ms delay. Any close clears that timer, and only
pointerleave resets the flag. useInlineTooltipPosition moves the
content only on pointermove and placement. So a scroll after focus can
leave the tooltip closed, or open without the fragment offset, and the
spec's single mouse.move never recovers it.

Change: before focusing the chip, poll until the timeline sits at its
floor (scrollHeight - clientHeight - scrollTop <= 1) at the start of
one poll, is still there two animation frames later, and its scrollTop
hasn't moved. Waiting only for scrollTop to hold for two frames isn't
enough: a timeline whose settle hasn't started yet reads as still, and
the failing run's last step landed under two frames before focus. The
settle's end state is the floor. useVirtualizedBottomSettle scrolls
only when Virtua's ResizeObserver reports a layout change, and after
the send and the 220 px wrap the timeline sits short of the floor
until the settle catches up. Focusing the chip and moving the pointer
over it don't change layout. The send's ack does. It removes the row's
"Sending…" status, and the confirmed row can lose its header, since a
pending row is never shown as a continuation (MessageRow.tsx:167). The
send's bottom intent is still armed, so that re-pins the timeline. So
before the floor poll, wait for the sent row's message-send-status to
reach count 0. If the ack has already landed, that wait is a no-op. The
standalone toBeVisible after focus is gone. positionOverFragment
asserts data-state matches /open/ instead, because a closing tooltip
stays visible through its exit animation.

No retry. Wrapping the measure, move and check in toPass, leaving and
re-entering the chip on each attempt, would also pass, but it retries
around the race instead of removing it. The ack and floor waits close
the scroll window. The only other way to scroll under the tooltip was a
remount that drops the row's 220 px width (mode A), and #8680's
retainAcknowledgedLocalKeys, on this branch's base (1eb67bb), keeps
the row's key. So positionOverFragment is a single pass, as on main,
plus mode A's width re-set: set the width, measure, move, assert open,
then poll the centre with the default timeouts.

huddle-transcription.spec.ts:637, "animates the responding agent with
the shared speaker ring"

Runs: 9 jobs, always Smoke shard 4 attempt 1. Main: 39896948198,
40718743531, 40732061485, 40840949170, 40906253761, 40969965981 and
40983029157. PRs: #8715, #8666. Signature: toBeFocused() at :697
reads inactive for all 14 polls over 5 s. At +3.0 s in the failure
video (40983029157, job 123827818084) the caret is in
"Message #huddle".

Cause: useComposerAutofocus re-runs when `focus` changes. That's
focusEnd, which depends on the editor, so it runs again once the
editor mounts. It backs off only for input, textarea, select and
contentEditable focus, so it takes focus from the focused trigger.

Change: wait for message-input to be focused before focusing the
trigger. The same spec already does this at :855.

scroll-history.spec.ts:1289, "fast middle-page scroll settles with
continuous mounted coverage"

Runs: 9 jobs in the window. After the fixes: main 40980404335 (Smoke
shard 9, job 123819180518), two attempts on #8720, and the
brain/native-account-setup branch. Signature: the viewportCoverage
poll expects < 100 and receives 200. The failure screenshot shows the
timeline at the floor. The 200 px is the bottom spacer
(--composer-overlay-height) under the last row.

Cause: TimelineMessageList arms useVirtualizedBottomSettle when the
channel first positions. Its ResizeObserver then pins to the floor
until listenForReaderScroll input or an accepted target cancels it.
handleScroll deliberately doesn't cancel, and a prepend delta doesn't
settle, so the intent survives the prepend and the spec's scrollTop
writes. Virtua's re-measure after the jump pulls the list back down.

Change: before the fast scroll, hover the timeline and wheel up 120 px.
Then poll until it is more than 32 px from the floor (the timeline's
at-bottom band) and scrollTop holds for two frames, so the writes stay
the last scroll.

custom-emoji.spec.ts:370, "reacting with a custom emoji renders via
the loopback media proxy"

Runs: main 40787609859 (10-28), main 40983029157 (Smoke shard 3, a
run with every fix), and #8241 (agent and auth files only). Signature:
the poll at :453 expects the inline "Add reaction" opacity "0" and
receives "1". The error context marks button "Open reactions"
[active].

Cause: the non-modal Radix Popover returns focus to its trigger when
the picker closes. FocusScope does that in a setTimeout(0) after the
content unmounts, and the content unmounts after its 100 ms exit
animation. That can land after the spec's blur. The action bar is
hidden by opacity only, so the trigger takes focus, and
group-focus-within/message keeps the button visible.

Change: wait for the "Open reactions" trigger to be focused, then
blur. The note suggested waiting for em-emoji-picker to reach count 0.
That still leaves the setTimeout window open. Focus reaches the
trigger only after unmount, so this wait covers both.

Verification

Not run locally: the Flake Check lane will repeat the touched specs.
pnpm typecheck and pnpm check pass. biome's 3 warnings are in
untouched files. An ad hoc tsc ran over the four specs, using the
desktop tsconfig with only those files, and over HEAD's copies of
them. Both gave the same error counts: navigation 7, huddle 17,
scroll-history 0, custom-emoji 2. The messages are identical once
line numbers are stripped, and all are pre-existing: no Node types,
findLast against the ES2022 lib, and the leadingBounds narrowing at
navigation:576. With the floor wait in place and the retry dropped,
navigation still gives the same 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
wesbillman pushed a commit that referenced this pull request Nov 2, 2026
…8743)

## Summary

Units 2–5 of the 23–30 Oct desktop Playwright flake audit: four E2E
specs that raced scroll settles and focus. All four fixes are test-only.
This is one of six PRs split from the reviewed `flakey-tests` branch
(siblings below).

### `navigation.spec.ts:472` "composer Buzz chip labels wrap without
orphaning their icons"

11 jobs failed in the window, 6 of them after the earlier fixes,
including 4 of the 7 main runs that carry every fix. Run 40970053997
(Smoke shard 7) failed all three attempts.

- **Mode A** (jobs 122971493553, 123066839967, 123572467962,
123819180597 and retry 2 of 123784825042): `fragmentStep` is null at
:634. Rows key on `renderKey = localKey ?? id`, and a head refetch or
live echo after the ack drops the `localKey`. The row remounts and loses
the 220 px width the test set earlier. **Change:** re-land the test half
of closed #6852. Set the width and measure in one `expect.poll`, and set
the width again before each fragment measurement.
- **Mode B** (jobs 123573056202, 123784199542, 123784825042 twice,
123804428893, 123818728963, 123819543461, and 122924164125): the
tooltip-centre poll at :667 receives 81.35 or Infinity. Radix Tooltip
closes when an ancestor of its trigger scrolls. In the failing attempt,
the post-send settle took its last step 21 ms or less before focus.
**Change:** wait for the sent row's `message-send-status` to clear. Then
poll until the timeline sits at its floor and holds there for two
animation frames, and only then focus the chip. `positionOverFragment`
asserts `data-state` is open. There is no retry loop around the race.

### `huddle-transcription.spec.ts:637` "animates the responding agent
with the shared speaker ring"

- **Runs:** 9 jobs, always Smoke shard 4, attempt 1. On main:
39896948198, 40718743531, 40732061485, 40840949170, 40906253761,
40969965981 and 40983029157. On PRs: #8715 and #8666. `toBeFocused()` at
:697 reads inactive.
- **Cause:** `useComposerAutofocus` runs again once the editor mounts
and takes focus from the focused trigger.
- **Change:** wait for `message-input` to be focused before focusing the
trigger, as the same spec already does at :855.

### `scroll-history.spec.ts:1289` "fast middle-page scroll settles with
continuous mounted coverage"

- **Runs:** 9 jobs. After the fixes: main 40980404335 (job 123819180518)
and two attempts on #8720. The `viewportCoverage` poll expects < 100 and
receives 200, the bottom spacer.
- **Cause:** the bottom-settle intent armed when the channel first
positions survives the prepend and the spec's `scrollTop` writes.
Virtua's re-measure then pulls the list back to the floor.
- **Change:** hover the timeline and wheel up 120 px first, since reader
input cancels the intent. Then poll until the timeline is more than 32
px from the floor and steady for two frames.
- The commit message calls the cancelling listener
`listenForReaderScroll`, a name introduced by sibling #8742. On `main`,
the same wheel listener is inline in `useVirtualizedBottomSettle`, so
this fix doesn't depend on that PR.

### `custom-emoji.spec.ts:370` "reacting with a custom emoji renders via
the loopback media proxy"

- **Runs:** main 40787609859 and 40983029157 (Smoke shard 3), and #8241.
The inline "Add reaction" opacity reads "1", not "0".
- **Cause:** the non-modal Radix Popover returns focus to its trigger in
a `setTimeout(0)` after its 100 ms exit animation. That can land after
the spec's blur.
- **Change:** wait for the "Open reactions" trigger to be focused, then
blur.

### Related issue

None found. Sibling #8744 also edits `navigation.spec.ts`, in a separate
hunk. The two merge cleanly in either order. #8622 adds an unrelated
test at the end of `navigation.spec.ts`, and #7812 (stale since 10-06)
adds tests elsewhere in three of these files.

### Testing

Not run locally, because Playwright bogs this machine down. Flake Check
will repeat the touched specs. `pnpm typecheck` and `pnpm check` pass.
An ad hoc `tsc` run over the four specs found the same pre-existing
errors as on their `main` copies (navigation 7, huddle 17,
scroll-history 0, custom-emoji 2).

Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.

### Readiness

An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. `buzz-review-completed` is not added, because the human
test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet.

### Siblings

- #8739 ci: Hermit fetch deadline and sccache fallback
- #8740 test(relay): thread_window aux-page hold and `api::media`
Postgres tests
- #8741 test(mobile): RunnerTests title fade and alert wait
- #8742 fix(desktop): deep-linked message hold (product change)
- #8744 test(desktop): five settle classes and remote-owned-mentions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
matt2e added a commit that referenced this pull request Nov 3, 2026
message-target-highlight.spec.ts:79 is the most frequent unfixed
desktop flake. It failed in all 7 main runs whose SHA carries every
earlier flake fix (40969965981, 40970005965, 40970053997, 40980258899,
40980404335, 40980513346, 40983029157), and in 26 jobs since those
fixes landed, on main and on PRs #8235, #8239, #8241, #8666, #8680,
attempts failed. Only the two cases that pick mounted[2] fail: "same
channel, row loaded off screen" (15 jobs) and "other channel, row
further up" (11 jobs). The target scrolls in and flashes, then the
timeline lands on the newest message, so `stillOnScreen` at :179 reads
false (twice it was `flashedOnScreen` at :153).

Cause

The triage note blamed the thread panel opening. The failure videos
from run 40983029157 (Smoke shard 6) show that is a precondition, not
the trigger:
- Other channel: the panel opens at +0.36 s, the target flashes at
  +0.7 s, the older page prepends by +1.04 s, the target is still on
  screen at +1.47 s, and the timeline sits at the exact floor by
  +1.97 s.
- Same channel: the panel and the flash land in the same frame. The
  snap follows about 0.5 s later, with the load-older spinner still
  showing.

The panel opening never moved the target. A later floor report did:
TimelineMessageList's onScroll reporting within 32 px of the bottom,
during or after the older-page load that only a mounted[2] target sets
off. onVirtualizerAtBottomStateChange(true) reset the anchor to
at-bottom and set virtualizerAtBottomRef. With the panel open, the
split-panel settle, the viewport-resize settle or the next append
settle then pinned the timeline to the floor. The exact-floor landing
points at our own settleAtBottom rather than restored scroll. Which
Virtua layout pass emits the floor report is still unidentified.

Change

Accepting a virtual route target now sets targetHoldRef in
useAnchoredScroll. Only the two targetMessageId paths set it, on first
mount and in the later target effect, by passing holdTarget to
scrollToMessage. The public scrollToMessage, which MessageTimeline's
oldest-unread pill and find-in-channel stepping call, doesn't hold. It
also replaces an earlier hold, so a find jump after a deep link
follows the floor as on main. While the hold is set:
- shouldSettleForSplitPanel and shouldSettleVirtualizedViewportResize
  return false;
- a floor report still updates isAtBottom and the new-message count,
  but keeps the message anchor, so live appends don't settle either;
- a commit that changes the newest message sets isAtBottom false,
  clears virtualizerAtBottomRef and adds the rows after the previous
  newest one to the new-message count. Virtua reports bottom state
  only from scroll events, and an append below the viewport emits
  none. Without this, a floor report left isAtBottom true, so
  MessageTimeline hid the Jump to latest / N new pill while rows piled
  up out of sight (Review-Proven Rule 6). A realistic trigger is a
  mention or search hit on one of the last few messages in a busy
  channel: scrollToIndex clamps at the floor and emits that report.
  Clearing virtualizerAtBottomRef means a later release doesn't snap
  past the new rows either. The hold only acts on an arrival, so a
  reader at the floor with nothing new sees no pill. Counting rows
  rather than the length change matters: a cold target is merged in
  older than the head window, and the history page that fills the gap
  keeps both ends of the list. classifyTimelineMessageDelta calls that
  an append, but nothing arrived below, so it raises no pill. Neither
  does deleting the newest message;
- viewport reflow re-centres the target through
  virtualScrollToMessage, from the viewport-resize observer and when
  the split panel opens or closes. Rows rewrap around the target
  without a scroll event, so the target can drift, and a floor report
  from before the reflow can keep isAtBottom true while the newest
  rows drop below the fold. The re-centring scroll refreshes the
  bottom state. Virtua's scrollToIndex cancels any earlier one still
  in flight, so a burst of resizes only restarts its measure loop.
  The split-panel call fires only on a transition, not on mount or
  when the virtualizer API registers.

The hold is released by reader scroll input (a wheel, a touch drag, a
pointerdown on the scroller itself, a scroll key aimed at it), Jump to
latest, the reader's own send, and a channel change. The input
listeners are the ones that already cancel bottom intent in
useVirtualizedBottomSettle, now shared as listenForReaderScroll. A
release at the floor restores the at-bottom anchor, so the timeline
follows live messages again. onTargetReached does not release it.

The artifacts never show the panel pushing the target off screen, so
skipping the settle is the fix aimed at the CI failure. The re-centring
keeps the target's place and the pill right after a reflow.

Known limits:
- Programmatic scrolls (focus moves, scrollTop writes) don't release
  the hold. A find or unread-pill jump replaces it. A resize after
  such a scroll (Tab focus into a lower row, say) re-centres the
  target, away from the focused row.
- ChannelPane's composer-height settle still calls scrollToBottom
  within 32 px of the floor, which releases the hold.
- Live messages that arrive while the hold is set near the floor are
  not followed until reader input, Jump to latest or the reader's own
  send. The pill shows them, with their count.
- Under the hold, only rows after the previous newest message count.
  A live message that sorts just ahead of it (a same-second timestamp)
  raises no pill until a later row lands.

Unit before/after

useAnchoredScroll.lifecycle.test.mjs replays the CI sequence: accept a
route target, clear it, open the split panel, report the floor, resize
the viewport and append a live message. It asserts no bottom settle,
and that the panel opening and the resize each re-centre the target.
Then it asserts the append shows the pill (isAtBottom false, one new
message). A scrollbar pointerdown after that releases the hold, and a
resize neither settles nor re-centres. One case per release path
asserts the resize settle resumes and the re-centring stops. One of
them is a find jump: a public scrollToMessage on a held timeline, then
a floor report. A descendant pointerdown (row interaction) keeps the
hold, and a resize still re-centres. Reader input at the floor resumes
following a live append. Under a hold, a history page filling the gap
below the target, and then deleting the newest message, leave
isAtBottom true and the count at 0. A commit that adds more history
and one live row counts 1. The split-panel and viewport-resize policy
tables gain a held row.

Mutations over the lifecycle file and both policy tables (29 tests,
all pass intact):
- hold never set on accept (the old behaviour): 12 fail, including
  the CI-shaped hold case
- split-panel settle ignores the hold: 13 fail
- viewport-resize settle ignores the hold: 13 fail
- floor report resets a held anchor: 12 fail (the reset anchor is
  never re-centred)
- reader input does not release: 6 fail
- Jump to latest, own send, channel change don't release: 1 each
- release skips the at-bottom resync: 1 fails
- a held append doesn't raise the pill: 2 fail
- a held append leaves virtualizerAtBottomRef set: 1 fails
- every scrollToMessage holds (this commit's first draft): 1 fails
- a public jump keeps an earlier hold: 1 fails
- the held-append branch as first drafted (any non-prepend growth,
  counted by length): 1 fails, the gap-fill case
- a newest-id gate but a length-delta count: 1 fails
- a missing previous newest counts every row: 1 fails
- no rowsBelow > 0 guard: 1 fails
- rows counted from the previous newest, off by one: 2 fail
- no split-panel re-centre: 12 fail
- no viewport-resize re-centre: 12 fail
- re-centre ignores the hold: 1 fails

pnpm typecheck and pnpm check pass. All 46 tests in the files that
import the touched modules pass.

Only hosted CI can show

Playwright was not run locally. Whether the spec now holds in Smoke
shard 6 and in Flake Check's repeats is unverified, and so is the
unidentified Virtua floor report. scroll-history.spec.ts and the
other timeline specs need the full desktop smoke run to show the
hold doesn't regress bottom-follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e added a commit that referenced this pull request Nov 4, 2026
## Summary

A product fix for the most frequent unfixed desktop E2E flake,
`message-target-highlight.spec.ts:79`. A deep-linked message now holds
its place against bottom settles until the reader scrolls. This is one
of six PRs split from the reviewed `flakey-tests` branch (siblings
below).

- **Runs:** it failed in all 7 main runs whose SHA carries every earlier
flake fix (40969965981, 40970005965, 40970053997, 40980258899,
40980404335, 40980513346, 40983029157). It also failed in 26 jobs since
those fixes landed, on main and on PRs #8235, #8239, #8241, #8666 and
#8680. Only the two cases that pick `mounted[2]` fail: "same channel,
row loaded off screen" (15 jobs) and "other channel, row further up" (11
jobs). The target scrolls in and flashes, then the timeline lands on the
newest message. `stillOnScreen` at :179 reads false (twice it was
`flashedOnScreen` at :153).

### Cause

The failure videos (run 40983029157, Smoke shard 6) show that the thread
panel opening is a precondition, not the trigger. A later floor report
moved the target: `TimelineMessageList`'s `onScroll` reported within 32
px of the bottom, during or after the older-page load that only a
`mounted[2]` target sets off. `onVirtualizerAtBottomStateChange(true)`
reset the anchor to at-bottom. Then the split-panel settle, the
viewport-resize settle or the next append settle pinned the timeline to
the floor. Which Virtua layout pass emits the floor report is still
unidentified.

### Change

Accepting a virtual route target sets `targetHoldRef` in
`useAnchoredScroll`. Only the two `targetMessageId` paths set it, via
`holdTarget`. The public `scrollToMessage`, which the oldest-unread pill
and find-in-channel use, doesn't hold, and it replaces any earlier hold.
While the hold is set:

- The split-panel and viewport-resize settles are skipped.
- A floor report still updates `isAtBottom` and the new-message count,
but keeps the message anchor, so live appends don't settle either.
- A commit that changes the newest message sets `isAtBottom` false and
clears `virtualizerAtBottomRef`. It adds the rows after the previous
newest one to the new-message count. So the "Jump to latest" / "N new
messages" pill still appears for rows below the fold (Review-Proven Rule
6). A history page that fills the gap below a cold target raises no
pill, and neither does deleting the newest message.
- Viewport reflow re-centres the target through
`virtualScrollToMessage`. It runs from the resize observer and when the
split panel opens or closes.

The hold is released by reader scroll input: a wheel, a touch drag, a
pointerdown on the scroller itself, or a scroll key. Those listeners
already cancel bottom intent in `useVirtualizedBottomSettle`, and this
PR shares them as `listenForReaderScroll`. Jump to latest, the reader's
own send and a channel change also release it. A release at the floor
restores the at-bottom anchor, so the timeline follows live messages
again. `onTargetReached` does not release the hold.

### Known limits

- Programmatic scrolls (focus moves, `scrollTop` writes) don't release
the hold, but a find or unread-pill jump replaces it. A resize after
such a scroll re-centres the target.
- ChannelPane's composer-height settle still calls `scrollToBottom`
within 32 px of the floor, which releases the hold.
- Live messages that arrive under a hold near the floor aren't followed
until reader input, Jump to latest or the reader's own send. The pill
shows them, with their count.
- Under the hold, only rows after the previous newest message count. A
same-second row that sorts just ahead of it raises no pill until a later
row lands.
- Agent review raised two optional gaps, and scrolling recovers from
both:
- If the previous newest message is deleted and a new row arrives in the
same deferred commit, 0 rows are counted.
- A thread reply sent with "also send to channel" doesn't arm the bottom
intent, so it shows as "1 new message" instead of being followed.

### Related issue

None found.

### Testing

`useAnchoredScroll.lifecycle.test.mjs` replays the CI sequence: accept a
route target, clear it, open the split panel, report the floor, resize
and append a live message. It asserts no bottom settle, a re-centre on
the panel opening and on the resize, and the pill on the append. Further
cases cover:
- each release path;
- a find jump on a held timeline;
- a descendant pointerdown keeping the hold;
- a gap-fill page and deleting the newest message, neither of which
raises the pill.

The split-panel and viewport-resize policy tables each gain a held row.

Mutations were run over the lifecycle file and both policy tables (29
tests, all pass intact). Each of the 20 mutations fails at least one
test:

| Mutation | Tests that fail |
|---|---|
| Hold never set (the old behaviour) | 12 |
| Split-panel settle ignores the hold | 13 |
| Viewport-resize settle ignores the hold | 13 |
| Floor report resets a held anchor | 12 |
| No split-panel re-centre | 12 |
| No viewport-resize re-centre | 12 |
| Reader input doesn't release the hold | 6 |
| Held append raises no pill | 2 |

The commit message lists the other 12, each failing 1 or 2 tests.

`pnpm typecheck` and `pnpm check` pass. All 46 tests in the files that
import the touched modules pass.

Only hosted CI can show whether the spec now holds in Smoke shard 6 and
in Flake Check's repeats, because Playwright was not run locally. It
also has to show that `scroll-history.spec.ts` and the other timeline
specs keep bottom-follow working.

Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.

### Readiness and human check

An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. This changes desktop app behaviour, so it needs a human
in-app check before `buzz-review-completed` is added. Suggested check,
in `just dev` against a channel with a few pages of history:

1. Open a message link (a mention or search hit) to a message several
screens up, once in the same channel and once from another channel. The
message should flash and stay on screen after older history loads,
including when the thread panel opens or closes, or the window is
resized.
2. With the target still held, have someone post in the channel. The
timeline stays put, and the pill reads "1 new message".
3. Wheel-scroll to the bottom, or click Jump to latest. The next message
that arrives is followed.

### Siblings

- #8739 ci: Hermit fetch deadline and sccache fallback
- #8740 test(relay): thread_window aux-page hold and `api::media`
Postgres tests
- #8741 test(mobile): RunnerTests title fade and alert wait
- #8743 test(desktop): four specs racing scroll settles and focus
- #8744 test(desktop): five settle classes and remote-owned-mentions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@bradseiler
bradseiler force-pushed the codex/buzz-271-draft-prep branch from c0c22bb to 46fe6ba Compare November 4, 2026 16:21
@bradseiler
bradseiler force-pushed the codex/buzz-271-draft-prep branch from 46fe6ba to fb8a7f5 Compare November 4, 2026 16:27

@wpfleger96 wpfleger96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Automated review at exact head fb8a7f50dae757241d45d10d18d4648bccbacc61 (base b7d7d61c7c5a19b28ba494e98d8f71301458a853). Requesting changes: the first blocker from the review at c0c22bbf is unchanged after the rebase, and the second is only partly addressed.

IMPORTANT (correctness), still present: a timed-out batch keeps its writer connection busy for up to two minutes. live_authorization_state_batch still checks out an ordinary pooled writer connection (live_authorization.rs:71) and runs the batch with no statement-level timeout (live_authorization.rs:120). revalidate_live_authorization_sessions still bounds it only with a 1-second tokio::time::timeout (state.rs:1082). That drops the future, but the statement keeps running on the server and the connection goes back through sqlx's return_to_pool ping, which waits for it. The writer session statement_timeout defaults to 2 minutes (buzz-db/src/runtime/mod.rs:750). So on a slow writer, every timed-out batch holds a writer connection for up to 120 s, and each 5-second scan adds up to four more. The "4 queries at a time" bound limits futures, not database work, and the scan can starve the serving writer pool under exactly the conditions the timeout exists for. Fix: bound the statement on the server, for example by running the batch in a transaction with SET LOCAL statement_timeout just under the 1-second wrapper. Also make a connection the scan times out or cancels close (with a bounded close) instead of returning to the pool.

IMPORTANT (design), partly addressed: closing sessions on a failed lookup still makes writer saturation worse. ARCHITECTURE.md:331-351 now states the security/availability tradeoff, which was one part of the earlier ask. Neither mitigation is in place, though. The failure branch (state.rs:1245) still closes every session on a target after the same 10-second grace, for pool and query timeouts as well as hard outages, and the closes still aren't jittered; ARCHITECTURE.md:349 says so. The desktop's jittered reconnect spreads that one client's re-admissions, but every root and audio socket on a pod still closes in the same sweep. Every other client reconnects on its own schedule, and each re-admission adds a writer read (the captured-owner read at auth.rs:420) against the writer that is already saturated. Please either keep sessions through lookup failures and apply the verdict on the next successful snapshot, or limit fail-closed to a much longer outage window and jitter the closes across it. Update the staleness budget and its compile-time assertion to match whichever you pick.

MINOR (non-blocking), all carried over from the earlier review and still present unless noted:

  • relayAuthPolicy.ts:34 still copies four relay error strings into SERVER_UNAVAILABLE_AUTH_REPLIES with no shared constant or contract test. If a relay message is reworded, the desktop silently goes back to capped retries.
  • The (None, true) invariant violation in final_admission_check (auth.rs:441) still returns "internal error checking relay membership". The desktop retries that message forever, but here it signals a server bug, not an outage.
  • admission_db_error_class (auth.rs:353) is still identical to live_authorization_db_error_class (state.rs:1321); keep one.
  • The useKnownAgentPubkeys.test.mjs observer-race fix still doesn't touch this feature and would be cleaner in its own PR.
  • ARCHITECTURE.md:283 still records workstation timings (70 ms, 129 ms) that will go stale. The batch, concurrency, and deadline bounds are the durable facts.
  • New: ARCHITECTURE.md:316 calls the admission captured-owner read "bounded", but is_relay_member_writer at auth.rs:420 has no deadline beyond pool acquisition and the 2-minute session statement_timeout. That's the first finding again, on the admission path. Give it the same server-side bound, or drop "bounded" from the doc.

Checked and fine: live_authorization_allowed (state.rs:1270) still scopes every predicate to the bound community and refuses a captured-owner mismatch before it considers membership. Ordering targets by their oldest last-successful check (state.rs:1054) gives a target that missed a deadline priority on the next scan without letting a newer sibling socket hide it. Token-owner admission is recorded on both the root (auth.rs:1075) and audio (audio/handler.rs:1168) enrollment paths, so NIP-FI owner-derived sessions keep the path they were admitted under when NIP-OA is disabled. The source review decided this verdict, so no E2E run was done at this head.

@bradseiler
bradseiler force-pushed the codex/buzz-271-draft-prep branch 2 times, most recently from 7c29360 to cc3041d Compare November 6, 2026 23:59
@bradseiler
bradseiler force-pushed the codex/buzz-271-draft-prep branch 2 times, most recently from 767fb7d to c02857b Compare November 10, 2026 00:29
@bradseiler
bradseiler force-pushed the codex/buzz-271-draft-prep branch from c02857b to 87275fa Compare November 10, 2026 00:41
bradseiler and others added 20 commits November 9, 2026 22:21
Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit 92f13229ed3038d63f2a2d9c889bfd4c212e38e2)
Signed-off-by: Brad Seiler <seiler@squareup.com>
Restore CommunityDisconnectReason near the top of state.rs and document scan deadline and reconnect-load limitations. Authorization behavior is unchanged.

Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
(cherry picked from commit a4d1ab288d761d44eba25cc62d25b7d2858788dc)
Signed-off-by: Brad Seiler <seiler@squareup.com>
Authenticate through root and audio handlers before a durable-only ban. Removing either production enrollment call must fail its regression.

Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Exercise signed root and audio admission against an isolated running relay, durable direct-member and owner bans, a responsive bystander, and bounded database-failure closure.

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
…cture

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Keep a clear audio participant present through policy eviction so the huddle remains active for the database-failure phase. Reject bare closes with a labeled diagnostic.

Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: seiler <seiler@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Keep NIP-FI owner delegation independent of the NIP-OA flag. Adapt membership fixtures and session identities to current main, and cover final admission through owner removal for both socket routes.

Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Restore audio stored-owner failure accounting and keep live policy revalidation separate from deny-set revocation. Preserve denied-only priority over weak removal claims; cover denied and unavailable root closes.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Apply a transaction-local server deadline below the caller timeout, preserve tighter operator limits, and close cancelled or failed checkouts. Verify blocked-query cancellation, pool recovery, and writer timeout restoration on the same backend.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Spread failed-lookup closes across scans with a stable per-socket offset, keeping the conservative revocation bound at 30 seconds. Share the database query deadline and verify sibling sockets close across three sweeps.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
Preserve the explicit access-log reason after the disconnect-reason match introduced on main. Extend the selected revalidation outage journey through the production close-reason reader.

Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
Signed-off-by: Brad Seiler <seiler@squareup.com>
@bradseiler
bradseiler force-pushed the codex/buzz-271-draft-prep branch from 87275fa to d91fe8c Compare November 10, 2026 06:21

This branch has not been deployed

No deployments
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.

2 participants