feat(gateways): Implement  ListOffsets Kafka Keys backed by Iggy bridge (#4259)
diff --git a/.config/nextest.toml b/.config/nextest.toml
index 271619d..c18175a 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -56,7 +56,7 @@
 max-threads = 4
 
 [[profile.default.overrides]]
-filter = 'binary_id(iggy-gateway-kafka::bridge_iggy_integration_tests)'
+filter = 'binary_id(iggy-gateway-kafka::bridge_iggy_integration_tests) or binary_id(iggy-gateway-kafka::list_offsets_real_bridge_tests)'
 test-group = "kafka_bridge"
 
 [profile.default]
diff --git a/gateways/kafka/README.md b/gateways/kafka/README.md
index 13ad306..66b5094 100644
--- a/gateways/kafka/README.md
+++ b/gateways/kafka/README.md
@@ -2,7 +2,13 @@
 
 Foundation layer for [apache/iggy#3421](https://github.com/apache/iggy/issues/3421): a TCP listener on the Kafka wire port that decodes requests, validates scoped API keys and versions, and returns stub responses.
 
-> **Stub warning:** no API persists or reads real data yet. Produce, Fetch, and ListOffsets return retriable `NOT_LEADER_OR_FOLLOWER` (6) so clients keep data locally / retry elsewhere instead of trusting a fake success. CreateTopics does **not** create topics; valid requests return `NOT_CONTROLLER` (41). Metadata still reports requested topics as unknown. Persistence lands with the Iggy bridge (see [docs/SCOPE.md](docs/SCOPE.md)).
+> **Stub warning:** most APIs still don't persist or read real data. Produce and Fetch return
+> retriable `NOT_LEADER_OR_FOLLOWER` (6) so clients keep data locally / retry elsewhere instead of
+> trusting a fake success. CreateTopics does **not** create topics; valid requests return
+> `NOT_CONTROLLER` (41). Metadata still reports requested topics as unknown. ListOffsets is wired
+> to the Iggy bridge: with `IGGY_KAFKA_BRIDGE_ENABLED=true` it answers `EARLIEST`/`LATEST` from
+> real partition state; with the bridge off (the default) it stays a stub and answers
+> `NOT_LEADER_OR_FOLLOWER` (6). See [docs/SCOPE.md](docs/SCOPE.md).
 
 ## Run
 
@@ -111,11 +117,11 @@
 does on expiry - cannot abort it mid-flight. A timed-out call can leave that task holding the
 shared client's connection lock for up to another 30s (the SDK's own reply deadline), queuing
 every other bridge call behind it. `IggyBridge` holds one `IggyClient` with no pooling (see
-Concurrency ceiling below), so this only matters once concurrent Kafka connections share a bridge
-
-- tolerable today only because nothing calls this bridge from a live handler yet; must be resolved
-before `#3535`/`#3536`. See `IggyBridge`'s own doc comment (its rustdoc is private, so this isn't
-a followable link outside the crate - read the source at `src/bridge/iggy_bridge.rs`).
+Concurrency ceiling below) and no semaphore bounding concurrent bridge calls - no longer a
+hypothetical now that ListOffsets (`#3537`) calls it from a real handler; more pressing once
+CreateTopics (`#3538`), Metadata (`#3534`), Produce (`#3535`) and Fetch (`#3536`) add their own
+concurrent callers. See `IggyBridge`'s own doc comment (its rustdoc is private, so this isn't a
+followable link outside the crate - read the source at `src/bridge/iggy_bridge.rs`).
 
 ### Topic mapping
 
diff --git a/gateways/kafka/docs/SCOPE.md b/gateways/kafka/docs/SCOPE.md
index 30289a9..ea05eda 100644
--- a/gateways/kafka/docs/SCOPE.md
+++ b/gateways/kafka/docs/SCOPE.md
@@ -113,6 +113,33 @@
   - Iggy partitions are **0-based** (same as Kafka) — direct `partition_id` mapping, no offset conversion
   - Kafka consumer groups do **not** map onto Iggy consumer groups. Assignment stays client-side, and Iggy's group registry is used as an offset key only ([`OFFSET_STORAGE.md`](OFFSET_STORAGE.md))
   - `Partitioning::partition_id(index)` on every Produce. A Kafka producer resolves the partition before it builds the request, so `Partitioning::balanced()` has no trigger there. The `-1` default-partition-count case belongs to CreateTopics
+- [x] Real ListOffsets ([#3537](https://github.com/apache/iggy/issues/3537)): with
+      `IGGY_KAFKA_BRIDGE_ENABLED=true`, `LATEST` answers from `IggyBridge::high_watermarks` and
+      `EARLIEST` answers `0`. Any other requested timestamp (arbitrary-timestamp offset search,
+      including `offsetsForTimes`/`by_duration` resets) is unsupported - Iggy exposes no
+      per-message timestamp index - and answers `UNSUPPORTED_FOR_MESSAGE_FORMAT` (43) per
+      partition rather than a fabricated offset; non-retriable, so a Java client resolves this
+      immediately instead of spinning until `default.api.timeout.ms`.
+      `src/protocol/handlers/list_offsets.rs`, `tests/list_offsets_real_bridge_tests.rs`. With the
+      bridge off, the stub from #3421 answers `NOT_LEADER_OR_FOLLOWER` (6) as before.
+  - `EARLIEST = 0` is real *only* for a partition this bridge has never had retention trim: Iggy
+    tracks no rolling low-watermark distinct from partition creation, so once a partition is
+    old enough for retention to purge its first segment, `0` names a log-start offset that no
+    longer exists - a real consumer with `auto.offset.reset=earliest` seeks into a hole. Not
+    fixable client-side; needs the bridge to expose a real start offset. Harmless *today* only
+    because Fetch (`#3536`) is still a stub - nothing yet reads at the offset this returns.
+  - Bridge fan-out is bounded independently of `bounds_guard`'s `MAX_REQUEST_ELEMENTS` (4,096,
+    still a pre-decode ceiling, not a usability one): topic entries sharing a name are deduped to
+    one `high_watermarks` call before any bridge work starts (a name repeated across request
+    entries costs one round trip per entry), and a topic whose every partition asks for an
+    unsupported timestamp skips the call entirely. A request naming more than 100 distinct topics
+    resolves the first 100 and answers the rest `REQUEST_TIMED_OUT` (retriable) with no bridge call
+    at all, so a client that retries only its still-erroring topics narrows below the cap on its
+    own. The whole request's aggregate bridge work runs under one 20s wall-clock deadline
+    (`ListOffsets` carries no `timeout_ms` field in any version this gateway supports, so this is a
+    fixed ceiling, not a client-honored one), applied per topic rather than once around the whole
+    batch: a topic already resolved when the deadline arrives keeps its real answer, and only the
+    not-yet-started topics answer `REQUEST_TIMED_OUT`.
 - [ ] Real Metadata topology (brokers, partitions, leaders) backed by Iggy state
 
 ### `kafka-protocol` crate adoption — superseded, done differently
diff --git a/gateways/kafka/src/bridge/iggy_bridge/mod.rs b/gateways/kafka/src/bridge/iggy_bridge/mod.rs
index e110791..5d388ff 100644
--- a/gateways/kafka/src/bridge/iggy_bridge/mod.rs
+++ b/gateways/kafka/src/bridge/iggy_bridge/mod.rs
@@ -72,11 +72,13 @@
 /// so no finite value here can guarantee catching it. That same unboundedness is why this timeout
 /// cannot simply be dropped for post-connect calls either: [`IggyBridge`] holds one `IggyClient`
 /// with no pooling, so an unbounded reconnect dial with nothing here to stop it would wedge every
-/// later call on this bridge, not just the one that triggered it. Tolerable only because nothing
-/// calls this bridge from a live Kafka handler yet - closing it needs either a cooperatively
+/// later call on this bridge, not just the one that triggered it. No longer a hypothetical: since
+/// `ListOffsets` (#3537) this bridge is called from a live Kafka handler, with no semaphore
+/// bounding concurrent bridge calls. Closing the underlying gap needs either a cooperatively
 /// cancellable SDK call or a deadline on the SDK's own reconnect dial, neither of which this
-/// bridge can add from the outside. Must be resolved before #3535/#3536 share this client across
-/// concurrent connections.
+/// bridge can add from the outside; bounding concurrent bridge calls is a separate, addressable
+/// fix that becomes more pressing as `CreateTopics` and `Metadata` (#3538/#3534) add more live
+/// callers.
 const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
 
 /// Wraps a single Iggy client call in [`REQUEST_TIMEOUT`]. See that constant's doc for why every
diff --git a/gateways/kafka/src/protocol/api.rs b/gateways/kafka/src/protocol/api.rs
index adf815e..3c29a95 100644
--- a/gateways/kafka/src/protocol/api.rs
+++ b/gateways/kafka/src/protocol/api.rs
@@ -75,6 +75,13 @@
 /// `CreateTopics` stub: do not claim topics were created (no controller / no Iggy bridge).
 pub const ERROR_NOT_CONTROLLER: i16 = 41;
 pub const ERROR_INVALID_REQUEST: i16 = 42;
+/// `ListOffsets`' code for a timestamp lookup the broker cannot perform.
+///
+/// Real brokers send this for an old-message-format log; this bridge sends it for any timestamp
+/// other than the two KIP-79 sentinels, since Iggy has no per-message timestamp index at all.
+/// Non-retriable, so a Java client resolves immediately instead of retrying
+/// [`ERROR_UNKNOWN_SERVER_ERROR`] until its own `default.api.timeout.ms`.
+pub const ERROR_UNSUPPORTED_FOR_MESSAGE_FORMAT: i16 = 43;
 
 /// Result of handling one Kafka request body.
 #[derive(Debug)]
diff --git a/gateways/kafka/src/protocol/handlers/list_offsets.rs b/gateways/kafka/src/protocol/handlers/list_offsets.rs
index acc29a3..ef9b2d5 100644
--- a/gateways/kafka/src/protocol/handlers/list_offsets.rs
+++ b/gateways/kafka/src/protocol/handlers/list_offsets.rs
@@ -17,19 +17,29 @@
 
 //! `ListOffsets` (API key 2).
 
+use std::collections::{HashMap, HashSet};
+use std::time::Duration;
+
 use bytes::Bytes;
+use kafka_protocol::messages::list_offsets_request::{ListOffsetsPartition, ListOffsetsTopic};
 use kafka_protocol::messages::list_offsets_response::{
     ListOffsetsPartitionResponse, ListOffsetsTopicResponse,
 };
 use kafka_protocol::messages::{ListOffsetsRequest, ListOffsetsResponse};
+use tokio::time::Instant;
 
+use crate::bridge::{BridgeError, IggyBridge};
 use crate::error::Result;
 use crate::protocol::api::{
-    API_KEY_LIST_OFFSETS, ApiVersionRange, ERROR_NOT_LEADER_OR_FOLLOWER, GatewayState,
-    HandleOutcome,
+    API_KEY_LIST_OFFSETS, ApiVersionRange, ERROR_INVALID_REQUEST, ERROR_NOT_LEADER_OR_FOLLOWER,
+    ERROR_REQUEST_TIMED_OUT, ERROR_UNKNOWN_TOPIC_OR_PARTITION,
+    ERROR_UNSUPPORTED_FOR_MESSAGE_FORMAT, ERROR_UNSUPPORTED_VERSION, GatewayState, HandleOutcome,
 };
 use crate::protocol::bounds_guard::validate_list_offsets_shape;
-use crate::protocol::handlers::{decode_guarded, encode_message, handle_versioned_request};
+use crate::protocol::handlers::{
+    decode_guarded, encode_message, handle_versioned_request, is_supported_version,
+    respond_or_close, unsupported_version_response,
+};
 
 pub const RANGE: ApiVersionRange = ApiVersionRange {
     api_key: API_KEY_LIST_OFFSETS,
@@ -37,24 +47,325 @@
     max_version: 6,
 };
 
-#[expect(
-    clippy::unused_async,
-    reason = "the shared handler signature, kept until a handler awaits the bridge"
-)]
+/// Cap on distinct topics one `ListOffsets` request resolves through the bridge in one pass.
+///
+/// `bounds_guard`'s `MAX_REQUEST_ELEMENTS` (4,096) is a pre-decode `DoS` ceiling, not a usability
+/// recommendation: each distinct topic here costs one `high_watermarks` round trip against the
+/// single lockstep `IggyClient` every Kafka connection on this gateway shares (`README.md`'s
+/// "Concurrency ceiling"). Each call takes its own turn on that shared client and releases it
+/// before the next, so a large batch does not hold other connections off for its whole duration -
+/// only for whichever single call is in flight at a time. 100 keeps a worst-case batch's aggregate
+/// bridge cost small relative to that shared resource while remaining generous for any real
+/// consumer's offset lookup. A request naming more than this many distinct topics gets the first
+/// 100 resolved and the rest answered [`ERROR_REQUEST_TIMED_OUT`] with no bridge call at all - a
+/// client that retries only its still-erroring topics (the common case) narrows below the cap on
+/// its own within a couple of retries, rather than resending the same oversized request forever.
+const MAX_BRIDGE_BACKED_TOPICS: usize = 100;
+
+/// Wall-clock ceiling for one request's aggregate bridge work.
+///
+/// `ListOffsets` carries no `timeout_ms` field in any version this gateway supports (that field
+/// is v10+; [`RANGE`] tops out at v6) - unlike `CreateTopics`, there is no client-supplied value
+/// to honor here, so this is a fixed ceiling instead. Sized well above one `high_watermarks`
+/// call's own `REQUEST_TIMEOUT` (15s, bridge-internal) so a single slow-but-alive call is not the
+/// common trigger, while still bounding the sum across up to [`MAX_BRIDGE_BACKED_TOPICS`] calls -
+/// without this, a large batch against a struggling bridge could hold the shared client for
+/// `MAX_BRIDGE_BACKED_TOPICS * 15s`, not just one call's worth.
+///
+/// Applied per call, not once around the whole batch: [`resolve_all_topics`] checks it before
+/// starting each topic's `high_watermarks` call and wraps the call itself in
+/// [`tokio::time::timeout_at`] against the same instant, so a topic already resolved when the
+/// deadline arrives keeps its real answer and only the not-yet-started ones fall back to
+/// [`ERROR_REQUEST_TIMED_OUT`].
+const REQUEST_DEADLINE: Duration = Duration::from_secs(20);
+
+/// KIP-79 sentinel: the offset of the next message that would be produced.
+const LATEST_TIMESTAMP: i64 = -1;
+/// KIP-79 sentinel: the offset of the first message still retained.
+const EARLIEST_TIMESTAMP: i64 = -2;
+/// Placeholder offset/timestamp for a partition result that carries an error - matches real
+/// Kafka's own convention on the error path.
+const NO_OFFSET: i64 = -1;
+
+/// [`IggyBridge::high_watermarks`]'s return type, spelled once for [`resolve_one_partition`].
+type HighWatermarksResult =
+    core::result::Result<Vec<(u32, core::result::Result<i64, BridgeError>)>, BridgeError>;
+
 pub async fn handle(state: &GatewayState, api_version: i16, body: Bytes) -> HandleOutcome {
-    handle_versioned_request(
-        API_KEY_LIST_OFFSETS,
-        api_version,
-        body,
-        |v, b| {
-            decode_guarded::<ListOffsetsRequest>(v, b, |v, b| {
-                validate_list_offsets_shape(v, b, state.max_frame_size)
-            })
-        },
-        encode_response,
-        encode_error_response,
-        "ListOffsets",
-    )
+    let Some(bridge) = &state.bridge else {
+        return handle_versioned_request(
+            API_KEY_LIST_OFFSETS,
+            api_version,
+            body,
+            |v, b| {
+                decode_guarded::<ListOffsetsRequest>(v, b, |v, b| {
+                    validate_list_offsets_shape(v, b, state.max_frame_size)
+                })
+            },
+            encode_response,
+            encode_error_response,
+            "ListOffsets",
+        );
+    };
+
+    if !is_supported_version(API_KEY_LIST_OFFSETS, api_version) {
+        return unsupported_version_response(API_KEY_LIST_OFFSETS, api_version, |version| {
+            encode_error_response(version, ERROR_UNSUPPORTED_VERSION)
+        });
+    }
+
+    let req = match decode_guarded::<ListOffsetsRequest>(api_version, body, |v, b| {
+        validate_list_offsets_shape(v, b, state.max_frame_size)
+    }) {
+        Ok(req) => req,
+        Err(error) => {
+            // debug!, not warn!: attacker-controlled, not operator-actionable.
+            tracing::debug!(%error, "Failed to decode ListOffsets request");
+            return respond_or_close(
+                encode_error_response(api_version, ERROR_INVALID_REQUEST),
+                "ListOffsets",
+            );
+        }
+    };
+
+    let deadline = Instant::now() + REQUEST_DEADLINE;
+    let topics = resolve_all_topics(bridge, &req.topics, deadline).await;
+    let resp = ListOffsetsResponse::default().with_topics(topics);
+    respond_or_close(encode_message(&resp, api_version, 256), "ListOffsets")
+}
+
+/// One topic's bridge-lookup outcome, decided once per distinct name in [`resolve_all_topics`]
+/// and reused for every partition of that topic in [`resolve_one_partition`].
+enum TopicLookup {
+    /// A real `high_watermarks` call was made and returned - or every partition requested was an
+    /// invalid index and there was nothing to call `high_watermarks` for, which is `Ok(vec![])`
+    /// as far as [`resolve_one_partition`] is concerned (every partition in it fails its own
+    /// `u32::try_from` before ever consulting this).
+    Watermarks(HighWatermarksResult),
+    /// Beyond [`MAX_BRIDGE_BACKED_TOPICS`], or the deadline elapsed before this topic's turn - no
+    /// call was made. Answered [`ERROR_REQUEST_TIMED_OUT`] (retriable) rather than
+    /// [`ERROR_INVALID_REQUEST`] so a client's own per-topic retry narrows the batch on its own.
+    NotAttempted,
+}
+
+/// Dedupes `requested` by topic name, merging every entry's partitions.
+///
+/// `order` preserves first-seen order so the topic cap in [`resolve_topic_lookups`] keeps a
+/// deterministic prefix of the request rather than an arbitrary hash-order subset.
+///
+/// Deliberately does *not* skip a topic based on what timestamp its partitions ask for: the only
+/// place this bridge checks whether a topic exists at all is the `get_topic` call
+/// `high_watermarks` makes internally, so a topic whose partitions all ask an unsupported
+/// timestamp still needs that same call to tell a nonexistent topic
+/// (`ERROR_UNKNOWN_TOPIC_OR_PARTITION`) apart from an existing one with an unsupported timestamp
+/// (`ERROR_UNSUPPORTED_FOR_MESSAGE_FORMAT`) - skipping it would report the latter for both.
+fn group_requested_topics(requested: &[ListOffsetsTopic]) -> (Vec<&str>, HashMap<&str, Vec<u32>>) {
+    let mut order: Vec<&str> = Vec::new();
+    let mut partitions_by_name: HashMap<&str, Vec<u32>> = HashMap::new();
+    for topic in requested {
+        let name = topic.name.as_str();
+        if !partitions_by_name.contains_key(name) {
+            order.push(name);
+        }
+        let entry = partitions_by_name.entry(name).or_default();
+        let valid = topic
+            .partitions
+            .iter()
+            .filter_map(|p| u32::try_from(p.partition_index).ok());
+        entry.extend(valid);
+    }
+    for partitions in partitions_by_name.values_mut() {
+        partitions.sort_unstable();
+        partitions.dedup();
+    }
+    (order, partitions_by_name)
+}
+
+/// Resolves one [`TopicLookup`] per name in `order`: [`TopicLookup::NotAttempted`] beyond
+/// [`MAX_BRIDGE_BACKED_TOPICS`] or once `deadline` has passed, otherwise a real `high_watermarks`
+/// call wrapped in [`tokio::time::timeout_at`] against `deadline` so a topic already resolved
+/// when time runs out keeps its real answer. A topic with no *valid* partition index at all
+/// skips the call - there is nothing `high_watermarks` could tell us that would change any
+/// partition's answer, since every one of them already fails its own index check.
+async fn resolve_topic_lookups<'a>(
+    bridge: &IggyBridge,
+    order: &[&'a str],
+    partitions_by_name: &HashMap<&'a str, Vec<u32>>,
+    deadline: Instant,
+) -> HashMap<&'a str, TopicLookup> {
+    let accepted: HashSet<&str> = order
+        .iter()
+        .take(MAX_BRIDGE_BACKED_TOPICS)
+        .copied()
+        .collect();
+    if order.len() > MAX_BRIDGE_BACKED_TOPICS {
+        // debug!, not warn!: the client controls how many topics it batches into one request and
+        // the connection stays open, so a consumer stuck above the cap logs this every retry.
+        tracing::debug!(
+            distinct_topics = order.len(),
+            max = MAX_BRIDGE_BACKED_TOPICS,
+            "ListOffsets request exceeds the per-request topic cap; resolving the first {} and \
+             answering the rest retriable",
+            MAX_BRIDGE_BACKED_TOPICS
+        );
+    }
+
+    let mut lookups: HashMap<&str, TopicLookup> = HashMap::new();
+    let mut deadline_exceeded = false;
+    for &name in order {
+        if !accepted.contains(name) {
+            lookups.insert(name, TopicLookup::NotAttempted);
+            continue;
+        }
+        let partitions = partitions_by_name[name].as_slice();
+        if partitions.is_empty() {
+            lookups.insert(name, TopicLookup::Watermarks(Ok(Vec::new())));
+            continue;
+        }
+        if deadline_exceeded || Instant::now() >= deadline {
+            if !deadline_exceeded {
+                deadline_exceeded = true;
+                tracing::warn!(
+                    deadline_secs = REQUEST_DEADLINE.as_secs(),
+                    "ListOffsets request's aggregate bridge work exceeded its deadline; \
+                     answering remaining topics retriable instead of starting new bridge calls"
+                );
+            }
+            lookups.insert(name, TopicLookup::NotAttempted);
+            continue;
+        }
+
+        let result =
+            match tokio::time::timeout_at(deadline, bridge.high_watermarks(name, partitions)).await
+            {
+                Ok(result) => result,
+                Err(_elapsed) => {
+                    deadline_exceeded = true;
+                    tracing::warn!(
+                        topic = name,
+                        deadline_secs = REQUEST_DEADLINE.as_secs(),
+                        "ListOffsets bridge call for this topic exceeded the request's aggregate \
+                     deadline; answering retriable instead of blocking further"
+                    );
+                    lookups.insert(name, TopicLookup::NotAttempted);
+                    continue;
+                }
+            };
+
+        if let Err(call_err) = &result {
+            let kafka_code = call_err.to_kafka_error_code();
+            if kafka_code == ERROR_UNKNOWN_TOPIC_OR_PARTITION {
+                // Client-caused (topic doesn't exist / isn't mapped): expected traffic, not
+                // operator-actionable.
+                tracing::debug!(topic = name, %call_err, "ListOffsets bridge lookup: topic not found");
+            } else {
+                tracing::error!(topic = name, %call_err, "ListOffsets bridge lookup failed");
+            }
+        }
+        lookups.insert(name, TopicLookup::Watermarks(result));
+    }
+    lookups
+}
+
+/// Resolves every requested topic entry via [`group_requested_topics`] +
+/// [`resolve_topic_lookups`], then stamps each requested partition with its topic's
+/// [`TopicLookup`] outcome. `EARLIEST`/`LATEST` are the only timestamps this bridge resolves -
+/// Iggy exposes no per-message timestamp index - so every other requested timestamp gets
+/// [`ERROR_UNSUPPORTED_FOR_MESSAGE_FORMAT`] rather than a fabricated offset.
+async fn resolve_all_topics(
+    bridge: &IggyBridge,
+    requested: &[ListOffsetsTopic],
+    deadline: Instant,
+) -> Vec<ListOffsetsTopicResponse> {
+    let (order, partitions_by_name) = group_requested_topics(requested);
+    let lookups = resolve_topic_lookups(bridge, &order, &partitions_by_name, deadline).await;
+
+    requested
+        .iter()
+        .map(|topic| {
+            // Always present: `order` (and so `lookups`) was built from exactly these same
+            // requested topic names, just above.
+            let lookup = lookups
+                .get(topic.name.as_str())
+                .expect("every requested topic name was resolved above");
+            let partitions = topic
+                .partitions
+                .iter()
+                .map(|requested| resolve_one_partition(requested, lookup))
+                .collect();
+            ListOffsetsTopicResponse::default()
+                .with_name(topic.name.clone())
+                .with_partitions(partitions)
+        })
+        .collect()
+}
+
+/// `lookup` is the whole topic's resolution outcome: an errored [`TopicLookup::Watermarks`] is a
+/// call-level failure (e.g. the mapped stream doesn't exist) applying to every partition alike;
+/// the inner per-partition `Result` inside its `Ok` is [`BridgeError::PartitionOutOfRange`] for one
+/// bad index among otherwise resolvable ones.
+fn resolve_one_partition(
+    requested: &ListOffsetsPartition,
+    lookup: &TopicLookup,
+) -> ListOffsetsPartitionResponse {
+    let Ok(partition_index) = u32::try_from(requested.partition_index) else {
+        return error_response(requested.partition_index, ERROR_UNKNOWN_TOPIC_OR_PARTITION);
+    };
+
+    let results = match lookup {
+        TopicLookup::NotAttempted => {
+            return error_response(requested.partition_index, ERROR_REQUEST_TIMED_OUT);
+        }
+        TopicLookup::Watermarks(Err(call_err)) => {
+            return error_response(requested.partition_index, call_err.to_kafka_error_code());
+        }
+        TopicLookup::Watermarks(Ok(results)) => results,
+    };
+
+    // `results` preserves the order of the sorted, deduped partition list `resolve_all_topics`
+    // passed to `high_watermarks` (`IggyBridge::high_watermarks` maps over its input in place),
+    // so a binary search is correct here, not just faster than the linear scan this replaced.
+    let Ok(found) = results.binary_search_by_key(&partition_index, |(index, _)| *index) else {
+        return error_response(requested.partition_index, ERROR_UNKNOWN_TOPIC_OR_PARTITION);
+    };
+    let (_, watermark) = &results[found];
+
+    let watermark = match watermark {
+        Err(err) => return error_response(requested.partition_index, err.to_kafka_error_code()),
+        Ok(watermark) => *watermark,
+    };
+
+    match requested.timestamp {
+        LATEST_TIMESTAMP => offset_response(requested.partition_index, watermark),
+        // Real only for a partition retention has never trimmed: Iggy tracks no rolling
+        // low-watermark distinct from partition creation, so a `0` here for an older,
+        // already-trimmed partition names a log-start offset that no longer exists - a real
+        // consumer with `auto.offset.reset=earliest` would seek into a hole. Harmless *today*
+        // only because Fetch (`#3536`) is still a stub - nothing yet reads at the offset this
+        // returns. Not fixable client-side; needs the bridge to expose a real start offset.
+        EARLIEST_TIMESTAMP => offset_response(requested.partition_index, 0),
+        // Non-retriable, unlike ERROR_UNKNOWN_SERVER_ERROR: a Java client resolves this
+        // immediately instead of retrying the request until its own default.api.timeout.ms.
+        _ => error_response(
+            requested.partition_index,
+            ERROR_UNSUPPORTED_FOR_MESSAGE_FORMAT,
+        ),
+    }
+}
+
+fn offset_response(partition: i32, offset: i64) -> ListOffsetsPartitionResponse {
+    ListOffsetsPartitionResponse::default()
+        .with_partition_index(partition)
+        .with_timestamp(LATEST_TIMESTAMP)
+        .with_offset(offset)
+}
+
+fn error_response(partition: i32, error_code: i16) -> ListOffsetsPartitionResponse {
+    ListOffsetsPartitionResponse::default()
+        .with_partition_index(partition)
+        .with_error_code(error_code)
+        .with_timestamp(NO_OFFSET)
+        .with_offset(NO_OFFSET)
 }
 
 /// Well-formed `ListOffsets` response with a single placeholder topic/partition.
@@ -71,7 +382,7 @@
 pub fn encode_error_response(version: i16, error_code: i16) -> Result<Bytes> {
     let topics = vec![
         ListOffsetsTopicResponse::default()
-            .with_partitions(vec![partition_response(0, error_code)]),
+            .with_partitions(vec![stub_partition_response(0, error_code)]),
     ];
     encode_inner(version, topics)
 }
@@ -94,7 +405,7 @@
                         .partitions
                         .iter()
                         .map(|p| {
-                            partition_response(p.partition_index, ERROR_NOT_LEADER_OR_FOLLOWER)
+                            stub_partition_response(p.partition_index, ERROR_NOT_LEADER_OR_FOLLOWER)
                         })
                         .collect(),
                 )
@@ -108,8 +419,105 @@
     encode_message(&resp, version, 256)
 }
 
-fn partition_response(partition: i32, error_code: i16) -> ListOffsetsPartitionResponse {
+fn stub_partition_response(partition: i32, error_code: i16) -> ListOffsetsPartitionResponse {
     ListOffsetsPartitionResponse::default()
         .with_partition_index(partition)
         .with_error_code(error_code)
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::protocol::api::ERROR_NONE;
+
+    use super::*;
+
+    fn partition(index: i32, timestamp: i64) -> ListOffsetsPartition {
+        ListOffsetsPartition::default()
+            .with_partition_index(index)
+            .with_timestamp(timestamp)
+    }
+
+    fn ok_watermarks(entries: &[(u32, i64)]) -> Vec<(u32, core::result::Result<i64, BridgeError>)> {
+        entries.iter().map(|&(p, w)| (p, Ok(w))).collect()
+    }
+
+    #[test]
+    fn latest_resolves_to_the_watermark() {
+        let lookup = TopicLookup::Watermarks(Ok(ok_watermarks(&[(0, 42)])));
+        let resp = resolve_one_partition(&partition(0, LATEST_TIMESTAMP), &lookup);
+        assert_eq!(resp.error_code, ERROR_NONE);
+        assert_eq!(resp.offset, 42);
+    }
+
+    #[test]
+    fn earliest_resolves_to_zero_regardless_of_the_watermark() {
+        let lookup = TopicLookup::Watermarks(Ok(ok_watermarks(&[(0, 42)])));
+        let resp = resolve_one_partition(&partition(0, EARLIEST_TIMESTAMP), &lookup);
+        assert_eq!(resp.error_code, ERROR_NONE);
+        assert_eq!(resp.offset, 0);
+    }
+
+    #[test]
+    fn an_arbitrary_timestamp_is_unsupported() {
+        let lookup = TopicLookup::Watermarks(Ok(ok_watermarks(&[(0, 42)])));
+        let resp = resolve_one_partition(&partition(0, 1_700_000_000_000), &lookup);
+        assert_eq!(resp.error_code, ERROR_UNSUPPORTED_FOR_MESSAGE_FORMAT);
+        assert_eq!(resp.offset, NO_OFFSET);
+    }
+
+    #[test]
+    fn a_nonexistent_topic_is_unknown_regardless_of_the_requested_timestamp() {
+        // Regression for the NoLookupNeeded design this replaced: existence is only ever
+        // established by the high_watermarks call itself, so a call-level error must win over
+        // the timestamp branch even when the requested timestamp is unsupported - the call is
+        // never skipped just because nothing would use its watermark.
+        let lookup = TopicLookup::Watermarks(Err(BridgeError::Timeout));
+        let resp = resolve_one_partition(&partition(0, 1_700_000_000_000), &lookup);
+        assert_eq!(resp.error_code, BridgeError::Timeout.to_kafka_error_code());
+    }
+
+    #[test]
+    fn a_topic_beyond_the_cap_or_past_the_deadline_answers_retriable() {
+        let resp =
+            resolve_one_partition(&partition(0, LATEST_TIMESTAMP), &TopicLookup::NotAttempted);
+        assert_eq!(resp.error_code, ERROR_REQUEST_TIMED_OUT);
+        assert_eq!(resp.offset, NO_OFFSET);
+    }
+
+    #[test]
+    fn a_negative_partition_index_is_rejected_without_consulting_the_bridge_result() {
+        let lookup = TopicLookup::Watermarks(Ok(vec![]));
+        let resp = resolve_one_partition(&partition(-1, LATEST_TIMESTAMP), &lookup);
+        assert_eq!(resp.error_code, ERROR_UNKNOWN_TOPIC_OR_PARTITION);
+    }
+
+    #[test]
+    fn a_partition_specific_error_only_affects_that_partition() {
+        let lookup = TopicLookup::Watermarks(Ok(vec![(
+            0,
+            Err(BridgeError::PartitionOutOfRange {
+                topic: "orders".to_string(),
+                partition: 0,
+                partitions_count: 0,
+            }),
+        )]));
+        let resp = resolve_one_partition(&partition(0, LATEST_TIMESTAMP), &lookup);
+        assert_eq!(resp.error_code, ERROR_UNKNOWN_TOPIC_OR_PARTITION);
+    }
+
+    #[test]
+    fn a_call_level_error_applies_regardless_of_the_requested_timestamp() {
+        let lookup = TopicLookup::Watermarks(Err(BridgeError::Timeout));
+        let resp = resolve_one_partition(&partition(0, EARLIEST_TIMESTAMP), &lookup);
+        assert_eq!(resp.error_code, BridgeError::Timeout.to_kafka_error_code());
+    }
+
+    #[test]
+    fn a_partition_index_absent_from_a_sorted_result_is_rejected() {
+        // Exercises the binary search on a multi-entry, sorted-by-index result - a single-entry
+        // vec would pass a linear scan and a broken binary search alike.
+        let lookup = TopicLookup::Watermarks(Ok(ok_watermarks(&[(0, 10), (2, 30), (5, 60)])));
+        let resp = resolve_one_partition(&partition(3, LATEST_TIMESTAMP), &lookup);
+        assert_eq!(resp.error_code, ERROR_UNKNOWN_TOPIC_OR_PARTITION);
+    }
+}
diff --git a/gateways/kafka/tests/list_offsets_real_bridge_tests.rs b/gateways/kafka/tests/list_offsets_real_bridge_tests.rs
new file mode 100644
index 0000000..fbbcdae
--- /dev/null
+++ b/gateways/kafka/tests/list_offsets_real_bridge_tests.rs
@@ -0,0 +1,401 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Wire-level `ListOffsets` tests against a real `iggy-server` process, through
+//! [`list_offsets::handle`] with a connected [`GatewayState`]. See
+//! `create_topics_real_bridge_tests.rs` for why requests/responses are hand-built here rather
+//! than through `kafka_protocol`'s own `Encodable`/`Decodable` (this crate builds
+//! `broker`-feature-only: request `Decodable` and response `Encodable`, not the reverse).
+
+use std::sync::Arc;
+
+use bytes::Bytes;
+use iggy::prelude::{Identifier, IggyMessage, MessageClient, Partitioning};
+use serial_test::serial;
+
+use iggy_gateway_kafka::bridge::IggyBridge;
+use iggy_gateway_kafka::protocol::api::{
+    BrokerAdvertise, ERROR_NONE, ERROR_REQUEST_TIMED_OUT, ERROR_UNKNOWN_TOPIC_OR_PARTITION,
+    ERROR_UNSUPPORTED_FOR_MESSAGE_FORMAT, GatewayState,
+};
+use iggy_gateway_kafka::protocol::handlers::list_offsets;
+
+#[path = "common/codec.rs"]
+mod codec;
+#[path = "common/iggy_server.rs"]
+mod iggy_server;
+
+use codec::{Decoder, Encoder};
+use iggy_server::TestServer;
+
+const REQUEST_VERSION: i16 = 6;
+const TEST_MAX_FRAME_SIZE: usize = 8 * 1024 * 1024;
+const LATEST_TIMESTAMP: i64 = -1;
+const EARLIEST_TIMESTAMP: i64 = -2;
+
+/// Builds a v6 flexible `ListOffsets` request body for one topic/partition.
+fn build_request(topic: &str, partition_index: i32, timestamp: i64) -> Bytes {
+    let mut enc = Encoder::with_capacity(128);
+    enc.write_i32(-1); // replica_id: ordinary client, not a follower broker
+    enc.write_i8(0); // isolation_level: READ_UNCOMMITTED
+
+    enc.write_varint(2); // one topic
+    enc.write_compact_nullable_string(Some(topic));
+    enc.write_varint(2); // one partition
+    enc.write_i32(partition_index);
+    enc.write_i32(-1); // current_leader_epoch: unset
+    enc.write_i64(timestamp);
+    enc.write_empty_tagged_fields(); // partition tagged fields
+    enc.write_empty_tagged_fields(); // topic tagged fields
+
+    enc.write_empty_tagged_fields(); // top-level tagged fields
+    enc.freeze()
+}
+
+/// Decodes a v6 flexible `ListOffsets` response's first partition result into `(error_code,
+/// offset)`.
+fn decode_first_result(body: Bytes) -> (i16, i64) {
+    let mut d = Decoder::new(body);
+    let _throttle_time_ms = d.read_i32().expect("throttle_time_ms");
+    let _topics_plus_one = d.read_varint().expect("topics array count");
+    let _name = d.read_compact_nullable_string().expect("topic name");
+    let _partitions_plus_one = d.read_varint().expect("partitions array count");
+    let _partition_index = d.read_i32().expect("partition_index");
+    let error_code = d.read_i16().expect("error_code");
+    let _timestamp = d.read_i64().expect("timestamp");
+    let offset = d.read_i64().expect("offset");
+    (error_code, offset)
+}
+
+async fn send(
+    state: &GatewayState,
+    topic: &str,
+    partition_index: i32,
+    timestamp: i64,
+) -> (i16, i64) {
+    let body = build_request(topic, partition_index, timestamp);
+    let outcome = list_offsets::handle(state, REQUEST_VERSION, body).await;
+    let resp_body = outcome.expect_response("ListOffsets request always answers");
+    decode_first_result(resp_body)
+}
+
+/// One requested topic entry, for [`build_multi_request`]: a name and its `(partition_index,
+/// timestamp)` pairs.
+struct TopicRequest<'a> {
+    name: &'a str,
+    partitions: &'a [(i32, i64)],
+}
+
+/// Builds a v6 flexible `ListOffsets` request body for several topic entries at once - unlike
+/// [`build_request`], `topics` may repeat the same name across more than one entry.
+fn build_multi_request(topics: &[TopicRequest]) -> Bytes {
+    let mut enc = Encoder::with_capacity(4096);
+    enc.write_i32(-1); // replica_id
+    enc.write_i8(0); // isolation_level
+
+    enc.write_varint((topics.len() + 1) as u64);
+    for topic in topics {
+        enc.write_compact_nullable_string(Some(topic.name));
+        enc.write_varint((topic.partitions.len() + 1) as u64);
+        for &(partition_index, timestamp) in topic.partitions {
+            enc.write_i32(partition_index);
+            enc.write_i32(-1); // current_leader_epoch
+            enc.write_i64(timestamp);
+            enc.write_empty_tagged_fields();
+        }
+        enc.write_empty_tagged_fields();
+    }
+    enc.write_empty_tagged_fields();
+    enc.freeze()
+}
+
+/// Decodes every topic/partition result in a v6 flexible `ListOffsets` response into `(name,
+/// partition_index, error_code, offset)`, in wire order.
+fn decode_all(body: Bytes) -> Vec<(String, i32, i16, i64)> {
+    let mut d = Decoder::new(body);
+    let _throttle_time_ms = d.read_i32().expect("throttle_time_ms");
+    let topics_plus_one = d.read_varint().expect("topics array count");
+    let mut results = Vec::new();
+    for _ in 1..topics_plus_one {
+        let name = d
+            .read_compact_nullable_string()
+            .expect("topic name")
+            .expect("name is never null in a request-echoing response");
+        let partitions_plus_one = d.read_varint().expect("partitions array count");
+        for _ in 1..partitions_plus_one {
+            let partition_index = d.read_i32().expect("partition_index");
+            let error_code = d.read_i16().expect("error_code");
+            let _timestamp = d.read_i64().expect("timestamp");
+            let offset = d.read_i64().expect("offset");
+            let _leader_epoch = d.read_i32().expect("leader_epoch");
+            let _partition_tagged_fields = d.read_varint().expect("partition tagged fields");
+            results.push((name.clone(), partition_index, error_code, offset));
+        }
+        let _topic_tagged_fields = d.read_varint().expect("topic tagged fields");
+    }
+    results
+}
+
+async fn send_multi(
+    state: &GatewayState,
+    topics: &[TopicRequest<'_>],
+) -> Vec<(String, i32, i16, i64)> {
+    let body = build_multi_request(topics);
+    let outcome = list_offsets::handle(state, REQUEST_VERSION, body).await;
+    let resp_body = outcome.expect_response("ListOffsets request always answers");
+    decode_all(resp_body)
+}
+
+async fn connected_state(server: &TestServer) -> (GatewayState, IggyBridge) {
+    let bridge = IggyBridge::connect(server.test_config())
+        .await
+        .expect("bridge should connect to a ready server");
+    // A second bridge for direct seeding (get_topics/create/produce) alongside the handler's own.
+    let seed_bridge = IggyBridge::connect(server.test_config())
+        .await
+        .expect("seed bridge should connect to a ready server");
+    let state = GatewayState::new(
+        BrokerAdvertise::default(),
+        Some(Arc::new(bridge)),
+        TEST_MAX_FRAME_SIZE,
+    );
+    (state, seed_bridge)
+}
+
+#[tokio::test]
+#[serial]
+async fn latest_on_a_fresh_empty_partition_is_zero() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let (state, seed) = connected_state(&server).await;
+    seed.ensure_stream_and_topic("orders", 1)
+        .await
+        .expect("seed the topic");
+
+    let (error_code, offset) = send(&state, "orders", 0, LATEST_TIMESTAMP).await;
+    assert_eq!(error_code, ERROR_NONE);
+    assert_eq!(offset, 0);
+}
+
+#[tokio::test]
+#[serial]
+async fn latest_reflects_produced_messages() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let (state, seed) = connected_state(&server).await;
+    seed.ensure_stream_and_topic("orders", 1)
+        .await
+        .expect("seed the topic");
+
+    let raw = iggy_server::raw_client(&server).await;
+    let mut messages: Vec<IggyMessage> = (0..3)
+        .map(|i| IggyMessage::from(format!("message-{i}")))
+        .collect();
+    raw.send_messages(
+        &Identifier::named("kafka").expect("valid stream name"),
+        &Identifier::named("orders").expect("valid topic name"),
+        &Partitioning::partition_id(0),
+        &mut messages,
+    )
+    .await
+    .expect("seed 3 messages");
+
+    let (error_code, offset) = send(&state, "orders", 0, LATEST_TIMESTAMP).await;
+    assert_eq!(error_code, ERROR_NONE);
+    assert_eq!(offset, 3);
+}
+
+#[tokio::test]
+#[serial]
+async fn earliest_is_always_zero() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let (state, seed) = connected_state(&server).await;
+    seed.ensure_stream_and_topic("orders", 1)
+        .await
+        .expect("seed the topic");
+
+    let (error_code, offset) = send(&state, "orders", 0, EARLIEST_TIMESTAMP).await;
+    assert_eq!(error_code, ERROR_NONE);
+    assert_eq!(offset, 0);
+}
+
+#[tokio::test]
+#[serial]
+async fn out_of_range_partition_returns_unknown_topic_or_partition() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let (state, seed) = connected_state(&server).await;
+    seed.ensure_stream_and_topic("orders", 1)
+        .await
+        .expect("seed a 1-partition topic");
+
+    let (error_code, _) = send(&state, "orders", 5, LATEST_TIMESTAMP).await;
+    assert_eq!(error_code, ERROR_UNKNOWN_TOPIC_OR_PARTITION);
+}
+
+#[tokio::test]
+#[serial]
+async fn a_nonexistent_topic_returns_unknown_topic_or_partition() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let (state, _seed) = connected_state(&server).await;
+
+    let (error_code, _) = send(&state, "orders", 0, LATEST_TIMESTAMP).await;
+    assert_eq!(error_code, ERROR_UNKNOWN_TOPIC_OR_PARTITION);
+}
+
+#[tokio::test]
+#[serial]
+async fn an_arbitrary_timestamp_is_unsupported() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let (state, seed) = connected_state(&server).await;
+    seed.ensure_stream_and_topic("orders", 1)
+        .await
+        .expect("seed the topic");
+
+    let (error_code, _) = send(&state, "orders", 0, 1_700_000_000_000).await;
+    assert_eq!(error_code, ERROR_UNSUPPORTED_FOR_MESSAGE_FORMAT);
+}
+
+/// Regression test: a topic named in two separate request entries must still resolve every
+/// partition across both correctly, not just avoid a crash - proves the dedup-by-name merge
+/// actually unions the two entries' partition lists rather than dropping one.
+#[tokio::test]
+#[serial]
+async fn a_topic_named_in_two_request_entries_resolves_both_entries_correctly() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let (state, seed) = connected_state(&server).await;
+    seed.ensure_stream_and_topic("orders", 2)
+        .await
+        .expect("seed a 2-partition topic");
+
+    let topics = [
+        TopicRequest {
+            name: "orders",
+            partitions: &[(0, LATEST_TIMESTAMP)],
+        },
+        TopicRequest {
+            name: "orders",
+            partitions: &[(1, LATEST_TIMESTAMP)],
+        },
+    ];
+    let results = send_multi(&state, &topics).await;
+    assert_eq!(results.len(), 2);
+    for (name, _partition_index, error_code, offset) in &results {
+        assert_eq!(name, "orders");
+        assert_eq!(*error_code, ERROR_NONE);
+        assert_eq!(*offset, 0);
+    }
+}
+
+/// Regression test: the binary search in `resolve_one_partition` must map each partition index to
+/// *its own* watermark, not just pass when both happen to agree. The two-empty-partitions test
+/// above can't catch a broken search or a constant-index bug - both partitions are 0 either way.
+#[tokio::test]
+#[serial]
+async fn distinct_partitions_resolve_to_their_own_distinct_watermarks() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let (state, seed) = connected_state(&server).await;
+    seed.ensure_stream_and_topic("orders", 2)
+        .await
+        .expect("seed a 2-partition topic");
+
+    let raw = iggy_server::raw_client(&server).await;
+    let stream_id = Identifier::named("kafka").expect("valid stream name");
+    let topic_id = Identifier::named("orders").expect("valid topic name");
+    let mut partition_0: Vec<IggyMessage> = (0..3)
+        .map(|i| IggyMessage::from(format!("p0-{i}")))
+        .collect();
+    raw.send_messages(
+        &stream_id,
+        &topic_id,
+        &Partitioning::partition_id(0),
+        &mut partition_0,
+    )
+    .await
+    .expect("seed partition 0 with 3 messages");
+    let mut partition_1: Vec<IggyMessage> = (0..7)
+        .map(|i| IggyMessage::from(format!("p1-{i}")))
+        .collect();
+    raw.send_messages(
+        &stream_id,
+        &topic_id,
+        &Partitioning::partition_id(1),
+        &mut partition_1,
+    )
+    .await
+    .expect("seed partition 1 with 7 messages");
+
+    let topics = [TopicRequest {
+        name: "orders",
+        partitions: &[(1, LATEST_TIMESTAMP), (0, LATEST_TIMESTAMP)], // deliberately out of index order
+    }];
+    let results = send_multi(&state, &topics).await;
+    assert_eq!(results.len(), 2);
+    for (_, partition_index, error_code, offset) in &results {
+        assert_eq!(*error_code, ERROR_NONE);
+        let expected = if *partition_index == 0 { 3 } else { 7 };
+        assert_eq!(
+            *offset, expected,
+            "partition {partition_index} got the wrong watermark"
+        );
+    }
+}
+
+/// Regression test: a request naming more than the bridge-backed topic cap must be rejected
+/// wholesale (every entry, `INVALID_REQUEST`) rather than partially served or left unbounded.
+#[tokio::test]
+#[serial]
+async fn more_than_the_topic_cap_is_rejected() {
+    let data_dir = tempfile::tempdir().expect("tempdir");
+    let server = TestServer::spawn(data_dir.path()).await;
+    let (state, seed) = connected_state(&server).await;
+    seed.ensure_stream_and_topic("orders", 1)
+        .await
+        .expect("seed the one real, resolvable topic");
+
+    // 100 nonexistent names fill the cap; the one real, resolvable topic goes last so a correct
+    // cap leaves it untouched by this round entirely - proving the cap bounds *which* topics get
+    // a bridge call, not just that a request over the cap fails uniformly.
+    let mut names: Vec<String> = (0..100).map(|i| format!("topic-{i}")).collect();
+    names.push("orders".to_string());
+    let partitions = [(0, LATEST_TIMESTAMP)];
+    let topics: Vec<TopicRequest> = names
+        .iter()
+        .map(|name| TopicRequest {
+            name,
+            partitions: &partitions,
+        })
+        .collect();
+
+    let results = send_multi(&state, &topics).await;
+    assert_eq!(results.len(), 101);
+    for (name, _, error_code, _) in &results {
+        if name == "orders" {
+            assert_eq!(
+                *error_code, ERROR_REQUEST_TIMED_OUT,
+                "the 101st topic must be left unattempted by the cap, not looked up"
+            );
+        } else {
+            assert_eq!(*error_code, ERROR_UNKNOWN_TOPIC_OR_PARTITION);
+        }
+    }
+}