diff --git a/archetype-validation-prober/PYTHON_COMPANION.md b/archetype-validation-prober/PYTHON_COMPANION.md
new file mode 100644
index 00000000..82fc5c3f
--- /dev/null
+++ b/archetype-validation-prober/PYTHON_COMPANION.md
@@ -0,0 +1,41 @@
+# Python Companion for the Archetype Gate
+
+This repository keeps the Java prober as the upstream reference, but the same
+idea can be implemented in a more compact and operationally friendly way with a
+Python companion.
+
+## What the companion adds
+
+- `Archetype` gate implemented as a total function in Python
+- `ValidationResult` and `ValidationError` as immutable value objects
+- `ClassifyingReceiver` without a builder or singleton boilerplate
+- `vertex_agent/` for ingestion, retrieval, and MCP exposure
+- real GCP resources:
+ - Pub/Sub topic and subscription
+ - BigQuery dataset and table
+ - Cloud Storage bucket for docs
+- smoke validation with OAuth/ADC only
+
+## Why this is useful
+
+The Java prober proves the gate behavior. The Python companion shows how the
+same architecture can be expanded into a full GCP-native workflow:
+
+1. validate deterministically before publish
+2. keep the retry path for transient failures only
+3. store knowledge in BigQuery
+4. expose the result through MCP for coding agents
+5. validate everything against real infrastructure
+
+## What was proven in practice
+
+- `uv run pytest` passed
+- `uv run python -m scripts.gcp_smoke` returned `smoke=ok`
+- Pub/Sub publish/pull round-trip succeeded
+- BigQuery insert/query succeeded
+
+## Security note
+
+No service account keys are stored in the repository. Real smoke tests use
+developer-owned OAuth/ADC credentials only.
+
diff --git a/archetype-validation-prober/README.md b/archetype-validation-prober/README.md
new file mode 100644
index 00000000..cb1ed8f3
--- /dev/null
+++ b/archetype-validation-prober/README.md
@@ -0,0 +1,116 @@
+# Archetype Validation Prober
+
+A reference implementation of an **archetype validation gate** in front of Cloud Pub/Sub.
+
+It demonstrates a single principle: **only what minimally fits the process should ever be
+enqueued.** Everything that is deterministically invalid is rejected *synchronously at the gate* and
+never published, so it can never bounce in the queue, never inflate the dead-letter store, and never
+burn engine resources being retried.
+
+The prober is provided as-is for demonstration purposes only, with no SLA. It is not meant to be run
+as part of a production or critical workload.
+
+## Why
+
+A common failure mode of asynchronous messaging is treating a transport `200`/ack as if it meant the
+message was *accepted*. It does not. A payload can be delivered and still be rejected on content, and
+a message that never returns a positive functional ack **bounces forever** (or is silently dropped at
+retention). At two messages that is noise; at two hundred thousand it is an outage.
+
+The fix is to separate the concerns and validate the deterministic ones **before** enqueuing:
+
+| Outcome | Nature | Where it is caught | Action |
+| --- | --- | --- | --- |
+| Conformant (functional ACK) | Success | — | Publish / `ack()` |
+| Not JSON / wrong type / missing field / bad enum / bad pattern | **Deterministic** | **At the gate (this prober)** | **Reject synchronously, never enqueue** |
+| Byte-different but semantically equal (charset / Unicode NFD) | False reject | At the gate (canonicalized) | Normalize to NFC, then accept |
+| Downstream down (timeout, 5xx, connection) | Transient | Consumer | `nack()` → retry with backoff → dead-letter |
+| State-dependent (unknown id, duplicate, business rule) | Unpredictable | Consumer | Route to quarantine / dead-letter |
+
+The gate handles the deterministic and false-reject rows. The consumer
+([`ClassifyingReceiver`](src/main/java/com/google/cloud/pubsub/archetype/ClassifyingReceiver.java))
+handles the rest and, crucially, **only lets transient failures ride the redelivery machinery**;
+functional rejects are `ack()`-ed and parked in quarantine so they never bounce.
+
+## Components
+
+- [`Archetype`](src/main/java/com/google/cloud/pubsub/archetype/Archetype.java) — the gate. Loads a
+ versioned JSON Schema (the *archetype*) and, cheapest check first, canonicalizes (charset + Unicode
+ NFC), then validates syntactically and structurally. Returns accept + canonical form, or reject +
+ machine-readable reason codes. Never throws on bad input.
+- [`ValidationResult`](src/main/java/com/google/cloud/pubsub/archetype/ValidationResult.java) — the
+ terminal outcome (`ACCEPTED` with canonical payload, or `REJECTED` with reasons).
+- [`ClassifyingReceiver`](src/main/java/com/google/cloud/pubsub/archetype/ClassifyingReceiver.java) —
+ a subscriber that classifies each processing failure (accepted / transient / functional reject)
+ and acts accordingly, keeping poison messages out of the retry loop.
+- [`ArchetypeValidationGateway`](src/main/java/com/google/cloud/pubsub/archetype/ArchetypeValidationGateway.java)
+ — the runnable entry point. With no arguments it runs a self-contained, offline demo of the gate
+ over representative payloads.
+- [`archetype.schema.json`](src/main/resources/archetype.schema.json) — the canonical contract used
+ by the demo. Replace it with your own to model your payload.
+
+## Build
+
+These instructions assume [Maven](https://maven.apache.org/) 3 and Java 8.
+
+```
+cd archetype-validation-prober
+mvn package
+```
+
+The resulting jar is at `target/pubsub-archetype-validation-prober.jar`.
+
+## Run (offline demo)
+
+```
+java -jar target/pubsub-archetype-validation-prober.jar
+```
+
+Expected output (one line per sample): the conformant and the NFD-normalized payloads are
+`ACCEPTED (enqueued)`; every deterministic failure is `REJECT` with its reason codes and is never
+enqueued.
+
+### Options
+
+| Property | Type | Default | Description |
+| --- | --- | --- | --- |
+| `--archetype` | String | bundled `archetype.schema.json` | Path to the JSON Schema archetype to validate against. |
+| `--charset` | String | `UTF-8` | Declared charset of incoming payloads, used for canonicalization. |
+| `--help` | flag | — | Print usage and exit. |
+
+## Test
+
+```
+mvn test
+```
+
+## Smoke test (real Pub/Sub or emulator)
+
+The smoke tests are intentionally off by default. They validate the end-to-end
+flow against either a local emulator or a real GCP project.
+
+### Emulator
+
+```bash
+export PUBSUB_EMULATOR_HOST=localhost:8085
+gcloud beta emulators pubsub start
+mvn verify -Psmoke
+```
+
+### Real GCP
+
+```bash
+export GOOGLE_CLOUD_PROJECT=portfolioadvanced-llm
+gcloud auth login
+gcloud auth application-default login
+mvn verify -Psmoke
+```
+
+The smoke suite covers:
+
+- valid payload round-trip
+- invalid payload never reaches publish
+- encoding rejection before JSON parsing
+
+For the higher-level GCP-native companion that demonstrates the same idea in
+Python, see [PYTHON_COMPANION.md](PYTHON_COMPANION.md).
diff --git a/archetype-validation-prober/pom.xml b/archetype-validation-prober/pom.xml
new file mode 100644
index 00000000..8b8d58c5
--- /dev/null
+++ b/archetype-validation-prober/pom.xml
@@ -0,0 +1,137 @@
+
An {@code Archetype} is the single, canonical, versioned contract that every incoming payload + * must satisfy before it is ever published to a topic. It performs, cheapest check first: + * + *
Whatever passes is guaranteed to at minimum fit the process: the downstream engine
+ * will not blow up on a missing field or a type mismatch. Whatever fails is rejected
+ * deterministically with reason codes and is never enqueued. This deliberately keeps the error
+ * store (dead-letter / quarantine) small: it only ever holds the failures that genuinely cannot be
+ * predicted at the gate (downstream outages, state-dependent business rejections).
+ */
+public final class Archetype {
+
+ private final JsonSchema schema;
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ /** Loads the archetype from a JSON Schema (Draft 7) stream. */
+ public Archetype(InputStream schemaStream) {
+ JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
+ this.schema = factory.getSchema(schemaStream);
+ }
+
+ /**
+ * Loads the archetype from a classpath resource (e.g. {@code "/archetype.schema.json"}).
+ * Convenience factory for tests and the offline demo.
+ */
+ public static Archetype fromResource(String resourcePath) {
+ InputStream stream = Archetype.class.getResourceAsStream(resourcePath);
+ if (stream == null) {
+ throw new IllegalArgumentException("Classpath resource not found: " + resourcePath);
+ }
+ return new Archetype(stream);
+ }
+
+ /**
+ * Validates and canonicalizes a raw payload at the gate.
+ *
+ * @param rawBytes the payload exactly as received on the wire
+ * @param declaredCharset the charset the emitter claims to have used
+ * @return {@link ValidationResult#accepted} with the canonical form, or
+ * {@link ValidationResult#rejected} with reason codes; never throws for invalid input.
+ */
+ public ValidationResult validate(byte[] rawBytes, Charset declaredCharset) {
+ // 1. Canonicalization: decode + normalize (Unicode NFC). Kills false rejects up front.
+ String canonical;
+ try {
+ CharsetDecoder decoder = declaredCharset.newDecoder();
+ decoder.onMalformedInput(CodingErrorAction.REPORT);
+ decoder.onUnmappableCharacter(CodingErrorAction.REPORT);
+ String decoded = decoder.decode(ByteBuffer.wrap(rawBytes)).toString();
+ canonical = Normalizer.normalize(decoded, Normalizer.Form.NFC);
+ } catch (CharacterCodingException | RuntimeException e) {
+ return ValidationResult.rejected(
+ single("ENCODING_UNDECODABLE: payload is not valid " + declaredCharset.name()));
+ }
+
+ // 2. Syntactic: is it parseable JSON?
+ JsonNode node;
+ try {
+ node = mapper.readTree(canonical);
+ } catch (IOException e) {
+ return ValidationResult.rejected(single("SYNTAX_NOT_JSON: " + e.getMessage()));
+ }
+ if (node == null || node.isMissingNode()) {
+ return ValidationResult.rejected(single("SYNTAX_EMPTY: payload contained no JSON value"));
+ }
+
+ // 3. Structural: does it satisfy the archetype schema?
+ Set It demonstrates the principle that only what minimally fits the process should ever be
+ * enqueued. Every payload is validated and canonicalized against a single, versioned archetype
+ * (JSON Schema) at the gate:
+ *
+ * Provided as-is for demonstration purposes only, with no SLA. Not meant to run as part of a
+ * production or critical workload. Run without arguments for a self-contained, offline demo of the
+ * gate over a set of representative payloads.
+ */
+public final class ArchetypeValidationGateway {
+
+ private static final Logger logger = Logger.getLogger(ArchetypeValidationGateway.class.getName());
+
+ public static final class Args {
+ @Parameter(
+ names = "--archetype",
+ description =
+ "Path to the JSON Schema archetype. Defaults to the bundled archetype.schema.json.")
+ private String archetypePath = null;
+
+ @Parameter(
+ names = "--charset",
+ description = "Declared charset of incoming payloads used for canonicalization.")
+ private String charset = "UTF-8";
+
+ @Parameter(names = "--help", help = true, description = "Print usage and exit.")
+ private boolean help = false;
+ }
+
+ public static void main(String[] argv) throws IOException {
+ Args args = new Args();
+ JCommander jc = JCommander.newBuilder().addObject(args).build();
+ jc.parse(argv);
+ if (args.help) {
+ jc.usage();
+ return;
+ }
+
+ Charset charset = Charset.forName(args.charset);
+ Archetype archetype = new Archetype(openArchetype(args.archetypePath));
+
+ logger.info("Archetype validation gate ready. Running offline demo over sample payloads.\n");
+ for (Map.Entry The redelivery machinery of Pub/Sub (nack → retry → dead-letter) is only for
+ * transient failures. Deterministic failures must never ride it: retrying them is pointless and, at
+ * scale, self-inflicts a redelivery storm. Hence the three-way decision:
+ *
+ * A payload is either {@link Status#ACCEPTED} (structurally conformant and therefore safe to
+ * publish, carrying its canonicalized form) or {@link Status#REJECTED} (deterministically invalid,
+ * carrying machine-readable reason codes). A rejected payload is never enqueued: retrying it would
+ * fail identically forever, so it is reported synchronously to the emitter instead.
+ */
+public final class ValidationResult {
+
+ /** Terminal classification of a payload at the gate. */
+ public enum Status {
+ ACCEPTED,
+ REJECTED
+ }
+
+ private final Status status;
+ private final String canonicalPayload;
+ private final List These tests are intentionally off by default. They run only under the {@code smoke}
+ * Maven profile ({@code mvn verify -Psmoke}) so they never execute in a standard unit build.
+ *
+ * Two modes are supported:
+ * Topic and subscription are created and deleted per-run; the test is hermetic and leaves no
+ * residue in the project.
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+public final class ClassifyingReceiver implements MessageReceiver {
+
+ private static final Logger logger = Logger.getLogger(ClassifyingReceiver.class.getName());
+
+ /** How a (simulated or real) downstream delivery ended. */
+ public enum DeliveryOutcome {
+ ACCEPTED,
+ TRANSIENT_FAILURE,
+ FUNCTIONAL_REJECT
+ }
+
+ /** Pluggable downstream. Real deployments wire this to the actual receiving system. */
+ public interface Downstream {
+ DeliveryOutcome deliver(PubsubMessage message);
+ }
+
+ private final Downstream downstream;
+ private final Publisher quarantinePublisher;
+
+ public ClassifyingReceiver(Downstream downstream, Publisher quarantinePublisher) {
+ this.downstream = downstream;
+ this.quarantinePublisher = quarantinePublisher;
+ }
+
+ @Override
+ public void receiveMessage(PubsubMessage message, AckReplyConsumer consumer) {
+ DeliveryOutcome outcome;
+ try {
+ outcome = downstream.deliver(message);
+ } catch (RuntimeException unexpected) {
+ // Unknown exceptions are treated as transient: better to retry than to silently drop.
+ logger.log(Level.WARNING, "Downstream threw; treating as transient", unexpected);
+ consumer.nack();
+ return;
+ }
+
+ switch (outcome) {
+ case ACCEPTED:
+ consumer.ack();
+ return;
+
+ case TRANSIENT_FAILURE:
+ // Retryable: let Pub/Sub redeliver with backoff (and eventually dead-letter it).
+ consumer.nack();
+ return;
+
+ case FUNCTIONAL_REJECT:
+ default:
+ // Deterministic: it will fail identically forever. Remove it from the retry loop
+ // (ack) and park it in quarantine with its reason for a human/spec decision.
+ quarantine(message);
+ consumer.ack();
+ return;
+ }
+ }
+
+ private void quarantine(PubsubMessage original) {
+ PubsubMessage tagged =
+ PubsubMessage.newBuilder()
+ .setData(original.getData())
+ .putAllAttributes(original.getAttributesMap())
+ .putAttributes("quarantine-reason", "FUNCTIONAL_REJECT")
+ .putAttributes("original-message-id", original.getMessageId())
+ .build();
+ try {
+ quarantinePublisher.publish(tagged).get();
+ } catch (Exception e) {
+ // If we cannot even quarantine, fall back to nack so the message is not lost.
+ logger.log(Level.SEVERE, "Failed to publish to quarantine topic", e);
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** Convenience for callers that hold raw bytes rather than a PubsubMessage. */
+ static PubsubMessage message(String data) {
+ return PubsubMessage.newBuilder().setData(ByteString.copyFromUtf8(data)).build();
+ }
+}
diff --git a/archetype-validation-prober/src/main/java/com/google/cloud/pubsub/archetype/ValidationResult.java b/archetype-validation-prober/src/main/java/com/google/cloud/pubsub/archetype/ValidationResult.java
new file mode 100644
index 00000000..0d97cf2e
--- /dev/null
+++ b/archetype-validation-prober/src/main/java/com/google/cloud/pubsub/archetype/ValidationResult.java
@@ -0,0 +1,81 @@
+// Copyright 2024 Google Inc.
+//
+// Licensed 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 com.google.cloud.pubsub.archetype;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Outcome of validating an incoming payload against the archetype at the ingestion gate.
+ *
+ *
+ * Downstream outcome Nature Action
+ * Accepted (functional ACK) Success {@code ack()} — done
+ * Transient (timeout, 5xx, connection) Retryable
+ * {@code nack()} — redeliver with backoff, eventually dead-letter
+ * Functional reject (contract / data) Deterministic
+ * {@code ack()} + republish to quarantine — never retried
+ *
+ *
+ * What these tests prove
+ *
+ *
+ */
+public class ArchetypeGateSmokeIT {
+
+ // ── Environment resolution ──────────────────────────────────────────────
+
+ private static final String ENV_PROJECT = System.getenv("GOOGLE_CLOUD_PROJECT");
+ private static final String ENV_EMULATOR = System.getenv("PUBSUB_EMULATOR_HOST");
+ private static final String TOPIC_ID = "archetype-smoke-" + UUID.randomUUID();
+ private static final String SUBSCRIPTION_ID = TOPIC_ID + "-sub";
+
+ private static final String VALID_PAYLOAD =
+ "{\"event_type\":\"llm_request\",\"payload\":{\"prompt\":\"smoke\"},\"version\":\"1.0\"}";
+
+ private TopicAdminClient topicAdmin;
+ private SubscriptionAdminClient subscriptionAdmin;
+ private Publisher publisher;
+ private GrpcSubscriberStub subscriberStub;
+ private ManagedChannel channel;
+ private Archetype gate;
+
+ // ── Setup / teardown ────────────────────────────────────────────────────
+
+ @Before
+ public void setUp() throws Exception {
+ // Skip if neither emulator nor real project is configured.
+ Assume.assumeTrue(
+ "Set PUBSUB_EMULATOR_HOST or GOOGLE_CLOUD_PROJECT to run smoke tests",
+ ENV_EMULATOR != null || ENV_PROJECT != null);
+
+ gate = Archetype.fromResource("/archetype.schema.json");
+
+ if (ENV_EMULATOR != null) {
+ channel = ManagedChannelBuilder.forTarget(ENV_EMULATOR).usePlaintext().build();
+ TransportChannelProvider channelProvider =
+ FixedTransportChannelProvider.create(GrpcTransportChannel.create(channel));
+ NoCredentialsProvider credentialsProvider = NoCredentialsProvider.create();
+
+ topicAdmin = TopicAdminClient.create(
+ com.google.cloud.pubsub.v1.TopicAdminSettings.newBuilder()
+ .setTransportChannelProvider(channelProvider)
+ .setCredentialsProvider(credentialsProvider)
+ .build());
+ subscriptionAdmin = SubscriptionAdminClient.create(
+ com.google.cloud.pubsub.v1.SubscriptionAdminSettings.newBuilder()
+ .setTransportChannelProvider(channelProvider)
+ .setCredentialsProvider(credentialsProvider)
+ .build());
+
+ publisher = Publisher.newBuilder(ProjectTopicName.of("smoke-project", TOPIC_ID))
+ .setChannelProvider(channelProvider)
+ .setCredentialsProvider(credentialsProvider)
+ .build();
+
+ SubscriberStubSettings stubSettings = SubscriberStubSettings.newBuilder()
+ .setTransportChannelProvider(channelProvider)
+ .setCredentialsProvider(credentialsProvider)
+ .build();
+ subscriberStub = GrpcSubscriberStub.create(stubSettings);
+
+ topicAdmin.createTopic(ProjectTopicName.of("smoke-project", TOPIC_ID));
+ subscriptionAdmin.createSubscription(
+ ProjectSubscriptionName.of("smoke-project", SUBSCRIPTION_ID),
+ ProjectTopicName.of("smoke-project", TOPIC_ID),
+ PushConfig.getDefaultInstance(), 10);
+ } else {
+ // Real GCP — use ADC
+ topicAdmin = TopicAdminClient.create();
+ subscriptionAdmin = SubscriptionAdminClient.create();
+ publisher = Publisher.newBuilder(ProjectTopicName.of(ENV_PROJECT, TOPIC_ID)).build();
+ subscriberStub = GrpcSubscriberStub.create(SubscriberStubSettings.newBuilder().build());
+
+ topicAdmin.createTopic(ProjectTopicName.of(ENV_PROJECT, TOPIC_ID));
+ subscriptionAdmin.createSubscription(
+ ProjectSubscriptionName.of(ENV_PROJECT, SUBSCRIPTION_ID),
+ ProjectTopicName.of(ENV_PROJECT, TOPIC_ID),
+ PushConfig.getDefaultInstance(), 10);
+ }
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (publisher != null) publisher.shutdown();
+ String project = ENV_EMULATOR != null ? "smoke-project" : ENV_PROJECT;
+ if (subscriptionAdmin != null) {
+ try { subscriptionAdmin.deleteSubscription(
+ ProjectSubscriptionName.of(project, SUBSCRIPTION_ID)); } catch (Exception ignored) {}
+ subscriptionAdmin.close();
+ }
+ if (topicAdmin != null) {
+ try { topicAdmin.deleteTopic(ProjectTopicName.of(project, TOPIC_ID)); } catch (Exception ignored) {}
+ topicAdmin.close();
+ }
+ if (subscriberStub != null) subscriberStub.close();
+ if (channel != null) channel.shutdownNow();
+ }
+
+ // ── Tests ───────────────────────────────────────────────────────────────
+
+ /**
+ * A conformant payload passes the gate, is published, and is pulled back byte-identical.
+ * Proves round-trip integrity through the real (or emulated) Pub/Sub transport.
+ */
+ @Test
+ public void validPayload_passesGateAndRoundTrips() throws Exception {
+ byte[] raw = VALID_PAYLOAD.getBytes(StandardCharsets.UTF_8);
+ ValidationResult result = gate.validate(raw, StandardCharsets.UTF_8);
+ assertTrue("Gate should accept a conformant payload", result.isAccepted());
+
+ // Publish the canonical form
+ String canonical = result.getCanonical();
+ publisher.publish(PubsubMessage.newBuilder()
+ .setData(ByteString.copyFromUtf8(canonical))
+ .build()).get(10, TimeUnit.SECONDS);
+
+ // Pull back and verify byte equality
+ String project = ENV_EMULATOR != null ? "smoke-project" : ENV_PROJECT;
+ PullResponse pullResponse = subscriberStub.pullCallable().call(
+ PullRequest.newBuilder()
+ .setSubscription(ProjectSubscriptionName.of(project, SUBSCRIPTION_ID).toString())
+ .setMaxMessages(1)
+ .build());
+
+ assertFalse("Expected at least one message", pullResponse.getReceivedMessagesList().isEmpty());
+ String pulled = pullResponse.getReceivedMessages(0).getMessage().getData().toStringUtf8();
+ assertEquals("Round-trip payload must be byte-identical to canonical form", canonical, pulled);
+ }
+
+ /**
+ * An invalid payload (schema violation) is rejected by the gate and never reaches publish().
+ * Verifies the gate holds the line against a real topic — the topic should remain empty.
+ */
+ @Test
+ public void invalidPayload_rejectedAtGate_neverPublished() throws Exception {
+ byte[] badPayload = "{\"event_type\":\"unknown\",\"payload\":{}}".getBytes(StandardCharsets.UTF_8);
+ ValidationResult result = gate.validate(badPayload, StandardCharsets.UTF_8);
+
+ assertFalse("Gate should reject a payload with invalid enum and missing required field",
+ result.isAccepted());
+ assertFalse("Rejection reasons must be non-empty", result.getRejectionReasons().isEmpty());
+
+ // Do NOT call publisher.publish() — verifying the caller respects the rejection.
+ // Pull from the subscription: it must be empty.
+ String project = ENV_EMULATOR != null ? "smoke-project" : ENV_PROJECT;
+ PullResponse pullResponse = subscriberStub.pullCallable().call(
+ PullRequest.newBuilder()
+ .setSubscription(ProjectSubscriptionName.of(project, SUBSCRIPTION_ID).toString())
+ .setMaxMessages(1)
+ .build());
+
+ assertTrue("Topic must be empty — invalid payload must never reach publish()",
+ pullResponse.getReceivedMessagesList().isEmpty());
+ }
+
+ /**
+ * A payload with invalid UTF-8 bytes is rejected at the encoding stage,
+ * before any JSON parsing or schema validation is attempted.
+ */
+ @Test
+ public void encodingError_rejectedBeforeJsonParsing() {
+ byte[] badBytes = new byte[]{(byte) 0xFF, (byte) 0xFE, 0x7B, 0x7D}; // invalid UTF-8 + {}
+ ValidationResult result = gate.validate(badBytes, StandardCharsets.UTF_8);
+
+ assertFalse("Encoding error must be rejected", result.isAccepted());
+ assertTrue("Rejection reason must mention encoding",
+ result.getRejectionReasons().stream()
+ .anyMatch(r -> r.contains("ENCODING_UNDECODABLE")));
+ }
+}