diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd08e9a..aa06a3e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,13 @@ The envelope wire format is versioned separately by `meta.schema_version`
## [Unreleased]
+## [1.14.0] - 2026-10-26
+
+### Added
+- `PoisonRecord.build(body, originalQueue, attempts, error, exception)` (ADR-0035): builds the poison `dead_letter`
+ block itself (`reason`, `error`, `exception`, `failed_at`, `original_queue`, `attempts`, `lang`) and delegates to
+ `build(body, deadLetter)`, so transports no longer hand-build it.
+
## [1.13.0] - 2026-10-24
### Added
diff --git a/pom.xml b/pom.xml
index 3cff2b6..7c50ccc 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
com.babelqueue
babelqueue-core
- 1.13.0
+ 1.14.0
jar
BabelQueue Core
diff --git a/src/main/java/com/babelqueue/PoisonRecord.java b/src/main/java/com/babelqueue/PoisonRecord.java
index 7c61220..7c3082e 100644
--- a/src/main/java/com/babelqueue/PoisonRecord.java
+++ b/src/main/java/com/babelqueue/PoisonRecord.java
@@ -5,6 +5,8 @@
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
/**
* Builds the DLQ record of a poison body (ADR-0035, error-handling.md section 6).
@@ -16,6 +18,10 @@
* "dead_letter": ...}}: {@code raw} is the body as plain text when it is valid UTF-8, otherwise the standard padded
* base64 of the exact bytes with {@code "raw_encoding": "base64"}. U+FFFD substitution never happens.
*
+ *
Transports should call {@link #build(byte[], String, int, String, String)}: it assembles the ADR-0035
+ * {@code dead_letter} block itself (so no transport hand-builds it) and delegates here. The two-argument
+ * {@link #build(byte[], String)} stays for callers that already hold the block JSON.
+ *
*
The strict scan is iterative (no recursion), so a body nested thousands of levels deep cannot overflow the stack.
*/
public final class PoisonRecord {
@@ -23,6 +29,35 @@ public final class PoisonRecord {
private PoisonRecord() {
}
+ /**
+ * Builds the DLQ record of a poison body, assembling the ADR-0035 {@code dead_letter} block itself. Transports
+ * should use this instead of hand-building the block.
+ *
+ *
The block members are written in this order: {@code reason} ({@code "poison"}), {@code error},
+ * {@code exception}, {@code failed_at} (epoch milliseconds, taken now), {@code original_queue}, {@code attempts}
+ * and {@code lang} ({@link EnvelopeCodec#SOURCE_LANG}). A {@code null} {@code error} or {@code exception} is
+ * written as JSON {@code null}, not omitted. The block is then spliced or wrapped by
+ * {@link #build(byte[], String)}.
+ *
+ * @param body the exact record value bytes
+ * @param originalQueue the queue the body was consumed from
+ * @param attempts informational delivery attempts so far
+ * @param error the failure message, or {@code null}
+ * @param exception the failure type name, or {@code null}
+ * @return the DLQ record JSON text
+ */
+ public static String build(byte[] body, String originalQueue, int attempts, String error, String exception) {
+ Map block = new LinkedHashMap<>();
+ block.put("reason", "poison");
+ block.put("error", error);
+ block.put("exception", exception);
+ block.put("failed_at", System.currentTimeMillis());
+ block.put("original_queue", originalQueue);
+ block.put("attempts", attempts);
+ block.put("lang", EnvelopeCodec.SOURCE_LANG);
+ return build(body, JsonValues.encode(block));
+ }
+
/**
* @param body the exact record value bytes
* @param deadLetter the JSON text of the {@code dead_letter} block: exactly one strict JSON object, spliced into
diff --git a/src/test/java/com/babelqueue/PoisonRecordTest.java b/src/test/java/com/babelqueue/PoisonRecordTest.java
new file mode 100644
index 0000000..f1c4f96
--- /dev/null
+++ b/src/test/java/com/babelqueue/PoisonRecordTest.java
@@ -0,0 +1,129 @@
+package com.babelqueue;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class PoisonRecordTest {
+
+ private static final List BLOCK_ORDER =
+ List.of("reason", "error", "exception", "failed_at", "original_queue", "attempts", "lang");
+
+ private static byte[] bytes(String s) {
+ return s.getBytes(StandardCharsets.UTF_8);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map parseObject(String json) {
+ return (Map) assertInstanceOf(Map.class, Json.parse(json));
+ }
+
+ /** Rebuilds the block by hand with the failed_at taken from the produced record, then compares bytes. */
+ private static void assertBytesEqualToManualBlock(byte[] body, String queue, int attempts, String error,
+ String exception) {
+ long before = System.currentTimeMillis();
+ String actual = PoisonRecord.build(body, queue, attempts, error, exception);
+ long after = System.currentTimeMillis();
+
+ Map dl = deadLetterOf(actual);
+ Object failedAt = dl.get("failed_at");
+ long ts = assertInstanceOf(Number.class, failedAt).longValue();
+ assertTrue(ts >= before && ts <= after, "failed_at is epoch ms taken during the call");
+
+ Map block = new LinkedHashMap<>();
+ block.put("reason", "poison");
+ block.put("error", error);
+ block.put("exception", exception);
+ block.put("failed_at", ts);
+ block.put("original_queue", queue);
+ block.put("attempts", attempts);
+ block.put("lang", EnvelopeCodec.SOURCE_LANG);
+ assertEquals(PoisonRecord.build(body, JsonValues.encode(block)), actual);
+ }
+
+ private static Map deadLetterOf(String record) {
+ Object dl = parseObject(record).get("dead_letter");
+ @SuppressWarnings("unchecked")
+ Map map = (Map) assertInstanceOf(Map.class, dl);
+ return map;
+ }
+
+ @Test
+ void overloadEqualsManuallyBuiltBlock() {
+ assertBytesEqualToManualBlock(bytes("{\"a\":1}"), "orders", 3, "boom", "java.lang.IllegalStateException");
+ }
+
+ @Test
+ void blockHasCanonicalOrderAndTypes() {
+ String record = PoisonRecord.build(bytes("{\"a\":1}"), "orders", 2, "boom", "X");
+ Map dl = deadLetterOf(record);
+ assertEquals(BLOCK_ORDER, new ArrayList<>(dl.keySet()));
+ assertEquals("poison", dl.get("reason"));
+ assertEquals("boom", dl.get("error"));
+ assertEquals("X", dl.get("exception"));
+ assertEquals("orders", dl.get("original_queue"));
+ assertEquals(2L, ((Number) dl.get("attempts")).longValue());
+ assertEquals("java", dl.get("lang"));
+ assertTrue(dl.get("failed_at") instanceof Number);
+ assertTrue(((Number) dl.get("failed_at")).longValue() > 1_600_000_000_000L);
+ }
+
+ @Test
+ void nullErrorAndExceptionAreWrittenAsNull() {
+ String record = PoisonRecord.build(bytes("{\"a\":1}"), "q", 0, null, null);
+ Map dl = deadLetterOf(record);
+ assertEquals(BLOCK_ORDER, new ArrayList<>(dl.keySet()));
+ assertTrue(dl.containsKey("error"));
+ assertTrue(dl.containsKey("exception"));
+ assertNull(dl.get("error"));
+ assertNull(dl.get("exception"));
+ assertTrue(record.contains("\"error\":null,\"exception\":null"));
+ assertBytesEqualToManualBlock(bytes("{\"a\":1}"), "q", 0, null, null);
+ }
+
+ @Test
+ void zeroAttempts() {
+ String record = PoisonRecord.build(bytes("{}"), "q", 0, "e", "E");
+ assertEquals(0L, ((Number) deadLetterOf(record).get("attempts")).longValue());
+ assertBytesEqualToManualBlock(bytes("{}"), "q", 0, "e", "E");
+ }
+
+ @Test
+ void invalidUtf8TakesBase64Branch() {
+ byte[] body = {(byte) 0xFF, (byte) 0xFE, 0x41};
+ String record = PoisonRecord.build(body, "q", 1, "bad", "E");
+ Map parsed = parseObject(record);
+ assertEquals(Base64.getEncoder().encodeToString(body), parsed.get("raw"));
+ assertEquals("base64", parsed.get("raw_encoding"));
+ assertEquals(BLOCK_ORDER, new ArrayList<>(deadLetterOf(record).keySet()));
+ assertBytesEqualToManualBlock(body, "q", 1, "bad", "E");
+ }
+
+ @Test
+ void spliceableObjectIsSplicedAtByteLevel() {
+ String body = "{\"n\":1e400,\"big\":18446744073709551616,\"n\":2}";
+ String record = PoisonRecord.build(bytes(body), "q", 4, "e", "E");
+ assertTrue(record.startsWith(body.substring(0, body.length() - 1) + ",\"dead_letter\":{"), record);
+ assertTrue(record.endsWith("}}"));
+ assertBytesEqualToManualBlock(bytes(body), "q", 4, "e", "E");
+ }
+
+ @Test
+ void nonObjectBodyIsWrappedAsRawText() {
+ String record = PoisonRecord.build(bytes("not json"), "q", 1, "e", "E");
+ Map parsed = parseObject(record);
+ assertEquals("not json", parsed.get("raw"));
+ assertFalse(parsed.containsKey("raw_encoding"));
+ assertBytesEqualToManualBlock(bytes("not json"), "q", 1, "e", "E");
+ }
+}