Skip to content

Commit 2f742bd

Browse files
committed
phase 1: envelope, IDs, errors, extensions, event log (gate 1)
1 parent cc4fa48 commit 2f742bd

45 files changed

Lines changed: 2109 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

PLAN.md

Lines changed: 390 additions & 3 deletions
Large diffs are not rendered by default.

lib/build.gradle.kts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,18 @@ tasks.withType<JavaCompile>().configureEach {
4646
"-Werror",
4747
"-Xlint:all",
4848
"-Xlint:-processing",
49+
// ulid-creator is an automatic module (no module descriptor).
50+
"-Xlint:-requires-automatic",
4951
"-parameters",
5052
),
5153
)
5254
options.errorprone {
5355
disableWarningsInGeneratedCode.set(true)
5456
option("NullAway:AnnotatedPackages", "dev.arcp")
5557
error("NullAway")
58+
// `@return ...` is an idiomatic Javadoc summary; the Google style
59+
// checker disagrees but we accept it.
60+
disable("MissingSummary")
5661
}
5762
}
5863

@@ -89,7 +94,8 @@ spotless {
8994
}
9095

9196
jacoco {
92-
toolVersion = "0.8.12"
97+
// JaCoCo 0.8.13+ adds support for Java 25 class major version 69.
98+
toolVersion = "0.8.13"
9399
}
94100

95101
tasks.named<JacocoReport>("jacocoTestReport") {
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package dev.arcp.envelope;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import com.fasterxml.jackson.databind.SerializationFeature;
5+
import com.fasterxml.jackson.databind.module.SimpleModule;
6+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
7+
8+
/**
9+
* Factory for a Jackson {@link ObjectMapper} configured for ARCP wire format
10+
* (RFC §6.1): JSR-310 {@code Instant} support, ISO-8601 timestamps, and a
11+
* custom {@link Envelope} deserializer that resolves the envelope-level
12+
* {@code type} discriminator via {@link MessageType#register(String, Class)}.
13+
*/
14+
public final class ARCPMapper {
15+
16+
private ARCPMapper() {
17+
}
18+
19+
/** @return a fresh, fully-configured mapper. Callers MAY cache it. */
20+
public static ObjectMapper create() {
21+
SimpleModule envelopes = new SimpleModule("arcp-envelopes");
22+
envelopes.addDeserializer(Envelope.class, new EnvelopeDeserializer());
23+
return new ObjectMapper().registerModule(new JavaTimeModule()).registerModule(envelopes)
24+
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
25+
}
26+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package dev.arcp.envelope;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
6+
import com.fasterxml.jackson.databind.JsonNode;
7+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
8+
import dev.arcp.ids.IdempotencyKey;
9+
import dev.arcp.ids.JobId;
10+
import dev.arcp.ids.MessageId;
11+
import dev.arcp.ids.SessionId;
12+
import dev.arcp.ids.SpanId;
13+
import dev.arcp.ids.StreamId;
14+
import dev.arcp.ids.SubscriptionId;
15+
import dev.arcp.ids.TraceId;
16+
import java.time.Instant;
17+
import java.util.Map;
18+
import java.util.Objects;
19+
import org.jspecify.annotations.Nullable;
20+
21+
/**
22+
* ARCP wire envelope (RFC §6.1).
23+
*
24+
* <p>
25+
* Required: {@link #arcp}, {@link #id}, {@link #type}, {@link #timestamp},
26+
* {@link #payload}. Conditional: {@link #sessionId}, {@link #jobId},
27+
* {@link #streamId}, {@link #subscriptionId}. Recommended: {@link #traceId},
28+
* {@link #spanId}, {@link #parentSpanId}. Optional: {@link #correlationId},
29+
* {@link #causationId}, {@link #idempotencyKey}, {@link #priority},
30+
* {@link #extensions}, {@link #source}, {@link #target}.
31+
*
32+
* <p>
33+
* The {@link #type} field is the envelope-level discriminator (RFC §6.1.1). The
34+
* compact constructor enforces {@code type.equals(payload.type())}.
35+
* Deserialization is performed by {@link EnvelopeDeserializer}, registered via
36+
* {@link ARCPMapper#create()}.
37+
*/
38+
@JsonDeserialize(using = EnvelopeDeserializer.class)
39+
@JsonPropertyOrder({"arcp", "id", "type", "timestamp", "session_id", "job_id", "stream_id", "subscription_id",
40+
"trace_id", "span_id", "parent_span_id", "correlation_id", "causation_id", "idempotency_key", "priority",
41+
"source", "target", "extensions", "payload"})
42+
@JsonInclude(JsonInclude.Include.NON_NULL)
43+
public record Envelope(@JsonProperty("arcp") String arcp, @JsonProperty("id") MessageId id,
44+
@JsonProperty("type") String type, @JsonProperty("timestamp") Instant timestamp,
45+
@JsonProperty("session_id") @Nullable SessionId sessionId, @JsonProperty("job_id") @Nullable JobId jobId,
46+
@JsonProperty("stream_id") @Nullable StreamId streamId,
47+
@JsonProperty("subscription_id") @Nullable SubscriptionId subscriptionId,
48+
@JsonProperty("trace_id") @Nullable TraceId traceId, @JsonProperty("span_id") @Nullable SpanId spanId,
49+
@JsonProperty("parent_span_id") @Nullable SpanId parentSpanId,
50+
@JsonProperty("correlation_id") @Nullable MessageId correlationId,
51+
@JsonProperty("causation_id") @Nullable MessageId causationId,
52+
@JsonProperty("idempotency_key") @Nullable IdempotencyKey idempotencyKey,
53+
@JsonProperty("priority") @Nullable Priority priority, @JsonProperty("source") @Nullable String source,
54+
@JsonProperty("target") @Nullable String target,
55+
@JsonProperty("extensions") @Nullable Map<String, JsonNode> extensions,
56+
@JsonProperty("payload") MessageType payload) {
57+
58+
/** Wire-format protocol version this SDK speaks. */
59+
public static final String PROTOCOL_VERSION = "1.0";
60+
61+
public Envelope {
62+
Objects.requireNonNull(arcp, "arcp");
63+
Objects.requireNonNull(id, "id");
64+
Objects.requireNonNull(type, "type");
65+
Objects.requireNonNull(timestamp, "timestamp");
66+
Objects.requireNonNull(payload, "payload");
67+
if (!type.equals(payload.type())) {
68+
throw new IllegalArgumentException("envelope type=" + type + " mismatches payload type=" + payload.type());
69+
}
70+
}
71+
72+
/**
73+
* Build a minimally-valid envelope with current protocol version and the given
74+
* timestamp. The {@code type} discriminator is derived from
75+
* {@code payload.type()}.
76+
*/
77+
public static Envelope of(MessageId id, Instant timestamp, MessageType payload) {
78+
Objects.requireNonNull(payload, "payload");
79+
return new Envelope(PROTOCOL_VERSION, id, payload.type(), timestamp, null, null, null, null, null, null, null,
80+
null, null, null, null, null, null, null, payload);
81+
}
82+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package dev.arcp.envelope;
2+
3+
import com.fasterxml.jackson.core.JsonParser;
4+
import com.fasterxml.jackson.databind.DeserializationContext;
5+
import com.fasterxml.jackson.databind.JsonNode;
6+
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
7+
import com.fasterxml.jackson.databind.node.ObjectNode;
8+
import dev.arcp.ids.IdempotencyKey;
9+
import dev.arcp.ids.JobId;
10+
import dev.arcp.ids.MessageId;
11+
import dev.arcp.ids.SessionId;
12+
import dev.arcp.ids.SpanId;
13+
import dev.arcp.ids.StreamId;
14+
import dev.arcp.ids.SubscriptionId;
15+
import dev.arcp.ids.TraceId;
16+
import java.io.IOException;
17+
import java.time.Instant;
18+
import java.util.HashMap;
19+
import java.util.Iterator;
20+
import java.util.Map;
21+
import org.jspecify.annotations.Nullable;
22+
23+
/**
24+
* Custom Jackson deserializer for {@link Envelope}. Resolves the envelope-level
25+
* {@code type} discriminator (RFC §6.1.1) to a concrete {@link MessageType}
26+
* variant via {@link MessageType#register(String, Class)}.
27+
*/
28+
final class EnvelopeDeserializer extends StdDeserializer<Envelope> {
29+
30+
private static final long serialVersionUID = 1L;
31+
32+
EnvelopeDeserializer() {
33+
super(Envelope.class);
34+
}
35+
36+
@Override
37+
public Envelope deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
38+
ObjectNode node = p.readValueAsTree();
39+
String type = requireText(node, "type");
40+
Class<? extends MessageType> payloadClass = MessageType.Registry.resolve(type);
41+
JsonNode payloadNode = node.get("payload");
42+
if (payloadNode == null) {
43+
throw ctxt.weirdStringException(type, Envelope.class, "missing payload");
44+
}
45+
MessageType payload = ctxt.readTreeAsValue(payloadNode, payloadClass);
46+
47+
return new Envelope(requireText(node, "arcp"), readId(node, "id", MessageId.class, ctxt), type,
48+
readInstant(node, "timestamp", ctxt), readNullable(node, "session_id", SessionId.class, ctxt),
49+
readNullable(node, "job_id", JobId.class, ctxt), readNullable(node, "stream_id", StreamId.class, ctxt),
50+
readNullable(node, "subscription_id", SubscriptionId.class, ctxt),
51+
readNullable(node, "trace_id", TraceId.class, ctxt), readNullable(node, "span_id", SpanId.class, ctxt),
52+
readNullable(node, "parent_span_id", SpanId.class, ctxt),
53+
readNullable(node, "correlation_id", MessageId.class, ctxt),
54+
readNullable(node, "causation_id", MessageId.class, ctxt),
55+
readNullable(node, "idempotency_key", IdempotencyKey.class, ctxt),
56+
readNullable(node, "priority", Priority.class, ctxt), readText(node, "source"),
57+
readText(node, "target"), readExtensions(node), payload);
58+
}
59+
60+
private static String requireText(ObjectNode node, String field) {
61+
JsonNode v = node.get(field);
62+
if (v == null || v.isNull()) {
63+
throw new IllegalArgumentException("envelope missing required field " + field);
64+
}
65+
return v.asText();
66+
}
67+
68+
private static <T> T readId(ObjectNode node, String field, Class<T> cls, DeserializationContext ctxt)
69+
throws IOException {
70+
JsonNode v = node.get(field);
71+
if (v == null || v.isNull()) {
72+
throw new IllegalArgumentException("envelope missing required field " + field);
73+
}
74+
return ctxt.readTreeAsValue(v, cls);
75+
}
76+
77+
private static Instant readInstant(ObjectNode node, String field, DeserializationContext ctxt) throws IOException {
78+
JsonNode v = node.get(field);
79+
if (v == null || v.isNull()) {
80+
throw new IllegalArgumentException("envelope missing required field " + field);
81+
}
82+
return ctxt.readTreeAsValue(v, Instant.class);
83+
}
84+
85+
@Nullable
86+
private static <T> T readNullable(ObjectNode node, String field, Class<T> cls, DeserializationContext ctxt)
87+
throws IOException {
88+
JsonNode v = node.get(field);
89+
if (v == null || v.isNull()) {
90+
return null;
91+
}
92+
return ctxt.readTreeAsValue(v, cls);
93+
}
94+
95+
@Nullable
96+
private static String readText(ObjectNode node, String field) {
97+
JsonNode v = node.get(field);
98+
return (v == null || v.isNull()) ? null : v.asText();
99+
}
100+
101+
@Nullable
102+
private static Map<String, JsonNode> readExtensions(ObjectNode node) {
103+
JsonNode v = node.get("extensions");
104+
if (v == null || v.isNull()) {
105+
return null;
106+
}
107+
if (!v.isObject()) {
108+
throw new IllegalArgumentException("extensions must be an object");
109+
}
110+
Map<String, JsonNode> out = new HashMap<>();
111+
Iterator<Map.Entry<String, JsonNode>> it = v.properties().iterator();
112+
while (it.hasNext()) {
113+
Map.Entry<String, JsonNode> e = it.next();
114+
out.put(e.getKey(), e.getValue());
115+
}
116+
return out;
117+
}
118+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package dev.arcp.envelope;
2+
3+
import dev.arcp.messages.control.Ping;
4+
import dev.arcp.messages.control.Pong;
5+
import java.util.Map;
6+
import java.util.concurrent.ConcurrentHashMap;
7+
8+
/**
9+
* Sealed root of every ARCP payload variant (RFC §6.2).
10+
*
11+
* <p>
12+
* Concrete variants register themselves with {@link #register(String, Class)};
13+
* {@link Envelope} uses the registry during JSON deserialization to map the
14+
* envelope-level {@code type} discriminator to the right record class.
15+
* {@code switch} expressions over {@code MessageType} are compile-time
16+
* exhaustive — the registry exists only for the wire-format mapping, not for
17+
* dispatch.
18+
*/
19+
public sealed interface MessageType permits Ping, Pong {
20+
21+
/** @return the wire-format discriminator (e.g. {@code "ping"}). */
22+
String type();
23+
24+
/** Mutable mapping from wire type string to the concrete record class. */
25+
final class Registry {
26+
private static final Map<String, Class<? extends MessageType>> MAP = new ConcurrentHashMap<>();
27+
28+
static {
29+
MAP.put("ping", Ping.class);
30+
MAP.put("pong", Pong.class);
31+
}
32+
33+
private Registry() {
34+
}
35+
36+
static Class<? extends MessageType> resolve(String type) {
37+
Class<? extends MessageType> c = MAP.get(type);
38+
if (c == null) {
39+
throw new IllegalArgumentException("unknown message type: " + type);
40+
}
41+
return c;
42+
}
43+
44+
static void put(String type, Class<? extends MessageType> cls) {
45+
MAP.put(type, cls);
46+
}
47+
48+
static boolean isKnown(String type) {
49+
return MAP.containsKey(type);
50+
}
51+
}
52+
53+
/**
54+
* Register a wire-format type string for a concrete variant. Idempotent. Used
55+
* by additional message-type modules added in later phases.
56+
*/
57+
static void register(String type, Class<? extends MessageType> cls) {
58+
Registry.put(type, cls);
59+
}
60+
61+
/** @return {@code true} if {@code type} is a recognized variant. */
62+
static boolean isKnown(String type) {
63+
return Registry.isKnown(type);
64+
}
65+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package dev.arcp.envelope;
2+
3+
import com.fasterxml.jackson.annotation.JsonCreator;
4+
import com.fasterxml.jackson.annotation.JsonValue;
5+
6+
/**
7+
* Envelope priority (RFC §6.5). Ordering:
8+
* {@code low < normal < high < critical}.
9+
*
10+
* <p>
11+
* {@code critical} is reserved for permission requests blocking real human
12+
* action and terminal job events. Runtimes SHOULD reorder between streams but
13+
* MUST NEVER reorder within a {@code stream_id}.
14+
*/
15+
public enum Priority {
16+
LOW, NORMAL, HIGH, CRITICAL;
17+
18+
/** @return canonical lowercase wire form. */
19+
@JsonValue
20+
public String wire() {
21+
return name().toLowerCase(java.util.Locale.ROOT);
22+
}
23+
24+
@JsonCreator
25+
public static Priority fromWire(String s) {
26+
return Priority.valueOf(s.toUpperCase(java.util.Locale.ROOT));
27+
}
28+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Wire-format envelope (RFC §6.1) and message-type discriminator.
3+
*
4+
* <p>
5+
* {@link dev.arcp.envelope.Envelope} carries every §6.1 field;
6+
* {@link dev.arcp.envelope.MessageType} is the sealed root of the payload
7+
* hierarchy. Jackson polymorphism is configured directly on the sealed
8+
* interface via {@code @JsonTypeInfo} + {@code @JsonSubTypes}, giving
9+
* compile-time exhaustive {@code switch} dispatch.
10+
*/
11+
@org.jspecify.annotations.NullMarked
12+
package dev.arcp.envelope;

0 commit comments

Comments
 (0)