diff --git a/.changeset/negotiation-proposal-apis.md b/.changeset/negotiation-proposal-apis.md new file mode 100644 index 0000000..2618169 --- /dev/null +++ b/.changeset/negotiation-proposal-apis.md @@ -0,0 +1,11 @@ +--- +"adcp": minor +"adcp-server": minor +"adcp-testing": minor +--- + +feat(negotiation): add first-class buyer and seller proposal APIs for AdCP 3.2 + +Introduces the `negotiation` package with sealed outcome models, capability-aware +request builders, terms digest verification (RFC 8785 JCS), response verification +utilities, and server-side handler interface for `refine_proposals`. diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalHandler.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalHandler.java new file mode 100644 index 0000000..4bf120b --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalHandler.java @@ -0,0 +1,94 @@ +package org.adcontextprotocol.adcp.server.negotiation; + +import org.adcontextprotocol.adcp.negotiation.ProposalRefinement; +import org.adcontextprotocol.adcp.negotiation.RefinementCapability; +import org.adcontextprotocol.adcp.negotiation.RefinementResult; +import org.adcontextprotocol.adcp.server.AdcpContext; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Server-side handler for proposal refinement operations. + * + *
Adopters implement this interface to handle incoming + * {@code refine_proposals} requests. The framework performs + * batch preflight validation (idempotency, cardinality, dimension + * checks) before delegating to the handler. Commercial pricing + * and optimization decisions are left to the application callback. + * + *
Example: + *
{@code
+ * public class MyProposalHandler implements ProposalHandler {
+ * @Override
+ * public RefinementCapability capability() {
+ * return new RefinementCapability(
+ * Set.of("product_changes", "total_budget"),
+ * 10, true);
+ * }
+ *
+ * @Override
+ * public List refine(
+ * List refinements,
+ * String idempotencyKey, AdcpContext ctx) {
+ * // commercial logic here
+ * }
+ * }
+ * }
+ */
+public interface ProposalHandler {
+
+ /**
+ * Declares this seller's refinement capabilities.
+ *
+ * The returned capability is used for: + *
The framework has already validated: + *
The handler is responsible for: + *
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 Every proposal produced by {@code refine_proposals} must carry
+ * {@code parent_proposal_id} equal to the request's source, a fresh
+ * {@code proposal_id}, and a {@code terms_digest} matching its
+ * {@code commercial_terms}. This utility enforces those invariants.
+ */
+public final class ProposalSuccessor {
+
+ private ProposalSuccessor() {}
+
+ /**
+ * Stamps a draft proposal node with immutable lineage fields.
+ * Sets {@code proposal_id}, {@code parent_proposal_id}, {@code proposal_status},
+ * and recomputes {@code terms_digest} from {@code commercial_terms}.
+ *
+ * @param draft mutable proposal node to stamp
+ * @param sourceProposalId the source proposal this was forked from
+ * @return the same node, mutated, for chaining
+ */
+ public static ObjectNode stamp(ObjectNode draft, String sourceProposalId) {
+ Objects.requireNonNull(draft, "draft is required");
+ Objects.requireNonNull(sourceProposalId, "sourceProposalId is required");
+
+ if (!draft.has("proposal_id") || draft.get("proposal_id").isNull()) {
+ draft.put("proposal_id", UUID.randomUUID().toString());
+ }
+ draft.put("parent_proposal_id", sourceProposalId);
+
+ if (!draft.has("proposal_status")) {
+ draft.put("proposal_status", "draft");
+ }
+
+ JsonNode terms = draft.get("commercial_terms");
+ if (terms != null && !terms.isNull()) {
+ draft.put("terms_digest", TermsDigest.compute(terms));
+ }
+
+ return draft;
+ }
+
+ /**
+ * Stamps a committed (finalized) proposal. Sets status to "committed"
+ * and requires {@code expires_at}.
+ */
+ public static ObjectNode stampFinalized(ObjectNode draft, String sourceProposalId,
+ String expiresAt) {
+ Objects.requireNonNull(expiresAt, "expiresAt is required for finalized proposals");
+ stamp(draft, sourceProposalId);
+ draft.put("proposal_status", "committed");
+ draft.put("expires_at", expiresAt);
+ return draft;
+ }
+}
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-server/src/test/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessorTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessorTest.java
new file mode 100644
index 0000000..d9de3fb
--- /dev/null
+++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessorTest.java
@@ -0,0 +1,57 @@
+package org.adcontextprotocol.adcp.server.negotiation;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.adcontextprotocol.adcp.negotiation.TermsDigest;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ProposalSuccessorTest {
+
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ @Test
+ void stamp_sets_lineage_and_digest() {
+ ObjectNode terms = mapper.createObjectNode().put("price", 42);
+ ObjectNode draft = mapper.createObjectNode();
+ draft.set("commercial_terms", terms);
+
+ ProposalSuccessor.stamp(draft, "parent-123");
+
+ assertEquals("parent-123", draft.get("parent_proposal_id").asText());
+ assertEquals("draft", draft.get("proposal_status").asText());
+ assertNotNull(draft.get("proposal_id"));
+ assertTrue(TermsDigest.verify(draft.get("terms_digest").asText(), terms));
+ }
+
+ @Test
+ void stamp_preserves_existing_proposal_id() {
+ ObjectNode draft = mapper.createObjectNode();
+ draft.put("proposal_id", "keep-this");
+
+ ProposalSuccessor.stamp(draft, "parent-1");
+
+ assertEquals("keep-this", draft.get("proposal_id").asText());
+ }
+
+ @Test
+ void stamp_finalized_sets_committed_status_and_expiry() {
+ ObjectNode terms = mapper.createObjectNode().put("total", 10000);
+ ObjectNode draft = mapper.createObjectNode();
+ draft.set("commercial_terms", terms);
+
+ ProposalSuccessor.stampFinalized(draft, "src-1", "2026-12-31T23:59:59Z");
+
+ assertEquals("committed", draft.get("proposal_status").asText());
+ assertEquals("2026-12-31T23:59:59Z", draft.get("expires_at").asText());
+ assertEquals("src-1", draft.get("parent_proposal_id").asText());
+ }
+
+ @Test
+ void rejects_null_source() {
+ ObjectNode draft = mapper.createObjectNode();
+ assertThrows(NullPointerException.class,
+ () -> ProposalSuccessor.stamp(draft, null));
+ }
+}
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 Maps to {@code proposal-refinement.json}. Use {@link #builder(String)}
+ * for multi-field requests; factory methods cover common single-concern cases.
+ *
+ * @param proposalId the source proposal to refine
+ * @param action revise or finalize
+ * @param changeKind amendment (default) or cancellation; only valid for accepted sources
+ * @param ask semantic commercial changes or cancellation reason
+ * @param criteria structured discovery changes; each present field replaces that criterion
+ * @param constraints typed hard requirements (budget, CPM, impressions, flight)
+ * @param productChanges product IDs mapped to include/omit actions
+ * @param alternatives alternatives request ({@code {"count": 2..10}})
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record ProposalRefinement(
+ @JsonProperty("proposal_id") String proposalId,
+ @Nullable @JsonProperty("action") RefinementAction action,
+ @Nullable @JsonProperty("change_kind") ChangeKind changeKind,
+ @Nullable @JsonProperty("ask") String ask,
+ @Nullable @JsonProperty("criteria") JsonNode criteria,
+ @Nullable @JsonProperty("constraints") RefinementConstraints constraints,
+ @Nullable @JsonProperty("product_changes") JsonNode productChanges,
+ @Nullable @JsonProperty("alternatives") JsonNode alternatives) {
+
+ /** Protocol maximum for alternatives.count. */
+ public static final int MAX_ALTERNATIVES = 10;
+
+ public ProposalRefinement {
+ Objects.requireNonNull(proposalId, "proposal_id is required");
+ if (proposalId.isBlank()) {
+ throw new IllegalArgumentException("proposal_id must not be blank");
+ }
+ if (alternatives != null) {
+ JsonNode count = alternatives.get("count");
+ if (count == null || !count.isInt()) {
+ throw new IllegalArgumentException("alternatives.count must be an integer");
+ }
+ int c = count.asInt();
+ if (c < 2 || c > MAX_ALTERNATIVES) {
+ throw new IllegalArgumentException(
+ "alternatives.count must be 2-" + MAX_ALTERNATIVES + ", got " + c);
+ }
+ }
+ }
+
+ /**
+ * Creates a revise refinement with a semantic ask.
+ */
+ public static ProposalRefinement revise(String proposalId, String ask) {
+ return new ProposalRefinement(proposalId, RefinementAction.REVISE,
+ null, ask, null, null, null, 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, null, null, null);
+ }
+
+ /**
+ * Creates a revise refinement with typed constraints.
+ */
+ public static ProposalRefinement reviseWithConstraints(
+ String proposalId, RefinementConstraints constraints) {
+ return new ProposalRefinement(proposalId, RefinementAction.REVISE,
+ null, null, null, constraints, null, null);
+ }
+
+ /**
+ * Creates a finalize refinement (no term changes, reserves inventory).
+ */
+ public static ProposalRefinement finalize(String proposalId) {
+ return new ProposalRefinement(proposalId, RefinementAction.FINALIZE,
+ null, null, null, 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, null, null, null);
+ }
+
+ public static Builder builder(String proposalId) {
+ return new Builder(proposalId);
+ }
+
+ public static final class Builder {
+ private final String proposalId;
+ private @Nullable RefinementAction action;
+ private @Nullable ChangeKind changeKind;
+ private @Nullable String ask;
+ private @Nullable JsonNode criteria;
+ private @Nullable RefinementConstraints constraints;
+ private @Nullable JsonNode productChanges;
+ private @Nullable JsonNode alternatives;
+
+ private Builder(String proposalId) {
+ this.proposalId = Objects.requireNonNull(proposalId);
+ }
+
+ public Builder action(RefinementAction action) {
+ this.action = action;
+ return this;
+ }
+
+ public Builder changeKind(ChangeKind changeKind) {
+ this.changeKind = changeKind;
+ return this;
+ }
+
+ public Builder ask(String ask) {
+ this.ask = ask;
+ return this;
+ }
+
+ public Builder criteria(JsonNode criteria) {
+ this.criteria = criteria;
+ return this;
+ }
+
+ public Builder constraints(RefinementConstraints constraints) {
+ this.constraints = constraints;
+ return this;
+ }
+
+ public Builder productChanges(JsonNode productChanges) {
+ this.productChanges = productChanges;
+ return this;
+ }
+
+ public Builder alternatives(JsonNode alternatives) {
+ this.alternatives = alternatives;
+ return this;
+ }
+
+ public ProposalRefinement build() {
+ return new ProposalRefinement(proposalId, action, changeKind,
+ ask, criteria, constraints, productChanges, alternatives);
+ }
+ }
+}
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..876068a
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java
@@ -0,0 +1,167 @@
+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 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 {@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..f670342
--- /dev/null
+++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java
@@ -0,0 +1,41 @@
+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 maxAlternatives maximum alternatives.count the seller accepts (default: 10)
+ * @param supportsFinalize whether this seller supports the finalize action
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record RefinementCapability(
+ @Nullable @JsonProperty("supported_dimensions") Set 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:
+ * 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 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 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 Currently checks that unsatisfied_constraints is present
+ * and non-empty for partial outcomes.
+ */
+ public static void verifyPartialInvariant(RefineProposalsResponse response,
+ List 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 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 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..99b783c
--- /dev/null
+++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java
@@ -0,0 +1,178 @@
+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());
+ }
+
+ @Test
+ void budget_constraint_requires_currency() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new TotalBudgetConstraint(BigDecimal.TEN, null, null));
+ }
+
+ @Test
+ void budget_constraint_requires_at_least_one_bound() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new TotalBudgetConstraint(null, null, "USD"));
+ }
+
+ @Test
+ void budget_constraint_rejects_min_exceeding_max() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new TotalBudgetConstraint(
+ new BigDecimal("10000"), new BigDecimal("5000"), "USD"));
+ }
+
+ @Test
+ void budget_constraint_accepts_valid_range() {
+ var bc = new TotalBudgetConstraint(
+ new BigDecimal("5000"), new BigDecimal("10000"), "USD");
+ assertEquals(new BigDecimal("5000"), bc.min());
+ assertEquals(new BigDecimal("10000"), bc.max());
+ assertEquals("USD", bc.currency());
+ }
+
+ @Test
+ void budget_constraint_accepts_max_only() {
+ var bc = new TotalBudgetConstraint(null, new BigDecimal("50000"), "EUR");
+ assertNull(bc.min());
+ assertEquals(new BigDecimal("50000"), bc.max());
+ }
+
+ @Test
+ void budget_constraint_round_trips() throws Exception {
+ var bc = new TotalBudgetConstraint(
+ new BigDecimal("1000"), new BigDecimal("5000"), "GBP");
+ String json = mapper.writeValueAsString(bc);
+ var back = mapper.readValue(json, TotalBudgetConstraint.class);
+
+ assertEquals(0, bc.min().compareTo(back.min()));
+ assertEquals(0, bc.max().compareTo(back.max()));
+ assertEquals(bc.currency(), back.currency());
+ }
+
+ @Test
+ void refinement_constraints_requires_at_least_one() {
+ assertThrows(IllegalArgumentException.class,
+ () -> new RefinementConstraints(null, null, null, null));
+ }
+
+ @Test
+ void refinement_constraints_accepts_single_dimension() {
+ var rc = new RefinementConstraints(
+ new TotalBudgetConstraint(null, new BigDecimal("10000"), "USD"),
+ null, null, null);
+ assertNotNull(rc.totalBudget());
+ assertNull(rc.cpm());
+ }
+
+ @Test
+ void refinement_constraints_round_trips() throws Exception {
+ var rc = new RefinementConstraints(
+ new TotalBudgetConstraint(new BigDecimal("1000"), null, "USD"),
+ new CpmConstraint(new BigDecimal("8.50"), "USD"),
+ new ImpressionsConstraint(100_000),
+ null);
+ String json = mapper.writeValueAsString(rc);
+ var back = mapper.readValue(json, RefinementConstraints.class);
+
+ assertNotNull(back.totalBudget());
+ assertNotNull(back.cpm());
+ assertNotNull(back.impressions());
+ assertNull(back.flight());
+ }
+}
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..0bc50c3
--- /dev/null
+++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java
@@ -0,0 +1,235 @@
+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.math.BigDecimal;
+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());
+ }
+
+ @Test
+ void rejects_alternatives_count_exceeding_protocol_max() {
+ var mapper = new ObjectMapper();
+ ObjectNode alt = mapper.createObjectNode().put("count", 11);
+
+ assertThrows(IllegalArgumentException.class, () ->
+ ProposalRefinement.builder("p-1")
+ .action(RefinementAction.REVISE)
+ .ask("give me options")
+ .alternatives(alt)
+ .build());
+ }
+
+ @Test
+ void rejects_alternatives_count_below_minimum() {
+ var mapper = new ObjectMapper();
+ ObjectNode alt = mapper.createObjectNode().put("count", 1);
+
+ assertThrows(IllegalArgumentException.class, () ->
+ ProposalRefinement.builder("p-1")
+ .action(RefinementAction.REVISE)
+ .alternatives(alt)
+ .build());
+ }
+
+ @Test
+ void accepts_valid_alternatives_count() {
+ var mapper = new ObjectMapper();
+ ObjectNode alt = mapper.createObjectNode().put("count", 5);
+
+ var refinement = ProposalRefinement.builder("p-1")
+ .action(RefinementAction.REVISE)
+ .ask("five options")
+ .alternatives(alt)
+ .build();
+
+ assertEquals(5, refinement.alternatives().get("count").asInt());
+ }
+
+ @Test
+ void rejects_alternatives_exceeding_seller_ceiling() {
+ var mapper = new ObjectMapper();
+ ObjectNode alt = mapper.createObjectNode().put("count", 5);
+
+ assertThrows(IllegalArgumentException.class, () ->
+ RefineProposalsRequest.builder()
+ .idempotencyKey(validKey())
+ .maxAlternatives(3)
+ .addRefinement(ProposalRefinement.builder("p-1")
+ .action(RefinementAction.REVISE)
+ .ask("options")
+ .alternatives(alt)
+ .build())
+ .build());
+ }
+
+ @Test
+ void revise_with_constraints() {
+ var constraints = new RefinementConstraints(
+ new TotalBudgetConstraint(new BigDecimal("5000"), new BigDecimal("10000"), "USD"),
+ null, null, null);
+
+ var refinement = ProposalRefinement.reviseWithConstraints("p-1", constraints);
+ assertNotNull(refinement.constraints());
+ assertEquals("USD", refinement.constraints().totalBudget().currency());
+ }
+
+ @Test
+ void builder_creates_multi_field_refinement() {
+ var mapper = new ObjectMapper();
+ var constraints = new RefinementConstraints(
+ new TotalBudgetConstraint(null, new BigDecimal("50000"), "EUR"),
+ new CpmConstraint(new BigDecimal("12.50"), "EUR"),
+ null, null);
+ ObjectNode alt = mapper.createObjectNode().put("count", 3);
+
+ var refinement = ProposalRefinement.builder("p-1")
+ .action(RefinementAction.REVISE)
+ .ask("lower CPM with alternatives")
+ .constraints(constraints)
+ .alternatives(alt)
+ .build();
+
+ assertEquals("p-1", refinement.proposalId());
+ assertNotNull(refinement.constraints());
+ assertNotNull(refinement.alternatives());
+ assertEquals("lower CPM with alternatives", refinement.ask());
+ }
+
+ @Test
+ void idempotency_replay_response() throws Exception {
+ var mapper = new ObjectMapper();
+ String json = """
+ {
+ "status": "completed",
+ "replayed": true,
+ "results": [{
+ "source_proposal_id": "src-1",
+ "outcome": "revised",
+ "proposal": {"proposal_id": "p-new", "parent_proposal_id": "src-1", "proposal_status": "draft"}
+ }]
+ }
+ """;
+
+ var response = mapper.readValue(json, RefineProposalsResponse.class);
+ assertTrue(response.isCompleted());
+ assertEquals(Boolean.TRUE, response.replayed());
+ }
+}
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..672e417
--- /dev/null
+++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java
@@ -0,0 +1,374 @@
+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{@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.
+ *
+ *