Skip to content

Commit 7c43958

Browse files
committed
feat: preserve unknown envelope keys, drop forbidden keys on decode (1.8.0)
Envelope and Meta gain an extras component so unknown top-level and meta keys survive decode/re-encode, including the retry, DLQ and redrive paths. The five forbidden keys are dropped on decode with a warning instead of being captured. Payload minLength now counts Unicode code points. Adds conformance runners for the roundtrip, data_shape, forbidden_keys and payload_schema_unicode sections and a dependabot configuration.
1 parent d49d78a commit 7c43958

11 files changed

Lines changed: 567 additions & 20 deletions

File tree

‎.github/dependabot.yml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: "maven"
4+
directory: "/"
5+
schedule:
6+
interval: "weekly"
7+
- package-ecosystem: "github-actions"
8+
directory: "/"
9+
schedule:
10+
interval: "weekly"

‎CHANGELOG.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,39 @@ The envelope wire format is versioned separately by `meta.schema_version`
99

1010
## [Unreleased]
1111

12+
## [1.8.0] - 2026-10-01
13+
14+
MINOR release: additive public API (`extras`, `withAttempts`, `withDeadLetter`, a new
15+
`decode` overload). Note that `Envelope` and `Meta` gain a record component, so a Java 21
16+
record *deconstruction pattern* listing the old component count (`case Envelope(var a, …)`)
17+
must add the `extras` slot; `new Envelope(...)` / `new Meta(...)` calls with the previous
18+
argument counts keep compiling.
19+
20+
### Fixed
21+
- **Unknown keys are preserved (message-envelope.md §4/§8, GR-5).** `Envelope` and `Meta`
22+
gain an `extras` component (`Map<String,Object>`, insertion-ordered, unmodifiable, never
23+
`null`): `EnvelopeCodec.decode` collects unknown top-level and `meta` keys into it, and
24+
`encode` writes the known fields in their canonical order first, then the extras. Every
25+
re-emit path carries them: retry (`Envelope.withAttempts`), DLQ (`DeadLetters.annotate`)
26+
and redrive (`Redrive.reset`); the outbox relay already publishes the stored bytes verbatim.
27+
Previously a decode → re-encode hop silently dropped them.
28+
- **Forbidden keys (§10, K-15).** `timestamp`, `meta.max_retries`, `meta.attempts`,
29+
`meta.source` and `meta.ts` are never captured into extras and never encoded. Decode does
30+
not reject them: it drops them and emits a warning naming the JSON pointer (e.g.
31+
`/meta/attempts`) — via `System.Logger` at `WARNING`, or to the callback of the new
32+
`EnvelopeCodec.decode(String, Consumer<String>)` overload.
33+
- **`minLength` counts Unicode code points** (ADR-0024), not UTF-16 code units — an emoji is
34+
one character, matching every other SDK's payload validator.
35+
36+
### Added
37+
- Additive API: `Envelope(..., extras)` / `Meta(..., extras)` canonical constructors (the
38+
previous 6-arg / 5-arg constructors remain and yield empty extras),
39+
`Envelope.withAttempts(int)`, `Envelope.withDeadLetter(DeadLetter)`,
40+
`EnvelopeCodec.decode(String, Consumer<String>)`.
41+
- Conformance runners for the `roundtrip`, `data_shape`, `forbidden_keys` and
42+
`payload_schema_unicode` manifest sections (none may be skipped).
43+
- `.github/dependabot.yml` (Maven + GitHub Actions, weekly).
44+
1245
## [1.7.0] - 2026-06-21
1346

1447
### Added

‎pom.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>com.babelqueue</groupId>
88
<artifactId>babelqueue-core</artifactId>
9-
<version>1.7.0</version>
9+
<version>1.8.0</version>
1010
<packaging>jar</packaging>
1111

1212
<name>BabelQueue Core</name>

‎src/main/java/com/babelqueue/DeadLetters.java‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,7 @@ public static Envelope annotate(
3737
attempts,
3838
EnvelopeCodec.SOURCE_LANG);
3939

40-
return new Envelope(
41-
envelope.job(),
42-
envelope.traceId(),
43-
envelope.data(),
44-
envelope.meta(),
45-
envelope.attempts(),
46-
deadLetter);
40+
// withDeadLetter keeps every other component, including unknown top-level/meta keys.
41+
return envelope.withDeadLetter(deadLetter);
4742
}
4843
}

‎src/main/java/com/babelqueue/Envelope.java‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.babelqueue;
22

3+
import java.util.Collections;
4+
import java.util.LinkedHashMap;
35
import java.util.Map;
46

57
/**
@@ -12,18 +14,57 @@
1214
* {@link EnvelopeCodec#decode}. The record is immutable; {@link DeadLetters}
1315
* returns a copy with a {@link DeadLetter} attached.
1416
*
17+
* <p>Top-level keys this SDK does not know (a newer producer's additions) are kept in
18+
* {@link #extras()} — in their original order — so a decode → re-emit chain (retry, DLQ,
19+
* redrive) forwards them unchanged (message-envelope.md §8: consumers MUST tolerate unknown
20+
* keys). Forbidden keys (§10) are never captured there and never encoded.
21+
*
1522
* @param job the message URN (never a class name)
1623
* @param traceId correlation id, preserved across every hop
1724
* @param data the pure-JSON payload
1825
* @param meta the immutable metadata block
1926
* @param attempts the top-level transport retry counter
2027
* @param deadLetter the dead-letter block, or {@code null} until dead-lettered
28+
* @param extras unknown top-level keys, insertion-ordered and unmodifiable (never {@code null})
2129
*/
2230
public record Envelope(
2331
String job,
2432
String traceId,
2533
Map<String, Object> data,
2634
Meta meta,
2735
int attempts,
28-
DeadLetter deadLetter
29-
) {}
36+
DeadLetter deadLetter,
37+
Map<String, Object> extras
38+
) {
39+
40+
/** Normalises {@code extras} to an unmodifiable, insertion-ordered copy ({@code null} → empty). */
41+
public Envelope {
42+
extras = extras == null || extras.isEmpty()
43+
? Map.of()
44+
: Collections.unmodifiableMap(new LinkedHashMap<>(extras));
45+
}
46+
47+
/** An envelope with no unknown top-level keys (the pre-1.8.0 shape). */
48+
public Envelope(
49+
String job,
50+
String traceId,
51+
Map<String, Object> data,
52+
Meta meta,
53+
int attempts,
54+
DeadLetter deadLetter) {
55+
this(job, traceId, data, meta, attempts, deadLetter, null);
56+
}
57+
58+
/**
59+
* A copy with {@code attempts} replaced — the retry/release path. Everything else,
60+
* including the {@code meta} and top-level {@link #extras()}, is carried over unchanged.
61+
*/
62+
public Envelope withAttempts(int attempts) {
63+
return new Envelope(job, traceId, data, meta, attempts, deadLetter, extras);
64+
}
65+
66+
/** A copy with the {@code dead_letter} block replaced ({@code null} removes it); extras are kept. */
67+
public Envelope withDeadLetter(DeadLetter deadLetter) {
68+
return new Envelope(job, traceId, data, meta, attempts, deadLetter, extras);
69+
}
70+
}

‎src/main/java/com/babelqueue/EnvelopeCodec.java‎

Lines changed: 85 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
import java.util.LinkedHashMap;
44
import java.util.Map;
5+
import java.util.Objects;
6+
import java.util.Set;
57
import java.util.UUID;
8+
import java.util.function.Consumer;
69

710
/**
811
* Builds, encodes and decodes the canonical BabelQueue envelope — the single Java
@@ -21,6 +24,23 @@ public final class EnvelopeCodec {
2124
/** The value stamped into {@code meta.lang} for envelopes produced here. */
2225
public static final String SOURCE_LANG = "java";
2326

27+
/** Top-level keys the codec maps to {@link Envelope} components ({@code urn} is the inbound alias of {@code job}). */
28+
private static final Set<String> KNOWN_TOP_LEVEL =
29+
Set.of("job", "urn", "trace_id", "data", "meta", "attempts", "dead_letter");
30+
31+
/** {@code meta} keys the codec maps to {@link Meta} components. */
32+
private static final Set<String> KNOWN_META =
33+
Set.of("id", "queue", "lang", "schema_version", "created_at");
34+
35+
/** Forbidden top-level keys (message-envelope.md §10) — never captured, never encoded. */
36+
private static final Set<String> FORBIDDEN_TOP_LEVEL = Set.of("timestamp");
37+
38+
/** Forbidden {@code meta} keys (message-envelope.md §10) — never captured, never encoded. */
39+
private static final Set<String> FORBIDDEN_META =
40+
Set.of("max_retries", "attempts", "source", "ts");
41+
42+
private static final System.Logger LOGGER = System.getLogger(EnvelopeCodec.class.getName());
43+
2444
private EnvelopeCodec() {}
2545

2646
/** Build the canonical envelope for a {@code (urn, data)} pair on the default queue. */
@@ -79,7 +99,10 @@ public static Envelope fromMessage(PolyglotMessage message, String queue) {
7999

80100
/**
81101
* Encode the envelope as compact UTF-8 JSON. Slashes and non-ASCII are left
82-
* unescaped, matching the other SDK cores; the field order is canonical.
102+
* unescaped, matching the other SDK cores; the field order is canonical. Known
103+
* fields are written first in their canonical order, followed by the unknown keys
104+
* kept in {@link Envelope#extras()} / {@link Meta#extras()} in their original order.
105+
* Forbidden keys (§10) are never written, even if a caller put them into extras.
83106
*/
84107
public static String encode(Envelope envelope) {
85108
Map<String, Object> root = new LinkedHashMap<>();
@@ -95,6 +118,7 @@ public static String encode(Envelope envelope) {
95118
metaMap.put("lang", meta.lang());
96119
metaMap.put("schema_version", meta.schemaVersion());
97120
metaMap.put("created_at", meta.createdAt());
121+
appendExtras(metaMap, meta.extras(), KNOWN_META, FORBIDDEN_META);
98122
}
99123
root.put("meta", metaMap);
100124
root.put("attempts", envelope.attempts());
@@ -112,16 +136,46 @@ public static String encode(Envelope envelope) {
112136
root.put("dead_letter", dlMap);
113137
}
114138

139+
appendExtras(root, envelope.extras(), KNOWN_TOP_LEVEL, FORBIDDEN_TOP_LEVEL);
115140
return Json.write(root);
116141
}
117142

143+
private static void appendExtras(
144+
Map<String, Object> target, Map<String, Object> extras, Set<String> known, Set<String> forbidden) {
145+
if (extras == null) {
146+
return;
147+
}
148+
for (Map.Entry<String, Object> entry : extras.entrySet()) {
149+
String key = entry.getKey();
150+
if (key == null || known.contains(key) || forbidden.contains(key) || target.containsKey(key)) {
151+
continue;
152+
}
153+
target.put(key, entry.getValue());
154+
}
155+
}
156+
118157
/**
119158
* Parse a raw JSON body into an {@link Envelope}. Malformed or non-object input
120159
* yields an empty envelope (so {@link #accepts} returns {@code false}); the
121160
* {@code urn} inbound alias is resolved into {@code job}. Does not validate the
122161
* contents — call {@link #accepts} first.
162+
*
163+
* <p>Unknown top-level and {@code meta} keys are preserved in the envelope's
164+
* extras. Forbidden keys (message-envelope.md §10) are dropped and a warning is
165+
* logged via {@link System.Logger} at {@code WARNING} level; use
166+
* {@link #decode(String, Consumer)} to receive the warnings directly.
123167
*/
124168
public static Envelope decode(String raw) {
169+
return decode(raw, warning -> LOGGER.log(System.Logger.Level.WARNING, warning));
170+
}
171+
172+
/**
173+
* Same as {@link #decode(String)}, but each warning — one per forbidden key found
174+
* and dropped, naming its JSON pointer (e.g. {@code /meta/attempts}) — is passed to
175+
* {@code onWarning} instead of the logger. Forbidden keys do not reject the message.
176+
*/
177+
public static Envelope decode(String raw, Consumer<String> onWarning) {
178+
Objects.requireNonNull(onWarning, "onWarning");
125179
Object parsed;
126180
try {
127181
parsed = Json.parse(raw);
@@ -140,13 +194,29 @@ public static Envelope decode(String raw) {
140194
}
141195
}
142196

197+
Map<String, Object> extras = new LinkedHashMap<>();
198+
for (Map.Entry<?, ?> entry : map.entrySet()) {
199+
String key = String.valueOf(entry.getKey());
200+
if (FORBIDDEN_TOP_LEVEL.contains(key)) {
201+
onWarning.accept(forbiddenWarning("/" + key));
202+
} else if (!KNOWN_TOP_LEVEL.contains(key)) {
203+
extras.put(key, entry.getValue());
204+
}
205+
}
206+
143207
return new Envelope(
144208
job,
145209
asString(map.get("trace_id")),
146210
asMap(map.get("data")),
147-
parseMeta(map.get("meta")),
211+
parseMeta(map.get("meta"), onWarning),
148212
asInt(map.get("attempts"), 0),
149-
parseDeadLetter(map.get("dead_letter")));
213+
parseDeadLetter(map.get("dead_letter")),
214+
extras);
215+
}
216+
217+
private static String forbiddenWarning(String pointer) {
218+
return "BabelQueue: dropped forbidden envelope key " + pointer
219+
+ " (message-envelope.md §10); it will not be re-emitted";
150220
}
151221

152222
/** The message URN — the canonical {@code job}, with the {@code urn} alias resolved by {@link #decode}. */
@@ -176,17 +246,27 @@ private static Envelope empty() {
176246
return new Envelope(null, null, null, null, 0, null);
177247
}
178248

179-
private static Meta parseMeta(Object value) {
249+
private static Meta parseMeta(Object value, Consumer<String> onWarning) {
180250
Map<String, Object> map = asMap(value);
181251
if (map == null) {
182252
return null;
183253
}
254+
Map<String, Object> extras = new LinkedHashMap<>();
255+
for (Map.Entry<String, Object> entry : map.entrySet()) {
256+
String key = entry.getKey();
257+
if (FORBIDDEN_META.contains(key)) {
258+
onWarning.accept(forbiddenWarning("/meta/" + key));
259+
} else if (!KNOWN_META.contains(key)) {
260+
extras.put(key, entry.getValue());
261+
}
262+
}
184263
return new Meta(
185264
asString(map.get("id")),
186265
asString(map.get("queue")),
187266
asString(map.get("lang")),
188267
asInt(map.get("schema_version"), 0),
189-
asLong(map.get("created_at"), 0L));
268+
asLong(map.get("created_at"), 0L),
269+
extras);
190270
}
191271

192272
private static DeadLetter parseDeadLetter(Object value) {
Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,42 @@
11
package com.babelqueue;
22

3+
import java.util.Collections;
4+
import java.util.LinkedHashMap;
5+
import java.util.Map;
6+
37
/**
48
* The immutable per-message metadata block of a {@link Envelope}.
59
*
10+
* <p>{@code meta} keys this SDK does not know are kept in {@link #extras()} (insertion
11+
* order preserved) and re-emitted after the known keys, so consumers stay
12+
* forward-compatible (message-envelope.md §4: consumers MUST ignore — and a re-emitting
13+
* hop MUST NOT drop — unknown {@code meta} keys). Forbidden keys (§10) are never kept.
14+
*
615
* @param id a unique identifier for this specific message
716
* @param queue the logical queue the message was produced for
817
* @param lang the source SDK language (e.g. {@code "java"})
918
* @param schemaVersion the wire envelope schema version
1019
* @param createdAt creation time in Unix milliseconds, UTC
20+
* @param extras unknown {@code meta} keys, insertion-ordered and unmodifiable (never {@code null})
1121
*/
1222
public record Meta(
1323
String id,
1424
String queue,
1525
String lang,
1626
int schemaVersion,
17-
long createdAt
18-
) {}
27+
long createdAt,
28+
Map<String, Object> extras
29+
) {
30+
31+
/** Normalises {@code extras} to an unmodifiable, insertion-ordered copy ({@code null} → empty). */
32+
public Meta {
33+
extras = extras == null || extras.isEmpty()
34+
? Map.of()
35+
: Collections.unmodifiableMap(new LinkedHashMap<>(extras));
36+
}
37+
38+
/** A meta block with no unknown keys (the pre-1.8.0 shape). */
39+
public Meta(String id, String queue, String lang, int schemaVersion, long createdAt) {
40+
this(id, queue, lang, schemaVersion, createdAt, null);
41+
}
42+
}

‎src/main/java/com/babelqueue/Redrive.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,10 @@ public record Result(int redriven, int skipped, List<Item> items) {
132132
/**
133133
* Returns a copy of {@code env} reset for reprocessing: the {@code dead_letter} block is
134134
* removed and {@code attempts} reset to 0; {@code job}, {@code trace_id}, {@code data} and
135-
* {@code meta} are preserved verbatim.
135+
* {@code meta} are preserved verbatim, as are unknown top-level keys ({@link Envelope#extras()}).
136136
*/
137137
public static Envelope reset(Envelope env) {
138-
return new Envelope(env.job(), env.traceId(), env.data(), env.meta(), 0, null);
138+
return new Envelope(env.job(), env.traceId(), env.data(), env.meta(), 0, null, env.extras());
139139
}
140140

141141
/**

‎src/main/java/com/babelqueue/schema/PayloadValidator.java‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ private static String checkString(Map<?, ?> schema, Object value, String path) {
104104
if (!(value instanceof String str)) {
105105
return violation(path, "not_a_string");
106106
}
107-
if (schema.get("minLength") instanceof Number min && str.length() < min.intValue()) {
107+
// JSON Schema counts string length in Unicode code points, not UTF-16 units.
108+
if (schema.get("minLength") instanceof Number min
109+
&& str.codePointCount(0, str.length()) < min.intValue()) {
108110
return violation(path, "below_min_length");
109111
}
110112
return null;

0 commit comments

Comments
 (0)