diff --git a/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java b/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java
new file mode 100644
index 000000000..89d4ad3fe
--- /dev/null
+++ b/conformance-tests/src/main/java/plugin/PluginAttemptInfoShape.java
@@ -0,0 +1,198 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package plugin;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Locale;
+import software.amazon.lambda.durable.DurableConfig;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.config.StepConfig;
+import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
+import software.amazon.lambda.durable.plugin.InvocationInfo;
+import software.amazon.lambda.durable.plugin.UserFunctionEndInfo;
+import software.amazon.lambda.durable.plugin.UserFunctionStartInfo;
+import software.amazon.lambda.durable.retry.JitterStrategy;
+import software.amazon.lambda.durable.retry.RetryStrategies;
+
+/**
+ * 10-21: Attempt hook info field shape (CANONICAL DUMP).
+ *
+ *
A single step named {@code "flaky"} that throws on attempt 1 and succeeds on attempt 2 using the SDK's real
+ * exponential-backoff retry strategy (max attempts 3, ~1s delay), returning {@code "ok"}. The instrumentation plugin
+ * (filtering to step-type attempts) emits ONE single-line JSON record per per-attempt (user-function) hook event: a
+ * canonical dump of that hook's OWN info parameter, every exposed component mapped to its canonical camelCase name,
+ * null / unexposed fields OMITTED.
+ *
+ *
Java's {@link UserFunctionStartInfo} exposes id/name/type/subType/parentId/startTimestamp/isReplay/
+ * isReplayingChildren/attempt (no endTimestamp/outcome/error at start). Java's {@link UserFunctionEndInfo} adds
+ * endTimestamp, the {@code succeeded} boolean (presented as the shared {@code outcome} SUCCEEDED/FAILED token) and
+ * {@code error}. {@code isReplay} is the operation-level replay indicator (this operation was present in the
+ * checkpointed state delivered at invocation start); {@code isReplayingChildren} is the distinct context-children
+ * indicator and is dumped unasserted here.
+ */
+@SuppressWarnings("deprecation")
+public class PluginAttemptInfoShape extends DurableHandler {
+
+ @Override
+ protected DurableConfig createConfiguration() {
+ return DurableConfig.builder().withPlugins(new AttemptShapePlugin()).build();
+ }
+
+ @Override
+ public String handleRequest(Object input, DurableContext context) {
+ return context.step(
+ "flaky",
+ String.class,
+ stepCtx -> {
+ // Fail on the first attempt, succeed on the second, using the SDK's built-in 1-based attempt
+ // number.
+ if (stepCtx.getAttempt() < 2) {
+ throw new RuntimeException("Attempt " + stepCtx.getAttempt() + " failed");
+ }
+ return "ok";
+ },
+ StepConfig.builder()
+ .retryStrategy(RetryStrategies.exponentialBackoff(
+ 3, Duration.ofSeconds(1), Duration.ofSeconds(10), 1.0, JitterStrategy.NONE))
+ .build());
+ }
+
+ private static final class AttemptShapePlugin implements DurableExecutionPlugin {
+ private volatile String executionArn;
+
+ @Override
+ public void onInvocationStart(InvocationInfo info) {
+ this.executionArn = info.durableExecutionArn();
+ }
+
+ @Override
+ public void onUserFunctionStart(UserFunctionStartInfo info) {
+ if (!PluginSupport.isStep(info.type()) || info.attempt() == null) {
+ return;
+ }
+ new Rec("attempt-start")
+ .str("id", info.id())
+ .str("name", info.name())
+ .str("type", Rec.upper(info.type()))
+ .str("subType", info.subType())
+ .str("parentId", info.parentId())
+ .num("attempt", info.attempt())
+ .time("startTimestamp", info.startTimestamp())
+ .bool("isReplay", info.isReplay())
+ .bool("isReplayingChildren", info.isReplayingChildren())
+ .emit(executionArn);
+ }
+
+ @Override
+ public void onUserFunctionEnd(UserFunctionEndInfo info) {
+ if (!PluginSupport.isStep(info.type()) || info.attempt() == null) {
+ return;
+ }
+ new Rec("attempt-end")
+ .str("id", info.id())
+ .str("name", info.name())
+ .str("type", Rec.upper(info.type()))
+ .str("subType", info.subType())
+ .str("parentId", info.parentId())
+ .num("attempt", info.attempt())
+ .time("startTimestamp", info.startTimestamp())
+ .time("endTimestamp", info.endTimestamp())
+ .bool("isReplay", info.isReplay())
+ .bool("isReplayingChildren", info.isReplayingChildren())
+ .str("outcome", info.succeeded() ? "SUCCEEDED" : "FAILED")
+ .str("error", Rec.msg(info.error()))
+ .emit(executionArn);
+ }
+ }
+
+ /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */
+ private static final class Rec {
+ private final StringBuilder sb = new StringBuilder("{");
+
+ Rec(String hook) {
+ raw("plugin", "\"CONFPLUGIN\"");
+ raw("hook", "\"" + hook + "\"");
+ }
+
+ Rec str(String key, String value) {
+ if (value != null) {
+ raw(key, quote(value));
+ }
+ return this;
+ }
+
+ Rec num(String key, Integer value) {
+ if (value != null) {
+ raw(key, value.toString());
+ }
+ return this;
+ }
+
+ Rec bool(String key, boolean value) {
+ raw(key, value ? "true" : "false");
+ return this;
+ }
+
+ Rec time(String key, Instant value) {
+ if (value != null) {
+ raw(key, quote(value.toString()));
+ }
+ return this;
+ }
+
+ private void raw(String key, String jsonValue) {
+ if (sb.length() > 1) {
+ sb.append(", ");
+ }
+ sb.append('"').append(key).append("\": ").append(jsonValue);
+ }
+
+ void emit(String executionArn) {
+ System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}'));
+ }
+
+ static String upper(String s) {
+ return s == null ? null : s.toUpperCase(Locale.ROOT);
+ }
+
+ static String msg(Throwable t) {
+ if (t == null) {
+ return null;
+ }
+ return t.getMessage() != null ? t.getMessage() : t.toString();
+ }
+
+ static String quote(String s) {
+ StringBuilder b = new StringBuilder("\"");
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ b.append("\\\"");
+ break;
+ case '\\':
+ b.append("\\\\");
+ break;
+ case '\n':
+ b.append("\\n");
+ break;
+ case '\r':
+ b.append("\\r");
+ break;
+ case '\t':
+ b.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ b.append(String.format("\\u%04x", (int) c));
+ } else {
+ b.append(c);
+ }
+ }
+ }
+ return b.append('"').toString();
+ }
+ }
+}
diff --git a/conformance-tests/src/main/java/plugin/PluginContextInfoShape.java b/conformance-tests/src/main/java/plugin/PluginContextInfoShape.java
new file mode 100644
index 000000000..966c3d934
--- /dev/null
+++ b/conformance-tests/src/main/java/plugin/PluginContextInfoShape.java
@@ -0,0 +1,187 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package plugin;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import software.amazon.lambda.durable.DurableConfig;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableFuture;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.ParallelDurableFuture;
+import software.amazon.lambda.durable.config.ParallelConfig;
+import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
+import software.amazon.lambda.durable.plugin.InvocationInfo;
+import software.amazon.lambda.durable.plugin.OperationInfo;
+import software.amazon.lambda.durable.plugin.UserFunctionStartInfo;
+
+/**
+ * 10-23: Context-typed hook info field shape (CANONICAL DUMP).
+ *
+ * A parallel operation named {@code "ctx"} with max-concurrency 1 and two branches: branch A runs a step named
+ * {@code "inner"} returning {@code "x"}, then a 2-second wait, and returns {@code "a-done"}; branch B returns
+ * {@code "b-done"} directly. With max-concurrency 1 branch A runs live, suspends on the wait, and re-runs on the replay
+ * (replaying its checkpointed children) before branch B runs live — so the children-replay indicator flips true on
+ * branch A's second {@code fn-start}.
+ *
+ *
The instrumentation plugin (filtering to CONTEXT-type operations) emits ONE single-line JSON record per hook
+ * event: a canonical camelCase dump of that hook's OWN info parameter, null / unset fields OMITTED.
+ *
+ *
Java's {@link OperationInfo} (operation-start) exposes id/name/type/subType/parentId/startTimestamp/endTimestamp/
+ * status/isReplay. Java's {@link UserFunctionStartInfo} (fn-start) exposes id/name/type/subType/parentId/
+ * startTimestamp/isReplayingChildren/attempt — for CONTEXT operations {@code attempt} is null and is omitted. Only
+ * fn-start is probed; attempt-end hooks are out of scope for a suspending context run.
+ */
+@SuppressWarnings("deprecation")
+public class PluginContextInfoShape extends DurableHandler> {
+
+ @Override
+ protected DurableConfig createConfiguration() {
+ return DurableConfig.builder().withPlugins(new ContextShapePlugin()).build();
+ }
+
+ @Override
+ public List handleRequest(Object input, DurableContext context) {
+ var config = ParallelConfig.builder().maxConcurrency(1).build();
+ var futures = new ArrayList>();
+ ParallelDurableFuture parallel = context.parallel("ctx", config);
+ try (parallel) {
+ futures.add(parallel.branch("branch-a", String.class, branch -> {
+ branch.step("inner", String.class, stepCtx -> "x");
+ branch.wait(null, Duration.ofSeconds(2));
+ return "a-done";
+ }));
+ futures.add(parallel.branch("branch-b", String.class, branch -> "b-done"));
+ }
+ return futures.stream().map(DurableFuture::get).toList();
+ }
+
+ private static final class ContextShapePlugin implements DurableExecutionPlugin {
+ private volatile String executionArn;
+
+ @Override
+ public void onInvocationStart(InvocationInfo info) {
+ this.executionArn = info.durableExecutionArn();
+ }
+
+ @Override
+ public void onOperationStart(OperationInfo info) {
+ if (!PluginSupport.isContext(info.type())) {
+ return;
+ }
+ new Rec("operation-start")
+ .str("id", info.id())
+ .str("name", info.name())
+ .str("type", Rec.upper(info.type()))
+ .str("subType", info.subType())
+ .str("parentId", info.parentId())
+ .str("status", Rec.upper(info.status()))
+ .time("startTimestamp", info.startTimestamp())
+ .time("endTimestamp", info.endTimestamp())
+ .bool("isReplay", info.isReplay())
+ .emit(executionArn);
+ }
+
+ @Override
+ public void onUserFunctionStart(UserFunctionStartInfo info) {
+ if (!PluginSupport.isContext(info.type())) {
+ return;
+ }
+ new Rec("fn-start")
+ .str("id", info.id())
+ .str("name", info.name())
+ .str("type", Rec.upper(info.type()))
+ .str("subType", info.subType())
+ .str("parentId", info.parentId())
+ .num("attempt", info.attempt())
+ .time("startTimestamp", info.startTimestamp())
+ .bool("isReplayingChildren", info.isReplayingChildren())
+ .emit(executionArn);
+ }
+ }
+
+ /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */
+ private static final class Rec {
+ private final StringBuilder sb = new StringBuilder("{");
+
+ Rec(String hook) {
+ raw("plugin", "\"CONFPLUGIN\"");
+ raw("hook", "\"" + hook + "\"");
+ }
+
+ Rec str(String key, String value) {
+ if (value != null) {
+ raw(key, quote(value));
+ }
+ return this;
+ }
+
+ Rec num(String key, Integer value) {
+ if (value != null) {
+ raw(key, value.toString());
+ }
+ return this;
+ }
+
+ Rec bool(String key, boolean value) {
+ raw(key, value ? "true" : "false");
+ return this;
+ }
+
+ Rec time(String key, Instant value) {
+ if (value != null) {
+ raw(key, quote(value.toString()));
+ }
+ return this;
+ }
+
+ private void raw(String key, String jsonValue) {
+ if (sb.length() > 1) {
+ sb.append(", ");
+ }
+ sb.append('"').append(key).append("\": ").append(jsonValue);
+ }
+
+ void emit(String executionArn) {
+ System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}'));
+ }
+
+ static String upper(String s) {
+ return s == null ? null : s.toUpperCase(Locale.ROOT);
+ }
+
+ static String quote(String s) {
+ StringBuilder b = new StringBuilder("\"");
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ b.append("\\\"");
+ break;
+ case '\\':
+ b.append("\\\\");
+ break;
+ case '\n':
+ b.append("\\n");
+ break;
+ case '\r':
+ b.append("\\r");
+ break;
+ case '\t':
+ b.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ b.append(String.format("\\u%04x", (int) c));
+ } else {
+ b.append(c);
+ }
+ }
+ }
+ return b.append('"').toString();
+ }
+ }
+}
diff --git a/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java b/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java
new file mode 100644
index 000000000..89b41b80e
--- /dev/null
+++ b/conformance-tests/src/main/java/plugin/PluginInvocationInfoShape.java
@@ -0,0 +1,161 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package plugin;
+
+import java.time.Duration;
+import java.time.Instant;
+import software.amazon.lambda.durable.DurableConfig;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
+import software.amazon.lambda.durable.plugin.InvocationEndInfo;
+import software.amazon.lambda.durable.plugin.InvocationInfo;
+import software.amazon.lambda.durable.plugin.InvocationStatus;
+
+/**
+ * 10-19: Invocation hook info field shape (CANONICAL DUMP).
+ *
+ * A single 2-second wait that then returns {@code "done-" + input}. The instrumentation plugin emits ONE single-line
+ * JSON record per invocation hook event: a canonical dump of that hook's OWN info parameter, every exposed component
+ * mapped one-to-one to its canonical camelCase name, null / unexposed fields OMITTED (a missing key fails its assertion
+ * — the parity signal).
+ *
+ *
Java's {@link InvocationInfo} exposes {@code requestId}, {@code executionStartTime} (→
+ * {@code executionStartTimestamp}), {@code isFirstInvocation} and the {@code operations} / {@code updatedOperations}
+ * maps (dumped as {@code operationsCount} / {@code updatedOperationsCount}); it does NOT expose the execution input, so
+ * {@code executionInput} is absent. Java's {@link InvocationEndInfo} carries the same identity surface plus
+ * {@code invocationStatus} (→ {@code status}) and {@code executionError}; it does NOT expose the execution input or the
+ * final result, so {@code executionInput} and {@code executionResult} are absent. The single derived scalar
+ * {@code terminal} := status in (SUCCEEDED, FAILED). Those payload omissions are deliberate — payload surfaces are out
+ * of GA scope.
+ */
+@SuppressWarnings("deprecation")
+public class PluginInvocationInfoShape extends DurableHandler {
+
+ @Override
+ protected DurableConfig createConfiguration() {
+ return DurableConfig.builder().withPlugins(new InvocationShapePlugin()).build();
+ }
+
+ @Override
+ public String handleRequest(String input, DurableContext context) {
+ context.wait(null, Duration.ofSeconds(2));
+ return "done-" + input;
+ }
+
+ private static final class InvocationShapePlugin implements DurableExecutionPlugin {
+ private volatile String executionArn;
+
+ @Override
+ public void onInvocationStart(InvocationInfo info) {
+ this.executionArn = info.durableExecutionArn();
+ new Rec("invocation-start")
+ .bool("isFirstInvocation", info.isFirstInvocation())
+ .str("requestId", info.requestId())
+ .num("operationsCount", info.operations().size())
+ .num("updatedOperationsCount", info.updatedOperations().size())
+ .time("executionStartTimestamp", info.executionStartTime())
+ .emit(executionArn);
+ }
+
+ @Override
+ public void onInvocationEnd(InvocationEndInfo info) {
+ InvocationStatus status = info.invocationStatus();
+ boolean terminal = status == InvocationStatus.SUCCEEDED || status == InvocationStatus.FAILED;
+ new Rec("invocation-end")
+ .bool("isFirstInvocation", info.isFirstInvocation())
+ .str("requestId", info.requestId())
+ .num("operationsCount", info.operations().size())
+ .time("executionStartTimestamp", info.executionStartTime())
+ .str("status", status == null ? null : status.name())
+ .bool("terminal", terminal)
+ .str("executionError", Rec.msg(info.executionError()))
+ .emit(executionArn);
+ }
+ }
+
+ /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */
+ private static final class Rec {
+ private final StringBuilder sb = new StringBuilder("{");
+
+ Rec(String hook) {
+ raw("plugin", "\"CONFPLUGIN\"");
+ raw("hook", "\"" + hook + "\"");
+ }
+
+ Rec str(String key, String value) {
+ if (value != null) {
+ raw(key, quote(value));
+ }
+ return this;
+ }
+
+ Rec num(String key, Integer value) {
+ if (value != null) {
+ raw(key, value.toString());
+ }
+ return this;
+ }
+
+ Rec bool(String key, boolean value) {
+ raw(key, value ? "true" : "false");
+ return this;
+ }
+
+ Rec time(String key, Instant value) {
+ if (value != null) {
+ raw(key, quote(value.toString()));
+ }
+ return this;
+ }
+
+ private void raw(String key, String jsonValue) {
+ if (sb.length() > 1) {
+ sb.append(", ");
+ }
+ sb.append('"').append(key).append("\": ").append(jsonValue);
+ }
+
+ void emit(String executionArn) {
+ System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}'));
+ }
+
+ static String msg(Throwable t) {
+ if (t == null) {
+ return null;
+ }
+ return t.getMessage() != null ? t.getMessage() : t.toString();
+ }
+
+ static String quote(String s) {
+ StringBuilder b = new StringBuilder("\"");
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ b.append("\\\"");
+ break;
+ case '\\':
+ b.append("\\\\");
+ break;
+ case '\n':
+ b.append("\\n");
+ break;
+ case '\r':
+ b.append("\\r");
+ break;
+ case '\t':
+ b.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ b.append(String.format("\\u%04x", (int) c));
+ } else {
+ b.append(c);
+ }
+ }
+ }
+ return b.append('"').toString();
+ }
+ }
+}
diff --git a/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java b/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java
new file mode 100644
index 000000000..b4915114b
--- /dev/null
+++ b/conformance-tests/src/main/java/plugin/PluginOperationChangeShape.java
@@ -0,0 +1,170 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package plugin;
+
+import java.time.Instant;
+import java.util.Locale;
+import software.amazon.lambda.durable.DurableConfig;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
+import software.amazon.lambda.durable.plugin.InvocationInfo;
+import software.amazon.lambda.durable.plugin.OperationChangeInfo;
+import software.amazon.lambda.durable.plugin.OperationChangeItemInfo;
+
+/**
+ * 10-22: Operation-change hook info field shape (CANONICAL DUMP).
+ *
+ * A single step named {@code "greet"} returning the constant {@code "task-a"}. For each step-type operation in the
+ * change info's updated-operations delta the instrumentation plugin emits ONE single-line JSON record: a canonical dump
+ * of that DELTA ITEM's OWN field surface, plus the hook-level fields {@code executionArn} (from the change info),
+ * {@code updatedOperationsCount}/{@code operationsCount} (map sizes) and the derived {@code inFullMap} := the same id
+ * also appears in the info's full operations map. Null / unexposed fields are OMITTED.
+ *
+ *
Java's {@link OperationChangeItemInfo} exposes the full operation field surface — id/name/type/subType/parentId/
+ * startTimestamp/endTimestamp/status/attempt/isReplay/error. It does NOT expose the checkpointed serialized result, so
+ * {@code result} is absent; payload surfaces are deliberately out of GA scope.
+ */
+@SuppressWarnings("deprecation")
+public class PluginOperationChangeShape extends DurableHandler {
+
+ @Override
+ protected DurableConfig createConfiguration() {
+ return DurableConfig.builder().withPlugins(new ChangeShapePlugin()).build();
+ }
+
+ @Override
+ public String handleRequest(Object input, DurableContext context) {
+ return context.step("greet", String.class, stepCtx -> "task-a");
+ }
+
+ private static final class ChangeShapePlugin implements DurableExecutionPlugin {
+ private volatile String executionArn;
+
+ @Override
+ public void onInvocationStart(InvocationInfo info) {
+ this.executionArn = info.durableExecutionArn();
+ }
+
+ @Override
+ public void onOperationChange(OperationChangeInfo info) {
+ int updatedOperationsCount = info.updatedOperations().size();
+ int operationsCount = info.operations().size();
+ for (OperationChangeItemInfo item : info.updatedOperations().values()) {
+ if (!PluginSupport.isStepChange(item.type())) {
+ continue;
+ }
+ new Rec("operation-change")
+ .str("executionArn", info.durableExecutionArn())
+ .num("updatedOperationsCount", updatedOperationsCount)
+ .num("operationsCount", operationsCount)
+ .bool("inFullMap", info.operations().containsKey(item.id()))
+ .str("id", item.id())
+ .str("name", item.name())
+ .str("type", Rec.upper(item.type()))
+ .str("subType", item.subType())
+ .str("parentId", item.parentId())
+ .str(
+ "status",
+ item.status() == null
+ ? null
+ : Rec.upper(item.status().toString()))
+ .time("startTimestamp", item.startTimestamp())
+ .time("endTimestamp", item.endTimestamp())
+ .num("attempt", item.attempt())
+ .bool("isReplay", item.isReplay())
+ .str("error", Rec.msg(item.error()))
+ .emit(executionArn);
+ }
+ }
+ }
+
+ /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */
+ private static final class Rec {
+ private final StringBuilder sb = new StringBuilder("{");
+
+ Rec(String hook) {
+ raw("plugin", "\"CONFPLUGIN\"");
+ raw("hook", "\"" + hook + "\"");
+ }
+
+ Rec str(String key, String value) {
+ if (value != null) {
+ raw(key, quote(value));
+ }
+ return this;
+ }
+
+ Rec num(String key, Integer value) {
+ if (value != null) {
+ raw(key, value.toString());
+ }
+ return this;
+ }
+
+ Rec bool(String key, boolean value) {
+ raw(key, value ? "true" : "false");
+ return this;
+ }
+
+ Rec time(String key, Instant value) {
+ if (value != null) {
+ raw(key, quote(value.toString()));
+ }
+ return this;
+ }
+
+ private void raw(String key, String jsonValue) {
+ if (sb.length() > 1) {
+ sb.append(", ");
+ }
+ sb.append('"').append(key).append("\": ").append(jsonValue);
+ }
+
+ void emit(String executionArn) {
+ System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}'));
+ }
+
+ static String upper(String s) {
+ return s == null ? null : s.toUpperCase(Locale.ROOT);
+ }
+
+ static String msg(Throwable t) {
+ if (t == null) {
+ return null;
+ }
+ return t.getMessage() != null ? t.getMessage() : t.toString();
+ }
+
+ static String quote(String s) {
+ StringBuilder b = new StringBuilder("\"");
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ b.append("\\\"");
+ break;
+ case '\\':
+ b.append("\\\\");
+ break;
+ case '\n':
+ b.append("\\n");
+ break;
+ case '\r':
+ b.append("\\r");
+ break;
+ case '\t':
+ b.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ b.append(String.format("\\u%04x", (int) c));
+ } else {
+ b.append(c);
+ }
+ }
+ }
+ return b.append('"').toString();
+ }
+ }
+}
diff --git a/conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java b/conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java
new file mode 100644
index 000000000..71bfce0c0
--- /dev/null
+++ b/conformance-tests/src/main/java/plugin/PluginOperationInfoShape.java
@@ -0,0 +1,176 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package plugin;
+
+import java.time.Instant;
+import java.util.Locale;
+import software.amazon.lambda.durable.DurableConfig;
+import software.amazon.lambda.durable.DurableContext;
+import software.amazon.lambda.durable.DurableHandler;
+import software.amazon.lambda.durable.plugin.DurableExecutionPlugin;
+import software.amazon.lambda.durable.plugin.InvocationInfo;
+import software.amazon.lambda.durable.plugin.OperationEndInfo;
+import software.amazon.lambda.durable.plugin.OperationInfo;
+
+/**
+ * 10-20: Operation hook info field shape (CANONICAL DUMP).
+ *
+ * A single step named {@code "greet"} returning the constant {@code "task-a"}. The instrumentation plugin (filtering
+ * to step-type operations) emits ONE single-line JSON record per operation hook event: a canonical dump of that hook's
+ * OWN info parameter, every exposed component mapped to its canonical camelCase name, null / unexposed fields OMITTED.
+ *
+ *
Java's {@link OperationInfo} (operation-start) exposes id/name/type/subType/parentId/startTimestamp/endTimestamp/
+ * status/isReplay; at a LIVE first start {@code status}/{@code startTimestamp} may be unset and are simply omitted, and
+ * the record has no {@code attempt}/{@code result}/{@code error} at all. Java's {@link OperationEndInfo}
+ * (operation-end) adds {@code attempt} and {@code error} but does NOT expose the checkpointed serialized result, so
+ * {@code result} is absent on the end record — that omission is the honest red the requirement produces.
+ */
+@SuppressWarnings("deprecation")
+public class PluginOperationInfoShape extends DurableHandler {
+
+ @Override
+ protected DurableConfig createConfiguration() {
+ return DurableConfig.builder().withPlugins(new OperationShapePlugin()).build();
+ }
+
+ @Override
+ public String handleRequest(Object input, DurableContext context) {
+ return context.step("greet", String.class, stepCtx -> "task-a");
+ }
+
+ private static final class OperationShapePlugin implements DurableExecutionPlugin {
+ private volatile String executionArn;
+
+ @Override
+ public void onInvocationStart(InvocationInfo info) {
+ this.executionArn = info.durableExecutionArn();
+ }
+
+ @Override
+ public void onOperationStart(OperationInfo info) {
+ if (!PluginSupport.isStep(info.type())) {
+ return;
+ }
+ new Rec("operation-start")
+ .str("id", info.id())
+ .str("name", info.name())
+ .str("type", Rec.upper(info.type()))
+ .str("subType", info.subType())
+ .str("parentId", info.parentId())
+ .str("status", Rec.upper(info.status()))
+ .time("startTimestamp", info.startTimestamp())
+ .time("endTimestamp", info.endTimestamp())
+ .bool("isReplay", info.isReplay())
+ .emit(executionArn);
+ }
+
+ @Override
+ public void onOperationEnd(OperationEndInfo info) {
+ if (!PluginSupport.isStep(info.type())) {
+ return;
+ }
+ new Rec("operation-end")
+ .str("id", info.id())
+ .str("name", info.name())
+ .str("type", Rec.upper(info.type()))
+ .str("subType", info.subType())
+ .str("parentId", info.parentId())
+ .str("status", Rec.upper(info.status()))
+ .time("startTimestamp", info.startTimestamp())
+ .time("endTimestamp", info.endTimestamp())
+ .num("attempt", info.attempt())
+ .bool("isReplay", info.isReplay())
+ .str("error", Rec.msg(info.error()))
+ .emit(executionArn);
+ }
+ }
+
+ /** Single-line JSON record builder: emits every provided key, skipping nulls, then stamps durableExecutionArn. */
+ private static final class Rec {
+ private final StringBuilder sb = new StringBuilder("{");
+
+ Rec(String hook) {
+ raw("plugin", "\"CONFPLUGIN\"");
+ raw("hook", "\"" + hook + "\"");
+ }
+
+ Rec str(String key, String value) {
+ if (value != null) {
+ raw(key, quote(value));
+ }
+ return this;
+ }
+
+ Rec num(String key, Integer value) {
+ if (value != null) {
+ raw(key, value.toString());
+ }
+ return this;
+ }
+
+ Rec bool(String key, boolean value) {
+ raw(key, value ? "true" : "false");
+ return this;
+ }
+
+ Rec time(String key, Instant value) {
+ if (value != null) {
+ raw(key, quote(value.toString()));
+ }
+ return this;
+ }
+
+ private void raw(String key, String jsonValue) {
+ if (sb.length() > 1) {
+ sb.append(", ");
+ }
+ sb.append('"').append(key).append("\": ").append(jsonValue);
+ }
+
+ void emit(String executionArn) {
+ System.out.println(sb.append(PluginSupport.arnField(executionArn)).append('}'));
+ }
+
+ static String upper(String s) {
+ return s == null ? null : s.toUpperCase(Locale.ROOT);
+ }
+
+ static String msg(Throwable t) {
+ if (t == null) {
+ return null;
+ }
+ return t.getMessage() != null ? t.getMessage() : t.toString();
+ }
+
+ static String quote(String s) {
+ StringBuilder b = new StringBuilder("\"");
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"':
+ b.append("\\\"");
+ break;
+ case '\\':
+ b.append("\\\\");
+ break;
+ case '\n':
+ b.append("\\n");
+ break;
+ case '\r':
+ b.append("\\r");
+ break;
+ case '\t':
+ b.append("\\t");
+ break;
+ default:
+ if (c < 0x20) {
+ b.append(String.format("\\u%04x", (int) c));
+ } else {
+ b.append(c);
+ }
+ }
+ }
+ return b.append('"').toString();
+ }
+ }
+}
diff --git a/conformance-tests/src/main/java/plugin/PluginSupport.java b/conformance-tests/src/main/java/plugin/PluginSupport.java
index 474f94e6c..5a6879e96 100644
--- a/conformance-tests/src/main/java/plugin/PluginSupport.java
+++ b/conformance-tests/src/main/java/plugin/PluginSupport.java
@@ -27,6 +27,16 @@ static boolean isWait(String type) {
return "WAIT".equals(type);
}
+ /**
+ * Operation type token for context operations (parallel, map, run-in-child-context, wait-for-callback) as reported
+ * by {@code OperationInfo#type()} / {@code UserFunctionStartInfo#type()} ({@code OperationType.CONTEXT}). Both the
+ * parallel parent and its branches report this token; the branch is distinguished by the {@code ParallelBranch}
+ * sub-type.
+ */
+ static boolean isContext(String type) {
+ return "CONTEXT".equals(type);
+ }
+
/**
* Operation type token for step operations as reported by {@code OperationChangeItemInfo#type()}
* ({@code Operation#typeAsString()} straight off the checkpoint response). Compared case-insensitively because it
diff --git a/conformance-tests/template_plugin.yaml b/conformance-tests/template_plugin.yaml
index ecc8cac17..4de478f31 100644
--- a/conformance-tests/template_plugin.yaml
+++ b/conformance-tests/template_plugin.yaml
@@ -304,3 +304,83 @@ Resources:
DurableConfig:
RetentionPeriodInDays: 7
ExecutionTimeout: 300
+
+ PluginInvocationInfoShape:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-19"]
+ Properties:
+ CodeUri: .
+ Handler: plugin.PluginInvocationInfoShape
+ Description: Invocation-start and invocation-end hook info carries the full invocation field set
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ PluginOperationInfoShape:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-20"]
+ Properties:
+ CodeUri: .
+ Handler: plugin.PluginOperationInfoShape
+ Description: Operation-start and operation-end hook info carries the full operation field set
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ PluginAttemptInfoShape:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-21"]
+ Properties:
+ CodeUri: .
+ Handler: plugin.PluginAttemptInfoShape
+ Description: Attempt-start and attempt-end hook info carries the full attempt field set
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ PluginOperationChangeShape:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-22"]
+ Properties:
+ CodeUri: .
+ Handler: plugin.PluginOperationChangeShape
+ Description: Operation-change hook info carries full operation items in the delta and full map
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300
+
+ PluginContextInfoShape:
+ Type: AWS::Serverless::Function
+ TestingMetadata:
+ TestDescription: ["10-23"]
+ Properties:
+ CodeUri: .
+ Handler: plugin.PluginContextInfoShape
+ Description: Context operation-start and user-function-start hook info carries subType tokens and the children-replay indicator
+ Role:
+ Fn::GetAtt:
+ - DurableFunctionRole
+ - Arn
+ DurableConfig:
+ RetentionPeriodInDays: 7
+ ExecutionTimeout: 300