refinements,
+ String idempotencyKey, AdcpContext ctx);
+
+ /**
+ * Optional hook called before the batch is dispatched to
+ * {@link #refine}. Returns null to proceed, or an error
+ * message to reject the batch.
+ *
+ * Use this for cross-entry validation that the framework
+ * cannot perform (e.g., checking that all source proposals
+ * belong to the same context).
+ */
+ default @Nullable String preflight(List refinements,
+ String idempotencyKey, AdcpContext ctx) {
+ return null;
+ }
+}
diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/package-info.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/package-info.java
new file mode 100644
index 0000000..5cbb620
--- /dev/null
+++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Server-side handler registration and capability declaration for
+ * proposal refinement. Commercial decisions are delegated to
+ * application callbacks via {@link ProposalHandler}.
+ */
+@org.jspecify.annotations.NullMarked
+package org.adcontextprotocol.adcp.server.negotiation;
diff --git a/adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/NegotiationFixtures.java b/adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/NegotiationFixtures.java
new file mode 100644
index 0000000..743863b
--- /dev/null
+++ b/adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/NegotiationFixtures.java
@@ -0,0 +1,140 @@
+package org.adcontextprotocol.adcp.testing.negotiation;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.adcontextprotocol.adcp.negotiation.CpmConstraint;
+import org.adcontextprotocol.adcp.negotiation.FlightConstraint;
+import org.adcontextprotocol.adcp.negotiation.ImpressionsConstraint;
+import org.adcontextprotocol.adcp.negotiation.ProposalRefinement;
+import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest;
+import org.adcontextprotocol.adcp.negotiation.TermsDigest;
+
+import java.math.BigDecimal;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Shared test fixtures for proposal negotiation tests.
+ *
+ * Provides pre-built request/response objects for common scenarios:
+ * single revise, batch finalize, partial outcomes, mixed-batch rejection,
+ * and constraint variations.
+ */
+public final class NegotiationFixtures {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private NegotiationFixtures() {}
+
+ public static String randomIdempotencyKey() {
+ return "idem-" + UUID.randomUUID().toString().replace("-", "");
+ }
+
+ // -- Proposals --
+
+ public static ObjectNode draftProposal(String proposalId, String parentProposalId) {
+ ObjectNode proposal = MAPPER.createObjectNode();
+ proposal.put("proposal_id", proposalId);
+ proposal.put("parent_proposal_id", parentProposalId);
+ proposal.put("proposal_status", "draft");
+ proposal.put("name", "Test Plan " + proposalId);
+
+ ObjectNode terms = MAPPER.createObjectNode();
+ terms.put("total_budget", 50000);
+ terms.put("currency", "USD");
+ proposal.set("commercial_terms", terms);
+ proposal.put("terms_digest", TermsDigest.compute(terms));
+
+ proposal.putArray("allocations").addObject()
+ .put("product_id", "prod-1")
+ .put("allocation_percentage", 100);
+ return proposal;
+ }
+
+ public static ObjectNode committedProposal(String proposalId,
+ String parentProposalId) {
+ ObjectNode proposal = draftProposal(proposalId, parentProposalId);
+ proposal.put("proposal_status", "committed");
+ proposal.put("expires_at",
+ OffsetDateTime.now(ZoneOffset.UTC).plusHours(24).toString());
+ return proposal;
+ }
+
+ // -- Requests --
+
+ public static RefineProposalsRequest singleReviseRequest(String proposalId) {
+ return RefineProposalsRequest.builder()
+ .idempotencyKey(randomIdempotencyKey())
+ .addRefinement(ProposalRefinement.revise(
+ proposalId, "Lower CPM to $8 and extend flight by 2 weeks"))
+ .build();
+ }
+
+ public static RefineProposalsRequest batchFinalizeRequest(List proposalIds) {
+ var builder = RefineProposalsRequest.builder()
+ .idempotencyKey(randomIdempotencyKey());
+ for (String id : proposalIds) {
+ builder.addRefinement(ProposalRefinement.finalize(id));
+ }
+ return builder.build();
+ }
+
+ // -- Constraints --
+
+ public static CpmConstraint standardCpmCeiling() {
+ return new CpmConstraint(new BigDecimal("12.50"), "USD");
+ }
+
+ public static ImpressionsConstraint minimumImpressions() {
+ return new ImpressionsConstraint(100_000);
+ }
+
+ public static FlightConstraint q4Flight() {
+ return new FlightConstraint(
+ OffsetDateTime.of(2026, 10, 1, 0, 0, 0, 0, ZoneOffset.UTC),
+ OffsetDateTime.of(2026, 12, 31, 23, 59, 59, 0, ZoneOffset.UTC));
+ }
+
+ // -- Response fragments --
+
+ /**
+ * Builds a JSON string for a completed refine_proposals response
+ * with a single revised result.
+ */
+ public static String revisedResponseJson(String sourceProposalId,
+ String newProposalId) {
+ ObjectNode proposal = draftProposal(newProposalId, sourceProposalId);
+
+ ObjectNode result = MAPPER.createObjectNode();
+ result.put("source_proposal_id", sourceProposalId);
+ result.put("outcome", "revised");
+ result.set("proposal", proposal);
+
+ ObjectNode response = MAPPER.createObjectNode();
+ response.put("status", "completed");
+ response.putArray("results").add(result);
+ response.putArray("products");
+
+ return response.toString();
+ }
+
+ /**
+ * Builds a JSON string for an "unable" result with a given reason.
+ */
+ public static String unableResponseJson(String sourceProposalId, String reason) {
+ ObjectNode result = MAPPER.createObjectNode();
+ result.put("source_proposal_id", sourceProposalId);
+ result.put("outcome", "unable");
+ result.put("reason", reason);
+
+ ObjectNode response = MAPPER.createObjectNode();
+ response.put("status", "completed");
+ response.putArray("results").add(result);
+ response.putArray("products");
+
+ return response.toString();
+ }
+}
diff --git a/adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/package-info.java b/adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/package-info.java
new file mode 100644
index 0000000..24ebb83
--- /dev/null
+++ b/adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Test fixtures and assertions for AdCP 3.2 proposal negotiation.
+ *
+ * @see org.adcontextprotocol.adcp.testing.negotiation.NegotiationFixtures
+ */
+@org.jspecify.annotations.NullMarked
+package org.adcontextprotocol.adcp.testing.negotiation;
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java b/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java
index 92e5f02..02e52df 100644
--- a/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java
@@ -4,6 +4,8 @@
import org.adcontextprotocol.adcp.error.ConfigurationError;
import org.adcontextprotocol.adcp.http.AdcpHttpClient;
import org.adcontextprotocol.adcp.http.SsrfPolicy;
+import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest;
+import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse;
import org.adcontextprotocol.adcp.schema.AdcpObjectMapperFactory;
import org.adcontextprotocol.adcp.transport.CallToolOptions;
import org.adcontextprotocol.adcp.transport.ProtocolClient;
@@ -129,6 +131,20 @@ public T callNamedTool(String toolName, Object request,
return callTool(toolName, toArgs(request), responseType);
}
+ // -- Proposal negotiation (3.2) --
+
+ /**
+ * Refines one or more proposals: creates draft revisions or finalizes
+ * drafts into held committed snapshots.
+ *
+ * @param request the refinement request
+ * @return the refinement response (synchronous or async)
+ */
+ public RefineProposalsResponse refineProposals(RefineProposalsRequest request) {
+ return callNamedTool("refine_proposals", request,
+ RefineProposalsResponse.class);
+ }
+
// -- Lifecycle --
/** Returns the agent config this client is bound to. */
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ChangeKind.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ChangeKind.java
new file mode 100644
index 0000000..fb6f088
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ChangeKind.java
@@ -0,0 +1,31 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+
+/**
+ * The kind of successor proposal to create when refining an accepted proposal.
+ */
+public enum ChangeKind {
+
+ AMENDMENT("amendment"),
+ CANCELLATION("cancellation");
+
+ private final String wire;
+
+ ChangeKind(String wire) {
+ this.wire = wire;
+ }
+
+ @JsonValue
+ public String toWire() {
+ return wire;
+ }
+
+ public static ChangeKind fromWire(String value) {
+ return switch (value) {
+ case "amendment" -> AMENDMENT;
+ case "cancellation" -> CANCELLATION;
+ default -> throw new IllegalArgumentException("Unknown change kind: " + value);
+ };
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/CpmConstraint.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/CpmConstraint.java
new file mode 100644
index 0000000..5d5acaa
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/CpmConstraint.java
@@ -0,0 +1,24 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.math.BigDecimal;
+
+/**
+ * CPM ceiling constraint: every purchase must be priced at fixed CPM/vCPM
+ * in the given currency at or under max.
+ *
+ * @param max maximum CPM value
+ * @param currency ISO 4217 currency code
+ */
+public record CpmConstraint(
+ @JsonProperty("max") BigDecimal max,
+ @JsonProperty("currency") String currency) {
+
+ public CpmConstraint {
+ if (max == null) throw new IllegalArgumentException("cpm max is required");
+ if (currency == null || currency.isBlank()) {
+ throw new IllegalArgumentException("cpm currency is required");
+ }
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/FlightConstraint.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/FlightConstraint.java
new file mode 100644
index 0000000..7f12649
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/FlightConstraint.java
@@ -0,0 +1,26 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.jspecify.annotations.Nullable;
+
+import java.time.OffsetDateTime;
+
+/**
+ * Flight timing constraint, checked against the envelope's
+ * {@code start_time}/{@code end_time}. An "asap" start never
+ * satisfies a {@code startNoLaterThan} bound.
+ *
+ * @param startNoLaterThan campaign must start on or before this time
+ * @param endNoEarlierThan campaign must end on or after this time
+ */
+public record FlightConstraint(
+ @Nullable @JsonProperty("start_no_later_than") OffsetDateTime startNoLaterThan,
+ @Nullable @JsonProperty("end_no_earlier_than") OffsetDateTime endNoEarlierThan) {
+
+ public FlightConstraint {
+ if (startNoLaterThan == null && endNoEarlierThan == null) {
+ throw new IllegalArgumentException(
+ "flight constraint must specify at least one bound");
+ }
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ImpressionsConstraint.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ImpressionsConstraint.java
new file mode 100644
index 0000000..3d24ade
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ImpressionsConstraint.java
@@ -0,0 +1,16 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Minimum summed impressions constraint across all purchases.
+ *
+ * @param min minimum total impressions required
+ */
+public record ImpressionsConstraint(
+ @JsonProperty("min") long min) {
+
+ public ImpressionsConstraint {
+ if (min < 0) throw new IllegalArgumentException("impressions min must be non-negative");
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java
new file mode 100644
index 0000000..5d313b3
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java
@@ -0,0 +1,67 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Objects;
+
+/**
+ * A single refinement operation within a {@link RefineProposalsRequest}.
+ *
+ * Revising with structured criteria and/or semantic instructions creates
+ * a new draft; finalizing changes no terms. Refining an accepted proposal
+ * creates a draft amendment or cancellation proposal.
+ *
+ * @param proposalId the source proposal to refine
+ * @param action revise or finalize
+ * @param changeKind amendment (default) or cancellation; only valid for accepted sources
+ * @param instructions semantic commercial changes or cancellation reason
+ * @param criteria structured changes; each present field replaces that criterion
+ */
+public record ProposalRefinement(
+ @JsonProperty("proposal_id") String proposalId,
+ @Nullable @JsonProperty("action") RefinementAction action,
+ @Nullable @JsonProperty("change_kind") ChangeKind changeKind,
+ @Nullable @JsonProperty("instructions") String instructions,
+ @Nullable @JsonProperty("criteria") JsonNode criteria) {
+
+ public ProposalRefinement {
+ Objects.requireNonNull(proposalId, "proposal_id is required");
+ if (proposalId.isBlank()) {
+ throw new IllegalArgumentException("proposal_id must not be blank");
+ }
+ }
+
+ /**
+ * Creates a revise refinement with instructions.
+ */
+ public static ProposalRefinement revise(String proposalId, String instructions) {
+ return new ProposalRefinement(proposalId, RefinementAction.REVISE,
+ null, instructions, null);
+ }
+
+ /**
+ * Creates a revise refinement with structured criteria.
+ */
+ public static ProposalRefinement reviseWithCriteria(String proposalId, JsonNode criteria) {
+ return new ProposalRefinement(proposalId, RefinementAction.REVISE,
+ null, null, criteria);
+ }
+
+ /**
+ * Creates a finalize refinement (no term changes, reserves inventory).
+ */
+ public static ProposalRefinement finalize(String proposalId) {
+ return new ProposalRefinement(proposalId, RefinementAction.FINALIZE,
+ null, null, null);
+ }
+
+ /**
+ * Creates a cancellation refinement against an accepted proposal.
+ */
+ public static ProposalRefinement cancel(String proposalId, String reason) {
+ return new ProposalRefinement(proposalId, RefinementAction.REVISE,
+ ChangeKind.CANCELLATION, reason, null);
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java
new file mode 100644
index 0000000..4ab0e49
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java
@@ -0,0 +1,143 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.jspecify.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Request payload for the {@code refine_proposals} tool.
+ *
+ *
Builds a batch of refinement operations (revise or finalize) with
+ * capability-aware validation: the builder enforces supported dimensions,
+ * seller ceilings, and protocol cardinality before transport.
+ *
+ * @param idempotencyKey client-generated key for retry safety (16-255 chars, alphanumeric + _.-)
+ * @param refinements ordered refinement operations, one per source proposal
+ * @param contextId optional context ID for the refinement session
+ * @param context optional context object
+ * @param governanceContext optional governance/compliance context
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record RefineProposalsRequest(
+ @JsonProperty("idempotency_key") String idempotencyKey,
+ @JsonProperty("refinements") List refinements,
+ @Nullable @JsonProperty("context_id") String contextId,
+ @Nullable @JsonProperty("context") JsonNode context,
+ @Nullable @JsonProperty("governance_context") String governanceContext) {
+
+ private static final Pattern IDEMPOTENCY_KEY_PATTERN =
+ Pattern.compile("^[A-Za-z0-9_.:-]{16,255}$");
+
+ public RefineProposalsRequest {
+ Objects.requireNonNull(idempotencyKey, "idempotency_key is required");
+ if (!IDEMPOTENCY_KEY_PATTERN.matcher(idempotencyKey).matches()) {
+ throw new IllegalArgumentException(
+ "idempotency_key must match [A-Za-z0-9_.:-]{16,255}");
+ }
+ Objects.requireNonNull(refinements, "refinements is required");
+ if (refinements.isEmpty()) {
+ throw new IllegalArgumentException("refinements must not be empty");
+ }
+ refinements = List.copyOf(refinements);
+ }
+
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ public static final class Builder {
+ private @Nullable String idempotencyKey;
+ private final List refinements = new ArrayList<>();
+ private @Nullable String contextId;
+ private @Nullable JsonNode context;
+ private @Nullable String governanceContext;
+ private int maxBatchSize = 25;
+
+ private Builder() {}
+
+ public Builder idempotencyKey(String idempotencyKey) {
+ this.idempotencyKey = Objects.requireNonNull(idempotencyKey);
+ return this;
+ }
+
+ public Builder addRefinement(ProposalRefinement refinement) {
+ this.refinements.add(Objects.requireNonNull(refinement));
+ return this;
+ }
+
+ public Builder refinements(List refinements) {
+ this.refinements.clear();
+ this.refinements.addAll(refinements);
+ return this;
+ }
+
+ public Builder contextId(String contextId) {
+ this.contextId = contextId;
+ return this;
+ }
+
+ public Builder context(JsonNode context) {
+ this.context = context;
+ return this;
+ }
+
+ public Builder governanceContext(String governanceContext) {
+ this.governanceContext = governanceContext;
+ return this;
+ }
+
+ /**
+ * Sets the maximum batch size (default 25 per protocol spec).
+ * The seller may advertise a lower ceiling.
+ */
+ public Builder maxBatchSize(int maxBatchSize) {
+ if (maxBatchSize < 1) {
+ throw new IllegalArgumentException("maxBatchSize must be positive");
+ }
+ this.maxBatchSize = maxBatchSize;
+ return this;
+ }
+
+ public RefineProposalsRequest build() {
+ validateBatch();
+ return new RefineProposalsRequest(
+ idempotencyKey, refinements, contextId,
+ context, governanceContext);
+ }
+
+ private void validateBatch() {
+ if (refinements.size() > maxBatchSize) {
+ throw new IllegalArgumentException(
+ "batch size " + refinements.size()
+ + " exceeds maximum " + maxBatchSize);
+ }
+
+ // Enforce unique proposal IDs
+ Set ids = new HashSet<>();
+ for (ProposalRefinement r : refinements) {
+ if (!ids.add(r.proposalId())) {
+ throw new IllegalArgumentException(
+ "duplicate proposal_id in batch: " + r.proposalId());
+ }
+ }
+
+ // Finalize batches must be homogeneous
+ boolean hasFinalize = refinements.stream()
+ .anyMatch(r -> r.action() == RefinementAction.FINALIZE);
+ boolean hasRevise = refinements.stream()
+ .anyMatch(r -> r.action() == RefinementAction.REVISE);
+ if (hasFinalize && hasRevise) {
+ throw new IllegalArgumentException(
+ "a batch containing finalize must contain only finalize entries");
+ }
+ }
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsResponse.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsResponse.java
new file mode 100644
index 0000000..dedca41
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsResponse.java
@@ -0,0 +1,46 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.jspecify.annotations.Nullable;
+
+import java.util.List;
+
+/**
+ * Response from the {@code refine_proposals} tool.
+ *
+ * Synchronous completions carry {@code results} and {@code products}.
+ * Asynchronous responses carry a {@code taskId} for polling.
+ *
+ * @param results ordered results, one per requested refinement
+ * @param products compact canonical products referenced by the results
+ * @param status "completed" or "submitted" (for async)
+ * @param taskId non-null when status is "submitted"
+ * @param message optional human-readable status message
+ * @param errors optional error array from the response
+ * @param replayed true when this is a replayed idempotent response
+ */
+public record RefineProposalsResponse(
+ @Nullable @JsonProperty("results") List results,
+ @Nullable @JsonProperty("products") List products,
+ @Nullable @JsonProperty("status") String status,
+ @Nullable @JsonProperty("task_id") String taskId,
+ @Nullable @JsonProperty("message") String message,
+ @Nullable @JsonProperty("errors") List errors,
+ @Nullable @JsonProperty("adcp_version") String adcpVersion,
+ @Nullable @JsonProperty("replayed") Boolean replayed) {
+
+ /**
+ * Whether this is a synchronous completed response.
+ */
+ public boolean isCompleted() {
+ return "completed".equals(status) || (results != null && taskId == null);
+ }
+
+ /**
+ * Whether this response was deferred for async processing.
+ */
+ public boolean isAsync() {
+ return taskId != null;
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementAction.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementAction.java
new file mode 100644
index 0000000..4ce3155
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementAction.java
@@ -0,0 +1,35 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+
+/**
+ * Actions that can be taken on a proposal during refinement.
+ *
+ * {@code REVISE} creates a new draft snapshot with changed commercial terms.
+ * {@code FINALIZE} targets a draft and creates a committed snapshot without
+ * changing terms, reserving inventory until expires_at.
+ */
+public enum RefinementAction {
+
+ REVISE("revise"),
+ FINALIZE("finalize");
+
+ private final String wire;
+
+ RefinementAction(String wire) {
+ this.wire = wire;
+ }
+
+ @JsonValue
+ public String toWire() {
+ return wire;
+ }
+
+ public static RefinementAction fromWire(String value) {
+ return switch (value) {
+ case "revise" -> REVISE;
+ case "finalize" -> FINALIZE;
+ default -> throw new IllegalArgumentException("Unknown refinement action: " + value);
+ };
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java
new file mode 100644
index 0000000..d38e59a
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java
@@ -0,0 +1,35 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import org.jspecify.annotations.Nullable;
+
+import java.util.Set;
+
+/**
+ * Declares a seller's refinement capabilities, advertised in the
+ * agent's capability manifest.
+ *
+ *
The capability value dimension is {@code product_changes}
+ * (renamed from draft-era {@code product_selection}).
+ *
+ * @param supportedDimensions the refinement dimensions this seller supports
+ * @param maxBatchSize maximum entries per refinement request (default: 25)
+ * @param supportsFinalize whether this seller supports the finalize action
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record RefinementCapability(
+ @Nullable @JsonProperty("supported_dimensions") Set supportedDimensions,
+ @Nullable @JsonProperty("max_batch_size") Integer maxBatchSize,
+ @Nullable @JsonProperty("supports_finalize") Boolean supportsFinalize) {
+
+ /** Protocol default batch size when not declared by the seller. */
+ public static final int DEFAULT_MAX_BATCH_SIZE = 25;
+
+ /** The capability dimension key used in the agent manifest. */
+ public static final String DIMENSION_KEY = "product_changes";
+
+ public int effectiveMaxBatchSize() {
+ return maxBatchSize != null ? maxBatchSize : DEFAULT_MAX_BATCH_SIZE;
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementOutcome.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementOutcome.java
new file mode 100644
index 0000000..4c8bddf
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementOutcome.java
@@ -0,0 +1,39 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+
+/**
+ * Possible outcomes for a single refinement entry in a response.
+ *
+ * Precedence rule for reason codes: {@code constraint_unsatisfiable} wins
+ * whenever a typed constraint failed; typed failures never surface as
+ * {@code commercially_declined}.
+ */
+public enum RefinementOutcome {
+
+ REVISED("revised"),
+ PARTIAL("partial"),
+ FINALIZED("finalized"),
+ UNABLE("unable");
+
+ private final String wire;
+
+ RefinementOutcome(String wire) {
+ this.wire = wire;
+ }
+
+ @JsonValue
+ public String toWire() {
+ return wire;
+ }
+
+ public static RefinementOutcome fromWire(String value) {
+ return switch (value) {
+ case "revised" -> REVISED;
+ case "partial" -> PARTIAL;
+ case "finalized" -> FINALIZED;
+ case "unable" -> UNABLE;
+ default -> throw new IllegalArgumentException("Unknown refinement outcome: " + value);
+ };
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementResult.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementResult.java
new file mode 100644
index 0000000..074bc63
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementResult.java
@@ -0,0 +1,106 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.databind.JsonNode;
+import org.jspecify.annotations.Nullable;
+
+import java.util.List;
+
+/**
+ * Sealed result for a single refinement entry in a response.
+ *
+ *
Discriminated on the {@code outcome} field. Pattern matching:
+ *
{@code
+ * switch (result) {
+ * case RefinementResult.Revised r -> handleRevised(r);
+ * case RefinementResult.Partial p -> handlePartial(p);
+ * case RefinementResult.Finalized f -> handleFinalized(f);
+ * case RefinementResult.Unable u -> handleUnable(u);
+ * }
+ * }
+ */
+@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "outcome")
+@JsonSubTypes({
+ @JsonSubTypes.Type(value = RefinementResult.Revised.class, name = "revised"),
+ @JsonSubTypes.Type(value = RefinementResult.Partial.class, name = "partial"),
+ @JsonSubTypes.Type(value = RefinementResult.Finalized.class, name = "finalized"),
+ @JsonSubTypes.Type(value = RefinementResult.Unable.class, name = "unable")
+})
+public sealed interface RefinementResult {
+
+ String sourceProposalId();
+
+ RefinementOutcome outcome();
+
+ /**
+ * A successful full revision. The returned proposal is a draft with
+ * all constraints satisfied.
+ */
+ record Revised(
+ @JsonProperty("source_proposal_id") String sourceProposalId,
+ @JsonProperty("proposal") JsonNode proposal,
+ @Nullable @JsonProperty("targeting_resolution") JsonNode targetingResolution
+ ) implements RefinementResult {
+ @Override
+ public RefinementOutcome outcome() {
+ return RefinementOutcome.REVISED;
+ }
+ }
+
+ /**
+ * A partial revision. The proposal is a draft but some constraints
+ * could not be fully satisfied. {@code unsatisfiedConstraints} names
+ * which constraint keys from the request were not met.
+ *
+ * Invariant: every constraint not listed in
+ * {@code unsatisfiedConstraints} is fully satisfied by this draft.
+ */
+ record Partial(
+ @JsonProperty("source_proposal_id") String sourceProposalId,
+ @JsonProperty("proposal") JsonNode proposal,
+ @JsonProperty("notes") String notes,
+ @Nullable @JsonProperty("unsatisfied_constraints") List unsatisfiedConstraints,
+ @Nullable @JsonProperty("suggestions") List suggestions,
+ @Nullable @JsonProperty("targeting_resolution") JsonNode targetingResolution
+ ) implements RefinementResult {
+ @Override
+ public RefinementOutcome outcome() {
+ return RefinementOutcome.PARTIAL;
+ }
+ }
+
+ /**
+ * Successful finalization: inventory is reserved, the proposal is
+ * now committed with a firm {@code expires_at}.
+ */
+ record Finalized(
+ @JsonProperty("source_proposal_id") String sourceProposalId,
+ @JsonProperty("proposal") JsonNode proposal
+ ) implements RefinementResult {
+ @Override
+ public RefinementOutcome outcome() {
+ return RefinementOutcome.FINALIZED;
+ }
+ }
+
+ /**
+ * The refinement could not be performed. The reason indicates why.
+ *
+ * Reason code precedence: {@code constraint_unsatisfiable} wins
+ * whenever a typed constraint failed; typed failures never surface
+ * as {@code commercially_declined}.
+ */
+ record Unable(
+ @JsonProperty("source_proposal_id") String sourceProposalId,
+ @JsonProperty("reason") String reason,
+ @Nullable @JsonProperty("notes") String notes,
+ @Nullable @JsonProperty("suggestions") List suggestions
+ ) implements RefinementResult {
+ @Override
+ public RefinementOutcome outcome() {
+ return RefinementOutcome.UNABLE;
+ }
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java
new file mode 100644
index 0000000..a85759d
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java
@@ -0,0 +1,275 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Verification utilities for {@code refine_proposals} responses.
+ *
+ * Checks response ordering, budget and product constraints, unique
+ * alternatives, machine-readable failure subsets, and finalize/expiry
+ * semantics per the AdCP 3.2 specification.
+ *
+ *
All methods return a list of violations found. An empty list
+ * means the response passed verification.
+ */
+public final class ResponseVerifier {
+
+ private ResponseVerifier() {}
+
+ /**
+ * Runs all verification checks on a completed response.
+ *
+ * @param request the original request
+ * @param response the response to verify
+ * @return list of violation descriptions (empty if valid)
+ */
+ public static List verify(RefineProposalsRequest request,
+ RefineProposalsResponse response) {
+ List violations = new ArrayList<>();
+
+ if (!response.isCompleted() || response.results() == null) {
+ return violations;
+ }
+
+ verifyResultOrdering(request, response, violations);
+ verifyFinalizeHomogeneity(response, violations);
+ verifyLineage(request, response, violations);
+ verifyUniqueProposalIds(response, violations);
+ verifyPartialInvariant(response, violations);
+ verifyOutcomeConstraints(response, violations);
+
+ return violations;
+ }
+
+ /**
+ * Verifies that results preserve request ordering: each result's
+ * source_proposal_id matches the corresponding refinement entry.
+ */
+ public static void verifyResultOrdering(RefineProposalsRequest request,
+ RefineProposalsResponse response,
+ List violations) {
+ List results = response.results();
+ List refinements = request.refinements();
+
+ if (results == null) return;
+
+ if (results.size() != refinements.size()) {
+ violations.add("result count " + results.size()
+ + " does not match refinement count " + refinements.size());
+ return;
+ }
+
+ for (int i = 0; i < results.size(); i++) {
+ String expectedId = refinements.get(i).proposalId();
+ String actualId = results.get(i).sourceProposalId();
+ if (!expectedId.equals(actualId)) {
+ violations.add("result[" + i + "] source_proposal_id '"
+ + actualId + "' does not match request '"
+ + expectedId + "'");
+ }
+ }
+ }
+
+ /**
+ * Verifies that if any result is finalized, all results are finalized
+ * (atomic finalize batch invariant).
+ */
+ public static void verifyFinalizeHomogeneity(RefineProposalsResponse response,
+ List violations) {
+ List results = response.results();
+ if (results == null || results.isEmpty()) return;
+
+ boolean anyFinalized = results.stream()
+ .anyMatch(r -> r.outcome() == RefinementOutcome.FINALIZED);
+ if (!anyFinalized) return;
+
+ for (int i = 0; i < results.size(); i++) {
+ if (results.get(i).outcome() != RefinementOutcome.FINALIZED) {
+ violations.add("result[" + i + "] is " + results.get(i).outcome()
+ + " but batch contains finalized results"
+ + " (finalize batches must be all-or-none)");
+ }
+ }
+ }
+
+ /**
+ * Verifies lineage: every returned proposal must carry a
+ * parent_proposal_id equal to the entry's source_proposal_id.
+ */
+ public static void verifyLineage(RefineProposalsRequest request,
+ RefineProposalsResponse response,
+ List violations) {
+ List results = response.results();
+ if (results == null) return;
+
+ for (int i = 0; i < results.size(); i++) {
+ RefinementResult result = results.get(i);
+ JsonNode proposal = extractProposal(result);
+ if (proposal == null) continue;
+
+ JsonNode parentId = proposal.get("parent_proposal_id");
+ if (parentId == null || parentId.isNull()) {
+ violations.add("result[" + i + "] proposal is missing parent_proposal_id");
+ } else if (!parentId.asText().equals(result.sourceProposalId())) {
+ violations.add("result[" + i + "] parent_proposal_id '"
+ + parentId.asText()
+ + "' does not match source_proposal_id '"
+ + result.sourceProposalId() + "'");
+ }
+ }
+ }
+
+ /**
+ * Verifies that all returned proposal_ids are unique (no duplicates
+ * across alternatives).
+ */
+ public static void verifyUniqueProposalIds(RefineProposalsResponse response,
+ List violations) {
+ List results = response.results();
+ if (results == null) return;
+
+ Set seen = new HashSet<>();
+ for (int i = 0; i < results.size(); i++) {
+ JsonNode proposal = extractProposal(results.get(i));
+ if (proposal == null) continue;
+
+ JsonNode pid = proposal.get("proposal_id");
+ if (pid != null && !pid.isNull()) {
+ if (!seen.add(pid.asText())) {
+ violations.add("result[" + i + "] duplicate proposal_id: "
+ + pid.asText());
+ }
+ }
+ }
+ }
+
+ /**
+ * Verifies the partial invariant: on a partial result, every
+ * constraint not listed in unsatisfied_constraints is fully
+ * satisfied by the draft.
+ *
+ * Currently checks that unsatisfied_constraints is present
+ * and non-empty for partial outcomes.
+ */
+ public static void verifyPartialInvariant(RefineProposalsResponse response,
+ List violations) {
+ List results = response.results();
+ if (results == null) return;
+
+ for (int i = 0; i < results.size(); i++) {
+ if (results.get(i) instanceof RefinementResult.Partial partial) {
+ if (partial.unsatisfiedConstraints() == null
+ || partial.unsatisfiedConstraints().isEmpty()) {
+ violations.add("result[" + i
+ + "] is partial but has no unsatisfied_constraints");
+ }
+ }
+ }
+ }
+
+ /**
+ * Verifies outcome-specific structural constraints:
+ * - revised: must have proposal with status=draft, no reason/notes
+ * - partial: must have proposal with status=draft, must have notes
+ * - finalized: must have proposal with status=committed and expires_at
+ * - unable: must have reason, must not have proposal
+ */
+ public static void verifyOutcomeConstraints(RefineProposalsResponse response,
+ List violations) {
+ List results = response.results();
+ if (results == null) return;
+
+ for (int i = 0; i < results.size(); i++) {
+ RefinementResult result = results.get(i);
+ switch (result) {
+ case RefinementResult.Revised r -> {
+ if (r.proposal() == null || r.proposal().isNull()) {
+ violations.add("result[" + i + "] revised but missing proposal");
+ } else {
+ checkProposalStatus(r.proposal(), "draft", i, violations);
+ }
+ }
+ case RefinementResult.Partial p -> {
+ if (p.proposal() == null || p.proposal().isNull()) {
+ violations.add("result[" + i + "] partial but missing proposal");
+ } else {
+ checkProposalStatus(p.proposal(), "draft", i, violations);
+ }
+ if (p.notes() == null || p.notes().isBlank()) {
+ violations.add("result[" + i + "] partial but missing notes");
+ }
+ }
+ case RefinementResult.Finalized f -> {
+ if (f.proposal() == null || f.proposal().isNull()) {
+ violations.add("result[" + i + "] finalized but missing proposal");
+ } else {
+ checkProposalStatus(f.proposal(), "committed", i, violations);
+ JsonNode expiresAt = f.proposal().get("expires_at");
+ if (expiresAt == null || expiresAt.isNull()) {
+ violations.add("result[" + i
+ + "] finalized proposal missing expires_at");
+ }
+ }
+ }
+ case RefinementResult.Unable u -> {
+ if (u.reason() == null || u.reason().isBlank()) {
+ violations.add("result[" + i + "] unable but missing reason");
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Verifies that all returned proposals have valid terms_digest values
+ * matching their commercial_terms.
+ */
+ public static List verifyDigests(RefineProposalsResponse response) {
+ List violations = new ArrayList<>();
+ List results = response.results();
+ if (results == null) return violations;
+
+ for (int i = 0; i < results.size(); i++) {
+ JsonNode proposal = extractProposal(results.get(i));
+ if (proposal == null) continue;
+
+ JsonNode digestNode = proposal.get("terms_digest");
+ JsonNode termsNode = proposal.get("commercial_terms");
+
+ if (digestNode != null && !digestNode.isNull()
+ && termsNode != null && !termsNode.isNull()) {
+ if (!TermsDigest.verify(digestNode.asText(), termsNode)) {
+ violations.add("result[" + i + "] terms_digest does not match "
+ + "recomputed SHA-256 of commercial_terms");
+ }
+ }
+ }
+
+ return violations;
+ }
+
+ private static void checkProposalStatus(JsonNode proposal, String expected,
+ int index, List violations) {
+ JsonNode status = proposal.get("proposal_status");
+ if (status == null || status.isNull()) {
+ violations.add("result[" + index + "] proposal missing proposal_status");
+ } else if (!expected.equals(status.asText())) {
+ violations.add("result[" + index + "] expected proposal_status '"
+ + expected + "' but got '" + status.asText() + "'");
+ }
+ }
+
+ private static JsonNode extractProposal(RefinementResult result) {
+ return switch (result) {
+ case RefinementResult.Revised r -> r.proposal();
+ case RefinementResult.Partial p -> p.proposal();
+ case RefinementResult.Finalized f -> f.proposal();
+ case RefinementResult.Unable u -> null;
+ };
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java
new file mode 100644
index 0000000..3fea3d6
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java
@@ -0,0 +1,213 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.jspecify.annotations.Nullable;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Computes and verifies {@code terms_digest} values per the AdCP 3.2
+ * normative digest specification.
+ *
+ * Format: {@code sha256:} + base64url(SHA-256(JCS(commercial_terms))),
+ * where JCS is RFC 8785 JSON Canonicalization Scheme.
+ *
+ *
Buyer helpers should recompute and compare rather than trusting
+ * the string. Alternative distinctness is defined as distinct
+ * {@code commercial_terms}.
+ */
+public final class TermsDigest {
+
+ private static final String PREFIX = "sha256:";
+
+ private TermsDigest() {}
+
+ /**
+ * Computes the canonical digest for a commercial_terms JSON node.
+ *
+ * @return "sha256:" + base64url(SHA-256(JCS(commercialTerms)))
+ */
+ public static String compute(JsonNode commercialTerms) {
+ byte[] canonical = canonicalize(commercialTerms);
+ byte[] hash = sha256(canonical);
+ String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
+ return PREFIX + encoded;
+ }
+
+ /**
+ * Verifies that a digest string matches the computed digest of
+ * the given commercial terms.
+ *
+ * @return true if the digest is valid
+ */
+ public static boolean verify(@Nullable String digest, JsonNode commercialTerms) {
+ if (digest == null || !digest.startsWith(PREFIX)) {
+ return false;
+ }
+ String expected = compute(commercialTerms);
+ return MessageDigest.isEqual(
+ digest.getBytes(StandardCharsets.UTF_8),
+ expected.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Checks whether two proposals have distinct commercial terms by
+ * comparing their canonical digests.
+ */
+ public static boolean areDistinct(JsonNode termsA, JsonNode termsB) {
+ return !compute(termsA).equals(compute(termsB));
+ }
+
+ /**
+ * RFC 8785 JCS canonicalization. Sorts object keys lexicographically
+ * by UTF-16 code unit order, and formats numbers per ES2015 rules.
+ *
+ *
This is a self-contained implementation to avoid a runtime
+ * dependency on org.webpki.jcs for the common case. The JCS number
+ * formatting corner cases (very large/small doubles) follow the
+ * ES2015 spec rather than Java's Double.toString().
+ */
+ static byte[] canonicalize(JsonNode node) {
+ try {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ writeCanonical(node, out);
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new IllegalArgumentException("failed to canonicalize JSON", e);
+ }
+ }
+
+ private static void writeCanonical(JsonNode node, ByteArrayOutputStream out)
+ throws IOException {
+ switch (node.getNodeType()) {
+ case OBJECT -> {
+ ObjectNode obj = (ObjectNode) node;
+ List keys = new ArrayList<>();
+ obj.fieldNames().forEachRemaining(keys::add);
+ // JCS: sort by UTF-16 code unit order (String.compareTo)
+ Collections.sort(keys);
+
+ out.write('{');
+ boolean first = true;
+ for (String key : keys) {
+ if (!first) out.write(',');
+ first = false;
+ writeCanonicalString(key, out);
+ out.write(':');
+ writeCanonical(obj.get(key), out);
+ }
+ out.write('}');
+ }
+ case ARRAY -> {
+ ArrayNode arr = (ArrayNode) node;
+ out.write('[');
+ boolean first = true;
+ for (int i = 0; i < arr.size(); i++) {
+ if (!first) out.write(',');
+ first = false;
+ writeCanonical(arr.get(i), out);
+ }
+ out.write(']');
+ }
+ case STRING -> writeCanonicalString(node.textValue(), out);
+ case NUMBER -> {
+ // JCS number serialization per ES2015 (RFC 8785 §3.2.2.3)
+ double d = node.doubleValue();
+ if (Double.isNaN(d) || Double.isInfinite(d)) {
+ throw new IOException("JCS does not support NaN or Infinity");
+ }
+ if (d == 0.0) {
+ // Normalize -0 to 0
+ out.write('0');
+ } else {
+ long asLong = node.longValue();
+ if (d == (double) asLong && !node.isDouble()
+ && !node.isFloat()
+ && Math.abs(asLong) < (1L << 53)) {
+ out.write(Long.toString(asLong).getBytes(StandardCharsets.UTF_8));
+ } else {
+ // For decimal values, we use the representation that
+ // round-trips through parseDouble — per JCS spec.
+ String repr = jcsNumberString(d);
+ out.write(repr.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+ }
+ case BOOLEAN -> out.write(
+ (node.booleanValue() ? "true" : "false")
+ .getBytes(StandardCharsets.UTF_8));
+ case NULL -> out.write("null".getBytes(StandardCharsets.UTF_8));
+ default -> throw new IOException("Unsupported JSON node type: " + node.getNodeType());
+ }
+ }
+
+ private static void writeCanonicalString(String s, ByteArrayOutputStream out)
+ throws IOException {
+ out.write('"');
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '"' -> out.write("\\\"".getBytes(StandardCharsets.UTF_8));
+ case '\\' -> out.write("\\\\".getBytes(StandardCharsets.UTF_8));
+ case '\b' -> out.write("\\b".getBytes(StandardCharsets.UTF_8));
+ case '\f' -> out.write("\\f".getBytes(StandardCharsets.UTF_8));
+ case '\n' -> out.write("\\n".getBytes(StandardCharsets.UTF_8));
+ case '\r' -> out.write("\\r".getBytes(StandardCharsets.UTF_8));
+ case '\t' -> out.write("\\t".getBytes(StandardCharsets.UTF_8));
+ default -> {
+ if (c < 0x20) {
+ out.write(String.format("\\u%04x", (int) c)
+ .getBytes(StandardCharsets.UTF_8));
+ } else {
+ out.write(String.valueOf(c).getBytes(StandardCharsets.UTF_8));
+ }
+ }
+ }
+ }
+ out.write('"');
+ }
+
+ /**
+ * ES2015-compliant number-to-string conversion for JCS.
+ * Produces the shortest decimal representation that round-trips
+ * through parseDouble, per RFC 8785 section 3.2.2.3.
+ */
+ static String jcsNumberString(double d) {
+ if (d == 0.0) return "0";
+ if (d == (long) d && Math.abs(d) < 1e21) {
+ return Long.toString((long) d);
+ }
+ String s = Double.toString(d);
+ if (s.contains("E") || s.contains("e")) {
+ // ES2015: lowercase 'e', no '+' sign, strip ".0" before 'e'
+ // e.g. Java "1.0E-7" → JCS "1e-7"
+ s = s.toLowerCase().replace("+", "");
+ int eIdx = s.indexOf('e');
+ String mantissa = s.substring(0, eIdx);
+ String exponent = s.substring(eIdx);
+ if (mantissa.endsWith(".0")) {
+ mantissa = mantissa.substring(0, mantissa.length() - 2);
+ }
+ return mantissa + exponent;
+ }
+ return s;
+ }
+
+ private static byte[] sha256(byte[] data) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(data);
+ } catch (NoSuchAlgorithmException e) {
+ throw new AssertionError("SHA-256 is required by the JDK spec", e);
+ }
+ }
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/UnsupportedRefinementDetails.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/UnsupportedRefinementDetails.java
new file mode 100644
index 0000000..20c169c
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/UnsupportedRefinementDetails.java
@@ -0,0 +1,21 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/**
+ * Typed error details for {@code UNSUPPORTED_FEATURE} when a refinement
+ * dimension is not supported by the seller.
+ *
+ * Follows the {@code error-details/unsupported-refinement-dimension.json}
+ * schema: carries the unsupported dimension and echoes back the seller's
+ * supported dimensions for typed error recovery.
+ *
+ * @param unsupportedDimension the dimension the buyer requested
+ * @param supportedDimensions the dimensions the seller actually supports
+ */
+public record UnsupportedRefinementDetails(
+ @JsonProperty("unsupported_dimension") String unsupportedDimension,
+ @JsonProperty("supported_dimensions") List supportedDimensions) {
+}
diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/package-info.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/package-info.java
new file mode 100644
index 0000000..c5bdf99
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/package-info.java
@@ -0,0 +1,13 @@
+/**
+ * Buyer and seller proposal negotiation APIs for AdCP 3.2.
+ *
+ * This package provides first-class types for the {@code refine_proposals}
+ * tool: sealed outcome models, capability-aware request builders, response
+ * verification utilities, and digest verification.
+ *
+ * @see org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest
+ * @see org.adcontextprotocol.adcp.negotiation.RefinementResult
+ * @see org.adcontextprotocol.adcp.negotiation.ResponseVerifier
+ */
+@org.jspecify.annotations.NullMarked
+package org.adcontextprotocol.adcp.negotiation;
diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java
new file mode 100644
index 0000000..840d378
--- /dev/null
+++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java
@@ -0,0 +1,100 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ConstraintsTest {
+
+ private final ObjectMapper mapper = new ObjectMapper()
+ .findAndRegisterModules();
+
+ @Test
+ void cpm_constraint_requires_max_and_currency() {
+ var cpm = new CpmConstraint(new BigDecimal("12.50"), "USD");
+ assertEquals(new BigDecimal("12.50"), cpm.max());
+ assertEquals("USD", cpm.currency());
+ }
+
+ @Test
+ void cpm_constraint_rejects_null_max() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new CpmConstraint(null, "USD"));
+ }
+
+ @Test
+ void cpm_constraint_rejects_blank_currency() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new CpmConstraint(BigDecimal.TEN, ""));
+ }
+
+ @Test
+ void cpm_constraint_round_trips_via_jackson() throws Exception {
+ var cpm = new CpmConstraint(new BigDecimal("8.25"), "EUR");
+ String json = mapper.writeValueAsString(cpm);
+ var back = mapper.readValue(json, CpmConstraint.class);
+
+ assertEquals(cpm.max().compareTo(back.max()), 0);
+ assertEquals(cpm.currency(), back.currency());
+ }
+
+ @Test
+ void impressions_constraint_requires_non_negative_min() {
+ var ic = new ImpressionsConstraint(100_000);
+ assertEquals(100_000, ic.min());
+ }
+
+ @Test
+ void impressions_constraint_rejects_negative() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new ImpressionsConstraint(-1));
+ }
+
+ @Test
+ void impressions_constraint_round_trips() throws Exception {
+ var ic = new ImpressionsConstraint(500_000);
+ String json = mapper.writeValueAsString(ic);
+ var back = mapper.readValue(json, ImpressionsConstraint.class);
+
+ assertEquals(ic.min(), back.min());
+ }
+
+ @Test
+ void flight_constraint_requires_at_least_one_bound() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new FlightConstraint(null, null));
+ }
+
+ @Test
+ void flight_constraint_accepts_start_only() {
+ var start = OffsetDateTime.of(2026, 10, 1, 0, 0, 0, 0, ZoneOffset.UTC);
+ var fc = new FlightConstraint(start, null);
+ assertEquals(start, fc.startNoLaterThan());
+ assertNull(fc.endNoEarlierThan());
+ }
+
+ @Test
+ void flight_constraint_accepts_both_bounds() {
+ var start = OffsetDateTime.of(2026, 10, 1, 0, 0, 0, 0, ZoneOffset.UTC);
+ var end = OffsetDateTime.of(2026, 12, 31, 23, 59, 59, 0, ZoneOffset.UTC);
+ var fc = new FlightConstraint(start, end);
+
+ assertEquals(start, fc.startNoLaterThan());
+ assertEquals(end, fc.endNoEarlierThan());
+ }
+
+ @Test
+ void flight_constraint_round_trips() throws Exception {
+ var start = OffsetDateTime.of(2026, 10, 1, 0, 0, 0, 0, ZoneOffset.UTC);
+ var fc = new FlightConstraint(start, null);
+ String json = mapper.writeValueAsString(fc);
+ var back = mapper.readValue(json, FlightConstraint.class);
+
+ assertEquals(fc.startNoLaterThan(), back.startNoLaterThan());
+ }
+}
diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java
new file mode 100644
index 0000000..e190cbb
--- /dev/null
+++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java
@@ -0,0 +1,123 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class RefineProposalsRequestTest {
+
+ private static String validKey() {
+ return "idem-" + UUID.randomUUID().toString().replace("-", "");
+ }
+
+ @Test
+ void builder_creates_valid_single_revise_request() {
+ var request = RefineProposalsRequest.builder()
+ .idempotencyKey(validKey())
+ .addRefinement(ProposalRefinement.revise("p-1", "lower CPM to $8"))
+ .build();
+
+ assertEquals(1, request.refinements().size());
+ assertEquals("p-1", request.refinements().get(0).proposalId());
+ assertEquals(RefinementAction.REVISE, request.refinements().get(0).action());
+ }
+
+ @Test
+ void builder_creates_batch_finalize_request() {
+ var request = RefineProposalsRequest.builder()
+ .idempotencyKey(validKey())
+ .addRefinement(ProposalRefinement.finalize("p-1"))
+ .addRefinement(ProposalRefinement.finalize("p-2"))
+ .addRefinement(ProposalRefinement.finalize("p-3"))
+ .build();
+
+ assertEquals(3, request.refinements().size());
+ request.refinements().forEach(r ->
+ assertEquals(RefinementAction.FINALIZE, r.action()));
+ }
+
+ @Test
+ void rejects_null_idempotency_key() {
+ var builder = RefineProposalsRequest.builder()
+ .addRefinement(ProposalRefinement.revise("p-1", "test"));
+
+ assertThrows(NullPointerException.class, builder::build);
+ }
+
+ @Test
+ void rejects_short_idempotency_key() {
+ assertThrows(IllegalArgumentException.class, () ->
+ RefineProposalsRequest.builder()
+ .idempotencyKey("too-short")
+ .addRefinement(ProposalRefinement.revise("p-1", "test"))
+ .build());
+ }
+
+ @Test
+ void rejects_empty_refinements() {
+ assertThrows(IllegalArgumentException.class, () ->
+ RefineProposalsRequest.builder()
+ .idempotencyKey(validKey())
+ .build());
+ }
+
+ @Test
+ void rejects_mixed_finalize_and_revise_batch() {
+ assertThrows(IllegalArgumentException.class, () ->
+ RefineProposalsRequest.builder()
+ .idempotencyKey(validKey())
+ .addRefinement(ProposalRefinement.finalize("p-1"))
+ .addRefinement(ProposalRefinement.revise("p-2", "change CPM"))
+ .build());
+ }
+
+ @Test
+ void rejects_duplicate_proposal_ids() {
+ assertThrows(IllegalArgumentException.class, () ->
+ RefineProposalsRequest.builder()
+ .idempotencyKey(validKey())
+ .addRefinement(ProposalRefinement.revise("p-1", "first"))
+ .addRefinement(ProposalRefinement.revise("p-1", "second"))
+ .build());
+ }
+
+ @Test
+ void rejects_batch_exceeding_max_size() {
+ var builder = RefineProposalsRequest.builder()
+ .idempotencyKey(validKey())
+ .maxBatchSize(2);
+
+ builder.addRefinement(ProposalRefinement.revise("p-1", "a"));
+ builder.addRefinement(ProposalRefinement.revise("p-2", "b"));
+ builder.addRefinement(ProposalRefinement.revise("p-3", "c"));
+
+ assertThrows(IllegalArgumentException.class, builder::build);
+ }
+
+ @Test
+ void refinements_list_is_immutable() {
+ var request = RefineProposalsRequest.builder()
+ .idempotencyKey(validKey())
+ .addRefinement(ProposalRefinement.revise("p-1", "test"))
+ .build();
+
+ assertThrows(UnsupportedOperationException.class, () ->
+ request.refinements().add(ProposalRefinement.revise("p-2", "x")));
+ }
+
+ @Test
+ void cancellation_refinement() {
+ var request = RefineProposalsRequest.builder()
+ .idempotencyKey(validKey())
+ .addRefinement(ProposalRefinement.cancel("p-1", "budget reallocated"))
+ .build();
+
+ var refinement = request.refinements().get(0);
+ assertEquals(RefinementAction.REVISE, refinement.action());
+ assertEquals(ChangeKind.CANCELLATION, refinement.changeKind());
+ }
+}
diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java
new file mode 100644
index 0000000..1cc47a7
--- /dev/null
+++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java
@@ -0,0 +1,143 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class RefinementResultTest {
+
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ @Test
+ void sealed_interface_permits_four_outcomes() {
+ assertTrue(RefinementResult.class.isSealed());
+
+ Class>[] permitted = RefinementResult.class.getPermittedSubclasses();
+ assertNotNull(permitted);
+ assertEquals(4, permitted.length);
+ }
+
+ @Test
+ void revised_round_trips_via_jackson() throws Exception {
+ ObjectNode proposal = mapper.createObjectNode();
+ proposal.put("proposal_id", "p-new");
+ proposal.put("proposal_status", "draft");
+
+ String json = """
+ {
+ "source_proposal_id": "p-1",
+ "outcome": "revised",
+ "proposal": {"proposal_id": "p-new", "proposal_status": "draft"}
+ }
+ """;
+
+ RefinementResult result = mapper.readValue(json, RefinementResult.class);
+ assertInstanceOf(RefinementResult.Revised.class, result);
+
+ RefinementResult.Revised revised = (RefinementResult.Revised) result;
+ assertEquals("p-1", revised.sourceProposalId());
+ assertEquals(RefinementOutcome.REVISED, revised.outcome());
+ assertEquals("p-new", revised.proposal().get("proposal_id").asText());
+ }
+
+ @Test
+ void partial_round_trips_with_unsatisfied_constraints() throws Exception {
+ String json = """
+ {
+ "source_proposal_id": "p-2",
+ "outcome": "partial",
+ "proposal": {"proposal_id": "p-new", "proposal_status": "draft"},
+ "notes": "CPM constraint could not be fully met",
+ "unsatisfied_constraints": ["cpm"]
+ }
+ """;
+
+ RefinementResult result = mapper.readValue(json, RefinementResult.class);
+ assertInstanceOf(RefinementResult.Partial.class, result);
+
+ RefinementResult.Partial partial = (RefinementResult.Partial) result;
+ assertEquals("CPM constraint could not be fully met", partial.notes());
+ assertEquals(List.of("cpm"), partial.unsatisfiedConstraints());
+ }
+
+ @Test
+ void finalized_round_trips() throws Exception {
+ String json = """
+ {
+ "source_proposal_id": "p-3",
+ "outcome": "finalized",
+ "proposal": {
+ "proposal_id": "p-committed",
+ "proposal_status": "committed",
+ "expires_at": "2026-10-01T00:00:00Z"
+ }
+ }
+ """;
+
+ RefinementResult result = mapper.readValue(json, RefinementResult.class);
+ assertInstanceOf(RefinementResult.Finalized.class, result);
+ assertEquals(RefinementOutcome.FINALIZED, result.outcome());
+ }
+
+ @Test
+ void unable_round_trips_with_reason() throws Exception {
+ String json = """
+ {
+ "source_proposal_id": "p-4",
+ "outcome": "unable",
+ "reason": "hold_unavailable",
+ "notes": "Inventory no longer available for this flight"
+ }
+ """;
+
+ RefinementResult result = mapper.readValue(json, RefinementResult.class);
+ assertInstanceOf(RefinementResult.Unable.class, result);
+
+ RefinementResult.Unable unable = (RefinementResult.Unable) result;
+ assertEquals("hold_unavailable", unable.reason());
+ assertNotNull(unable.notes());
+ }
+
+ @Test
+ void serialize_then_deserialize_round_trip() throws Exception {
+ ObjectNode proposal = mapper.createObjectNode();
+ proposal.put("proposal_id", "p-rt");
+ proposal.put("proposal_status", "draft");
+
+ RefinementResult original = new RefinementResult.Revised("src-1", proposal, null);
+
+ String json = mapper.writeValueAsString(original);
+ assertTrue(json.contains("\"outcome\":\"revised\""));
+ assertTrue(json.contains("\"source_proposal_id\":\"src-1\""));
+
+ RefinementResult deserialized = mapper.readValue(json, RefinementResult.class);
+ assertInstanceOf(RefinementResult.Revised.class, deserialized);
+ assertEquals("src-1", deserialized.sourceProposalId());
+ }
+
+ @Test
+ void pattern_matching_exhaustiveness() throws Exception {
+ String json = """
+ {
+ "source_proposal_id": "p-5",
+ "outcome": "unable",
+ "reason": "commercially_declined"
+ }
+ """;
+
+ RefinementResult result = mapper.readValue(json, RefinementResult.class);
+
+ String label = switch (result) {
+ case RefinementResult.Revised r -> "revised: " + r.sourceProposalId();
+ case RefinementResult.Partial p -> "partial: " + p.notes();
+ case RefinementResult.Finalized f -> "finalized: " + f.sourceProposalId();
+ case RefinementResult.Unable u -> "unable: " + u.reason();
+ };
+
+ assertEquals("unable: commercially_declined", label);
+ }
+}
diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java
new file mode 100644
index 0000000..9f21d65
--- /dev/null
+++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java
@@ -0,0 +1,259 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ResponseVerifierTest {
+
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ private static String key() {
+ return "idem-" + UUID.randomUUID().toString().replace("-", "");
+ }
+
+ @Test
+ void valid_revised_response_passes_verification() throws Exception {
+ var request = RefineProposalsRequest.builder()
+ .idempotencyKey(key())
+ .addRefinement(ProposalRefinement.revise("src-1", "lower CPM"))
+ .build();
+
+ ObjectNode proposal = draftProposal("new-1", "src-1");
+ String json = """
+ {
+ "status": "completed",
+ "results": [{
+ "source_proposal_id": "src-1",
+ "outcome": "revised",
+ "proposal": %s
+ }],
+ "products": []
+ }
+ """.formatted(proposal.toString());
+
+ var response = mapper.readValue(json, RefineProposalsResponse.class);
+ List violations = ResponseVerifier.verify(request, response);
+
+ assertTrue(violations.isEmpty(), "Expected no violations but got: " + violations);
+ }
+
+ @Test
+ void detects_result_count_mismatch() throws Exception {
+ var request = RefineProposalsRequest.builder()
+ .idempotencyKey(key())
+ .addRefinement(ProposalRefinement.revise("src-1", "test"))
+ .addRefinement(ProposalRefinement.revise("src-2", "test"))
+ .build();
+
+ String json = """
+ {
+ "status": "completed",
+ "results": [{
+ "source_proposal_id": "src-1",
+ "outcome": "unable",
+ "reason": "commercially_declined"
+ }],
+ "products": []
+ }
+ """;
+
+ var response = mapper.readValue(json, RefineProposalsResponse.class);
+ List violations = ResponseVerifier.verify(request, response);
+
+ assertTrue(violations.stream().anyMatch(v -> v.contains("result count")));
+ }
+
+ @Test
+ void detects_ordering_mismatch() throws Exception {
+ var request = RefineProposalsRequest.builder()
+ .idempotencyKey(key())
+ .addRefinement(ProposalRefinement.revise("src-1", "a"))
+ .addRefinement(ProposalRefinement.revise("src-2", "b"))
+ .build();
+
+ String json = """
+ {
+ "status": "completed",
+ "results": [
+ {"source_proposal_id": "src-2", "outcome": "unable", "reason": "test"},
+ {"source_proposal_id": "src-1", "outcome": "unable", "reason": "test"}
+ ],
+ "products": []
+ }
+ """;
+
+ var response = mapper.readValue(json, RefineProposalsResponse.class);
+ List violations = ResponseVerifier.verify(request, response);
+
+ assertTrue(violations.stream().anyMatch(v -> v.contains("does not match request")));
+ }
+
+ @Test
+ void detects_mixed_finalize_in_response() throws Exception {
+ var request = RefineProposalsRequest.builder()
+ .idempotencyKey(key())
+ .addRefinement(ProposalRefinement.finalize("src-1"))
+ .addRefinement(ProposalRefinement.finalize("src-2"))
+ .build();
+
+ ObjectNode committed = committedProposal("new-1", "src-1");
+
+ // Construct a response where one result is finalized but the other
+ // is revised (violates atomicity)
+ String json = """
+ {
+ "status": "completed",
+ "results": [
+ {
+ "source_proposal_id": "src-1",
+ "outcome": "finalized",
+ "proposal": %s
+ },
+ {
+ "source_proposal_id": "src-2",
+ "outcome": "unable",
+ "reason": "hold_unavailable"
+ }
+ ],
+ "products": []
+ }
+ """.formatted(committed.toString());
+
+ var response = mapper.readValue(json, RefineProposalsResponse.class);
+ List violations = ResponseVerifier.verify(request, response);
+
+ assertTrue(violations.stream().anyMatch(v -> v.contains("finalize batches")));
+ }
+
+ @Test
+ void detects_missing_lineage() throws Exception {
+ var request = RefineProposalsRequest.builder()
+ .idempotencyKey(key())
+ .addRefinement(ProposalRefinement.revise("src-1", "test"))
+ .build();
+
+ // Proposal without parent_proposal_id
+ ObjectNode proposal = mapper.createObjectNode();
+ proposal.put("proposal_id", "new-1");
+ proposal.put("proposal_status", "draft");
+
+ String json = """
+ {
+ "status": "completed",
+ "results": [{
+ "source_proposal_id": "src-1",
+ "outcome": "revised",
+ "proposal": %s
+ }],
+ "products": []
+ }
+ """.formatted(proposal.toString());
+
+ var response = mapper.readValue(json, RefineProposalsResponse.class);
+ List violations = ResponseVerifier.verify(request, response);
+
+ assertTrue(violations.stream()
+ .anyMatch(v -> v.contains("parent_proposal_id")));
+ }
+
+ @Test
+ void detects_wrong_proposal_status_for_finalized() throws Exception {
+ var request = RefineProposalsRequest.builder()
+ .idempotencyKey(key())
+ .addRefinement(ProposalRefinement.finalize("src-1"))
+ .build();
+
+ // Return a "draft" instead of "committed" for a finalized outcome
+ ObjectNode proposal = draftProposal("new-1", "src-1");
+ proposal.put("expires_at", "2026-10-01T00:00:00Z");
+
+ String json = """
+ {
+ "status": "completed",
+ "results": [{
+ "source_proposal_id": "src-1",
+ "outcome": "finalized",
+ "proposal": %s
+ }],
+ "products": []
+ }
+ """.formatted(proposal.toString());
+
+ var response = mapper.readValue(json, RefineProposalsResponse.class);
+ List violations = ResponseVerifier.verify(request, response);
+
+ assertTrue(violations.stream()
+ .anyMatch(v -> v.contains("proposal_status") && v.contains("committed")));
+ }
+
+ @Test
+ void skips_verification_for_async_responses() throws Exception {
+ var request = RefineProposalsRequest.builder()
+ .idempotencyKey(key())
+ .addRefinement(ProposalRefinement.revise("src-1", "test"))
+ .build();
+
+ String json = """
+ {"status": "submitted", "task_id": "task-123"}
+ """;
+
+ var response = mapper.readValue(json, RefineProposalsResponse.class);
+ List violations = ResponseVerifier.verify(request, response);
+
+ assertTrue(violations.isEmpty());
+ }
+
+ @Test
+ void digest_verification_catches_mismatch() throws Exception {
+ ObjectNode terms = mapper.createObjectNode();
+ terms.put("price", 10);
+
+ ObjectNode proposal = mapper.createObjectNode();
+ proposal.put("proposal_id", "p-1");
+ proposal.put("parent_proposal_id", "src-1");
+ proposal.put("proposal_status", "draft");
+ proposal.set("commercial_terms", terms);
+ proposal.put("terms_digest", "sha256:AAAA_wrong_digest");
+
+ String json = """
+ {
+ "status": "completed",
+ "results": [{
+ "source_proposal_id": "src-1",
+ "outcome": "revised",
+ "proposal": %s
+ }],
+ "products": []
+ }
+ """.formatted(proposal.toString());
+
+ var response = mapper.readValue(json, RefineProposalsResponse.class);
+ List violations = ResponseVerifier.verifyDigests(response);
+
+ assertFalse(violations.isEmpty());
+ assertTrue(violations.get(0).contains("terms_digest"));
+ }
+
+ // -- helpers --
+
+ private ObjectNode draftProposal(String id, String parentId) {
+ ObjectNode p = mapper.createObjectNode();
+ p.put("proposal_id", id);
+ p.put("parent_proposal_id", parentId);
+ p.put("proposal_status", "draft");
+ return p;
+ }
+
+ private ObjectNode committedProposal(String id, String parentId) {
+ ObjectNode p = draftProposal(id, parentId);
+ p.put("proposal_status", "committed");
+ p.put("expires_at", "2026-12-31T23:59:59Z");
+ return p;
+ }
+}
diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java
new file mode 100644
index 0000000..bf21503
--- /dev/null
+++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java
@@ -0,0 +1,184 @@
+package org.adcontextprotocol.adcp.negotiation;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class TermsDigestTest {
+
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ @Test
+ void compute_returns_sha256_prefixed_digest() {
+ ObjectNode terms = mapper.createObjectNode();
+ terms.put("total_budget", 50000);
+ terms.put("currency", "USD");
+
+ String digest = TermsDigest.compute(terms);
+
+ assertTrue(digest.startsWith("sha256:"));
+ // base64url: no padding, no +, no /
+ String encoded = digest.substring(7);
+ assertFalse(encoded.contains("="));
+ assertFalse(encoded.contains("+"));
+ assertFalse(encoded.contains("/"));
+ }
+
+ @Test
+ void compute_is_deterministic() {
+ ObjectNode terms = mapper.createObjectNode();
+ terms.put("currency", "USD");
+ terms.put("total_budget", 50000);
+
+ String digest1 = TermsDigest.compute(terms);
+ String digest2 = TermsDigest.compute(terms);
+
+ assertEquals(digest1, digest2);
+ }
+
+ @Test
+ void compute_is_key_order_independent() {
+ ObjectNode terms1 = mapper.createObjectNode();
+ terms1.put("currency", "USD");
+ terms1.put("total_budget", 50000);
+
+ ObjectNode terms2 = mapper.createObjectNode();
+ terms2.put("total_budget", 50000);
+ terms2.put("currency", "USD");
+
+ assertEquals(TermsDigest.compute(terms1), TermsDigest.compute(terms2));
+ }
+
+ @Test
+ void verify_succeeds_for_matching_digest() {
+ ObjectNode terms = mapper.createObjectNode();
+ terms.put("price", 10.5);
+
+ String digest = TermsDigest.compute(terms);
+
+ assertTrue(TermsDigest.verify(digest, terms));
+ }
+
+ @Test
+ void verify_fails_for_tampered_terms() {
+ ObjectNode original = mapper.createObjectNode();
+ original.put("price", 10.5);
+
+ String digest = TermsDigest.compute(original);
+
+ ObjectNode tampered = mapper.createObjectNode();
+ tampered.put("price", 15.0);
+
+ assertFalse(TermsDigest.verify(digest, tampered));
+ }
+
+ @Test
+ void verify_fails_for_null_digest() {
+ ObjectNode terms = mapper.createObjectNode();
+ assertFalse(TermsDigest.verify(null, terms));
+ }
+
+ @Test
+ void verify_fails_for_wrong_prefix() {
+ ObjectNode terms = mapper.createObjectNode();
+ assertFalse(TermsDigest.verify("md5:abc", terms));
+ }
+
+ @Test
+ void areDistinct_detects_different_terms() {
+ ObjectNode terms1 = mapper.createObjectNode();
+ terms1.put("price", 10);
+
+ ObjectNode terms2 = mapper.createObjectNode();
+ terms2.put("price", 20);
+
+ assertTrue(TermsDigest.areDistinct(terms1, terms2));
+ }
+
+ @Test
+ void areDistinct_detects_identical_terms() {
+ ObjectNode terms1 = mapper.createObjectNode();
+ terms1.put("price", 10);
+
+ ObjectNode terms2 = mapper.createObjectNode();
+ terms2.put("price", 10);
+
+ assertFalse(TermsDigest.areDistinct(terms1, terms2));
+ }
+
+ @Test
+ void jcs_canonicalization_handles_nested_objects() {
+ ObjectNode inner = mapper.createObjectNode();
+ inner.put("b", 2);
+ inner.put("a", 1);
+
+ ObjectNode outer = mapper.createObjectNode();
+ outer.put("z", "last");
+ outer.set("nested", inner);
+
+ byte[] canonical = TermsDigest.canonicalize(outer);
+ String result = new String(canonical, StandardCharsets.UTF_8);
+
+ // Keys should be sorted: nested before z, and within nested: a before b
+ assertTrue(result.indexOf("\"nested\"") < result.indexOf("\"z\""));
+ assertTrue(result.indexOf("\"a\"") < result.indexOf("\"b\""));
+ }
+
+ @Test
+ void jcs_number_string_handles_integers() {
+ assertEquals("0", TermsDigest.jcsNumberString(0.0));
+ assertEquals("42", TermsDigest.jcsNumberString(42.0));
+ assertEquals("-17", TermsDigest.jcsNumberString(-17.0));
+ }
+
+ @Test
+ void jcs_number_string_handles_decimals() {
+ String result = TermsDigest.jcsNumberString(3.14);
+ assertEquals("3.14", result);
+ }
+
+ @Test
+ void jcs_number_string_strips_dot_zero_in_exponent() {
+ // Java Double.toString(1e-7) → "1.0E-7"; JCS requires "1e-7"
+ assertEquals("1e-7", TermsDigest.jcsNumberString(1e-7));
+ assertEquals("1e-20", TermsDigest.jcsNumberString(1e-20));
+ }
+
+ @Test
+ void jcs_number_string_keeps_fractional_exponent() {
+ // 1.5e10 = 15000000000 → integer path
+ assertEquals("15000000000", TermsDigest.jcsNumberString(1.5e10));
+ // 1.5e21 stays exponential since > 1e21
+ assertEquals("1.5e21", TermsDigest.jcsNumberString(1.5e21));
+ }
+
+ @Test
+ void jcs_handles_string_escaping() {
+ ObjectNode node = mapper.createObjectNode();
+ node.put("msg", "hello\nworld\t\"quoted\"");
+
+ byte[] canonical = TermsDigest.canonicalize(node);
+ String result = new String(canonical, StandardCharsets.UTF_8);
+
+ assertTrue(result.contains("\\n"));
+ assertTrue(result.contains("\\t"));
+ assertTrue(result.contains("\\\""));
+ }
+
+ @Test
+ void jcs_handles_boolean_and_null() {
+ ObjectNode node = mapper.createObjectNode();
+ node.put("flag", true);
+ node.putNull("empty");
+
+ byte[] canonical = TermsDigest.canonicalize(node);
+ String result = new String(canonical, StandardCharsets.UTF_8);
+
+ assertTrue(result.contains("true"));
+ assertTrue(result.contains("null"));
+ }
+}