Skip to content

fix(channels): multi-tenant ThreadKey + sweep_expired wiring (#3414 follow-up)#3419

Closed
houko wants to merge 2 commits into
mainfrom
fix/3414-multitenant-threadkey
Closed

fix(channels): multi-tenant ThreadKey + sweep_expired wiring (#3414 follow-up)#3419
houko wants to merge 2 commits into
mainfrom
fix/3414-multitenant-threadkey

Conversation

@houko

@houko houko commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Why

Follow-up review feedback for #3414 (thread ownership). Two real bugs and one documentation gap surfaced after merge:

  1. Multi-tenant key collision (security). ThreadKey was (channel, thread) only. Two Slack workspaces (account A vs B) producing the same thread_ts collided in the registry — account A's claim shadowed B's distinct conversation. In a multi-workspace install this lets one workspace's thread-ownership decisions leak into another's routing.

  2. Unbounded registry growth (quality / DoS). sweep_expired was implemented but had no caller. decide evicted only on access, so a daemon in a Slack workspace with churning thread_ts accumulated stale entries until restart. Registry grew without bound until the process died.

  3. REST push_message bypass clarification. push_message (cron / workflow / agent_send / REST /push) intentionally does not consult the registry — caller has already chosen the sender, no routing decision to gate. Added a doc-comment so future readers don't mistake the asymmetry for a bug.

What

  • ThreadKey { channel, account_id: Option<String>, thread } — constructor takes (channel, account_id, thread). Empty account_id normalizes to None so single-tenant traffic keeps a stable key.
  • BridgeManager::ensure_thread_ownership_sweep_started — armed lazily on the first start_adapter call (so the sync constructor stays runtime-free for unit tests). Runs a 60-second tokio::time::interval tied to shutdown_rx.
  • Doc-comment on push_message explaining the bypass and pointing to a future opt-in respect_thread_ownership parameter if user-visible pain shows up.

Tests

Three new tests; all pass:

  • thread_ownership::tests::distinct_accounts_do_not_collide_on_same_thread_id
  • thread_ownership::tests::empty_account_id_normalized_to_none
  • bridge::tests::multi_tenant_account_id_flows_into_thread_key

cargo clippy --workspace --all-targets -- -D warnings clean. cargo test -p librefang-channels --lib --no-fail-fast 843 passed.

Out of scope

  • Mention-spoofing — by design ("@-mention re-claims" matches the issue spec).
  • Restart drops all claims — single-process MVP.
  • Real-dispatch integration test for the gate's order-of-operations vs RBAC / journal / auto-reply — captured in helper's doc-comment for follow-up.

Branch hygiene

feat/3334-thread-ownership (original PR #3414 branch) had this commit pushed after #3414 merged but no PR was opened. This PR cherry-picks it onto a clean main-based branch (fix/3414-multitenant-threadkey), drops an unrelated dashboard-motion commit that shadowed onto the old branch, and adds the missing CHANGELOG entry. The old branch can be deleted after this merges.

Address review feedback on #3414:

1. Multi-tenant key collision (security FIX). `ThreadKey` was
   `(channel, thread)` only; two Slack workspaces (account_id A vs B)
   producing the same `thread_ts` collided in the registry, letting
   account A's claim shadow account B's distinct conversation. New
   shape `ThreadKey { channel, account_id: Option<String>, thread }`,
   constructor takes `(channel, account_id, thread)`. Empty account_id
   normalizes to `None` so single-tenant traffic keeps a stable key.

2. Unbounded registry growth (quality BUG / security FIX).
   `sweep_expired` had no caller — `decide` evicted only on access, so
   a daemon in a Slack workspace with churning `thread_ts` accumulated
   stale entries until restart. New
   `BridgeManager::ensure_thread_ownership_sweep_started`, armed
   lazily on the first `start_adapter` call (so the sync constructor
   stays runtime-free for unit tests), runs a 60-second
   `tokio::time::interval` tied to `shutdown_rx`.

3. REST push bypass (Explore BYPASS-RISK → BYPASS-OK with explicit
   doc). `push_message` (cron / workflow / `agent_send` / REST `/push`
   outbound) intentionally does **not** consult the registry: the
   caller has already chosen who is sending, so there is no routing
   decision to gate. Added a doc-comment explaining the asymmetry and
   pointing to the follow-up path (opt-in
   `respect_thread_ownership` parameter) if user-visible pain shows
   up.

4. Test coverage. Added `distinct_accounts_do_not_collide_on_same_thread_id`
   and `empty_account_id_normalized_to_none` in `thread_ownership::tests`,
   plus `multi_tenant_account_id_flows_into_thread_key` in
   `bridge::tests` so the helper actually exercises the new metadata
   read. The bridge-side helper still mirrors the gate's logic — the
   acknowledged debt of a real-dispatch integration test (drives
   `dispatch_message` end-to-end with a stub `ChannelBridgeHandle`) is
   captured in the helper's doc-comment for follow-up.

Pre-flight: `cargo clippy --workspace --all-targets -- -D warnings`
clean, `cargo test -p librefang-channels --lib --no-fail-fast` 843
passed (3 new — 2 in `thread_ownership::tests`, 1 in `bridge::tests`).

Out of scope, captured in PR description: mention-spoofing as design
intent ("@-mention re-claims" matches the issue spec), restart drops
all claims (single-process MVP), real-dispatch integration test for
the gate's order-of-operations vs RBAC / journal / auto-reply.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions github-actions Bot added the size/M 50-249 lines changed label Apr 27, 2026
@github-actions github-actions Bot added area/docs Documentation and guides area/channels Messaging channel adapters labels Apr 27, 2026
`tokio::time::interval` defaults to `Burst` — if the sweep task is
paused past a tick deadline (long GC, suspended runtime, OS sleep),
the next `tick().await` returns immediately and `select!` keeps
draining backlogged ticks until the schedule catches up. Each
catch-up tick runs another `sweep_expired`, producing a burst of
near-zero-cost-but-noisy debug logs.

`sweep_expired` is idempotent so this isn't a correctness bug, but
`MissedTickBehavior::Delay` resets the schedule to a fresh 60s
cadence after a stall, which is closer to the intent ('sweep roughly
once a minute, not 'sweep N times in a row to make up for lost
time').
@houko

houko commented Apr 27, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #3420 — the same fix landed there with MissedTickBehavior::Delay added back and the CHANGELOG entry. Closing this one. The branch fix/3414-multitenant-threadkey will be cleaned up after #3420 merges.

@houko houko closed this Apr 27, 2026
@houko
houko deleted the fix/3414-multitenant-threadkey branch April 27, 2026 15:16
houko added a commit that referenced this pull request Jun 16, 2026
… groups (#5323) (#6127)

* feat(channels,kernel): per-conversation agent routing for multi-agent groups (#5323)

Route a group message that names a specific non-default agent to that agent
instead of the channel default, and keep the conversation sticky to it.

Two addressing paths feed a new agent resolution step ahead of the
binding/default chain: an explicit @-mention the adapter surfaces in
metadata["mention_names"] (resolved against agent names/handles), and a
non-default agent's channel_overrides.group_trigger_patterns alias matching
the text. Alias disambiguation uses a deterministic per-agent attention
scorer (librefang_channels::bridge::best_alias_match) reusing the compiled
regex cache, which the dispatch path also consults before the previously
non-deterministic "first available" fallback (closes layer (c) of #5294).

ThreadKey grows three optional slices — account_id (multi-tenant, the
unlanded #3419/#3420 fix), chat_id, and peer_id (per-sender stickiness) —
all defaulting to None so the historical (channel, thread) key is
reproduced byte-for-byte. A topic-less group now claims by chat id instead
of bypassing the registry, and a live claim makes a follow-up sticky to the
same agent without a fresh mention; an explicit address re-claims for the
new agent, preserving #3334 TTL semantics.

New per-channel knobs conversation_ownership_ttl_seconds (default 600) and
conversation_ownership_include_dms (default false). The Telegram / Discord /
Slack / Matrix sidecar adapters surface mention_names and a per-group
sender_user_id so the bridge can route and scope per peer.

Built on #5671 PR-A's agent/available_agents schema; additive and backward
compatible. Verified: cargo check --workspace --lib, clippy -D warnings,
cargo fmt --check, cargo test -p librefang-channels (493) and
-p librefang-types config, and the Python sidecar suites (288).

* docs(channels): reflow new doc comments to one sentence per line

Doc comments added in the multi-agent routing change were mid-sentence
wrapped at ~80 columns. The prose convention (CLAUDE.md) requires no
column limit — break only at sentence boundaries so a single-word edit
doesn't reflow an entire paragraph.

Affected: ThreadKey struct/builder docs, best_alias_match, RouteResolution,
mention_names, agent_allows_channel, resolve_addressed_agent,
build_thread_key, conversation_ownership_allows, module-level doc.
No logic change; rustfmt passes.

---------

Co-authored-by: Evan <[email protected]>
Co-authored-by: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/channels Messaging channel adapters area/docs Documentation and guides size/M 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant