You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@kafka.apache.org by ma...@apache.org on 2026/07/02 08:54:35 UTC

(kafka) branch trunk updated: KAFKA-20032: KIP-1265 Automatic detection of internal API usage (#21337)

This is an automated email from the ASF dual-hosted git repository.

omkreddy pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git


The following commit(s) were added to refs/heads/trunk by this push:
     new fac6838045e KAFKA-20032: KIP-1265 Automatic detection of internal API usage (#21337)
fac6838045e is described below

commit fac6838045e8de367666730d8f12b434596eb71c
Author: ashwinpankaj <ap...@confluent.io>
AuthorDate: Thu Jul 2 14:24:27 2026 +0530

    KAFKA-20032: KIP-1265 Automatic detection of internal API usage (#21337)
    
    ## Summary
    
    Wires up the KIP-1265 mechanism for explicit declaration and automated
    enforcement of Apache Kafka's public API surface. Adds two annotations
    (`@InterfaceAudience.Public` / `.Private`, modelled on Hadoop and
    orthogonal to the existing `@InterfaceStability`), an auditable escape
    hatch (`@SuppressKafkaInternalApiUsage`), a Gradle checker that
    cross-validates the javadoc HTML against the bytecode annotations and
    cascades through public method signatures (including class-header
    extends/implements + generic supertype arguments), and a Gradle/Maven
    checker plugin for external plugin developers. Every javadoc'd class
    across the 10 docsJar modules is annotated, and `./gradlew docsJar` now
    finalizes with the checker on every project — 0 violations, 53
    suppressions, every suppression carrying a reason the next reviewer can
    audit.
    
    JIRA: [KAFKA-20032](https://issues.apache.org/jira/browse/KAFKA-20032) ·
    KIP:
    
    [KIP-1265](https://cwiki.apache.org/confluence/display/KAFKA/KIP-1265%3A+Mechanism+for+automatic+detection+of+internal+API+usage)
    
    ## What's in this PR
    
    ### Annotations (in `o.a.k.common.annotation`)
    - `@InterfaceAudience.Public` / `.Private` — marker annotations, RUNTIME
    retention, `@Documented`, `@Target(TYPE)`. The audience dimension is
    orthogonal to `@InterfaceStability`; a class may carry both.
    - `@SuppressKafkaInternalApiUsage("reason")` —
    class/method/field/constructor target, RUNTIME retention. Required
    reason text is surfaced in every checker report so each escape hatch is
    reviewable inline.
    
    ### Audience inheritance
    A class's effective audience is its own direct `@InterfaceAudience`
    annotation if present, otherwise the audience of its nearest annotated
    enclosing class. Default everywhere is `@Private` per the KIP. A nested
    class can override an inherited `@Public` with explicit
    `@InterfaceAudience.Private`. This matches Hadoop's model and aligns
    with how javadoc treats nested classes — they're documented under the
    outer's page, so requiring a separate annotation on every public nested
    class would be redundant noise. Inheritance also handles protected and
    package-private nested classes correctly: they appear in javadoc only as
    part of the outer's docs, so the checker only enforces direct `@Public`
    for the `MISSING_JAVADOC` check, while cascade reference checks accept
    inherited `@Public` for any nested type. Net effect: ~640 javadoc'd
    classes are covered by ~550 top-level annotations, with the rest carried
    by inheritance.
    
    ### Producer-side checker (`kafkaPublicApiChecker` — runs as part of
    `docsJar`)
    Cross-validates each module's javadoc jar against its bytecode-level
    annotations. Reports four categories of finding:
    - `MISSING_PUBLICAPI_ANNOTATION` — a class is in the javadoc HTML but
    isn't effectively `@Public`.
    - `MISSING_JAVADOC` — a class carries a direct `@Public` but no HTML
    page in the javadoc jar.
    - Method/field cascade (`INVALID_RETURN_TYPE` / `INVALID_PARAMETER_TYPE`
    / `INVALID_EXCEPTION_TYPE` / `INVALID_FIELD_TYPE`) — a public member
    signature on a `@Public` class leaks an internal type. Position-aware:
    the same generic walker that catches `Map<String, InternalFoo>` in a
    return type emits `INVALID_RETURN_TYPE`, a parameter-position leak
    emits `INVALID_PARAMETER_TYPE`, and a throws-clause leak emits
    `INVALID_EXCEPTION_TYPE`. Findings are deduped by
    (violation-type, owner, member, referenced-type) so the descriptor
    walk and the generic-signature walk don't double-report the same leak.
    - Supertype cascade (`INVALID_SUPERTYPE`) — a `@Public` class extends or
    implements an internal type the consumer can name (skipped for
    package-private supertypes that a consumer can't even reference). Also
    covers generic supertype arguments via the JVM signature attribute, so
    `extends Windows<InternalWindow>` is caught.
    
    Cascade findings whose containing member or class carries
    `@SuppressKafkaInternalApiUsage` divert to a separate suppressions
    section with the supplied reason; the report lists any suppression
    missing a reason as a warning.
    
    ### Consumer-side checker (`kafkaInternalApiChecker` plugin)
    Gradle and Maven plugins
    (`o.a.k:kafka-internal-api-checker-gradle-plugin`,
    `o.a.k:kafka-internal-api-checker-maven-plugin`) for external plugin
    developers — walk compiled bytecode (Java/Scala/Kotlin, uniformly) and
    flag references to any `org.apache.kafka.*` type that isn't `@Public`.
    Violations are grouped in the report by the consumer class (the file a
    developer needs to open), with the leaked Kafka type carried in the
    description. The same `@SuppressKafkaInternalApiUsage` annotation
    applies to known/intentional consumer-side exposures. Skip the check
    for one invocation via `-PkafkaInternalApiChecker.skip` (Gradle) or
    `-Dkafka.internal-api-checker.enabled=false` (Maven). A symmetric
    `-PkafkaPublicApiChecker.skip` exists for the producer-side checker.
    
    ### Cross-module support
    Per-module checks include sibling Kafka jars as `referenceJarFiles` —
    reference classes contribute to the surface so cross-module `@Public`
    resolution works (e.g. `:storage:storage-api` referencing
    `o.a.k.common.TopicIdPartition` from `:clients`), but they don't
    participate in the module's own `MISSING_JAVADOC` or cascade iteration.
    Gradle wires this via
    `configurations.compileClasspath.incoming.artifactView { componentFilter
    { it instanceof ProjectComponentIdentifier } … }` filtered to the JAR
    artifact variant.
    
    ## Coverage
    
    `./gradlew docsJar` triggers the checker on every subproject. Per-module
    results across the 10 docsJar modules:
    
    | Module | Violations | Suppressions | |---|---:|---:| | `:clients` | 0
    | 18 | | `:streams` | 0 | 27 | | `:streams:test-utils` | 0 | 2 | |
    `:streams:streams-scala` | 0 | 0 | | `:connect:api` | 0 | 0 | |
    `:connect:mirror-client` | 0 | 6 | | `:connect:test-plugins` | 0 | 0 | |
    `:storage:storage-api` | 0 | 0 | | `:tools:tools-api` | 0 | 0 | |
    `:group-coordinator:group-coordinator-api` | 0 | 0 | | **Total** | **0**
    | **53** |
    
    ## Suppressions in this PR
    
    Every suppression carries a `KIP-1265: …` reason in the source and is
    rendered in the checker report. They mark cascade leaks that can't be
    cleanly resolved without API-shape changes the KIP scope doesn't cover.
    Grouped by pattern:
    
    - **Internal `Time`/`Metrics` injection on public ctors** — `Metrics`,
    `KafkaMetric`, `KafkaStreams`, the JWT retrievers/validator, and the
    mirror-client constructors take internal `o.a.k.common.utils.Time` (or
    `Metrics`) for test injection.
    - **Internal partition-assignor supertypes** — `StickyAssignor`,
    `CooperativeStickyAssignor`, `RangeAssignor`, `RoundRobinAssignor`
    extend internal `AbstractStickyAssignor` / `AbstractPartitionAssignor`.
    Reachable only through the existing `extends`; the methods themselves
    are stable.
    - **OAuthBearer plugin supertypes** — `JwtRetriever`, `JwtValidator`
    implement internal `OAuthBearerConfigurable`.
    - **Streams windowing generic supertypes** — `TimeWindows`,
    `UnlimitedWindows` extend `Windows<TimeWindow>` /
    `Windows<UnlimitedWindow>`; `TimeWindowedSerializer`,
    `SessionWindowedSerializer` implement internal `WindowedSerializer`.
    - **`QueryableStoreType` / `QueryableStoreTypes`** —
    `create(StateStoreProvider)` and its five nested overrides take an
    internal type.
    - **`:streams:test-utils`** — `MockProcessorContext.recordCollector()`
    returns internal `RecordCollector`; the class also implements
    `RecordCollector.Supplier`.
    
    These are deliberate, reviewable, and easy to grep. Follow-up KIPs can
    decide per-case whether to promote the internal type or refactor the
    member signature.
    
    ## Genuine public-API leaks surfaced and fixed
    
    The checker found cases where the *de-facto* public surface didn't match
    the published javadoc:
    
    1. **`o.a.k.common.config.types.Password`** — already `public class`,
    returned by `AbstractConfig.getPassword()` which is `@Public`, but the
    `…common.config.types.*` package was missing from the `:clients` javadoc
    include list. Brought it into the include list and annotated `Password`
    with `@Public`.
    2. **`SampledStat.Sample`** — `protected static class` returned by two
    public methods (`current(long)`, `oldest(long)`) of `@Public abstract
    SampledStat`. Subclasses extending `SampledStat` have always been able
    to touch this type; the annotation now makes the de-facto contract
    explicit. Visibility unchanged.
    
    ## Implementation notes
    
    The checker lives in its own composite-included Gradle build at
    `api-checker/` so its Maven runtime dependencies stay off Kafka's main
    build classpath and each published artifact carries only the classes its
    consumers need. Three subprojects:
    
    - **`api-checker/core`** — ASM-based scanner + validators + reporter
    (no Gradle / Maven dependencies). `PublicApiChecker` is the facade.
    `ApiSurfaceScanner` does a two-pass static `scan(projectJars,
    referenceJars) → ApiSurface`. `CascadeValidator` runs the
    extends/implements + member-signature cascade.
    `JavadocConsistencyValidator` cross-checks the javadoc HTML.
    `PluginDeveloperApiUsageScanner` is the consumer-side walker.
    - **`api-checker/gradle-plugins`** — published as
    `kafka-internal-api-checker-gradle-plugin`. Also hosts the internal
    `org.apache.kafka.public-api-checker` plugin Kafka's own build applies
    (its marker publication is disabled — Kafka-internal only).
    - **`api-checker/maven-plugin`** — published as
    `kafka-internal-api-checker-maven-plugin`. A `PluginXmlParityTest`
    locks the generated `plugin.xml` Mojo descriptor against the
    configuration fields on the Mojo class, so adding a new parameter
    without exposing it (or vice versa) fails CI.
    
    **No classloading anywhere.** All bytecode reading goes through ASM, so
    a class with broken transitive deps on the checker's classpath (gRPC
    stubs, telemetry shims, etc.) can't trip `LinkageError` /
    `NoClassDefFoundError`.
    
    **Reproducible reports.** Reports omit wall-clock timestamps and sort
    by class then member, so two runs over the same inputs produce
    byte-identical files (clean diffs in CI, clean review comparisons).
    
    **Documented scanner gaps.** The consumer-side bytecode scanner walks
    every callable instruction, field type, declared exception, and generic
    signature, but four reference kinds are intentionally not followed
    (documented at `docs/apis/internal-api-checker.md`): parameter
    annotations, JSR 308 type-use annotations, class literals appearing
    *only* inside annotation element values, and Java's compile-time
    inlining of `public static final` primitive/`String` constants. In
    practice these don't show up in plugin/connector code, but a "0
    violations" report does not strictly exclude them.
    
    ## Test plan
    
    - [x] `./gradlew docsJar` — every module's `kafkaPublicApiChecker` runs
    and reports 0 violations.
    - [x] Per-module reports inspected: 0 violations, 53 auditable
    suppressions across `:clients` (18), `:streams` (27),
    `:streams:test-utils` (2), and `:connect:mirror-client` (6).
    - [x] `./gradlew :api-checker:core:check
    :api-checker:gradle-plugins:check
    :api-checker:maven-plugin:check` — checkstyle + unit tests clean.
    - [x] End-to-end Gradle and Maven consumer tests (sample plugin app
    referencing `KafkaProducer`, `ProducerRecord`, `Callback`,
    `RecordMetadata`, `StringSerializer`) produce the expected violations
    and suppressions. A deliberate-failure variant referencing
    `org.apache.kafka.clients.producer.internals.ProducerInterceptors` is
    flagged as `INTERNAL_API_USAGE` by both plugins and grouped in the
    report under the consumer class, not the leaked Kafka type.
    - [x] `-PkafkaPublicApiChecker.skip` / `-PkafkaInternalApiChecker.skip`
    gates verified to short-circuit at configuration time.
    - [x] Existing Kafka tests continue to pass.
    
    Reviewers: Manikumar Reddy <ma...@gmail.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <no...@anthropic.com>
---
 api-checker/README.md                              |  90 +++
 api-checker/build.gradle                           | 146 +++++
 api-checker/core/build.gradle                      |  53 ++
 .../java/org/apache/kafka/apicheck/ApiSurface.java | 169 ++++++
 .../apache/kafka/apicheck/ApiSurfaceScanner.java   | 225 ++++++++
 .../apache/kafka/apicheck/CascadeValidator.java    | 455 +++++++++++++++
 .../org/apache/kafka/apicheck/CheckResult.java     |  52 ++
 .../java/org/apache/kafka/apicheck/ClassFacts.java | 162 ++++++
 .../apicheck/JavadocConsistencyValidator.java      | 118 ++++
 .../apicheck/PluginDeveloperApiUsageScanner.java   | 624 +++++++++++++++++++++
 .../apache/kafka/apicheck/PublicApiChecker.java    | 121 ++++
 .../apache/kafka/apicheck/PublicApiViolation.java  | 109 ++++
 .../kafka/apicheck/ReasonCaptureVisitor.java       |  54 ++
 .../apache/kafka/apicheck/ViolationReporter.java   | 178 ++++++
 .../kafka/apicheck/ApiSurfaceScannerTest.java      | 215 +++++++
 .../org/apache/kafka/apicheck/ApiSurfaceTest.java  | 200 +++++++
 .../kafka/apicheck/CascadeValidatorTest.java       | 424 ++++++++++++++
 .../apicheck/JavadocConsistencyValidatorTest.java  | 206 +++++++
 .../PluginDeveloperApiUsageScannerTest.java        | 513 +++++++++++++++++
 .../kafka/apicheck/PublicApiCheckerTest.java       | 220 ++++++++
 .../org/apache/kafka/apicheck/AsmClassFactory.java | 366 ++++++++++++
 .../org/apache/kafka/apicheck/TempJarBuilder.java  |  89 +++
 api-checker/gradle-plugins/build.gradle            |  79 +++
 .../gradle/KafkaInternalApiCheckerExtension.java   | 118 ++++
 .../gradle/KafkaInternalApiCheckerPlugin.java      | 127 +++++
 .../kafka/gradle/KafkaInternalApiCheckerTask.java  | 190 +++++++
 .../gradle/KafkaPublicApiCheckerExtension.java     |  98 ++++
 .../kafka/gradle/KafkaPublicApiCheckerPlugin.java  | 117 ++++
 .../kafka/gradle/KafkaPublicApiCheckerTask.java    | 180 ++++++
 .../gradle/KafkaInternalApiCheckerPluginTest.java  |  93 +++
 .../gradle/KafkaInternalApiCheckerTaskTest.java    | 169 ++++++
 .../gradle/KafkaPublicApiCheckerTaskTest.java      | 188 +++++++
 api-checker/maven-plugin/build.gradle              |  65 +++
 .../kafka/maven/KafkaInternalApiCheckerMojo.java   | 226 ++++++++
 .../src/main/resources/META-INF/maven/plugin.xml   | 134 +++++
 .../apache/kafka/maven/PluginXmlParityTest.java    | 146 +++++
 api-checker/settings.gradle                        |  20 +
 build.gradle                                       |  33 ++
 checkstyle/import-control-api-checker-test.xml     |  35 ++
 checkstyle/import-control-api-checker.xml          |  32 ++
 .../clients/admin/AbortTransactionOptions.java     |   4 +
 .../clients/admin/AbortTransactionResult.java      |   3 +
 .../kafka/clients/admin/AbortTransactionSpec.java  |   3 +
 .../kafka/clients/admin/AbstractOptions.java       |   2 +
 .../kafka/clients/admin/AddRaftVoterOptions.java   |   2 +
 .../kafka/clients/admin/AddRaftVoterResult.java    |   2 +
 .../java/org/apache/kafka/clients/admin/Admin.java |   2 +
 .../apache/kafka/clients/admin/AdminClient.java    |   3 +
 .../kafka/clients/admin/AdminClientConfig.java     |   2 +
 .../clients/admin/AlterClientQuotasOptions.java    |   3 +
 .../clients/admin/AlterClientQuotasResult.java     |   2 +
 .../apache/kafka/clients/admin/AlterConfigOp.java  |   3 +
 .../kafka/clients/admin/AlterConfigsOptions.java   |   3 +
 .../kafka/clients/admin/AlterConfigsResult.java    |   2 +
 .../admin/AlterConsumerGroupOffsetsOptions.java    |   4 +
 .../admin/AlterConsumerGroupOffsetsResult.java     |   2 +
 .../admin/AlterPartitionReassignmentsOptions.java  |   3 +
 .../admin/AlterPartitionReassignmentsResult.java   |   2 +
 .../clients/admin/AlterReplicaLogDirsOptions.java  |   3 +
 .../clients/admin/AlterReplicaLogDirsResult.java   |   2 +
 .../admin/AlterShareGroupOffsetsOptions.java       |   4 +
 .../admin/AlterShareGroupOffsetsResult.java        |   2 +
 .../admin/AlterStreamsGroupOffsetsOptions.java     |   4 +-
 .../admin/AlterStreamsGroupOffsetsResult.java      |   2 +
 .../admin/AlterUserScramCredentialsOptions.java    |   3 +
 .../admin/AlterUserScramCredentialsResult.java     |   2 +
 .../clients/admin/ClassicGroupDescription.java     |   2 +
 .../org/apache/kafka/clients/admin/Config.java     |   3 +
 .../apache/kafka/clients/admin/ConfigEntry.java    |   3 +
 .../clients/admin/ConsumerGroupDescription.java    |   2 +
 .../kafka/clients/admin/CreateAclsOptions.java     |   3 +
 .../kafka/clients/admin/CreateAclsResult.java      |   2 +
 .../admin/CreateDelegationTokenOptions.java        |   2 +
 .../clients/admin/CreateDelegationTokenResult.java |   2 +
 .../clients/admin/CreatePartitionsOptions.java     |   3 +
 .../clients/admin/CreatePartitionsResult.java      |   2 +
 .../kafka/clients/admin/CreateTopicsOptions.java   |   3 +
 .../kafka/clients/admin/CreateTopicsResult.java    |   2 +
 .../kafka/clients/admin/DeleteAclsOptions.java     |   3 +
 .../kafka/clients/admin/DeleteAclsResult.java      |   2 +
 .../admin/DeleteConsumerGroupOffsetsOptions.java   |   4 +
 .../admin/DeleteConsumerGroupOffsetsResult.java    |   2 +
 .../clients/admin/DeleteConsumerGroupsOptions.java |   4 +
 .../clients/admin/DeleteConsumerGroupsResult.java  |   3 +
 .../kafka/clients/admin/DeleteRecordsOptions.java  |   3 +
 .../kafka/clients/admin/DeleteRecordsResult.java   |   2 +
 .../admin/DeleteShareGroupOffsetsOptions.java      |   4 +
 .../admin/DeleteShareGroupOffsetsResult.java       |   2 +
 .../clients/admin/DeleteShareGroupsOptions.java    |   4 +
 .../clients/admin/DeleteShareGroupsResult.java     |   3 +
 .../admin/DeleteStreamsGroupOffsetsOptions.java    |   2 +
 .../admin/DeleteStreamsGroupOffsetsResult.java     |   2 +
 .../clients/admin/DeleteStreamsGroupsOptions.java  |   2 +
 .../clients/admin/DeleteStreamsGroupsResult.java   |   2 +
 .../kafka/clients/admin/DeleteTopicsOptions.java   |   3 +
 .../kafka/clients/admin/DeleteTopicsResult.java    |   2 +
 .../apache/kafka/clients/admin/DeletedRecords.java |   3 +
 .../kafka/clients/admin/DescribeAclsOptions.java   |   2 +
 .../kafka/clients/admin/DescribeAclsResult.java    |   2 +
 .../admin/DescribeClassicGroupsOptions.java        |   3 +
 .../clients/admin/DescribeClassicGroupsResult.java |   2 +
 .../clients/admin/DescribeClientQuotasOptions.java |   2 +
 .../clients/admin/DescribeClientQuotasResult.java  |   2 +
 .../clients/admin/DescribeClusterOptions.java      |   3 +
 .../kafka/clients/admin/DescribeClusterResult.java |   2 +
 .../clients/admin/DescribeConfigsOptions.java      |   3 +
 .../kafka/clients/admin/DescribeConfigsResult.java |   2 +
 .../admin/DescribeConsumerGroupsOptions.java       |   3 +
 .../admin/DescribeConsumerGroupsResult.java        |   2 +
 .../admin/DescribeDelegationTokenOptions.java      |   2 +
 .../admin/DescribeDelegationTokenResult.java       |   2 +
 .../clients/admin/DescribeFeaturesOptions.java     |   4 +
 .../clients/admin/DescribeFeaturesResult.java      |   3 +
 .../clients/admin/DescribeLogDirsOptions.java      |   3 +
 .../kafka/clients/admin/DescribeLogDirsResult.java |   2 +
 .../admin/DescribeMetadataQuorumOptions.java       |   4 +
 .../admin/DescribeMetadataQuorumResult.java        |   3 +
 .../clients/admin/DescribeProducersOptions.java    |   4 +
 .../clients/admin/DescribeProducersResult.java     |   3 +
 .../admin/DescribeReplicaLogDirsOptions.java       |   3 +
 .../admin/DescribeReplicaLogDirsResult.java        |   2 +
 .../clients/admin/DescribeShareGroupsOptions.java  |   4 +
 .../clients/admin/DescribeShareGroupsResult.java   |   3 +
 .../admin/DescribeStreamsGroupsOptions.java        |   2 +
 .../clients/admin/DescribeStreamsGroupsResult.java |   2 +
 .../kafka/clients/admin/DescribeTopicsOptions.java |   3 +
 .../kafka/clients/admin/DescribeTopicsResult.java  |   2 +
 .../clients/admin/DescribeTransactionsOptions.java |   4 +
 .../clients/admin/DescribeTransactionsResult.java  |   3 +
 .../admin/DescribeUserScramCredentialsOptions.java |   3 +
 .../admin/DescribeUserScramCredentialsResult.java  |   2 +
 .../kafka/clients/admin/ElectLeadersOptions.java   |   2 +
 .../kafka/clients/admin/ElectLeadersResult.java    |   3 +-
 .../apache/kafka/clients/admin/EndpointType.java   |   3 +
 .../admin/ExpireDelegationTokenOptions.java        |   3 +
 .../clients/admin/ExpireDelegationTokenResult.java |   2 +
 .../kafka/clients/admin/FeatureMetadata.java       |   3 +
 .../apache/kafka/clients/admin/FeatureUpdate.java  |   4 +
 .../kafka/clients/admin/FenceProducersOptions.java |   3 +
 .../kafka/clients/admin/FenceProducersResult.java  |   2 +
 .../kafka/clients/admin/FinalizedVersionRange.java |   4 +
 .../kafka/clients/admin/ForwardingAdmin.java       |   2 +
 .../apache/kafka/clients/admin/GroupListing.java   |   3 +
 .../kafka/clients/admin/KafkaAdminClient.java      |   2 +
 .../clients/admin/ListConfigResourcesOptions.java  |   4 +
 .../clients/admin/ListConfigResourcesResult.java   |   2 +
 .../admin/ListConsumerGroupOffsetsOptions.java     |   3 +
 .../admin/ListConsumerGroupOffsetsResult.java      |   2 +
 .../admin/ListConsumerGroupOffsetsSpec.java        |   2 +
 .../kafka/clients/admin/ListGroupsOptions.java     |   3 +
 .../kafka/clients/admin/ListGroupsResult.java      |   2 +
 .../kafka/clients/admin/ListOffsetsOptions.java    |   3 +
 .../kafka/clients/admin/ListOffsetsResult.java     |   3 +
 .../admin/ListPartitionReassignmentsOptions.java   |   3 +
 .../admin/ListPartitionReassignmentsResult.java    |   2 +
 .../admin/ListShareGroupOffsetsOptions.java        |   4 +
 .../clients/admin/ListShareGroupOffsetsResult.java |   3 +
 .../clients/admin/ListShareGroupOffsetsSpec.java   |   3 +
 .../admin/ListStreamsGroupOffsetsOptions.java      |   3 +-
 .../admin/ListStreamsGroupOffsetsResult.java       |   2 +
 .../clients/admin/ListStreamsGroupOffsetsSpec.java |   2 +
 .../kafka/clients/admin/ListTopicsOptions.java     |   3 +
 .../kafka/clients/admin/ListTopicsResult.java      |   2 +
 .../clients/admin/ListTransactionsOptions.java     |   3 +
 .../clients/admin/ListTransactionsResult.java      |   2 +
 .../kafka/clients/admin/LogDirDescription.java     |   2 +
 .../kafka/clients/admin/MemberAssignment.java      |   3 +
 .../kafka/clients/admin/MemberDescription.java     |   3 +
 .../apache/kafka/clients/admin/MemberToRemove.java |   2 +
 .../clients/admin/NewPartitionReassignment.java    |   3 +
 .../apache/kafka/clients/admin/NewPartitions.java  |   3 +
 .../org/apache/kafka/clients/admin/NewTopic.java   |   2 +
 .../org/apache/kafka/clients/admin/OffsetSpec.java |   4 +
 .../kafka/clients/admin/PartitionReassignment.java |   3 +
 .../apache/kafka/clients/admin/ProducerState.java  |   4 +
 .../org/apache/kafka/clients/admin/QuorumInfo.java |   3 +
 .../kafka/clients/admin/RaftVoterEndpoint.java     |   2 +
 .../kafka/clients/admin/RecordsToDelete.java       |   3 +
 .../RemoveMembersFromConsumerGroupOptions.java     |   4 +
 .../RemoveMembersFromConsumerGroupResult.java      |   2 +
 .../clients/admin/RemoveRaftVoterOptions.java      |   2 +
 .../kafka/clients/admin/RemoveRaftVoterResult.java |   2 +
 .../clients/admin/RenewDelegationTokenOptions.java |   3 +
 .../clients/admin/RenewDelegationTokenResult.java  |   2 +
 .../apache/kafka/clients/admin/ReplicaInfo.java    |   4 +
 .../kafka/clients/admin/ScramCredentialInfo.java   |   3 +
 .../apache/kafka/clients/admin/ScramMechanism.java |   3 +
 .../kafka/clients/admin/ShareGroupDescription.java |   2 +
 .../kafka/clients/admin/ShareMemberAssignment.java |   3 +
 .../clients/admin/ShareMemberDescription.java      |   4 +
 .../clients/admin/SharePartitionOffsetInfo.java    |   3 +
 .../clients/admin/StreamsGroupDescription.java     |   2 +
 .../admin/StreamsGroupMemberAssignment.java        |   2 +
 .../admin/StreamsGroupMemberDescription.java       |   2 +
 .../admin/StreamsGroupSubtopologyDescription.java  |   2 +
 .../admin/StreamsGroupTopologyDescription.java     |   2 +
 .../StreamsGroupTopologyDescriptionStatus.java     |   2 +
 .../kafka/clients/admin/SupportedVersionRange.java |   4 +
 .../clients/admin/TerminateTransactionOptions.java |   3 +
 .../clients/admin/TerminateTransactionResult.java  |   2 +
 .../kafka/clients/admin/TopicDescription.java      |   2 +
 .../apache/kafka/clients/admin/TopicListing.java   |   2 +
 .../clients/admin/TransactionDescription.java      |   3 +
 .../kafka/clients/admin/TransactionListing.java    |   4 +
 .../kafka/clients/admin/TransactionState.java      |   4 +
 .../clients/admin/UnregisterBrokerOptions.java     |   3 +
 .../clients/admin/UnregisterBrokerResult.java      |   2 +
 .../kafka/clients/admin/UpdateFeaturesOptions.java |   4 +
 .../kafka/clients/admin/UpdateFeaturesResult.java  |   3 +
 .../admin/UserScramCredentialAlteration.java       |   3 +
 .../clients/admin/UserScramCredentialDeletion.java |   3 +
 .../admin/UserScramCredentialUpsertion.java        |   2 +
 .../admin/UserScramCredentialsDescription.java     |   3 +
 .../kafka/clients/consumer/AcknowledgeType.java    |   4 +
 .../consumer/AcknowledgementCommitCallback.java    |   2 +
 .../kafka/clients/consumer/CloseOptions.java       |   3 +
 .../clients/consumer/CommitFailedException.java    |   3 +
 .../apache/kafka/clients/consumer/Consumer.java    |   2 +
 .../kafka/clients/consumer/ConsumerConfig.java     |   2 +
 .../clients/consumer/ConsumerGroupMetadata.java    |   2 +
 .../clients/consumer/ConsumerInterceptor.java      |   4 +-
 .../consumer/ConsumerPartitionAssignor.java        |   2 +
 .../consumer/ConsumerRebalanceListener.java        |   3 +
 .../kafka/clients/consumer/ConsumerRecord.java     |   2 +
 .../kafka/clients/consumer/ConsumerRecords.java    |   2 +
 .../consumer/CooperativeStickyAssignor.java        |   7 +
 .../kafka/clients/consumer/GroupProtocol.java      |   4 +
 .../clients/consumer/InvalidOffsetException.java   |   3 +
 .../kafka/clients/consumer/KafkaConsumer.java      |   2 +
 .../kafka/clients/consumer/KafkaShareConsumer.java |   2 +
 .../clients/consumer/LogTruncationException.java   |   3 +
 .../kafka/clients/consumer/MockConsumer.java       |   4 +-
 .../kafka/clients/consumer/MockShareConsumer.java  |   2 +
 .../consumer/NoOffsetForPartitionException.java    |   3 +
 .../kafka/clients/consumer/OffsetAndMetadata.java  |   2 +
 .../kafka/clients/consumer/OffsetAndTimestamp.java |   4 +
 .../clients/consumer/OffsetCommitCallback.java     |   3 +
 .../consumer/OffsetOutOfRangeException.java        |   3 +
 .../kafka/clients/consumer/RangeAssignor.java      |   5 +
 .../kafka/clients/consumer/RebalanceConsumer.java  |   4 +-
 .../kafka/clients/consumer/RebalanceListener.java  |   3 +
 .../consumer/RetriableCommitFailedException.java   |   2 +
 .../kafka/clients/consumer/RoundRobinAssignor.java |   4 +
 .../kafka/clients/consumer/ShareConsumer.java      |   2 +
 .../kafka/clients/consumer/StickyAssignor.java     |   7 +
 .../clients/consumer/SubscriptionPattern.java      |   4 +
 .../clients/producer/BufferExhaustedException.java |   2 +
 .../apache/kafka/clients/producer/Callback.java    |   4 +
 .../kafka/clients/producer/KafkaProducer.java      |   4 +-
 .../kafka/clients/producer/MockProducer.java       |   2 +
 .../apache/kafka/clients/producer/Partitioner.java |   3 +
 .../kafka/clients/producer/PreparedTxnState.java   |   2 +
 .../apache/kafka/clients/producer/Producer.java    |   2 +
 .../kafka/clients/producer/ProducerConfig.java     |   2 +
 .../clients/producer/ProducerInterceptor.java      |   2 +
 .../kafka/clients/producer/ProducerRecord.java     |   2 +
 .../kafka/clients/producer/RecordMetadata.java     |   2 +
 .../clients/producer/RoundRobinPartitioner.java    |   2 +
 .../org/apache/kafka/common/ClassicGroupState.java |   3 +
 .../main/java/org/apache/kafka/common/Cluster.java |   4 +
 .../org/apache/kafka/common/ClusterResource.java   |   3 +
 .../kafka/common/ClusterResourceListener.java      |   4 +
 .../java/org/apache/kafka/common/Configurable.java |   4 +
 .../java/org/apache/kafka/common/ElectionType.java |   3 +
 .../java/org/apache/kafka/common/Endpoint.java     |   2 +
 .../java/org/apache/kafka/common/GroupState.java   |   3 +
 .../java/org/apache/kafka/common/GroupType.java    |   4 +
 .../kafka/common/InvalidRecordException.java       |   2 +
 .../org/apache/kafka/common/IsolationLevel.java    |   4 +
 .../org/apache/kafka/common/KafkaException.java    |   4 +
 .../java/org/apache/kafka/common/KafkaFuture.java  |   2 +
 .../org/apache/kafka/common/MessageFormatter.java  |   3 +
 .../main/java/org/apache/kafka/common/Metric.java  |   4 +
 .../java/org/apache/kafka/common/MetricName.java   |   4 +
 .../apache/kafka/common/MetricNameTemplate.java    |   4 +
 .../main/java/org/apache/kafka/common/Node.java    |   4 +
 .../org/apache/kafka/common/PartitionInfo.java     |   4 +
 .../org/apache/kafka/common/Reconfigurable.java    |   2 +
 .../org/apache/kafka/common/TopicCollection.java   |   4 +
 .../org/apache/kafka/common/TopicIdPartition.java  |   4 +
 .../org/apache/kafka/common/TopicPartition.java    |   4 +
 .../apache/kafka/common/TopicPartitionInfo.java    |   3 +
 .../apache/kafka/common/TopicPartitionReplica.java |   5 +-
 .../main/java/org/apache/kafka/common/Uuid.java    |   4 +
 .../kafka/common/acl/AccessControlEntry.java       |   3 +
 .../kafka/common/acl/AccessControlEntryFilter.java |   3 +
 .../org/apache/kafka/common/acl/AclBinding.java    |   2 +
 .../apache/kafka/common/acl/AclBindingFilter.java  |   2 +
 .../org/apache/kafka/common/acl/AclOperation.java  |   3 +
 .../apache/kafka/common/acl/AclPermissionType.java |   3 +
 .../kafka/common/annotation/InterfaceAudience.java |  57 ++
 .../common/annotation/InterfaceStability.java      |   1 +
 .../annotation/SuppressKafkaInternalApiUsage.java  |  46 ++
 .../apache/kafka/common/config/AbstractConfig.java |   2 +
 .../org/apache/kafka/common/config/Config.java     |   4 +
 .../kafka/common/config/ConfigChangeCallback.java  |   2 +
 .../org/apache/kafka/common/config/ConfigData.java |   2 +
 .../org/apache/kafka/common/config/ConfigDef.java  |   2 +
 .../kafka/common/config/ConfigException.java       |   3 +
 .../apache/kafka/common/config/ConfigResource.java |   3 +
 .../kafka/common/config/ConfigTransformer.java     |   2 +
 .../common/config/ConfigTransformerResult.java     |   2 +
 .../apache/kafka/common/config/ConfigValue.java    |   4 +
 .../apache/kafka/common/config/LogLevelConfig.java |   3 +
 .../apache/kafka/common/config/SaslConfigs.java    |   2 +
 .../apache/kafka/common/config/SecurityConfig.java |   4 +
 .../apache/kafka/common/config/SslClientAuth.java  |   3 +
 .../org/apache/kafka/common/config/SslConfigs.java |   2 +
 .../apache/kafka/common/config/TopicConfig.java    |   3 +
 .../common/config/provider/ConfigProvider.java     |   2 +
 .../config/provider/DirectoryConfigProvider.java   |   4 +-
 .../config/provider/EnvVarConfigProvider.java      |   2 +
 .../common/config/provider/FileConfigProvider.java |   2 +
 .../apache/kafka/common/config/types/Password.java |   4 +
 .../apache/kafka/common/errors/ApiException.java   |   3 +
 .../errors/ApplicationRecoverableException.java    |   4 +
 .../common/errors/AuthenticationException.java     |   4 +
 .../common/errors/AuthorizationException.java      |   4 +
 .../common/errors/AuthorizerNotReadyException.java |   4 +
 .../errors/BrokerIdNotRegisteredException.java     |   4 +
 .../common/errors/BrokerNotAvailableException.java |   4 +
 .../errors/ClusterAuthorizationException.java      |   4 +
 .../errors/ConcurrentTransactionsException.java    |   4 +
 .../common/errors/ControllerMovedException.java    |   4 +
 .../errors/CoordinatorLoadInProgressException.java |   4 +
 .../errors/CoordinatorNotAvailableException.java   |   4 +
 .../common/errors/CorruptRecordException.java      |   4 +
 .../DelegationTokenAuthorizationException.java     |   4 +
 .../errors/DelegationTokenDisabledException.java   |   4 +
 .../errors/DelegationTokenExpiredException.java    |   4 +
 .../errors/DelegationTokenNotFoundException.java   |   4 +
 .../DelegationTokenOwnerMismatchException.java     |   4 +
 .../kafka/common/errors/DisconnectException.java   |   3 +
 .../DuplicateBrokerRegistrationException.java      |   4 +
 .../common/errors/DuplicateResourceException.java  |   4 +
 .../common/errors/DuplicateSequenceException.java  |   4 +
 .../common/errors/DuplicateVoterException.java     |   4 +
 .../common/errors/ElectionNotNeededException.java  |   4 +
 .../EligibleLeadersNotAvailableException.java      |   4 +
 .../errors/FeatureUpdateFailedException.java       |   4 +
 .../common/errors/FencedInstanceIdException.java   |   4 +
 .../common/errors/FencedLeaderEpochException.java  |   4 +
 .../common/errors/FencedMemberEpochException.java  |   4 +
 .../common/errors/FencedStateEpochException.java   |   4 +
 .../errors/FetchSessionIdNotFoundException.java    |   3 +
 .../errors/FetchSessionTopicIdException.java       |   4 +
 .../common/errors/GroupAuthorizationException.java |   4 +
 .../errors/GroupDeletionFailedException.java       |   4 +
 .../common/errors/GroupIdNotFoundException.java    |   4 +
 .../errors/GroupMaxSizeReachedException.java       |   4 +
 .../common/errors/GroupNotEmptyException.java      |   4 +
 .../errors/GroupSubscribedToTopicException.java    |   4 +
 .../common/errors/IllegalGenerationException.java  |   4 +
 .../common/errors/IllegalSaslStateException.java   |   4 +
 .../errors/InconsistentClusterIdException.java     |   4 +
 .../errors/InconsistentGroupProtocolException.java |   4 +
 .../errors/InconsistentTopicIdException.java       |   4 +
 .../errors/InconsistentVoterSetException.java      |   4 +
 .../common/errors/IneligibleReplicaException.java  |   4 +
 .../kafka/common/errors/InterruptException.java    |   3 +
 .../errors/InvalidCommitOffsetSizeException.java   |   4 +
 .../errors/InvalidConfigurationException.java      |   4 +
 .../errors/InvalidFetchSessionEpochException.java  |   3 +
 .../common/errors/InvalidFetchSizeException.java   |   4 +
 .../common/errors/InvalidGroupIdException.java     |   4 +
 .../common/errors/InvalidMetadataException.java    |   4 +
 .../common/errors/InvalidOffsetException.java      |   4 +
 .../common/errors/InvalidPartitionsException.java  |   4 +
 .../common/errors/InvalidPidMappingException.java  |   4 +
 .../errors/InvalidPrincipalTypeException.java      |   4 +
 .../errors/InvalidProducerEpochException.java      |   4 +
 .../common/errors/InvalidRecordStateException.java |   4 +
 .../errors/InvalidRegistrationException.java       |   4 +
 .../common/errors/InvalidRegularExpression.java    |   3 +
 .../errors/InvalidReplicaAssignmentException.java  |   4 +
 .../errors/InvalidReplicationFactorException.java  |   4 +
 .../common/errors/InvalidRequestException.java     |   4 +
 .../errors/InvalidRequiredAcksException.java       |   4 +
 .../errors/InvalidSessionTimeoutException.java     |   4 +
 .../errors/InvalidShareSessionEpochException.java  |   4 +
 .../common/errors/InvalidTimestampException.java   |   4 +
 .../kafka/common/errors/InvalidTopicException.java |   5 +-
 .../common/errors/InvalidTxnStateException.java    |   4 +
 .../common/errors/InvalidTxnTimeoutException.java  |   4 +
 .../errors/InvalidUpdateVersionException.java      |   4 +
 .../common/errors/InvalidVoterKeyException.java    |   4 +
 .../kafka/common/errors/KafkaStorageException.java |   4 +
 .../common/errors/LeaderNotAvailableException.java |   4 +
 .../common/errors/ListenerNotFoundException.java   |   4 +
 .../common/errors/LogDirNotFoundException.java     |   4 +
 .../common/errors/MemberIdRequiredException.java   |   4 +
 .../errors/MismatchedEndpointTypeException.java    |   4 +
 .../kafka/common/errors/NetworkException.java      |   4 +
 .../common/errors/NewLeaderElectedException.java   |   4 +
 .../errors/NoReassignmentInProgressException.java  |   3 +
 .../common/errors/NotControllerException.java      |   4 +
 .../common/errors/NotCoordinatorException.java     |   4 +
 .../NotEnoughReplicasAfterAppendException.java     |   4 +
 .../common/errors/NotEnoughReplicasException.java  |   4 +
 .../errors/NotLeaderOrFollowerException.java       |   4 +
 .../common/errors/OffsetMetadataTooLarge.java      |   4 +
 .../OffsetMovedToTieredStorageException.java       |   4 +
 .../common/errors/OffsetNotAvailableException.java |   4 +
 .../common/errors/OffsetOutOfRangeException.java   |   4 +
 .../errors/OperationNotAttemptedException.java     |   4 +
 .../common/errors/OutOfOrderSequenceException.java |   4 +
 .../common/errors/PolicyViolationException.java    |   4 +
 .../common/errors/PositionOutOfRangeException.java |   4 +
 .../PreferredLeaderNotAvailableException.java      |   4 +
 .../errors/PrincipalDeserializationException.java  |   4 +
 .../common/errors/ProducerFencedException.java     |   4 +
 .../errors/ReassignmentInProgressException.java    |   3 +
 .../errors/RebalanceInProgressException.java       |   4 +
 .../errors/RebootstrapRequiredException.java       |   3 +
 .../errors/RecordBatchTooLargeException.java       |   4 +
 .../errors/RecordDeserializationException.java     |   2 +
 .../common/errors/RecordTooLargeException.java     |   3 +
 .../common/errors/RefreshRetriableException.java   |   4 +
 .../errors/ReplicaNotAvailableException.java       |   4 +
 .../common/errors/ResourceNotFoundException.java   |   4 +
 .../kafka/common/errors/RetriableException.java    |   4 +
 .../common/errors/SaslAuthenticationException.java |   4 +
 .../common/errors/SecurityDisabledException.java   |   4 +
 .../common/errors/SerializationException.java      |   3 +
 .../errors/ShareSessionLimitReachedException.java  |   4 +
 .../errors/ShareSessionNotFoundException.java      |   4 +
 .../common/errors/SnapshotNotFoundException.java   |   4 +
 .../common/errors/SslAuthenticationException.java  |   4 +
 .../common/errors/StaleBrokerEpochException.java   |   4 +
 .../common/errors/StaleMemberEpochException.java   |   4 +
 .../StreamsInvalidTopologyEpochException.java      |   4 +
 .../errors/StreamsInvalidTopologyException.java    |   4 +
 ...msTopologyDescriptionUpdateFailedException.java |   4 +
 .../errors/StreamsTopologyFencedException.java     |   4 +
 .../common/errors/TelemetryTooLargeException.java  |   4 +
 .../errors/ThrottlingQuotaExceededException.java   |   4 +
 .../kafka/common/errors/TimeoutException.java      |   4 +
 .../common/errors/TopicAuthorizationException.java |   4 +
 .../errors/TopicDeletionDisabledException.java     |   3 +
 .../kafka/common/errors/TopicExistsException.java  |   4 +
 .../errors/TransactionAbortableException.java      |   4 +
 .../common/errors/TransactionAbortedException.java |   4 +
 .../TransactionCoordinatorFencedException.java     |   4 +
 .../TransactionalIdAuthorizationException.java     |   4 +
 .../errors/TransactionalIdNotFoundException.java   |   4 +
 .../errors/UnacceptableCredentialException.java    |   4 +
 .../errors/UnknownControllerIdException.java       |   4 +
 .../common/errors/UnknownLeaderEpochException.java |   4 +
 .../common/errors/UnknownMemberIdException.java    |   4 +
 .../common/errors/UnknownProducerIdException.java  |   3 +
 .../common/errors/UnknownServerException.java      |   4 +
 .../errors/UnknownSubscriptionIdException.java     |   4 +
 .../common/errors/UnknownTopicIdException.java     |   4 +
 .../errors/UnknownTopicOrPartitionException.java   |   4 +
 .../errors/UnreleasedInstanceIdException.java      |   4 +
 .../errors/UnstableOffsetCommitException.java      |   4 +
 .../errors/UnsupportedAssignorException.java       |   4 +
 .../UnsupportedByAuthenticationException.java      |   4 +
 .../UnsupportedCompressionTypeException.java       |   4 +
 .../errors/UnsupportedEndpointTypeException.java   |   4 +
 .../UnsupportedForMessageFormatException.java      |   4 +
 .../errors/UnsupportedSaslMechanismException.java  |   4 +
 .../common/errors/UnsupportedVersionException.java |   4 +
 .../common/errors/VoterNotFoundException.java      |   4 +
 .../kafka/common/errors/WakeupException.java       |   3 +
 .../org/apache/kafka/common/header/Header.java     |   4 +
 .../org/apache/kafka/common/header/Headers.java    |   3 +
 .../apache/kafka/common/metrics/CompoundStat.java  |   3 +
 .../org/apache/kafka/common/metrics/Gauge.java     |   4 +
 .../apache/kafka/common/metrics/JmxReporter.java   |   2 +
 .../apache/kafka/common/metrics/KafkaMetric.java   |   4 +
 .../kafka/common/metrics/KafkaMetricsContext.java  |   4 +
 .../apache/kafka/common/metrics/Measurable.java    |   4 +
 .../kafka/common/metrics/MeasurableStat.java       |   4 +
 .../apache/kafka/common/metrics/MetricConfig.java  |   4 +
 .../kafka/common/metrics/MetricValueProvider.java  |   4 +
 .../org/apache/kafka/common/metrics/Metrics.java   |   9 +
 .../kafka/common/metrics/MetricsContext.java       |   4 +
 .../kafka/common/metrics/MetricsReporter.java      |   2 +
 .../apache/kafka/common/metrics/Monitorable.java   |   4 +
 .../apache/kafka/common/metrics/PluginMetrics.java |   3 +
 .../org/apache/kafka/common/metrics/Quota.java     |   4 +
 .../common/metrics/QuotaViolationException.java    |   3 +
 .../org/apache/kafka/common/metrics/Sensor.java    |   2 +
 .../java/org/apache/kafka/common/metrics/Stat.java |   4 +
 .../org/apache/kafka/common/metrics/stats/Avg.java |   2 +
 .../common/metrics/stats/CumulativeCount.java      |   2 +
 .../kafka/common/metrics/stats/CumulativeSum.java  |   2 +
 .../kafka/common/metrics/stats/Frequencies.java    |   2 +
 .../kafka/common/metrics/stats/Frequency.java      |   4 +-
 .../kafka/common/metrics/stats/Histogram.java      |   4 +
 .../org/apache/kafka/common/metrics/stats/Max.java |   2 +
 .../apache/kafka/common/metrics/stats/Meter.java   |   4 +-
 .../org/apache/kafka/common/metrics/stats/Min.java |   2 +
 .../kafka/common/metrics/stats/Percentile.java     |   3 +
 .../kafka/common/metrics/stats/Percentiles.java    |   2 +
 .../apache/kafka/common/metrics/stats/Rate.java    |   2 +
 .../kafka/common/metrics/stats/SampledStat.java    |   2 +
 .../kafka/common/metrics/stats/SimpleRate.java     |   2 +
 .../kafka/common/metrics/stats/TokenBucket.java    |   2 +
 .../apache/kafka/common/metrics/stats/Value.java   |   2 +
 .../kafka/common/metrics/stats/WindowedCount.java  |   2 +
 .../kafka/common/metrics/stats/WindowedSum.java    |   2 +
 .../kafka/common/quota/ClientQuotaAlteration.java  |   3 +
 .../kafka/common/quota/ClientQuotaEntity.java      |   3 +
 .../kafka/common/quota/ClientQuotaFilter.java      |   3 +
 .../common/quota/ClientQuotaFilterComponent.java   |   3 +
 .../apache/kafka/common/record/TimestampType.java  |   4 +
 .../apache/kafka/common/resource/PatternType.java  |   3 +
 .../org/apache/kafka/common/resource/Resource.java |   3 +
 .../kafka/common/resource/ResourcePattern.java     |   3 +
 .../common/resource/ResourcePatternFilter.java     |   3 +
 .../apache/kafka/common/resource/ResourceType.java |   3 +
 .../security/auth/AuthenticateCallbackHandler.java |   3 +
 .../security/auth/AuthenticationContext.java       |   5 +-
 .../kafka/common/security/auth/KafkaPrincipal.java |   3 +
 .../security/auth/KafkaPrincipalBuilder.java       |   4 +
 .../common/security/auth/KafkaPrincipalSerde.java  |   2 +
 .../apache/kafka/common/security/auth/Login.java   |   4 +
 .../auth/PlaintextAuthenticationContext.java       |   4 +
 .../security/auth/SaslAuthenticationContext.java   |   4 +
 .../kafka/common/security/auth/SaslExtensions.java |   4 +
 .../security/auth/SaslExtensionsCallback.java      |   3 +
 .../common/security/auth/SecurityProtocol.java     |   4 +
 .../security/auth/SecurityProviderCreator.java     |   3 +
 .../security/auth/SslAuthenticationContext.java    |   4 +
 .../common/security/auth/SslEngineFactory.java     |   3 +
 .../security/oauthbearer/BrokerJwtValidator.java   |   2 +
 .../oauthbearer/ClientCredentialsJwtRetriever.java |   4 +
 .../security/oauthbearer/ClientJwtValidator.java   |   2 +
 .../security/oauthbearer/DefaultJwtRetriever.java  |   2 +
 .../security/oauthbearer/DefaultJwtValidator.java  |   4 +
 .../security/oauthbearer/FileJwtRetriever.java     |   2 +
 .../oauthbearer/JwtBearerJwtRetriever.java         |   4 +
 .../common/security/oauthbearer/JwtRetriever.java  |   5 +-
 .../oauthbearer/JwtRetrieverException.java         |   2 +
 .../common/security/oauthbearer/JwtValidator.java  |   5 +-
 .../oauthbearer/JwtValidatorException.java         |   2 +
 .../OAuthBearerExtensionsValidatorCallback.java    |   2 +
 .../OAuthBearerLoginCallbackHandler.java           |   2 +
 .../oauthbearer/OAuthBearerLoginModule.java        |   2 +
 .../security/oauthbearer/OAuthBearerToken.java     |   4 +
 .../oauthbearer/OAuthBearerTokenCallback.java      |   4 +
 .../oauthbearer/OAuthBearerValidatorCallback.java  |   4 +
 .../OAuthBearerValidatorCallbackHandler.java       |   2 +
 .../security/plain/PlainAuthenticateCallback.java  |   3 +
 .../common/security/plain/PlainLoginModule.java    |   2 +
 .../common/security/scram/ScramCredential.java     |   4 +
 .../security/scram/ScramCredentialCallback.java    |   4 +
 .../security/scram/ScramExtensionsCallback.java    |   5 +-
 .../common/security/scram/ScramLoginModule.java    |   2 +
 .../security/token/delegation/DelegationToken.java |   4 +
 .../token/delegation/TokenInformation.java         |   2 +
 .../common/serialization/BooleanDeserializer.java  |   2 +
 .../common/serialization/BooleanSerializer.java    |   4 +
 .../serialization/ByteArrayDeserializer.java       |   4 +
 .../common/serialization/ByteArraySerializer.java  |   4 +
 .../serialization/ByteBufferDeserializer.java      |   2 +
 .../common/serialization/ByteBufferSerializer.java |   4 +
 .../common/serialization/BytesDeserializer.java    |   2 +
 .../common/serialization/BytesSerializer.java      |   2 +
 .../kafka/common/serialization/Deserializer.java   |   2 +
 .../common/serialization/DoubleDeserializer.java   |   2 +
 .../common/serialization/DoubleSerializer.java     |   4 +
 .../common/serialization/FloatDeserializer.java    |   2 +
 .../common/serialization/FloatSerializer.java      |   4 +
 .../common/serialization/IntegerDeserializer.java  |   2 +
 .../common/serialization/IntegerSerializer.java    |   4 +
 .../common/serialization/ListDeserializer.java     |   2 +
 .../kafka/common/serialization/ListSerializer.java |   2 +
 .../common/serialization/LongDeserializer.java     |   2 +
 .../kafka/common/serialization/LongSerializer.java |   4 +
 .../apache/kafka/common/serialization/Serde.java   |   4 +
 .../apache/kafka/common/serialization/Serdes.java  |   2 +
 .../kafka/common/serialization/Serializer.java     |   2 +
 .../common/serialization/ShortDeserializer.java    |   2 +
 .../common/serialization/ShortSerializer.java      |   4 +
 .../common/serialization/StringDeserializer.java   |   2 +
 .../common/serialization/StringSerializer.java     |   2 +
 .../common/serialization/UUIDDeserializer.java     |   2 +
 .../kafka/common/serialization/UUIDSerializer.java |   2 +
 .../common/serialization/VoidDeserializer.java     |   2 +
 .../kafka/common/serialization/VoidSerializer.java |   4 +
 .../java/org/apache/kafka/common/utils/Bytes.java  |   2 +
 .../kafka/server/authorizer/AclCreateResult.java   |   2 +
 .../kafka/server/authorizer/AclDeleteResult.java   |   2 +
 .../org/apache/kafka/server/authorizer/Action.java |   2 +
 .../authorizer/AuthorizableRequestContext.java     |   2 +
 .../server/authorizer/AuthorizationResult.java     |   3 +
 .../apache/kafka/server/authorizer/Authorizer.java |   2 +
 .../server/authorizer/AuthorizerServerInfo.java    |   2 +
 .../kafka/server/policy/AlterConfigPolicy.java     |   2 +
 .../kafka/server/policy/CreateTopicPolicy.java     |   2 +
 .../kafka/server/quota/ClientQuotaCallback.java    |   2 +
 .../kafka/server/quota/ClientQuotaEntity.java      |   4 +
 .../apache/kafka/server/quota/ClientQuotaType.java |   4 +
 .../server/telemetry/ClientTelemetryContext.java   |   2 +
 .../server/telemetry/ClientTelemetryExporter.java  |   3 +
 .../telemetry/ClientTelemetryExporterProvider.java |   2 +
 .../server/telemetry/ClientTelemetryPayload.java   |   2 +
 .../kafka/connect/components/ConnectPlugin.java    |   2 +
 .../apache/kafka/connect/components/Versioned.java |   3 +
 .../kafka/connect/connector/ConnectRecord.java     |   2 +
 .../apache/kafka/connect/connector/Connector.java  |   2 +
 .../kafka/connect/connector/ConnectorContext.java  |   2 +
 .../org/apache/kafka/connect/connector/Task.java   |   4 +
 .../ConnectorClientConfigOverridePolicy.java       |   2 +
 .../policy/ConnectorClientConfigRequest.java       |   2 +
 .../apache/kafka/connect/data/ConnectSchema.java   |   2 +
 .../java/org/apache/kafka/connect/data/Date.java   |   2 +
 .../org/apache/kafka/connect/data/Decimal.java     |   2 +
 .../java/org/apache/kafka/connect/data/Field.java  |   4 +
 .../java/org/apache/kafka/connect/data/Schema.java |   2 +
 .../apache/kafka/connect/data/SchemaAndValue.java  |   4 +
 .../apache/kafka/connect/data/SchemaBuilder.java   |   2 +
 .../apache/kafka/connect/data/SchemaProjector.java |   2 +
 .../java/org/apache/kafka/connect/data/Struct.java |   2 +
 .../java/org/apache/kafka/connect/data/Time.java   |   2 +
 .../org/apache/kafka/connect/data/Timestamp.java   |   2 +
 .../java/org/apache/kafka/connect/data/Values.java |   2 +
 .../connect/errors/AlreadyExistsException.java     |   4 +
 .../kafka/connect/errors/ConnectException.java     |   3 +
 .../apache/kafka/connect/errors/DataException.java |   4 +
 .../errors/IllegalWorkerStateException.java        |   4 +
 .../kafka/connect/errors/NotFoundException.java    |   4 +
 .../kafka/connect/errors/RetriableException.java   |   4 +
 .../connect/errors/SchemaBuilderException.java     |   4 +
 .../connect/errors/SchemaProjectorException.java   |   4 +
 .../kafka/connect/header/ConnectHeaders.java       |   2 +
 .../org/apache/kafka/connect/header/Header.java    |   2 +
 .../org/apache/kafka/connect/header/Headers.java   |   2 +
 .../apache/kafka/connect/health/AbstractState.java |   2 +
 .../connect/health/ConnectClusterDetails.java      |   3 +
 .../kafka/connect/health/ConnectClusterState.java  |   3 +
 .../kafka/connect/health/ConnectorHealth.java      |   2 +
 .../kafka/connect/health/ConnectorState.java       |   3 +
 .../apache/kafka/connect/health/ConnectorType.java |   4 +
 .../org/apache/kafka/connect/health/TaskState.java |   3 +
 .../kafka/connect/rest/ConnectRestExtension.java   |   2 +
 .../connect/rest/ConnectRestExtensionContext.java  |   3 +-
 .../kafka/connect/sink/ErrantRecordReporter.java   |   2 +
 .../apache/kafka/connect/sink/SinkConnector.java   |   2 +
 .../kafka/connect/sink/SinkConnectorContext.java   |   2 +
 .../org/apache/kafka/connect/sink/SinkRecord.java  |   2 +
 .../org/apache/kafka/connect/sink/SinkTask.java    |   2 +
 .../apache/kafka/connect/sink/SinkTaskContext.java |   2 +
 .../source/ConnectorTransactionBoundaries.java     |   4 +
 .../kafka/connect/source/ExactlyOnceSupport.java   |   4 +
 .../kafka/connect/source/SourceConnector.java      |   2 +
 .../connect/source/SourceConnectorContext.java     |   2 +
 .../apache/kafka/connect/source/SourceRecord.java  |   2 +
 .../apache/kafka/connect/source/SourceTask.java    |   2 +
 .../kafka/connect/source/SourceTaskContext.java    |   2 +
 .../kafka/connect/source/TransactionContext.java   |   4 +
 .../apache/kafka/connect/storage/Converter.java    |   2 +
 .../kafka/connect/storage/ConverterConfig.java     |   2 +
 .../kafka/connect/storage/ConverterType.java       |   4 +
 .../kafka/connect/storage/HeaderConverter.java     |   2 +
 .../kafka/connect/storage/OffsetStorageReader.java |   4 +
 .../connect/storage/SimpleHeaderConverter.java     |   2 +
 .../kafka/connect/storage/StringConverter.java     |   2 +
 .../connect/storage/StringConverterConfig.java     |   2 +
 .../kafka/connect/transforms/Transformation.java   |   2 +
 .../connect/transforms/predicates/Predicate.java   |   2 +
 .../apache/kafka/connect/util/ConnectorUtils.java  |   4 +
 .../apache/kafka/connect/mirror/Checkpoint.java    |   6 +
 .../connect/mirror/DefaultReplicationPolicy.java   |   2 +
 .../org/apache/kafka/connect/mirror/Heartbeat.java |   6 +
 .../connect/mirror/IdentityReplicationPolicy.java  |   3 +
 .../apache/kafka/connect/mirror/MirrorClient.java  |   2 +
 .../kafka/connect/mirror/MirrorClientConfig.java   |   2 +
 .../kafka/connect/mirror/RemoteClusterUtils.java   |   3 +-
 .../kafka/connect/mirror/ReplicationPolicy.java    |   3 +
 .../kafka/connect/mirror/SourceAndTarget.java      |   4 +
 .../apache/kafka/connect/tools/MockConnector.java  |   2 +
 .../kafka/connect/tools/MockSinkConnector.java     |   2 +
 .../apache/kafka/connect/tools/MockSinkTask.java   |   2 +
 .../kafka/connect/tools/MockSourceConnector.java   |   2 +
 .../apache/kafka/connect/tools/MockSourceTask.java |   2 +
 .../kafka/connect/tools/SchemaSourceConnector.java |   2 +
 .../kafka/connect/tools/SchemaSourceTask.java      |   2 +
 .../connect/tools/VerifiableSinkConnector.java     |   2 +
 .../kafka/connect/tools/VerifiableSinkTask.java    |   2 +
 .../connect/tools/VerifiableSourceConnector.java   |   2 +
 .../kafka/connect/tools/VerifiableSourceTask.java  |   2 +
 docs/apis/internal-api-checker.md                  | 187 ++++++
 .../assignor/ConsumerGroupPartitionAssignor.java   |   4 +
 .../group/api/assignor/GroupAssignment.java        |   4 +
 .../coordinator/group/api/assignor/GroupSpec.java  |   3 +
 .../group/api/assignor/MemberAssignment.java       |   3 +
 .../group/api/assignor/MemberSubscription.java     |   3 +
 .../group/api/assignor/PartitionAssignor.java      |   4 +
 .../api/assignor/PartitionAssignorException.java   |   2 +
 .../api/assignor/ShareGroupPartitionAssignor.java  |   4 +
 .../api/assignor/SubscribedTopicDescriber.java     |   3 +
 .../group/api/assignor/SubscriptionType.java       |   4 +
 .../streams/StreamsGroupTopologyDescription.java   |   2 +
 .../StreamsGroupTopologyDescriptionPlugin.java     |   2 +
 ...pologyDescriptionPermanentFailureException.java |   2 +
 ...pologyDescriptionTransientFailureException.java |   2 +
 release/release.py                                 |  19 +
 .../kafka/server/share/fetch/InFlightState.java    |   2 +-
 settings.gradle                                    |   9 +
 .../server/log/remote/storage/LogSegmentData.java  |   4 +
 .../log/remote/storage/RemoteLogMetadata.java      |   3 +
 .../remote/storage/RemoteLogMetadataManager.java   |   3 +
 .../log/remote/storage/RemoteLogSegmentId.java     |   3 +
 .../remote/storage/RemoteLogSegmentMetadata.java   |   3 +
 .../storage/RemoteLogSegmentMetadataUpdate.java    |   2 +
 .../log/remote/storage/RemoteLogSegmentState.java  |   4 +
 .../storage/RemotePartitionDeleteMetadata.java     |   3 +
 .../remote/storage/RemotePartitionDeleteState.java |   4 +
 .../storage/RemoteResourceNotFoundException.java   |   4 +
 .../log/remote/storage/RemoteStorageException.java |   4 +
 .../log/remote/storage/RemoteStorageManager.java   |   2 +
 .../log/remote/storage/RemoteStorageMetrics.java   |   2 +
 .../storage/RemoteStorageNotReadyException.java    |   4 +
 .../storage/RetriableRemoteStorageException.java   |   4 +
 .../remote/storage/LocalTieredStorageListener.java |   2 +-
 .../org/apache/kafka/streams/AutoOffsetReset.java  |   4 +
 .../apache/kafka/streams/ClientInstanceIds.java    |   3 +
 .../org/apache/kafka/streams/CloseOptions.java     |   4 +
 .../org/apache/kafka/streams/DslStoreFormat.java   |   4 +
 .../org/apache/kafka/streams/GroupProtocol.java    |   4 +
 .../apache/kafka/streams/KafkaClientSupplier.java  |   2 +
 .../org/apache/kafka/streams/KafkaStreams.java     |  13 +
 .../org/apache/kafka/streams/KeyQueryMetadata.java |   2 +
 .../java/org/apache/kafka/streams/KeyValue.java    |   4 +
 .../java/org/apache/kafka/streams/LagInfo.java     |   4 +
 .../apache/kafka/streams/StoreQueryParameters.java |   2 +
 .../org/apache/kafka/streams/StreamsBuilder.java   |   5 +
 .../org/apache/kafka/streams/StreamsConfig.java    |   2 +
 .../org/apache/kafka/streams/StreamsMetadata.java  |   2 +
 .../org/apache/kafka/streams/StreamsMetrics.java   |   2 +
 .../org/apache/kafka/streams/TaskMetadata.java     |   4 +-
 .../org/apache/kafka/streams/ThreadMetadata.java   |   4 +
 .../java/org/apache/kafka/streams/Topology.java    |   5 +
 .../org/apache/kafka/streams/TopologyConfig.java   |   2 +
 .../apache/kafka/streams/TopologyDescription.java  |   2 +
 .../errors/DefaultProductionExceptionHandler.java  |   2 +
 .../errors/DeserializationExceptionHandler.java    |   2 +
 .../kafka/streams/errors/ErrorHandlerContext.java  |   2 +
 .../InternalTopicsAlreadySetupException.java       |   4 +
 .../streams/errors/InvalidStateStoreException.java |   4 +
 .../InvalidStateStorePartitionException.java       |   2 +
 .../apache/kafka/streams/errors/LockException.java |   4 +
 .../errors/LogAndContinueExceptionHandler.java     |   2 +
 .../LogAndContinueProcessingExceptionHandler.java  |   2 +
 .../streams/errors/LogAndFailExceptionHandler.java |   2 +
 .../LogAndFailProcessingExceptionHandler.java      |   2 +
 .../MisconfiguredInternalTopicException.java       |   4 +
 .../errors/MissingInternalTopicsException.java     |   4 +
 .../errors/MissingSourceTopicException.java        |   4 +
 .../streams/errors/ProcessingExceptionHandler.java |   2 +
 .../streams/errors/ProcessorStateException.java    |   4 +
 .../streams/errors/ProductionExceptionHandler.java |   3 +
 .../errors/StateStoreMigratedException.java        |   4 +
 .../errors/StateStoreNotAvailableException.java    |   4 +
 .../kafka/streams/errors/StreamsException.java     |   2 +
 .../streams/errors/StreamsNotStartedException.java |   2 +
 .../errors/StreamsRebalancingException.java        |   4 +
 .../streams/errors/StreamsStoppedException.java    |   2 +
 .../errors/StreamsUncaughtExceptionHandler.java    |   4 +
 .../streams/errors/TaskAssignmentException.java    |   4 +
 .../streams/errors/TaskCorruptedException.java     |   2 +
 .../streams/errors/TaskIdFormatException.java      |   3 +
 .../streams/errors/TaskMigratedException.java      |   3 +
 .../kafka/streams/errors/TopologyException.java    |   4 +
 .../streams/errors/UnknownStateStoreException.java |   4 +
 .../streams/errors/UnknownTopologyException.java   |   4 +
 .../apache/kafka/streams/kstream/Aggregator.java   |   3 +
 .../org/apache/kafka/streams/kstream/Branched.java |   4 +
 .../kafka/streams/kstream/BranchedKStream.java     |   2 +
 .../kafka/streams/kstream/CogroupedKStream.java    |   2 +
 .../org/apache/kafka/streams/kstream/Consumed.java |   2 +
 .../apache/kafka/streams/kstream/EmitStrategy.java |   2 +
 .../kafka/streams/kstream/ForeachAction.java       |   3 +
 .../apache/kafka/streams/kstream/GlobalKTable.java |   2 +
 .../org/apache/kafka/streams/kstream/Grouped.java  |   2 +
 .../apache/kafka/streams/kstream/Initializer.java  |   4 +
 .../apache/kafka/streams/kstream/JoinWindows.java  |   2 +
 .../org/apache/kafka/streams/kstream/Joined.java   |   2 +
 .../kafka/streams/kstream/KGroupedStream.java      |   2 +
 .../kafka/streams/kstream/KGroupedTable.java       |   2 +
 .../org/apache/kafka/streams/kstream/KStream.java  |   2 +
 .../org/apache/kafka/streams/kstream/KTable.java   |   2 +
 .../kafka/streams/kstream/KeyValueMapper.java      |   2 +
 .../apache/kafka/streams/kstream/Materialized.java |   2 +
 .../org/apache/kafka/streams/kstream/Merger.java   |   3 +
 .../org/apache/kafka/streams/kstream/Named.java    |   2 +
 .../apache/kafka/streams/kstream/Predicate.java    |   2 +
 .../org/apache/kafka/streams/kstream/Printed.java  |   2 +
 .../org/apache/kafka/streams/kstream/Produced.java |   2 +
 .../org/apache/kafka/streams/kstream/Reducer.java  |   2 +
 .../kafka/streams/kstream/Repartitioned.java       |   2 +
 .../kstream/SessionWindowedCogroupedKStream.java   |   4 +-
 .../kstream/SessionWindowedDeserializer.java       |   2 +
 .../streams/kstream/SessionWindowedKStream.java    |   2 +
 .../streams/kstream/SessionWindowedSerializer.java |   4 +
 .../kafka/streams/kstream/SessionWindows.java      |   2 +
 .../kafka/streams/kstream/SlidingWindows.java      |   2 +
 .../apache/kafka/streams/kstream/StreamJoined.java |   2 +
 .../apache/kafka/streams/kstream/Suppressed.java   |   2 +
 .../apache/kafka/streams/kstream/TableJoined.java  |   2 +
 .../kstream/TimeWindowedCogroupedKStream.java      |   4 +-
 .../streams/kstream/TimeWindowedDeserializer.java  |   2 +
 .../kafka/streams/kstream/TimeWindowedKStream.java |   2 +
 .../streams/kstream/TimeWindowedSerializer.java    |   4 +
 .../apache/kafka/streams/kstream/TimeWindows.java  |   5 +
 .../kafka/streams/kstream/UnlimitedWindows.java    |   5 +
 .../apache/kafka/streams/kstream/ValueJoiner.java  |   3 +
 .../kafka/streams/kstream/ValueJoinerWithKey.java  |   3 +
 .../apache/kafka/streams/kstream/ValueMapper.java  |   2 +
 .../kafka/streams/kstream/ValueMapperWithKey.java  |   4 +
 .../streams/kstream/ValueTransformerWithKey.java   |   2 +
 .../kstream/ValueTransformerWithKeySupplier.java   |   2 +
 .../org/apache/kafka/streams/kstream/Window.java   |   2 +
 .../org/apache/kafka/streams/kstream/Windowed.java |   3 +
 .../kafka/streams/kstream/WindowedSerdes.java      |   2 +
 .../org/apache/kafka/streams/kstream/Windows.java  |   2 +
 .../processor/BatchingStateRestoreCallback.java    |   3 +-
 .../kafka/streams/processor/Cancellable.java       |   4 +
 .../kafka/streams/processor/CommitCallback.java    |   2 +
 .../streams/processor/ConnectedStoreProvider.java  |   2 +
 .../streams/processor/FailOnInvalidTimestamp.java  |   2 +
 .../processor/LogAndSkipOnInvalidTimestamp.java    |   2 +
 .../kafka/streams/processor/ProcessorContext.java  |   2 +
 .../kafka/streams/processor/PunctuationType.java   |   4 +
 .../apache/kafka/streams/processor/Punctuator.java |   2 +
 .../kafka/streams/processor/RecordContext.java     |   2 +
 .../streams/processor/StandbyUpdateListener.java   |   3 +
 .../streams/processor/StateRestoreCallback.java    |   4 +
 .../streams/processor/StateRestoreListener.java    |   2 +
 .../apache/kafka/streams/processor/StateStore.java |   2 +
 .../kafka/streams/processor/StateStoreContext.java |   2 +
 .../kafka/streams/processor/StreamPartitioner.java |   2 +
 .../org/apache/kafka/streams/processor/TaskId.java |   2 +
 .../streams/processor/TimestampExtractor.java      |   2 +
 .../org/apache/kafka/streams/processor/To.java     |   4 +
 .../streams/processor/TopicNameExtractor.java      |   4 +
 .../UsePartitionTimeOnInvalidTimestamp.java        |   2 +
 .../processor/WallclockTimestampExtractor.java     |   3 +
 .../processor/api/ContextualFixedKeyProcessor.java |   4 +
 .../streams/processor/api/ContextualProcessor.java |   4 +
 .../streams/processor/api/FixedKeyProcessor.java   |   2 +
 .../processor/api/FixedKeyProcessorContext.java    |   4 +
 .../processor/api/FixedKeyProcessorSupplier.java   |   2 +
 .../streams/processor/api/FixedKeyRecord.java      |   2 +
 .../api/InternalFixedKeyRecordFactory.java         |   4 +
 .../streams/processor/api/ProcessingContext.java   |   2 +
 .../kafka/streams/processor/api/Processor.java     |   2 +
 .../streams/processor/api/ProcessorContext.java    |   4 +
 .../streams/processor/api/ProcessorSupplier.java   |   2 +
 .../streams/processor/api/ProcessorWrapper.java    |   2 +
 .../streams/processor/api/ReadOnlyRecord.java      |   2 +
 .../apache/kafka/streams/processor/api/Record.java |   2 +
 .../streams/processor/api/RecordMetadata.java      |   2 +
 .../api/WrappedFixedKeyProcessorSupplier.java      |   3 +
 .../processor/api/WrappedProcessorSupplier.java    |   3 +
 .../processor/assignment/ApplicationState.java     |   2 +
 .../processor/assignment/AssignmentConfigs.java    |   2 +
 .../assignment/KafkaStreamsAssignment.java         |   2 +
 .../processor/assignment/KafkaStreamsState.java    |   2 +
 .../streams/processor/assignment/ProcessId.java    |   4 +
 .../processor/assignment/TaskAssignmentUtils.java  |   2 +
 .../streams/processor/assignment/TaskAssignor.java |   2 +
 .../streams/processor/assignment/TaskInfo.java     |   4 +-
 .../processor/assignment/TaskTopicPartition.java   |   3 +
 .../assignment/assignors/StickyTaskAssignor.java   |   4 +-
 .../apache/kafka/streams/query/FailureReason.java  |   4 +-
 .../org/apache/kafka/streams/query/KeyQuery.java   |   2 +
 .../streams/query/MultiVersionedKeyQuery.java      |   2 +
 .../org/apache/kafka/streams/query/Position.java   |   4 +-
 .../apache/kafka/streams/query/PositionBound.java  |   4 +-
 .../java/org/apache/kafka/streams/query/Query.java |   4 +
 .../apache/kafka/streams/query/QueryConfig.java    |   2 +
 .../apache/kafka/streams/query/QueryResult.java    |   2 +
 .../org/apache/kafka/streams/query/RangeQuery.java |   4 +-
 .../apache/kafka/streams/query/ResultOrder.java    |   3 +
 .../kafka/streams/query/StateQueryRequest.java     |   2 +
 .../kafka/streams/query/StateQueryResult.java      |   2 +
 .../kafka/streams/query/TimestampedKeyQuery.java   |   2 +
 .../kafka/streams/query/TimestampedRangeQuery.java |   4 +-
 .../kafka/streams/query/VersionedKeyQuery.java     |   2 +
 .../apache/kafka/streams/query/WindowKeyQuery.java |   2 +
 .../kafka/streams/query/WindowRangeQuery.java      |   2 +
 .../streams/state/AggregationWithHeaders.java      |   2 +
 .../streams/state/BuiltInDslStoreSuppliers.java    |   2 +
 .../kafka/streams/state/DslKeyValueParams.java     |   2 +
 .../kafka/streams/state/DslSessionParams.java      |   2 +
 .../kafka/streams/state/DslStoreSuppliers.java     |   2 +
 .../kafka/streams/state/DslWindowParams.java       |   2 +
 .../kafka/streams/state/HeadersBytesStore.java     |   3 +
 .../streams/state/HeadersBytesStoreSupplier.java   |   4 +
 .../org/apache/kafka/streams/state/HostInfo.java   |   2 +
 .../streams/state/KeyValueBytesStoreSupplier.java  |   2 +
 .../kafka/streams/state/KeyValueIterator.java      |   2 +
 .../apache/kafka/streams/state/KeyValueStore.java  |   2 +
 .../kafka/streams/state/QueryableStoreType.java    |   4 +
 .../kafka/streams/state/QueryableStoreTypes.java   |   8 +
 .../kafka/streams/state/ReadOnlyKeyValueStore.java |   2 +
 .../kafka/streams/state/ReadOnlySessionStore.java  |   4 +-
 .../kafka/streams/state/ReadOnlyWindowStore.java   |   2 +
 .../kafka/streams/state/RocksDBConfigSetter.java   |   3 +
 .../streams/state/SessionBytesStoreSupplier.java   |   2 +
 .../apache/kafka/streams/state/SessionStore.java   |   2 +
 .../streams/state/SessionStoreWithHeaders.java     |   2 +
 .../apache/kafka/streams/state/StateSerdes.java    |   2 +
 .../apache/kafka/streams/state/StoreBuilder.java   |   2 +
 .../apache/kafka/streams/state/StoreSupplier.java  |   2 +
 .../org/apache/kafka/streams/state/Stores.java     |   2 +
 .../kafka/streams/state/TimestampedBytesStore.java |   3 +
 .../streams/state/TimestampedKeyValueStore.java    |   4 +
 .../state/TimestampedKeyValueStoreWithHeaders.java |   4 +
 .../streams/state/TimestampedWindowStore.java      |   2 +
 .../state/TimestampedWindowStoreWithHeaders.java   |   3 +
 .../kafka/streams/state/ValueAndTimestamp.java     |   2 +
 .../kafka/streams/state/ValueTimestampHeaders.java |   2 +
 .../kafka/streams/state/VersionedBytesStore.java   |   2 +
 .../streams/state/VersionedBytesStoreSupplier.java |   2 +
 .../streams/state/VersionedKeyValueStore.java      |   2 +
 .../kafka/streams/state/VersionedRecord.java       |   4 +
 .../streams/state/VersionedRecordIterator.java     |   5 +-
 .../streams/state/WindowBytesStoreSupplier.java    |   2 +
 .../apache/kafka/streams/state/WindowStore.java    |   2 +
 .../kafka/streams/state/WindowStoreIterator.java   |   2 +
 .../state/internals/InMemoryWindowStore.java       |   2 +-
 .../AbstractColumnFamilyAccessorTest.java          |   2 +-
 .../state/internals/AbstractSegmentsTest.java      |   2 +-
 .../internals/AbstractSessionBytesStoreTest.java   |   2 +-
 .../internals/QueryableStoreProviderTest.java      |   2 +-
 .../streams/state/internals/ThreadCacheTest.java   |   2 +-
 .../state/internals/WrappingStoreProviderTest.java |   2 +-
 .../org/apache/kafka/streams/TestInputTopic.java   |   2 +
 .../org/apache/kafka/streams/TestOutputTopic.java  |   2 +
 .../apache/kafka/streams/TopologyTestDriver.java   |   2 +
 .../kafka/streams/TopologyTestDriverBuilder.java   |   3 +
 .../processor/api/MockProcessorContext.java        |   5 +
 .../org/apache/kafka/streams/test/TestRecord.java  |   2 +
 .../java/org/apache/kafka/tools/ConfigCommand.java |   2 +-
 .../kafka/tools/ClientMetricsCommandTest.java      |   2 +-
 .../java/org/apache/kafka/tools/api/Decoder.java   |   4 +
 .../org/apache/kafka/tools/api/DefaultDecoder.java |   4 +
 .../org/apache/kafka/tools/api/IntegerDecoder.java |   4 +
 .../org/apache/kafka/tools/api/LongDecoder.java    |   4 +
 .../org/apache/kafka/tools/api/RecordReader.java   |   3 +
 .../org/apache/kafka/tools/api/StringDecoder.java  |   4 +
 947 files changed, 9820 insertions(+), 44 deletions(-)

diff --git a/api-checker/README.md b/api-checker/README.md
new file mode 100644
index 00000000000..7db9851dde3
--- /dev/null
+++ b/api-checker/README.md
@@ -0,0 +1,90 @@
+# Kafka API-checker plugins
+
+This is a separate Gradle build (composite-included from the root `settings.gradle` via
+`pluginManagement { includeBuild 'api-checker' }`) that produces the KIP-1265 API checkers.
+Living outside Kafka's main build keeps the Maven runtime dependencies off the main-build
+classpath and lets each published artifact carry only the classes its consumers need.
+
+| Subproject | Publishes | Audience |
+|---|---|---|
+| `:core` | `org.apache.kafka:kafka-api-checker-core` | Shared scanner + validator + reporter (ASM only). Both plugin jars depend on it. |
+| `:gradle-plugins` | `org.apache.kafka:kafka-internal-api-checker-gradle-plugin` + plugin markers for `org.apache.kafka.public-api-checker` and `org.apache.kafka.internal-api-checker` | The Kafka-internal producer-side checker and the published consumer-side Gradle checker. |
+| `:maven-plugin` | `org.apache.kafka:kafka-internal-api-checker-maven-plugin` | Maven equivalent of the consumer-side checker. |
+
+End-user documentation (Gradle/Maven snippets, `@SuppressKafkaInternalApiUsage`,
+audience-inheritance rules) lives at
+[`docs/apis/internal-api-checker.md`](../docs/apis/internal-api-checker.md). The notes
+below cover building, testing, and publishing the plugins themselves.
+
+## Build
+
+```bash
+./gradlew :api-checker:core:build :api-checker:gradle-plugins:build :api-checker:maven-plugin:build
+```
+
+## Test
+
+```bash
+./gradlew :api-checker:core:test :api-checker:gradle-plugins:test
+```
+
+`:gradle-plugins:test` includes a Gradle TestKit end-to-end test that applies the
+`org.apache.kafka.internal-api-checker` plugin to a synthetic consumer project.
+
+## Publish
+
+Each subproject's `publish` task stages to the URL passed via `-PmavenUrl` (with
+`-PmavenUsername` / `-PmavenPassword` for credentials). The version is read from the
+repo-root `gradle.properties`, so `release.py`'s existing `updateVersion` call sets it
+automatically. `-PkafkaPluginsVersion=…` overrides for one-off out-of-band publishes.
+
+```bash
+# Stage to ASF Nexus alongside the rest of an AK release
+./gradlew :api-checker:core:publish \
+         :api-checker:gradle-plugins:publish \
+         :api-checker:maven-plugin:publish \
+  -PmavenUrl=$ASF_NEXUS_STAGING_URL \
+  -PmavenUsername=$NEXUS_USER \
+  -PmavenPassword=$NEXUS_PASS
+
+# Local smoke-test
+./gradlew :api-checker:core:publishToMavenLocal \
+         :api-checker:gradle-plugins:publishToMavenLocal \
+         :api-checker:maven-plugin:publishToMavenLocal
+```
+
+The five published coordinates:
+
+- `org.apache.kafka:kafka-api-checker-core:$KAFKA_VERSION` — shared scanner library.
+- `org.apache.kafka:kafka-internal-api-checker-gradle-plugin:$KAFKA_VERSION` — Gradle plugin
+  implementation jar (consumed by the marker poms).
+- `org.apache.kafka.internal-api-checker:org.apache.kafka.internal-api-checker.gradle.plugin:$KAFKA_VERSION` —
+  marker pom for `plugins { id 'org.apache.kafka.internal-api-checker' }`.
+- `org.apache.kafka.public-api-checker:org.apache.kafka.public-api-checker.gradle.plugin:$KAFKA_VERSION` —
+  marker pom for the producer-side checker. (Kafka-internal use, but published from the
+  same module for consistency.)
+- `org.apache.kafka:kafka-internal-api-checker-maven-plugin:$KAFKA_VERSION` — Maven plugin
+  (packaging `maven-plugin`).
+
+## Layout
+
+```
+api-checker/
+├── settings.gradle           # declares the three subprojects
+├── build.gradle              # group / version / signing / common publishing config
+├── core/
+│   ├── build.gradle          # ASM dep; published as kafka-api-checker-core
+│   └── src/
+│       ├── main/java/.../apicheck/                # scanner, validators, reporter
+│       ├── test/java/.../apicheck/                # unit tests
+│       └── testFixtures/java/.../apicheck/        # AsmClassFactory, TempJarBuilder
+│                                                  # — re-used by :gradle-plugins' tests
+├── gradle-plugins/
+│   ├── build.gradle          # java-gradle-plugin; depends on :core
+│   └── src/{main,test}/java/.../gradle/           # Plugin/Task/Extension × 2
+└── maven-plugin/
+    ├── build.gradle          # Maven deps; templates plugin.xml at processResources
+    └── src/main/
+        ├── java/.../maven/KafkaInternalApiCheckerMojo.java
+        └── resources/META-INF/maven/plugin.xml
+```
diff --git a/api-checker/build.gradle b/api-checker/build.gradle
new file mode 100644
index 00000000000..f7955cb6d9b
--- /dev/null
+++ b/api-checker/build.gradle
@@ -0,0 +1,146 @@
+/*
+ * 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.
+ */
+
+// Shared configuration for the api-checker included build. Lives outside Kafka's main
+// settings tree so its Maven dependencies (in :maven-plugin) don't end up on every
+// developer's main-build classpath, and so each published artifact carries only the classes
+// its consumers need.
+
+// Default the published version to the same string as the rest of Kafka (gradle.properties
+// at the repo root). release.py's `updateVersion` task writes that file, so the included
+// build's artifacts ship under the AK release version automatically. `-PkafkaPluginsVersion=…`
+// overrides for one-off out-of-band publishes.
+def rootGradleProps = new Properties()
+def rootGradlePropsFile = file('../gradle.properties')
+if (rootGradlePropsFile.exists()) {
+    rootGradlePropsFile.withInputStream { rootGradleProps.load(it) }
+}
+def kafkaVersion = providers.gradleProperty('kafkaPluginsVersion')
+        .orElse(providers.provider { rootGradleProps.getProperty('version') })
+        .orElse('1.0.0-SNAPSHOT')
+
+// Single source of truth for every dependency version used in the api-checker build.
+// Cross-referenced against the root Kafka build's gradle/dependencies.gradle for the deps
+// that exist on both sides (junit, slf4j, mockito); when bumping those in the root catalog,
+// bump them here too. ASM and the Maven plugin family are unique to this included build.
+//
+// ASM versions cap the JDK class-file major they can parse. 9.6 covers up to Java 21
+// (class-file 65). If Kafka starts compiling against a newer JDK whose major exceeds what
+// this ASM release knows about, the scanner will throw
+// `IllegalArgumentException: Unsupported class file major version N` on encountering a fresh
+// bytecode file. Bump in lockstep with the project's max-supported compile JDK.
+ext.apiCheckerVersions = [
+        asm: '9.6',
+        slf4j: '2.0.16',
+        junit: '5.9.2',
+        junitPlatform: '1.9.2',
+        mockito: '5.3.1',
+        mavenApi: '3.9.4',
+        mavenPluginAnnotations: '3.9.0',
+]
+
+allprojects {
+    group = 'org.apache.kafka'
+    version = kafkaVersion.get()
+    ext.apiCheckerVersions = rootProject.apiCheckerVersions
+
+    repositories {
+        gradlePluginPortal()
+        mavenCentral()
+    }
+}
+
+subprojects {
+    apply plugin: 'java-library'
+    apply plugin: 'maven-publish'
+    apply plugin: 'signing'
+    apply plugin: 'checkstyle'
+
+    java {
+        sourceCompatibility = JavaVersion.VERSION_11
+        targetCompatibility = JavaVersion.VERSION_11
+        // Maven Central requires sources + javadoc jars alongside every artifact.
+        withSourcesJar()
+        withJavadocJar()
+    }
+
+    test {
+        useJUnitPlatform()
+    }
+
+    checkstyle {
+        toolVersion = '12.3.1'
+        configDirectory = file("$rootDir/../checkstyle")
+        configProperties = [importControlFile: 'import-control-api-checker.xml']
+    }
+    // Tests legitimately need junit/mockito; main sources don't. Swap the import-control
+    // file for every test-flavoured Checkstyle task so a stray junit/mockito import in a
+    // main file still fails checkstyle.
+    tasks.matching { it.name == 'checkstyleTest' || it.name == 'checkstyleTestFixtures' }.configureEach {
+        configProperties = [importControlFile: 'import-control-api-checker-test.xml']
+    }
+
+    publishing {
+        repositories {
+            // Mirrors the root Kafka project's publishing.repositories block — credentials
+            // come from ~/.gradle/gradle.properties via the same -PmavenUrl / -PmavenUsername /
+            // -PmavenPassword properties release.py uses.
+            maven {
+                url = project.hasProperty('mavenUrl') ? project.mavenUrl : ''
+                credentials {
+                    username = project.hasProperty('mavenUsername') ? project.mavenUsername : ''
+                    password = project.hasProperty('mavenPassword') ? project.mavenPassword : ''
+                }
+            }
+        }
+
+        // Apply Apache-Kafka POM defaults (url, license, developer, scm) to every publication
+        // in this subproject — each MavenPublication block only needs to set its own name,
+        // description, and packaging.
+        publications.withType(MavenPublication).configureEach {
+            pom {
+                url = 'https://kafka.apache.org'
+                licenses {
+                    license {
+                        name = 'The Apache License, Version 2.0'
+                        url = 'https://www.apache.org/licenses/LICENSE-2.0.txt'
+                    }
+                }
+                developers {
+                    developer {
+                        id = 'apache-kafka'
+                        name = 'Apache Kafka Team'
+                        email = 'dev@kafka.apache.org'
+                    }
+                }
+                scm {
+                    connection = 'scm:git:https://github.com/apache/kafka.git'
+                    developerConnection = 'scm:git:https://github.com/apache/kafka.git'
+                    url = 'https://github.com/apache/kafka'
+                }
+            }
+        }
+    }
+
+    def skipSigning = project.hasProperty('skipSigning') && skipSigning.toBoolean()
+    def shouldSign = !skipSigning && !version.toString().endsWith('SNAPSHOT')
+    if (shouldSign) {
+        signing {
+            sign publishing.publications
+        }
+    }
+}
diff --git a/api-checker/core/build.gradle b/api-checker/core/build.gradle
new file mode 100644
index 00000000000..b1f00b94c1e
--- /dev/null
+++ b/api-checker/core/build.gradle
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ */
+
+// Shared scanner + validator + reporter for the KIP-1265 API checkers. Both the Gradle
+// plugin and the Maven plugin depend on this module — neither carries the bytecode
+// scanner classes themselves any more, so the published Gradle jar no longer drags Maven
+// classes (and vice versa).
+
+apply plugin: 'java-test-fixtures'
+
+dependencies {
+    // ASM is the entire production dependency footprint. No Gradle, no Maven.
+    api "org.ow2.asm:asm:${apiCheckerVersions.asm}"
+    api "org.slf4j:slf4j-api:${apiCheckerVersions.slf4j}"
+
+    testImplementation "org.junit.jupiter:junit-jupiter:${apiCheckerVersions.junit}"
+    testImplementation "org.junit.jupiter:junit-jupiter-engine:${apiCheckerVersions.junit}"
+    testImplementation "org.junit.platform:junit-platform-launcher:${apiCheckerVersions.junitPlatform}"
+
+    // ASM-based class-file builders used by every checker test in the included build.
+    // Exposed as test-fixtures so the gradle-plugins module's tests can pull them in via
+    // `testImplementation(testFixtures(project(':core')))` without us re-publishing them
+    // as a separate "tests" classifier artifact.
+    testFixturesImplementation "org.ow2.asm:asm:${apiCheckerVersions.asm}"
+}
+
+publishing {
+    publications {
+        coreJar(MavenPublication) {
+            artifactId = 'kafka-api-checker-core'
+            from components.java
+            // url, license, developers, scm come from the shared block in ../build.gradle.
+            pom {
+                name = 'Apache Kafka API Checker Core'
+                description = 'Bytecode scanner and reporter shared by the KIP-1265 Gradle and Maven plugins'
+            }
+        }
+    }
+}
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/ApiSurface.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ApiSurface.java
new file mode 100644
index 00000000000..b5d1dab3d7b
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ApiSurface.java
@@ -0,0 +1,169 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
+
+/**
+ * The Kafka public-API surface, resolved from one project-jars scan. Immutable; consumed by the
+ * cascade and javadoc validators. Built via {@link ApiSurfaceScanner}.
+ *
+ * <p>All lookup methods accept a class name in either binary ({@code Outer$Inner}) or dotted
+ * ({@code Outer.Inner}) form — callers don't need to know which form the surface stores. The two
+ * iteration sets ({@link #effectivePublic()} and {@link #directPublic()}) return {@link ClassFacts}
+ * directly so callers never juggle name strings.
+ */
+final class ApiSurface {
+
+    /** Every class that is effectively {@code @Public} (direct or inherited), regardless of visibility. */
+    private final Set<ClassFacts> effectivePublic;
+    /** Classes carrying a direct {@code @InterfaceAudience.Public} — drives the MISSING_JAVADOC iteration. */
+    private final Set<ClassFacts> directPublic;
+    private final Map<String, ClassFacts> byDottedName;
+    private final Map<String, File> jarByDottedName;
+
+    private ApiSurface(Builder b) {
+        this.effectivePublic = Set.copyOf(b.effectivePublic);
+        this.directPublic = Set.copyOf(b.directPublic);
+        this.byDottedName = Map.copyOf(b.byDottedName);
+        this.jarByDottedName = Map.copyOf(b.jarByDottedName);
+    }
+
+    /**
+     * Every class that is effectively {@code @Public} (direct or inherited). Cascade iteration
+     * filters this further on {@link ClassFacts#isExternallyVisible()} — private/package-private
+     * nested classes inherit the audience but their methods aren't reachable to consumers.
+     */
+    Set<ClassFacts> effectivePublic() {
+        return effectivePublic;
+    }
+
+    /** Classes carrying a *direct* {@code @InterfaceAudience.Public}. Drives the MISSING_JAVADOC check. */
+    Set<ClassFacts> directPublic() {
+        return directPublic;
+    }
+
+    /** Look up facts by either binary or dotted name. Returns {@code null} if not in any scanned jar. */
+    ClassFacts factsOf(String name) {
+        return byDottedName.get(normalize(name));
+    }
+
+    /** @return the jar that contained the class, or {@code null} if not in any scanned jar. */
+    File jarOf(String name) {
+        return jarByDottedName.get(normalize(name));
+    }
+
+    /**
+     * True iff the class is effectively {@code @Public} — directly or via enclosing-class
+     * inheritance, regardless of source-level access. Walks the enclosing chain just like
+     * {@link #isDeprecated}; explicit {@code @InterfaceAudience.Private} on a nested class
+     * overrides an inherited {@code @Public}.
+     */
+    boolean isEffectivelyPublic(String name) {
+        ClassFacts hit = findInChain(name, f -> f.isPrivate() || f.isPublic());
+        return hit != null && hit.isPublic();
+    }
+
+    /**
+     * True iff the class — or any enclosing class — carries {@code @Deprecated}. Deprecation
+     * propagates through nesting so a nested class of a {@code @Deprecated} outer is itself
+     * out of scope on both validation sides (mirrors the {@code @Public} inheritance model).
+     */
+    boolean isDeprecated(String name) {
+        return findInChain(name, ClassFacts::isDeprecated) != null;
+    }
+
+    /**
+     * Walk the enclosing chain and return the first {@link ClassFacts} for which {@code stopAt}
+     * is true, or {@code null} if nothing matches before we reach the top-level class.
+     *
+     * <p>When the current level resolves to {@link ClassFacts}, stepping uses
+     * {@link ClassFacts#enclosingName} (works for both binary and dotted-form input). When it
+     * doesn't — e.g. an anonymous intermediate like {@code Outer$1} that
+     * {@link ApiSurfaceScanner#isSyntheticOrAnonymous} dropped — we fall back to lexical
+     * {@code $}-stripping so the chain continues past the gap. That fallback matches the
+     * scanner's own {@code resolveEffectiveAudience} so the two walks agree on what counts as
+     * an effective audience.
+     */
+    private ClassFacts findInChain(String name, Predicate<ClassFacts> stopAt) {
+        String current = name;
+        while (current != null) {
+            ClassFacts facts = factsOf(current);
+            if (facts != null) {
+                if (stopAt.test(facts)) return facts;
+                current = facts.enclosingName();
+            } else {
+                String parent = ClassFacts.parentBinaryName(current);
+                if (parent == null) return null;
+                current = parent;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Treat {@code $} purely as a Java-style nesting separator. This is correct for Java
+     * source-level nested classes and for the {@code @InterfaceAudience.Public} surface (which
+     * is itself defined in plain Java). It's a known oversimplification for Scala/Kotlin
+     * compiled output — e.g. Scala companion-object names ({@code Foo$}) or anonymous-function
+     * synthetics ({@code Foo$$anonfun$1}) — but those compiler-generated symbols are not part
+     * of the Public surface, so any normalization confusion they cause stays below the
+     * checker's prefix gate.
+     */
+    private static String normalize(String name) {
+        return name.indexOf('$') < 0 ? name : name.replace('$', '.');
+    }
+
+    static Builder builder() {
+        return new Builder();
+    }
+
+    /** Accumulator used by {@link ApiSurfaceScanner}; {@link #build()} freezes into an {@link ApiSurface}. */
+    static final class Builder {
+        private final Set<ClassFacts> effectivePublic = new HashSet<>();
+        private final Set<ClassFacts> directPublic = new HashSet<>();
+        private final Map<String, ClassFacts> byDottedName = new HashMap<>();
+        private final Map<String, File> jarByDottedName = new HashMap<>();
+
+        /** Record a class's facts and the jar it came from. First jar wins for duplicates. */
+        Builder recordClass(ClassFacts facts, File jar) {
+            byDottedName.put(facts.dottedName(), facts);
+            jarByDottedName.putIfAbsent(facts.dottedName(), jar);
+            return this;
+        }
+
+        /** Add a class that is effectively {@code @Public} (direct or inherited), any visibility. */
+        Builder addEffectivePublic(ClassFacts facts) {
+            effectivePublic.add(facts);
+            return this;
+        }
+
+        Builder addDirectPublic(ClassFacts facts) {
+            directPublic.add(facts);
+            return this;
+        }
+
+        ApiSurface build() {
+            return new ApiSurface(this);
+        }
+    }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/ApiSurfaceScanner.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ApiSurfaceScanner.java
new file mode 100644
index 00000000000..5a991c51a94
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ApiSurfaceScanner.java
@@ -0,0 +1,225 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * Builds an {@link ApiSurface} from a set of project jars in a single two-pass scan:
+ * <ol>
+ *   <li>Read each class's direct bytecode facts (annotations, source-level access).</li>
+ *   <li>Walk every class's enclosing-class chain (Hadoop-style inheritance) to resolve effective
+ *       audience and assemble the derived dotted-name sets in the surface.</li>
+ * </ol>
+ *
+ * <p>Reads bytecode metadata via ASM rather than the classloader, so a class with broken
+ * transitive deps (gRPC stubs, telemetry shims, …) doesn't trip {@code LinkageError} —
+ * annotation descriptors live in the constant pool, no linking required.
+ */
+final class ApiSurfaceScanner {
+
+    // Bytecode descriptors used to identify class-level annotations the checker cares about.
+    private static final String PUBLIC_API_DESCRIPTOR =
+            "Lorg/apache/kafka/common/annotation/InterfaceAudience$Public;";
+    private static final String PRIVATE_API_DESCRIPTOR =
+            "Lorg/apache/kafka/common/annotation/InterfaceAudience$Private;";
+    private static final String DEPRECATED_DESCRIPTOR = "Ljava/lang/Deprecated;";
+
+    private ApiSurfaceScanner() {}
+
+    /**
+     * Scan the project's own jars and any reference jars (sibling Kafka modules it depends on)
+     * and return an immutable surface.
+     *
+     * <p>Reference-jar classes contribute to {@link ApiSurface#factsOf} lookups and the
+     * membership set behind {@link ApiSurface#isEffectivelyPublic} so cross-module
+     * {@code @Public} references resolve correctly, but they do NOT take part in this
+     * project's MISSING_JAVADOC iteration or cascade iteration — each module checks its
+     * own surface.
+     */
+    static ApiSurface scan(List<File> projectJars, List<File> referenceJars) throws IOException {
+        ApiSurface.Builder surface = ApiSurface.builder();
+        Map<String, ClassFacts> byBinaryName = new HashMap<>();
+        Set<String> projectJarDottedNames = new HashSet<>();
+
+        // Pass 1 — read facts for every in-scope class. Project-owned classes get tracked so
+        // pass 2 can keep them out of reference-only iteration sets.
+        scanJars(projectJars, byBinaryName, surface, projectJarDottedNames::add);
+        scanJars(referenceJars, byBinaryName, surface, name -> { });
+
+        // Pass 2 — resolve inheritance and populate the surface's derived sets. Deprecated
+        // classes are out of scope on both validation sides; the surface answers
+        // {@link ApiSurface#isDeprecated} directly from the per-class facts so no separate
+        // deprecated set is needed here.
+        for (ClassFacts facts : byBinaryName.values()) {
+            if (facts.isDeprecated() || !projectJarDottedNames.contains(facts.dottedName())) continue;
+            if (facts.isPublic()) {
+                surface.addDirectPublic(facts);
+            }
+            if (resolveEffectiveAudience(facts.binaryName(), byBinaryName) == DirectAudience.PUBLIC) {
+                // Owned effective-Public classes all go into the surface — cross-module
+                // @Public references resolve via {@link ApiSurface#isEffectivelyPublic} which
+                // walks the enclosing chain. Reference-jar classes don't need to be added
+                // here: they're validated by their own module's task.
+                //
+                // Iteration consumers (CascadeValidator) filter on
+                // {@link ClassFacts#isExternallyVisible()} at the call site — private nested
+                // classes inherit the audience but their methods/ctors aren't reachable to
+                // consumers and shouldn't be cascade-walked.
+                surface.addEffectivePublic(facts);
+            }
+        }
+
+        return surface.build();
+    }
+
+    /** Backwards-compatible overload for callers that don't need reference jars (consumer-side scan). */
+    static ApiSurface scan(List<File> projectJars) throws IOException {
+        return scan(projectJars, java.util.Collections.emptyList());
+    }
+
+    private static void scanJars(List<File> jars, Map<String, ClassFacts> byBinaryName,
+                                 ApiSurface.Builder surface,
+                                 java.util.function.Consumer<String> markOwned) throws IOException {
+        for (File jar : jars) {
+            try (JarFile jarFile = new JarFile(jar)) {
+                Enumeration<JarEntry> entries = jarFile.entries();
+                while (entries.hasMoreElements()) {
+                    JarEntry entry = entries.nextElement();
+                    if (!entry.getName().endsWith(".class")) continue;
+
+                    String binaryName = entry.getName()
+                            .replace('/', '.')
+                            .replaceAll(".class$", "");
+                    if (binaryName.endsWith("package-info") || binaryName.endsWith("module-info")) continue;
+                    if (!binaryName.startsWith("org.apache.kafka.")) continue;
+                    // Anonymous / local / synthetic classes are never part of the public API surface,
+                    // but would otherwise inherit @Public from an enclosing class under the
+                    // Hadoop-style inheritance rule and trip cascade checks.
+                    if (isSyntheticOrAnonymous(binaryName)) continue;
+                    // First jar wins for cross-jar duplicates — project jars are scanned first
+                    // so they keep ownership over a class also present in a reference jar.
+                    if (byBinaryName.containsKey(binaryName)) continue;
+
+                    ClassFacts facts = readClassFacts(jarFile, entry, binaryName);
+                    byBinaryName.put(binaryName, facts);
+                    surface.recordClass(facts, jar);
+                    markOwned.accept(facts.dottedName());
+                }
+            }
+        }
+    }
+
+    /** Read a class file's bytecode facts via ASM (jar-entry variant). */
+    static ClassFacts readClassFacts(JarFile jar, JarEntry entry, String binaryName) throws IOException {
+        try (InputStream in = jar.getInputStream(entry)) {
+            return readClassFactsFromStream(in, binaryName);
+        }
+    }
+
+    /** Read a class file's bytecode facts via ASM (stream variant — used for classpath lookups). */
+    static ClassFacts readClassFactsFromStream(InputStream in, String binaryName) throws IOException {
+        ClassFacts.Builder builder = ClassFacts.builder(binaryName);
+        String internalName = binaryName.replace('.', '/');
+        ClassReader reader = new ClassReader(in);
+        reader.accept(new ClassVisitor(Opcodes.ASM9) {
+            @Override
+            public void visit(int version, int access, String name, String signature,
+                              String superName, String[] interfaces) {
+                builder.sourceAccess(access); // top-level access; overridden below for nested
+            }
+
+            @Override
+            public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+                if (PUBLIC_API_DESCRIPTOR.equals(descriptor)) {
+                    builder.addFlag(ClassFacts.Flag.PUBLIC_API);
+                } else if (PRIVATE_API_DESCRIPTOR.equals(descriptor)) {
+                    builder.addFlag(ClassFacts.Flag.PRIVATE_API);
+                } else if (DEPRECATED_DESCRIPTOR.equals(descriptor)) {
+                    builder.addFlag(ClassFacts.Flag.DEPRECATED);
+                }
+                return null;
+            }
+
+            @Override
+            public void visitInnerClass(String name, String outerName, String innerName, int access) {
+                if (internalName.equals(name)) {
+                    // For nested classes the InnerClasses entry holds the real source-level
+                    // access; the class header's ACC_PUBLIC is a compiler artefact.
+                    builder.sourceAccess(access);
+                }
+            }
+        }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+        return builder.build();
+    }
+
+    /**
+     * @return true if the binary name encodes an anonymous, local, or compiler-synthetic class
+     *         (e.g. {@code Outer$1}, {@code Outer$1$Inner}, {@code Outer$$Lambda$N}). Such classes
+     *         are never part of the public API surface.
+     */
+    private static boolean isSyntheticOrAnonymous(String binaryName) {
+        if (binaryName.contains("$$")) return true; // lambdas / synthetic accessor classes
+        int dollar = binaryName.indexOf('$');
+        while (dollar >= 0) {
+            int nextDollar = binaryName.indexOf('$', dollar + 1);
+            int end = nextDollar < 0 ? binaryName.length() : nextDollar;
+            // A segment that starts with a digit is an anonymous or local class.
+            if (end > dollar + 1 && Character.isDigit(binaryName.charAt(dollar + 1))) {
+                return true;
+            }
+            dollar = nextDollar;
+        }
+        return false;
+    }
+
+    /**
+     * Walk the enclosing-class chain (by stripping {@code $}-segments from the binary name) and
+     * return the audience of the nearest class with an explicit annotation. Default is
+     * {@code Private} per the KIP. Uses the same {@link ClassFacts#parentBinaryName} stepping
+     * rule as {@link ApiSurface#findInChain} so the two walks agree on missing intermediates.
+     */
+    private static DirectAudience resolveEffectiveAudience(String binaryName, Map<String, ClassFacts> byBinaryName) {
+        String name = binaryName;
+        while (name != null) {
+            ClassFacts facts = byBinaryName.get(name);
+            if (facts != null) {
+                if (facts.isPublic()) return DirectAudience.PUBLIC;
+                if (facts.isPrivate()) return DirectAudience.PRIVATE;
+            }
+            name = ClassFacts.parentBinaryName(name);
+        }
+        return DirectAudience.PRIVATE;
+    }
+
+    private enum DirectAudience { PUBLIC, PRIVATE }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/CascadeValidator.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/CascadeValidator.java
new file mode 100644
index 00000000000..40bfd8df7c4
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/CascadeValidator.java
@@ -0,0 +1,455 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.signature.SignatureReader;
+import org.objectweb.asm.signature.SignatureVisitor;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * Checks that no public method of any effectively-{@code @Public} class leaks an internal Kafka
+ * type through its return type, parameter types, or declared exceptions. Each finding either
+ * lands in {@link CheckResult#violations()} (a real failure) or in
+ * {@link CheckResult#suppressions()} (silenced by a class- or method-level
+ * {@code @SuppressKafkaInternalApiUsage} — the reason is captured so reviewers can audit every
+ * escape hatch on every build).
+ *
+ * <p>Reads bytecode directly via ASM rather than reflecting on a loaded {@code Class<?>}, which
+ * sidesteps {@code LinkageError} / {@code NoClassDefFoundError} from broken transitive deps
+ * (gRPC stubs, telemetry shims, etc.). The same robustness property as {@link ApiSurfaceScanner}.
+ */
+final class CascadeValidator {
+
+    /** {@code @SuppressKafkaInternalApiUsage} — the escape hatch for known cascade leaks pending review. */
+    private static final String SUPPRESS_DESCRIPTOR =
+            "Lorg/apache/kafka/common/annotation/SuppressKafkaInternalApiUsage;";
+
+    private CascadeValidator() {}
+
+    static CheckResult validate(ApiSurface surface) throws IOException {
+        List<PublicApiViolation> violations = new ArrayList<>();
+        List<PublicApiViolation> suppressions = new ArrayList<>();
+        // Group by jar so each archive is opened once. Private/package-private nested classes
+        // inherit the audience but their methods and ctors aren't reachable to consumers, so
+        // cascade-walking them would just produce noise on internal helpers — filter them out
+        // before grouping.
+        Map<File, List<ClassFacts>> classesByJar = new LinkedHashMap<>();
+        for (ClassFacts cls : surface.effectivePublic()) {
+            if (!cls.isExternallyVisible()) continue;
+            File jar = surface.jarOf(cls.binaryName());
+            if (jar == null) continue;
+            classesByJar.computeIfAbsent(jar, j -> new ArrayList<>()).add(cls);
+        }
+        for (Map.Entry<File, List<ClassFacts>> e : classesByJar.entrySet()) {
+            try (JarFile jar = new JarFile(e.getKey())) {
+                for (ClassFacts cls : e.getValue()) {
+                    checkClass(cls, jar, surface, violations, suppressions);
+                }
+            }
+        }
+        return new CheckResult(violations, suppressions);
+    }
+
+    private static void checkClass(ClassFacts cls, JarFile jar, ApiSurface surface,
+                                   List<PublicApiViolation> violations,
+                                   List<PublicApiViolation> suppressions) throws IOException {
+        String entryPath = cls.binaryName().replace('.', '/') + ".class";
+        JarEntry entry = jar.getJarEntry(entryPath);
+        if (entry == null) return;
+        try (InputStream in = jar.getInputStream(entry)) {
+            ClassReader reader = new ClassReader(in);
+            reader.accept(new CascadeClassVisitor(cls, surface, violations, suppressions),
+                    ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+        }
+    }
+
+    /**
+     * Drives the cascade check for a single class: validates the class header (extends/implements
+     * + generic supertype args), then dispatches to per-method and per-field visitors that buffer
+     * findings so a member-level {@code @SuppressKafkaInternalApiUsage} can divert them after the
+     * fact.
+     */
+    private static final class CascadeClassVisitor extends ClassVisitor {
+        private final ClassFacts cls;
+        private final ApiSurface surface;
+        private final List<PublicApiViolation> violations;
+        private final List<PublicApiViolation> suppressions;
+        /** Reason from a class-level {@code @SuppressKafkaInternalApiUsage}, or null. */
+        private String classSuppressionReason;
+        /**
+         * Buffer extends/implements violations until {@link #visitEnd} so the
+         * class-level {@code @SuppressKafkaInternalApiUsage} (visited after the class
+         * header in ASM's event order) can divert them to suppressions if present.
+         */
+        private final List<PublicApiViolation> headerBuffered = new ArrayList<>();
+
+        CascadeClassVisitor(ClassFacts cls, ApiSurface surface,
+                            List<PublicApiViolation> violations,
+                            List<PublicApiViolation> suppressions) {
+            super(Opcodes.ASM9);
+            this.cls = cls;
+            this.surface = surface;
+            this.violations = violations;
+            this.suppressions = suppressions;
+        }
+
+        @Override
+        public void visit(int version, int access, String name, String signature,
+                          String superName, String[] interfaces) {
+            // The class header itself leaks a non-public type if the @Public class
+            // extends or implements an internal Kafka type the consumer can name.
+            if (superName != null && !"java/lang/Object".equals(superName)) {
+                checkSupertype(superName.replace('/', '.'),
+                        "Public class extends non-public API type");
+            }
+            if (interfaces != null) {
+                for (String iface : interfaces) {
+                    checkSupertype(iface.replace('/', '.'),
+                            "Public class implements non-public API type");
+                }
+            }
+            // Generic supertype + interface type arguments live in the signature.
+            // Walk via the supertype-aware path so package-private intermediates are
+            // skipped just like the direct extends/implements check above.
+            if (signature != null) {
+                new SignatureReader(signature).accept(new SignatureVisitor(Opcodes.ASM9) {
+                    @Override
+                    public void visitClassType(String typeName) {
+                        checkSupertype(typeName.replace('/', '.'),
+                                "Public class header signature exposes non-public API type");
+                    }
+                });
+            }
+        }
+
+        /**
+         * Supertype cascade is stricter than the method/field cascade: package-private
+         * supertypes aren't a real leak because a consumer can't even <em>name</em>
+         * them from outside the package (no {@code instanceof}, no downcast). Skip
+         * those; otherwise fall through to the same cascade rule as everywhere else.
+         */
+        private void checkSupertype(String binaryName, String message) {
+            ClassFacts target = surface.factsOf(binaryName);
+            if (target != null && !target.isExternallyVisible()) return;
+            checkBinaryReference(binaryName, "INVALID_SUPERTYPE", message,
+                    cls.binaryName(), null, surface, headerBuffered);
+        }
+
+        @Override
+        public void visitEnd() {
+            if (classSuppressionReason != null) {
+                for (PublicApiViolation original : headerBuffered) {
+                    suppressions.add(asSuppression(original, classSuppressionReason));
+                }
+            } else {
+                violations.addAll(headerBuffered);
+            }
+        }
+
+        @Override
+        public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+            if (SUPPRESS_DESCRIPTOR.equals(descriptor)) {
+                return new ReasonCaptureVisitor(r -> classSuppressionReason = r);
+            }
+            return null;
+        }
+
+        @Override
+        public MethodVisitor visitMethod(int access, String name, String descriptor,
+                                         String signature, String[] exceptions) {
+            // KIP-1265: a Public class's externally-visible methods (public + protected,
+            // since protected members are reachable to subclasses of an extensible Public
+            // class) must not leak non-public types.
+            if ((access & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED)) == 0) return null;
+            // Bridge/synthetic methods are compiler-generated and never source-level API.
+            if ((access & (Opcodes.ACC_BRIDGE | Opcodes.ACC_SYNTHETIC)) != 0) return null;
+
+            // Buffer would-be violations and route them in visitEnd, because the method's
+            // own @SuppressKafkaInternalApiUsage is visited *after* visitMethod returns.
+            List<PublicApiViolation> buffered = new ArrayList<>();
+            checkAsmType(Type.getReturnType(descriptor), "INVALID_RETURN_TYPE",
+                    "Public method returns non-public API type",
+                    cls.binaryName(), name, surface, buffered);
+            for (Type argType : Type.getArgumentTypes(descriptor)) {
+                checkAsmType(argType, "INVALID_PARAMETER_TYPE",
+                        "Public method has non-public API parameter type",
+                        cls.binaryName(), name, surface, buffered);
+            }
+            if (exceptions != null) {
+                for (String excInternal : exceptions) {
+                    checkBinaryReference(excInternal.replace('/', '.'),
+                            "INVALID_EXCEPTION_TYPE",
+                            "Public method declares non-public API exception type",
+                            cls.binaryName(), name, surface, buffered);
+                }
+            }
+            // Generic type arguments (e.g. Map<String, InternalFoo>) live in the
+            // signature, not the erased descriptor — walk them too so the cascade
+            // catches leaks the type-erasure layer would otherwise hide. Position-aware
+            // so a return-type leak doesn't get mislabeled as a parameter.
+            collectMethodSignatureRefs(signature, cls.binaryName(), name, surface, buffered);
+
+            return new BufferedMemberVisitor(buffered);
+        }
+
+        @Override
+        public FieldVisitor visitField(int access, String name, String descriptor,
+                                       String signature, Object value) {
+            // KIP-1265 names field types explicitly: a Public class's externally-visible
+            // fields (public + protected) must not expose non-public types either.
+            if ((access & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED)) == 0) return null;
+            if ((access & Opcodes.ACC_SYNTHETIC) != 0) return null;
+
+            // Buffer the would-be violation and route it in visitEnd, because the
+            // field's own @SuppressKafkaInternalApiUsage is visited *after* visitField.
+            List<PublicApiViolation> buffered = new ArrayList<>();
+            checkAsmType(Type.getType(descriptor), "INVALID_FIELD_TYPE",
+                    "Public field exposes non-public API type",
+                    cls.binaryName(), name, surface, buffered);
+            // Walk the generic field signature too — `List<InternalFoo>` etc. is
+            // erased to plain List in the descriptor.
+            collectSignatureRefs(signature, "INVALID_FIELD_TYPE",
+                    "Public field signature exposes non-public API type",
+                    cls.binaryName(), name, surface, buffered);
+
+            return new BufferedFieldVisitor(buffered);
+        }
+
+        /**
+         * Drains {@code buffered} into either {@link #violations} or {@link #suppressions} after
+         * the member's own {@code @SuppressKafkaInternalApiUsage} (if any) has been seen. Falls
+         * back to the class-level suppression reason when the member doesn't carry its own.
+         */
+        private void flush(List<PublicApiViolation> buffered, String memberReason) {
+            String reason = memberReason != null ? memberReason : classSuppressionReason;
+            if (reason != null) {
+                for (PublicApiViolation original : buffered) {
+                    suppressions.add(asSuppression(original, reason));
+                }
+            } else {
+                violations.addAll(buffered);
+            }
+        }
+
+        /** MethodVisitor that captures a method-level suppression reason and flushes on visitEnd. */
+        private final class BufferedMemberVisitor extends MethodVisitor {
+            private final List<PublicApiViolation> buffered;
+            private String reason;
+
+            BufferedMemberVisitor(List<PublicApiViolation> buffered) {
+                super(Opcodes.ASM9);
+                this.buffered = buffered;
+            }
+
+            @Override
+            public AnnotationVisitor visitAnnotation(String d, boolean v) {
+                if (SUPPRESS_DESCRIPTOR.equals(d)) {
+                    return new ReasonCaptureVisitor(r -> reason = r);
+                }
+                return null;
+            }
+
+            @Override
+            public void visitEnd() {
+                flush(buffered, reason);
+            }
+        }
+
+        /** FieldVisitor that captures a field-level suppression reason and flushes on visitEnd. */
+        private final class BufferedFieldVisitor extends FieldVisitor {
+            private final List<PublicApiViolation> buffered;
+            private String reason;
+
+            BufferedFieldVisitor(List<PublicApiViolation> buffered) {
+                super(Opcodes.ASM9);
+                this.buffered = buffered;
+            }
+
+            @Override
+            public AnnotationVisitor visitAnnotation(String d, boolean v) {
+                if (SUPPRESS_DESCRIPTOR.equals(d)) {
+                    return new ReasonCaptureVisitor(r -> reason = r);
+                }
+                return null;
+            }
+
+            @Override
+            public void visitEnd() {
+                flush(buffered, reason);
+            }
+        }
+    }
+
+    /**
+     * Walk a generic JVM signature and route each referenced class type through the cascade
+     * check. {@code signature} is the optional generic descriptor ASM hands to
+     * {@code visitMethod}/{@code visitField}; it's null for non-generic members.
+     *
+     * <p>Used for field signatures and the class-header signature, both of which have a
+     * single cascade label; method signatures go through {@link #collectMethodSignatureRefs}
+     * so the return-type / parameter-type / exception-type positions get distinct labels.
+     */
+    private static void collectSignatureRefs(String signature, String violationType, String message,
+                                             String owner, String memberName, ApiSurface surface,
+                                             List<PublicApiViolation> violations) {
+        if (signature == null) return;
+        new SignatureReader(signature).accept(new SignatureVisitor(Opcodes.ASM9) {
+            @Override
+            public void visitClassType(String name) {
+                checkBinaryReference(name.replace('/', '.'),
+                        violationType, message, owner, memberName, surface, violations);
+            }
+        });
+    }
+
+    /**
+     * Walk a method signature using ASM's position-aware sub-visitor hooks so generic
+     * leaks get labeled by where they actually sit: a leaking return-type
+     * argument becomes {@code INVALID_RETURN_TYPE}, a parameter argument becomes
+     * {@code INVALID_PARAMETER_TYPE}, and a generic throws-clause becomes
+     * {@code INVALID_EXCEPTION_TYPE}. Type-parameter bounds
+     * ({@code <T extends InternalFoo>}) fall under the parameter label since they constrain
+     * caller-supplied types.
+     */
+    private static void collectMethodSignatureRefs(String signature, String owner, String memberName,
+                                                   ApiSurface surface, List<PublicApiViolation> violations) {
+        if (signature == null) return;
+        new SignatureReader(signature).accept(new SignatureVisitor(Opcodes.ASM9) {
+            @Override
+            public SignatureVisitor visitReturnType() {
+                return classTypeCollector("INVALID_RETURN_TYPE",
+                        "Public method signature return type exposes non-public API type",
+                        owner, memberName, surface, violations);
+            }
+
+            @Override
+            public SignatureVisitor visitParameterType() {
+                return classTypeCollector("INVALID_PARAMETER_TYPE",
+                        "Public method signature parameter type exposes non-public API type",
+                        owner, memberName, surface, violations);
+            }
+
+            @Override
+            public SignatureVisitor visitExceptionType() {
+                return classTypeCollector("INVALID_EXCEPTION_TYPE",
+                        "Public method signature exception type exposes non-public API type",
+                        owner, memberName, surface, violations);
+            }
+
+            @Override
+            public SignatureVisitor visitClassBound() {
+                return classTypeCollector("INVALID_PARAMETER_TYPE",
+                        "Public method type parameter bound exposes non-public API type",
+                        owner, memberName, surface, violations);
+            }
+
+            @Override
+            public SignatureVisitor visitInterfaceBound() {
+                return classTypeCollector("INVALID_PARAMETER_TYPE",
+                        "Public method type parameter bound exposes non-public API type",
+                        owner, memberName, surface, violations);
+            }
+        });
+    }
+
+    private static SignatureVisitor classTypeCollector(String violationType, String message, String owner,
+                                                       String memberName, ApiSurface surface,
+                                                       List<PublicApiViolation> violations) {
+        return new SignatureVisitor(Opcodes.ASM9) {
+            @Override
+            public void visitClassType(String name) {
+                checkBinaryReference(name.replace('/', '.'),
+                        violationType, message, owner, memberName, surface, violations);
+            }
+        };
+    }
+
+    /** Recurse through array element types to find the concrete reference type, then check it. */
+    private static void checkAsmType(Type type, String violationType, String message,
+                                     String owner, String methodName, ApiSurface surface,
+                                     List<PublicApiViolation> violations) {
+        if (type.getSort() == Type.ARRAY) {
+            checkAsmType(type.getElementType(), violationType, message, owner, methodName, surface, violations);
+        } else if (type.getSort() == Type.OBJECT) {
+            // Type.getClassName() returns the binary form (e.g. "org.apache.kafka.X$Y").
+            checkBinaryReference(type.getClassName(), violationType, message, owner, methodName, surface, violations);
+        }
+    }
+
+    /**
+     * Apply the cascade rule to one referenced type. The reference is a violation iff it is
+     * in {@code org.apache.kafka.*}, not deprecated, and not in the surface's
+     * effective-Public-dotted set.
+     *
+     * <p>The descriptor walk and the generic-signature walk overlap (e.g. {@code Windows} in
+     * {@code extends Windows<TimeWindow>} is seen by both), so dedup before adding to keep
+     * one entry per leaked type per member.
+     */
+    private static void checkBinaryReference(String binaryName, String violationType, String message,
+                                             String owner, String methodName, ApiSurface surface,
+                                             List<PublicApiViolation> violations) {
+        if (!binaryName.startsWith("org.apache.kafka.")) return;
+        if (surface.isDeprecated(binaryName)) return;
+        if (surface.isEffectivelyPublic(binaryName)) return;
+        // Dedup on the semantic key (type + violation position + owner + member) rather than
+        // the human-readable description, so the descriptor walk and the signature walk don't
+        // produce two findings for the same leak just because they word the message differently.
+        String descriptionSuffix = ": " + binaryName;
+        for (PublicApiViolation existing : violations) {
+            if (violationType.equals(existing.getViolationType())
+                    && owner.equals(existing.getClassName())
+                    && java.util.Objects.equals(methodName, existing.getMemberName())
+                    && existing.getDescription().endsWith(descriptionSuffix)) {
+                return;
+            }
+        }
+        violations.add(new PublicApiViolation(owner, violationType, message + descriptionSuffix, methodName));
+    }
+
+    /**
+     * Render a would-be violation as a suppression entry that the reporter prints in the
+     * "Suppressions" section. The reason from the {@code @SuppressKafkaInternalApiUsage}
+     * annotation is appended so reviewers can audit every escape hatch on every build.
+     */
+    private static PublicApiViolation asSuppression(PublicApiViolation original, String reason) {
+        boolean noReason = reason.isEmpty();
+        String prettyReason = noReason ? PublicApiViolation.NO_REASON_MARKER : reason;
+        String description = "Suppressed " + original.getViolationType() + " in "
+                + original.getClassName() + "#" + original.getMemberName()
+                + " — " + original.getDescription()
+                + " — reason: " + prettyReason;
+        return new PublicApiViolation(original.getClassName(), "SUPPRESSED",
+                description, original.getMemberName(), noReason);
+    }
+
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/CheckResult.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/CheckResult.java
new file mode 100644
index 00000000000..9625461fb09
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/CheckResult.java
@@ -0,0 +1,52 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import java.util.List;
+
+/**
+ * Outcome of any of the KIP-1265 checks: the Kafka-internal cascade and javadoc validators
+ * (returning would-be public-API leaks against Kafka's own surface) and the consumer-side
+ * internal-usage scanner (returning references from a downstream project's bytecode to a
+ * non-{@code @Public} Kafka type).
+ *
+ * <p>Violations are real failures that should fail the build. Suppressions are would-be
+ * violations that were silenced by a class-, method-, or field-level
+ * {@code @SuppressKafkaInternalApiUsage}; each carries the reason supplied to the annotation
+ * so reviewers can audit every escape hatch on every build.
+ */
+public final class CheckResult {
+    private final List<PublicApiViolation> violations;
+    private final List<PublicApiViolation> suppressions;
+
+    public CheckResult(List<PublicApiViolation> violations, List<PublicApiViolation> suppressions) {
+        this.violations = violations == null ? List.of() : List.copyOf(violations);
+        this.suppressions = suppressions == null ? List.of() : List.copyOf(suppressions);
+    }
+
+    public List<PublicApiViolation> violations() {
+        return violations;
+    }
+
+    public List<PublicApiViolation> suppressions() {
+        return suppressions;
+    }
+
+    public boolean hasViolations() {
+        return !violations.isEmpty();
+    }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/ClassFacts.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ClassFacts.java
new file mode 100644
index 00000000000..2ba45922ce7
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ClassFacts.java
@@ -0,0 +1,162 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import org.objectweb.asm.Opcodes;
+
+import java.util.EnumSet;
+
+/**
+ * Everything the checker needs to know about a single class, read once via ASM and cached.
+ * Carries:
+ * <ul>
+ *   <li>The class's two names — binary ({@code org.apache.kafka.X$Y}) for jar-entry lookup,
+ *       dotted ({@code org.apache.kafka.X.Y}) for javadoc HTML comparison.</li>
+ *   <li>The set of class-level bytecode annotations the checker recognises (see {@link Flag}).</li>
+ *   <li>The class's source-level access flag — for top-level classes the compiler writes the
+ *       true source access on the class header; for nested classes the header is always
+ *       {@code ACC_PUBLIC} regardless of source, but the real access lives in the
+ *       {@code InnerClasses} attribute entry for the class itself. The scanner reads both, with
+ *       the inner-class entry winning when present.</li>
+ * </ul>
+ *
+ * <p>Reading from bytecode rather than loading the class sidesteps {@code LinkageError} /
+ * {@code NoClassDefFoundError} from broken transitive deps (gRPC stubs, telemetry shims, etc.) —
+ * annotation descriptors live in the class file's constant pool, no linking required.
+ *
+ * <p>Instances are immutable; construct via {@link #builder(String)} so the ASM visitor that
+ * populates each field has an explicit mutable target instead of poking the result type.
+ * Equality is by {@code binaryName} so instances can be deduplicated in sets.
+ */
+final class ClassFacts {
+
+    /** Class-level bytecode annotation markers the checker cares about. */
+    enum Flag {
+        PUBLIC_API,
+        PRIVATE_API,
+        DEPRECATED
+    }
+
+    private final String binaryName;
+    private final String dottedName;
+    private final EnumSet<Flag> flags;
+    private final int sourceAccess;
+
+    private ClassFacts(Builder b) {
+        this.binaryName = b.binaryName;
+        this.dottedName = b.binaryName.replace('$', '.');
+        this.flags = b.flags.isEmpty() ? EnumSet.noneOf(Flag.class) : EnumSet.copyOf(b.flags);
+        this.sourceAccess = b.sourceAccess;
+    }
+
+    String binaryName() {
+        return binaryName;
+    }
+
+    String dottedName() {
+        return dottedName;
+    }
+
+    /** Carries a direct {@code @InterfaceAudience.Public}. */
+    boolean isPublic() {
+        return flags.contains(Flag.PUBLIC_API);
+    }
+
+    /** Carries a direct {@code @InterfaceAudience.Private} — overrides inherited Public on a nested class. */
+    boolean isPrivate() {
+        return flags.contains(Flag.PRIVATE_API);
+    }
+
+    /** Carries {@code @Deprecated} at the class level. */
+    boolean isDeprecated() {
+        return flags.contains(Flag.DEPRECATED);
+    }
+
+    /** True iff source-level access is {@code public} or {@code protected}. */
+    boolean isExternallyVisible() {
+        return (sourceAccess & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED)) != 0;
+    }
+
+    /**
+     * @return dotted name of the enclosing class, or {@code null} if this is a top-level class.
+     *         Lets chain walks avoid manipulating the {@code $} separator directly.
+     */
+    String enclosingName() {
+        String parent = parentBinaryName(binaryName);
+        return parent == null ? null : parent.replace('$', '.');
+    }
+
+    /**
+     * @return the immediate-parent binary name of a nested class (strip the trailing
+     *         {@code $segment}), or {@code null} for top-level classes. The single canonical
+     *         way to step one level outward in the enclosing-class chain.
+     */
+    static String parentBinaryName(String binaryName) {
+        int dollar = binaryName.lastIndexOf('$');
+        return dollar < 0 ? null : binaryName.substring(0, dollar);
+    }
+
+    /**
+     * @return the outermost compilation-unit binary name — strip everything from the first
+     *         {@code $} onward. Used for self-reference detection (two binary names share the
+     *         same outermost iff they're nested under the same top-level class).
+     */
+    static String outermostBinaryName(String binaryName) {
+        int dollar = binaryName.indexOf('$');
+        return dollar < 0 ? binaryName : binaryName.substring(0, dollar);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (!(o instanceof ClassFacts)) return false;
+        return binaryName.equals(((ClassFacts) o).binaryName);
+    }
+
+    @Override
+    public int hashCode() {
+        return binaryName.hashCode();
+    }
+
+    static Builder builder(String binaryName) {
+        return new Builder(binaryName);
+    }
+
+    /** Mutable accumulator used by the ASM visitor; {@link #build()} freezes into a {@link ClassFacts}. */
+    static final class Builder {
+        private final String binaryName;
+        private final EnumSet<Flag> flags = EnumSet.noneOf(Flag.class);
+        private int sourceAccess;
+
+        private Builder(String binaryName) {
+            this.binaryName = binaryName;
+        }
+
+        Builder addFlag(Flag flag) {
+            flags.add(flag);
+            return this;
+        }
+
+        Builder sourceAccess(int access) {
+            this.sourceAccess = access;
+            return this;
+        }
+
+        ClassFacts build() {
+            return new ClassFacts(this);
+        }
+    }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/JavadocConsistencyValidator.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/JavadocConsistencyValidator.java
new file mode 100644
index 00000000000..38f2b92fa1e
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/JavadocConsistencyValidator.java
@@ -0,0 +1,118 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * Cross-validates the published javadoc HTML against the project's bytecode-level audience
+ * annotations. Two complementary checks:
+ * <ul>
+ *   <li>{@code MISSING_JAVADOC} — a class carries a *direct* {@code @InterfaceAudience.Public}
+ *       but has no HTML page in the javadoc jar. Fired only on direct annotation because
+ *       javadoc doesn't emit a separate page for inherited-Public nested classes (protected or
+ *       package-private); their docs live on the outer's page.</li>
+ *   <li>{@code MISSING_PUBLICAPI_ANNOTATION} — a class appears in the javadoc HTML but isn't
+ *       effectively {@code @Public} (direct or inherited).</li>
+ * </ul>
+ *
+ * <p>Deprecated classes are out of scope on both sides: the deprecation set on the
+ * {@link ApiSurface} is subtracted from the HTML-discovered set before cross-validation.
+ *
+ * <p>Returns a {@link CheckResult} with an empty suppressions list — these violations don't
+ * currently have an escape-hatch mechanism, but the uniform shape lets callers compose
+ * validators without per-validator special cases.
+ */
+final class JavadocConsistencyValidator {
+
+    private JavadocConsistencyValidator() {}
+
+    static CheckResult validate(File javadocJar, ApiSurface surface) throws IOException {
+        List<PublicApiViolation> violations = new ArrayList<>();
+
+        Set<String> classesWithPublicDoc = findClassesFromJavadocHtml(javadocJar);
+        classesWithPublicDoc.removeIf(surface::isDeprecated);
+
+        for (ClassFacts facts : surface.directPublic()) {
+            if (!classesWithPublicDoc.contains(facts.dottedName())) {
+                violations.add(new PublicApiViolation(
+                    facts.dottedName(),
+                    "MISSING_JAVADOC",
+                    "Class has @InterfaceAudience.Public annotation but is missing from javadoc",
+                    null));
+            }
+        }
+
+        for (String dottedName : classesWithPublicDoc) {
+            if (!surface.isEffectivelyPublic(dottedName)) {
+                violations.add(new PublicApiViolation(
+                    dottedName,
+                    "MISSING_PUBLICAPI_ANNOTATION",
+                    "Class appears in javadoc but lacks @InterfaceAudience.Public annotation",
+                    null));
+            }
+        }
+
+        return new CheckResult(violations, List.of());
+    }
+
+    /** Find Kafka-namespaced class names from HTML files in a javadoc JAR. */
+    private static Set<String> findClassesFromJavadocHtml(File javadocJar) throws IOException {
+        Set<String> classes = new HashSet<>();
+        try (JarFile jar = new JarFile(javadocJar)) {
+            Enumeration<JarEntry> entries = jar.entries();
+            while (entries.hasMoreElements()) {
+                JarEntry entry = entries.nextElement();
+                if (!isClassHtmlFile(entry.getName())) continue;
+                String className = convertHtmlPathToClassName(entry.getName());
+                if (className.startsWith("org.apache.kafka.")) {
+                    classes.add(className);
+                }
+            }
+        }
+        return classes;
+    }
+
+    /** True if the entry path is an {@code org/apache/kafka/...} class HTML page. */
+    private static boolean isClassHtmlFile(String path) {
+        if (!path.endsWith(".html")) return false;
+        if (!path.startsWith("org/apache/kafka/")) return false;
+        // Skip the auxiliary trees javadoc emits when options.use() / options.linksource() are on:
+        // .../class-use/Foo.html (cross-reference index) and .../src-html/Foo.html (source listing).
+        // Neither is a class page, but both would parse as one and produce a bogus
+        // MISSING_PUBLICAPI_ANNOTATION for "...class-use.Foo" or "...src-html.Foo".
+        if (path.contains("/class-use/") || path.contains("/src-html/")) return false;
+        String fileName = path.substring(path.lastIndexOf('/') + 1);
+        // Class HTML files start with an uppercase letter; structural pages (index, overview-tree,
+        // package-summary, …) don't.
+        String classNamePart = fileName.replaceAll(".html$", "");
+        return !classNamePart.isEmpty() && Character.isUpperCase(classNamePart.charAt(0));
+    }
+
+    /** {@code org/apache/kafka/common/resource/Resource.html} → {@code org.apache.kafka.common.resource.Resource}. */
+    private static String convertHtmlPathToClassName(String htmlPath) {
+        return htmlPath.replace('/', '.').replaceAll(".html$", "");
+    }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScanner.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScanner.java
new file mode 100644
index 00000000000..5ca8f919251
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScanner.java
@@ -0,0 +1,624 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.Handle;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.signature.SignatureReader;
+import org.objectweb.asm.signature.SignatureVisitor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * Scans compiled JVM bytecode (.class files, packaged or loose) for references to Kafka classes
+ * that are not annotated with {@code @InterfaceAudience.Public}. Catches Java, Scala, Kotlin and
+ * any other JVM-language consumer uniformly — unlike a source-level scan, which is regex-bound
+ * to .java imports.
+ *
+ * <h2>Known limitations</h2>
+ * A handful of bytecode reference kinds are intentionally not walked. None of these are likely
+ * in practice for plugin/connector code but they're worth knowing about when interpreting a
+ * "0 violations" report:
+ * <ul>
+ *   <li><b>Parameter annotations</b> ({@code MethodVisitor#visitParameterAnnotation}) — the
+ *       annotation TYPE on a parameter, e.g. {@code void foo(@InternalAnno String s)}. The
+ *       method's own header (return / parameter / exception types) is walked, so any usage
+ *       that survives erasure into the method descriptor is still caught.</li>
+ *   <li><b>Type-use annotations</b> (JSR 308, {@code MethodVisitor#visitTypeAnnotation}) —
+ *       annotations attached to type positions like {@code List<@InternalAnno String>}. Used
+ *       almost exclusively by static-analysis tools; the underlying type is still recorded.</li>
+ *   <li><b>Class literals inside annotation element values</b> — e.g.
+ *       {@code @SomeAnnotation(impl = InternalClass.class)}. The annotation type itself is
+ *       recorded but {@code AnnotationVisitor#visit} isn't traversed into for class-typed
+ *       values. {@code InternalClass.class} loaded into a local also shows up via
+ *       {@code visitLdcInsn} from the method body, so a typical "save it then return it"
+ *       pattern is still caught — the gap is only when the class literal exists exclusively
+ *       inside an annotation.</li>
+ *   <li><b>Inlined compile-time constants</b> — Java inlines {@code public static final}
+ *       primitive/String constants at the use site, so {@code InternalClass.SOME_CONSTANT}
+ *       leaves no reference in the consumer's bytecode at all. This is documented in the KIP.</li>
+ *   <li><b>Nested types referenced only through a generic type argument</b> — e.g.
+ *       {@code Foo<Outer.Inner>} in a signature. The signature visitor handles the outer type
+ *       via {@link SignatureVisitor#visitClassType} but doesn't override
+ *       {@link SignatureVisitor#visitInnerClassType}, so the inner type is missed. Direct
+ *       (non-generic) references like {@code Outer.Inner} as a return / parameter / field
+ *       type carry {@code Outer$Inner} in the erased descriptor and ARE caught.</li>
+ * </ul>
+ */
+public class PluginDeveloperApiUsageScanner {
+    private static final Logger LOG = LoggerFactory.getLogger(PluginDeveloperApiUsageScanner.class);
+    private static final int ASM_API = Opcodes.ASM9;
+
+    /** Internal-form prefix (slashes) for any class we care about checking the audience of. */
+    private static final String KAFKA_INTERNAL_PREFIX = "org/apache/kafka/";
+    /** Descriptor of {@code @SuppressKafkaInternalApiUsage} — honoured when present on the enclosing class or member. */
+    private static final String SUPPRESS_DESCRIPTOR =
+            "Lorg/apache/kafka/common/annotation/SuppressKafkaInternalApiUsage;";
+    private static final String NO_REASON_GIVEN = PublicApiViolation.NO_REASON_MARKER;
+
+    private final Predicate<String> isPublicApi;
+
+    /**
+     * @param isPublicApi callback that returns {@code true} when the given binary class name
+     *                    (e.g. {@code org.apache.kafka.clients.producer.KafkaProducer}) is part
+     *                    of the public API surface
+     */
+    public PluginDeveloperApiUsageScanner(Predicate<String> isPublicApi) {
+        this.isPublicApi = isPublicApi;
+    }
+
+    /**
+     * Scan every {@code .class} entry reachable from the supplied roots. Each root may be a
+     * directory of class files, an individual .class file, or a .jar archive.
+     */
+    public CheckResult scan(List<File> roots) throws IOException {
+        // Use maps keyed by (consumer class, referenced internal class, member, line) so we
+        // don't double-record the same call site reachable through multiple visitor callbacks.
+        Map<String, PublicApiViolation> violations = new LinkedHashMap<>();
+        Map<String, PublicApiViolation> suppressions = new LinkedHashMap<>();
+        for (File root : roots) {
+            if (root == null || !root.exists()) {
+                continue;
+            }
+            if (root.isDirectory()) {
+                scanDirectory(root, violations, suppressions);
+            } else if (root.getName().endsWith(".jar")) {
+                scanJar(root, violations, suppressions);
+            } else if (root.getName().endsWith(".class")) {
+                try (InputStream in = new BufferedInputStream(Files.newInputStream(root.toPath()))) {
+                    scanClassStream(in, violations, suppressions);
+                }
+            }
+        }
+        return new CheckResult(new ArrayList<>(violations.values()),
+                new ArrayList<>(suppressions.values()));
+    }
+
+    private void scanDirectory(File dir,
+                               Map<String, PublicApiViolation> violations,
+                               Map<String, PublicApiViolation> suppressions) throws IOException {
+        File[] children = dir.listFiles();
+        if (children == null) {
+            return;
+        }
+        for (File child : children) {
+            if (child.isDirectory()) {
+                scanDirectory(child, violations, suppressions);
+            } else if (child.getName().endsWith(".class")) {
+                try (InputStream in = new BufferedInputStream(Files.newInputStream(child.toPath()))) {
+                    scanClassStream(in, violations, suppressions);
+                }
+            }
+        }
+    }
+
+    private void scanJar(File jar,
+                         Map<String, PublicApiViolation> violations,
+                         Map<String, PublicApiViolation> suppressions) throws IOException {
+        try (JarFile jarFile = new JarFile(jar)) {
+            Enumeration<JarEntry> entries = jarFile.entries();
+            while (entries.hasMoreElements()) {
+                JarEntry entry = entries.nextElement();
+                if (!entry.getName().endsWith(".class")) {
+                    continue;
+                }
+                try (InputStream in = new BufferedInputStream(jarFile.getInputStream(entry))) {
+                    scanClassStream(in, violations, suppressions);
+                }
+            }
+        }
+    }
+
+    private void scanClassStream(InputStream in,
+                                 Map<String, PublicApiViolation> violations,
+                                 Map<String, PublicApiViolation> suppressions) throws IOException {
+        ClassReader reader = new ClassReader(in);
+        reader.accept(new ReferenceCollectingClassVisitor(violations, suppressions), ClassReader.SKIP_FRAMES);
+    }
+
+    private void recordIfInternal(String internalName,
+                                  String consumerClass,
+                                  String memberName,
+                                  int line,
+                                  String suppressionReason,
+                                  Map<String, PublicApiViolation> violations,
+                                  Map<String, PublicApiViolation> suppressions) {
+        String binaryName = resolveInternalKafkaReference(internalName);
+        if (binaryName == null) {
+            return;
+        }
+        // Test the full nested name. The predicate (backed by ApiSurface#isEffectivelyPublic)
+        // walks the enclosing chain level-by-level, so an explicit @InterfaceAudience.Private
+        // on a nested class correctly overrides an inherited @Public from its outer — collapsing
+        // to the outer name here would silently allow exactly that override.
+        if (isPublicApi.test(binaryName)) {
+            return;
+        }
+        // Don't report a class flagging references to its own outermost compilation unit
+        // (covers self-references and references between siblings nested under the same outer).
+        if (ClassFacts.outermostBinaryName(binaryName).equals(ClassFacts.outermostBinaryName(consumerClass))) {
+            return;
+        }
+        String location = formatLocation(consumerClass, memberName, line);
+        String key = referenceKey(consumerClass, binaryName, memberName, line);
+        if (suppressionReason != null) {
+            recordSuppression(suppressions, key, consumerClass, binaryName, memberName, location, suppressionReason);
+        } else {
+            recordViolation(violations, key, consumerClass, binaryName, memberName, location);
+        }
+    }
+
+    /**
+     * @return the dotted binary name of an {@code org.apache.kafka.*} type referenced by
+     *         {@code internalName} (an ASM internal name or descriptor), or {@code null} if the
+     *         reference is to a non-Kafka type, a primitive, or unparseable.
+     */
+    private static String resolveInternalKafkaReference(String internalName) {
+        if (internalName == null) {
+            return null;
+        }
+        String trimmed = stripDescriptor(internalName);
+        if (trimmed == null || !trimmed.startsWith(KAFKA_INTERNAL_PREFIX)) {
+            return null;
+        }
+        return trimmed.replace('/', '.');
+    }
+
+    /** Render the consumer-side location as {@code Class#member (line N)}, omitting absent parts. */
+    private static String formatLocation(String consumerClass, String memberName, int line) {
+        StringBuilder sb = new StringBuilder(consumerClass);
+        if (memberName != null) {
+            sb.append('#').append(memberName);
+        }
+        if (line > 0) {
+            sb.append(" (line ").append(line).append(')');
+        }
+        return sb.toString();
+    }
+
+    /** Stable de-dup key so the same call-site reported via multiple visitor callbacks collapses to one entry. */
+    private static String referenceKey(String consumerClass, String binaryName, String memberName, int line) {
+        return consumerClass + "|" + binaryName + "|" + (memberName == null ? "" : memberName) + "|" + line;
+    }
+
+    private static void recordSuppression(Map<String, PublicApiViolation> suppressions,
+                                          String key, String consumerClass, String binaryName,
+                                          String memberName, String location, String reason) {
+        boolean noReason = reason.isEmpty();
+        String prettyReason = noReason ? NO_REASON_GIVEN : reason;
+        LOG.info("Suppressed internal-API reference to {} from {}: {}", binaryName, location, prettyReason);
+        String description = String.format(
+                "Suppressed reference to internal Kafka class %s from %s — reason: %s",
+                binaryName, location, prettyReason);
+        // className is the offender (consumer) so the report's `Summary by Class` groups by the
+        // file a developer needs to open — the leaked target lives in the description.
+        suppressions.putIfAbsent(key,
+                new PublicApiViolation(consumerClass, "SUPPRESSED_INTERNAL_API_USAGE", description, memberName, noReason));
+    }
+
+    private static void recordViolation(Map<String, PublicApiViolation> violations,
+                                        String key, String consumerClass, String binaryName,
+                                        String memberName, String location) {
+        String description = String.format(
+                "Bytecode reference to internal Kafka class %s from %s",
+                binaryName, location);
+        violations.putIfAbsent(key,
+                new PublicApiViolation(consumerClass, "INTERNAL_API_USAGE", description, memberName));
+    }
+
+    /**
+     * Return the ASM internal name for a reference-typed {@link Type} (OBJECT or ARRAY), or
+     * {@code null} for VOID/primitive types. {@link Type#getInternalName()} is only documented
+     * for reference types — calling it on a primitive happens to produce a single-char descriptor
+     * ("V"/"I"/...) today which the prefix gate happens to reject, but that's undefined behaviour
+     * we shouldn't depend on.
+     */
+    private static String referenceInternalName(Type type) {
+        int sort = type.getSort();
+        return (sort == Type.OBJECT || sort == Type.ARRAY) ? type.getInternalName() : null;
+    }
+
+    private static String stripDescriptor(String name) {
+        if (name == null || name.isEmpty()) {
+            return null;
+        }
+        int i = 0;
+        while (i < name.length() && name.charAt(i) == '[') {
+            i++;
+        }
+        if (i >= name.length()) {
+            return null;
+        }
+        char c = name.charAt(i);
+        if (c == 'L' && name.endsWith(";")) {
+            return name.substring(i + 1, name.length() - 1);
+        }
+        // Primitive descriptor (I, J, Z, ...) — nothing internal to record.
+        if (i > 0 || "VZBSCIJFD".indexOf(c) >= 0) {
+            return null;
+        }
+        return name;
+    }
+
+    /**
+     * Buffered reference. Header references (class super/interfaces, method return/param types,
+     * field type) are buffered because the {@code @SuppressKafkaInternalApiUsage} annotation that
+     * may legitimise them is visited <em>after</em> the header. Body-instruction references can be
+     * recorded immediately because annotations on a method/field are visited before the body.
+     */
+    private static final class PendingReference {
+        final String internalName;
+        final String memberName;
+        final int line;
+        PendingReference(String internalName, String memberName, int line) {
+            this.internalName = internalName;
+            this.memberName = memberName;
+            this.line = line;
+        }
+    }
+
+    /** Visits a class and records every referenced type, honouring {@code @SuppressKafkaInternalApiUsage}. */
+    private final class ReferenceCollectingClassVisitor extends ClassVisitor {
+        private final Map<String, PublicApiViolation> violations;
+        private final Map<String, PublicApiViolation> suppressions;
+        private String currentClass;
+        private String classSuppression; // null = none; otherwise the reason (may be empty string)
+        private final List<PendingReference> headerRefs = new ArrayList<>();
+
+        ReferenceCollectingClassVisitor(Map<String, PublicApiViolation> violations,
+                                        Map<String, PublicApiViolation> suppressions) {
+            super(ASM_API);
+            this.violations = violations;
+            this.suppressions = suppressions;
+        }
+
+        /**
+         * Class header — superclass + interface list + generic signature. Caught here so a consumer
+         * that {@code extends} or {@code implements} an internal Kafka type is flagged even if its
+         * body never names the type. Generics ({@code class C<T> extends Foo<Internal>}) hide
+         * inside the signature string and are pulled out via {@link SignatureReader}.
+         */
+        @Override
+        public void visit(int version, int access, String name, String signature,
+                          String superName, String[] interfaces) {
+            this.currentClass = name == null ? "<unknown>" : name.replace('/', '.');
+            if (superName != null) {
+                headerRefs.add(new PendingReference(superName, null, -1));
+            }
+            if (interfaces != null) {
+                for (String iface : interfaces) {
+                    headerRefs.add(new PendingReference(iface, null, -1));
+                }
+            }
+            collectSignatureRefs(signature, null, -1, headerRefs);
+        }
+
+        /**
+         * Class-level annotations. Two jobs: (a) capture the reason on
+         * {@code @SuppressKafkaInternalApiUsage} so header refs can be silenced, and (b) treat the
+         * annotation's own type as a reference — an {@code @InternalAnnotation} on a consumer class
+         * is still a reference into Kafka internals.
+         */
+        @Override
+        public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+            if (SUPPRESS_DESCRIPTOR.equals(descriptor)) {
+                return new ReasonCaptureVisitor(r -> classSuppression = r);
+            }
+            headerRefs.add(new PendingReference(stripDescriptor(descriptor), null, -1));
+            return null;
+        }
+
+        /**
+         * Field declarations. The field's declared type (descriptor) and its full generic form
+         * (signature) can both name internal types — e.g. {@code private InternalCache cache} or
+         * {@code Map<String, InternalFoo> data}. Refs are buffered so a field-level
+         * {@code @SuppressKafkaInternalApiUsage} (visited after the declaration) can suppress them.
+         */
+        @Override
+        public FieldVisitor visitField(int access, String name, String descriptor,
+                                       String signature, Object value) {
+            return new FieldVisitor(ASM_API) {
+                private String fieldSuppression;
+                private final List<PendingReference> fieldRefs = new ArrayList<>();
+                {
+                    fieldRefs.add(new PendingReference(stripDescriptor(descriptor), name, -1));
+                    collectSignatureRefs(signature, name, -1, fieldRefs);
+                }
+                @Override
+                public AnnotationVisitor visitAnnotation(String d, boolean v) {
+                    if (SUPPRESS_DESCRIPTOR.equals(d)) {
+                        return new ReasonCaptureVisitor(r -> fieldSuppression = r);
+                    }
+                    fieldRefs.add(new PendingReference(stripDescriptor(d), name, -1));
+                    return null;
+                }
+                @Override
+                public void visitEnd() {
+                    String reason = effective(fieldSuppression);
+                    flush(fieldRefs, reason);
+                }
+            };
+        }
+
+        /**
+         * Per-method delegate. Each method's body is walked by a
+         * {@link ReferenceCollectingMethodVisitor} that records every kind of bytecode-level
+         * reference (see its own javadoc).
+         */
+        @Override
+        public MethodVisitor visitMethod(int access, String name, String descriptor,
+                                         String signature, String[] exceptions) {
+            return new ReferenceCollectingMethodVisitor(name, descriptor, signature, exceptions);
+        }
+
+        /**
+         * End of class. Flushes the buffered header refs (superclass / interfaces / generic
+         * signature / non-suppress annotations) now that the class-level
+         * {@code @SuppressKafkaInternalApiUsage} reason — which is visited <em>after</em> the
+         * header in ASM's callback order — is finally known.
+         */
+        @Override
+        public void visitEnd() {
+            flush(headerRefs, classSuppression);
+        }
+
+        /** {@code memberReason} takes precedence; falls back to class-level. */
+        String effective(String memberReason) {
+            return memberReason != null ? memberReason : classSuppression;
+        }
+
+        void flush(List<PendingReference> refs, String reason) {
+            for (PendingReference r : refs) {
+                recordIfInternal(r.internalName, currentClass, r.memberName, r.line, reason, violations, suppressions);
+            }
+        }
+
+        private void collectSignatureRefs(String signature, String member, int line, List<PendingReference> out) {
+            if (signature == null) {
+                return;
+            }
+            new SignatureReader(signature).accept(new SignatureVisitor(ASM_API) {
+                @Override
+                public void visitClassType(String name) {
+                    out.add(new PendingReference(name, member, line));
+                }
+            });
+        }
+
+        /** Records type references encountered in method bodies; honours method-level + class-level suppression. */
+        private final class ReferenceCollectingMethodVisitor extends MethodVisitor {
+            private final String methodName;
+            private final List<PendingReference> headerBuffer = new ArrayList<>();
+            private int currentLine = -1;
+            private String methodSuppression;
+            private boolean codeStarted;
+
+            ReferenceCollectingMethodVisitor(String name, String descriptor, String signature, String[] exceptions) {
+                super(ASM_API);
+                this.methodName = name;
+                Type methodType = Type.getMethodType(descriptor);
+                headerBuffer.add(new PendingReference(referenceInternalName(methodType.getReturnType()), name, -1));
+                for (Type arg : methodType.getArgumentTypes()) {
+                    headerBuffer.add(new PendingReference(referenceInternalName(arg), name, -1));
+                }
+                if (exceptions != null) {
+                    for (String ex : exceptions) {
+                        headerBuffer.add(new PendingReference(ex, name, -1));
+                    }
+                }
+                collectSignatureRefs(signature, name, -1, headerBuffer);
+            }
+
+            /**
+             * Method-level annotations. {@code @SuppressKafkaInternalApiUsage} captures the reason
+             * to silence both header refs (return/param/exception types) and body refs in this
+             * method. All other annotation types are themselves recorded as references — an
+             * annotation IS a class reference, even if it's never used elsewhere.
+             */
+            @Override
+            public AnnotationVisitor visitAnnotation(String d, boolean v) {
+                if (SUPPRESS_DESCRIPTOR.equals(d)) {
+                    return new ReasonCaptureVisitor(r -> methodSuppression = r);
+                }
+                headerBuffer.add(new PendingReference(stripDescriptor(d), methodName, -1));
+                return null;
+            }
+
+            /**
+             * Marker fired when ASM transitions from method header to method body. All method-level
+             * annotations have already been visited by this point, so {@link #methodSuppression} is
+             * stable — header refs (return type / params / declared exceptions / generic signature
+             * / non-suppress annotations) are flushed now with the correct effective reason.
+             */
+            @Override
+            public void visitCode() {
+                if (!codeStarted) {
+                    flush(headerBuffer, effective(methodSuppression));
+                    headerBuffer.clear();
+                    codeStarted = true;
+                }
+            }
+
+            /**
+             * Source line marker from the {@code LineNumberTable} debug attribute. Only fires for
+             * classes compiled with {@code javac -g} (the default). Carries no class reference of
+             * its own — we just track the current line so violations can be reported as
+             * {@code ConsumerClass#method (line N)}. Stays at -1 when debug info is stripped.
+             */
+            @Override
+            public void visitLineNumber(int line, org.objectweb.asm.Label start) {
+                this.currentLine = line;
+            }
+
+            /**
+             * Type-as-operand instructions: {@code NEW}, {@code ANEWARRAY}, {@code CHECKCAST},
+             * {@code INSTANCEOF}. The operand is the class being instantiated, cast to, or tested —
+             * exactly the cases where a consumer reaches an internal type without naming it via a
+             * method call or field access.
+             */
+            @Override
+            public void visitTypeInsn(int opcode, String type) {
+                recordBody(type);
+            }
+
+            /**
+             * Field-access instructions: {@code GETFIELD}, {@code PUTFIELD}, {@code GETSTATIC},
+             * {@code PUTSTATIC}. Both the field's <em>owner</em> (the declaring class) and its
+             * <em>type</em> (descriptor) can name internal Kafka classes — e.g. reading
+             * {@code InternalClass.CONSTANT} (owner is internal) or writing to a field whose type
+             * is internal.
+             */
+            @Override
+            public void visitFieldInsn(int opcode, String owner, String name, String descriptor) {
+                recordBody(owner);
+                recordBody(stripDescriptor(descriptor));
+            }
+
+            /**
+             * Method-invocation instructions: {@code INVOKEVIRTUAL}, {@code INVOKESPECIAL},
+             * {@code INVOKESTATIC}, {@code INVOKEINTERFACE}. Three reference slots per call site:
+             * the owner (declaring class), the return type, and each argument type. Catches both
+             * "calls into an internal class" and "passes an internal type as an argument or
+             * receives one as a return".
+             */
+            @Override
+            public void visitMethodInsn(int opcode, String owner, String name,
+                                        String descriptor, boolean isInterface) {
+                recordBody(owner);
+                Type methodType = Type.getMethodType(descriptor);
+                recordBody(referenceInternalName(methodType.getReturnType()));
+                for (Type arg : methodType.getArgumentTypes()) {
+                    recordBody(referenceInternalName(arg));
+                }
+            }
+
+            /**
+             * {@code INVOKEDYNAMIC}. Emitted by {@code javac} for lambdas, method references, and
+             * {@code String} concatenation since Java 9. Only the call-site descriptor — return
+             * type and argument types — is walked. The bootstrap method handle itself is not
+             * followed, because the LambdaMetafactory machinery is JDK-owned and the user-visible
+             * references show up via the descriptor anyway.
+             */
+            @Override
+            public void visitInvokeDynamicInsn(String name, String descriptor,
+                                               Handle bootstrapMethodHandle, Object... bootstrapMethodArguments) {
+                Type methodType = Type.getMethodType(descriptor);
+                recordBody(referenceInternalName(methodType.getReturnType()));
+                for (Type arg : methodType.getArgumentTypes()) {
+                    recordBody(referenceInternalName(arg));
+                }
+            }
+
+            /**
+             * {@code LDC} loads a constant onto the operand stack — int / long / float / double /
+             * String / and, relevantly here, {@code Class} literals such as
+             * {@code InternalClass.class}. Only the {@link Type} case is interesting; primitive
+             * and string constants carry no class reference.
+             */
+            @Override
+            public void visitLdcInsn(Object value) {
+                if (value instanceof Type) {
+                    Type t = (Type) value;
+                    if (t.getSort() == Type.OBJECT || t.getSort() == Type.ARRAY) {
+                        recordBody(t.getInternalName());
+                    }
+                }
+            }
+
+            /**
+             * {@code MULTIANEWARRAY} for multi-dimensional arrays
+             * ({@code new InternalClass[3][3]}). Single-dimensional array allocation goes through
+             * {@code ANEWARRAY} which is handled by {@link #visitTypeInsn}.
+             */
+            @Override
+            public void visitMultiANewArrayInsn(String descriptor, int numDimensions) {
+                recordBody(stripDescriptor(descriptor));
+            }
+
+            /**
+             * Exception-handler table entries: {@code catch (InternalKafkaException e)}. The
+             * {@code type} is the internal name of the caught exception (or {@code null} for a
+             * {@code finally} block, which has no exception type to record).
+             */
+            @Override
+            public void visitTryCatchBlock(org.objectweb.asm.Label start, org.objectweb.asm.Label end,
+                                           org.objectweb.asm.Label handler, String type) {
+                if (type != null) {
+                    recordBody(type);
+                }
+            }
+
+            /**
+             * End of method. {@link #visitCode} is never called for abstract / native methods —
+             * they have no body — so their header refs would otherwise leak unflushed. This safety
+             * net guarantees every method's return/param/exception types are still audited.
+             */
+            @Override
+            public void visitEnd() {
+                if (!codeStarted) {
+                    flush(headerBuffer, effective(methodSuppression));
+                    headerBuffer.clear();
+                }
+            }
+
+            private void recordBody(String internalName) {
+                recordIfInternal(internalName, currentClass, methodName, currentLine,
+                        effective(methodSuppression), violations, suppressions);
+            }
+        }
+    }
+}
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiChecker.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiChecker.java
new file mode 100644
index 00000000000..eea888f4b55
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiChecker.java
@@ -0,0 +1,121 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Facade for the KIP-1265 public-API checker. Pre-scans the supplied Kafka jars into an
+ * {@link ApiSurface} once and delegates each check to a focused validator:
+ * <ul>
+ *   <li>{@link JavadocConsistencyValidator} for {@code MISSING_JAVADOC} / {@code MISSING_PUBLICAPI_ANNOTATION}.</li>
+ *   <li>{@link CascadeValidator} for {@code INVALID_RETURN_TYPE} / {@code INVALID_PARAMETER_TYPE} /
+ *       {@code INVALID_EXCEPTION_TYPE} with {@code @SuppressKafkaInternalApiUsage} suppression.</li>
+ *   <li>{@link PluginDeveloperApiUsageScanner} for the consumer-side check (via {@link #checkBytecode}).</li>
+ * </ul>
+ *
+ * <p>All bytecode reading is done via ASM; no classloading, no {@code LinkageError} risk from
+ * broken transitive deps.
+ */
+public class PublicApiChecker {
+
+    private final ApiSurface surface;
+
+    /**
+     * @param projectJars   jars produced by the project being checked. Their classes drive
+     *                      the MISSING_JAVADOC iteration and are cascade-checked for method
+     *                      signature leaks.
+     * @param referenceJars jars from sibling Kafka modules this project depends on. Their
+     *                      classes contribute to the {@code @InterfaceAudience.Public}
+     *                      membership set so cross-module references resolve, but they don't
+     *                      take part in this project's own javadoc consistency or cascade
+     *                      iteration (each module checks its own surface).
+     */
+    public PublicApiChecker(List<File> projectJars, List<File> referenceJars) throws IOException {
+        this.surface = ApiSurfaceScanner.scan(projectJars, referenceJars);
+    }
+
+    /** Convenience for the consumer-side scanner: no separate reference jars needed. */
+    public PublicApiChecker(List<File> kafkaJars) throws IOException {
+        this(kafkaJars, java.util.Collections.emptyList());
+    }
+
+    /**
+     * Cross-validate the javadoc HTML against the project's bytecode audience annotations and
+     * cascade-check public method signatures for internal-type leaks.
+     */
+    public CheckResult checkPublicApiConsistency(File javadocJar) throws IOException {
+        CheckResult javadoc = JavadocConsistencyValidator.validate(javadocJar, surface);
+        CheckResult cascade = CascadeValidator.validate(surface);
+
+        List<PublicApiViolation> violations = new ArrayList<>(javadoc.violations());
+        violations.addAll(cascade.violations());
+        List<PublicApiViolation> suppressions = new ArrayList<>(javadoc.suppressions());
+        suppressions.addAll(cascade.suppressions());
+        return new CheckResult(violations, suppressions);
+    }
+
+    /**
+     * Consumer-side check: walk compiled bytecode at the given roots and flag references to any
+     * {@code org.apache.kafka.*} class that isn't effectively {@code @InterfaceAudience.Public}.
+     * Roots may be class directories or jar archives.
+     */
+    public CheckResult checkBytecode(List<File> classFileRoots) throws IOException {
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(this::isPublicApi);
+        return scanner.scan(classFileRoots);
+    }
+
+    /**
+     * Filter the supplied class-file roots to those that exist on disk. Both adapters
+     * (Gradle task, Maven mojo) seed the input from build configuration that may include
+     * directories the build hasn't produced yet (e.g. test classes when there are no tests).
+     */
+    public static List<File> collectExistingRoots(Iterable<File> roots) {
+        List<File> existing = new ArrayList<>();
+        for (File root : roots) {
+            if (root.exists()) {
+                existing.add(root);
+            }
+        }
+        return existing;
+    }
+
+    /**
+     * Consumer-side predicate: true if the binary class name (e.g.
+     * {@code org.apache.kafka.clients.producer.KafkaProducer} or
+     * {@code org.apache.kafka.clients.admin.OffsetSpec$LatestSpec}) is effectively
+     * {@code @InterfaceAudience.Public} — either via a direct annotation or by inheritance
+     * from an enclosing class. A direct {@code @InterfaceAudience.Private} overrides an
+     * inherited Public. Non-{@code org.apache.kafka.*} types are treated as out of scope.
+     *
+     * <p>Deprecation is intentionally <em>not</em> a bypass on the consumer side:
+     * {@code @Deprecated} internal types are exactly the ones most likely to be removed in
+     * the next release, so consumer references to them deserve a violation. The cascade
+     * validator has its own deprecation bypass for the producer-side leak check, which is
+     * fine — that side is just asking "did we expose this in the API surface?", whereas the
+     * consumer side is asking "am I going to break when Kafka removes this?".
+     */
+    public boolean isPublicApi(String binaryClassName) {
+        if (!binaryClassName.startsWith("org.apache.kafka.")) {
+            return true; // not in scope
+        }
+        return surface.isEffectivelyPublic(binaryClassName);
+    }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiViolation.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiViolation.java
new file mode 100644
index 00000000000..3a55eecb578
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiViolation.java
@@ -0,0 +1,109 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+/**
+ * Represents a violation of the public API rules.
+ */
+public class PublicApiViolation {
+    /**
+     * Rendered into a suppression's description when the {@code @SuppressKafkaInternalApiUsage}
+     * annotation carried no {@code value()}. Stable marker so the reporter and the build tasks
+     * can surface unjustified suppressions consistently.
+     */
+    public static final String NO_REASON_MARKER = "(no reason given)";
+
+    private final String className;
+    private final String violationType;
+    private final String description;
+    private final String memberName;
+    private final boolean lacksReason;
+
+    public PublicApiViolation(String className, String violationType, String description, String memberName) {
+        this(className, violationType, description, memberName, false);
+    }
+
+    /**
+     * Suppression-constructor variant: pass {@code lacksReason=true} when the underlying
+     * {@code @SuppressKafkaInternalApiUsage} carried no {@code value()}. KIP-1265 makes the
+     * reason required, so the reporter surfaces unjustified suppressions as a warning.
+     */
+    public PublicApiViolation(String className, String violationType, String description,
+                              String memberName, boolean lacksReason) {
+        this.className = className;
+        this.violationType = violationType;
+        this.description = description;
+        this.memberName = memberName;
+        this.lacksReason = lacksReason;
+    }
+
+    public String getClassName() {
+        return className;
+    }
+
+    public String getViolationType() {
+        return violationType;
+    }
+
+    public String getDescription() {
+        return description;
+    }
+
+    public String getMemberName() {
+        return memberName;
+    }
+
+    @Override
+    public String toString() {
+        if (memberName != null && !memberName.isEmpty()) {
+            return String.format("[%s] %s.%s: %s", violationType, className, memberName, description);
+        } else {
+            return String.format("[%s] %s: %s", violationType, className, description);
+        }
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+
+        PublicApiViolation that = (PublicApiViolation) o;
+
+        if (!className.equals(that.className)) return false;
+        if (!violationType.equals(that.violationType)) return false;
+        if (!description.equals(that.description)) return false;
+        return memberName != null ? memberName.equals(that.memberName) : that.memberName == null;
+    }
+
+    /**
+     * @return true iff this represents a suppression whose {@code @SuppressKafkaInternalApiUsage}
+     *         annotation carried no {@code value()}. KIP-1265 makes the reason required, so the
+     *         checker warns on unjustified suppressions.
+     */
+    public boolean lacksReason() {
+        return lacksReason;
+    }
+
+    @Override
+    public int hashCode() {
+        int result = className.hashCode();
+        result = 31 * result + violationType.hashCode();
+        result = 31 * result + description.hashCode();
+        result = 31 * result + (memberName != null ? memberName.hashCode() : 0);
+        return result;
+    }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/ReasonCaptureVisitor.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ReasonCaptureVisitor.java
new file mode 100644
index 00000000000..6d30aa22727
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ReasonCaptureVisitor.java
@@ -0,0 +1,54 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.Opcodes;
+
+import java.util.function.Consumer;
+
+/**
+ * Captures the {@code value()} string of a {@code @SuppressKafkaInternalApiUsage} annotation.
+ * If the annotation is present but its {@code value()} was omitted, {@link #visitEnd()} routes
+ * an empty string to the setter — the convention both checkers use to mean "suppressed without
+ * a reason."
+ */
+final class ReasonCaptureVisitor extends AnnotationVisitor {
+
+    private final Consumer<String> setter;
+    private boolean assigned;
+
+    ReasonCaptureVisitor(Consumer<String> setter) {
+        super(Opcodes.ASM9);
+        this.setter = setter;
+    }
+
+    @Override
+    public void visit(String name, Object value) {
+        if ("value".equals(name) && value instanceof String) {
+            setter.accept((String) value);
+            assigned = true;
+        }
+    }
+
+    @Override
+    public void visitEnd() {
+        if (!assigned) {
+            setter.accept("");
+        }
+    }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/ViolationReporter.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ViolationReporter.java
new file mode 100644
index 00000000000..cd68b57c649
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ViolationReporter.java
@@ -0,0 +1,178 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+/**
+ * Generates reports for public API violations.
+ */
+public class ViolationReporter {
+
+    /**
+     * Write violations + suppressions to a text report file. Suppressions are rendered in a
+     * dedicated section so reviewers can audit every place {@code @SuppressKafkaInternalApiUsage}
+     * has been applied — together with the reason supplied to the annotation.
+     */
+    public void writeTextReport(List<PublicApiViolation> violations,
+                                List<PublicApiViolation> suppressions,
+                                File reportFile) throws IOException {
+        reportFile.getParentFile().mkdirs();
+        List<PublicApiViolation> safeSuppressions =
+                suppressions == null ? Collections.emptyList() : suppressions;
+
+        // Report contents must be reproducible: any two runs over the same inputs should produce
+        // byte-identical reports so CI diff tooling and reviewers can compare cleanly. That rules
+        // out a wall-clock timestamp (omitted) and HashMap-order grouping (replaced with TreeMap
+        // + a stable per-list sort by class then member).
+        try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter(reportFile.toPath(), StandardCharsets.UTF_8))) {
+            writer.println("Apache Kafka Public API Violation Report");
+            writer.println("========================================");
+            writer.println("Total violations: " + violations.size());
+            writer.println("Total suppressions: " + safeSuppressions.size());
+            writer.println();
+
+            if (violations.isEmpty()) {
+                writer.println("No violations found.");
+            } else {
+                Map<String, List<PublicApiViolation>> violationsByType = groupSorted(
+                        violations, PublicApiViolation::getViolationType);
+                for (Map.Entry<String, List<PublicApiViolation>> entry : violationsByType.entrySet()) {
+                    writer.println("## " + entry.getKey() + " (" + entry.getValue().size() + " violations)");
+                    writer.println();
+                    for (PublicApiViolation violation : entry.getValue()) {
+                        writer.println("- " + violation.toString());
+                    }
+                    writer.println();
+                }
+
+                writer.println("## Summary by Class");
+                writer.println();
+                Map<String, List<PublicApiViolation>> violationsByClass = groupSorted(
+                        violations, PublicApiViolation::getClassName);
+                for (Map.Entry<String, List<PublicApiViolation>> entry : violationsByClass.entrySet()) {
+                    writer.println("### " + entry.getKey() + " (" + entry.getValue().size() + " violations)");
+                    for (PublicApiViolation violation : entry.getValue()) {
+                        writer.println("  - " + violation.getViolationType() + ": " + violation.getDescription());
+                    }
+                    writer.println();
+                }
+            }
+
+            if (!safeSuppressions.isEmpty()) {
+                writer.println("## Suppressions (" + safeSuppressions.size() + " entries)");
+                writer.println("Checks skipped due to @SuppressKafkaInternalApiUsage.");
+                writer.println("Each line shows the reason supplied to the annotation; review periodically.");
+                writer.println();
+                List<PublicApiViolation> sortedSuppressions = new ArrayList<>(safeSuppressions);
+                sortedSuppressions.sort(VIOLATION_ORDER);
+                for (PublicApiViolation suppression : sortedSuppressions) {
+                    writer.println("- " + suppression.getDescription());
+                }
+                writer.println();
+            }
+        }
+    }
+
+    /** Stable sort key for any list of violations: class, then member, then description. */
+    private static final Comparator<PublicApiViolation> VIOLATION_ORDER =
+            Comparator.comparing(PublicApiViolation::getClassName, Comparator.nullsFirst(String::compareTo))
+                    .thenComparing(PublicApiViolation::getMemberName, Comparator.nullsFirst(String::compareTo))
+                    .thenComparing(PublicApiViolation::getDescription, Comparator.nullsFirst(String::compareTo));
+
+    private static <K extends Comparable<? super K>> Map<K, List<PublicApiViolation>> groupSorted(
+            List<PublicApiViolation> violations,
+            java.util.function.Function<PublicApiViolation, K> keyFn) {
+        Map<K, List<PublicApiViolation>> grouped = violations.stream()
+                .collect(Collectors.groupingBy(keyFn, TreeMap::new, Collectors.toList()));
+        grouped.values().forEach(list -> list.sort(VIOLATION_ORDER));
+        return grouped;
+    }
+
+    /** Back-compat overload — call sites that don't yet pass suppressions. */
+    public void writeTextReport(List<PublicApiViolation> violations, File reportFile) throws IOException {
+        writeTextReport(violations, Collections.emptyList(), reportFile);
+    }
+
+    /**
+     * Print to console using {@link #shouldUseColors()} to decide whether to emit ANSI escapes.
+     * Callers in unknown environments (Gradle / Maven tasks that may run under CI / redirected
+     * output) should prefer this overload over hard-coding {@code useColors=true}.
+     */
+    public void printToConsole(List<PublicApiViolation> violations,
+                               List<PublicApiViolation> suppressions) {
+        printToConsole(violations, suppressions, shouldUseColors());
+    }
+
+    /**
+     * True when stdout is attached to a real terminal and the user hasn't opted out via the
+     * {@code NO_COLOR} convention (<a href="https://no-color.org/">no-color.org</a>). Returns
+     * false under CI or when output has been redirected, so colored output doesn't leak ANSI
+     * escapes into logs/files.
+     */
+    public static boolean shouldUseColors() {
+        if (System.getenv("NO_COLOR") != null) return false;
+        return System.console() != null;
+    }
+
+    /**
+     * Print violations to console with color coding (if supported). Suppressions are listed at the
+     * end so reviewers see what was waived (each with reason).
+     */
+    public void printToConsole(List<PublicApiViolation> violations,
+                               List<PublicApiViolation> suppressions,
+                               boolean useColors) {
+        String redColor = useColors ? "\u001B[31m" : "";
+        String greenColor = useColors ? "\u001B[32m" : "";
+        String yellowColor = useColors ? "\u001B[33m" : "";
+        String cyanColor = useColors ? "\u001B[36m" : "";
+        String resetColor = useColors ? "\u001B[0m" : "";
+
+        if (violations.isEmpty()) {
+            System.out.println(greenColor + "No public API violations found." + resetColor);
+        } else {
+            System.out.println(redColor + "Found " + violations.size() + " public API violation(s):" + resetColor);
+            System.out.println();
+            for (PublicApiViolation violation : violations) {
+                System.out.println(yellowColor + violation.toString() + resetColor);
+            }
+            System.out.println();
+            System.out.println("Please fix these violations to ensure API compatibility.");
+        }
+
+        if (suppressions != null && !suppressions.isEmpty()) {
+            System.out.println();
+            System.out.println(cyanColor + suppressions.size()
+                    + " check(s) suppressed via @SuppressKafkaInternalApiUsage:" + resetColor);
+            for (PublicApiViolation suppression : suppressions) {
+                System.out.println(cyanColor + "  " + suppression.getDescription() + resetColor);
+            }
+        }
+    }
+
+}
diff --git a/api-checker/core/src/test/java/org/apache/kafka/apicheck/ApiSurfaceScannerTest.java b/api-checker/core/src/test/java/org/apache/kafka/apicheck/ApiSurfaceScannerTest.java
new file mode 100644
index 00000000000..3e2c8b42f78
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/ApiSurfaceScannerTest.java
@@ -0,0 +1,215 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ApiSurfaceScannerTest {
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void publicTopLevelClass_isInDirectAndEffectiveSets() throws IOException {
+        File jar = projectJar("proj.jar",
+                AsmClassFactory.klass("org.apache.kafka.foo.Bar").access(Opcodes.ACC_PUBLIC).publicApi());
+        ApiSurface s = scan(jar);
+
+        assertTrue(containsDotted(s.directPublic(), "org.apache.kafka.foo.Bar"));
+        assertTrue(containsDotted(s.effectivePublic(), "org.apache.kafka.foo.Bar"));
+        assertTrue(s.isEffectivelyPublic("org.apache.kafka.foo.Bar"));
+        assertEquals(jar, s.jarOf("org.apache.kafka.foo.Bar"));
+    }
+
+    @Test
+    void privateNested_overridesInheritedPublic() throws IOException {
+        File jar = projectJar("proj.jar",
+                AsmClassFactory.klass("org.apache.kafka.foo.Outer").access(Opcodes.ACC_PUBLIC).publicApi(),
+                AsmClassFactory.klass("org.apache.kafka.foo.Outer$Inner").access(Opcodes.ACC_PUBLIC).privateApi());
+        ApiSurface s = scan(jar);
+
+        assertTrue(s.isEffectivelyPublic("org.apache.kafka.foo.Outer"));
+        assertFalse(s.isEffectivelyPublic("org.apache.kafka.foo.Outer$Inner"),
+                "@Private must override inherited @Public");
+        assertFalse(containsDotted(s.directPublic(), "org.apache.kafka.foo.Outer.Inner"));
+        assertFalse(containsDotted(s.effectivePublic(), "org.apache.kafka.foo.Outer.Inner"));
+    }
+
+    @Test
+    void unannotatedNested_inheritsPublicFromOuter() throws IOException {
+        File jar = projectJar("proj.jar",
+                AsmClassFactory.klass("org.apache.kafka.foo.Outer").access(Opcodes.ACC_PUBLIC).publicApi(),
+                AsmClassFactory.klass("org.apache.kafka.foo.Outer$Inner").access(Opcodes.ACC_PUBLIC));
+        ApiSurface s = scan(jar);
+
+        assertTrue(s.isEffectivelyPublic("org.apache.kafka.foo.Outer$Inner"));
+        // Inner has no direct @Public, so it's not in the MISSING_JAVADOC iteration set.
+        assertFalse(containsDotted(s.directPublic(), "org.apache.kafka.foo.Outer.Inner"));
+        // Externally visible + effective @Public + owned → in the cascade iteration set.
+        assertTrue(containsDotted(s.effectivePublic(), "org.apache.kafka.foo.Outer.Inner"));
+    }
+
+    @Test
+    void packagePrivateNested_inheritsPublicButNotInCascade() throws IOException {
+        // Nested-class header is ACC_PUBLIC (compiler convention) but the InnerClasses entry says
+        // package-private. The scanner must trust the InnerClasses entry over the header.
+        File jar = projectJar("proj.jar",
+                AsmClassFactory.klass("org.apache.kafka.foo.Outer").access(Opcodes.ACC_PUBLIC).publicApi(),
+                AsmClassFactory.klass("org.apache.kafka.foo.Outer$Inner")
+                        .access(Opcodes.ACC_PUBLIC)
+                        .nestedAccess(0));
+        ApiSurface s = scan(jar);
+
+        assertTrue(s.isEffectivelyPublic("org.apache.kafka.foo.Outer$Inner"),
+                "chain walk sees inherited @Public regardless of visibility");
+        // The effectivePublic set contains all owned + effective-Public classes; CascadeValidator
+        // filters on isExternallyVisible() at the iteration site rather than at the scanner.
+        assertTrue(containsDotted(s.effectivePublic(), "org.apache.kafka.foo.Outer.Inner"));
+        ClassFacts innerFacts = s.factsOf("org.apache.kafka.foo.Outer$Inner");
+        assertFalse(innerFacts.isExternallyVisible(),
+                "package-private nested classes are filtered out by CascadeValidator, not the scanner");
+    }
+
+    @Test
+    void deprecatedClass_isExcludedFromIterationSets() throws IOException {
+        File jar = projectJar("proj.jar",
+                AsmClassFactory.klass("org.apache.kafka.foo.OldBar")
+                        .access(Opcodes.ACC_PUBLIC)
+                        .publicApi()
+                        .deprecated());
+        ApiSurface s = scan(jar);
+
+        assertTrue(s.isDeprecated("org.apache.kafka.foo.OldBar"));
+        assertTrue(s.directPublic().isEmpty(), "deprecated classes are out of scope on both validation sides");
+        assertTrue(s.effectivePublic().isEmpty());
+        // isEffectivelyPublic answers the audience question only — deprecation is handled
+        // separately by callers (CascadeValidator skips deprecated refs before this check).
+        assertTrue(s.isEffectivelyPublic("org.apache.kafka.foo.OldBar"));
+    }
+
+    @Test
+    void anonymousAndLambdaClasses_areSkippedEntirely() throws IOException {
+        File jar = TempJarBuilder.jar()
+                .addClass(AsmClassFactory.klass("org.apache.kafka.foo.Outer").access(Opcodes.ACC_PUBLIC).publicApi())
+                .addClass(AsmClassFactory.klass("org.apache.kafka.foo.Outer$1").access(Opcodes.ACC_PUBLIC))
+                .addClass(AsmClassFactory.klass("org.apache.kafka.foo.Outer$$Lambda$0").access(Opcodes.ACC_PUBLIC))
+                .writeTo(tempDir, "proj.jar");
+        ApiSurface s = scan(jar);
+
+        assertNotNull(s.factsOf("org.apache.kafka.foo.Outer"));
+        assertNull(s.factsOf("org.apache.kafka.foo.Outer$1"),
+                "anonymous classes (digit after $) are not part of the API surface");
+        assertNull(s.factsOf("org.apache.kafka.foo.Outer$$Lambda$0"),
+                "lambda / synthetic-accessor classes ($$) are skipped");
+    }
+
+    @Test
+    void nonKafkaClasses_areSkipped() throws IOException {
+        File jar = TempJarBuilder.jar()
+                .addClass(AsmClassFactory.klass("com.example.External").access(Opcodes.ACC_PUBLIC).publicApi())
+                .writeTo(tempDir, "proj.jar");
+        ApiSurface s = scan(jar);
+
+        assertNull(s.factsOf("com.example.External"));
+        assertTrue(s.directPublic().isEmpty());
+    }
+
+    @Test
+    void packageInfoAndModuleInfo_areSkipped() throws IOException {
+        // The scanner short-circuits on the binary name before reading any bytes, so the content
+        // of these entries is irrelevant — pass garbage to prove they aren't parsed.
+        File jar = TempJarBuilder.jar()
+                .addEntry("org/apache/kafka/foo/package-info.class", new byte[]{0})
+                .addEntry("module-info.class", new byte[]{0})
+                .addClass(AsmClassFactory.klass("org.apache.kafka.foo.Bar").access(Opcodes.ACC_PUBLIC).publicApi())
+                .writeTo(tempDir, "proj.jar");
+        ApiSurface s = scan(jar);
+
+        assertNotNull(s.factsOf("org.apache.kafka.foo.Bar"));
+        assertNull(s.factsOf("org.apache.kafka.foo.package-info"));
+        assertNull(s.factsOf("module-info"));
+    }
+
+    @Test
+    void referenceJar_contributesMembershipButNotIteration() throws IOException {
+        File proj = projectJar("proj.jar",
+                AsmClassFactory.klass("org.apache.kafka.proj.OwnedClass").access(Opcodes.ACC_PUBLIC).publicApi());
+        File ref = TempJarBuilder.jar()
+                .addClass(AsmClassFactory.klass("org.apache.kafka.ref.RefClass").access(Opcodes.ACC_PUBLIC).publicApi())
+                .writeTo(tempDir, "ref.jar");
+        ApiSurface s = ApiSurfaceScanner.scan(List.of(proj), List.of(ref));
+
+        // Reference class is recorded and contributes to the membership set so cross-module
+        // @Public references resolve in cascade checks…
+        assertNotNull(s.factsOf("org.apache.kafka.ref.RefClass"));
+        assertTrue(s.isEffectivelyPublic("org.apache.kafka.ref.RefClass"));
+        // …but doesn't take part in this project's MISSING_JAVADOC / cascade iteration.
+        assertFalse(containsDotted(s.directPublic(),    "org.apache.kafka.ref.RefClass"));
+        assertFalse(containsDotted(s.effectivePublic(), "org.apache.kafka.ref.RefClass"));
+        // Project-owned class participates in both iteration sets.
+        assertTrue(containsDotted(s.directPublic(),    "org.apache.kafka.proj.OwnedClass"));
+        assertTrue(containsDotted(s.effectivePublic(), "org.apache.kafka.proj.OwnedClass"));
+    }
+
+    @Test
+    void crossJarDuplicate_projectJarWins() throws IOException {
+        // Same binary name in both jars — the project jar is scanned first and keeps ownership.
+        AsmClassFactory.ClassBuilder dup =
+                AsmClassFactory.klass("org.apache.kafka.dup.Dup").access(Opcodes.ACC_PUBLIC).publicApi();
+        File proj = TempJarBuilder.jar().addClass(dup).writeTo(tempDir, "proj.jar");
+        // Re-build a new ClassBuilder for the ref jar to avoid sharing state with the above call.
+        File ref = TempJarBuilder.jar()
+                .addClass(AsmClassFactory.klass("org.apache.kafka.dup.Dup").access(Opcodes.ACC_PUBLIC).publicApi())
+                .writeTo(tempDir, "ref.jar");
+
+        ApiSurface s = ApiSurfaceScanner.scan(List.of(proj), List.of(ref));
+
+        assertEquals(proj, s.jarOf("org.apache.kafka.dup.Dup"));
+        assertTrue(containsDotted(s.directPublic(), "org.apache.kafka.dup.Dup"),
+                "project-jar entry establishes ownership so the class participates in iteration");
+    }
+
+    // Helpers
+
+    private File projectJar(String fileName, AsmClassFactory.ClassBuilder... builders) throws IOException {
+        TempJarBuilder jar = TempJarBuilder.jar();
+        for (AsmClassFactory.ClassBuilder b : builders) jar.addClass(b);
+        return jar.writeTo(tempDir, fileName);
+    }
+
+    private static ApiSurface scan(File... projectJars) throws IOException {
+        return ApiSurfaceScanner.scan(List.of(projectJars), Collections.emptyList());
+    }
+
+    private static boolean containsDotted(java.util.Collection<ClassFacts> set, String dottedName) {
+        return set.stream().anyMatch(f -> f.dottedName().equals(dottedName));
+    }
+}
diff --git a/api-checker/core/src/test/java/org/apache/kafka/apicheck/ApiSurfaceTest.java b/api-checker/core/src/test/java/org/apache/kafka/apicheck/ApiSurfaceTest.java
new file mode 100644
index 00000000000..42155a77c2e
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/ApiSurfaceTest.java
@@ -0,0 +1,200 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import org.junit.jupiter.api.Test;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ApiSurfaceTest {
+
+    private static final File JAR_A = new File("/tmp/a.jar");
+    private static final File JAR_B = new File("/tmp/b.jar");
+
+    @Test
+    void factsOf_acceptsBothBinaryAndDottedNames() {
+        ClassFacts inner = facts("org.apache.kafka.foo.Outer$Inner");
+        ApiSurface surface = ApiSurface.builder().recordClass(inner, JAR_A).build();
+
+        assertSame(inner, surface.factsOf("org.apache.kafka.foo.Outer$Inner"));
+        assertSame(inner, surface.factsOf("org.apache.kafka.foo.Outer.Inner"));
+    }
+
+    @Test
+    void factsOf_returnsNullForUnknownClass() {
+        ApiSurface surface = ApiSurface.builder().build();
+        assertNull(surface.factsOf("org.apache.kafka.NotThere"));
+    }
+
+    @Test
+    void jarOf_returnsRecordedJar() {
+        ClassFacts f = facts("org.apache.kafka.foo.Bar");
+        ApiSurface surface = ApiSurface.builder().recordClass(f, JAR_A).build();
+        assertEquals(JAR_A, surface.jarOf("org.apache.kafka.foo.Bar"));
+        assertNull(surface.jarOf("org.apache.kafka.NotThere"));
+    }
+
+    @Test
+    void recordClass_jarRegistrationIsFirstWins() {
+        // The scanner already de-dupes by binary name before calling recordClass, but the builder
+        // defends with putIfAbsent on the jar map so jarOf stays stable if a duplicate slips through.
+        ClassFacts f = facts("org.apache.kafka.foo.Bar");
+        ApiSurface surface = ApiSurface.builder()
+                .recordClass(f, JAR_A)
+                .recordClass(f, JAR_B)
+                .build();
+        assertEquals(JAR_A, surface.jarOf("org.apache.kafka.foo.Bar"));
+    }
+
+    @Test
+    void isEffectivelyPublic_acceptsBothNameForms() {
+        // isEffectivelyPublic walks the enclosing chain on facts. Inner inherits via Outer's @Public.
+        ClassFacts outer = facts("org.apache.kafka.foo.Outer", ClassFacts.Flag.PUBLIC_API);
+        ClassFacts inner = facts("org.apache.kafka.foo.Outer$Inner");
+        ApiSurface surface = ApiSurface.builder()
+                .recordClass(outer, JAR_A)
+                .recordClass(inner, JAR_A)
+                .build();
+
+        assertTrue(surface.isEffectivelyPublic("org.apache.kafka.foo.Outer$Inner"));
+        assertTrue(surface.isEffectivelyPublic("org.apache.kafka.foo.Outer.Inner"));
+    }
+
+    @Test
+    void isEffectivelyPublic_skipsMissingIntermediates() {
+        // Bug #5 regression: an anonymous/synthetic intermediate like Outer$1 is filtered out
+        // of the surface by ApiSurfaceScanner#isSyntheticOrAnonymous, but a named nested class
+        // *inside* the synthetic (Outer$1$Inner) should still inherit Outer's @Public. The walk
+        // has to step past the missing intermediate lexically rather than stopping at the gap.
+        ClassFacts outer = facts("org.apache.kafka.foo.Outer", ClassFacts.Flag.PUBLIC_API);
+        ApiSurface surface = ApiSurface.builder()
+                .recordClass(outer, JAR_A)
+                .build();
+
+        assertTrue(surface.isEffectivelyPublic("org.apache.kafka.foo.Outer$1$Inner"),
+                "anonymous-enclosed nested class must still inherit Outer's @Public");
+    }
+
+    @Test
+    void isEffectivelyPublic_falseWhenNotAnnotated() {
+        ClassFacts f = facts("org.apache.kafka.foo.Bar");
+        ApiSurface surface = ApiSurface.builder().recordClass(f, JAR_A).build();
+        assertFalse(surface.isEffectivelyPublic("org.apache.kafka.foo.Bar"));
+        assertFalse(surface.isEffectivelyPublic("org.apache.kafka.UnknownClass"));
+    }
+
+    @Test
+    void isEffectivelyPublic_privateNestedOverridesInheritedPublic() {
+        ClassFacts outer = facts("org.apache.kafka.foo.Outer", ClassFacts.Flag.PUBLIC_API);
+        ClassFacts inner = facts("org.apache.kafka.foo.Outer$Inner", ClassFacts.Flag.PRIVATE_API);
+        ApiSurface surface = ApiSurface.builder()
+                .recordClass(outer, JAR_A).recordClass(inner, JAR_A)
+                .build();
+
+        assertTrue(surface.isEffectivelyPublic("org.apache.kafka.foo.Outer"));
+        assertFalse(surface.isEffectivelyPublic("org.apache.kafka.foo.Outer$Inner"));
+    }
+
+    @Test
+    void isDeprecated_trueWhenClassItselfIsDeprecated() {
+        ClassFacts f = facts("org.apache.kafka.foo.Bar", ClassFacts.Flag.DEPRECATED);
+        ApiSurface surface = ApiSurface.builder().recordClass(f, JAR_A).build();
+        assertTrue(surface.isDeprecated("org.apache.kafka.foo.Bar"));
+    }
+
+    @Test
+    void isDeprecated_inheritsFromEnclosingClass() {
+        // Outer is @Deprecated; Inner is not directly annotated. The walk up the enclosing chain
+        // (Outer$Inner → Outer) finds the deprecation and propagates it.
+        ClassFacts outer = facts("org.apache.kafka.foo.Outer", ClassFacts.Flag.DEPRECATED);
+        ClassFacts inner = facts("org.apache.kafka.foo.Outer$Inner");
+        ApiSurface surface = ApiSurface.builder()
+                .recordClass(outer, JAR_A)
+                .recordClass(inner, JAR_A)
+                .build();
+
+        assertTrue(surface.isDeprecated("org.apache.kafka.foo.Outer$Inner"));
+        assertTrue(surface.isDeprecated("org.apache.kafka.foo.Outer.Inner"));
+    }
+
+    @Test
+    void isDeprecated_falseWhenNeitherSelfNorEnclosingIsDeprecated() {
+        ClassFacts outer = facts("org.apache.kafka.foo.Outer");
+        ClassFacts inner = facts("org.apache.kafka.foo.Outer$Inner");
+        ApiSurface surface = ApiSurface.builder()
+                .recordClass(outer, JAR_A)
+                .recordClass(inner, JAR_A)
+                .build();
+
+        assertFalse(surface.isDeprecated("org.apache.kafka.foo.Outer$Inner"));
+    }
+
+    @Test
+    void isDeprecated_falseWhenClassNotInSurface() {
+        // factsOf returns null on the very first iteration; the walk exits cleanly with false.
+        ApiSurface surface = ApiSurface.builder().build();
+        assertFalse(surface.isDeprecated("org.apache.kafka.NotThere"));
+    }
+
+    @Test
+    void isDeprecated_falseWhenEnclosingNotInSurface() {
+        // Nested class is recorded but its outer isn't. The walk finds the inner (not deprecated),
+        // then looks up the missing outer, gets null, and exits with false. No inheritance possible
+        // without facts for the enclosing class.
+        ClassFacts inner = facts("org.apache.kafka.foo.Outer$Inner");
+        ApiSurface surface = ApiSurface.builder().recordClass(inner, JAR_A).build();
+        assertFalse(surface.isDeprecated("org.apache.kafka.foo.Outer$Inner"));
+    }
+
+    @Test
+    void iterationSets_containWhatWasAdded() {
+        ClassFacts a = facts("org.apache.kafka.foo.A");
+        ClassFacts b = facts("org.apache.kafka.foo.B");
+        ApiSurface surface = ApiSurface.builder()
+                .recordClass(a, JAR_A).recordClass(b, JAR_A)
+                .addEffectivePublic(a)
+                .addDirectPublic(b)
+                .build();
+
+        assertEquals(1, surface.effectivePublic().size());
+        assertTrue(surface.effectivePublic().contains(a));
+        assertEquals(1, surface.directPublic().size());
+        assertTrue(surface.directPublic().contains(b));
+    }
+
+    @Test
+    void iterationSets_areImmutable() {
+        ApiSurface surface = ApiSurface.builder().build();
+        ClassFacts f = facts("org.apache.kafka.foo.Bar");
+        assertThrows(UnsupportedOperationException.class, () -> surface.effectivePublic().add(f));
+        assertThrows(UnsupportedOperationException.class, () -> surface.directPublic().add(f));
+    }
+
+    private static ClassFacts facts(String binaryName, ClassFacts.Flag... flags) {
+        ClassFacts.Builder b = ClassFacts.builder(binaryName).sourceAccess(Opcodes.ACC_PUBLIC);
+        for (ClassFacts.Flag flag : flags) b.addFlag(flag);
+        return b.build();
+    }
+}
diff --git a/api-checker/core/src/test/java/org/apache/kafka/apicheck/CascadeValidatorTest.java b/api-checker/core/src/test/java/org/apache/kafka/apicheck/CascadeValidatorTest.java
new file mode 100644
index 00000000000..beda30ad3d3
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/CascadeValidatorTest.java
@@ -0,0 +1,424 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CascadeValidatorTest {
+
+    private static final String OWNER_BIN = "org.apache.kafka.api.Owner";
+    private static final String INTERNAL_BIN = "org.apache.kafka.internals.Internal";
+    private static final String INTERNAL_DESC = "Lorg/apache/kafka/internals/Internal;";
+    private static final String INTERNAL_INTERNAL_NAME = "org/apache/kafka/internals/Internal";
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void publicMethodWithInternalReturnType_emitsInvalidReturnType() throws IOException {
+        CheckResult r = run(owner()
+                .method(AsmClassFactory.method("leak").returns(INTERNAL_DESC)));
+
+        assertEquals(1, r.violations().size());
+        PublicApiViolation v = r.violations().get(0);
+        assertEquals("INVALID_RETURN_TYPE", v.getViolationType());
+        assertEquals(OWNER_BIN, v.getClassName());
+        assertEquals("leak", v.getMemberName());
+        assertTrue(v.getDescription().contains(INTERNAL_BIN),
+                "description should name the leaked type: " + v.getDescription());
+    }
+
+    @Test
+    void publicMethodWithInternalParameter_emitsInvalidParameterType() throws IOException {
+        CheckResult r = run(owner()
+                .method(AsmClassFactory.method("take").param(INTERNAL_DESC)));
+
+        assertEquals(1, r.violations().size());
+        assertEquals("INVALID_PARAMETER_TYPE", r.violations().get(0).getViolationType());
+    }
+
+    @Test
+    void publicMethodWithInternalException_emitsInvalidExceptionType() throws IOException {
+        CheckResult r = run(owner()
+                .method(AsmClassFactory.method("boom").throwsExc(INTERNAL_INTERNAL_NAME)));
+
+        assertEquals(1, r.violations().size());
+        assertEquals("INVALID_EXCEPTION_TYPE", r.violations().get(0).getViolationType());
+    }
+
+    @Test
+    void arrayOfInternalType_recursesAndFlags() throws IOException {
+        // Array descriptors prepend "[" to the element descriptor. The validator must recurse
+        // through the array layer to reach the object element type.
+        CheckResult r = run(owner()
+                .method(AsmClassFactory.method("batch").returns("[" + INTERNAL_DESC)));
+
+        assertEquals(1, r.violations().size());
+        assertEquals("INVALID_RETURN_TYPE", r.violations().get(0).getViolationType());
+    }
+
+    @Test
+    void publicMethodWithInternalInGenericReturnSignature_emitsReturnTypeViolation() throws IOException {
+        // The erased descriptor is `()Ljava/util/Map;` — no internal type. The generic signature
+        // `()Ljava/util/Map<Ljava/lang/String;Lorg/apache/kafka/internals/Internal;>;` carries
+        // the internal type as a Map value parameter on the *return* position. The signature
+        // walker is position-aware so it surfaces this as INVALID_RETURN_TYPE rather than
+        // mislabeling it as a parameter type.
+        CheckResult r = run(owner()
+                .method(AsmClassFactory.method("getStuff")
+                        .returns("Ljava/util/Map;")
+                        .signature("()Ljava/util/Map<Ljava/lang/String;Lorg/apache/kafka/internals/Internal;>;")));
+
+        assertTrue(r.violations().stream().anyMatch(v -> "INVALID_RETURN_TYPE".equals(v.getViolationType())
+                        && v.getDescription().contains(INTERNAL_BIN)),
+                "generic return-type argument should surface as INVALID_RETURN_TYPE; got: " + r.violations());
+    }
+
+    @Test
+    void publicMethodWithInternalInGenericParameterSignature_emitsParameterTypeViolation() throws IOException {
+        // Generic on the parameter side: `void take(java.util.List<o.a.k.internals.Internal>)`.
+        CheckResult r = run(owner()
+                .method(AsmClassFactory.method("take")
+                        .returns("V")
+                        .param("Ljava/util/List;")
+                        .signature("(Ljava/util/List<Lorg/apache/kafka/internals/Internal;>;)V")));
+
+        assertTrue(r.violations().stream().anyMatch(v -> "INVALID_PARAMETER_TYPE".equals(v.getViolationType())
+                        && v.getDescription().contains(INTERNAL_BIN)),
+                "generic parameter-type argument should surface as INVALID_PARAMETER_TYPE; got: " + r.violations());
+    }
+
+    @Test
+    void publicFieldWithInternalInGenericSignature_emitsFieldTypeViolation() throws IOException {
+        // The erased descriptor is `Ljava/util/List;`. The signature
+        // `Ljava/util/List<Lorg/apache/kafka/internals/Internal;>;` exposes Internal as a
+        // List element parameter. Cascade must walk the signature.
+        CheckResult r = run(owner()
+                .field(AsmClassFactory.field("things")
+                        .ofType("Ljava/util/List;")
+                        .signature("Ljava/util/List<Lorg/apache/kafka/internals/Internal;>;")));
+
+        assertTrue(r.violations().stream().anyMatch(v -> "INVALID_FIELD_TYPE".equals(v.getViolationType())
+                        && v.getDescription().contains(INTERNAL_BIN)),
+                "generic field signature should surface as an INVALID_FIELD_TYPE; got: " + r.violations());
+    }
+
+    @Test
+    void publicClassExtendingInternalType_emitsSupertypeViolation() throws IOException {
+        // A @Public class whose superclass is an internal Kafka type exposes the supertype
+        // to consumers (it's part of the type's public contract — inherited methods, casts,
+        // etc.). The cascade catches this from the class-header visit.
+        CheckResult r = run(owner().superClass("org/apache/kafka/internals/Internal"));
+
+        assertTrue(r.violations().stream().anyMatch(v -> "INVALID_SUPERTYPE".equals(v.getViolationType())
+                        && v.getDescription().contains(INTERNAL_BIN)),
+                "extending an internal type must trigger an INVALID_SUPERTYPE; got: " + r.violations());
+    }
+
+    @Test
+    void publicClassImplementingInternalInterface_emitsSupertypeViolation() throws IOException {
+        CheckResult r = run(owner().interfaces("org/apache/kafka/internals/Internal"));
+
+        assertTrue(r.violations().stream().anyMatch(v -> "INVALID_SUPERTYPE".equals(v.getViolationType())
+                        && v.getDescription().contains(INTERNAL_BIN)),
+                "implementing an internal interface must trigger an INVALID_SUPERTYPE; got: " + r.violations());
+    }
+
+    @Test
+    void classLevelSuppress_silencesSupertypeViolation() throws IOException {
+        // Class-level @SuppressKafkaInternalApiUsage diverts the header leak to the
+        // suppressions list, same as for method-level cascade leaks.
+        CheckResult r = run(owner()
+                .suppress("legacy-base-class")
+                .superClass("org/apache/kafka/internals/Internal"));
+
+        assertTrue(r.violations().isEmpty(), "class-level suppress should silence the header leak");
+        assertTrue(r.suppressions().stream().anyMatch(s -> s.getDescription().contains("reason: legacy-base-class")),
+                "suppression must carry the annotation reason; got: " + r.suppressions());
+    }
+
+    @Test
+    void deprecatedInternalType_isNotFlagged() throws IOException {
+        // Internal is recorded with @Deprecated → out of scope on both sides.
+        CheckResult r = runWithExtras(owner()
+                        .method(AsmClassFactory.method("legacy").returns(INTERNAL_DESC)),
+                facts(INTERNAL_BIN, ClassFacts.Flag.DEPRECATED));
+
+        assertTrue(r.violations().isEmpty(),
+                "deprecated referenced type must not trigger: " + r.violations());
+    }
+
+    @Test
+    void referenceToEffectivelyPublicType_isNotFlagged() throws IOException {
+        // Internal is in the membership set → counts as part of the public API surface.
+        CheckResult r = runWithEffectivelyPublic(owner()
+                        .method(AsmClassFactory.method("ok").returns(INTERNAL_DESC)),
+                INTERNAL_BIN);
+
+        assertTrue(r.violations().isEmpty());
+    }
+
+    @Test
+    void referenceToNonKafkaType_isNotFlagged() throws IOException {
+        // JDK types (java/util/Map) and third-party types are out of scope — the cascade rule
+        // only constrains references inside org.apache.kafka.*.
+        CheckResult r = run(owner()
+                .method(AsmClassFactory.method("safe")
+                        .returns("Ljava/util/Map;")
+                        .param("Lcom/example/External;")));
+
+        assertTrue(r.violations().isEmpty());
+    }
+
+    @Test
+    void privateMethod_isIgnored() throws IOException {
+        // Cascade only inspects externally-visible methods. Private leaks are tolerated
+        // because they're invisible to consumers.
+        CheckResult r = run(owner()
+                .method(AsmClassFactory.method("priv").access(Opcodes.ACC_PRIVATE).returns(INTERNAL_DESC)));
+
+        assertTrue(r.violations().isEmpty());
+    }
+
+    @Test
+    void protectedMethod_emitsViolation() throws IOException {
+        // KIP-1265: protected members on an extensible @Public class are reachable to
+        // subclasses, so they count toward the public API surface and must not leak
+        // non-public types.
+        CheckResult r = run(owner()
+                .method(AsmClassFactory.method("prot").access(Opcodes.ACC_PROTECTED).returns(INTERNAL_DESC)));
+
+        assertEquals(1, r.violations().size());
+        assertEquals("INVALID_RETURN_TYPE", r.violations().get(0).getViolationType());
+    }
+
+    @Test
+    void publicFieldOfInternalType_emitsInvalidFieldType() throws IOException {
+        // KIP-1265: field types are part of the cascade — a public field of an internal type
+        // leaks the internal type just like a method signature does.
+        CheckResult r = run(owner()
+                .field(AsmClassFactory.field("leakField").ofType(INTERNAL_DESC)));
+
+        assertEquals(1, r.violations().size());
+        PublicApiViolation v = r.violations().get(0);
+        assertEquals("INVALID_FIELD_TYPE", v.getViolationType());
+        assertEquals(OWNER_BIN, v.getClassName());
+        assertEquals("leakField", v.getMemberName());
+        assertTrue(v.getDescription().contains(INTERNAL_BIN),
+                "description should name the leaked type: " + v.getDescription());
+    }
+
+    @Test
+    void protectedFieldOfInternalType_emitsInvalidFieldType() throws IOException {
+        // protected fields on an extensible @Public class are also part of the API surface.
+        CheckResult r = run(owner()
+                .field(AsmClassFactory.field("protLeak").access(Opcodes.ACC_PROTECTED).ofType(INTERNAL_DESC)));
+
+        assertEquals(1, r.violations().size());
+        assertEquals("INVALID_FIELD_TYPE", r.violations().get(0).getViolationType());
+    }
+
+    @Test
+    void privateFieldOfInternalType_isIgnored() throws IOException {
+        CheckResult r = run(owner()
+                .field(AsmClassFactory.field("hidden").access(Opcodes.ACC_PRIVATE).ofType(INTERNAL_DESC)));
+
+        assertTrue(r.violations().isEmpty());
+    }
+
+    @Test
+    void arrayFieldOfInternalType_recursesAndFlags() throws IOException {
+        CheckResult r = run(owner()
+                .field(AsmClassFactory.field("buf").ofType("[" + INTERNAL_DESC)));
+
+        assertEquals(1, r.violations().size());
+        assertEquals("INVALID_FIELD_TYPE", r.violations().get(0).getViolationType());
+    }
+
+    @Test
+    void fieldLevelSuppress_movesFieldViolationToSuppressions() throws IOException {
+        CheckResult r = run(owner()
+                .field(AsmClassFactory.field("leak").ofType(INTERNAL_DESC).suppress("legacy-field")));
+
+        assertTrue(r.violations().isEmpty());
+        assertEquals(1, r.suppressions().size());
+        assertTrue(r.suppressions().get(0).getDescription().contains("reason: legacy-field"));
+    }
+
+    @Test
+    void classLevelSuppress_silencesFieldLeaks() throws IOException {
+        CheckResult r = run(owner()
+                .suppress("legacy-api")
+                .field(AsmClassFactory.field("leak").ofType(INTERNAL_DESC)));
+
+        assertTrue(r.violations().isEmpty());
+        assertEquals(1, r.suppressions().size());
+        assertTrue(r.suppressions().get(0).getDescription().contains("reason: legacy-api"));
+    }
+
+    @Test
+    void syntheticField_isIgnored() throws IOException {
+        // Compiler-generated synthetic fields (e.g. $assertionsDisabled) are not source-level API.
+        CheckResult r = run(owner()
+                .field(AsmClassFactory.field("synth")
+                        .access(Opcodes.ACC_PUBLIC | Opcodes.ACC_SYNTHETIC)
+                        .ofType(INTERNAL_DESC)));
+
+        assertTrue(r.violations().isEmpty());
+    }
+
+    @Test
+    void syntheticMethod_isIgnored() throws IOException {
+        // Bridge / ACC_SYNTHETIC methods are compiler-generated, not source-level API.
+        CheckResult r = run(owner()
+                .method(AsmClassFactory.method("bridge").bridge().returns(INTERNAL_DESC))
+                .method(AsmClassFactory.method("synth").synthetic().returns(INTERNAL_DESC)));
+
+        assertTrue(r.violations().isEmpty());
+    }
+
+    @Test
+    void classLevelSuppress_movesAllViolationsToSuppressions() throws IOException {
+        CheckResult r = run(owner()
+                .suppress("legacy-api")
+                .method(AsmClassFactory.method("leak").returns(INTERNAL_DESC)));
+
+        assertTrue(r.violations().isEmpty(), "class-level suppress should silence every method");
+        assertEquals(1, r.suppressions().size());
+        PublicApiViolation s = r.suppressions().get(0);
+        assertEquals("SUPPRESSED", s.getViolationType());
+        assertTrue(s.getDescription().contains("reason: legacy-api"),
+                "suppression must carry the annotation's reason: " + s.getDescription());
+    }
+
+    @Test
+    void methodLevelSuppress_overridesClassLevelReason() throws IOException {
+        // Class-level "class-reason" applies to methods without their own annotation;
+        // a method-level annotation wins for that method.
+        CheckResult r = run(owner()
+                .suppress("class-reason")
+                .method(AsmClassFactory.method("m1").returns(INTERNAL_DESC).suppress("method-reason"))
+                .method(AsmClassFactory.method("m2").returns(INTERNAL_DESC)));
+
+        assertTrue(r.violations().isEmpty());
+        assertEquals(2, r.suppressions().size());
+        assertTrue(r.suppressions().stream().anyMatch(v ->
+                v.getMemberName().equals("m1") && v.getDescription().contains("reason: method-reason")));
+        assertTrue(r.suppressions().stream().anyMatch(v ->
+                v.getMemberName().equals("m2") && v.getDescription().contains("reason: class-reason")));
+    }
+
+    @Test
+    void suppressWithNoValue_recordsNoReasonGiven() throws IOException {
+        // @SuppressKafkaInternalApiUsage on its own (no value()) → ReasonCaptureVisitor records
+        // an empty reason, which the reporter renders as "(no reason given)".
+        CheckResult r = run(owner()
+                .method(AsmClassFactory.method("leak").returns(INTERNAL_DESC).suppress(null)));
+
+        assertTrue(r.violations().isEmpty());
+        assertEquals(1, r.suppressions().size());
+        assertTrue(r.suppressions().get(0).getDescription().contains("reason: (no reason given)"),
+                "empty reason must render as '(no reason given)': "
+                        + r.suppressions().get(0).getDescription());
+    }
+
+    @Test
+    void descriptorAndSignatureWalks_dedupSameType() throws IOException {
+        // When the type appears in both the erased descriptor AND the generic signature (this
+        // happens for non-generic types and for the outer type in `Outer<...>`), the cascade
+        // mustn't emit two violations for the same (member, type, position).
+        CheckResult r = run(owner()
+                .method(AsmClassFactory.method("doIt")
+                        .returns(INTERNAL_DESC)
+                        .signature("()L" + AsmClassFactory.toInternal(INTERNAL_BIN) + ";")));
+
+        long internalHits = r.violations().stream()
+                .filter(v -> v.getDescription().contains(INTERNAL_BIN))
+                .count();
+        assertEquals(1, internalHits,
+                "descriptor and signature walks must dedup the same type/position: " + r.violations());
+    }
+
+    @Test
+    void jarOfReturnsNull_classIsSilentlySkipped() throws IOException {
+        // Class is in the cascade iteration set but no jar is recorded for it. The validator
+        // bails on the missing jar without throwing — defensive against scan/cascade desync.
+        ClassFacts orphan = facts("org.apache.kafka.api.Orphan", ClassFacts.Flag.PUBLIC_API);
+        ApiSurface surface = ApiSurface.builder()
+                .addEffectivePublic(orphan)
+                .addEffectivePublic(orphan)
+                .build();
+
+        CheckResult r = CascadeValidator.validate(surface);
+        assertTrue(r.violations().isEmpty());
+        assertTrue(r.suppressions().isEmpty());
+    }
+
+    // ----- helpers -----
+
+    /** Owner class scaffolding shared by every cascade test: top-level public, audience @Public. */
+    private static AsmClassFactory.ClassBuilder owner() {
+        return AsmClassFactory.klass(OWNER_BIN).access(Opcodes.ACC_PUBLIC).publicApi();
+    }
+
+    private CheckResult run(AsmClassFactory.ClassBuilder owner) throws IOException {
+        return runWithExtras(owner);
+    }
+
+    /** Validate against a surface that registers {@code extras} in addition to the owner class. */
+    private CheckResult runWithExtras(AsmClassFactory.ClassBuilder owner, ClassFacts... extras) throws IOException {
+        File jar = TempJarBuilder.jar().addClass(owner).writeTo(tempDir, "x.jar");
+        ClassFacts ownerFacts = facts(owner.binaryName(), ClassFacts.Flag.PUBLIC_API);
+        ApiSurface.Builder b = ApiSurface.builder()
+                .recordClass(ownerFacts, jar)
+                .addEffectivePublic(ownerFacts)
+                .addEffectivePublic(ownerFacts);
+        for (ClassFacts f : extras) b.recordClass(f, jar);
+        return CascadeValidator.validate(b.build());
+    }
+
+    /** Validate against a surface where the named extras are also marked effectively public. */
+    private CheckResult runWithEffectivelyPublic(AsmClassFactory.ClassBuilder owner,
+                                                 String... effectivelyPublicBinaryNames) throws IOException {
+        File jar = TempJarBuilder.jar().addClass(owner).writeTo(tempDir, "x.jar");
+        ClassFacts ownerFacts = facts(owner.binaryName(), ClassFacts.Flag.PUBLIC_API);
+        ApiSurface.Builder b = ApiSurface.builder()
+                .recordClass(ownerFacts, jar)
+                .addEffectivePublic(ownerFacts)
+                .addEffectivePublic(ownerFacts);
+        for (String name : effectivelyPublicBinaryNames) {
+            ClassFacts f = facts(name, ClassFacts.Flag.PUBLIC_API);
+            b.recordClass(f, jar).addEffectivePublic(f);
+        }
+        return CascadeValidator.validate(b.build());
+    }
+
+    private static ClassFacts facts(String binaryName, ClassFacts.Flag... flags) {
+        ClassFacts.Builder b = ClassFacts.builder(binaryName).sourceAccess(Opcodes.ACC_PUBLIC);
+        for (ClassFacts.Flag f : flags) b.addFlag(f);
+        return b.build();
+    }
+}
diff --git a/api-checker/core/src/test/java/org/apache/kafka/apicheck/JavadocConsistencyValidatorTest.java b/api-checker/core/src/test/java/org/apache/kafka/apicheck/JavadocConsistencyValidatorTest.java
new file mode 100644
index 00000000000..ebd186d942a
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/JavadocConsistencyValidatorTest.java
@@ -0,0 +1,206 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class JavadocConsistencyValidatorTest {
+
+    private static final File DUMMY_JAR = new File("ignored");
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void directPublicMissingFromJavadoc_emitsMissingJavadoc() throws IOException {
+        ClassFacts bar = factsPublic("org.apache.kafka.foo.Bar");
+        ApiSurface surface = ApiSurface.builder()
+                .recordClass(bar, DUMMY_JAR)
+                .addDirectPublic(bar)
+                .addEffectivePublic(bar)
+                .build();
+        File javadocJar = TempJarBuilder.jar().writeTo(tempDir, "javadoc.jar");
+
+        CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+
+        assertEquals(1, result.violations().size());
+        PublicApiViolation v = result.violations().get(0);
+        assertEquals("MISSING_JAVADOC", v.getViolationType());
+        assertEquals("org.apache.kafka.foo.Bar", v.getClassName());
+        assertTrue(result.suppressions().isEmpty());
+    }
+
+    @Test
+    void htmlClassNotEffectivelyPublic_emitsMissingAnnotation() throws IOException {
+        // HTML claims a class is documented; the surface has nothing on it.
+        ApiSurface surface = ApiSurface.builder().build();
+        File javadocJar = TempJarBuilder.jar()
+                .addHtml("org/apache/kafka/foo/Sneaky.html", "")
+                .writeTo(tempDir, "javadoc.jar");
+
+        CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+
+        assertEquals(1, result.violations().size());
+        PublicApiViolation v = result.violations().get(0);
+        assertEquals("MISSING_PUBLICAPI_ANNOTATION", v.getViolationType());
+        assertEquals("org.apache.kafka.foo.Sneaky", v.getClassName());
+    }
+
+    @Test
+    void effectivelyPublicViaInheritance_isNotFlagged() throws IOException {
+        // Inner has no direct @Public but inherits it from Outer. Real javadoc emits nested-class
+        // pages using the dotted form (Outer.Inner.html), which the validator must recognise.
+        ClassFacts outer = factsPublic("org.apache.kafka.foo.Outer");
+        ClassFacts inner = factsPlain("org.apache.kafka.foo.Outer$Inner");
+        ApiSurface surface = ApiSurface.builder()
+                .recordClass(outer, DUMMY_JAR).recordClass(inner, DUMMY_JAR)
+                .addDirectPublic(outer)
+                .addEffectivePublic(outer).addEffectivePublic(inner)
+                .build();
+        File javadocJar = TempJarBuilder.jar()
+                .addHtml("org/apache/kafka/foo/Outer.html", "")
+                .addHtml("org/apache/kafka/foo/Outer.Inner.html", "")
+                .writeTo(tempDir, "javadoc.jar");
+
+        CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+
+        assertTrue(result.violations().isEmpty(),
+                "perfect match (direct + inherited) should yield no violations: " + result.violations());
+    }
+
+    @Test
+    void deprecatedClassInJavadoc_isFilteredOut() throws IOException {
+        // OldClass is deprecated and not in directPublic — would normally trip MISSING_PUBLICAPI_ANNOTATION,
+        // but isDeprecated removes it from the HTML set first.
+        ClassFacts deprecated = factsBuilder("org.apache.kafka.foo.OldClass")
+                .addFlag(ClassFacts.Flag.DEPRECATED)
+                .build();
+        ApiSurface surface = ApiSurface.builder().recordClass(deprecated, DUMMY_JAR).build();
+        File javadocJar = TempJarBuilder.jar()
+                .addHtml("org/apache/kafka/foo/OldClass.html", "")
+                .writeTo(tempDir, "javadoc.jar");
+
+        CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+        assertTrue(result.violations().isEmpty(),
+                "deprecated classes are out of scope on both validation sides");
+    }
+
+    @Test
+    void perfectMatch_noViolations() throws IOException {
+        ClassFacts bar = factsPublic("org.apache.kafka.foo.Bar");
+        ApiSurface surface = ApiSurface.builder()
+                .recordClass(bar, DUMMY_JAR)
+                .addDirectPublic(bar)
+                .addEffectivePublic(bar)
+                .build();
+        File javadocJar = TempJarBuilder.jar()
+                .addHtml("org/apache/kafka/foo/Bar.html", "")
+                .writeTo(tempDir, "javadoc.jar");
+
+        CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+        assertTrue(result.violations().isEmpty());
+        assertTrue(result.suppressions().isEmpty());
+    }
+
+    @Test
+    void structuralHtmlPages_areIgnored() throws IOException {
+        // Javadoc emits many non-class HTML files alongside class pages. The validator must skip
+        // them; otherwise every javadoc jar trips bogus MISSING_PUBLICAPI_ANNOTATION violations.
+        ApiSurface surface = ApiSurface.builder().build();
+        File javadocJar = TempJarBuilder.jar()
+                .addHtml("org/apache/kafka/foo/package-summary.html", "")
+                .addHtml("org/apache/kafka/foo/overview-tree.html", "")
+                .addHtml("org/apache/kafka/foo/index-all.html", "")
+                .addHtml("index.html", "")
+                .writeTo(tempDir, "javadoc.jar");
+
+        CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+        assertTrue(result.violations().isEmpty(),
+                "structural HTML must not be misread as class pages: " + result.violations());
+    }
+
+    @Test
+    void nonKafkaHtml_isIgnored() throws IOException {
+        ApiSurface surface = ApiSurface.builder().build();
+        File javadocJar = TempJarBuilder.jar()
+                .addHtml("com/example/External.html", "")
+                .addHtml("java/util/HashMap.html", "")
+                .writeTo(tempDir, "javadoc.jar");
+
+        CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+        assertTrue(result.violations().isEmpty());
+    }
+
+    @Test
+    void emptyJavadocJar_flagsAllDirectPublicClasses() throws IOException {
+        ClassFacts a = factsPublic("org.apache.kafka.foo.A");
+        ClassFacts b = factsPublic("org.apache.kafka.foo.B");
+        ApiSurface surface = ApiSurface.builder()
+                .recordClass(a, DUMMY_JAR).recordClass(b, DUMMY_JAR)
+                .addDirectPublic(a).addDirectPublic(b)
+                .addEffectivePublic(a).addEffectivePublic(b)
+                .build();
+        File javadocJar = TempJarBuilder.jar().writeTo(tempDir, "javadoc.jar");
+
+        CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+
+        assertEquals(2, result.violations().size());
+        assertTrue(result.violations().stream()
+                .allMatch(v -> "MISSING_JAVADOC".equals(v.getViolationType())));
+    }
+
+    @Test
+    void suppressionsListIsAlwaysEmpty_evenWithViolations() throws IOException {
+        // JavadocConsistencyValidator doesn't carry a suppression mechanism — the CheckResult
+        // always has an empty suppressions list. Callers compose validators uniformly, so the
+        // shape matters even when no suppressions are possible.
+        ClassFacts bar = factsPublic("org.apache.kafka.foo.Bar");
+        ApiSurface surface = ApiSurface.builder()
+                .recordClass(bar, DUMMY_JAR)
+                .addDirectPublic(bar)
+                .build();
+        File javadocJar = TempJarBuilder.jar().writeTo(tempDir, "javadoc.jar");
+
+        CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+        assertFalse(result.violations().isEmpty());
+        assertNotNull(result.suppressions());
+        assertTrue(result.suppressions().isEmpty());
+    }
+
+    private static ClassFacts factsPublic(String binaryName) {
+        return factsBuilder(binaryName).addFlag(ClassFacts.Flag.PUBLIC_API).build();
+    }
+
+    private static ClassFacts factsPlain(String binaryName) {
+        return factsBuilder(binaryName).build();
+    }
+
+    private static ClassFacts.Builder factsBuilder(String binaryName) {
+        return ClassFacts.builder(binaryName).sourceAccess(Opcodes.ACC_PUBLIC);
+    }
+}
diff --git a/api-checker/core/src/test/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScannerTest.java b/api-checker/core/src/test/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScannerTest.java
new file mode 100644
index 00000000000..5171fae3c8e
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScannerTest.java
@@ -0,0 +1,513 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PluginDeveloperApiUsageScannerTest {
+
+    @TempDir
+    Path tempDir;
+
+    @Test
+    void scan_noRoots_returnsEmpty() throws IOException {
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(true));
+        CheckResult result = scanner.scan(Collections.emptyList());
+        assertTrue(result.violations().isEmpty());
+        assertTrue(result.suppressions().isEmpty());
+    }
+
+    @Test
+    void scan_consumerReferencesPublicApiClass_returnsNoViolations() throws IOException {
+        File classFile = writeClassFile("com/example/PublicConsumer",
+                generateConsumerReferencing("com/example/PublicConsumer", "org/apache/kafka/clients/producer/KafkaProducer"));
+
+        Predicate<String> isPublic = name -> "org.apache.kafka.clients.producer.KafkaProducer".equals(name);
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(isPublic);
+        List<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+        assertTrue(violations.isEmpty(),
+                "Reference to an @InterfaceAudience.Public class must not be reported, but got: " + violations);
+    }
+
+    @Test
+    void scan_consumerReferencesInternalApiClass_returnsViolation() throws IOException {
+        File classFile = writeClassFile("com/example/InternalConsumer",
+                generateConsumerReferencing("com/example/InternalConsumer", "org/apache/kafka/internals/SecretCabal"));
+
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+        List<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+        assertFalse(violations.isEmpty(), "Reference to an internal Kafka class must be reported");
+        PublicApiViolation v = violations.get(0);
+        assertEquals("INTERNAL_API_USAGE", v.getViolationType());
+        // className is the consumer (offender) so the report's `Summary by Class` groups by the
+        // file a developer needs to open; the leaked target lives in the description.
+        assertEquals("com.example.InternalConsumer", v.getClassName());
+        assertTrue(v.getDescription().contains("org.apache.kafka.internals.SecretCabal"),
+                "violation description must name the leaked internal class. got: " + v.getDescription());
+    }
+
+    @Test
+    void scan_ignoresNonKafkaReferences() throws IOException {
+        File classFile = writeClassFile("com/example/JdkConsumer",
+                generateConsumerReferencing("com/example/JdkConsumer", "java/util/HashMap"));
+
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+        List<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+        assertTrue(violations.isEmpty(),
+                "References to non-Kafka classes (e.g. JDK) must not be reported, got: " + violations);
+    }
+
+    @Test
+    void scan_classesPackagedInJar_areScanned() throws IOException {
+        byte[] internalBytes = generateConsumerReferencing(
+                "com/example/JarConsumer", "org/apache/kafka/internals/Hidden");
+        File jar = tempDir.resolve("consumer.jar").toFile();
+        try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jar))) {
+            jos.putNextEntry(new JarEntry("com/example/JarConsumer.class"));
+            jos.write(internalBytes);
+            jos.closeEntry();
+        }
+
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+        List<PublicApiViolation> violations = scanner.scan(List.of(jar)).violations();
+
+        assertFalse(violations.isEmpty(), "scan of jar should find the internal reference");
+        assertEquals("com.example.JarConsumer", violations.get(0).getClassName());
+    }
+
+    @Test
+    void scan_classWithKafkaFieldOfInternalType_returnsViolation() throws IOException {
+        // Different bytecode shape: a field whose type is an internal Kafka class.
+        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+        cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, "com/example/FieldHolder", null, "java/lang/Object", null);
+        cw.visitField(Opcodes.ACC_PRIVATE, "secret", "Lorg/apache/kafka/internals/Hidden;", null, null).visitEnd();
+        // default ctor
+        MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        mv.visitCode();
+        mv.visitVarInsn(Opcodes.ALOAD, 0);
+        mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
+        mv.visitInsn(Opcodes.RETURN);
+        mv.visitMaxs(0, 0);
+        mv.visitEnd();
+        cw.visitEnd();
+        File classFile = writeClassFile("com/example/FieldHolder", cw.toByteArray());
+
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+        List<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+        assertTrue(violations.stream().anyMatch(v -> "com.example.FieldHolder".equals(v.getClassName())),
+                "field-type reference must be reported, got: " + violations);
+    }
+
+    @Test
+    void scan_classLevelSuppressionAnnotation_skipsViolations() throws IOException {
+        byte[] bytes = generateConsumerReferencing(
+                "com/example/SuppressedClass", "org/apache/kafka/internals/Hidden",
+                ClassAnnotation.suppress("ports legacy adapter; tracked in JIRA-1234"),
+                MethodAnnotations.none());
+        File classFile = writeClassFile("com/example/SuppressedClass", bytes);
+
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+        CheckResult result = scanner.scan(List.of(classFile.getParentFile()));
+
+        assertTrue(result.violations().isEmpty(),
+                "Class-level @SuppressKafkaInternalApiUsage must suppress all violations; got: " + result.violations());
+        assertFalse(result.suppressions().isEmpty(),
+                "suppressions list must record the skipped reference so it shows in the report");
+        PublicApiViolation s = result.suppressions().get(0);
+        assertEquals("SUPPRESSED_INTERNAL_API_USAGE", s.getViolationType());
+        assertTrue(s.getDescription().contains("ports legacy adapter; tracked in JIRA-1234"),
+                "suppression description must carry the annotation's reason; got: " + s.getDescription());
+    }
+
+    @Test
+    void scan_methodLevelSuppression_skipsOnlyThatMethod() throws IOException {
+        byte[] bytes = generateConsumerWithTwoMethods(
+                "com/example/PartiallySuppressed",
+                "org/apache/kafka/internals/Hidden",
+                MethodAnnotations.suppressOn("useIt", "intentional fallback"));
+        File classFile = writeClassFile("com/example/PartiallySuppressed", bytes);
+
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+        List<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+        // The other method ("alsoUseIt") is not suppressed -- it must still report.
+        assertEquals(1, violations.size(),
+                "method-level suppression must only affect that method; got: " + violations);
+        assertEquals("alsoUseIt", violations.get(0).getMemberName(),
+                "unsuppressed method should be the one reported");
+    }
+
+    @Test
+    void scan_suppressionWithoutReason_stillSuppresses() throws IOException {
+        byte[] bytes = generateConsumerReferencing(
+                "com/example/SuppressedNoReason", "org/apache/kafka/internals/Hidden",
+                ClassAnnotation.suppress(null),  // annotation present, no value()
+                MethodAnnotations.none());
+        File classFile = writeClassFile("com/example/SuppressedNoReason", bytes);
+
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+        List<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+        assertTrue(violations.isEmpty(),
+                "@SuppressKafkaInternalApiUsage with no reason must still suppress; got: " + violations);
+    }
+
+    @Test
+    void scan_nestedPrivateOverride_isFlagged_evenWhenOuterIsPublic() throws IOException {
+        // KIP-1265: a nested class explicitly marked @InterfaceAudience.Private must override an
+        // inherited @Public from its outer. The predicate is given the full nested name; a Private
+        // override must propagate through it, so a reference to org/apache/kafka/Outer$Inner is a
+        // violation even though Outer alone is Public.
+        File classFile = writeClassFile("com/example/NestedConsumer",
+                generateConsumerReferencing("com/example/NestedConsumer", "org/apache/kafka/Outer$Inner"));
+
+        // Outer is public, but the nested Outer$Inner is explicitly Private.
+        Predicate<String> audience = name -> "org.apache.kafka.Outer".equals(name);
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(audience);
+        List<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+        assertFalse(violations.isEmpty(),
+                "Reference to a Private-nested type must be flagged even when its outer is Public; got: " + violations);
+        assertEquals("com.example.NestedConsumer", violations.get(0).getClassName());
+        assertTrue(violations.get(0).getDescription().contains("org.apache.kafka.Outer$Inner"),
+                "description must carry the leaked nested type; got: " + violations.get(0).getDescription());
+    }
+
+    @Test
+    void scan_tryCatchOnInternalException_isFlagged() throws IOException {
+        // `catch (InternalKafkaException ignored)` where the variable is never used leaves the
+        // only reference to the exception type in the exception-handler table — visitTryCatchBlock.
+        // Without that visitor override the scanner would miss it.
+        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+        cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, "com/example/CatchConsumer", null, "java/lang/Object", null);
+        MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        ctor.visitCode();
+        ctor.visitVarInsn(Opcodes.ALOAD, 0);
+        ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
+        ctor.visitInsn(Opcodes.RETURN);
+        ctor.visitMaxs(0, 0);
+        ctor.visitEnd();
+
+        MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "doIt", "()V", null, null);
+        mv.visitCode();
+        org.objectweb.asm.Label start = new org.objectweb.asm.Label();
+        org.objectweb.asm.Label end = new org.objectweb.asm.Label();
+        org.objectweb.asm.Label handler = new org.objectweb.asm.Label();
+        org.objectweb.asm.Label after = new org.objectweb.asm.Label();
+        mv.visitTryCatchBlock(start, end, handler, "org/apache/kafka/internals/Boom");
+        mv.visitLabel(start);
+        // empty try body
+        mv.visitLabel(end);
+        mv.visitJumpInsn(Opcodes.GOTO, after);
+        mv.visitLabel(handler);
+        mv.visitInsn(Opcodes.POP);
+        mv.visitLabel(after);
+        mv.visitInsn(Opcodes.RETURN);
+        mv.visitMaxs(0, 0);
+        mv.visitEnd();
+        cw.visitEnd();
+
+        File classFile = writeClassFile("com/example/CatchConsumer", cw.toByteArray());
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+        List<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+        assertTrue(violations.stream().anyMatch(v -> "com.example.CatchConsumer".equals(v.getClassName())),
+                "exception-table entry must be reported; got: " + violations);
+    }
+
+    @Test
+    void scan_methodHeaderRefBuffering_returnTypeFlushedAtVisitCode() throws IOException {
+        // The method-header ref (return type from the descriptor) is buffered until
+        // visitCode fires — that's when the method-level @SuppressKafkaInternalApiUsage has
+        // been visited and we know the effective reason. A non-suppressed method must still
+        // produce the violation.
+        byte[] bytes = generateMethodWithInternalReturnType("com/example/HeaderRef",
+                "fetch", "Lorg/apache/kafka/internals/Hidden;", null);
+        File classFile = writeClassFile("com/example/HeaderRef", bytes);
+
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+        List<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+        assertTrue(violations.stream().anyMatch(v -> "com.example.HeaderRef".equals(v.getClassName())),
+                "method-header return-type ref must flush as a violation; got: " + violations);
+    }
+
+    @Test
+    void scan_abstractMethodHeaderRef_flushedAtVisitEnd() throws IOException {
+        // Abstract methods have no body, so ASM never fires visitCode. The scanner's visitEnd
+        // safety net must flush the buffered header refs anyway, otherwise an abstract method's
+        // internal return/param/exception types would silently slip through.
+        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+        cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT,
+                "com/example/AbstractConsumer", null, "java/lang/Object", null);
+
+        MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        ctor.visitCode();
+        ctor.visitVarInsn(Opcodes.ALOAD, 0);
+        ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
+        ctor.visitInsn(Opcodes.RETURN);
+        ctor.visitMaxs(0, 0);
+        ctor.visitEnd();
+
+        // public abstract Hidden fetch();  — no body, no visitCode, no visitMaxs.
+        MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT,
+                "fetch", "()Lorg/apache/kafka/internals/Hidden;", null, null);
+        mv.visitEnd();
+        cw.visitEnd();
+
+        File classFile = writeClassFile("com/example/AbstractConsumer", cw.toByteArray());
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+        List<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+        assertTrue(violations.stream().anyMatch(v -> "com.example.AbstractConsumer".equals(v.getClassName())),
+                "abstract-method header ref must be flushed at visitEnd; got: " + violations);
+    }
+
+    @Test
+    void scan_methodHeaderRefBuffering_methodLevelSuppressDivertsToSuppressions() throws IOException {
+        // Same method header as the previous test, but the method carries
+        // @SuppressKafkaInternalApiUsage. visitCode flushes the buffered header refs using the
+        // effective reason (method-level wins over class-level), so they land in suppressions.
+        byte[] bytes = generateMethodWithInternalReturnType("com/example/SuppressedHeader",
+                "fetch", "Lorg/apache/kafka/internals/Hidden;", "header-ref reason");
+        File classFile = writeClassFile("com/example/SuppressedHeader", bytes);
+
+        PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+        CheckResult r = scanner.scan(List.of(classFile.getParentFile()));
+
+        assertTrue(r.violations().isEmpty(),
+                "method-level suppress must divert the buffered header ref; got violations: " + r.violations());
+        assertTrue(r.suppressions().stream().anyMatch(s -> s.getDescription().contains("reason: header-ref reason")),
+                "suppression list must carry the method's reason; got: " + r.suppressions());
+    }
+
+    /**
+     * Generate a class with a single method whose return type descriptor names an internal
+     * Kafka type. If {@code suppressReason} is non-null, the method carries
+     * {@code @SuppressKafkaInternalApiUsage(suppressReason)} (or no value when "").
+     */
+    private static byte[] generateMethodWithInternalReturnType(String consumerInternalName,
+                                                               String methodName,
+                                                               String returnDescriptor,
+                                                               String suppressReason) {
+        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+        cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, consumerInternalName, null, "java/lang/Object", null);
+
+        MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        ctor.visitCode();
+        ctor.visitVarInsn(Opcodes.ALOAD, 0);
+        ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
+        ctor.visitInsn(Opcodes.RETURN);
+        ctor.visitMaxs(0, 0);
+        ctor.visitEnd();
+
+        MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, methodName, "()" + returnDescriptor, null, null);
+        if (suppressReason != null) {
+            AnnotationVisitor av = mv.visitAnnotation(SUPPRESS_DESC, true);
+            if (!suppressReason.isEmpty()) {
+                av.visit("value", suppressReason);
+            }
+            av.visitEnd();
+        }
+        mv.visitCode();  // triggers the header-ref flush in the scanner's visitor
+        mv.visitInsn(Opcodes.ACONST_NULL);
+        mv.visitInsn(Opcodes.ARETURN);
+        mv.visitMaxs(0, 0);
+        mv.visitEnd();
+        cw.visitEnd();
+        return cw.toByteArray();
+    }
+
+    /** Build a class with a default ctor and a method that loads a class constant of {@code internalNameOfReferenced}. */
+    private static byte[] generateConsumerReferencing(String consumerInternalName, String internalNameOfReferenced) {
+        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+        cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, consumerInternalName, null, "java/lang/Object", null);
+
+        // default ctor
+        MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        ctor.visitCode();
+        ctor.visitVarInsn(Opcodes.ALOAD, 0);
+        ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
+        ctor.visitInsn(Opcodes.RETURN);
+        ctor.visitMaxs(0, 0);
+        ctor.visitEnd();
+
+        // public void useIt() { Class<?> c = <referenced>.class; }
+        MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "useIt", "()V", null, null);
+        mv.visitCode();
+        mv.visitLdcInsn(org.objectweb.asm.Type.getObjectType(internalNameOfReferenced));
+        mv.visitInsn(Opcodes.POP);
+        mv.visitInsn(Opcodes.RETURN);
+        mv.visitMaxs(0, 0);
+        mv.visitEnd();
+
+        cw.visitEnd();
+        return cw.toByteArray();
+    }
+
+    private File writeClassFile(String internalName, byte[] bytes) throws IOException {
+        File classFile = tempDir.resolve(internalName.replace('/', '_') + ".class").toFile();
+        Files.write(classFile.toPath(), bytes);
+        return classFile;
+    }
+
+    private static Predicate<String> always(boolean value) {
+        return s -> value;
+    }
+
+    // --- suppression-aware test helpers -------------------------------------------------
+
+    private static final String SUPPRESS_DESC =
+            "Lorg/apache/kafka/common/annotation/SuppressKafkaInternalApiUsage;";
+
+    /** Class-level annotation descriptor — empty = no class annotation. */
+    private static final class ClassAnnotation {
+        final String reason; // null reason => annotation present with no value(); empty marker => no annotation
+        final boolean present;
+        private ClassAnnotation(boolean present, String reason) {
+            this.present = present;
+            this.reason = reason;
+        }
+        static ClassAnnotation none() {
+            return new ClassAnnotation(false, null);
+        }
+        static ClassAnnotation suppress(String reason) {
+            return new ClassAnnotation(true, reason);
+        }
+    }
+
+    /** Per-method suppression directives. */
+    private static final class MethodAnnotations {
+        final String suppressedMethod;
+        final String reason;
+        private MethodAnnotations(String suppressedMethod, String reason) {
+            this.suppressedMethod = suppressedMethod;
+            this.reason = reason;
+        }
+        static MethodAnnotations none() {
+            return new MethodAnnotations(null, null);
+        }
+        static MethodAnnotations suppressOn(String methodName, String reason) {
+            return new MethodAnnotations(methodName, reason);
+        }
+    }
+
+    /** Overload that also writes class-level + method-level suppression annotations. */
+    private static byte[] generateConsumerReferencing(String consumerInternalName,
+                                                     String internalNameOfReferenced,
+                                                     ClassAnnotation classAnnotation,
+                                                     MethodAnnotations methodAnnotations) {
+        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+        cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, consumerInternalName, null, "java/lang/Object", null);
+
+        if (classAnnotation.present) {
+            AnnotationVisitor av = cw.visitAnnotation(SUPPRESS_DESC, true);
+            if (classAnnotation.reason != null) {
+                av.visit("value", classAnnotation.reason);
+            }
+            av.visitEnd();
+        }
+
+        // default ctor
+        MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        ctor.visitCode();
+        ctor.visitVarInsn(Opcodes.ALOAD, 0);
+        ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
+        ctor.visitInsn(Opcodes.RETURN);
+        ctor.visitMaxs(0, 0);
+        ctor.visitEnd();
+
+        MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "useIt", "()V", null, null);
+        if ("useIt".equals(methodAnnotations.suppressedMethod)) {
+            AnnotationVisitor mav = mv.visitAnnotation(SUPPRESS_DESC, true);
+            if (methodAnnotations.reason != null) {
+                mav.visit("value", methodAnnotations.reason);
+            }
+            mav.visitEnd();
+        }
+        mv.visitCode();
+        mv.visitLdcInsn(org.objectweb.asm.Type.getObjectType(internalNameOfReferenced));
+        mv.visitInsn(Opcodes.POP);
+        mv.visitInsn(Opcodes.RETURN);
+        mv.visitMaxs(0, 0);
+        mv.visitEnd();
+
+        cw.visitEnd();
+        return cw.toByteArray();
+    }
+
+    /** Two methods both referencing the same internal class, with a suppression on one. */
+    private static byte[] generateConsumerWithTwoMethods(String consumerInternalName,
+                                                        String internalNameOfReferenced,
+                                                        MethodAnnotations methodAnnotations) {
+        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+        cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, consumerInternalName, null, "java/lang/Object", null);
+
+        // default ctor
+        MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        ctor.visitCode();
+        ctor.visitVarInsn(Opcodes.ALOAD, 0);
+        ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
+        ctor.visitInsn(Opcodes.RETURN);
+        ctor.visitMaxs(0, 0);
+        ctor.visitEnd();
+
+        for (String methodName : List.of("useIt", "alsoUseIt")) {
+            MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, methodName, "()V", null, null);
+            if (methodName.equals(methodAnnotations.suppressedMethod)) {
+                AnnotationVisitor mav = mv.visitAnnotation(SUPPRESS_DESC, true);
+                if (methodAnnotations.reason != null) {
+                    mav.visit("value", methodAnnotations.reason);
+                }
+                mav.visitEnd();
+            }
+            mv.visitCode();
+            mv.visitLdcInsn(org.objectweb.asm.Type.getObjectType(internalNameOfReferenced));
+            mv.visitInsn(Opcodes.POP);
+            mv.visitInsn(Opcodes.RETURN);
+            mv.visitMaxs(0, 0);
+            mv.visitEnd();
+        }
+
+        cw.visitEnd();
+        return cw.toByteArray();
+    }
+}
diff --git a/api-checker/core/src/test/java/org/apache/kafka/apicheck/PublicApiCheckerTest.java b/api-checker/core/src/test/java/org/apache/kafka/apicheck/PublicApiCheckerTest.java
new file mode 100644
index 00000000000..d84a3a00138
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/PublicApiCheckerTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for the {@link PublicApiChecker} facade: surface construction, {@code isPublicApi}
+ * predicate semantics, and merged results from {@link JavadocConsistencyValidator} +
+ * {@link CascadeValidator} + {@link PluginDeveloperApiUsageScanner}.
+ */
+class PublicApiCheckerTest {
+
+    @TempDir
+    Path tempDir;
+
+    // ----- isPublicApi -----
+
+    @Test
+    void isPublicApi_nonKafkaClass_returnsTrueOutOfScope() throws IOException {
+        PublicApiChecker checker = checkerFor();
+        assertTrue(checker.isPublicApi("java.util.Map"));
+        assertTrue(checker.isPublicApi("com.example.Foo"));
+    }
+
+    @Test
+    void isPublicApi_directPublic_returnsTrue() throws IOException {
+        PublicApiChecker checker = checkerFor(
+                AsmClassFactory.klass("org.apache.kafka.api.Pub").access(Opcodes.ACC_PUBLIC).publicApi());
+        assertTrue(checker.isPublicApi("org.apache.kafka.api.Pub"));
+    }
+
+    @Test
+    void isPublicApi_directPrivate_returnsFalse() throws IOException {
+        PublicApiChecker checker = checkerFor(
+                AsmClassFactory.klass("org.apache.kafka.api.Hidden").access(Opcodes.ACC_PUBLIC).privateApi());
+        assertFalse(checker.isPublicApi("org.apache.kafka.api.Hidden"));
+    }
+
+    @Test
+    void isPublicApi_deprecatedInternal_isStillFlagged() throws IOException {
+        // Consumer side: @Deprecated does NOT make an internal class out-of-scope.
+        // Deprecated-internal types are the most likely to be removed in the next release, so
+        // a consumer reference to one is exactly the kind of break the checker exists to catch.
+        // (The cascade validator still has its own deprecation bypass for the producer-side
+        // leak check, which is a separate policy.)
+        PublicApiChecker checker = checkerFor(
+                AsmClassFactory.klass("org.apache.kafka.api.Old")
+                        .access(Opcodes.ACC_PUBLIC).privateApi().deprecated());
+        assertFalse(checker.isPublicApi("org.apache.kafka.api.Old"),
+                "consumer predicate must flag @Deprecated @Private — those are the highest-risk references");
+    }
+
+    @Test
+    void isPublicApi_nestedInheritsFromOuter() throws IOException {
+        PublicApiChecker checker = checkerFor(
+                AsmClassFactory.klass("org.apache.kafka.api.Outer").access(Opcodes.ACC_PUBLIC).publicApi(),
+                AsmClassFactory.klass("org.apache.kafka.api.Outer$Inner").access(Opcodes.ACC_PUBLIC));
+        assertTrue(checker.isPublicApi("org.apache.kafka.api.Outer$Inner"));
+    }
+
+    @Test
+    void isPublicApi_nestedPrivateOverridesOuterPublic() throws IOException {
+        PublicApiChecker checker = checkerFor(
+                AsmClassFactory.klass("org.apache.kafka.api.Outer").access(Opcodes.ACC_PUBLIC).publicApi(),
+                AsmClassFactory.klass("org.apache.kafka.api.Outer$Inner").access(Opcodes.ACC_PUBLIC).privateApi());
+        assertFalse(checker.isPublicApi("org.apache.kafka.api.Outer$Inner"));
+    }
+
+    @Test
+    void isPublicApi_unknownKafkaClass_returnsFalse() throws IOException {
+        PublicApiChecker checker = checkerFor();
+        assertFalse(checker.isPublicApi("org.apache.kafka.UnknownClass"));
+    }
+
+    @Test
+    void isPublicApi_nestedWithoutOuterFacts_returnsFalse() throws IOException {
+        // Inner is recorded but Outer isn't. The chain walk finds Inner (no annotation), looks up
+        // Outer, gets null, exits with false — inheritance requires the outer to be in scope.
+        PublicApiChecker checker = checkerFor(
+                AsmClassFactory.klass("org.apache.kafka.api.Outer$Inner").access(Opcodes.ACC_PUBLIC));
+        assertFalse(checker.isPublicApi("org.apache.kafka.api.Outer$Inner"));
+    }
+
+    // ----- checkPublicApiConsistency -----
+
+    @Test
+    void checkPublicApiConsistency_mergesJavadocAndCascadeViolations() throws IOException {
+        // Bar is @Public with a method that leaks an internal type; javadoc jar is empty.
+        // Expected: MISSING_JAVADOC from the javadoc validator + INVALID_RETURN_TYPE from cascade.
+        AsmClassFactory.ClassBuilder bar = AsmClassFactory.klass("org.apache.kafka.api.Bar")
+                .access(Opcodes.ACC_PUBLIC).publicApi()
+                .method(AsmClassFactory.method("leak").returns("Lorg/apache/kafka/internals/Internal;"));
+        File projectJar = TempJarBuilder.jar().addClass(bar).writeTo(tempDir, "proj.jar");
+        File javadocJar = TempJarBuilder.jar().writeTo(tempDir, "javadoc.jar");
+
+        CheckResult result = new PublicApiChecker(List.of(projectJar)).checkPublicApiConsistency(javadocJar);
+
+        assertTrue(result.violations().stream().anyMatch(v -> "MISSING_JAVADOC".equals(v.getViolationType())),
+                "expected MISSING_JAVADOC from javadoc validator: " + result.violations());
+        assertTrue(result.violations().stream().anyMatch(v -> "INVALID_RETURN_TYPE".equals(v.getViolationType())),
+                "expected INVALID_RETURN_TYPE from cascade validator: " + result.violations());
+    }
+
+    @Test
+    void checkPublicApiConsistency_mergesSuppressionsFromCascade() throws IOException {
+        // Class-level @SuppressKafkaInternalApiUsage routes the cascade leak into suppressions
+        // instead of violations. Javadoc HTML present → no MISSING_JAVADOC.
+        AsmClassFactory.ClassBuilder bar = AsmClassFactory.klass("org.apache.kafka.api.Bar")
+                .access(Opcodes.ACC_PUBLIC).publicApi().suppress("legacy")
+                .method(AsmClassFactory.method("leak").returns("Lorg/apache/kafka/internals/Internal;"));
+        File projectJar = TempJarBuilder.jar().addClass(bar).writeTo(tempDir, "proj.jar");
+        File javadocJar = TempJarBuilder.jar()
+                .addHtml("org/apache/kafka/api/Bar.html", "")
+                .writeTo(tempDir, "javadoc.jar");
+
+        CheckResult result = new PublicApiChecker(List.of(projectJar)).checkPublicApiConsistency(javadocJar);
+
+        assertTrue(result.violations().isEmpty(), "everything suppressed: " + result.violations());
+        assertEquals(1, result.suppressions().size());
+        assertTrue(result.suppressions().get(0).getDescription().contains("reason: legacy"),
+                "suppression must carry the annotation reason: " + result.suppressions().get(0).getDescription());
+    }
+
+    // ----- checkBytecode -----
+
+    @Test
+    void checkBytecode_flagsConsumerReferenceToInternalClass() throws IOException {
+        // Project surface defines Hidden as an in-scope Kafka class with no @Public annotation.
+        // The consumer references it → checkBytecode should report INTERNAL_API_USAGE.
+        File projectJar = TempJarBuilder.jar()
+                .addClass(AsmClassFactory.klass("org.apache.kafka.internals.Hidden").access(Opcodes.ACC_PUBLIC))
+                .writeTo(tempDir, "proj.jar");
+        File consumerJar = TempJarBuilder.jar()
+                .addClass(AsmClassFactory.klass("com.example.Consumer")
+                        .access(Opcodes.ACC_PUBLIC)
+                        .method(AsmClassFactory.method("hold").returns("Lorg/apache/kafka/internals/Hidden;")))
+                .writeTo(tempDir, "consumer.jar");
+
+        CheckResult result = new PublicApiChecker(List.of(projectJar)).checkBytecode(List.of(consumerJar));
+
+        assertFalse(result.violations().isEmpty(),
+                "unannotated Kafka class reference must be flagged: " + result.violations());
+        assertEquals("INTERNAL_API_USAGE", result.violations().get(0).getViolationType());
+        // className tracks the consumer (offender), so `Summary by Class` in the report groups
+        // by the file a developer has to open. The leaked target lives in the description.
+        assertEquals("com.example.Consumer", result.violations().get(0).getClassName());
+        assertTrue(result.violations().get(0).getDescription().contains("org.apache.kafka.internals.Hidden"),
+                "description must carry the leaked internal type: " + result.violations().get(0).getDescription());
+    }
+
+    @Test
+    void checkBytecode_passesConsumerReferenceToPublicClass() throws IOException {
+        File projectJar = TempJarBuilder.jar()
+                .addClass(AsmClassFactory.klass("org.apache.kafka.api.Pub").access(Opcodes.ACC_PUBLIC).publicApi())
+                .writeTo(tempDir, "proj.jar");
+        File consumerJar = TempJarBuilder.jar()
+                .addClass(AsmClassFactory.klass("com.example.Consumer")
+                        .access(Opcodes.ACC_PUBLIC)
+                        .method(AsmClassFactory.method("use").returns("Lorg/apache/kafka/api/Pub;")))
+                .writeTo(tempDir, "consumer.jar");
+
+        CheckResult result = new PublicApiChecker(List.of(projectJar)).checkBytecode(List.of(consumerJar));
+
+        assertTrue(result.violations().isEmpty(),
+                "reference to a @Public class must pass: " + result.violations());
+    }
+
+    // ----- constructor overload -----
+
+    @Test
+    void singleArgConstructor_equivalentToEmptyReferenceJars() throws IOException {
+        File projectJar = TempJarBuilder.jar()
+                .addClass(AsmClassFactory.klass("org.apache.kafka.api.Bar").access(Opcodes.ACC_PUBLIC).publicApi())
+                .writeTo(tempDir, "proj.jar");
+
+        PublicApiChecker single = new PublicApiChecker(List.of(projectJar));
+        PublicApiChecker dual   = new PublicApiChecker(List.of(projectJar), Collections.emptyList());
+
+        assertTrue(single.isPublicApi("org.apache.kafka.api.Bar"));
+        assertTrue(dual.isPublicApi("org.apache.kafka.api.Bar"));
+        assertFalse(single.isPublicApi("org.apache.kafka.UnknownClass"));
+        assertFalse(dual.isPublicApi("org.apache.kafka.UnknownClass"));
+    }
+
+    // ----- helper -----
+
+    private PublicApiChecker checkerFor(AsmClassFactory.ClassBuilder... classes) throws IOException {
+        if (classes.length == 0) return new PublicApiChecker(List.of());
+        TempJarBuilder jar = TempJarBuilder.jar();
+        for (AsmClassFactory.ClassBuilder c : classes) jar.addClass(c);
+        return new PublicApiChecker(List.of(jar.writeTo(tempDir, "proj.jar")));
+    }
+}
diff --git a/api-checker/core/src/testFixtures/java/org/apache/kafka/apicheck/AsmClassFactory.java b/api-checker/core/src/testFixtures/java/org/apache/kafka/apicheck/AsmClassFactory.java
new file mode 100644
index 00000000000..d1df74baf2d
--- /dev/null
+++ b/api-checker/core/src/testFixtures/java/org/apache/kafka/apicheck/AsmClassFactory.java
@@ -0,0 +1,366 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Synthesises {@code .class} bytes for KIP-1265 checker tests so they don't depend on real Kafka
+ * classes on the classpath. Carries enough knobs to exercise the audience-annotation, nested-class
+ * inner-access, and {@code @SuppressKafkaInternalApiUsage} paths the checker reads.
+ */
+public final class AsmClassFactory {
+
+    public static final String PUBLIC_API_DESC =
+            "Lorg/apache/kafka/common/annotation/InterfaceAudience$Public;";
+    public static final String PRIVATE_API_DESC =
+            "Lorg/apache/kafka/common/annotation/InterfaceAudience$Private;";
+    public static final String SUPPRESS_DESC =
+            "Lorg/apache/kafka/common/annotation/SuppressKafkaInternalApiUsage;";
+    public static final String DEPRECATED_DESC = "Ljava/lang/Deprecated;";
+
+    private AsmClassFactory() {}
+
+    public static ClassBuilder klass(String binaryName) {
+        return new ClassBuilder(binaryName);
+    }
+
+    public static MethodSpec method(String name) {
+        return new MethodSpec(name);
+    }
+
+    public static FieldSpec field(String name) {
+        return new FieldSpec(name);
+    }
+
+    /** Wrap an internal name ("org/apache/kafka/X") as an object type descriptor. */
+    public static String objDesc(String internalName) {
+        return "L" + internalName + ";";
+    }
+
+    public static String toInternal(String binaryName) {
+        return binaryName.replace('.', '/');
+    }
+
+    public static final class ClassBuilder {
+        private final String binaryName;
+        private int headerAccess = Opcodes.ACC_PUBLIC;
+        private Integer nestedAccess;
+        private String superInternal = "java/lang/Object";
+        private String[] interfaceInternals = new String[0];
+        private boolean isInterface;
+        private boolean publicApi;
+        private boolean privateApi;
+        private boolean deprecated;
+        private boolean hasSuppress;
+        private String suppressReason;
+        private final List<MethodSpec> methods = new ArrayList<>();
+        private final List<FieldSpec> fields = new ArrayList<>();
+
+        private ClassBuilder(String binaryName) {
+            this.binaryName = binaryName;
+        }
+
+        public String binaryName() {
+            return binaryName;
+        }
+
+        public ClassBuilder access(int access) {
+            this.headerAccess = access;
+            return this;
+        }
+
+        /**
+         * Add an {@code InnerClasses} attribute entry for this class with the given source-level
+         * access — the scanner reads this in preference to the (compiler-synthesised) header access
+         * for nested classes. Binary name must contain {@code $}.
+         */
+        public ClassBuilder nestedAccess(int access) {
+            this.nestedAccess = access;
+            return this;
+        }
+
+        public ClassBuilder superClass(String internalName) {
+            this.superInternal = internalName;
+            return this;
+        }
+
+        public ClassBuilder interfaces(String... internalNames) {
+            this.interfaceInternals = internalNames;
+            return this;
+        }
+
+        public ClassBuilder asInterface() {
+            this.isInterface = true;
+            return this;
+        }
+
+        public ClassBuilder publicApi() {
+            this.publicApi = true;
+            return this;
+        }
+
+        public ClassBuilder privateApi() {
+            this.privateApi = true;
+            return this;
+        }
+
+        public ClassBuilder deprecated() {
+            this.deprecated = true;
+            return this;
+        }
+
+        /** {@code @SuppressKafkaInternalApiUsage("reason")}; pass {@code null} to omit {@code value()}. */
+        public ClassBuilder suppress(String reason) {
+            this.hasSuppress = true;
+            this.suppressReason = reason;
+            return this;
+        }
+
+        public ClassBuilder method(MethodSpec method) {
+            this.methods.add(method);
+            return this;
+        }
+
+        public ClassBuilder field(FieldSpec field) {
+            this.fields.add(field);
+            return this;
+        }
+
+        public byte[] toBytes() {
+            ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
+            String internalName = toInternal(binaryName);
+            int access = headerAccess | (isInterface ? Opcodes.ACC_INTERFACE | Opcodes.ACC_ABSTRACT : 0);
+            cw.visit(Opcodes.V11, access, internalName, null,
+                    isInterface ? "java/lang/Object" : superInternal, interfaceInternals);
+            writeClassAnnotations(cw);
+            writeInnerClassEntry(cw, internalName);
+            if (!isInterface) {
+                writeDefaultCtor(cw, superInternal);
+            }
+            for (FieldSpec f : fields) {
+                f.write(cw);
+            }
+            for (MethodSpec m : methods) {
+                m.write(cw);
+            }
+            cw.visitEnd();
+            return cw.toByteArray();
+        }
+
+        private void writeClassAnnotations(ClassWriter cw) {
+            if (publicApi) {
+                cw.visitAnnotation(PUBLIC_API_DESC, true).visitEnd();
+            }
+            if (privateApi) {
+                cw.visitAnnotation(PRIVATE_API_DESC, true).visitEnd();
+            }
+            if (deprecated) {
+                cw.visitAnnotation(DEPRECATED_DESC, true).visitEnd();
+            }
+            if (hasSuppress) {
+                writeSuppress(cw.visitAnnotation(SUPPRESS_DESC, true), suppressReason);
+            }
+        }
+
+        private void writeInnerClassEntry(ClassWriter cw, String internalName) {
+            if (nestedAccess == null) {
+                return;
+            }
+            int dollar = internalName.lastIndexOf('$');
+            if (dollar < 0) {
+                throw new IllegalStateException(
+                        "nestedAccess requires a binaryName containing '$' (got " + binaryName + ")");
+            }
+            cw.visitInnerClass(internalName, internalName.substring(0, dollar),
+                    internalName.substring(dollar + 1), nestedAccess);
+        }
+    }
+
+    public static final class MethodSpec {
+        private final String name;
+        private int access = Opcodes.ACC_PUBLIC;
+        private String returnDesc = "V";
+        private final List<String> paramDescs = new ArrayList<>();
+        private final List<String> exceptionInternals = new ArrayList<>();
+        private String signature;
+        private boolean hasSuppress;
+        private String suppressReason;
+
+        private MethodSpec(String name) {
+            this.name = name;
+        }
+
+        public MethodSpec access(int access) {
+            this.access = access;
+            return this;
+        }
+
+        public MethodSpec returns(String desc) {
+            this.returnDesc = desc;
+            return this;
+        }
+
+        public MethodSpec param(String desc) {
+            this.paramDescs.add(desc);
+            return this;
+        }
+
+        public MethodSpec throwsExc(String internalName) {
+            this.exceptionInternals.add(internalName);
+            return this;
+        }
+
+        /** Generic signature (JVMS §4.7.9), e.g. {@code "()Ljava/util/Map<Ljava/lang/String;Lorg/apache/kafka/X;>;"}. */
+        public MethodSpec signature(String signature) {
+            this.signature = signature;
+            return this;
+        }
+
+        public MethodSpec bridge() {
+            this.access |= Opcodes.ACC_BRIDGE | Opcodes.ACC_SYNTHETIC;
+            return this;
+        }
+
+        public MethodSpec synthetic() {
+            this.access |= Opcodes.ACC_SYNTHETIC;
+            return this;
+        }
+
+        public MethodSpec suppress(String reason) {
+            this.hasSuppress = true;
+            this.suppressReason = reason;
+            return this;
+        }
+
+        public void write(ClassWriter cw) {
+            String desc = "(" + String.join("", paramDescs) + ")" + returnDesc;
+            String[] excs = exceptionInternals.isEmpty()
+                    ? null : exceptionInternals.toArray(new String[0]);
+            MethodVisitor mv = cw.visitMethod(access, name, desc, signature, excs);
+            if (hasSuppress) {
+                writeSuppress(mv.visitAnnotation(SUPPRESS_DESC, true), suppressReason);
+            }
+            mv.visitCode();
+            emitZeroReturn(mv, returnDesc);
+            mv.visitMaxs(0, 0);
+            mv.visitEnd();
+        }
+    }
+
+    public static final class FieldSpec {
+        private final String name;
+        private int access = Opcodes.ACC_PUBLIC;
+        private String typeDesc = "I";
+        private String signature;
+        private boolean hasSuppress;
+        private String suppressReason;
+
+        private FieldSpec(String name) {
+            this.name = name;
+        }
+
+        public FieldSpec access(int access) {
+            this.access = access;
+            return this;
+        }
+
+        public FieldSpec ofType(String desc) {
+            this.typeDesc = desc;
+            return this;
+        }
+
+        /** Generic signature (JVMS §4.7.9), e.g. {@code "Ljava/util/List<Lorg/apache/kafka/X;>;"}. */
+        public FieldSpec signature(String signature) {
+            this.signature = signature;
+            return this;
+        }
+
+        public FieldSpec suppress(String reason) {
+            this.hasSuppress = true;
+            this.suppressReason = reason;
+            return this;
+        }
+
+        public void write(ClassWriter cw) {
+            FieldVisitor fv = cw.visitField(access, name, typeDesc, signature, null);
+            if (hasSuppress) {
+                writeSuppress(fv.visitAnnotation(SUPPRESS_DESC, true), suppressReason);
+            }
+            fv.visitEnd();
+        }
+    }
+
+    private static void writeSuppress(AnnotationVisitor av, String reason) {
+        if (reason != null) {
+            av.visit("value", reason);
+        }
+        av.visitEnd();
+    }
+
+    private static void writeDefaultCtor(ClassWriter cw, String superInternal) {
+        MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        mv.visitCode();
+        mv.visitVarInsn(Opcodes.ALOAD, 0);
+        mv.visitMethodInsn(Opcodes.INVOKESPECIAL, superInternal, "<init>", "()V", false);
+        mv.visitInsn(Opcodes.RETURN);
+        mv.visitMaxs(1, 1);
+        mv.visitEnd();
+    }
+
+    private static void emitZeroReturn(MethodVisitor mv, String desc) {
+        switch (desc.charAt(0)) {
+            case 'V':
+                mv.visitInsn(Opcodes.RETURN);
+                break;
+            case 'I':
+            case 'B':
+            case 'S':
+            case 'C':
+            case 'Z':
+                mv.visitInsn(Opcodes.ICONST_0);
+                mv.visitInsn(Opcodes.IRETURN);
+                break;
+            case 'J':
+                mv.visitInsn(Opcodes.LCONST_0);
+                mv.visitInsn(Opcodes.LRETURN);
+                break;
+            case 'F':
+                mv.visitInsn(Opcodes.FCONST_0);
+                mv.visitInsn(Opcodes.FRETURN);
+                break;
+            case 'D':
+                mv.visitInsn(Opcodes.DCONST_0);
+                mv.visitInsn(Opcodes.DRETURN);
+                break;
+            case 'L':
+            case '[':
+                mv.visitInsn(Opcodes.ACONST_NULL);
+                mv.visitInsn(Opcodes.ARETURN);
+                break;
+            default:
+                throw new IllegalArgumentException("Unsupported return descriptor: " + desc);
+        }
+    }
+}
diff --git a/api-checker/core/src/testFixtures/java/org/apache/kafka/apicheck/TempJarBuilder.java b/api-checker/core/src/testFixtures/java/org/apache/kafka/apicheck/TempJarBuilder.java
new file mode 100644
index 00000000000..bfa471cb0ff
--- /dev/null
+++ b/api-checker/core/src/testFixtures/java/org/apache/kafka/apicheck/TempJarBuilder.java
@@ -0,0 +1,89 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.apicheck;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+/**
+ * Builds a temp {@code .jar} with class-bytes and/or HTML entries — covers both jar shapes the
+ * checker consumes: project/reference jars (class bytes) and javadoc jars (HTML).
+ */
+public final class TempJarBuilder {
+
+    private final List<Entry> entries = new ArrayList<>();
+
+    public static TempJarBuilder jar() {
+        return new TempJarBuilder();
+    }
+
+    private TempJarBuilder() {}
+
+    /** Add a class entry; jar path is derived from the binary name. */
+    public TempJarBuilder addClass(String binaryName, byte[] bytes) {
+        entries.add(new Entry(binaryName.replace('.', '/') + ".class", bytes));
+        return this;
+    }
+
+    /** Convenience: build the bytes from a {@link AsmClassFactory.ClassBuilder} and add them. */
+    public TempJarBuilder addClass(AsmClassFactory.ClassBuilder builder) {
+        return addClass(builder.binaryName(), builder.toBytes());
+    }
+
+    /** Add a javadoc HTML entry under the given jar-relative path; body may be empty. */
+    public TempJarBuilder addHtml(String entryPath, String body) {
+        entries.add(new Entry(entryPath, body.getBytes(StandardCharsets.UTF_8)));
+        return this;
+    }
+
+    /** Escape hatch for arbitrary entry paths (e.g. {@code module-info.class}, package-info, ...). */
+    public TempJarBuilder addEntry(String entryPath, byte[] bytes) {
+        entries.add(new Entry(entryPath, bytes));
+        return this;
+    }
+
+    public File writeTo(File jarFile) throws IOException {
+        try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jarFile))) {
+            for (Entry e : entries) {
+                jos.putNextEntry(new JarEntry(e.path));
+                jos.write(e.bytes);
+                jos.closeEntry();
+            }
+        }
+        return jarFile;
+    }
+
+    public File writeTo(Path tempDir, String fileName) throws IOException {
+        return writeTo(tempDir.resolve(fileName).toFile());
+    }
+
+    private static final class Entry {
+        final String path;
+        final byte[] bytes;
+        Entry(String path, byte[] bytes) {
+            this.path = path;
+            this.bytes = bytes;
+        }
+    }
+}
diff --git a/api-checker/gradle-plugins/build.gradle b/api-checker/gradle-plugins/build.gradle
new file mode 100644
index 00000000000..02357121ea5
--- /dev/null
+++ b/api-checker/gradle-plugins/build.gradle
@@ -0,0 +1,79 @@
+/*
+ * 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.
+ */
+
+apply plugin: 'java-gradle-plugin'
+
+dependencies {
+    api project(':core')
+
+    testImplementation "org.junit.jupiter:junit-jupiter:${apiCheckerVersions.junit}"
+    testImplementation "org.junit.jupiter:junit-jupiter-engine:${apiCheckerVersions.junit}"
+    testImplementation "org.junit.platform:junit-platform-launcher:${apiCheckerVersions.junitPlatform}"
+    testImplementation "org.mockito:mockito-core:${apiCheckerVersions.mockito}"
+    testImplementation gradleTestKit()
+    // Re-use the ASM-based class-file builders from :core's test-fixtures (AsmClassFactory,
+    // TempJarBuilder) instead of duplicating them.
+    testImplementation(testFixtures(project(':core')))
+}
+
+gradlePlugin {
+    plugins {
+        kafkaPublicApiChecker {
+            id = 'org.apache.kafka.public-api-checker'
+            implementationClass = 'org.apache.kafka.gradle.KafkaPublicApiCheckerPlugin'
+            displayName = 'Kafka Public API Checker'
+            description = 'Internal plugin for checking public API consistency in Kafka codebase'
+        }
+        kafkaInternalApiChecker {
+            id = 'org.apache.kafka.internal-api-checker'
+            implementationClass = 'org.apache.kafka.gradle.KafkaInternalApiCheckerPlugin'
+            displayName = 'Kafka Internal API Checker'
+            description = 'Plugin for external projects to check they don\'t use internal Kafka APIs'
+        }
+    }
+}
+
+// The `java-gradle-plugin` plugin auto-generates one publication per declared plugin
+// (the marker poms) plus a `pluginMaven` publication carrying the implementation jar.
+// Rename the implementation jar to a sensible Maven coordinate; the markers point at it
+// by group + artifactId.
+// The `public-api-checker` plugin is Kafka-internal: it's applied to Kafka's own subprojects
+// via the includedBuild wiring, but shipping its marker pom to Maven Central would only
+// invite consumers to apply a plugin that expects Kafka-style javadoc layout. Disable the
+// publish tasks for that marker only — the plugin must stay registered in
+// `gradlePlugin { plugins { } }` above so `apply plugin: 'org.apache.kafka.public-api-checker'`
+// continues to resolve, but the marker artifact never reaches a remote repository.
+tasks.matching { it.name.startsWith('publishKafkaPublicApiCheckerPluginMarkerMavenPublication') }.configureEach {
+    enabled = false
+}
+
+afterEvaluate {
+    // url, license, developers, scm come from the shared block in ../build.gradle.
+    publishing.publications.matching { it.name == 'pluginMaven' }.configureEach {
+        artifactId = 'kafka-internal-api-checker-gradle-plugin'
+        pom {
+            name = 'Apache Kafka Internal API Checker Gradle Plugin'
+            description = 'Gradle plugin that flags references to non-@Public Kafka classes in a downstream project'
+        }
+    }
+    publishing.publications.matching { it.name.endsWith('PluginMarkerMaven') }.configureEach {
+        pom {
+            name = 'Apache Kafka Internal API Checker Gradle Plugin'
+            description = 'Gradle plugin marker resolving the Apache Kafka internal-API checker'
+        }
+    }
+}
diff --git a/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerExtension.java b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerExtension.java
new file mode 100644
index 00000000000..f17a04c423a
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerExtension.java
@@ -0,0 +1,118 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.gradle;
+
+import org.gradle.api.Project;
+import org.gradle.api.file.ConfigurableFileCollection;
+import org.gradle.api.file.RegularFileProperty;
+import org.gradle.api.provider.Property;
+
+import java.io.File;
+
+/**
+ * Configuration extension for the KafkaInternalApiChecker plugin.
+ * This plugin is used by external projects to ensure they don't use internal Kafka APIs.
+ */
+public class KafkaInternalApiCheckerExtension {
+    private final Property<Boolean> enabled;
+    private final Property<Boolean> failOnViolation;
+    private final Property<Boolean> failOnNoKafkaDependency;
+    private final ConfigurableFileCollection classDirs;
+    private final ConfigurableFileCollection kafkaDependencyJars;
+    private final RegularFileProperty reportFile;
+
+    public KafkaInternalApiCheckerExtension(Project project) {
+        this.enabled = project.getObjects().property(Boolean.class);
+        this.enabled.convention(true);
+
+        this.failOnViolation = project.getObjects().property(Boolean.class);
+        this.failOnViolation.convention(true);
+
+        // Safety net for misconfigured projects. By default the task warns when it can't find
+        // any org.apache.kafka:* artifact on the classpath and proceeds with an empty surface
+        // (back-compat). Setting this to true turns that warning into a hard failure so a
+        // classpath or configuration mistake doesn't silently produce a "0 violations" report.
+        this.failOnNoKafkaDependency = project.getObjects().property(Boolean.class);
+        this.failOnNoKafkaDependency.convention(false);
+
+        this.classDirs = project.getObjects().fileCollection();
+        // Intentionally empty at construction. KafkaInternalApiCheckerPlugin reacts to the
+        // Java plugin being applied — see project.getPlugins().withType(JavaPlugin.class) — and
+        // adds sourceSets.main.output.classesDirs as a default contributor. That FileCollection
+        // carries the producer-task info for compileJava / compileScala / compileKotlin / …, so
+        // Gradle's @InputFiles validation can infer the compile-task dependency automatically.
+        // A raw project.file("build/classes") would scan the same bytecode but with no producer
+        // info attached, tripping "implicit dependency" validation errors on Gradle 9+.
+        //
+        // Users can still extend or replace this default from their build script:
+        //   kafkaInternalApiChecker { classDirs.from(file("extra-classes")) }   // extend
+        //   kafkaInternalApiChecker { classDirs.setFrom(file("only-this")) }    // replace
+
+        this.kafkaDependencyJars = project.getObjects().fileCollection();
+
+        this.reportFile = project.getObjects().fileProperty();
+        this.reportFile.convention(project.getLayout().getBuildDirectory().file("reports/kafka-internal-api-usage.txt"));
+    }
+
+    public Property<Boolean> getEnabled() {
+        return enabled;
+    }
+
+    public void setEnabled(boolean enabled) {
+        this.enabled.set(enabled);
+    }
+
+    public Property<Boolean> getFailOnViolation() {
+        return failOnViolation;
+    }
+
+    public void setFailOnViolation(boolean failOnViolation) {
+        this.failOnViolation.set(failOnViolation);
+    }
+
+    public Property<Boolean> getFailOnNoKafkaDependency() {
+        return failOnNoKafkaDependency;
+    }
+
+    public void setFailOnNoKafkaDependency(boolean failOnNoKafkaDependency) {
+        this.failOnNoKafkaDependency.set(failOnNoKafkaDependency);
+    }
+
+    public ConfigurableFileCollection getClassDirs() {
+        return classDirs;
+    }
+
+    public void setClassDirs(Object... classDirs) {
+        this.classDirs.setFrom(classDirs);
+    }
+
+    public ConfigurableFileCollection getKafkaDependencyJars() {
+        return kafkaDependencyJars;
+    }
+
+    public void setKafkaDependencyJars(Object... jars) {
+        this.kafkaDependencyJars.setFrom(jars);
+    }
+
+    public RegularFileProperty getReportFile() {
+        return reportFile;
+    }
+
+    public void setReportFile(File reportFile) {
+        this.reportFile.set(reportFile);
+    }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerPlugin.java b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerPlugin.java
new file mode 100644
index 00000000000..4ec5a3b13bd
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerPlugin.java
@@ -0,0 +1,127 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.gradle;
+
+import org.gradle.api.Plugin;
+import org.gradle.api.Project;
+import org.gradle.api.artifacts.component.ModuleComponentIdentifier;
+import org.gradle.api.plugins.JavaPlugin;
+import org.gradle.api.tasks.SourceSetContainer;
+import org.gradle.api.tasks.TaskProvider;
+
+/**
+ * Gradle plugin for checking that external projects don't use internal Kafka APIs.
+ * This plugin is intended to be published and used by external Kafka plugin/application developers.
+ *
+ * <h2>Wiring strategy</h2>
+ *
+ * The plugin uses a reactive {@code Plugins.withType(JavaPlugin.class)} callback rather than
+ * {@code project.afterEvaluate(...)} for the source-set + lifecycle wiring. Two reasons:
+ *
+ * <ul>
+ *   <li><b>Order-insensitive</b>. {@code withType} fires whenever the Java plugin is applied,
+ *       regardless of whether the user listed {@code id 'java'} before or after this plugin.
+ *       Scala and Kotlin plugins apply JavaPlugin transitively, so the same callback also
+ *       handles those.</li>
+ *   <li><b>Configuration-cache friendly</b>. {@code afterEvaluate} forces eager evaluation
+ *       at configuration time, which is at odds with the configuration cache and with
+ *       Gradle's longer-term direction (isolated projects). The reactive form survives both.</li>
+ * </ul>
+ *
+ * <h2>How {@code classDirs} is fed</h2>
+ *
+ * The extension's {@code classDirs} starts empty. When the Java plugin is applied, this plugin
+ * <em>adds</em> {@code sourceSets.main.output.classesDirs} as a contributor. That FileCollection
+ * carries producer-task info for the compile tasks, so Gradle's {@code @InputFiles} validation
+ * can infer the {@code compileJava} / {@code compileScala} / {@code compileKotlin} ordering
+ * automatically — no manual {@code dependsOn} required. Users can still call {@code .from(…)}
+ * on the extension to extend, or {@code .setFrom(…)} to replace.
+ *
+ * <p>The task's {@code classDirs} is wired to the extension's {@code ConfigurableFileCollection}
+ * by reference at task-registration time. Because {@code Property<FileCollection>.set(FileCollection)}
+ * stores the same reference, later {@code .from(...)} calls on the extension (including the
+ * main-source-set contribution made by the {@code withType} callback) are visible at task
+ * execution time.
+ */
+public class KafkaInternalApiCheckerPlugin implements Plugin<Project> {
+
+    /** Project property name that disables this checker for the current Gradle invocation. */
+    static final String SKIP_PROPERTY = "kafkaInternalApiChecker.skip";
+
+    @Override
+    public void apply(Project project) {
+        // Create the extension for configuration
+        KafkaInternalApiCheckerExtension extension = project.getExtensions()
+            .create("kafkaInternalApiChecker", KafkaInternalApiCheckerExtension.class, project);
+
+        if (KafkaPublicApiCheckerPlugin.isPropertyTruthy(project, SKIP_PROPERTY)) {
+            extension.getEnabled().set(false);
+            project.getLogger().lifecycle("Internal API checking disabled via -P{}", SKIP_PROPERTY);
+        }
+
+        // Register the task. classDirs is wired directly to the extension's ConfigurableFileCollection
+        // by reference, so anything added to extension.classDirs later (either by the JavaPlugin
+        // hook below or by the user's build script) is visible to the task at execution time.
+        TaskProvider<KafkaInternalApiCheckerTask> taskProvider = project.getTasks()
+            .register("kafkaInternalApiChecker", KafkaInternalApiCheckerTask.class, task -> {
+                task.getCheckerEnabled().set(extension.getEnabled());
+                task.getFailOnViolation().set(extension.getFailOnViolation());
+                task.getFailOnNoKafkaDependency().set(extension.getFailOnNoKafkaDependency());
+                task.getClassDirs().set(extension.getClassDirs());
+                task.getKafkaDependencyJars().from(extension.getKafkaDependencyJars());
+                task.getReportFile().set(extension.getReportFile());
+            });
+
+        // Reactive hook: fires when the Java plugin (or any plugin that applies JavaPlugin
+        // transitively, e.g. Scala or Kotlin) becomes part of this project. See the class-level
+        // javadoc for why this is preferred over `project.afterEvaluate(...)`.
+        project.getPlugins().withType(JavaPlugin.class, plugin -> {
+            SourceSetContainer sourceSets = project.getExtensions().getByType(SourceSetContainer.class);
+
+            // Contribute main source set output as the default scanning target. Wrapped in a
+            // Provider so the underlying FileCollection (and its producer-task dependencies)
+            // is resolved lazily — important for configuration cache compatibility.
+            extension.getClassDirs().from(
+                sourceSets.named("main").map(s -> s.getOutput().getClassesDirs())
+            );
+
+            // Default kafkaDependencyJars: the org.apache.kafka-filtered artifact view of the
+            // project's compile classpaths. Wired through ArtifactView (not raw resolution) so
+            // (a) it's a declared task input — a Kafka version bump invalidates the task — and
+            // (b) resolution is deferred to execution time, which is configuration-cache safe.
+            extension.getKafkaDependencyJars().from(
+                kafkaArtifactsFromConfiguration(project, JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME),
+                kafkaArtifactsFromConfiguration(project, JavaPlugin.TEST_COMPILE_CLASSPATH_CONFIGURATION_NAME)
+            );
+
+            // Lifecycle wiring: make `check` depend on our task. The 'check' task itself comes
+            // from LifecycleBasePlugin, which JavaPlugin pulls in.
+            project.getTasks().named("check").configure(checkTask -> checkTask.dependsOn(taskProvider));
+        });
+
+        project.getLogger().debug("Applied KafkaInternalApiChecker plugin to project: {}", project.getName());
+    }
+
+    private static org.gradle.api.file.FileCollection kafkaArtifactsFromConfiguration(Project project, String configurationName) {
+        return project.getConfigurations().getByName(configurationName)
+                .getIncoming()
+                .artifactView(view -> view.componentFilter(id ->
+                        id instanceof ModuleComponentIdentifier
+                                && "org.apache.kafka".equals(((ModuleComponentIdentifier) id).getGroup())))
+                .getFiles();
+    }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerTask.java b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerTask.java
new file mode 100644
index 00000000000..de093447c13
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerTask.java
@@ -0,0 +1,190 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.gradle;
+
+import org.apache.kafka.apicheck.PublicApiChecker;
+import org.apache.kafka.apicheck.PublicApiViolation;
+import org.apache.kafka.apicheck.CheckResult;
+import org.apache.kafka.apicheck.ViolationReporter;
+
+import org.gradle.api.DefaultTask;
+import org.gradle.api.GradleException;
+import org.gradle.api.file.ConfigurableFileCollection;
+import org.gradle.api.file.FileCollection;
+import org.gradle.api.file.RegularFileProperty;
+import org.gradle.api.provider.Property;
+import org.gradle.api.tasks.Input;
+import org.gradle.api.tasks.InputFiles;
+import org.gradle.api.tasks.OutputFile;
+import org.gradle.api.tasks.PathSensitive;
+import org.gradle.api.tasks.PathSensitivity;
+import org.gradle.api.tasks.TaskAction;
+import org.gradle.api.tasks.CacheableTask;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Gradle task for checking that external projects don't use internal Kafka APIs.
+ *
+ * <p>Scans compiled bytecode (.class files) under the project's class output directories, so it
+ * works uniformly for Java, Scala, Kotlin and any other JVM-language consumer. The task
+ * therefore runs after the project's {@code classes} task.
+ */
+@CacheableTask
+public class KafkaInternalApiCheckerTask extends DefaultTask {
+
+    private final Property<Boolean> enabled = getProject().getObjects().property(Boolean.class);
+    private final Property<Boolean> failOnViolation = getProject().getObjects().property(Boolean.class);
+    private final Property<Boolean> failOnNoKafkaDependency = getProject().getObjects().property(Boolean.class);
+    private final Property<FileCollection> classDirs = getProject().getObjects().property(FileCollection.class);
+    private final ConfigurableFileCollection kafkaDependencyJars = getProject().getObjects().fileCollection();
+    private final RegularFileProperty reportFile = getProject().getObjects().fileProperty();
+
+    public KafkaInternalApiCheckerTask() {
+        setGroup("verification");
+        setDescription("Checks that compiled bytecode doesn't reference internal Kafka APIs");
+
+        // Set default values
+        enabled.convention(true);
+        failOnViolation.convention(true);
+        failOnNoKafkaDependency.convention(false);
+        classDirs.convention(getProject().files("build/classes"));
+        reportFile.convention(getProject().getLayout().getBuildDirectory().file("reports/kafka-internal-api-usage.txt"));
+    }
+
+    @TaskAction
+    public void checkInternalApiUsage() {
+        if (!getCheckerEnabled().get()) {
+            getLogger().info("KafkaInternalApiChecker is disabled, skipping...");
+            return;
+        }
+
+        FileCollection classes = classDirs.get();
+        if (classes.isEmpty()) {
+            getLogger().info("No class directories configured, skipping internal API check");
+            return;
+        }
+
+        getLogger().info("Checking for internal Kafka API usage in compiled bytecode...");
+
+        List<File> kafkaJars = new ArrayList<>(kafkaDependencyJars.getFiles());
+        if (kafkaJars.isEmpty()) {
+            handleNoKafkaDependency();
+            return;
+        }
+
+        List<File> classRoots = PublicApiChecker.collectExistingRoots(classes.getFiles());
+        if (classRoots.isEmpty()) {
+            getLogger().info("No class files found, skipping internal API check");
+            return;
+        }
+
+        runCheck(kafkaJars, classRoots);
+    }
+
+    private void handleNoKafkaDependency() {
+        String msg = "No org.apache.kafka:* dependencies found on the configured "
+                + "kafkaDependencyJars. The checker cannot derive an API surface and would "
+                + "produce a meaningless '0 violations' report — likely a classpath or "
+                + "configuration issue.";
+        if (failOnNoKafkaDependency.get()) {
+            throw new GradleException(msg);
+        }
+        getLogger().warn("{} Skipping internal API check. "
+                + "Set kafkaInternalApiChecker.failOnNoKafkaDependency = true to make this fatal.", msg);
+    }
+
+    private void runCheck(List<File> kafkaJars, List<File> classRoots) {
+        try {
+            getLogger().info("Scanning {} class file root(s) for internal API usage", classRoots.size());
+            CheckResult result = new PublicApiChecker(kafkaJars).checkBytecode(classRoots);
+            reportResults(result);
+        } catch (IOException e) {
+            throw new GradleException("Failed to check internal API usage: " + e.getMessage(), e);
+        }
+    }
+
+    private void reportResults(CheckResult result) throws IOException {
+        List<PublicApiViolation> violations = result.violations();
+        List<PublicApiViolation> suppressions = result.suppressions();
+
+        ViolationReporter reporter = new ViolationReporter();
+        File report = reportFile.get().getAsFile();
+        reporter.writeTextReport(violations, suppressions, report);
+        reporter.printToConsole(violations, suppressions);
+
+        getLogger().info("Internal API usage check completed. Report written to: {}", report.getAbsolutePath());
+
+        long unjustified = suppressions.stream().filter(PublicApiViolation::lacksReason).count();
+        if (unjustified > 0) {
+            getLogger().warn("{} suppression(s) carry no reason — KIP-1265 requires a justification on every @SuppressKafkaInternalApiUsage", unjustified);
+        }
+
+        if (violations.isEmpty()) {
+            getLogger().info("No internal API usage found.");
+            return;
+        }
+
+        String message = String.format("Found %d internal API usage violations. See report: %s",
+                violations.size(), report.getAbsolutePath());
+        if (failOnViolation.get()) {
+            throw new GradleException(message);
+        }
+        getLogger().warn(message);
+    }
+
+    @Input
+    public Property<Boolean> getCheckerEnabled() {
+        return enabled;
+    }
+
+    @Input
+    public Property<Boolean> getFailOnViolation() {
+        return failOnViolation;
+    }
+
+    @Input
+    public Property<Boolean> getFailOnNoKafkaDependency() {
+        return failOnNoKafkaDependency;
+    }
+
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    public Property<FileCollection> getClassDirs() {
+        return classDirs;
+    }
+
+    /**
+     * The Kafka jars whose {@code @InterfaceAudience.Public} annotations define the legal API
+     * surface. Declared as an {@code @InputFiles} so a Kafka version bump (i.e. a different jar
+     * path or content) invalidates the task and re-runs the scan. The plugin wires the default
+     * from the project's compile classpath, filtered to {@code org.apache.kafka}.
+     */
+    @InputFiles
+    @PathSensitive(PathSensitivity.RELATIVE)
+    public ConfigurableFileCollection getKafkaDependencyJars() {
+        return kafkaDependencyJars;
+    }
+
+    @OutputFile
+    public RegularFileProperty getReportFile() {
+        return reportFile;
+    }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerExtension.java b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerExtension.java
new file mode 100644
index 00000000000..6aca486d028
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerExtension.java
@@ -0,0 +1,98 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.gradle;
+
+import org.gradle.api.Project;
+import org.gradle.api.file.ConfigurableFileCollection;
+import org.gradle.api.file.RegularFileProperty;
+import org.gradle.api.provider.Property;
+
+import java.io.File;
+
+/**
+ * Configuration extension for the KafkaPublicApiChecker plugin.
+ */
+public class KafkaPublicApiCheckerExtension {
+    private final Property<Boolean> enabled;
+    private final Property<Boolean> failOnViolation;
+    private final RegularFileProperty javadocJarPath;
+    private final ConfigurableFileCollection projectJarFiles;
+    private final ConfigurableFileCollection referenceJarFiles;
+    private final RegularFileProperty reportFile;
+
+    public KafkaPublicApiCheckerExtension(Project project) {
+        this.enabled = project.getObjects().property(Boolean.class);
+        this.enabled.convention(true);
+
+        this.failOnViolation = project.getObjects().property(Boolean.class);
+        this.failOnViolation.convention(true);
+
+        this.javadocJarPath = project.getObjects().fileProperty();
+
+        this.projectJarFiles = project.getObjects().fileCollection();
+
+        this.referenceJarFiles = project.getObjects().fileCollection();
+
+        this.reportFile = project.getObjects().fileProperty();
+        this.reportFile.convention(project.getLayout().getBuildDirectory().file("reports/kafka-public-api-checker.txt"));
+    }
+
+    public Property<Boolean> getEnabled() {
+        return enabled;
+    }
+
+    public void setEnabled(boolean enabled) {
+        this.enabled.set(enabled);
+    }
+
+    public Property<Boolean> getFailOnViolation() {
+        return failOnViolation;
+    }
+
+    public void setFailOnViolation(boolean failOnViolation) {
+        this.failOnViolation.set(failOnViolation);
+    }
+
+    public RegularFileProperty getJavadocJarPath() {
+        return javadocJarPath;
+    }
+
+    public void setJavadocJarPath(File javadocJarPath) {
+        this.javadocJarPath.set(javadocJarPath);
+    }
+
+    public ConfigurableFileCollection getProjectJarFiles() {
+        return projectJarFiles;
+    }
+
+    /**
+     * Jars of sibling Kafka modules this project depends on. Their classes are merged into the
+     * scanned surface so cross-module {@code @InterfaceAudience.Public} references resolve, but
+     * they don't contribute to this module's own javadoc-consistency or cascade iteration.
+     */
+    public ConfigurableFileCollection getReferenceJarFiles() {
+        return referenceJarFiles;
+    }
+
+    public RegularFileProperty getReportFile() {
+        return reportFile;
+    }
+
+    public void setReportFile(File reportFile) {
+        this.reportFile.set(reportFile);
+    }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerPlugin.java b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerPlugin.java
new file mode 100644
index 00000000000..2774b56c262
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerPlugin.java
@@ -0,0 +1,117 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.gradle;
+
+import org.gradle.api.DefaultTask;
+import org.gradle.api.Plugin;
+import org.gradle.api.Project;
+import org.gradle.api.Task;
+import org.gradle.api.tasks.TaskProvider;
+import org.gradle.api.tasks.bundling.Jar;
+
+/**
+ * Gradle plugin for checking public API consistency in the Kafka codebase.
+ * This is an internal plugin that runs as part of Kafka's own build process.
+ */
+public class KafkaPublicApiCheckerPlugin implements Plugin<Project> {
+
+    /** Project property name that disables this checker for the current Gradle invocation. */
+    static final String SKIP_PROPERTY = "kafkaPublicApiChecker.skip";
+
+    @Override
+    public void apply(Project project) {
+        // Create the extension for configuration
+        KafkaPublicApiCheckerExtension extension = project.getExtensions()
+            .create("kafkaPublicApiChecker", KafkaPublicApiCheckerExtension.class, project);
+
+        if (isPropertyTruthy(project, SKIP_PROPERTY)) {
+            extension.getEnabled().set(false);
+        }
+
+        // Register the task
+        TaskProvider<KafkaPublicApiCheckerTask> taskProvider = project.getTasks()
+            .register("kafkaPublicApiChecker", KafkaPublicApiCheckerTask.class, task -> {
+                task.getCheckerEnabled().set(extension.getEnabled());
+                task.getFailOnViolation().set(extension.getFailOnViolation());
+                // Intentionally NOT calling task.getJavadocJarPath().set(extension.getJavadocJarPath())
+                // here — `set(Provider)` marks the property as explicitly assigned even if the upstream
+                // is absent, which silently disables any later `.convention(...)` we install in
+                // afterEvaluate. The convention block below is the single source of truth.
+                task.getProjectJarFiles().from(extension.getProjectJarFiles());
+                task.getReferenceJarFiles().from(extension.getReferenceJarFiles());
+                task.getReportFile().set(extension.getReportFile());
+            });
+
+        // Configure task to run after javadoc
+        project.afterEvaluate(p -> {
+            TaskProvider<DefaultTask> docsJarTask = p.getTasks().named("docsJar", DefaultTask.class);
+
+            // Wire the checker's `javadocJarPath` input to the `javadocJar` task's `archiveFile`
+            // output where it exists. Reading the task's lazy output Provider — rather than
+            // recomputing the path from project.name + version — guarantees we read the *exact*
+            // file this Gradle run produced, which closes a stale-jar foot-gun:
+            //
+            //   build/libs/kafka-foo-4.3.0-SNAPSHOT-javadoc.jar   (left over from a months-old build)
+            //   build/libs/kafka-foo-4.4.0-SNAPSHOT-javadoc.jar   (just produced this run)
+            //
+            // The pre-fix auto-detect listed the directory and picked files[0], which on APFS
+            // returned the 4.3.0 (alphabetically earlier) jar. The checker then compared
+            // months-old javadoc against current bytecode and reported "all clean" while the
+            // module had real MISSING_PUBLICAPI_ANNOTATION violations against the fresh code.
+            Task javadocJarRaw = p.getTasks().findByName("javadocJar");
+            taskProvider.configure(task -> {
+                task.mustRunAfter(docsJarTask);
+                task.dependsOn(docsJarTask);
+
+                if (javadocJarRaw instanceof Jar) {
+                    Jar javadocJar = (Jar) javadocJarRaw;
+                    task.getJavadocJarPath().convention(
+                        extension.getJavadocJarPath().orElse(javadocJar.getArchiveFile())
+                    );
+                } else {
+                    // No `javadocJar` task on this project — fall back to whatever the extension
+                    // declared. The task's @TaskAction throws a clear error if neither is set,
+                    // rather than silently scanning the wrong file.
+                    task.getJavadocJarPath().convention(extension.getJavadocJarPath());
+                }
+            });
+
+            // Make javadoc task finalize with our checker
+            docsJarTask.configure(javadoc -> javadoc.finalizedBy(taskProvider));
+
+            // Add to check task dependencies if it exists
+            project.getTasks().matching(task -> task.getName().equals("check")).configureEach(checkTask -> {
+                checkTask.dependsOn(taskProvider);
+            });
+        });
+
+        if (isPropertyTruthy(project, SKIP_PROPERTY)) {
+            project.getLogger().lifecycle("Public API checking disabled via -P{}", SKIP_PROPERTY);
+        }
+
+        project.getLogger().debug("Applied KafkaPublicApiChecker plugin to project: {}", project.getName());
+    }
+
+    /** Treat property values "true", "1", "yes" (case-insensitive) — or an empty value (just {@code -PsomeProp}) — as true. */
+    static boolean isPropertyTruthy(Project project, String name) {
+        if (!project.hasProperty(name)) return false;
+        Object raw = project.findProperty(name);
+        if (raw == null) return true;
+        String s = raw.toString().trim();
+        return s.isEmpty() || "true".equalsIgnoreCase(s) || "1".equals(s) || "yes".equalsIgnoreCase(s);
+    }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTask.java b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTask.java
new file mode 100644
index 00000000000..80252aa1a39
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTask.java
@@ -0,0 +1,180 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.gradle;
+
+import org.apache.kafka.apicheck.CheckResult;
+import org.apache.kafka.apicheck.PublicApiChecker;
+import org.apache.kafka.apicheck.PublicApiViolation;
+import org.apache.kafka.apicheck.ViolationReporter;
+
+import org.gradle.api.DefaultTask;
+import org.gradle.api.GradleException;
+import org.gradle.api.file.ConfigurableFileCollection;
+import org.gradle.api.file.RegularFileProperty;
+import org.gradle.api.provider.Property;
+import org.gradle.api.tasks.Input;
+import org.gradle.api.tasks.InputFile;
+import org.gradle.api.tasks.InputFiles;
+import org.gradle.api.tasks.Optional;
+import org.gradle.api.tasks.OutputFile;
+import org.gradle.api.tasks.PathSensitive;
+import org.gradle.api.tasks.PathSensitivity;
+import org.gradle.api.tasks.TaskAction;
+import org.gradle.api.tasks.CacheableTask;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Gradle task for checking public API consistency in Kafka codebase.
+ *
+ * <p>Cacheable: the inputs (project jars, reference jars, javadoc jar) and the single output
+ * report file are all declared, and the scan is a pure function of those inputs. Applied to
+ * every subproject so unchanged modules become cache hits rather than re-running the bytecode
+ * scan on every {@code docsJar}.
+ */
+@CacheableTask
+public class KafkaPublicApiCheckerTask extends DefaultTask {
+
+    private final Property<Boolean> enabled = getProject().getObjects().property(Boolean.class);
+    private final Property<Boolean> failOnViolation = getProject().getObjects().property(Boolean.class);
+    private final RegularFileProperty javadocJarPath = getProject().getObjects().fileProperty();
+    private final ConfigurableFileCollection projectJarFiles = getProject().getObjects().fileCollection();
+    private final ConfigurableFileCollection referenceJarFiles = getProject().getObjects().fileCollection();
+    private final RegularFileProperty reportFile = getProject().getObjects().fileProperty();
+
+    public KafkaPublicApiCheckerTask() {
+        setGroup("verification");
+        setDescription("Checks consistency between javadoc HTML files and @InterfaceAudience.Public annotations across project JARs");
+
+        // Set default values
+        enabled.convention(true);
+        failOnViolation.convention(true);
+        reportFile.convention(getProject().getLayout().getBuildDirectory().file("reports/kafka-public-api-checker.txt"));
+    }
+
+    @TaskAction
+    public void checkPublicApi() {
+        if (!getCheckerEnabled().get()) {
+            getLogger().info("KafkaPublicApiChecker is disabled, skipping...");
+            return;
+        }
+
+        File jarFile = getJavadocJarFile();
+        if (!jarFile.exists()) {
+            throw new GradleException("Javadoc JAR file not found: " + jarFile.getAbsolutePath() +
+                ". Make sure the javadoc task has run first.");
+        }
+
+        getLogger().info("Checking public API consistency in: {}", jarFile.getAbsolutePath());
+
+        try {
+            if (projectJarFiles.getFiles().isEmpty()) {
+                throw new GradleException(
+                        "No project JARs configured on kafkaPublicApiChecker.projectJarFiles — "
+                        + "the checker needs at least one classes/jar source to build the API surface.");
+            }
+            PublicApiChecker checker = new PublicApiChecker(
+                new ArrayList<>(projectJarFiles.getFiles()),
+                new ArrayList<>(referenceJarFiles.getFiles()));
+            CheckResult result = checker.checkPublicApiConsistency(jarFile);
+            List<PublicApiViolation> violations = result.violations();
+            List<PublicApiViolation> suppressions = result.suppressions();
+
+            // Generate report
+            ViolationReporter reporter = new ViolationReporter();
+            File report = reportFile.get().getAsFile();
+            reporter.writeTextReport(violations, suppressions, report);
+
+            // Print summary to console
+            reporter.printToConsole(violations, suppressions);
+
+            getLogger().info("Public API check completed. Report written to: {}", report.getAbsolutePath());
+
+            if (!suppressions.isEmpty()) {
+                getLogger().lifecycle("{} suppression(s) honoured — see report for justifications.", suppressions.size());
+                long unjustified = suppressions.stream().filter(PublicApiViolation::lacksReason).count();
+                if (unjustified > 0) {
+                    getLogger().warn("{} suppression(s) carry no reason — KIP-1265 requires a justification on every @SuppressKafkaInternalApiUsage", unjustified);
+                }
+            }
+            if (!violations.isEmpty()) {
+                String message = String.format("Found %d public API violations. See report: %s",
+                    violations.size(), report.getAbsolutePath());
+
+                if (failOnViolation.get()) {
+                    throw new GradleException(message);
+                } else {
+                    getLogger().warn(message);
+                }
+            } else {
+                getLogger().info("No public API violations found.");
+            }
+
+        } catch (IOException e) {
+            throw new GradleException("Failed to check public API: " + e.getMessage(), e);
+        }
+    }
+
+    private File getJavadocJarFile() {
+        if (!javadocJarPath.isPresent()) {
+            throw new GradleException("kafkaPublicApiChecker.javadocJarPath is not set. "
+                    + "Either configure it explicitly on the extension, or apply this plugin to a "
+                    + "project that defines a 'javadocJar' Jar task whose output the plugin can "
+                    + "wire automatically.");
+        }
+        return javadocJarPath.get().getAsFile();
+    }
+
+    @Input
+    public Property<Boolean> getCheckerEnabled() {
+        return enabled;
+    }
+
+    @Input
+    public Property<Boolean> getFailOnViolation() {
+        return failOnViolation;
+    }
+
+    @InputFile
+    @Optional
+    @PathSensitive(PathSensitivity.RELATIVE)
+    public RegularFileProperty getJavadocJarPath() {
+        return javadocJarPath;
+    }
+
+    @InputFiles
+    @Optional
+    @PathSensitive(PathSensitivity.RELATIVE)
+    public ConfigurableFileCollection getProjectJarFiles() {
+        return projectJarFiles;
+    }
+
+    @InputFiles
+    @Optional
+    @PathSensitive(PathSensitivity.RELATIVE)
+    public ConfigurableFileCollection getReferenceJarFiles() {
+        return referenceJarFiles;
+    }
+
+    @OutputFile
+    public RegularFileProperty getReportFile() {
+        return reportFile;
+    }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaInternalApiCheckerPluginTest.java b/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaInternalApiCheckerPluginTest.java
new file mode 100644
index 00000000000..b90bb63c4b4
--- /dev/null
+++ b/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaInternalApiCheckerPluginTest.java
@@ -0,0 +1,93 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.gradle;
+
+import org.gradle.api.Project;
+import org.gradle.api.Task;
+import org.gradle.api.plugins.JavaPlugin;
+import org.gradle.testfixtures.ProjectBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Plugin-wiring coverage for the consumer-facing {@code internal-api-checker}. The actual scan
+ * logic is covered by {@code PluginDeveloperApiUsageScannerTest}; this class verifies that
+ * applying the plugin produces a working extension/task and that the JavaPlugin reactive hook
+ * contributes the expected defaults (classDirs + kafkaDependencyJars + `check` dependency).
+ */
+class KafkaInternalApiCheckerPluginTest {
+
+    @Test
+    void pluginApply_createsExtensionAndTask() {
+        Project project = ProjectBuilder.builder().build();
+        project.getPlugins().apply(KafkaInternalApiCheckerPlugin.class);
+
+        assertNotNull(project.getExtensions().findByName("kafkaInternalApiChecker"),
+                "expected the extension to be registered");
+        assertNotNull(project.getTasks().findByName("kafkaInternalApiChecker"),
+                "expected the kafkaInternalApiChecker task to be registered");
+    }
+
+    @Test
+    void skipProperty_disablesTheChecker() {
+        // -PkafkaInternalApiChecker.skip flips extension.enabled to false at configure time
+        // (replacement for the old execution-phase skipInternalApiCheck task, whose doLast
+        // mutation never reliably propagated).
+        Project project = ProjectBuilder.builder().build();
+        project.getExtensions().getExtraProperties().set("kafkaInternalApiChecker.skip", "true");
+        project.getPlugins().apply(KafkaInternalApiCheckerPlugin.class);
+
+        KafkaInternalApiCheckerExtension extension = (KafkaInternalApiCheckerExtension)
+                project.getExtensions().getByName("kafkaInternalApiChecker");
+        assertFalse(extension.getEnabled().get(),
+                "extension.enabled must flip to false when the skip property is set");
+    }
+
+    @Test
+    void pluginApplyWithJavaPlugin_wiresClassDirsKafkaJarsAndCheckLifecycle() {
+        Project project = ProjectBuilder.builder().build();
+        project.getPlugins().apply(KafkaInternalApiCheckerPlugin.class);
+        project.getPlugins().apply(JavaPlugin.class);
+
+        KafkaInternalApiCheckerExtension extension = (KafkaInternalApiCheckerExtension)
+                project.getExtensions().getByName("kafkaInternalApiChecker");
+        KafkaInternalApiCheckerTask task = (KafkaInternalApiCheckerTask)
+                project.getTasks().getByName("kafkaInternalApiChecker");
+
+        // classDirs default: sourceSets.main.output.classesDirs contributed by the reactive hook.
+        // Resolving via getFiles() walks the contributors lazily.
+        assertTrue(extension.getClassDirs().getFiles().stream()
+                        .anyMatch(f -> f.getPath().contains("classes")),
+                "JavaPlugin hook should contribute main source set output to classDirs, got: "
+                        + extension.getClassDirs().getFiles());
+
+        // The new declared input. With no Kafka deps on the test project's classpath the filter
+        // produces an empty file collection; what matters is that the file collection itself is
+        // wired (not null) so version bumps can later invalidate the task.
+        assertNotNull(task.getKafkaDependencyJars(),
+                "task.getKafkaDependencyJars() must be declared and wired by the plugin");
+
+        // Lifecycle: kafkaInternalApiChecker must be a dependency of `check`.
+        Task checkTask = project.getTasks().getByName("check");
+        assertTrue(checkTask.getTaskDependencies().getDependencies(checkTask).stream()
+                        .anyMatch(d -> "kafkaInternalApiChecker".equals(d.getName())),
+                "`check` task should depend on `kafkaInternalApiChecker`");
+    }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaInternalApiCheckerTaskTest.java b/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaInternalApiCheckerTaskTest.java
new file mode 100644
index 00000000000..cc0bf8663f3
--- /dev/null
+++ b/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaInternalApiCheckerTaskTest.java
@@ -0,0 +1,169 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.gradle;
+
+import org.apache.kafka.apicheck.AsmClassFactory;
+import org.apache.kafka.apicheck.TempJarBuilder;
+
+import org.gradle.api.GradleException;
+import org.gradle.api.Project;
+import org.gradle.testfixtures.ProjectBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * End-to-end coverage of the consumer Gradle path: feeds a {@link KafkaInternalApiCheckerTask}
+ * a synthetic Kafka surface jar + synthetic consumer bytecode and runs the task action directly.
+ *
+ * <p>The scanner itself is unit-tested in {@code PluginDeveloperApiUsageScannerTest}; what this
+ * class adds is the wiring between the task's declared {@code kafkaDependencyJars} /
+ * {@code classDirs} inputs, the {@code @TaskAction}, and the GradleException-vs-warning routing
+ * via {@code failOnViolation}.
+ */
+class KafkaInternalApiCheckerTaskTest {
+
+    @TempDir
+    Path tempDir;
+
+    private Project project;
+    private KafkaInternalApiCheckerTask task;
+
+    @BeforeEach
+    void setUp() {
+        project = ProjectBuilder.builder().build();
+        task = project.getTasks().create("kafkaInternalApiChecker", KafkaInternalApiCheckerTask.class);
+    }
+
+    @Test
+    void disabled_returnsWithoutScanning() {
+        task.getCheckerEnabled().set(false);
+        assertDoesNotThrow(() -> task.checkInternalApiUsage());
+    }
+
+    @Test
+    void emptyKafkaDependencyJars_skipsCheckWithWarning() throws IOException {
+        // classDirs configured, but no Kafka jars on the classpath — the scanner has no public
+        // surface to validate against, so the task warns and returns rather than treating every
+        // org.apache.kafka.* reference in the consumer bytecode as a violation.
+        task.getClassDirs().set(project.files(consumerClassesDir("org/apache/kafka/internals/Hidden")));
+        assertDoesNotThrow(() -> task.checkInternalApiUsage());
+    }
+
+    @Test
+    void consumerReferencingInternalKafkaType_failsWhenFailOnViolation() throws IOException {
+        File kafkaJar = synthesisedKafkaJarWithPublicClass();
+        File classesDir = consumerClassesDir("org/apache/kafka/internals/Hidden");
+
+        task.getKafkaDependencyJars().from(kafkaJar);
+        task.getClassDirs().set(project.files(classesDir));
+        task.getFailOnViolation().set(true);
+
+        GradleException ex = assertThrows(GradleException.class, () -> task.checkInternalApiUsage());
+        org.junit.jupiter.api.Assertions.assertTrue(ex.getMessage().contains("internal API usage violations"),
+                "expected violations message, got: " + ex.getMessage());
+    }
+
+    @Test
+    void consumerReferencingInternalKafkaType_warnsWhenFailOnViolationFalse() throws IOException {
+        File kafkaJar = synthesisedKafkaJarWithPublicClass();
+        File classesDir = consumerClassesDir("org/apache/kafka/internals/Hidden");
+
+        task.getKafkaDependencyJars().from(kafkaJar);
+        task.getClassDirs().set(project.files(classesDir));
+        task.getFailOnViolation().set(false);
+
+        // Logs a warning but does not throw — useful for adopt-as-warning rollouts.
+        assertDoesNotThrow(() -> task.checkInternalApiUsage());
+    }
+
+    @Test
+    void consumerReferencingPublicKafkaType_passes() throws IOException {
+        File kafkaJar = synthesisedKafkaJarWithPublicClass();
+        // Consumer references the @Public type rather than the internal one.
+        File classesDir = consumerClassesDir("org/apache/kafka/clients/producer/KafkaProducer");
+
+        task.getKafkaDependencyJars().from(kafkaJar);
+        task.getClassDirs().set(project.files(classesDir));
+        task.getFailOnViolation().set(true);
+
+        assertDoesNotThrow(() -> task.checkInternalApiUsage());
+    }
+
+    // ----- helpers -----
+
+    /**
+     * Produce a "kafka-clients-like" jar containing one {@code @InterfaceAudience.Public}
+     * class. References to that class from a consumer should pass; references to any other
+     * {@code org.apache.kafka.*} class should be flagged as internal.
+     */
+    private File synthesisedKafkaJarWithPublicClass() throws IOException {
+        return TempJarBuilder.jar()
+                .addClass(AsmClassFactory.klass("org.apache.kafka.clients.producer.KafkaProducer")
+                        .access(Opcodes.ACC_PUBLIC)
+                        .publicApi())
+                .writeTo(tempDir, "kafka-clients.jar");
+    }
+
+    /**
+     * Write a single consumer .class to a fresh directory; the class loads a constant of the
+     * given internal-name type, which the bytecode scanner picks up as a usage reference.
+     */
+    private File consumerClassesDir(String referencedInternalName) throws IOException {
+        File dir = Files.createTempDirectory(tempDir, "consumer-classes").toFile();
+        Files.write(new File(dir, "Consumer.class").toPath(),
+                consumerBytes("com/example/Consumer", referencedInternalName));
+        return dir;
+    }
+
+    /** Default ctor + one method that loads a class constant of {@code referencedInternalName}. */
+    private static byte[] consumerBytes(String consumerInternalName, String referencedInternalName) {
+        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+        cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, consumerInternalName, null, "java/lang/Object", null);
+
+        MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        ctor.visitCode();
+        ctor.visitVarInsn(Opcodes.ALOAD, 0);
+        ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
+        ctor.visitInsn(Opcodes.RETURN);
+        ctor.visitMaxs(0, 0);
+        ctor.visitEnd();
+
+        MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "useIt", "()V", null, null);
+        mv.visitCode();
+        mv.visitLdcInsn(Type.getObjectType(referencedInternalName));
+        mv.visitInsn(Opcodes.POP);
+        mv.visitInsn(Opcodes.RETURN);
+        mv.visitMaxs(0, 0);
+        mv.visitEnd();
+
+        cw.visitEnd();
+        return cw.toByteArray();
+    }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTaskTest.java b/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTaskTest.java
new file mode 100644
index 00000000000..43024fbf1a8
--- /dev/null
+++ b/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTaskTest.java
@@ -0,0 +1,188 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.gradle;
+
+import org.gradle.api.GradleException;
+import org.gradle.api.Project;
+import org.gradle.testfixtures.ProjectBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for the enhanced KafkaPublicApiCheckerTask with dual validation.
+ */
+public class KafkaPublicApiCheckerTaskTest {
+
+    @TempDir
+    Path tempDir;
+
+    private Project project;
+    private KafkaPublicApiCheckerTask task;
+
+    @BeforeEach
+    void setUp() {
+        project = ProjectBuilder.builder().build();
+        task = project.getTasks().create("checkPublicApi", KafkaPublicApiCheckerTask.class);
+    }
+
+    @Test
+    void testTaskConfiguration_DefaultValues() {
+        assertTrue(task.getCheckerEnabled().get());
+        assertTrue(task.getFailOnViolation().get());
+    }
+
+    @Test
+    void testTaskConfiguration_CustomValues() {
+        task.getCheckerEnabled().set(false);
+        task.getFailOnViolation().set(false);
+
+        assertFalse(task.getCheckerEnabled().get());
+        assertFalse(task.getFailOnViolation().get());
+    }
+
+    @Test
+    void testTaskExecution_DisabledChecker() {
+        task.getCheckerEnabled().set(false);
+
+        // Should not throw exception when disabled
+        assertDoesNotThrow(() -> task.checkPublicApi());
+    }
+
+    @Test
+    void testTaskExecution_MissingJavadocJar() {
+        task.getCheckerEnabled().set(true);
+        task.getJavadocJarPath().set(new File("nonexistent.jar"));
+
+        GradleException exception = assertThrows(GradleException.class, () -> task.checkPublicApi());
+        assertTrue(exception.getMessage().contains("Javadoc JAR file not found"));
+    }
+
+    @Test
+    void testTaskExecution_WithValidJarFiles() throws IOException {
+        File javadocJar = createMockJavadocJar();
+        File projectJar = createMockProjectJar();
+
+        task.getCheckerEnabled().set(true);
+        task.getJavadocJarPath().set(javadocJar);
+        task.getProjectJarFiles().from(projectJar);
+        task.getFailOnViolation().set(false); // Don't fail on violations for this test
+
+        // Should not throw exception
+        assertDoesNotThrow(() -> task.checkPublicApi());
+    }
+
+    @Test
+    void testTaskExecution_FailsWhenProjectJarsMissing() throws IOException {
+        // Project JAR files are mandatory after the SOLID refactor — without them the scanner has
+        // nothing to read. The task must surface this as a configuration error rather than silently
+        // running on an empty surface (which would let everything appear "missing javadoc").
+        File javadocJar = createMockJavadocJar();
+
+        task.getCheckerEnabled().set(true);
+        task.getJavadocJarPath().set(javadocJar);
+        task.getFailOnViolation().set(false);
+
+        assertThrows(org.gradle.api.GradleException.class, () -> task.checkPublicApi());
+    }
+
+    @Test
+    void testTaskInputsOutputs() throws IOException {
+        File javadocJar = createMockJavadocJar();
+        File projectJar = createMockProjectJar();
+
+        task.getJavadocJarPath().set(javadocJar);
+        task.getProjectJarFiles().from(projectJar);
+
+        // Verify inputs are configured
+        assertNotNull(task.getJavadocJarPath().getOrNull());
+        assertFalse(task.getProjectJarFiles().isEmpty());
+
+        // Verify outputs are configured
+        assertNotNull(task.getReportFile().getOrNull());
+    }
+
+    @Test
+    void testTaskDescription() {
+        assertEquals("Checks consistency between javadoc HTML files and @InterfaceAudience.Public annotations across project JARs",
+                    task.getDescription());
+    }
+
+    @Test
+    void testTaskGroup() {
+        assertEquals("verification", task.getGroup());
+    }
+
+    // Helper methods for creating mock JAR files
+
+    private File createMockJavadocJar() throws IOException {
+        File jarFile = tempDir.resolve("test-javadoc.jar").toFile();
+
+        try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jarFile))) {
+            // Add valid class HTML files
+            addHtmlEntry(jos, "org/apache/kafka/common/Resource.html",
+                "<html><body>Resource class documentation</body></html>");
+            addHtmlEntry(jos, "org/apache/kafka/clients/producer/Producer.html",
+                "<html><body>Producer interface documentation</body></html>");
+
+            // Add structural HTML files
+            addHtmlEntry(jos, "index.html", "<html><body>Index</body></html>");
+            addHtmlEntry(jos, "overview-tree.html", "<html><body>Tree</body></html>");
+        }
+
+        return jarFile;
+    }
+
+    private File createMockProjectJar() throws IOException {
+        File jarFile = tempDir.resolve("test-project.jar").toFile();
+
+        try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jarFile))) {
+            // Add some empty entries instead of invalid class files
+            addEntry(jos, "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n".getBytes());
+        }
+
+        return jarFile;
+    }
+
+    private void addHtmlEntry(JarOutputStream jos, String path, String content) throws IOException {
+        JarEntry entry = new JarEntry(path);
+        jos.putNextEntry(entry);
+        jos.write(content.getBytes());
+        jos.closeEntry();
+    }
+
+    private void addEntry(JarOutputStream jos, String path, byte[] content) throws IOException {
+        JarEntry entry = new JarEntry(path);
+        jos.putNextEntry(entry);
+        jos.write(content);
+        jos.closeEntry();
+    }
+}
\ No newline at end of file
diff --git a/api-checker/maven-plugin/build.gradle b/api-checker/maven-plugin/build.gradle
new file mode 100644
index 00000000000..172f8674822
--- /dev/null
+++ b/api-checker/maven-plugin/build.gradle
@@ -0,0 +1,65 @@
+/*
+ * 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.
+ */
+
+// Maven mojo for the KIP-1265 internal-api-checker. Lives in its own subproject so the
+// Maven runtime dependencies (maven-plugin-api, maven-core, …) stay off the Gradle plugin's
+// classpath, and so the published Gradle jar no longer carries Maven mojo classes.
+
+// The Maven dependency versions come from the shared ext.apiCheckerVersions block in
+// ../build.gradle. The plugin.xml descriptor templating below reads the same values, so the
+// runtime dep declarations and the descriptor can never drift.
+dependencies {
+    api project(':core')
+
+    implementation "org.apache.maven:maven-plugin-api:${apiCheckerVersions.mavenApi}"
+    implementation "org.apache.maven:maven-core:${apiCheckerVersions.mavenApi}"
+    implementation "org.apache.maven:maven-artifact:${apiCheckerVersions.mavenApi}"
+    implementation "org.apache.maven.plugin-tools:maven-plugin-annotations:${apiCheckerVersions.mavenPluginAnnotations}"
+
+    testImplementation "org.junit.jupiter:junit-jupiter:${apiCheckerVersions.junit}"
+    testImplementation "org.junit.jupiter:junit-jupiter-engine:${apiCheckerVersions.junit}"
+    testImplementation "org.junit.platform:junit-platform-launcher:${apiCheckerVersions.junitPlatform}"
+}
+
+// Template plugin.xml at packaging time so its <version> and the Maven dep versions resolve
+// from the same source as the published artifact / dependency block above. Ant's @token@
+// delimiter is intentional — Maven's runtime substitutions inside plugin.xml use ${...},
+// so the @ delimiter can never collide.
+processResources {
+    filesMatching('META-INF/maven/plugin.xml') {
+        filter(org.apache.tools.ant.filters.ReplaceTokens, tokens: [
+            version: project.version.toString(),
+            mavenApiVersion: apiCheckerVersions.mavenApi,
+            mavenPluginAnnotationsVersion: apiCheckerVersions.mavenPluginAnnotations,
+        ])
+    }
+}
+
+publishing {
+    publications {
+        mavenPlugin(MavenPublication) {
+            artifactId = 'kafka-internal-api-checker-maven-plugin'
+            from components.java
+            // url, license, developers, scm come from the shared block in ../build.gradle.
+            pom {
+                name = 'Apache Kafka Internal API Checker Maven Plugin'
+                description = 'Maven plugin that flags references to non-@Public Kafka classes in a downstream project'
+                packaging = 'maven-plugin'
+            }
+        }
+    }
+}
diff --git a/api-checker/maven-plugin/src/main/java/org/apache/kafka/maven/KafkaInternalApiCheckerMojo.java b/api-checker/maven-plugin/src/main/java/org/apache/kafka/maven/KafkaInternalApiCheckerMojo.java
new file mode 100644
index 00000000000..b4759956bc6
--- /dev/null
+++ b/api-checker/maven-plugin/src/main/java/org/apache/kafka/maven/KafkaInternalApiCheckerMojo.java
@@ -0,0 +1,226 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.maven;
+
+import org.apache.kafka.apicheck.PublicApiChecker;
+import org.apache.kafka.apicheck.PublicApiViolation;
+import org.apache.kafka.apicheck.CheckResult;
+import org.apache.kafka.apicheck.ViolationReporter;
+
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.plugins.annotations.ResolutionScope;
+import org.apache.maven.project.MavenProject;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Maven plugin for checking that external projects don't use internal Kafka APIs.
+ *
+ * <p>Scans compiled bytecode (.class files) under the project's build output directory, so it
+ * works uniformly for Java, Scala, Kotlin and any other JVM-language consumer. Runs during the
+ * verify phase after compilation has produced the bytecode it inspects.
+ */
+@Mojo(name = "verify",
+      defaultPhase = LifecyclePhase.VERIFY,
+      requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME,
+      threadSafe = true)
+public class KafkaInternalApiCheckerMojo extends AbstractMojo {
+
+    /**
+     * The Maven project.
+     */
+    @Parameter(defaultValue = "${project}", required = true, readonly = true)
+    private MavenProject project;
+
+    /**
+     * Enable/disable the checker.
+     */
+    @Parameter(property = "kafka.internal-api-checker.enabled", defaultValue = "true")
+    private boolean enabled;
+
+    /**
+     * Fail build on violations.
+     */
+    @Parameter(property = "kafka.internal-api-checker.failOnViolation", defaultValue = "true")
+    private boolean failOnViolation;
+
+    /**
+     * Fail the build when no {@code org.apache.kafka:*} artifact is on the project's
+     * classpath. The default is {@code false} for back-compat (the mojo warns and skips),
+     * but on a project that's expected to depend on Kafka, turning this on catches
+     * classpath/config mistakes that would otherwise produce a meaningless "0 violations"
+     * report.
+     */
+    @Parameter(property = "kafka.internal-api-checker.failOnNoKafkaDependency", defaultValue = "false")
+    private boolean failOnNoKafkaDependency;
+
+    /**
+     * Compiled-class directories to scan. Defaults to the project's main and test build output
+     * directories. Each entry may be a directory of {@code .class} files, an individual
+     * {@code .class} file, or a {@code .jar} archive.
+     */
+    @Parameter
+    private List<File> classesDirectories;
+
+
+    /**
+     * Report file location.
+     */
+    @Parameter(defaultValue = "${project.build.directory}/reports/kafka-internal-api-usage.txt")
+    private File reportFile;
+
+    @Override
+    public void execute() throws MojoExecutionException, MojoFailureException {
+        if (!enabled) {
+            getLog().info("KafkaInternalApiChecker is disabled, skipping...");
+            return;
+        }
+
+        if (classesDirectories == null || classesDirectories.isEmpty()) {
+            classesDirectories = getDefaultClassesDirectories();
+        }
+
+        getLog().info("Checking for internal Kafka API usage in compiled bytecode...");
+
+        List<File> kafkaJars = getKafkaJarsFromDependencies();
+        if (kafkaJars.isEmpty()) {
+            handleNoKafkaDependency();
+            return;
+        }
+
+        List<File> classRoots = PublicApiChecker.collectExistingRoots(classesDirectories);
+        if (classRoots.isEmpty()) {
+            getLog().info("No class files found, skipping internal API check");
+            return;
+        }
+
+        runCheck(kafkaJars, classRoots);
+    }
+
+    private void handleNoKafkaDependency() throws MojoFailureException {
+        String msg = "No org.apache.kafka:* dependencies found on the project classpath. "
+                + "The checker cannot derive an API surface and would produce a meaningless "
+                + "'0 violations' report — likely a classpath or configuration issue.";
+        if (failOnNoKafkaDependency) {
+            throw new MojoFailureException(msg);
+        }
+        getLog().warn(msg + " Skipping internal API check. "
+                + "Set <failOnNoKafkaDependency>true</failOnNoKafkaDependency> to make this fatal.");
+    }
+
+    private void runCheck(List<File> kafkaJars, List<File> classRoots)
+            throws MojoExecutionException, MojoFailureException {
+        try {
+            getLog().info("Scanning " + classRoots.size() + " class file root(s) for internal API usage");
+            CheckResult result = new PublicApiChecker(kafkaJars).checkBytecode(classRoots);
+            reportResults(result);
+        } catch (IOException e) {
+            throw new MojoExecutionException("Failed to check internal API usage: " + e.getMessage(), e);
+        }
+    }
+
+    private void reportResults(CheckResult result) throws MojoFailureException, IOException {
+        List<PublicApiViolation> violations = result.violations();
+        List<PublicApiViolation> suppressions = result.suppressions();
+
+        ViolationReporter reporter = new ViolationReporter();
+        reporter.writeTextReport(violations, suppressions, reportFile);
+        reporter.printToConsole(violations, suppressions);
+
+        getLog().info("Internal API usage check completed. Report written to: " + reportFile.getAbsolutePath());
+
+        long unjustified = suppressions.stream().filter(PublicApiViolation::lacksReason).count();
+        if (unjustified > 0) {
+            getLog().warn(unjustified + " suppression(s) carry no reason — KIP-1265 requires a justification on every @SuppressKafkaInternalApiUsage");
+        }
+
+        if (violations.isEmpty()) {
+            getLog().info("No internal API usage found.");
+            return;
+        }
+
+        String message = String.format("Found %d internal API usage violations. See report: %s",
+                violations.size(), reportFile.getAbsolutePath());
+        if (failOnViolation) {
+            throw new MojoFailureException(message);
+        }
+        getLog().warn(message);
+    }
+
+    /**
+     * Default to the project's main compiled output, matching the Gradle plugin's behaviour
+     * (which feeds {@code sourceSets.main.output.classesDirs}). Test code legitimately uses
+     * internal/test utilities, so including it by default would create noise that isn't a
+     * real consumer-side concern. Users who want to scan test code can opt in by setting
+     * {@code <classesDirectories>} explicitly.
+     */
+    private List<File> getDefaultClassesDirectories() {
+        List<File> dirs = new ArrayList<>();
+        File mainClasses = new File(project.getBuild().getOutputDirectory());
+        if (mainClasses.exists()) {
+            dirs.add(mainClasses);
+        }
+        return dirs;
+    }
+
+    private List<File> getKafkaJarsFromDependencies() {
+        List<File> kafkaJars = new ArrayList<>();
+
+        for (Artifact artifact : project.getArtifacts()) {
+            if ("org.apache.kafka".equals(artifact.getGroupId())) {
+                kafkaJars.add(artifact.getFile());
+                getLog().debug("Found Kafka dependency: " + artifact.getFile().getName());
+            }
+        }
+
+        return kafkaJars;
+    }
+
+    // Getters and setters for testing
+    public void setProject(MavenProject project) {
+        this.project = project;
+    }
+
+    public void setEnabled(boolean enabled) {
+        this.enabled = enabled;
+    }
+
+    public void setFailOnViolation(boolean failOnViolation) {
+        this.failOnViolation = failOnViolation;
+    }
+
+    public void setFailOnNoKafkaDependency(boolean failOnNoKafkaDependency) {
+        this.failOnNoKafkaDependency = failOnNoKafkaDependency;
+    }
+
+    public void setClassesDirectories(List<File> classesDirectories) {
+        this.classesDirectories = classesDirectories;
+    }
+
+    public void setReportFile(File reportFile) {
+        this.reportFile = reportFile;
+    }
+}
\ No newline at end of file
diff --git a/api-checker/maven-plugin/src/main/resources/META-INF/maven/plugin.xml b/api-checker/maven-plugin/src/main/resources/META-INF/maven/plugin.xml
new file mode 100644
index 00000000000..1850e42eef9
--- /dev/null
+++ b/api-checker/maven-plugin/src/main/resources/META-INF/maven/plugin.xml
@@ -0,0 +1,134 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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.
+-->
+
+<plugin>
+  <name>Apache Kafka Internal API Checker Maven Plugin</name>
+  <description>Maven plugin to check that external projects don't use internal Kafka APIs</description>
+  <groupId>org.apache.kafka</groupId>
+  <artifactId>kafka-internal-api-checker-maven-plugin</artifactId>
+  <version>@version@</version>
+  <goalPrefix>kafka-internal-api-checker</goalPrefix>
+  <isolatedRealm>false</isolatedRealm>
+  <inheritedByDefault>true</inheritedByDefault>
+
+  <mojos>
+    <mojo>
+      <goal>verify</goal>
+      <description>Checks source code for usage of internal Kafka APIs</description>
+      <requiresDirectInvocation>false</requiresDirectInvocation>
+      <requiresProject>true</requiresProject>
+      <requiresReports>false</requiresReports>
+      <aggregator>false</aggregator>
+      <requiresOnline>false</requiresOnline>
+      <inheritedByDefault>true</inheritedByDefault>
+      <phase>verify</phase>
+      <implementation>org.apache.kafka.maven.KafkaInternalApiCheckerMojo</implementation>
+      <language>java</language>
+      <instantiationStrategy>per-lookup</instantiationStrategy>
+      <executionStrategy>once-per-session</executionStrategy>
+      <threadSafe>true</threadSafe>
+      <requiresDependencyResolution>compile+runtime</requiresDependencyResolution>
+
+      <parameters>
+        <parameter>
+          <name>project</name>
+          <type>org.apache.maven.project.MavenProject</type>
+          <required>true</required>
+          <editable>false</editable>
+          <description>The Maven project</description>
+        </parameter>
+
+        <parameter>
+          <name>enabled</name>
+          <type>boolean</type>
+          <required>false</required>
+          <editable>true</editable>
+          <description>Enable/disable the internal API checker</description>
+        </parameter>
+
+        <parameter>
+          <name>failOnViolation</name>
+          <type>boolean</type>
+          <required>false</required>
+          <editable>true</editable>
+          <description>Fail build on violations</description>
+        </parameter>
+
+        <parameter>
+          <name>failOnNoKafkaDependency</name>
+          <type>boolean</type>
+          <required>false</required>
+          <editable>true</editable>
+          <description>Fail the build when no org.apache.kafka:* dependency is found on the classpath (default: false, which warns and skips)</description>
+        </parameter>
+
+        <parameter>
+          <name>classesDirectories</name>
+          <type>java.util.List</type>
+          <required>false</required>
+          <editable>true</editable>
+          <description>Compiled-class directories or jars to scan for internal API usage</description>
+        </parameter>
+
+        <parameter>
+          <name>reportFile</name>
+          <type>java.io.File</type>
+          <required>false</required>
+          <editable>true</editable>
+          <description>Report file location</description>
+        </parameter>
+      </parameters>
+
+      <configuration>
+        <project implementation="org.apache.maven.project.MavenProject" default-value="${project}" />
+        <enabled implementation="boolean" default-value="true">${kafka.internal-api-checker.enabled}</enabled>
+        <failOnViolation implementation="boolean" default-value="true">${kafka.internal-api-checker.failOnViolation}</failOnViolation>
+        <failOnNoKafkaDependency implementation="boolean" default-value="false">${kafka.internal-api-checker.failOnNoKafkaDependency}</failOnNoKafkaDependency>
+        <classesDirectories implementation="java.util.List" />
+        <reportFile implementation="java.io.File" default-value="${project.build.directory}/reports/kafka-internal-api-usage.txt" />
+      </configuration>
+    </mojo>
+  </mojos>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.apache.maven</groupId>
+      <artifactId>maven-plugin-api</artifactId>
+      <type>jar</type>
+      <version>@mavenApiVersion@</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.maven</groupId>
+      <artifactId>maven-core</artifactId>
+      <type>jar</type>
+      <version>@mavenApiVersion@</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.maven</groupId>
+      <artifactId>maven-artifact</artifactId>
+      <type>jar</type>
+      <version>@mavenApiVersion@</version>
+    </dependency>
+    <dependency>
+      <groupId>org.apache.maven.plugin-tools</groupId>
+      <artifactId>maven-plugin-annotations</artifactId>
+      <type>jar</type>
+      <version>@mavenPluginAnnotationsVersion@</version>
+    </dependency>
+  </dependencies>
+</plugin>
\ No newline at end of file
diff --git a/api-checker/maven-plugin/src/test/java/org/apache/kafka/maven/PluginXmlParityTest.java b/api-checker/maven-plugin/src/test/java/org/apache/kafka/maven/PluginXmlParityTest.java
new file mode 100644
index 00000000000..b2f6cc3e105
--- /dev/null
+++ b/api-checker/maven-plugin/src/test/java/org/apache/kafka/maven/PluginXmlParityTest.java
@@ -0,0 +1,146 @@
+/*
+ * 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.
+ */
+package org.apache.kafka.maven;
+
+import org.junit.jupiter.api.Test;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.Opcodes;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import java.io.File;
+import java.io.InputStream;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.TreeSet;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Catches drift between the Mojo's {@code @Parameter} fields and the hand-maintained
+ * {@code plugin.xml} descriptor. Adding a parameter to the Mojo without editing the descriptor
+ * silently makes it non-configurable; this test fires the moment the two sides disagree.
+ *
+ * <p>If this fails, update {@code src/main/resources/META-INF/maven/plugin.xml} to add or remove
+ * the listed parameter name(s) so the two stay in lockstep.
+ */
+class PluginXmlParityTest {
+
+    @Test
+    void everyParameterFieldHasAMatchingDescriptorEntry() throws Exception {
+        Set<String> mojoParams = parameterFieldsOf(KafkaInternalApiCheckerMojo.class);
+        Set<String> xmlParams = parametersDeclaredInPluginXml();
+
+        assertEquals(new TreeSet<>(mojoParams), new TreeSet<>(xmlParams),
+                "Mojo @Parameter fields and plugin.xml <parameter> entries are out of sync. "
+                + "Mojo: " + new TreeSet<>(mojoParams) + ", plugin.xml: " + new TreeSet<>(xmlParams)
+                + ". Edit src/main/resources/META-INF/maven/plugin.xml to add/remove entries.");
+
+        // And the <configuration> block must carry an entry for each parameter too, otherwise the
+        // parameter is declared but not wired to its property/default-value.
+        Set<String> xmlConfig = configurationEntriesInPluginXml();
+        assertTrue(xmlConfig.containsAll(mojoParams),
+                "plugin.xml <configuration> is missing entries for: "
+                        + diff(mojoParams, xmlConfig));
+    }
+
+    /**
+     * Maven's {@code @Parameter} carries {@code @Retention(CLASS)}, so reflection on the loaded
+     * class returns zero matches. Read the .class file directly via ASM, whose visitor pipeline
+     * surfaces both CLASS- and RUNTIME-retained annotations.
+     */
+    private static Set<String> parameterFieldsOf(Class<?> mojo) throws Exception {
+        Set<String> names = new LinkedHashSet<>();
+        String resourcePath = mojo.getName().replace('.', '/') + ".class";
+        try (InputStream in = mojo.getClassLoader().getResourceAsStream(resourcePath)) {
+            if (in == null) throw new IllegalStateException("Mojo .class not found: " + resourcePath);
+            new ClassReader(in).accept(new ClassVisitor(Opcodes.ASM9) {
+                @Override
+                public FieldVisitor visitField(int access, String name, String descriptor,
+                                               String signature, Object value) {
+                    return new FieldVisitor(Opcodes.ASM9) {
+                        @Override
+                        public org.objectweb.asm.AnnotationVisitor visitAnnotation(String desc, boolean visible) {
+                            if ("Lorg/apache/maven/plugins/annotations/Parameter;".equals(desc)) {
+                                names.add(name);
+                            }
+                            return null;
+                        }
+                    };
+                }
+            }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+        }
+        return names;
+    }
+
+    private static Set<String> parametersDeclaredInPluginXml() throws Exception {
+        Document doc = loadPluginXml();
+        NodeList params = doc.getElementsByTagName("parameter");
+        Set<String> names = new LinkedHashSet<>();
+        for (int i = 0; i < params.getLength(); i++) {
+            Node n = params.item(i);
+            // Skip <parameter> elements that aren't a direct child of <parameters> — e.g. nested
+            // tag names that happen to collide. plugin.xml's only "parameter" nodes are inside
+            // <mojo><parameters>, so this guard is defensive.
+            if (!"parameters".equals(n.getParentNode().getNodeName())) continue;
+            NodeList nameNodes = ((Element) n).getElementsByTagName("name");
+            if (nameNodes.getLength() > 0) {
+                names.add(nameNodes.item(0).getTextContent().trim());
+            }
+        }
+        return names;
+    }
+
+    private static Set<String> configurationEntriesInPluginXml() throws Exception {
+        Document doc = loadPluginXml();
+        NodeList configs = doc.getElementsByTagName("configuration");
+        Set<String> names = new LinkedHashSet<>();
+        for (int i = 0; i < configs.getLength(); i++) {
+            NodeList children = configs.item(i).getChildNodes();
+            for (int j = 0; j < children.getLength(); j++) {
+                Node child = children.item(j);
+                if (child.getNodeType() == Node.ELEMENT_NODE) {
+                    names.add(child.getNodeName());
+                }
+            }
+        }
+        return names;
+    }
+
+    private static Document loadPluginXml() throws Exception {
+        File f = new File("src/main/resources/META-INF/maven/plugin.xml");
+        assertTrue(f.exists(), "plugin.xml not found at " + f.getAbsolutePath()
+                + " — run this test from the :maven-plugin project directory");
+        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+        // Don't try to fetch the DTD over the network — Maven's plugin descriptor schema isn't
+        // hosted at a URL the test runner can reach in CI.
+        dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
+        return dbf.newDocumentBuilder().parse(f);
+    }
+
+    private static Set<String> diff(Set<String> required, Set<String> have) {
+        Set<String> missing = new TreeSet<>(required);
+        missing.removeAll(have);
+        return missing;
+    }
+}
diff --git a/api-checker/settings.gradle b/api-checker/settings.gradle
new file mode 100644
index 00000000000..8389e1cf9d6
--- /dev/null
+++ b/api-checker/settings.gradle
@@ -0,0 +1,20 @@
+// 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.
+
+rootProject.name = 'kafka-api-checker'
+
+include 'core'
+include 'gradle-plugins'
+include 'maven-plugin'
diff --git a/build.gradle b/build.gradle
index 5f7a0c4ff70..dfd7ded029c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -42,6 +42,11 @@ plugins {
   id 'org.scoverage' version '8.1' apply false
   id 'com.gradleup.shadow' version '9.4.1' apply false
   id 'com.diffplug.spotless' version "8.4.0"
+
+  // Pre-register the KIP-1265 checker so `apply plugin:` resolves it inside subprojects { }.
+  // The plugin itself ships from the `:api-checker` included build (see settings.gradle's
+  // pluginManagement block).
+  id 'org.apache.kafka.public-api-checker' apply false
 }
 
 ext {
@@ -335,6 +340,8 @@ subprojects {
 
   apply plugin: 'checkstyle'
   apply plugin: "com.github.spotbugs"
+  apply plugin: 'org.apache.kafka.public-api-checker'
+
 
   // We use the shadow plugin for the jmh-benchmarks module and the `-all` jar can get pretty large, so
   // don't publish it
@@ -734,6 +741,31 @@ subprojects {
 
   check.dependsOn('javadoc')
 
+kafkaPublicApiChecker {
+
+    // Jars produced by this project — the surface that's actually validated.
+    projectJarFiles.from(jar.archiveFile)
+
+    // Sibling Kafka modules this project depends on (direct or transitive). Their classes
+    // are merged into the scanned surface so cross-module @InterfaceAudience.Public references
+    // resolve, but they don't contribute to this module's MISSING_JAVADOC or cascade
+    // iteration — each module checks its own surface.
+    referenceJarFiles.from(
+        configurations.compileClasspath.incoming.artifactView {
+            // Resolve project deps to their assembled JAR (not the classes-dir variant Gradle
+            // picks by default for compile-classpath), so the scanner only sees real jars.
+            attributes {
+                attribute(org.gradle.api.attributes.LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE,
+                          objects.named(org.gradle.api.attributes.LibraryElements,
+                                        org.gradle.api.attributes.LibraryElements.JAR))
+            }
+            componentFilter { it instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier }
+        }.files
+    )
+
+    enabled = true
+    failOnViolation = true
+}
   task systemTestLibs(dependsOn: jar)
 
   if (!sourceSets.test.allSource.isEmpty()) {
@@ -2136,6 +2168,7 @@ project(':clients') {
     include "**/org/apache/kafka/common/annotation/*"
     include "**/org/apache/kafka/common/config/*"
     include "**/org/apache/kafka/common/config/provider/*"
+    include "**/org/apache/kafka/common/config/types/*"
     include "**/org/apache/kafka/common/errors/*"
     include "**/org/apache/kafka/common/header/*"
     include "**/org/apache/kafka/common/metrics/*"
diff --git a/checkstyle/import-control-api-checker-test.xml b/checkstyle/import-control-api-checker-test.xml
new file mode 100644
index 00000000000..ed651abfbde
--- /dev/null
+++ b/checkstyle/import-control-api-checker-test.xml
@@ -0,0 +1,35 @@
+<!DOCTYPE import-control PUBLIC
+        "-//Puppy Crawl//DTD Import Control 1.1//EN"
+        "http://www.puppycrawl.com/dtds/import_control_1_1.dtd">
+<!--
+ 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.
+-->
+
+<!-- Test-scope import policy for the api-checker included build. Same allowlist as the main
+     scope plus the test frameworks (junit, mockito, gradle's testkit). Wired in via the
+     checkstyleTest task's configProperties in api-checker/build.gradle. -->
+<import-control pkg="org.apache.kafka">
+    <allow pkg="java"/>
+    <allow pkg="javax"/>
+    <allow pkg="org.apache.kafka"/>
+    <allow pkg="org.gradle"/>
+    <allow pkg="org.objectweb.asm"/>
+    <allow pkg="org.slf4j"/>
+    <allow pkg="org.apache.maven"/>
+    <allow pkg="org.junit"/>
+    <allow pkg="org.mockito"/>
+    <allow pkg="org.w3c.dom"/>
+</import-control>
diff --git a/checkstyle/import-control-api-checker.xml b/checkstyle/import-control-api-checker.xml
new file mode 100644
index 00000000000..22edeca4b0c
--- /dev/null
+++ b/checkstyle/import-control-api-checker.xml
@@ -0,0 +1,32 @@
+<!DOCTYPE import-control PUBLIC
+        "-//Puppy Crawl//DTD Import Control 1.1//EN"
+        "http://www.puppycrawl.com/dtds/import_control_1_1.dtd">
+<!--
+ 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.
+-->
+
+<!-- Production import policy for the api-checker included build's main sources — Gradle/Maven
+     plugins and the ASM-based scanner only. junit/mockito are intentionally absent: those
+     belong only in the test source sets (see import-control-api-checker-test.xml), so a stray
+     test-dep leaking into a main-source file fails checkstyle here. -->
+<import-control pkg="org.apache.kafka">
+    <allow pkg="java"/>
+    <allow pkg="org.apache.kafka"/>
+    <allow pkg="org.gradle"/>
+    <allow pkg="org.objectweb.asm"/>
+    <allow pkg="org.slf4j"/>
+    <allow pkg="org.apache.maven"/>
+</import-control>
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionOptions.java
index 26674e1ccc1..577eb8da977 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionOptions.java
@@ -14,8 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
+@InterfaceAudience.Public
 public class AbortTransactionOptions extends AbstractOptions<AbortTransactionOptions> {
 
     @Override
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionResult.java
index 602c4f96443..e1501fc1bd6 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionResult.java
@@ -14,16 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Map;
 
 /**
  * The result of {@link Admin#abortTransaction(AbortTransactionSpec, AbortTransactionOptions)}.
  */
+@InterfaceAudience.Public
 public class AbortTransactionResult {
     private final Map<TopicPartition, KafkaFuture<Void>> futures;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionSpec.java b/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionSpec.java
index 6af55978726..ed55dd1709d 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionSpec.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionSpec.java
@@ -14,12 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Objects;
 
+@InterfaceAudience.Public
 public class AbortTransactionSpec {
     private final TopicPartition topicPartition;
     private final long producerId;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AbstractOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AbstractOptions.java
index 12effaf4e63..56959619ac1 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AbstractOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AbstractOptions.java
@@ -17,11 +17,13 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 /*
  * This class implements the common APIs that are shared by Options classes for various AdminClient commands
  */
 @SuppressWarnings("rawtypes")
+@InterfaceAudience.Public
 public abstract class AbstractOptions<T extends AbstractOptions> {
 
     protected Integer timeoutMs = null;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterOptions.java
index 81e889db30d..09013c36876 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterOptions.java
@@ -16,6 +16,7 @@
  */
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.annotation.InterfaceStability;
 import org.apache.kafka.common.protocol.Errors;
 
@@ -33,6 +34,7 @@ import java.util.Optional;
  * If not provided, the cluster id check is skipped.
  */
 @InterfaceStability.Stable
+@InterfaceAudience.Public
 public class AddRaftVoterOptions extends AbstractOptions<AddRaftVoterOptions> {
     private Optional<String> clusterId = Optional.empty();
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterResult.java
index d42204c5e4e..04fe2de20ee 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterResult.java
@@ -17,6 +17,7 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.annotation.InterfaceStability;
 
 /**
@@ -25,6 +26,7 @@ import org.apache.kafka.common.annotation.InterfaceStability;
  * The API of this class is evolving, see {@link Admin} for details.
  */
 @InterfaceStability.Stable
+@InterfaceAudience.Public
 public class AddRaftVoterResult {
     private final KafkaFuture<Void> result;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java b/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java
index 313c29ca49e..942b71d6433 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java
@@ -29,6 +29,7 @@ import org.apache.kafka.common.TopicPartitionReplica;
 import org.apache.kafka.common.Uuid;
 import org.apache.kafka.common.acl.AclBinding;
 import org.apache.kafka.common.acl.AclBindingFilter;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.annotation.InterfaceStability;
 import org.apache.kafka.common.config.ConfigResource;
 import org.apache.kafka.common.errors.FeatureUpdateFailedException;
@@ -122,6 +123,7 @@ import java.util.Set;
  * version required.
  * <p>
  */
+@InterfaceAudience.Public
 public interface Admin extends AutoCloseable {
 
     /**
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AdminClient.java b/clients/src/main/java/org/apache/kafka/clients/admin/AdminClient.java
index 204579605bc..0c2923bab4c 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AdminClient.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AdminClient.java
@@ -17,6 +17,8 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Map;
 import java.util.Properties;
 
@@ -27,6 +29,7 @@ import java.util.Properties;
  *
  * This class may be removed in a later release, but has not been marked as deprecated to avoid unnecessary noise.
  */
+@InterfaceAudience.Public
 public abstract class AdminClient implements Admin {
 
     /**
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AdminClientConfig.java b/clients/src/main/java/org/apache/kafka/clients/admin/AdminClientConfig.java
index 39086886156..62e72bb6ba0 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AdminClientConfig.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AdminClientConfig.java
@@ -20,6 +20,7 @@ package org.apache.kafka.clients.admin;
 import org.apache.kafka.clients.ClientDnsLookup;
 import org.apache.kafka.clients.CommonClientConfigs;
 import org.apache.kafka.clients.MetadataRecoveryStrategy;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.config.AbstractConfig;
 import org.apache.kafka.common.config.ConfigDef;
 import org.apache.kafka.common.config.ConfigDef.Importance;
@@ -41,6 +42,7 @@ import static org.apache.kafka.common.config.ConfigDef.ValidString.in;
 /**
  * The AdminClient configuration class, which also contains constants for configuration entry names.
  */
+@InterfaceAudience.Public
 public class AdminClientConfig extends AbstractConfig {
     private static final ConfigDef CONFIG;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasOptions.java
index 027ccd1e9e0..39dac2ac6b6 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for {@link Admin#alterClientQuotas(Collection, AlterClientQuotasOptions)}.
  */
+@InterfaceAudience.Public
 public class AlterClientQuotasOptions extends AbstractOptions<AlterClientQuotasOptions> {
 
     private boolean validateOnly = false;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasResult.java
index 4906184b3c9..2de0158e42d 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasResult.java
@@ -18,6 +18,7 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.quota.ClientQuotaEntity;
 
 import java.util.Collection;
@@ -26,6 +27,7 @@ import java.util.Map;
 /**
  * The result of the {@link Admin#alterClientQuotas(Collection, AlterClientQuotasOptions)} call.
  */
+@InterfaceAudience.Public
 public class AlterClientQuotasResult {
 
     private final Map<ClientQuotaEntity, KafkaFuture<Void>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigOp.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigOp.java
index 789c9f64a93..f3afc4dbbb1 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigOp.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigOp.java
@@ -17,6 +17,8 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Map;
@@ -41,6 +43,7 @@ import java.util.stream.Collectors;
  * new AlterConfigOp(new ConfigEntry(loggerName, "DEBUG"), OpType.SET)
  * </pre>
  */
+@InterfaceAudience.Public
 public class AlterConfigOp {
 
     public enum OpType {
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsOptions.java
index 31812bbfc16..689a787c970 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Map;
 
 /**
  * Options for {@link Admin#incrementalAlterConfigs(Map)}.
  */
+@InterfaceAudience.Public
 public class AlterConfigsOptions extends AbstractOptions<AlterConfigsOptions> {
 
     private boolean validateOnly = false;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsResult.java
index cd9279300de..51639d3b23d 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsResult.java
@@ -18,6 +18,7 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.config.ConfigResource;
 
 import java.util.Map;
@@ -25,6 +26,7 @@ import java.util.Map;
 /**
  * The result of the {@link Admin#incrementalAlterConfigs(Map, AlterConfigsOptions)} call.
  */
+@InterfaceAudience.Public
 public class AlterConfigsResult {
 
     private final Map<ConfigResource, KafkaFuture<Void>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsOptions.java
index c2428b6430a..ea3f80bb4a3 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsOptions.java
@@ -14,12 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Map;
 
 /**
  * Options for the {@link AdminClient#alterConsumerGroupOffsets(String, Map, AlterConsumerGroupOffsetsOptions)} call.
  */
+@InterfaceAudience.Public
 public class AlterConsumerGroupOffsetsOptions extends AbstractOptions<AlterConsumerGroupOffsetsOptions> {
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsResult.java
index 8d78a16b574..b20a55c8b1e 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsResult.java
@@ -18,6 +18,7 @@ package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.internals.KafkaFutureImpl;
 import org.apache.kafka.common.protocol.Errors;
 
@@ -28,6 +29,7 @@ import java.util.stream.Collectors;
 /**
  * The result of the {@link AdminClient#alterConsumerGroupOffsets(String, Map)} call.
  */
+@InterfaceAudience.Public
 public class AlterConsumerGroupOffsetsResult {
 
     private final KafkaFuture<Map<TopicPartition, Errors>> future;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
index 74c9f3dcdec..e1659db4195 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Map;
 
 /**
  * Options for {@link AdminClient#alterPartitionReassignments(Map, AlterPartitionReassignmentsOptions)}
  */
+@InterfaceAudience.Public
 public class AlterPartitionReassignmentsOptions extends AbstractOptions<AlterPartitionReassignmentsOptions> {
 
     private boolean allowReplicationFactorChange = true;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsResult.java
index f918074af77..8dd1c457a52 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsResult.java
@@ -19,12 +19,14 @@ package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Map;
 
 /**
  * The result of {@link AdminClient#alterPartitionReassignments(Map, AlterPartitionReassignmentsOptions)}.
  */
+@InterfaceAudience.Public
 public class AlterPartitionReassignmentsResult {
     private final Map<TopicPartition, KafkaFuture<Void>> futures;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsOptions.java
index b6cca48ec8d..447bde43333 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Map;
 
 /**
  * Options for {@link Admin#alterReplicaLogDirs(Map, AlterReplicaLogDirsOptions)}.
  */
+@InterfaceAudience.Public
 public class AlterReplicaLogDirsOptions extends AbstractOptions<AlterReplicaLogDirsOptions> {
 
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsResult.java
index 6b6a98e20f6..20f89e8751a 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsResult.java
@@ -18,6 +18,7 @@ package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.TopicPartitionReplica;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.errors.ClusterAuthorizationException;
 import org.apache.kafka.common.errors.InvalidTopicException;
 import org.apache.kafka.common.errors.KafkaStorageException;
@@ -35,6 +36,7 @@ import java.util.concurrent.ExecutionException;
  * To retrieve the detailed result per specified {@link TopicPartitionReplica}, use {@link #values()}. To retrieve the
  * overall result only, use {@link #all()}.
  */
+@InterfaceAudience.Public
 public class AlterReplicaLogDirsResult {
     private final Map<TopicPartitionReplica, KafkaFuture<Void>> futures;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsOptions.java
index b032be54060..bf0309921c0 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsOptions.java
@@ -14,12 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Map;
 
 /**
  * Options for {@link Admin#alterShareGroupOffsets(String, Map, AlterShareGroupOffsetsOptions)}.
  */
+@InterfaceAudience.Public
 public class AlterShareGroupOffsetsOptions extends AbstractOptions<AlterShareGroupOffsetsOptions> {
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsResult.java
index 293ad549ea8..f2ebd526be7 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsResult.java
@@ -19,6 +19,7 @@ package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.errors.ApiException;
 import org.apache.kafka.common.internals.KafkaFutureImpl;
 import org.apache.kafka.common.protocol.Errors;
@@ -30,6 +31,7 @@ import java.util.stream.Collectors;
 /**
  * The result of the {@link Admin#alterShareGroupOffsets(String, Map, AlterShareGroupOffsetsOptions)} call.
  */
+@InterfaceAudience.Public
 public class AlterShareGroupOffsetsResult {
 
     private final KafkaFuture<Map<TopicPartition, ApiException>> future;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsOptions.java
index 2eceacdf254..f3bc28926b5 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsOptions.java
@@ -14,18 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.annotation.InterfaceStability;
 
 import java.util.Map;
 
-
 /**
  * Options for the {@link Admin#alterStreamsGroupOffsets(String, Map, AlterStreamsGroupOffsetsOptions)} call.
  * <p>
  * The API of this class is evolving, see {@link Admin} for details.
  */
 @InterfaceStability.Evolving
+@InterfaceAudience.Public
 public class AlterStreamsGroupOffsetsOptions extends AbstractOptions<AlterStreamsGroupOffsetsOptions> {
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsResult.java
index 11813bf767f..5e1249f9b2f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsResult.java
@@ -18,6 +18,7 @@ package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.annotation.InterfaceStability;
 
 import java.util.Map;
@@ -28,6 +29,7 @@ import java.util.Map;
  * The API of this class is evolving, see {@link AdminClient} for details.
  */
 @InterfaceStability.Evolving
+@InterfaceAudience.Public
 public class AlterStreamsGroupOffsetsResult {
 
     private final AlterConsumerGroupOffsetsResult delegate;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsOptions.java
index 9ead2ee0912..26f5d2f8e80 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsOptions.java
@@ -17,10 +17,13 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.List;
 
 /**
  * Options for {@link AdminClient#alterUserScramCredentials(List, AlterUserScramCredentialsOptions)}
  */
+@InterfaceAudience.Public
 public class AlterUserScramCredentialsOptions extends AbstractOptions<AlterUserScramCredentialsOptions> {
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsResult.java
index d61d3958863..4b140fe0e92 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsResult.java
@@ -18,6 +18,7 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collections;
 import java.util.List;
@@ -27,6 +28,7 @@ import java.util.Objects;
 /**
  * The result of the {@link Admin#alterUserScramCredentials(List)} call.
  */
+@InterfaceAudience.Public
 public class AlterUserScramCredentialsResult {
     private final Map<String, KafkaFuture<Void>> futures;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/ClassicGroupDescription.java b/clients/src/main/java/org/apache/kafka/clients/admin/ClassicGroupDescription.java
index 5a59eb96980..963d9ce8843 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/ClassicGroupDescription.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/ClassicGroupDescription.java
@@ -20,6 +20,7 @@ package org.apache.kafka.clients.admin;
 import org.apache.kafka.common.ClassicGroupState;
 import org.apache.kafka.common.Node;
 import org.apache.kafka.common.acl.AclOperation;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collection;
 import java.util.List;
@@ -30,6 +31,7 @@ import java.util.stream.Collectors;
 /**
  * A detailed description of a single classic group in the cluster.
  */
+@InterfaceAudience.Public
 public class ClassicGroupDescription {
     private final String groupId;
     private final String protocol;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/Config.java b/clients/src/main/java/org/apache/kafka/clients/admin/Config.java
index 59f6f12e266..454f553d6e4 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/Config.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/Config.java
@@ -17,6 +17,8 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
@@ -26,6 +28,7 @@ import java.util.Map;
  * A configuration object containing the configuration entries for a resource.
  * <p>
  */
+@InterfaceAudience.Public
 public class Config {
 
     private final Map<String, ConfigEntry> entries = new HashMap<>();
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/ConfigEntry.java b/clients/src/main/java/org/apache/kafka/clients/admin/ConfigEntry.java
index cde99ffb852..69134f02a82 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/ConfigEntry.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/ConfigEntry.java
@@ -17,6 +17,8 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
@@ -24,6 +26,7 @@ import java.util.Objects;
 /**
  * A class representing a configuration entry containing name, value and additional metadata.
  */
+@InterfaceAudience.Public
 public class ConfigEntry {
 
     private final String name;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/ConsumerGroupDescription.java b/clients/src/main/java/org/apache/kafka/clients/admin/ConsumerGroupDescription.java
index fa9c010393e..599ab934ad0 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/ConsumerGroupDescription.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/ConsumerGroupDescription.java
@@ -22,6 +22,7 @@ import org.apache.kafka.common.GroupState;
 import org.apache.kafka.common.GroupType;
 import org.apache.kafka.common.Node;
 import org.apache.kafka.common.acl.AclOperation;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collection;
 import java.util.Collections;
@@ -34,6 +35,7 @@ import java.util.stream.Collectors;
 /**
  * A detailed description of a single consumer group in the cluster.
  */
+@InterfaceAudience.Public
 public class ConsumerGroupDescription {
     private final String groupId;
     private final boolean isSimpleConsumerGroup;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsOptions.java
index e9f771ebd9f..911be059de0 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for {@link Admin#createAcls(Collection)}.
  */
+@InterfaceAudience.Public
 public class CreateAclsOptions extends AbstractOptions<CreateAclsOptions> {
 
     /**
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsResult.java
index de57fbb8911..c6fa0f87eff 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsResult.java
@@ -19,6 +19,7 @@ package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.acl.AclBinding;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collection;
 import java.util.Map;
@@ -26,6 +27,7 @@ import java.util.Map;
 /**
  * The result of the {@link Admin#createAcls(Collection)} call.
  */
+@InterfaceAudience.Public
 public class CreateAclsResult {
     private final Map<AclBinding, KafkaFuture<Void>> futures;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenOptions.java
index f7ef1d4619d..2e5708a72a0 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenOptions.java
@@ -17,6 +17,7 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.security.auth.KafkaPrincipal;
 
 import java.util.LinkedList;
@@ -26,6 +27,7 @@ import java.util.Optional;
 /**
  * Options for {@link Admin#createDelegationToken(CreateDelegationTokenOptions)}.
  */
+@InterfaceAudience.Public
 public class CreateDelegationTokenOptions extends AbstractOptions<CreateDelegationTokenOptions> {
     private long maxLifetimeMs = -1;
     private List<KafkaPrincipal> renewers =  new LinkedList<>();
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenResult.java
index b8b1a5694f6..76227aabd39 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenResult.java
@@ -18,11 +18,13 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.security.token.delegation.DelegationToken;
 
 /**
  * The result of the {@link KafkaAdminClient#createDelegationToken(CreateDelegationTokenOptions)} call.
  */
+@InterfaceAudience.Public
 public class CreateDelegationTokenResult {
     private final KafkaFuture<DelegationToken> delegationToken;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsOptions.java
index ccdbe6d52ca..12a68467434 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Map;
 
 /**
  * Options for {@link Admin#createPartitions(Map)}.
  */
+@InterfaceAudience.Public
 public class CreatePartitionsOptions extends AbstractOptions<CreatePartitionsOptions> {
 
     private boolean validateOnly = false;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsResult.java
index 21d26a62467..f60a8ae2bcd 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsResult.java
@@ -18,12 +18,14 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Map;
 
 /**
  * The result of the {@link Admin#createPartitions(Map)} call.
  */
+@InterfaceAudience.Public
 public class CreatePartitionsResult {
 
     private final Map<String, KafkaFuture<Void>> values;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsOptions.java
index c81f6e41a90..8b9f11a401f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for {@link Admin#createTopics(Collection)}.
  */
+@InterfaceAudience.Public
 public class CreateTopicsOptions extends AbstractOptions<CreateTopicsOptions> {
 
     private boolean validateOnly = false;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsResult.java
index 6cf794c65ee..752afc0180b 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsResult.java
@@ -18,6 +18,7 @@ package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.Uuid;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.errors.ApiException;
 
 import java.util.Collection;
@@ -27,6 +28,7 @@ import java.util.stream.Collectors;
 /**
  * The result of {@link Admin#createTopics(Collection)}.
  */
+@InterfaceAudience.Public
 public class CreateTopicsResult {
     static final int UNKNOWN = -1;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsOptions.java
index 59d5ce2bb19..dee2ceab703 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for the {@link Admin#deleteAcls(Collection)} call.
  */
+@InterfaceAudience.Public
 public class DeleteAclsOptions extends AbstractOptions<DeleteAclsOptions> {
 
     /**
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsResult.java
index db43ac3f047..b0909fb316a 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsResult.java
@@ -21,6 +21,7 @@ import org.apache.kafka.common.KafkaException;
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.acl.AclBinding;
 import org.apache.kafka.common.acl.AclBindingFilter;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.errors.ApiException;
 
 import java.util.ArrayList;
@@ -31,6 +32,7 @@ import java.util.Map;
 /**
  * The result of the {@link Admin#deleteAcls(Collection)} call.
  */
+@InterfaceAudience.Public
 public class DeleteAclsResult {
 
     /**
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsOptions.java
index 014b9fa9d4f..920367dc5e1 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsOptions.java
@@ -14,13 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Set;
 
 /**
  * Options for the {@link Admin#deleteConsumerGroupOffsets(String, Set)} call.
  */
+@InterfaceAudience.Public
 public class DeleteConsumerGroupOffsetsOptions extends AbstractOptions<DeleteConsumerGroupOffsetsOptions> {
 
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsResult.java
index 80aaeb2a4bc..ffdee4a1108 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsResult.java
@@ -18,6 +18,7 @@ package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.internals.KafkaFutureImpl;
 import org.apache.kafka.common.protocol.Errors;
 
@@ -27,6 +28,7 @@ import java.util.Set;
 /**
  * The result of the {@link Admin#deleteConsumerGroupOffsets(String, Set)} call.
  */
+@InterfaceAudience.Public
 public class DeleteConsumerGroupOffsetsResult {
     private final KafkaFuture<Map<TopicPartition, Errors>> future;
     private final Set<TopicPartition> partitions;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsOptions.java
index b8bc1ecba74..91ba4492b59 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsOptions.java
@@ -14,13 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for the {@link Admin#deleteConsumerGroups(Collection)} call.
  */
+@InterfaceAudience.Public
 public class DeleteConsumerGroupsOptions extends AbstractOptions<DeleteConsumerGroupsOptions> {
 
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsResult.java
index 39f893b047b..355e9732a43 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsResult.java
@@ -14,9 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collection;
 import java.util.HashMap;
@@ -26,6 +28,7 @@ import java.util.Map;
  * The result of the {@link Admin#deleteConsumerGroups(Collection, DeleteConsumerGroupsOptions)} call for
  * {@code Collection<String>} input.
  */
+@InterfaceAudience.Public
 public class DeleteConsumerGroupsResult {
     private final Map<String, KafkaFuture<Void>> futures;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsOptions.java
index 320d3d8cb6c..9b844c227b4 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Map;
 
 /**
  * Options for {@link Admin#deleteRecords(Map, DeleteRecordsOptions)}.
  */
+@InterfaceAudience.Public
 public class DeleteRecordsOptions extends AbstractOptions<DeleteRecordsOptions> {
 
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsResult.java
index 061403eeb16..586eff2e010 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsResult.java
@@ -19,12 +19,14 @@ package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Map;
 
 /**
  * The result of the {@link Admin#deleteRecords(Map)} call.
  */
+@InterfaceAudience.Public
 public class DeleteRecordsResult {
 
     private final Map<TopicPartition, KafkaFuture<DeletedRecords>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsOptions.java
index e38fdf10745..c9497e103d6 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsOptions.java
@@ -14,12 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Set;
 
 /**
  * Options for {@link Admin#deleteShareGroupOffsets(String, Set, DeleteShareGroupOffsetsOptions)}.
  */
+@InterfaceAudience.Public
 public class DeleteShareGroupOffsetsOptions extends AbstractOptions<DeleteShareGroupOffsetsOptions> {
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsResult.java
index 65231d1ca0f..76c93aa471b 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsResult.java
@@ -17,6 +17,7 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.errors.ApiException;
 import org.apache.kafka.common.internals.KafkaFutureImpl;
 
@@ -26,6 +27,7 @@ import java.util.Set;
 /**
  * The result of the {@link Admin#deleteShareGroupOffsets(String, Set, DeleteShareGroupOffsetsOptions)} call.
  */
+@InterfaceAudience.Public
 public class DeleteShareGroupOffsetsResult {
 
     private final KafkaFuture<Map<String, ApiException>> future;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsOptions.java
index 1709f169cb1..278ddb248b1 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsOptions.java
@@ -14,12 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for {@link Admin#deleteShareGroups(Collection, DeleteShareGroupsOptions)}.
  */
+@InterfaceAudience.Public
 public class DeleteShareGroupsOptions extends AbstractOptions<DeleteShareGroupsOptions> {
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsResult.java
index c4831e58494..ebd1840cdda 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsResult.java
@@ -14,9 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collection;
 import java.util.HashMap;
@@ -25,6 +27,7 @@ import java.util.Map;
 /**
  * The result of the {@link Admin#deleteShareGroups(Collection, DeleteShareGroupsOptions)} call.
  */
+@InterfaceAudience.Public
 public class DeleteShareGroupsResult {
     private final Map<String, KafkaFuture<Void>> futures;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsOptions.java
index 10d39075d8a..1861db4984c 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsOptions.java
@@ -16,6 +16,7 @@
  */
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.annotation.InterfaceStability;
 
 import java.util.Set;
@@ -26,6 +27,7 @@ import java.util.Set;
  * The API of this class is evolving, see {@link Admin} for details.
  */
 @InterfaceStability.Evolving
+@InterfaceAudience.Public
 public class DeleteStreamsGroupOffsetsOptions extends AbstractOptions<DeleteStreamsGroupOffsetsOptions> {
 
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsResult.java
index 4966ed063a8..b8930d8c1c7 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsResult.java
@@ -18,6 +18,7 @@ package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.annotation.InterfaceStability;
 import org.apache.kafka.common.protocol.Errors;
 
@@ -30,6 +31,7 @@ import java.util.Set;
  * The API of this class is evolving, see {@link Admin} for details.
  */
 @InterfaceStability.Evolving
+@InterfaceAudience.Public
 public class DeleteStreamsGroupOffsetsResult {
     private final DeleteConsumerGroupOffsetsResult delegate;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsOptions.java
index 6ca2ec66a27..7ee61a74919 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsOptions.java
@@ -16,6 +16,7 @@
  */
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.annotation.InterfaceStability;
 
 import java.util.Collection;
@@ -26,5 +27,6 @@ import java.util.Collection;
  * The API of this class is evolving, see {@link Admin} for details.
  */
 @InterfaceStability.Evolving
+@InterfaceAudience.Public
 public class DeleteStreamsGroupsOptions extends AbstractOptions<DeleteStreamsGroupsOptions> {
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsResult.java
index 70debbbb519..300f51a8e7d 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsResult.java
@@ -18,6 +18,7 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.annotation.InterfaceStability;
 
 import java.util.Collection;
@@ -29,6 +30,7 @@ import java.util.Map;
  * The API of this class is evolving, see {@link Admin} for details.
  */
 @InterfaceStability.Evolving
+@InterfaceAudience.Public
 public class DeleteStreamsGroupsResult {
 
     private final DeleteConsumerGroupsResult delegate;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsOptions.java
index 6f5e9f069e0..3991e68c804 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for {@link Admin#deleteTopics(Collection)}.
  */
+@InterfaceAudience.Public
 public class DeleteTopicsOptions extends AbstractOptions<DeleteTopicsOptions> {
 
     private boolean retryOnQuotaViolation = true;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsResult.java
index eefdbdf9da1..6dfe10c44f7 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsResult.java
@@ -20,6 +20,7 @@ package org.apache.kafka.clients.admin;
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.TopicCollection;
 import org.apache.kafka.common.Uuid;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collection;
 import java.util.Map;
@@ -27,6 +28,7 @@ import java.util.Map;
 /**
  * The result of the {@link Admin#deleteTopics(Collection)} call.
  */
+@InterfaceAudience.Public
 public class DeleteTopicsResult {
     private final Map<Uuid, KafkaFuture<Void>> topicIdFutures;
     private final Map<String, KafkaFuture<Void>> nameFutures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeletedRecords.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeletedRecords.java
index 97d83a77758..db92cd49674 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeletedRecords.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeletedRecords.java
@@ -17,9 +17,12 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 /**
  * Represents information about deleted records
  */
+@InterfaceAudience.Public
 public class DeletedRecords {
 
     private final long lowWatermark;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsOptions.java
index f310b6bb73b..a81ed630665 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsOptions.java
@@ -18,10 +18,12 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.acl.AclBindingFilter;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 /**
  * Options for {@link Admin#describeAcls(AclBindingFilter)}.
  */
+@InterfaceAudience.Public
 public class DescribeAclsOptions extends AbstractOptions<DescribeAclsOptions> {
 
     /**
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsResult.java
index df86515d3ba..d6567a5fe08 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsResult.java
@@ -20,12 +20,14 @@ package org.apache.kafka.clients.admin;
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.acl.AclBinding;
 import org.apache.kafka.common.acl.AclBindingFilter;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collection;
 
 /**
  * The result of the {@link KafkaAdminClient#describeAcls(AclBindingFilter)} call.
  */
+@InterfaceAudience.Public
 public class DescribeAclsResult {
     private final KafkaFuture<Collection<AclBinding>> future;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsOptions.java
index 6422e27524e..95e1335077a 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for {@link Admin#describeClassicGroups(Collection, DescribeClassicGroupsOptions)}.
  */
+@InterfaceAudience.Public
 public class DescribeClassicGroupsOptions extends AbstractOptions<DescribeClassicGroupsOptions> {
     private boolean includeAuthorizedOperations;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsResult.java
index 22d0134569b..5bfc37d15b6 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsResult.java
@@ -18,6 +18,7 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collection;
 import java.util.HashMap;
@@ -27,6 +28,7 @@ import java.util.concurrent.ExecutionException;
 /**
  * The result of the {@link Admin#describeClassicGroups(Collection, DescribeClassicGroupsOptions)}} call.
  */
+@InterfaceAudience.Public
 public class DescribeClassicGroupsResult {
 
     private final Map<String, KafkaFuture<ClassicGroupDescription>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasOptions.java
index 9030378e222..6f5a11983b3 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasOptions.java
@@ -17,10 +17,12 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.quota.ClientQuotaFilter;
 
 /**
  * Options for {@link Admin#describeClientQuotas(ClientQuotaFilter, DescribeClientQuotasOptions)}.
  */
+@InterfaceAudience.Public
 public class DescribeClientQuotasOptions extends AbstractOptions<DescribeClientQuotasOptions> {
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasResult.java
index 192918a414f..ff7fc4ff2ac 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasResult.java
@@ -18,6 +18,7 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.quota.ClientQuotaEntity;
 import org.apache.kafka.common.quota.ClientQuotaFilter;
 
@@ -26,6 +27,7 @@ import java.util.Map;
 /**
  * The result of the {@link Admin#describeClientQuotas(ClientQuotaFilter, DescribeClientQuotasOptions)} call.
  */
+@InterfaceAudience.Public
 public class DescribeClientQuotasResult {
 
     private final KafkaFuture<Map<ClientQuotaEntity, Map<String, Double>>> entities;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterOptions.java
index 910b64fb428..36a23fd8a2f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterOptions.java
@@ -17,9 +17,12 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 /**
  * Options for {@link Admin#describeCluster()}.
  */
+@InterfaceAudience.Public
 public class DescribeClusterOptions extends AbstractOptions<DescribeClusterOptions> {
 
     private boolean includeAuthorizedOperations;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterResult.java
index 69782d5c9c3..125392c20af 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterResult.java
@@ -20,6 +20,7 @@ package org.apache.kafka.clients.admin;
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.Node;
 import org.apache.kafka.common.acl.AclOperation;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collection;
 import java.util.Set;
@@ -27,6 +28,7 @@ import java.util.Set;
 /**
  * The result of the {@link KafkaAdminClient#describeCluster()} call.
  */
+@InterfaceAudience.Public
 public class DescribeClusterResult {
     private final KafkaFuture<Collection<Node>> nodes;
     private final KafkaFuture<Node> controller;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsOptions.java
index 6586b1ff84d..335cff81d4f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for {@link Admin#describeConfigs(Collection)}.
  */
+@InterfaceAudience.Public
 public class DescribeConfigsOptions extends AbstractOptions<DescribeConfigsOptions> {
 
     private boolean includeSynonyms = false;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsResult.java
index 72cdaf098a3..fbff4d5663c 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsResult.java
@@ -18,6 +18,7 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.config.ConfigResource;
 
 import java.util.Collection;
@@ -28,6 +29,7 @@ import java.util.concurrent.ExecutionException;
 /**
  * The result of the {@link KafkaAdminClient#describeConfigs(Collection)} call.
  */
+@InterfaceAudience.Public
 public class DescribeConfigsResult {
 
     private final Map<ConfigResource, KafkaFuture<Config>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsOptions.java
index 574a8f07fa6..493f851cffb 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsOptions.java
@@ -17,12 +17,15 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for {@link Admin#describeConsumerGroups(Collection, DescribeConsumerGroupsOptions)}.
  * <p>
  */
+@InterfaceAudience.Public
 public class DescribeConsumerGroupsOptions extends AbstractOptions<DescribeConsumerGroupsOptions> {
     private boolean includeAuthorizedOperations;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsResult.java
index f01a6c7b814..29cecb661af 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsResult.java
@@ -18,6 +18,7 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collection;
 import java.util.HashMap;
@@ -27,6 +28,7 @@ import java.util.concurrent.ExecutionException;
 /**
  * The result of the {@link KafkaAdminClient#describeConsumerGroups(Collection, DescribeConsumerGroupsOptions)}} call.
  */
+@InterfaceAudience.Public
 public class DescribeConsumerGroupsResult {
 
     private final Map<String, KafkaFuture<ConsumerGroupDescription>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenOptions.java
index 52fad07eae6..5beafc0a2ca 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenOptions.java
@@ -17,6 +17,7 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.security.auth.KafkaPrincipal;
 
 import java.util.List;
@@ -24,6 +25,7 @@ import java.util.List;
 /**
  * Options for {@link Admin#describeDelegationToken(DescribeDelegationTokenOptions)}.
  */
+@InterfaceAudience.Public
 public class DescribeDelegationTokenOptions extends AbstractOptions<DescribeDelegationTokenOptions> {
     private List<KafkaPrincipal> owners;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenResult.java
index 01355c0385f..37537869e9f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenResult.java
@@ -18,6 +18,7 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.security.token.delegation.DelegationToken;
 
 import java.util.List;
@@ -25,6 +26,7 @@ import java.util.List;
 /**
  * The result of the {@link KafkaAdminClient#describeDelegationToken(DescribeDelegationTokenOptions)} call.
  */
+@InterfaceAudience.Public
 public class DescribeDelegationTokenResult {
     private final KafkaFuture<List<DelegationToken>> delegationTokens;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesOptions.java
index d364df61dc1..6c33b06884d 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesOptions.java
@@ -14,13 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.OptionalInt;
 
 /**
  * Options for {@link AdminClient#describeFeatures(DescribeFeaturesOptions)}.
  */
+@InterfaceAudience.Public
 public class DescribeFeaturesOptions extends AbstractOptions<DescribeFeaturesOptions> {
     private OptionalInt nodeId = OptionalInt.empty();
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesResult.java
index c48dc191430..2b8c3fa521e 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesResult.java
@@ -14,15 +14,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 /**
  * The result of the {@link Admin#describeFeatures(DescribeFeaturesOptions)} call.
  *
  * The API of this class is evolving, see {@link Admin} for details.
  */
+@InterfaceAudience.Public
 public class DescribeFeaturesResult {
 
     private final KafkaFuture<FeatureMetadata> future;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsOptions.java
index 41cbf3f6d9c..fd30968394a 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for {@link Admin#describeLogDirs(Collection)}
  */
+@InterfaceAudience.Public
 public class DescribeLogDirsOptions extends AbstractOptions<DescribeLogDirsOptions> {
 
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsResult.java
index cd2e37d7c86..999093027f7 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsResult.java
@@ -18,6 +18,7 @@
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collection;
 import java.util.HashMap;
@@ -27,6 +28,7 @@ import java.util.concurrent.ExecutionException;
 /**
  * The result of the {@link Admin#describeLogDirs(Collection)} call.
  */
+@InterfaceAudience.Public
 public class DescribeLogDirsResult {
     private final Map<Integer, KafkaFuture<Map<String, LogDirDescription>>> futures;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumOptions.java
index 8f54cc81f21..21262905911 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumOptions.java
@@ -14,11 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 /**
  * Options for {@link Admin#describeMetadataQuorum(DescribeMetadataQuorumOptions)}
  */
+@InterfaceAudience.Public
 public class DescribeMetadataQuorumOptions extends AbstractOptions<DescribeMetadataQuorumOptions> {
 
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumResult.java
index aa9bbe84ead..90209bb500c 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumResult.java
@@ -14,13 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 /**
  * The result of {@link Admin#describeMetadataQuorum(DescribeMetadataQuorumOptions)}
  */
+@InterfaceAudience.Public
 public class DescribeMetadataQuorumResult {
 
     private final KafkaFuture<QuorumInfo> quorumInfo;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersOptions.java
index 6ce4680f986..3eff322be28 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersOptions.java
@@ -14,8 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 import java.util.Objects;
 import java.util.OptionalInt;
@@ -23,6 +26,7 @@ import java.util.OptionalInt;
 /**
  * Options for {@link Admin#describeProducers(Collection)}.
  */
+@InterfaceAudience.Public
 public class DescribeProducersOptions extends AbstractOptions<DescribeProducersOptions> {
     private OptionalInt brokerId = OptionalInt.empty();
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersResult.java
index 597c59b8fff..d3e24f8127f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersResult.java
@@ -14,17 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaException;
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ExecutionException;
 
+@InterfaceAudience.Public
 public class DescribeProducersResult {
 
     private final Map<TopicPartition, KafkaFuture<PartitionProducerState>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsOptions.java
index 62b665658ad..d54488495bc 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsOptions.java
@@ -17,11 +17,14 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for {@link Admin#describeReplicaLogDirs(Collection)}.
  */
+@InterfaceAudience.Public
 public class DescribeReplicaLogDirsOptions extends AbstractOptions<DescribeReplicaLogDirsOptions> {
 
 }
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsResult.java
index 8e01bf80a91..045238c4747 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsResult.java
@@ -18,6 +18,7 @@ package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
 import org.apache.kafka.common.TopicPartitionReplica;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.requests.DescribeLogDirsResponse;
 
 import java.util.Collection;
@@ -28,6 +29,7 @@ import java.util.concurrent.ExecutionException;
 /**
  * The result of {@link Admin#describeReplicaLogDirs(Collection)}.
  */
+@InterfaceAudience.Public
 public class DescribeReplicaLogDirsResult {
     private final Map<TopicPartitionReplica, KafkaFuture<ReplicaLogDirInfo>> futures;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsOptions.java
index 47a8f008c44..b3571f3e98a 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsOptions.java
@@ -14,13 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
 import java.util.Collection;
 
 /**
  * Options for {@link Admin#describeShareGroups(Collection, DescribeShareGroupsOptions)}.
  */
+@InterfaceAudience.Public
 public class DescribeShareGroupsOptions extends AbstractOptions<DescribeShareGroupsOptions> {
     private boolean includeAuthorizedOperations;
 
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsResult.java
index 9ff0fcc610f..0b2de542ed5 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsResult.java
@@ -14,9 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+
 package org.apache.kafka.clients.admin;
 
 import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
 
 import java.util.Collection;
 import java.util.HashMap;
@@ -26,6 +28,7 @@ import java.util.concurrent.ExecutionException;
 /**
  * The result of the {@link KafkaAdminClient#describeShareGroups(Collection, DescribeShareGroupsOptions)}} call.
  */
+@InterfaceAudience.Public
 public class DescribeShareGroupsResult {
 
     private final Map<String, KafkaFuture<ShareGroupDescription>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeStreamsGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeStreamsGroupsOptions.java
index 65f11f8c362..1b75b71bb85 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeStreamsGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeStreamsGroupsOptions.java
@@ -17,6 +17,7 @@
 
 package org.apache.kafka.clients.admin;
 
+import org.apache.kafka.common.annotation.InterfaceAudience;
 import org.apache.kafka.common.annotation.InterfaceStability;
 
 import java.util.Collection;
... 17371 lines suppressed ...