From f65c2524440e4b7dca74cb4b2c8253f4edf40db9 Mon Sep 17 00:00:00 2001 From: garvitkaushik-123 Date: Sun, 16 Aug 2026 01:22:41 +0530 Subject: [PATCH 1/6] feat(negotiation): add first-class buyer and seller proposal APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the `negotiation` package across adcp, adcp-server, and adcp-testing modules for the refine_proposals tool surface (AdCP 3.2). Client (adcp module): - Sealed RefinementResult with four outcomes (revised/partial/finalized/unable) supporting exhaustive pattern matching - Capability-aware RefineProposalsRequest builder enforcing batch size ceilings, unique proposal IDs, and finalize-only homogeneity - Three typed constraints from the 3.2 delta: CpmConstraint, ImpressionsConstraint, FlightConstraint - TermsDigest utility implementing RFC 8785 JCS canonicalization for SHA-256 digest computation and verification - ResponseVerifier checking result ordering, lineage (parent_proposal_id), finalize atomicity, outcome structural constraints, partial invariant, unique alternatives, and digest integrity - AdcpClient.refineProposals() convenience method - UnsupportedRefinementDetails for typed UNSUPPORTED_FEATURE error recovery - RefinementCapability with product_changes dimension key (post-rename) Server (adcp-server module): - ProposalHandler interface with capability declaration, batch preflight hook, and refine callback — commercial decisions stay in the application Testing (adcp-testing module): - NegotiationFixtures with factory methods for common scenarios: single revise, batch finalize, constraint objects, draft/committed proposals Covers the unsatisfied_constraints-is-open-strings design, reason code precedence (constraint_unsatisfiable > commercially_declined), lineage verification, and the hold_unavailable/batch_aborted reason codes from the post-red-team protocol delta. Ref: adcontextprotocol/adcp-sdk-java#36 --- .changeset/negotiation-proposal-apis.md | 11 + .../server/negotiation/ProposalHandler.java | 94 ++++++ .../adcp/server/negotiation/package-info.java | 7 + .../negotiation/NegotiationFixtures.java | 140 +++++++++ .../testing/negotiation/package-info.java | 7 + .../adcontextprotocol/adcp/AdcpClient.java | 17 ++ .../adcp/negotiation/ChangeKind.java | 31 ++ .../adcp/negotiation/CpmConstraint.java | 24 ++ .../adcp/negotiation/FlightConstraint.java | 26 ++ .../negotiation/ImpressionsConstraint.java | 16 + .../adcp/negotiation/ProposalRefinement.java | 67 +++++ .../negotiation/RefineProposalsRequest.java | 155 ++++++++++ .../negotiation/RefineProposalsResponse.java | 46 +++ .../adcp/negotiation/RefinementAction.java | 35 +++ .../negotiation/RefinementCapability.java | 35 +++ .../adcp/negotiation/RefinementOutcome.java | 39 +++ .../adcp/negotiation/RefinementResult.java | 106 +++++++ .../adcp/negotiation/ResponseVerifier.java | 275 ++++++++++++++++++ .../adcp/negotiation/TermsDigest.java | 216 ++++++++++++++ .../UnsupportedRefinementDetails.java | 21 ++ .../adcp/negotiation/package-info.java | 13 + .../adcp/negotiation/ConstraintsTest.java | 100 +++++++ .../RefineProposalsRequestTest.java | 123 ++++++++ .../negotiation/RefinementResultTest.java | 126 ++++++++ .../negotiation/ResponseVerifierTest.java | 259 +++++++++++++++++ .../adcp/negotiation/TermsDigestTest.java | 169 +++++++++++ 26 files changed, 2158 insertions(+) create mode 100644 .changeset/negotiation-proposal-apis.md create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalHandler.java create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/package-info.java create mode 100644 adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/NegotiationFixtures.java create mode 100644 adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/package-info.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ChangeKind.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/CpmConstraint.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/FlightConstraint.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ImpressionsConstraint.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsResponse.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementAction.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementOutcome.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementResult.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/UnsupportedRefinementDetails.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/package-info.java create mode 100644 adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java create mode 100644 adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java create mode 100644 adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java create mode 100644 adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java create mode 100644 adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java 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: + *

+ */ + RefinementCapability capability(); + + /** + * Handles a batch of refinement operations. + * + *

The framework has already validated: + *

+ * + *

The handler is responsible for: + *

+ * + * @param refinements validated refinement entries + * @param idempotencyKey client-provided idempotency key + * @param ctx per-request context + * @return results in request order, one per refinement entry + */ + List refine(List 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..b3d3cc0 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java @@ -13,6 +13,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse; + import java.time.Duration; import java.util.Map; import java.util.Objects; @@ -129,6 +132,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..fae5f82 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java @@ -0,0 +1,155 @@ +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.Collections; +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 @Nullable Set supportedDimensions; + 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 supported refinement dimensions declared by the seller. + * When set, the builder validates that refinement criteria only + * reference supported dimensions. + */ + public Builder supportedDimensions(Set dimensions) { + this.supportedDimensions = Set.copyOf(dimensions); + 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..0df2dab --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java @@ -0,0 +1,216 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +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.Iterator; +import java.util.List; +import java.util.Map; + +/** + * 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 static final ObjectMapper MAPPER = new ObjectMapper() + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + + 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(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 { + // UTF-8 encode directly + OutputStreamWriter w = new OutputStreamWriter(out, StandardCharsets.UTF_8); + w.write(c); + w.flush(); + } + } + } + } + out.write('"'); + } + + /** + * ES2015-compliant number-to-string conversion for JCS. + * Uses Double.toString and strips unnecessary trailing zeros. + */ + static String jcsNumberString(double d) { + if (d == 0.0) return "0"; + if (d == (long) d && Math.abs(d) < 1e21) { + return Long.toString((long) d); + } + // The ES2015 spec requires the shortest representation that + // round-trips. Java's Double.toString gives this for most values. + String s = Double.toString(d); + // Strip trailing zeros in the decimal part, but keep the exponent + if (s.contains("E") || s.contains("e")) { + return s.toLowerCase().replace("+", ""); + } + 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..ffa5bbe --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java @@ -0,0 +1,126 @@ +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 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..69cf967 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java @@ -0,0 +1,169 @@ +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_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")); + } +} From 355270dabe9c81452fcfbf9d9191a4e85161c0c7 Mon Sep 17 00:00:00 2001 From: garvitkaushik-123 Date: Sun, 16 Aug 2026 01:34:04 +0530 Subject: [PATCH 2/6] fix(negotiation): address review issues in proposal APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TermsDigest: remove unused MAPPER field and three unused imports (JsonProcessingException, Iterator, Map) - TermsDigest: add @Nullable to verify() digest parameter — the method handles null but the @NullMarked package default says non-null - TermsDigest: fix JCS exponent formatting — strip ".0" before 'e' so 1.0E-7 becomes "1e-7" per ES2015/RFC 8785 (was producing "1.0e-7") - TermsDigest: replace per-character OutputStreamWriter allocation in writeCanonicalString with direct String.valueOf(c).getBytes() - RefineProposalsRequest: remove unused Collections import and dead supportedDimensions builder field (stored but never validated) - AdcpClient: fix import ordering — negotiation imports grouped with other org.adcontextprotocol imports, not after slf4j - Add serialization round-trip test for RefinementResult - Add test coverage for JCS exponent edge cases --- .../adcontextprotocol/adcp/AdcpClient.java | 5 ++- .../negotiation/RefineProposalsRequest.java | 12 ------- .../adcp/negotiation/TermsDigest.java | 33 +++++++++---------- .../negotiation/RefinementResultTest.java | 17 ++++++++++ .../adcp/negotiation/TermsDigestTest.java | 15 +++++++++ 5 files changed, 49 insertions(+), 33 deletions(-) diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java b/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java index b3d3cc0..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; @@ -13,9 +15,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest; -import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse; - import java.time.Duration; import java.util.Map; import java.util.Objects; diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java index fae5f82..4ab0e49 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java @@ -6,7 +6,6 @@ import org.jspecify.annotations.Nullable; import java.util.ArrayList; -import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Objects; @@ -60,7 +59,6 @@ public static final class Builder { private @Nullable String contextId; private @Nullable JsonNode context; private @Nullable String governanceContext; - private @Nullable Set supportedDimensions; private int maxBatchSize = 25; private Builder() {} @@ -96,16 +94,6 @@ public Builder governanceContext(String governanceContext) { return this; } - /** - * Sets the supported refinement dimensions declared by the seller. - * When set, the builder validates that refinement criteria only - * reference supported dimensions. - */ - public Builder supportedDimensions(Set dimensions) { - this.supportedDimensions = Set.copyOf(dimensions); - return this; - } - /** * Sets the maximum batch size (default 25 per protocol spec). * The seller may advertise a lower ceiling. diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java index 0df2dab..3fea3d6 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java @@ -1,24 +1,19 @@ package org.adcontextprotocol.adcp.negotiation; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; 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.io.OutputStreamWriter; 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.Iterator; import java.util.List; -import java.util.Map; /** * Computes and verifies {@code terms_digest} values per the AdCP 3.2 @@ -34,8 +29,6 @@ public final class TermsDigest { private static final String PREFIX = "sha256:"; - private static final ObjectMapper MAPPER = new ObjectMapper() - .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); private TermsDigest() {} @@ -57,7 +50,7 @@ public static String compute(JsonNode commercialTerms) { * * @return true if the digest is valid */ - public static boolean verify(String digest, JsonNode commercialTerms) { + public static boolean verify(@Nullable String digest, JsonNode commercialTerms) { if (digest == null || !digest.startsWith(PREFIX)) { return false; } @@ -176,10 +169,7 @@ private static void writeCanonicalString(String s, ByteArrayOutputStream out) out.write(String.format("\\u%04x", (int) c) .getBytes(StandardCharsets.UTF_8)); } else { - // UTF-8 encode directly - OutputStreamWriter w = new OutputStreamWriter(out, StandardCharsets.UTF_8); - w.write(c); - w.flush(); + out.write(String.valueOf(c).getBytes(StandardCharsets.UTF_8)); } } } @@ -189,19 +179,26 @@ private static void writeCanonicalString(String s, ByteArrayOutputStream out) /** * ES2015-compliant number-to-string conversion for JCS. - * Uses Double.toString and strips unnecessary trailing zeros. + * 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); } - // The ES2015 spec requires the shortest representation that - // round-trips. Java's Double.toString gives this for most values. String s = Double.toString(d); - // Strip trailing zeros in the decimal part, but keep the exponent if (s.contains("E") || s.contains("e")) { - return s.toLowerCase().replace("+", ""); + // 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; } diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java index ffa5bbe..1cc47a7 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java @@ -102,6 +102,23 @@ void unable_round_trips_with_reason() throws Exception { 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 = """ diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java index 69cf967..bf21503 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java @@ -141,6 +141,21 @@ void jcs_number_string_handles_decimals() { 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(); From 8e95ed14f9252ce0b8e4d2698b7f81a35e13442b Mon Sep 17 00:00:00 2001 From: garvitkaushik-123 Date: Thu, 27 Aug 2026 21:54:53 +0530 Subject: [PATCH 3/6] feat(negotiation): complete proposal refinement API gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add missing typed constraints, alternatives validation, and server-side successor utilities to close the proposal negotiation gaps tracked in adcontextprotocol/adcp#6556. - TotalBudgetConstraint: budget bounds (min/max/currency) per schema - RefinementConstraints: composite of all four constraint dimensions - ProposalRefinement: add constraints, productChanges, alternatives fields + builder; rename instructions→ask to match wire schema; enforce alternatives.count 2-10 (protocol max) - RefinementCapability: add maxAlternatives field - RefineProposalsRequest.Builder: validate alternatives against seller ceiling - ResponseVerifier: add verifyConstraintSatisfaction checking budget bounds and CPM ceiling on revised outcomes - ProposalSuccessor (server): stamp immutable successors with lineage and recomputed terms_digest - 15 new tests across constraints, alternatives, verification, and successor stamping Co-Authored-By: Claude Opus 4.6 --- .../server/negotiation/ProposalSuccessor.java | 64 +++++++++ .../negotiation/ProposalSuccessorTest.java | 57 +++++++++ .../adcp/negotiation/ProposalRefinement.java | 121 +++++++++++++++--- .../negotiation/RefineProposalsRequest.java | 24 ++++ .../negotiation/RefinementCapability.java | 6 + .../negotiation/RefinementConstraints.java | 28 ++++ .../adcp/negotiation/ResponseVerifier.java | 94 ++++++++++++++ .../negotiation/TotalBudgetConstraint.java | 34 +++++ .../adcp/negotiation/ConstraintsTest.java | 78 +++++++++++ .../RefineProposalsRequestTest.java | 112 ++++++++++++++++ .../negotiation/ResponseVerifierTest.java | 115 +++++++++++++++++ 11 files changed, 717 insertions(+), 16 deletions(-) create mode 100644 adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessor.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessorTest.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementConstraints.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TotalBudgetConstraint.java diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessor.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessor.java new file mode 100644 index 0000000..e04a584 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessor.java @@ -0,0 +1,64 @@ +package org.adcontextprotocol.adcp.server.negotiation; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.adcontextprotocol.adcp.negotiation.TermsDigest; + +import java.util.Objects; +import java.util.UUID; + +/** + * Creates immutable successor proposals with correct lineage and digest. + * + *

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/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/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java index 5d313b3..8ed2678 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java @@ -1,5 +1,6 @@ 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; @@ -9,36 +10,56 @@ /** * 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. + *

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 instructions semantic commercial changes or cancellation reason - * @param criteria structured changes; each present field replaces that criterion + * @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("instructions") String instructions, - @Nullable @JsonProperty("criteria") JsonNode criteria) { + @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 instructions. + * Creates a revise refinement with a semantic ask. */ - public static ProposalRefinement revise(String proposalId, String instructions) { + public static ProposalRefinement revise(String proposalId, String ask) { return new ProposalRefinement(proposalId, RefinementAction.REVISE, - null, instructions, null); + null, ask, null, null, null, null); } /** @@ -46,7 +67,16 @@ public static ProposalRefinement revise(String proposalId, String instructions) */ public static ProposalRefinement reviseWithCriteria(String proposalId, JsonNode criteria) { return new ProposalRefinement(proposalId, RefinementAction.REVISE, - null, null, criteria); + 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); } /** @@ -54,7 +84,7 @@ public static ProposalRefinement reviseWithCriteria(String proposalId, JsonNode */ public static ProposalRefinement finalize(String proposalId) { return new ProposalRefinement(proposalId, RefinementAction.FINALIZE, - null, null, null); + null, null, null, null, null, null); } /** @@ -62,6 +92,65 @@ public static ProposalRefinement finalize(String proposalId) { */ public static ProposalRefinement cancel(String proposalId, String reason) { return new ProposalRefinement(proposalId, RefinementAction.REVISE, - ChangeKind.CANCELLATION, reason, null); + 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 index 4ab0e49..876068a 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java @@ -60,6 +60,7 @@ public static final class Builder { private @Nullable JsonNode context; private @Nullable String governanceContext; private int maxBatchSize = 25; + private int maxAlternatives = ProposalRefinement.MAX_ALTERNATIVES; private Builder() {} @@ -106,6 +107,17 @@ public Builder maxBatchSize(int maxBatchSize) { return this; } + /** + * Sets the seller's alternatives ceiling (default 10 per protocol spec). + */ + public Builder maxAlternatives(int maxAlternatives) { + if (maxAlternatives < 2) { + throw new IllegalArgumentException("maxAlternatives must be >= 2"); + } + this.maxAlternatives = maxAlternatives; + return this; + } + public RefineProposalsRequest build() { validateBatch(); return new RefineProposalsRequest( @@ -138,6 +150,18 @@ private void validateBatch() { throw new IllegalArgumentException( "a batch containing finalize must contain only finalize entries"); } + + // Validate alternatives count against seller ceiling + for (ProposalRefinement r : refinements) { + if (r.alternatives() != null) { + JsonNode count = r.alternatives().get("count"); + if (count != null && count.asInt() > maxAlternatives) { + throw new IllegalArgumentException( + "alternatives.count " + count.asInt() + + " exceeds seller maximum " + maxAlternatives); + } + } + } } } } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java index d38e59a..f670342 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java @@ -15,12 +15,14 @@ * * @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 supportedDimensions, @Nullable @JsonProperty("max_batch_size") Integer maxBatchSize, + @Nullable @JsonProperty("max_alternatives") Integer maxAlternatives, @Nullable @JsonProperty("supports_finalize") Boolean supportsFinalize) { /** Protocol default batch size when not declared by the seller. */ @@ -32,4 +34,8 @@ public record RefinementCapability( public int effectiveMaxBatchSize() { return maxBatchSize != null ? maxBatchSize : DEFAULT_MAX_BATCH_SIZE; } + + public int effectiveMaxAlternatives() { + return maxAlternatives != null ? maxAlternatives : ProposalRefinement.MAX_ALTERNATIVES; + } } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementConstraints.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementConstraints.java new file mode 100644 index 0000000..2f0c114 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementConstraints.java @@ -0,0 +1,28 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jspecify.annotations.Nullable; + +/** + * Typed constraint envelope for a refinement entry. At least one + * constraint must be present per the schema's {@code minProperties: 1}. + * + * @param totalBudget inclusive budget bounds + * @param cpm CPM ceiling across all purchases + * @param impressions minimum summed impressions + * @param flight flight-window timing bounds + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RefinementConstraints( + @Nullable @JsonProperty("total_budget") TotalBudgetConstraint totalBudget, + @Nullable @JsonProperty("cpm") CpmConstraint cpm, + @Nullable @JsonProperty("impressions") ImpressionsConstraint impressions, + @Nullable @JsonProperty("flight") FlightConstraint flight) { + + public RefinementConstraints { + if (totalBudget == null && cpm == null && impressions == null && flight == null) { + throw new IllegalArgumentException("constraints must specify at least one dimension"); + } + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java index a85759d..d7925e4 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashSet; import java.util.List; @@ -42,6 +43,7 @@ public static List verify(RefineProposalsRequest request, verifyUniqueProposalIds(response, violations); verifyPartialInvariant(response, violations); verifyOutcomeConstraints(response, violations); + verifyConstraintSatisfaction(request, response, violations); return violations; } @@ -253,6 +255,98 @@ public static List verifyDigests(RefineProposalsResponse response) { return violations; } + /** + * Verifies that revised results satisfy the request's typed constraints. + * Only checks revised outcomes (partial/unable carry their own diagnostics). + * Verifies budget bounds and CPM ceiling against commercial_terms. + */ + public static void verifyConstraintSatisfaction( + RefineProposalsRequest request, + RefineProposalsResponse response, + List violations) { + List results = response.results(); + List refinements = request.refinements(); + if (results == null) return; + + for (int i = 0; i < Math.min(results.size(), refinements.size()); i++) { + if (!(results.get(i) instanceof RefinementResult.Revised revised)) continue; + + RefinementConstraints constraints = refinements.get(i).constraints(); + if (constraints == null) continue; + + JsonNode proposal = revised.proposal(); + if (proposal == null || proposal.isNull()) continue; + + JsonNode terms = proposal.get("commercial_terms"); + if (terms == null || terms.isNull()) continue; + + if (constraints.totalBudget() != null) { + verifyBudgetBounds(terms, constraints.totalBudget(), i, violations); + } + if (constraints.cpm() != null) { + verifyCpmCeiling(terms, constraints.cpm(), i, violations); + } + } + } + + private static void verifyBudgetBounds(JsonNode terms, TotalBudgetConstraint budget, + int index, List violations) { + JsonNode totalBudget = terms.get("total_budget"); + if (totalBudget == null || totalBudget.isNull()) { + violations.add("result[" + index + + "] revised but commercial_terms.total_budget is missing" + + " (budget constraint present)"); + return; + } + JsonNode amountNode = totalBudget.get("amount"); + JsonNode currencyNode = totalBudget.get("currency"); + if (amountNode == null || currencyNode == null) return; + + String currency = currencyNode.asText(); + if (!budget.currency().equals(currency)) { + violations.add("result[" + index + + "] budget currency mismatch: constraint=" + budget.currency() + + " proposal=" + currency); + return; + } + + BigDecimal amount = amountNode.decimalValue(); + if (budget.min() != null && amount.compareTo(budget.min()) < 0) { + violations.add("result[" + index + + "] budget " + amount + " below constraint min " + budget.min()); + } + if (budget.max() != null && amount.compareTo(budget.max()) > 0) { + violations.add("result[" + index + + "] budget " + amount + " above constraint max " + budget.max()); + } + } + + private static void verifyCpmCeiling(JsonNode terms, CpmConstraint cpm, + int index, List violations) { + JsonNode purchases = terms.get("purchases"); + if (purchases == null || !purchases.isArray()) return; + + for (int p = 0; p < purchases.size(); p++) { + JsonNode pricing = purchases.get(p).get("pricing"); + if (pricing == null) continue; + + JsonNode model = pricing.get("pricing_model"); + if (model == null) continue; + String modelStr = model.asText(); + if (!"cpm".equals(modelStr) && !"vcpm".equals(modelStr)) continue; + + JsonNode currencyNode = pricing.get("currency"); + if (currencyNode != null && !cpm.currency().equals(currencyNode.asText())) continue; + + JsonNode fixedPrice = pricing.get("fixed_price"); + if (fixedPrice != null && fixedPrice.decimalValue().compareTo(cpm.max()) > 0) { + violations.add("result[" + index + "] purchase[" + p + + "] CPM " + fixedPrice.decimalValue() + + " exceeds constraint max " + cpm.max()); + } + } + } + private static void checkProposalStatus(JsonNode proposal, String expected, int index, List violations) { JsonNode status = proposal.get("proposal_status"); diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TotalBudgetConstraint.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TotalBudgetConstraint.java new file mode 100644 index 0000000..d714d6b --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TotalBudgetConstraint.java @@ -0,0 +1,34 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jspecify.annotations.Nullable; + +import java.math.BigDecimal; + +/** + * Budget bounds constraint per {@code proposal-budget-constraint.json}. + * Currency-aware inclusive bounds checked against {@code commercial_terms.total_budget}. + * + * @param min minimum budget (inclusive), null if unconstrained below + * @param max maximum budget (inclusive), null if unconstrained above + * @param currency ISO 4217 currency code + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record TotalBudgetConstraint( + @Nullable @JsonProperty("min") BigDecimal min, + @Nullable @JsonProperty("max") BigDecimal max, + @JsonProperty("currency") String currency) { + + public TotalBudgetConstraint { + if (currency == null || !currency.matches("^[A-Z]{3}$")) { + throw new IllegalArgumentException("budget currency must be a 3-letter ISO 4217 code"); + } + if (min == null && max == null) { + throw new IllegalArgumentException("budget must specify at least one bound"); + } + if (min != null && max != null && min.compareTo(max) > 0) { + throw new IllegalArgumentException("budget min must not exceed max"); + } + } +} diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java index 840d378..99b783c 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java @@ -97,4 +97,82 @@ void flight_constraint_round_trips() throws Exception { 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 index e190cbb..0bc50c3 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java @@ -1,7 +1,10 @@ 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; @@ -120,4 +123,113 @@ void cancellation_refinement() { 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/ResponseVerifierTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java index 9f21d65..672e417 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java @@ -240,6 +240,121 @@ void digest_verification_catches_mismatch() throws Exception { assertTrue(violations.get(0).contains("terms_digest")); } + @Test + void detects_budget_constraint_violation() throws Exception { + var budgetConstraint = new TotalBudgetConstraint( + new java.math.BigDecimal("5000"), new java.math.BigDecimal("10000"), "USD"); + var constraints = new RefinementConstraints(budgetConstraint, null, null, null); + + var request = RefineProposalsRequest.builder() + .idempotencyKey(key()) + .addRefinement(ProposalRefinement.reviseWithConstraints("src-1", constraints)) + .build(); + + ObjectNode proposal = draftProposal("new-1", "src-1"); + ObjectNode totalBudget = mapper.createObjectNode(); + totalBudget.put("amount", 15000); + totalBudget.put("currency", "USD"); + ObjectNode terms = mapper.createObjectNode(); + terms.set("total_budget", totalBudget); + proposal.set("commercial_terms", terms); + + 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("above constraint max"))); + } + + @Test + void budget_within_bounds_passes() throws Exception { + var budgetConstraint = new TotalBudgetConstraint( + new java.math.BigDecimal("5000"), new java.math.BigDecimal("10000"), "USD"); + var constraints = new RefinementConstraints(budgetConstraint, null, null, null); + + var request = RefineProposalsRequest.builder() + .idempotencyKey(key()) + .addRefinement(ProposalRefinement.reviseWithConstraints("src-1", constraints)) + .build(); + + ObjectNode proposal = draftProposal("new-1", "src-1"); + ObjectNode totalBudget = mapper.createObjectNode(); + totalBudget.put("amount", 7500); + totalBudget.put("currency", "USD"); + ObjectNode terms = mapper.createObjectNode(); + terms.set("total_budget", totalBudget); + proposal.set("commercial_terms", terms); + + 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 = new java.util.ArrayList<>(); + ResponseVerifier.verifyConstraintSatisfaction(request, response, violations); + + assertTrue(violations.isEmpty(), "Expected no constraint violations but got: " + violations); + } + + @Test + void detects_cpm_ceiling_violation() throws Exception { + var cpmConstraint = new CpmConstraint(new java.math.BigDecimal("10.00"), "USD"); + var constraints = new RefinementConstraints(null, cpmConstraint, null, null); + + var request = RefineProposalsRequest.builder() + .idempotencyKey(key()) + .addRefinement(ProposalRefinement.reviseWithConstraints("src-1", constraints)) + .build(); + + ObjectNode proposal = draftProposal("new-1", "src-1"); + ObjectNode pricing = mapper.createObjectNode(); + pricing.put("pricing_model", "cpm"); + pricing.put("fixed_price", 15.00); + pricing.put("currency", "USD"); + ObjectNode purchase = mapper.createObjectNode(); + purchase.set("pricing", pricing); + ObjectNode terms = mapper.createObjectNode(); + terms.set("purchases", mapper.createArrayNode().add(purchase)); + proposal.set("commercial_terms", terms); + + 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 = new java.util.ArrayList<>(); + ResponseVerifier.verifyConstraintSatisfaction(request, response, violations); + + assertTrue(violations.stream().anyMatch(v -> v.contains("CPM") && v.contains("exceeds"))); + } + // -- helpers -- private ObjectNode draftProposal(String id, String parentId) { From a6389fe34b4897d606f53264c68251ffd3d7f641 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 30 Aug 2026 09:33:39 +0200 Subject: [PATCH 4/6] feat(negotiation): complete AdCP 3.2 proposal APIs --- .changeset/negotiation-proposal-apis.md | 4 + adcp-cli/gradle.lockfile | 1 + .../org/adcontextprotocol/adcp/cli/Main.java | 115 +++- adcp-kotlin/gradle.lockfile | 1 + .../org/adcontextprotocol/adcp/kotlin/Adcp.kt | 13 + adcp-mutiny/gradle.lockfile | 1 + .../adcp/mutiny/MutinyAdcpClient.java | 28 + adcp-reactor/gradle.lockfile | 1 + .../adcp/reactor/ReactorAdcpClient.java | 29 + adcp-server/gradle.lockfile | 1 + .../adcp/server/AdcpServerBuilder.java | 99 ++- .../server/negotiation/ProposalHandler.java | 36 +- .../server/negotiation/ProposalSuccessor.java | 18 +- .../ProposalHandlerIntegrationTest.java | 177 +++++ .../negotiation/ProposalSuccessorTest.java | 20 +- adcp-spring-boot-starter/gradle.lockfile | 1 + adcp-testing/gradle.lockfile | 1 + .../negotiation/NegotiationAssertions.java | 58 ++ .../negotiation/NegotiationFixtures.java | 86 ++- .../adcp/testing/NegotiationFixturesTest.java | 47 ++ adcp/build.gradle.kts | 1 + adcp/gradle.lockfile | 1 + .../adcp/negotiation/AlternativesRequest.java | 15 + .../adcp/negotiation/CpmConstraint.java | 8 +- .../negotiation/ImpressionsConstraint.java | 11 +- .../adcp/negotiation/ProposalRefinement.java | 45 +- .../negotiation/ProposalRefinementReason.java | 35 + .../negotiation/RefineProposalsRequest.java | 163 ++++- .../negotiation/RefineProposalsResponse.java | 13 +- .../negotiation/RefinementCapability.java | 25 +- .../adcp/negotiation/RefinementResult.java | 34 +- .../adcp/negotiation/ResponseVerifier.java | 631 ++++++++++-------- .../adcp/negotiation/TermsDigest.java | 140 +--- .../negotiation/TotalBudgetConstraint.java | 3 + .../UnsupportedRefinementException.java | 18 + .../adcp/negotiation/ConstraintsTest.java | 2 +- .../RefineProposalsRequestTest.java | 63 +- .../negotiation/RefinementResultTest.java | 135 +--- .../negotiation/ResponseVerifierTest.java | 431 ++++-------- .../adcp/negotiation/TermsDigestTest.java | 185 +---- docs/proposal-negotiation.md | 44 ++ gradle/libs.versions.toml | 2 + 42 files changed, 1621 insertions(+), 1121 deletions(-) create mode 100644 adcp-mutiny/src/main/java/org/adcontextprotocol/adcp/mutiny/MutinyAdcpClient.java create mode 100644 adcp-reactor/src/main/java/org/adcontextprotocol/adcp/reactor/ReactorAdcpClient.java create mode 100644 adcp-server/src/test/java/org/adcontextprotocol/adcp/server/ProposalHandlerIntegrationTest.java create mode 100644 adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/NegotiationAssertions.java create mode 100644 adcp-testing/src/test/java/org/adcontextprotocol/adcp/testing/NegotiationFixturesTest.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/AlternativesRequest.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinementReason.java create mode 100644 adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/UnsupportedRefinementException.java create mode 100644 docs/proposal-negotiation.md diff --git a/.changeset/negotiation-proposal-apis.md b/.changeset/negotiation-proposal-apis.md index 2618169..0569f45 100644 --- a/.changeset/negotiation-proposal-apis.md +++ b/.changeset/negotiation-proposal-apis.md @@ -2,6 +2,10 @@ "adcp": minor "adcp-server": minor "adcp-testing": minor +"adcp-reactor": minor +"adcp-mutiny": minor +"adcp-kotlin": minor +"adcp-cli": minor --- feat(negotiation): add first-class buyer and seller proposal APIs for AdCP 3.2 diff --git a/adcp-cli/gradle.lockfile b/adcp-cli/gradle.lockfile index 2291d2e..e06d7e2 100644 --- a/adcp-cli/gradle.lockfile +++ b/adcp-cli/gradle.lockfile @@ -15,6 +15,7 @@ com.google.errorprone:error_prone_annotations:2.48.0=compileClasspath,runtimeCla com.google.protobuf:protobuf-java-util:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.protobuf:protobuf-java:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.networknt:json-schema-validator:1.5.6=runtimeClasspath,testRuntimeClasspath +io.github.erdtman:java-json-canonicalization:1.1=runtimeClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-core:1.1.2=runtimeClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-json-jackson2:1.1.2=runtimeClasspath,testRuntimeClasspath io.projectreactor:reactor-core:3.7.0=runtimeClasspath,testRuntimeClasspath diff --git a/adcp-cli/src/main/java/org/adcontextprotocol/adcp/cli/Main.java b/adcp-cli/src/main/java/org/adcontextprotocol/adcp/cli/Main.java index 570d918..2837103 100644 --- a/adcp-cli/src/main/java/org/adcontextprotocol/adcp/cli/Main.java +++ b/adcp-cli/src/main/java/org/adcontextprotocol/adcp/cli/Main.java @@ -1,8 +1,24 @@ package org.adcontextprotocol.adcp.cli; +import com.fasterxml.jackson.databind.JsonNode; +import org.adcontextprotocol.adcp.AdcpClient; +import org.adcontextprotocol.adcp.AgentConfig; +import org.adcontextprotocol.adcp.http.SsrfPolicy; +import org.adcontextprotocol.adcp.negotiation.ProposalRefinement; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse; +import org.adcontextprotocol.adcp.negotiation.RefinementConstraints; +import org.adcontextprotocol.adcp.negotiation.ResponseVerifier; +import org.adcontextprotocol.adcp.negotiation.TotalBudgetConstraint; + +import java.math.BigDecimal; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.UUID; + /** - * Entry point for the {@code adcp} CLI. Commands land here as the CLI track - * is implemented; today this is a placeholder that prints a usage stub. + * Entry point for the {@code adcp} CLI and runnable proposal-negotiation lab. */ public final class Main { @@ -11,7 +27,98 @@ private Main() { } public static void main(String[] args) { - System.out.println("adcp "); - System.out.println("see ROADMAP.md track 13 (cli) for the planned surface"); + if (args.length > 0 && "proposal-negotiation".equals(args[0])) { + runProposalNegotiation(args); + return; + } + System.out.println("adcp proposal-negotiation [seller-mcp-url]"); + System.out.println("Set ADCP_AUTH_TOKEN before running the public training-seller lab."); + } + + private static void runProposalNegotiation(String[] args) { + String endpoint = args.length > 1 ? args[1] + : "https://test-agent.adcontextprotocol.org/sales/profiles/constrained-seller/mcp"; + String token = System.getenv("ADCP_AUTH_TOKEN"); + if (token == null || token.isBlank()) { + throw new IllegalStateException("Set ADCP_AUTH_TOKEN to the public test token"); + } + + String nonce = UUID.randomUUID().toString(); + AgentConfig agent = AgentConfig.mcp("training-seller", URI.create(endpoint), token); + try (AdcpClient client = AdcpClient.builder() + .agent(agent) + .ssrfPolicy(SsrfPolicy.strict()) + .build()) { + Map version = Map.of( + "adcp_version", "3.2-beta.6", "adcp_major_version", 3); + JsonNode capabilities = client.callTool( + "get_adcp_capabilities", version, JsonNode.class); + if (!capabilities.path("media_buy").path("lifecycle_tools") + .toString().contains("refine_proposals")) { + throw new IllegalStateException("seller did not advertise refine_proposals"); + } + + Map proposalArgs = new java.util.LinkedHashMap<>(version); + proposalArgs.put("idempotency_key", "java-request-" + nonce); + proposalArgs.put("brand", Map.of("domain", "acmeoutdoor.example")); + proposalArgs.put("brief", "social engagement display"); + JsonNode proposals = client.callTool( + "request_proposals", proposalArgs, JsonNode.class); + String sourceId = proposals.path("proposals").path(0) + .path("proposal_id").asText(null); + if (sourceId == null) { + throw new IllegalStateException("seller returned no source proposal"); + } + + RefinementConstraints constraints = new RefinementConstraints( + new TotalBudgetConstraint(null, new BigDecimal("50000"), "USD"), + null, null, null); + RefineProposalsRequest three = request( + sourceId, constraints, 3, "java-refine-three-" + nonce); + RefineProposalsResponse partial = client.refineProposals(three); + requireVerified(three, partial); + + // This changes the logical request, so it deliberately uses a new key. + RefineProposalsRequest two = request( + sourceId, constraints, 2, "java-refine-two-" + nonce); + RefineProposalsResponse revised = client.refineProposals(two); + requireVerified(two, revised); + + System.out.printf("first=%s (%d alternatives), retry=%s (%d alternatives)%n", + partial.results().getFirst().outcome(), proposalCount(partial), + revised.results().getFirst().outcome(), proposalCount(revised)); + } + } + + private static RefineProposalsRequest request( + String sourceId, RefinementConstraints constraints, int alternatives, String key) { + return RefineProposalsRequest.builder() + .adcpVersion("3.2-beta.6") + .adcpMajorVersion(3) + .idempotencyKey(key) + .maxAlternatives(3) + .addRefinement(ProposalRefinement.builder(sourceId) + .constraints(constraints) + .alternatives(alternatives) + .build()) + .build(); + } + + private static void requireVerified(RefineProposalsRequest request, + RefineProposalsResponse response) { + List violations = ResponseVerifier.verify(request, response); + if (!violations.isEmpty()) { + throw new IllegalStateException("invalid seller response: " + violations); + } + } + + private static int proposalCount(RefineProposalsResponse response) { + return switch (response.results().getFirst()) { + case org.adcontextprotocol.adcp.negotiation.RefinementResult.Revised r -> + r.proposals().size(); + case org.adcontextprotocol.adcp.negotiation.RefinementResult.Partial p -> + p.proposals().size(); + default -> 0; + }; } } diff --git a/adcp-kotlin/gradle.lockfile b/adcp-kotlin/gradle.lockfile index b048140..a47da06 100644 --- a/adcp-kotlin/gradle.lockfile +++ b/adcp-kotlin/gradle.lockfile @@ -15,6 +15,7 @@ com.google.errorprone:error_prone_annotations:2.48.0=apiDependenciesMetadata,com com.google.protobuf:protobuf-java-util:4.33.2=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.google.protobuf:protobuf-java:4.33.2=apiDependenciesMetadata,compileClasspath,implementationDependenciesMetadata,runtimeClasspath,testCompileClasspath,testImplementationDependenciesMetadata,testRuntimeClasspath com.networknt:json-schema-validator:1.5.6=runtimeClasspath,testRuntimeClasspath +io.github.erdtman:java-json-canonicalization:1.1=runtimeClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-core:1.1.2=runtimeClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-json-jackson2:1.1.2=runtimeClasspath,testRuntimeClasspath io.projectreactor:reactor-core:3.7.0=runtimeClasspath,testRuntimeClasspath diff --git a/adcp-kotlin/src/main/kotlin/org/adcontextprotocol/adcp/kotlin/Adcp.kt b/adcp-kotlin/src/main/kotlin/org/adcontextprotocol/adcp/kotlin/Adcp.kt index d418f43..1d67a2b 100644 --- a/adcp-kotlin/src/main/kotlin/org/adcontextprotocol/adcp/kotlin/Adcp.kt +++ b/adcp-kotlin/src/main/kotlin/org/adcontextprotocol/adcp/kotlin/Adcp.kt @@ -3,3 +3,16 @@ // Nullability is correct because the Java surface is JSpecify-annotated. package org.adcontextprotocol.adcp.kotlin + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.adcontextprotocol.adcp.AdcpClient +import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest +import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse + +/** Coroutine bridge preserving task errors and sealed per-proposal outcomes. */ +public suspend fun AdcpClient.refineProposalsAwait( + request: RefineProposalsRequest, +): RefineProposalsResponse = withContext(Dispatchers.IO) { + refineProposals(request) +} diff --git a/adcp-mutiny/gradle.lockfile b/adcp-mutiny/gradle.lockfile index a56f380..577f805 100644 --- a/adcp-mutiny/gradle.lockfile +++ b/adcp-mutiny/gradle.lockfile @@ -15,6 +15,7 @@ com.google.errorprone:error_prone_annotations:2.48.0=compileClasspath,runtimeCla com.google.protobuf:protobuf-java-util:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.protobuf:protobuf-java:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.networknt:json-schema-validator:1.5.6=runtimeClasspath,testRuntimeClasspath +io.github.erdtman:java-json-canonicalization:1.1=runtimeClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-core:1.1.2=runtimeClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-json-jackson2:1.1.2=runtimeClasspath,testRuntimeClasspath io.projectreactor:reactor-core:3.7.0=runtimeClasspath,testRuntimeClasspath diff --git a/adcp-mutiny/src/main/java/org/adcontextprotocol/adcp/mutiny/MutinyAdcpClient.java b/adcp-mutiny/src/main/java/org/adcontextprotocol/adcp/mutiny/MutinyAdcpClient.java new file mode 100644 index 0000000..a4b52eb --- /dev/null +++ b/adcp-mutiny/src/main/java/org/adcontextprotocol/adcp/mutiny/MutinyAdcpClient.java @@ -0,0 +1,28 @@ +package org.adcontextprotocol.adcp.mutiny; + +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.infrastructure.Infrastructure; +import org.adcontextprotocol.adcp.AdcpClient; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse; + +import java.util.Objects; + +/** Non-blocking SmallRye Mutiny bridge for the synchronous AdCP client. */ +public final class MutinyAdcpClient { + + private final AdcpClient delegate; + + public MutinyAdcpClient(AdcpClient delegate) { + this.delegate = Objects.requireNonNull(delegate); + } + + /** + * Refines proposals on Mutiny's default worker pool. Task-level failures + * fail the {@link Uni}; per-proposal outcomes remain in the typed response. + */ + public Uni refineProposals(RefineProposalsRequest request) { + return Uni.createFrom().item(() -> delegate.refineProposals(request)) + .runSubscriptionOn(Infrastructure.getDefaultWorkerPool()); + } +} diff --git a/adcp-reactor/gradle.lockfile b/adcp-reactor/gradle.lockfile index 7448381..366aea7 100644 --- a/adcp-reactor/gradle.lockfile +++ b/adcp-reactor/gradle.lockfile @@ -15,6 +15,7 @@ com.google.errorprone:error_prone_annotations:2.48.0=compileClasspath,runtimeCla com.google.protobuf:protobuf-java-util:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.protobuf:protobuf-java:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.networknt:json-schema-validator:1.5.6=runtimeClasspath,testRuntimeClasspath +io.github.erdtman:java-json-canonicalization:1.1=runtimeClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-core:1.1.2=runtimeClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-json-jackson2:1.1.2=runtimeClasspath,testRuntimeClasspath io.projectreactor:reactor-core:3.7.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/adcp-reactor/src/main/java/org/adcontextprotocol/adcp/reactor/ReactorAdcpClient.java b/adcp-reactor/src/main/java/org/adcontextprotocol/adcp/reactor/ReactorAdcpClient.java new file mode 100644 index 0000000..e84ccf8 --- /dev/null +++ b/adcp-reactor/src/main/java/org/adcontextprotocol/adcp/reactor/ReactorAdcpClient.java @@ -0,0 +1,29 @@ +package org.adcontextprotocol.adcp.reactor; + +import org.adcontextprotocol.adcp.AdcpClient; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.util.Objects; + +/** Non-blocking Project Reactor bridge for the synchronous AdCP client. */ +public final class ReactorAdcpClient { + + private final AdcpClient delegate; + + public ReactorAdcpClient(AdcpClient delegate) { + this.delegate = Objects.requireNonNull(delegate); + } + + /** + * Refines proposals on Reactor's bounded-elastic scheduler. + * Transport and task-level errors are emitted through {@link Mono#error}; + * per-proposal outcomes remain safely discriminated in the response. + */ + public Mono refineProposals(RefineProposalsRequest request) { + return Mono.fromCallable(() -> delegate.refineProposals(request)) + .subscribeOn(Schedulers.boundedElastic()); + } +} diff --git a/adcp-server/gradle.lockfile b/adcp-server/gradle.lockfile index 0fbc22a..e7cf6f8 100644 --- a/adcp-server/gradle.lockfile +++ b/adcp-server/gradle.lockfile @@ -15,6 +15,7 @@ com.google.errorprone:error_prone_annotations:2.48.0=compileClasspath,runtimeCla com.google.protobuf:protobuf-java-util:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.protobuf:protobuf-java:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.networknt:json-schema-validator:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.erdtman:java-json-canonicalization:1.1=runtimeClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-core:1.1.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-json-jackson2:1.1.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.projectreactor:reactor-core:3.7.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/AdcpServerBuilder.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/AdcpServerBuilder.java index b453e97..206bed7 100644 --- a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/AdcpServerBuilder.java +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/AdcpServerBuilder.java @@ -6,15 +6,25 @@ import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerTransportProvider; import org.adcontextprotocol.adcp.AdcpVersion; +import org.adcontextprotocol.adcp.error.ValidationError; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse; +import org.adcontextprotocol.adcp.negotiation.RefinementAction; +import org.adcontextprotocol.adcp.negotiation.ResponseVerifier; +import org.adcontextprotocol.adcp.negotiation.UnsupportedRefinementException; import org.adcontextprotocol.adcp.schema.AdcpObjectMapperFactory; +import org.adcontextprotocol.adcp.server.negotiation.ProposalHandler; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; /** * Builds and wires an MCP server backed by an {@link AdcpPlatform}. @@ -40,6 +50,7 @@ public final class AdcpServerBuilder { private @Nullable McpServerTransportProvider transport; private @Nullable ObjectMapper objectMapper; private @Nullable AdcpVersion adcpVersion; + private @Nullable ProposalHandler proposalHandler; private String serverName = "adcp-java-sdk"; private String serverVersion = "0.1.0"; @@ -82,6 +93,12 @@ public AdcpServerBuilder adcpVersion(AdcpVersion adcpVersion) { return this; } + /** Registers the typed {@code refine_proposals} seller callback. */ + public AdcpServerBuilder proposalHandler(ProposalHandler proposalHandler) { + this.proposalHandler = Objects.requireNonNull(proposalHandler); + return this; + } + /** * Builds and returns the MCP server. Call {@code initialize()} on the * result to start accepting connections. @@ -96,8 +113,11 @@ public McpSyncServer build() { ? objectMapper : AdcpObjectMapperFactory.create(); - Set tools = platform.supportedTools(); - Map descriptions = platform.toolDescriptions(); + Set tools = new HashSet<>(platform.supportedTools()); + if (proposalHandler != null) tools.add("refine_proposals"); + Map descriptions = new LinkedHashMap<>(platform.toolDescriptions()); + descriptions.putIfAbsent("refine_proposals", + "Revise proposal terms or atomically finalize draft proposals"); Map schemas = platform.toolSchemas(); log.info("Building AdCP server with {} tool(s): {}", tools.size(), tools); @@ -147,12 +167,28 @@ McpSchema.CallToolResult handleToolCall( AdcpContext ctx = new AdcpContext(version, Map.of(), null); - Object response = platform.handleTool(toolName, args, ctx); + Object response = "refine_proposals".equals(toolName) && proposalHandler != null + ? handleProposalRefinement(om, args, ctx) + : platform.handleTool(toolName, args, ctx); String json = om.writeValueAsString(response); return new McpSchema.CallToolResult( List.of(new McpSchema.TextContent(json)), false, null, Map.of()); + } catch (UnsupportedRefinementException e) { + log.warn("Tool call failed ({}) [UNSUPPORTED_FEATURE]: {}", toolName, e.getMessage()); + try { + String json = om.writeValueAsString(Map.of( + "error", "UNSUPPORTED_FEATURE", + "message", sanitizeErrorMessage(e.getMessage()), + "details", e.details())); + return new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent(json)), true, null, Map.of()); + } catch (Exception ignored) { + return new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent("{\"error\":\"UNSUPPORTED_FEATURE\"}")), + true, null, Map.of()); + } } catch (org.adcontextprotocol.adcp.error.AdcpError e) { // Known application errors — surface the stable code plus a // brief, sanitized message. The full message is logged server-side. @@ -179,6 +215,63 @@ McpSchema.CallToolResult handleToolCall( } } + private RefineProposalsResponse handleProposalRefinement( + ObjectMapper om, Map args, AdcpContext ctx) { + ProposalHandler handler = Objects.requireNonNull(proposalHandler); + RefineProposalsRequest request; + try { + request = om.convertValue(args, RefineProposalsRequest.class); + request.validateAgainst(handler.capability()); + } catch (UnsupportedRefinementException e) { + throw e; + } catch (IllegalArgumentException e) { + throw new ValidationError(e.getMessage(), "refinements"); + } + + String preflightFailure = handler.preflight( + request.refinements(), request.idempotencyKey(), ctx); + if (preflightFailure != null) { + throw new ValidationError(preflightFailure, "refinements"); + } + + boolean finalize = request.refinements().stream() + .allMatch(r -> r.action() == RefinementAction.FINALIZE); + Supplier operation = () -> { + RefineProposalsResponse candidate = handler.refineResponse( + request.refinements(), request.idempotencyKey(), ctx); + validateProposalResponse(request, candidate); + return candidate; + }; + RefineProposalsResponse response = finalize + ? handler.finalizeAtomically( + request.refinements(), request.idempotencyKey(), ctx, operation) + : operation.get(); + // Exact-replay implementations may return cached results without invoking + // operation. Validate those too; newly-created results were already + // validated inside the transaction callback before commit. + validateProposalResponse(request, response); + String version = response.adcpVersion(); + if (version == null && ctx.adcpVersion() != null) { + version = ctx.adcpVersion().minorVersion(); + } + return new RefineProposalsResponse( + response.results(), response.products(), response.status(), response.taskId(), + response.message(), response.errors(), version, + response.context() != null ? response.context() : request.context(), + response.ext(), response.replayed()); + } + + private static void validateProposalResponse( + RefineProposalsRequest request, + RefineProposalsResponse response) { + List violations = ResponseVerifier.verify(request, response); + if (!violations.isEmpty()) { + throw new ValidationError( + "proposal handler returned an invalid response: " + String.join("; ", violations), + "results"); + } + } + private static final int MAX_ERROR_MESSAGE_LENGTH = 500; /** 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 index 4bf120b..556d3d8 100644 --- 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 @@ -3,10 +3,12 @@ import org.adcontextprotocol.adcp.negotiation.ProposalRefinement; import org.adcontextprotocol.adcp.negotiation.RefinementCapability; import org.adcontextprotocol.adcp.negotiation.RefinementResult; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse; import org.adcontextprotocol.adcp.server.AdcpContext; import org.jspecify.annotations.Nullable; import java.util.List; +import java.util.function.Supplier; /** * Server-side handler for proposal refinement operations. @@ -23,8 +25,7 @@ * @Override * public RefinementCapability capability() { * return new RefinementCapability( - * Set.of("product_changes", "total_budget"), - * 10, true); + * Set.of("product_changes", "total_budget"), 10); * } * * @Override @@ -78,6 +79,37 @@ public interface ProposalHandler { List refine(List refinements, String idempotencyKey, AdcpContext ctx); + /** + * Full-response hook for sellers that need to return canonical products, + * asynchronous submission, context, or exact-replay metadata. The default + * wraps {@link #refine} as a synchronous completed response. + */ + default RefineProposalsResponse refineResponse( + List refinements, + String idempotencyKey, AdcpContext ctx) { + return new RefineProposalsResponse( + refine(refinements, idempotencyKey, ctx), List.of(), "completed", + null, null, null, null, null, null, null); + } + + /** + * Applies a homogeneous finalize batch in one transaction. + * + *

The implementation must invoke {@code operation} inside its transaction + * and commit only after it returns; the operation includes SDK response + * validation. It must create every requested hold or create none and + * implement exact idempotent replay for {@code idempotencyKey}. + * The default fails closed so a seller cannot accidentally provide partial + * finalization by routing finalize through {@link #refine}. + */ + default RefineProposalsResponse finalizeAtomically( + List refinements, + String idempotencyKey, AdcpContext ctx, + Supplier operation) { + throw new UnsupportedOperationException( + "finalizeAtomically must be implemented before advertising finalization"); + } + /** * Optional hook called before the batch is dispatched to * {@link #refine}. Returns null to proceed, or an error diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessor.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessor.java index e04a584..c1d9f7a 100644 --- a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessor.java +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessor.java @@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.adcontextprotocol.adcp.negotiation.TermsDigest; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; import java.util.Objects; import java.util.UUID; @@ -32,9 +34,9 @@ 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()); - } + // A successor is immutable protocol state. Never preserve an ID supplied + // by an application draft, since it may be the source proposal's ID. + draft.put("proposal_id", UUID.randomUUID().toString()); draft.put("parent_proposal_id", sourceProposalId); if (!draft.has("proposal_status")) { @@ -42,9 +44,10 @@ public static ObjectNode stamp(ObjectNode draft, String sourceProposalId) { } JsonNode terms = draft.get("commercial_terms"); - if (terms != null && !terms.isNull()) { - draft.put("terms_digest", TermsDigest.compute(terms)); + if (terms == null || !terms.isObject()) { + throw new IllegalArgumentException("commercial_terms object is required"); } + draft.put("terms_digest", TermsDigest.compute(terms)); return draft; } @@ -56,6 +59,11 @@ public static ObjectNode stamp(ObjectNode draft, String sourceProposalId) { public static ObjectNode stampFinalized(ObjectNode draft, String sourceProposalId, String expiresAt) { Objects.requireNonNull(expiresAt, "expiresAt is required for finalized proposals"); + try { + OffsetDateTime.parse(expiresAt); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("expiresAt must be an RFC 3339 timestamp", e); + } stamp(draft, sourceProposalId); draft.put("proposal_status", "committed"); draft.put("expires_at", expiresAt); diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/ProposalHandlerIntegrationTest.java b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/ProposalHandlerIntegrationTest.java new file mode 100644 index 0000000..97d6a3a --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/ProposalHandlerIntegrationTest.java @@ -0,0 +1,177 @@ +package org.adcontextprotocol.adcp.server; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.modelcontextprotocol.spec.McpSchema; +import org.adcontextprotocol.adcp.negotiation.ProposalRefinement; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest; +import org.adcontextprotocol.adcp.negotiation.RefinementCapability; +import org.adcontextprotocol.adcp.negotiation.RefinementAction; +import org.adcontextprotocol.adcp.negotiation.RefinementResult; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse; +import org.adcontextprotocol.adcp.schema.AdcpObjectMapperFactory; +import org.adcontextprotocol.adcp.server.negotiation.ProposalHandler; +import org.adcontextprotocol.adcp.server.negotiation.ProposalSuccessor; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class ProposalHandlerIntegrationTest { + + private final ObjectMapper mapper = AdcpObjectMapperFactory.create(); + + @Test + void revise_dispatches_only_after_preflight_and_validates_result() { + RecordingHandler handler = new RecordingHandler(); + AdcpServerBuilder server = AdcpServerBuilder.create(new EmptyPlatform()) + .proposalHandler(handler); + var request = RefineProposalsRequest.builder().idempotencyKey(key()) + .addRefinement(ProposalRefinement.revise("src-1", "lower price")) + .build(); + + McpSchema.CallToolResult result = call(server, request); + + assertFalse(result.isError()); + assertTrue(handler.preflightCalled); + assertEquals(1, handler.refineCalls); + assertEquals(0, handler.finalizeCalls); + } + + @Test + void finalize_uses_explicit_atomic_callback() { + RecordingHandler handler = new RecordingHandler(); + AdcpServerBuilder server = AdcpServerBuilder.create(new EmptyPlatform()) + .proposalHandler(handler); + var request = RefineProposalsRequest.builder().idempotencyKey(key()) + .addRefinement(ProposalRefinement.finalize("src-1")) + .build(); + + McpSchema.CallToolResult result = call(server, request); + + assertFalse(result.isError()); + assertEquals(1, handler.refineCalls); + assertEquals(1, handler.finalizeCalls); + } + + @Test + void preflight_failure_prevents_mutation() { + RecordingHandler handler = new RecordingHandler(); + handler.preflightError = "source does not belong to this buyer"; + AdcpServerBuilder server = AdcpServerBuilder.create(new EmptyPlatform()) + .proposalHandler(handler); + var request = RefineProposalsRequest.builder().idempotencyKey(key()) + .addRefinement(ProposalRefinement.revise("src-1", "change")) + .build(); + + McpSchema.CallToolResult result = call(server, request); + + assertTrue(result.isError()); + assertEquals(0, handler.refineCalls + handler.finalizeCalls); + } + + @Test + void invalid_finalize_result_fails_before_atomic_commit() { + boolean[] committed = {false}; + ProposalHandler handler = new ProposalHandler() { + @Override + public RefinementCapability capability() { + return new RefinementCapability(Set.of(), null); + } + + @Override + public List refine(List refinements, + String key, AdcpContext context) { + ObjectNode invalid = mapper.createObjectNode(); + invalid.put("proposal_id", refinements.getFirst().proposalId()); + invalid.put("parent_proposal_id", refinements.getFirst().proposalId()); + invalid.put("proposal_status", "committed"); + invalid.put("expires_at", "2099-01-01T00:00:00Z"); + invalid.set("commercial_terms", mapper.createObjectNode().put("price", 10)); + invalid.put("terms_digest", "sha256:invalid"); + return List.of(new RefinementResult.Finalized( + refinements.getFirst().proposalId(), invalid)); + } + + @Override + public RefineProposalsResponse finalizeAtomically( + List refinements, String key, AdcpContext context, + java.util.function.Supplier operation) { + RefineProposalsResponse results = operation.get(); + committed[0] = true; + return results; + } + }; + AdcpServerBuilder server = AdcpServerBuilder.create(new EmptyPlatform()) + .proposalHandler(handler); + var request = RefineProposalsRequest.builder().idempotencyKey(key()) + .addRefinement(ProposalRefinement.finalize("src-1")) + .build(); + + assertTrue(call(server, request).isError()); + assertFalse(committed[0]); + } + + @SuppressWarnings("unchecked") + private McpSchema.CallToolResult call(AdcpServerBuilder server, + RefineProposalsRequest request) { + Map args = mapper.convertValue(request, Map.class); + return server.handleToolCall(mapper, "refine_proposals", + new McpSchema.CallToolRequest("refine_proposals", args)); + } + + private static String key() { + return "idem-" + UUID.randomUUID().toString().replace("-", ""); + } + + private static final class EmptyPlatform extends AdcpPlatform {} + + private final class RecordingHandler implements ProposalHandler { + boolean preflightCalled; + String preflightError; + int refineCalls; + int finalizeCalls; + + @Override + public RefinementCapability capability() { + return new RefinementCapability(Set.of(), null); + } + + @Override + public String preflight(List refinements, + String idempotencyKey, AdcpContext ctx) { + preflightCalled = true; + return preflightError; + } + + @Override + public List refine(List refinements, + String idempotencyKey, AdcpContext ctx) { + refineCalls++; + ObjectNode draft = mapper.createObjectNode(); + draft.set("commercial_terms", mapper.createObjectNode().put("price", 10)); + if (refinements.getFirst().action() == RefinementAction.FINALIZE) { + ProposalSuccessor.stampFinalized( + draft, refinements.getFirst().proposalId(), "2099-01-01T00:00:00Z"); + return List.of(new RefinementResult.Finalized( + refinements.getFirst().proposalId(), draft)); + } + ProposalSuccessor.stamp(draft, refinements.getFirst().proposalId()); + return List.of(new RefinementResult.Revised( + refinements.getFirst().proposalId(), List.of(draft), null)); + } + + @Override + public RefineProposalsResponse finalizeAtomically( + List refinements, + String idempotencyKey, AdcpContext ctx, + java.util.function.Supplier operation) { + finalizeCalls++; + return operation.get(); + } + } +} 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 index d9de3fb..88663e7 100644 --- 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 @@ -26,13 +26,15 @@ void stamp_sets_lineage_and_digest() { } @Test - void stamp_preserves_existing_proposal_id() { + void stamp_always_assigns_fresh_proposal_id() { ObjectNode draft = mapper.createObjectNode(); draft.put("proposal_id", "keep-this"); + draft.set("commercial_terms", mapper.createObjectNode().put("price", 1)); ProposalSuccessor.stamp(draft, "parent-1"); - assertEquals("keep-this", draft.get("proposal_id").asText()); + assertNotEquals("keep-this", draft.get("proposal_id").asText()); + assertNotEquals("parent-1", draft.get("proposal_id").asText()); } @Test @@ -54,4 +56,18 @@ void rejects_null_source() { assertThrows(NullPointerException.class, () -> ProposalSuccessor.stamp(draft, null)); } + + @Test + void rejects_missing_commercial_terms() { + assertThrows(IllegalArgumentException.class, + () -> ProposalSuccessor.stamp(mapper.createObjectNode(), "parent-1")); + } + + @Test + void rejects_invalid_finalized_expiry() { + ObjectNode draft = mapper.createObjectNode(); + draft.set("commercial_terms", mapper.createObjectNode().put("price", 1)); + assertThrows(IllegalArgumentException.class, + () -> ProposalSuccessor.stampFinalized(draft, "parent-1", "tomorrow")); + } } diff --git a/adcp-spring-boot-starter/gradle.lockfile b/adcp-spring-boot-starter/gradle.lockfile index 1e574f9..bc69662 100644 --- a/adcp-spring-boot-starter/gradle.lockfile +++ b/adcp-spring-boot-starter/gradle.lockfile @@ -15,6 +15,7 @@ com.google.errorprone:error_prone_annotations:2.48.0=compileClasspath,runtimeCla com.google.protobuf:protobuf-java-util:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.protobuf:protobuf-java:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.networknt:json-schema-validator:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.erdtman:java-json-canonicalization:1.1=runtimeClasspath,testRuntimeClasspath io.micrometer:micrometer-commons:1.14.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.14.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-core:1.1.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/adcp-testing/gradle.lockfile b/adcp-testing/gradle.lockfile index 4e503ff..006269c 100644 --- a/adcp-testing/gradle.lockfile +++ b/adcp-testing/gradle.lockfile @@ -17,6 +17,7 @@ com.google.protobuf:protobuf-java-util:4.33.2=compileClasspath,runtimeClasspath, com.google.protobuf:protobuf-java:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.networknt:json-schema-validator:1.5.6=runtimeClasspath com.networknt:json-schema-validator:2.0.0=testCompileClasspath,testRuntimeClasspath +io.github.erdtman:java-json-canonicalization:1.1=runtimeClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-core:1.1.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-json-jackson2:1.1.2=runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.projectreactor:reactor-core:3.7.0=runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/NegotiationAssertions.java b/adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/NegotiationAssertions.java new file mode 100644 index 0000000..0aa3710 --- /dev/null +++ b/adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/NegotiationAssertions.java @@ -0,0 +1,58 @@ +package org.adcontextprotocol.adcp.testing.negotiation; + +import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse; +import org.adcontextprotocol.adcp.negotiation.RefinementOutcome; +import org.adcontextprotocol.adcp.negotiation.ResponseVerifier; + +import java.time.Instant; +import java.util.List; + +/** Reusable conformance assertions for proposal-negotiation implementations. */ +public final class NegotiationAssertions { + + private NegotiationAssertions() {} + + /** Fails with all protocol violations, rather than stopping at the first. */ + public static void assertValid(RefineProposalsRequest request, + RefineProposalsResponse response) { + assertValid(request, response, Instant.now()); + } + + /** Time-controlled variant for deterministic committed-hold expiry tests. */ + public static void assertValid(RefineProposalsRequest request, + RefineProposalsResponse response, + Instant now) { + List violations = ResponseVerifier.verify(request, response, now); + if (!violations.isEmpty()) { + throw new AssertionError("Invalid refine_proposals response: " + + String.join("; ", violations)); + } + } + + /** Asserts the ordered per-entry outcomes without conflating task errors. */ + public static void assertOutcomes(RefineProposalsResponse response, + RefinementOutcome... expected) { + if (response.results() == null) { + throw new AssertionError("Response has no completed results"); + } + List actual = response.results().stream() + .map(result -> result.outcome()).toList(); + if (!actual.equals(List.of(expected))) { + throw new AssertionError("Expected outcomes " + List.of(expected) + + " but got " + actual); + } + } + + /** Asserts an exact idempotent replay without conflating it with a new request. */ + public static void assertExactReplay(RefineProposalsResponse original, + RefineProposalsResponse replay) { + if (!Boolean.TRUE.equals(replay.replayed())) { + throw new AssertionError("Replay response is missing replayed=true"); + } + if (!java.util.Objects.equals(original.results(), replay.results()) + || !java.util.Objects.equals(original.products(), replay.products())) { + throw new AssertionError("Idempotent replay changed protocol results"); + } + } +} 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 index 743863b..8ed1b5c 100644 --- 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 @@ -9,6 +9,8 @@ import org.adcontextprotocol.adcp.negotiation.ProposalRefinement; import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest; import org.adcontextprotocol.adcp.negotiation.TermsDigest; +import org.adcontextprotocol.adcp.negotiation.RefinementConstraints; +import org.adcontextprotocol.adcp.negotiation.TotalBudgetConstraint; import java.math.BigDecimal; import java.time.OffsetDateTime; @@ -43,14 +45,22 @@ public static ObjectNode draftProposal(String proposalId, String parentProposalI proposal.put("name", "Test Plan " + proposalId); ObjectNode terms = MAPPER.createObjectNode(); - terms.put("total_budget", 50000); - terms.put("currency", "USD"); + terms.set("total_budget", MAPPER.createObjectNode() + .put("amount", 50000).put("currency", "USD")); + terms.put("start_time", "2026-10-01T00:00:00Z"); + terms.put("end_time", "2026-12-31T23:59:59Z"); + ObjectNode pricing = MAPPER.createObjectNode() + .put("pricing_model", "cpm") + .put("fixed_price", 10) + .put("currency", "USD"); + ObjectNode purchase = MAPPER.createObjectNode() + .put("product_id", "prod-1") + .put("impressions", 100_000); + purchase.set("pricing", pricing); + terms.putArray("purchases").add(purchase); 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; } @@ -82,6 +92,22 @@ public static RefineProposalsRequest batchFinalizeRequest(List proposalI return builder.build(); } + /** A request exactly at the protocol batch ceiling of 25. */ + public static RefineProposalsRequest maximumBatchRequest() { + var builder = RefineProposalsRequest.builder() + .idempotencyKey(randomIdempotencyKey()); + for (int i = 0; i < RefineProposalsRequest.PROTOCOL_MAX_REFINEMENTS; i++) { + builder.addRefinement(ProposalRefinement.revise( + "source-" + i, "deterministic fixture revision")); + } + return builder.build(); + } + + /** A revision exactly at the protocol alternatives ceiling of 10. */ + public static ProposalRefinement maximumAlternativesRefinement(String proposalId) { + return ProposalRefinement.builder(proposalId).alternatives(10).build(); + } + // -- Constraints -- public static CpmConstraint standardCpmCeiling() { @@ -98,6 +124,14 @@ public static FlightConstraint q4Flight() { OffsetDateTime.of(2026, 12, 31, 23, 59, 59, 0, ZoneOffset.UTC)); } + /** Composite fixture exercising every typed hard constraint. */ + public static RefinementConstraints allHardConstraints() { + return new RefinementConstraints( + new TotalBudgetConstraint(new BigDecimal("1000"), + new BigDecimal("50000"), "USD"), + standardCpmCeiling(), minimumImpressions(), q4Flight()); + } + // -- Response fragments -- /** @@ -111,7 +145,7 @@ public static String revisedResponseJson(String sourceProposalId, ObjectNode result = MAPPER.createObjectNode(); result.put("source_proposal_id", sourceProposalId); result.put("outcome", "revised"); - result.set("proposal", proposal); + result.putArray("proposals").add(proposal); ObjectNode response = MAPPER.createObjectNode(); response.put("status", "completed"); @@ -121,14 +155,50 @@ public static String revisedResponseJson(String sourceProposalId, return response.toString(); } + /** Builds a partial response; pass any protocol reason code. */ + public static String partialResponseJson(String sourceProposalId, + String newProposalId, + String reasonCode) { + ObjectNode result = MAPPER.createObjectNode(); + result.put("source_proposal_id", sourceProposalId); + result.put("outcome", "partial"); + result.putArray("proposals").add(draftProposal(newProposalId, sourceProposalId)); + result.put("reason_code", reasonCode); + result.put("reason", "Deterministic fixture counteroffer"); + if ("constraint_unsatisfiable".equals(reasonCode)) { + result.putArray("unsatisfied_constraints").add("total_budget"); + } + ObjectNode response = MAPPER.createObjectNode().put("status", "completed"); + response.putArray("results").add(result); + response.putArray("products"); + return response.toString(); + } + + /** Builds a committed finalized response with a future hold. */ + public static String finalizedResponseJson(String sourceProposalId, + String newProposalId) { + ObjectNode result = MAPPER.createObjectNode(); + result.put("source_proposal_id", sourceProposalId); + result.put("outcome", "finalized"); + result.set("proposal", committedProposal(newProposalId, sourceProposalId)); + ObjectNode response = MAPPER.createObjectNode().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) { + public static String unableResponseJson(String sourceProposalId, String reasonCode) { ObjectNode result = MAPPER.createObjectNode(); result.put("source_proposal_id", sourceProposalId); result.put("outcome", "unable"); - result.put("reason", reason); + result.put("reason_code", reasonCode); + result.put("reason", "Deterministic fixture outcome"); + if ("constraint_unsatisfiable".equals(reasonCode)) { + result.putArray("unsatisfied_constraints").add("total_budget"); + } ObjectNode response = MAPPER.createObjectNode(); response.put("status", "completed"); diff --git a/adcp-testing/src/test/java/org/adcontextprotocol/adcp/testing/NegotiationFixturesTest.java b/adcp-testing/src/test/java/org/adcontextprotocol/adcp/testing/NegotiationFixturesTest.java new file mode 100644 index 0000000..77e85e6 --- /dev/null +++ b/adcp-testing/src/test/java/org/adcontextprotocol/adcp/testing/NegotiationFixturesTest.java @@ -0,0 +1,47 @@ +package org.adcontextprotocol.adcp.testing; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.adcontextprotocol.adcp.negotiation.ProposalRefinementReason; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse; +import org.adcontextprotocol.adcp.negotiation.RefinementOutcome; +import org.adcontextprotocol.adcp.schema.AdcpObjectMapperFactory; +import org.adcontextprotocol.adcp.testing.negotiation.NegotiationAssertions; +import org.adcontextprotocol.adcp.testing.negotiation.NegotiationFixtures; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class NegotiationFixturesTest { + + private final ObjectMapper mapper = AdcpObjectMapperFactory.create(); + + @Test + void fixtures_cover_all_outcomes_and_reason_codes() throws Exception { + NegotiationAssertions.assertOutcomes(read( + NegotiationFixtures.revisedResponseJson("source", "draft")), + RefinementOutcome.REVISED); + NegotiationAssertions.assertOutcomes(read( + NegotiationFixtures.partialResponseJson( + "source", "draft", "alternatives_unavailable")), + RefinementOutcome.PARTIAL); + NegotiationAssertions.assertOutcomes(read( + NegotiationFixtures.finalizedResponseJson("source", "committed")), + RefinementOutcome.FINALIZED); + for (ProposalRefinementReason reason : ProposalRefinementReason.values()) { + NegotiationAssertions.assertOutcomes(read( + NegotiationFixtures.unableResponseJson("source", reason.toWire())), + RefinementOutcome.UNABLE); + } + } + + @Test + void fixtures_cover_protocol_cardinality_boundaries() { + assertEquals(25, NegotiationFixtures.maximumBatchRequest().refinements().size()); + assertEquals(10, NegotiationFixtures.maximumAlternativesRefinement("source") + .alternatives().count()); + } + + private RefineProposalsResponse read(String json) throws Exception { + return mapper.readValue(json, RefineProposalsResponse.class); + } +} diff --git a/adcp/build.gradle.kts b/adcp/build.gradle.kts index 17e4559..59be76c 100644 --- a/adcp/build.gradle.kts +++ b/adcp/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { api(libs.jackson.datatype.jsr310) api(libs.slf4j.api) api(libs.jspecify) + implementation(libs.jcs) implementation(libs.json.schema.validator) // MCP SDK client transport — needed for McpClient, StreamableHTTP, SSE fallback. // Same artifacts as adcp-server; here they provide the caller/client side. diff --git a/adcp/gradle.lockfile b/adcp/gradle.lockfile index 5150e54..8db63b3 100644 --- a/adcp/gradle.lockfile +++ b/adcp/gradle.lockfile @@ -15,6 +15,7 @@ com.google.errorprone:error_prone_annotations:2.48.0=compileClasspath,runtimeCla com.google.protobuf:protobuf-java-util:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.google.protobuf:protobuf-java:4.33.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.networknt:json-schema-validator:1.5.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.github.erdtman:java-json-canonicalization:1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-core:1.1.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.modelcontextprotocol.sdk:mcp-json-jackson2:1.1.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.projectreactor:reactor-core:3.7.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/AlternativesRequest.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/AlternativesRequest.java new file mode 100644 index 0000000..af6d89c --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/AlternativesRequest.java @@ -0,0 +1,15 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** Request for distinct draft alternatives. */ +public record AlternativesRequest(@JsonProperty("count") int count) { + + public static final int PROTOCOL_MAX = 10; + + public AlternativesRequest { + if (count < 2 || count > PROTOCOL_MAX) { + throw new IllegalArgumentException("alternatives.count must be 2-" + PROTOCOL_MAX); + } + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/CpmConstraint.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/CpmConstraint.java index 5d5acaa..d41cd19 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/CpmConstraint.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/CpmConstraint.java @@ -16,9 +16,11 @@ public record CpmConstraint( @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"); + if (max == null || max.signum() <= 0) { + throw new IllegalArgumentException("cpm max must be positive"); + } + if (currency == null || !currency.matches("^[A-Z]{3}$")) { + throw new IllegalArgumentException("cpm currency must be a 3-letter ISO 4217 code"); } } } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ImpressionsConstraint.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ImpressionsConstraint.java index 3d24ade..46e02a6 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ImpressionsConstraint.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ImpressionsConstraint.java @@ -1,6 +1,7 @@ package org.adcontextprotocol.adcp.negotiation; import com.fasterxml.jackson.annotation.JsonProperty; +import java.math.BigDecimal; /** * Minimum summed impressions constraint across all purchases. @@ -8,9 +9,15 @@ * @param min minimum total impressions required */ public record ImpressionsConstraint( - @JsonProperty("min") long min) { + @JsonProperty("min") BigDecimal min) { public ImpressionsConstraint { - if (min < 0) throw new IllegalArgumentException("impressions min must be non-negative"); + if (min == null || min.signum() <= 0) { + throw new IllegalArgumentException("impressions min must be positive"); + } + } + + public ImpressionsConstraint(long min) { + this(BigDecimal.valueOf(min)); } } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java index 8ed2678..3a725a1 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode; import org.jspecify.annotations.Nullable; +import java.util.Map; import java.util.Objects; /** @@ -30,27 +31,37 @@ public record ProposalRefinement( @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) { + @Nullable @JsonProperty("product_changes") Map productChanges, + @Nullable @JsonProperty("alternatives") AlternativesRequest alternatives) { /** Protocol maximum for alternatives.count. */ - public static final int MAX_ALTERNATIVES = 10; + public static final int MAX_ALTERNATIVES = AlternativesRequest.PROTOCOL_MAX; 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"); + if (productChanges != null) { + productChanges = Map.copyOf(productChanges); + for (var entry : productChanges.entrySet()) { + if (entry.getKey().isBlank() + || !("include".equals(entry.getValue()) || "omit".equals(entry.getValue()))) { + throw new IllegalArgumentException( + "product_changes must map non-blank product IDs to include or omit"); + } } - int c = count.asInt(); - if (c < 2 || c > MAX_ALTERNATIVES) { - throw new IllegalArgumentException( - "alternatives.count must be 2-" + MAX_ALTERNATIVES + ", got " + c); + } + RefinementAction effectiveAction = action != null ? action : RefinementAction.REVISE; + if (effectiveAction == RefinementAction.FINALIZE) { + if (changeKind != null || ask != null || criteria != null || constraints != null + || (productChanges != null && !productChanges.isEmpty()) || alternatives != null) { + throw new IllegalArgumentException("finalize cannot change proposal terms"); } + } else if (changeKind != ChangeKind.CANCELLATION + && (ask == null || ask.isBlank()) && criteria == null && constraints == null + && (productChanges == null || productChanges.isEmpty()) && alternatives == null) { + throw new IllegalArgumentException("revise requires at least one requested change"); } } @@ -106,8 +117,8 @@ public static final class Builder { private @Nullable String ask; private @Nullable JsonNode criteria; private @Nullable RefinementConstraints constraints; - private @Nullable JsonNode productChanges; - private @Nullable JsonNode alternatives; + private @Nullable Map productChanges; + private @Nullable AlternativesRequest alternatives; private Builder(String proposalId) { this.proposalId = Objects.requireNonNull(proposalId); @@ -138,16 +149,20 @@ public Builder constraints(RefinementConstraints constraints) { return this; } - public Builder productChanges(JsonNode productChanges) { + public Builder productChanges(Map productChanges) { this.productChanges = productChanges; return this; } - public Builder alternatives(JsonNode alternatives) { + public Builder alternatives(AlternativesRequest alternatives) { this.alternatives = alternatives; return this; } + public Builder alternatives(int count) { + return alternatives(new AlternativesRequest(count)); + } + 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/ProposalRefinementReason.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinementReason.java new file mode 100644 index 0000000..a21f390 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinementReason.java @@ -0,0 +1,35 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +/** Machine-readable reason for a partial or unable refinement result. */ +public enum ProposalRefinementReason { + COMMERCIALLY_DECLINED("commercially_declined"), + CONSTRAINT_UNSATISFIABLE("constraint_unsatisfiable"), + UNSUPPORTED_DIMENSION("unsupported_dimension"), + UNINTERPRETED("uninterpreted"), + ALTERNATIVES_UNAVAILABLE("alternatives_unavailable"), + SOURCE_UNAVAILABLE("source_unavailable"), + HOLD_UNAVAILABLE("hold_unavailable"), + BATCH_ABORTED("batch_aborted"); + + private final String wire; + + ProposalRefinementReason(String wire) { + this.wire = wire; + } + + @JsonValue + public String toWire() { + return wire; + } + + @JsonCreator + public static ProposalRefinementReason fromWire(String value) { + for (ProposalRefinementReason reason : values()) { + if (reason.wire.equals(value)) return reason; + } + throw new IllegalArgumentException("Unknown proposal refinement reason: " + value); + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java index 876068a..bee7580 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java @@ -27,11 +27,17 @@ */ @JsonInclude(JsonInclude.Include.NON_NULL) public record RefineProposalsRequest( + @Nullable @JsonProperty("adcp_version") String adcpVersion, + @Nullable @JsonProperty("adcp_major_version") Integer adcpMajorVersion, @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) { + @Nullable @JsonProperty("governance_context") String governanceContext, + @Nullable @JsonProperty("push_notification_config") JsonNode pushNotificationConfig) { + + /** Protocol maximum number of refinement entries in one request. */ + public static final int PROTOCOL_MAX_REFINEMENTS = 25; private static final Pattern IDEMPOTENCY_KEY_PATTERN = Pattern.compile("^[A-Za-z0-9_.:-]{16,255}$"); @@ -46,21 +52,46 @@ public record RefineProposalsRequest( if (refinements.isEmpty()) { throw new IllegalArgumentException("refinements must not be empty"); } + if (refinements.size() > PROTOCOL_MAX_REFINEMENTS) { + throw new IllegalArgumentException( + "refinements must contain at most " + PROTOCOL_MAX_REFINEMENTS + " entries"); + } refinements = List.copyOf(refinements); + validateBatchShape(refinements, PROTOCOL_MAX_REFINEMENTS); + } + + public RefineProposalsRequest(String idempotencyKey, + List refinements, + @Nullable String contextId, + @Nullable JsonNode context, + @Nullable String governanceContext) { + this(null, null, idempotencyKey, refinements, contextId, context, + governanceContext, null); } public static Builder builder() { return new Builder(); } + /** Validates typed dimensions and alternatives against seller capabilities. */ + public void validateAgainst(RefinementCapability capability) { + Objects.requireNonNull(capability, "capability"); + validateCapabilities(refinements, capability.supportedDimensions(), + capability.effectiveMaxAlternatives()); + } + 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 @Nullable JsonNode pushNotificationConfig; + private @Nullable String adcpVersion; + private @Nullable Integer adcpMajorVersion; + private int maxBatchSize = PROTOCOL_MAX_REFINEMENTS; private int maxAlternatives = ProposalRefinement.MAX_ALTERNATIVES; + private @Nullable Set supportedDimensions; private Builder() {} @@ -95,13 +126,37 @@ public Builder governanceContext(String governanceContext) { return this; } + public Builder pushNotificationConfig(JsonNode pushNotificationConfig) { + this.pushNotificationConfig = pushNotificationConfig; + return this; + } + + public Builder adcpVersion(String adcpVersion) { + this.adcpVersion = adcpVersion; + return this; + } + + public Builder adcpMajorVersion(int adcpMajorVersion) { + this.adcpMajorVersion = adcpMajorVersion; + return this; + } + + /** Applies the seller-advertised typed-dimension and alternatives limits. */ + public Builder capability(RefinementCapability capability) { + Objects.requireNonNull(capability, "capability"); + this.supportedDimensions = capability.supportedDimensions(); + this.maxAlternatives = capability.effectiveMaxAlternatives(); + 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"); + if (maxBatchSize < 1 || maxBatchSize > PROTOCOL_MAX_REFINEMENTS) { + throw new IllegalArgumentException( + "maxBatchSize must be 1-" + PROTOCOL_MAX_REFINEMENTS); } this.maxBatchSize = maxBatchSize; return this; @@ -111,8 +166,8 @@ public Builder maxBatchSize(int maxBatchSize) { * Sets the seller's alternatives ceiling (default 10 per protocol spec). */ public Builder maxAlternatives(int maxAlternatives) { - if (maxAlternatives < 2) { - throw new IllegalArgumentException("maxAlternatives must be >= 2"); + if (maxAlternatives < 2 || maxAlternatives > ProposalRefinement.MAX_ALTERNATIVES) { + throw new IllegalArgumentException("maxAlternatives must be 2-10"); } this.maxAlternatives = maxAlternatives; return this; @@ -121,47 +176,85 @@ public Builder maxAlternatives(int maxAlternatives) { public RefineProposalsRequest build() { validateBatch(); return new RefineProposalsRequest( - idempotencyKey, refinements, contextId, - context, governanceContext); + adcpVersion, adcpMajorVersion, idempotencyKey, refinements, contextId, + context, governanceContext, pushNotificationConfig); } private void validateBatch() { - if (refinements.size() > maxBatchSize) { - throw new IllegalArgumentException( - "batch size " + refinements.size() - + " exceeds maximum " + maxBatchSize); + validateBatchShape(refinements, maxBatchSize); + if (supportedDimensions != null) { + validateCapabilities(refinements, supportedDimensions, maxAlternatives); + } else { + validateAlternatives(refinements, maxAlternatives); } + } - // 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()); - } + private static Set requestedDimensions(ProposalRefinement refinement) { + Set dimensions = new HashSet<>(); + if (refinement.constraints() != null) { + if (refinement.constraints().totalBudget() != null) dimensions.add("total_budget"); + if (refinement.constraints().cpm() != null) dimensions.add("cpm"); + if (refinement.constraints().impressions() != null) dimensions.add("impressions"); + if (refinement.constraints().flight() != null) dimensions.add("flight"); + } + if (refinement.productChanges() != null && !refinement.productChanges().isEmpty()) { + dimensions.add("product_changes"); } + if (refinement.alternatives() != null) dimensions.add("alternatives"); + if (refinement.criteria() != null) dimensions.add("criteria"); + return dimensions; + } + } - // 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) { + private static void validateBatchShape(List refinements, int maximum) { + if (refinements.size() > maximum) { + throw new IllegalArgumentException( + "batch size " + refinements.size() + " exceeds maximum " + maximum); + } + Set ids = new HashSet<>(); + boolean hasFinalize = false; + boolean hasRevise = false; + for (ProposalRefinement refinement : refinements) { + if (!ids.add(refinement.proposalId())) { throw new IllegalArgumentException( - "a batch containing finalize must contain only finalize entries"); + "duplicate proposal_id in batch: " + refinement.proposalId()); + } + if (refinement.action() == RefinementAction.FINALIZE) { + hasFinalize = true; + } else { + hasRevise = true; } + } + if (hasFinalize && hasRevise) { + throw new IllegalArgumentException( + "a batch containing finalize must contain only finalize entries"); + } + } - // Validate alternatives count against seller ceiling - for (ProposalRefinement r : refinements) { - if (r.alternatives() != null) { - JsonNode count = r.alternatives().get("count"); - if (count != null && count.asInt() > maxAlternatives) { - throw new IllegalArgumentException( - "alternatives.count " + count.asInt() - + " exceeds seller maximum " + maxAlternatives); - } + private static void validateCapabilities(List refinements, + Set supportedDimensions, + int maxAlternatives) { + validateAlternatives(refinements, maxAlternatives); + for (ProposalRefinement refinement : refinements) { + for (String dimension : Builder.requestedDimensions(refinement)) { + if (!supportedDimensions.contains(dimension)) { + throw new UnsupportedRefinementException( + new UnsupportedRefinementDetails( + dimension, List.copyOf(supportedDimensions))); } } } } + + private static void validateAlternatives(List refinements, + int maxAlternatives) { + for (ProposalRefinement refinement : refinements) { + if (refinement.alternatives() != null + && refinement.alternatives().count() > maxAlternatives) { + throw new IllegalArgumentException( + "alternatives.count " + refinement.alternatives().count() + + " exceeds seller maximum " + maxAlternatives); + } + } + } } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsResponse.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsResponse.java index dedca41..b46991b 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsResponse.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsResponse.java @@ -18,6 +18,9 @@ * @param taskId non-null when status is "submitted" * @param message optional human-readable status message * @param errors optional error array from the response + * @param adcpVersion release-precision wire version + * @param context opaque correlation context echoed by the seller + * @param ext registered extension data * @param replayed true when this is a replayed idempotent response */ public record RefineProposalsResponse( @@ -28,8 +31,16 @@ public record RefineProposalsResponse( @Nullable @JsonProperty("message") String message, @Nullable @JsonProperty("errors") List errors, @Nullable @JsonProperty("adcp_version") String adcpVersion, + @Nullable @JsonProperty("context") JsonNode context, + @Nullable @JsonProperty("ext") JsonNode ext, @Nullable @JsonProperty("replayed") Boolean replayed) { + public RefineProposalsResponse { + results = results == null ? null : List.copyOf(results); + products = products == null ? null : List.copyOf(products); + errors = errors == null ? null : List.copyOf(errors); + } + /** * Whether this is a synchronous completed response. */ @@ -41,6 +52,6 @@ public boolean isCompleted() { * Whether this response was deferred for async processing. */ public boolean isAsync() { - return taskId != null; + return "submitted".equals(status); } } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java index f670342..6d63124 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import org.jspecify.annotations.Nullable; +import java.util.Objects; import java.util.Set; /** @@ -14,25 +15,27 @@ * (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 supportedDimensions, - @Nullable @JsonProperty("max_batch_size") Integer maxBatchSize, - @Nullable @JsonProperty("max_alternatives") Integer maxAlternatives, - @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; + @JsonProperty("supported_dimensions") Set supportedDimensions, + @Nullable @JsonProperty("max_alternatives") Integer maxAlternatives) { /** 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; + public RefinementCapability { + supportedDimensions = Set.copyOf(Objects.requireNonNull( + supportedDimensions, "supported_dimensions is required")); + if (maxAlternatives != null + && (maxAlternatives < 2 || maxAlternatives > AlternativesRequest.PROTOCOL_MAX)) { + throw new IllegalArgumentException("max_alternatives must be 2-10"); + } + if (maxAlternatives != null && !supportedDimensions.contains("alternatives")) { + throw new IllegalArgumentException( + "max_alternatives requires alternatives in supported_dimensions"); + } } public int effectiveMaxAlternatives() { diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementResult.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementResult.java index 074bc63..69d4e3b 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementResult.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementResult.java @@ -7,6 +7,7 @@ import org.jspecify.annotations.Nullable; import java.util.List; +import java.util.Map; /** * Sealed result for a single refinement entry in a response. @@ -40,9 +41,13 @@ public sealed interface RefinementResult { */ record Revised( @JsonProperty("source_proposal_id") String sourceProposalId, - @JsonProperty("proposal") JsonNode proposal, + @JsonProperty("proposals") List proposals, @Nullable @JsonProperty("targeting_resolution") JsonNode targetingResolution ) implements RefinementResult { + public Revised { + proposals = List.copyOf(proposals); + } + @Override public RefinementOutcome outcome() { return RefinementOutcome.REVISED; @@ -59,12 +64,23 @@ public RefinementOutcome outcome() { */ record Partial( @JsonProperty("source_proposal_id") String sourceProposalId, - @JsonProperty("proposal") JsonNode proposal, - @JsonProperty("notes") String notes, + @JsonProperty("proposals") List proposals, + @JsonProperty("reason_code") ProposalRefinementReason reasonCode, + @JsonProperty("reason") String reason, @Nullable @JsonProperty("unsatisfied_constraints") List unsatisfiedConstraints, + @Nullable @JsonProperty("unsatisfied_product_changes") Map unsatisfiedProductChanges, @Nullable @JsonProperty("suggestions") List suggestions, @Nullable @JsonProperty("targeting_resolution") JsonNode targetingResolution ) implements RefinementResult { + public Partial { + proposals = List.copyOf(proposals); + unsatisfiedConstraints = unsatisfiedConstraints == null + ? null : List.copyOf(unsatisfiedConstraints); + unsatisfiedProductChanges = unsatisfiedProductChanges == null + ? null : Map.copyOf(unsatisfiedProductChanges); + suggestions = suggestions == null ? null : List.copyOf(suggestions); + } + @Override public RefinementOutcome outcome() { return RefinementOutcome.PARTIAL; @@ -94,10 +110,20 @@ public RefinementOutcome outcome() { */ record Unable( @JsonProperty("source_proposal_id") String sourceProposalId, + @JsonProperty("reason_code") ProposalRefinementReason reasonCode, @JsonProperty("reason") String reason, - @Nullable @JsonProperty("notes") String notes, + @Nullable @JsonProperty("unsatisfied_constraints") List unsatisfiedConstraints, + @Nullable @JsonProperty("unsatisfied_product_changes") Map unsatisfiedProductChanges, @Nullable @JsonProperty("suggestions") List suggestions ) implements RefinementResult { + public Unable { + unsatisfiedConstraints = unsatisfiedConstraints == null + ? null : List.copyOf(unsatisfiedConstraints); + unsatisfiedProductChanges = unsatisfiedProductChanges == null + ? null : Map.copyOf(unsatisfiedProductChanges); + suggestions = suggestions == null ? null : List.copyOf(suggestions); + } + @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 index d7925e4..dc8c578 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java @@ -3,367 +3,418 @@ import com.fasterxml.jackson.databind.JsonNode; import java.math.BigDecimal; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; 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. - */ +/** Fail-closed verification for {@code refine_proposals} responses. */ 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) - */ + /** Verifies a response using the current time for committed-hold expiry. */ public static List verify(RefineProposalsRequest request, RefineProposalsResponse response) { - List violations = new ArrayList<>(); + return verify(request, response, Instant.now()); + } + /** Verifies ordering, shapes, lineage, digests, constraints, and expiry. */ + public static List verify(RefineProposalsRequest request, + RefineProposalsResponse response, + Instant now) { + List violations = new ArrayList<>(); + if (response.status() != null + && !"completed".equals(response.status()) + && !"submitted".equals(response.status())) { + violations.add("status must be completed or submitted"); + } + if (response.isAsync()) { + if (response.taskId() == null || response.taskId().isBlank()) { + violations.add("submitted response is missing task_id"); + } + if (response.results() != null && !response.results().isEmpty()) { + violations.add("submitted response must not contain results"); + } + return violations; + } + if (response.taskId() != null) { + violations.add("non-submitted response must not contain task_id"); + } if (!response.isCompleted() || response.results() == null) { + violations.add("completed response is missing results"); return violations; } - verifyResultOrdering(request, response, violations); - verifyFinalizeHomogeneity(response, violations); - verifyLineage(request, response, violations); - verifyUniqueProposalIds(response, violations); - verifyPartialInvariant(response, violations); - verifyOutcomeConstraints(response, violations); - verifyConstraintSatisfaction(request, 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++) { + Set proposalIds = new HashSet<>(); + boolean finalizeBatch = refinements.stream() + .allMatch(r -> effectiveAction(r) == RefinementAction.FINALIZE); + int finalizedResults = 0; + for (int i = 0; i < Math.min(results.size(), refinements.size()); i++) { RefinementResult result = results.get(i); - JsonNode proposal = extractProposal(result); - if (proposal == null) continue; + ProposalRefinement refinement = refinements.get(i); + String prefix = "result[" + i + "] "; - 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() + "'"); + if (!refinement.proposalId().equals(result.sourceProposalId())) { + violations.add(prefix + "source_proposal_id does not preserve request order"); + } + if (result.outcome() == RefinementOutcome.FINALIZED) finalizedResults++; + if ((!finalizeBatch && result.outcome() == RefinementOutcome.FINALIZED) + || (finalizeBatch && result.outcome() != RefinementOutcome.FINALIZED + && result.outcome() != RefinementOutcome.UNABLE)) { + violations.add(prefix + "outcome does not match request action"); } - } - } - - /** - * 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()); - } + List proposals = proposals(result); + verifyOutcomeShape(refinement, result, proposals, now, prefix, violations); + verifyFailureSubsets(refinement, result, prefix, violations); + + Set termsDigests = new HashSet<>(); + for (int p = 0; p < proposals.size(); p++) { + JsonNode proposal = proposals.get(p); + String proposalPrefix = prefix + "proposal[" + p + "] "; + verifyProposal(proposal, result.sourceProposalId(), proposalPrefix, + proposalIds, termsDigests, violations); + verifySatisfiedDimensions(refinement, result, proposal, + proposalPrefix, violations); } } + if (finalizeBatch && finalizedResults > 0 && finalizedResults != results.size()) { + violations.add("finalize batch mixes committed and failed results; holds are not atomic"); + } + return violations; } - /** - * 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"); + private static void verifyOutcomeShape(ProposalRefinement refinement, + RefinementResult result, + List proposals, + Instant now, + String prefix, + List violations) { + int expected = refinement.alternatives() == null + ? 1 : refinement.alternatives().count(); + switch (result) { + case RefinementResult.Revised ignored -> { + if (proposals.size() != expected) { + violations.add(prefix + "revised returned " + proposals.size() + + " proposals, expected " + expected); } + proposals.forEach(p -> checkStatus(p, "draft", prefix, violations)); } - } - } - - /** - * 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 partial -> { + if (proposals.isEmpty()) { + violations.add(prefix + "partial is missing proposals"); } - 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"); - } + if (proposals.size() > expected) { + violations.add(prefix + "partial returned more proposals than requested"); } - 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"); + if (partial.reasonCode() == null || partial.reason() == null + || partial.reason().isBlank()) { + violations.add(prefix + "partial is missing reason_code or reason"); + } + proposals.forEach(p -> checkStatus(p, "draft", prefix, violations)); + } + case RefinementResult.Finalized finalized -> { + if (proposals.size() != 1) { + violations.add(prefix + "finalized is missing proposal"); + return; + } + JsonNode proposal = finalized.proposal(); + checkStatus(proposal, "committed", prefix, violations); + JsonNode expires = proposal.get("expires_at"); + if (expires == null || !expires.isTextual()) { + violations.add(prefix + "committed proposal is missing expires_at"); + } else { + try { + if (!OffsetDateTime.parse(expires.textValue()).toInstant().isAfter(now)) { + violations.add(prefix + "committed proposal hold is expired"); } + } catch (DateTimeParseException e) { + violations.add(prefix + "committed proposal has invalid expires_at"); } } - case RefinementResult.Unable u -> { - if (u.reason() == null || u.reason().isBlank()) { - violations.add("result[" + i + "] unable but missing reason"); - } + } + case RefinementResult.Unable unable -> { + if (!proposals.isEmpty()) { + violations.add(prefix + "unable must not contain proposals"); + } + if (unable.reasonCode() == null || unable.reason() == null + || unable.reason().isBlank()) { + violations.add(prefix + "unable is missing reason_code or 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"); - } + private static void verifyProposal(JsonNode proposal, + String sourceProposalId, + String prefix, + Set allProposalIds, + Set resultDigests, + List violations) { + if (proposal == null || !proposal.isObject()) { + violations.add(prefix + "must be an object"); + return; + } + String proposalId = text(proposal, "proposal_id"); + if (proposalId == null || proposalId.isBlank()) { + violations.add(prefix + "is missing proposal_id"); + } else { + if (proposalId.equals(sourceProposalId)) { + violations.add(prefix + "must use a fresh proposal_id"); + } + if (!allProposalIds.add(proposalId)) { + violations.add(prefix + "duplicates proposal_id " + proposalId); } } - - return violations; + if (!Objects.equals(sourceProposalId, text(proposal, "parent_proposal_id"))) { + violations.add(prefix + "parent_proposal_id does not match source_proposal_id"); + } + JsonNode terms = proposal.get("commercial_terms"); + String digest = text(proposal, "terms_digest"); + if (terms == null || !terms.isObject() || digest == null + || !TermsDigest.verify(digest, terms)) { + violations.add(prefix + "terms_digest does not match commercial_terms"); + } else if (!resultDigests.add(digest)) { + violations.add(prefix + "duplicates commercial_terms within alternatives"); + } } - /** - * Verifies that revised results satisfy the request's typed constraints. - * Only checks revised outcomes (partial/unable carry their own diagnostics). - * Verifies budget bounds and CPM ceiling against commercial_terms. - */ - public static void verifyConstraintSatisfaction( - RefineProposalsRequest request, - RefineProposalsResponse response, - List violations) { - List results = response.results(); - List refinements = request.refinements(); - if (results == null) return; - - for (int i = 0; i < Math.min(results.size(), refinements.size()); i++) { - if (!(results.get(i) instanceof RefinementResult.Revised revised)) continue; - - RefinementConstraints constraints = refinements.get(i).constraints(); - if (constraints == null) continue; - - JsonNode proposal = revised.proposal(); - if (proposal == null || proposal.isNull()) continue; - - JsonNode terms = proposal.get("commercial_terms"); - if (terms == null || terms.isNull()) continue; - - if (constraints.totalBudget() != null) { - verifyBudgetBounds(terms, constraints.totalBudget(), i, violations); + private static void verifyFailureSubsets(ProposalRefinement refinement, + RefinementResult result, + String prefix, + List violations) { + List unsatisfied = unsatisfiedConstraints(result); + Map unsatisfiedProducts = unsatisfiedProductChanges(result); + Set requested = requestedConstraints(refinement); + Set reported = new HashSet<>(); + for (String constraint : unsatisfied) { + if (constraint == null || !reported.add(constraint)) { + violations.add(prefix + "reports duplicate or empty unsatisfied constraint"); + continue; } - if (constraints.cpm() != null) { - verifyCpmCeiling(terms, constraints.cpm(), i, violations); + if (!requested.contains(constraint)) { + violations.add(prefix + "reports unrequested constraint " + constraint); } } + Map requestedProducts = refinement.productChanges() == null + ? Map.of() : refinement.productChanges(); + unsatisfiedProducts.forEach((id, action) -> { + if (!action.equals(requestedProducts.get(id))) { + violations.add(prefix + "reports unrequested product change " + id); + } + }); + ProposalRefinementReason reason = reasonCode(result); + if ((!unsatisfied.isEmpty() || !unsatisfiedProducts.isEmpty()) + && reason != ProposalRefinementReason.CONSTRAINT_UNSATISFIABLE) { + violations.add(prefix + "must use constraint_unsatisfiable precedence"); + } + if (reason == ProposalRefinementReason.CONSTRAINT_UNSATISFIABLE + && unsatisfied.isEmpty() && unsatisfiedProducts.isEmpty()) { + violations.add(prefix + "constraint_unsatisfiable has no failure subset"); + } } - private static void verifyBudgetBounds(JsonNode terms, TotalBudgetConstraint budget, - int index, List violations) { - JsonNode totalBudget = terms.get("total_budget"); - if (totalBudget == null || totalBudget.isNull()) { - violations.add("result[" + index - + "] revised but commercial_terms.total_budget is missing" - + " (budget constraint present)"); - return; + private static void verifySatisfiedDimensions(ProposalRefinement refinement, + RefinementResult result, + JsonNode proposal, + String prefix, + List violations) { + JsonNode terms = proposal.get("commercial_terms"); + if (terms == null || !terms.isObject()) return; + Set unsatisfied = new HashSet<>(unsatisfiedConstraints(result)); + Map unsatisfiedProducts = unsatisfiedProductChanges(result); + RefinementConstraints constraints = refinement.constraints(); + if (constraints != null) { + if (constraints.totalBudget() != null && !unsatisfied.contains("total_budget")) { + verifyBudget(terms, constraints.totalBudget(), prefix, violations); + } + if (constraints.cpm() != null && !unsatisfied.contains("cpm")) { + verifyCpm(terms, constraints.cpm(), prefix, violations); + } + if (constraints.impressions() != null && !unsatisfied.contains("impressions")) { + verifyImpressions(terms, constraints.impressions(), prefix, violations); + } + if (constraints.flight() != null && !unsatisfied.contains("flight")) { + verifyFlight(terms, constraints.flight(), prefix, violations); + } } - JsonNode amountNode = totalBudget.get("amount"); - JsonNode currencyNode = totalBudget.get("currency"); - if (amountNode == null || currencyNode == null) return; + if (refinement.productChanges() != null) { + Set present = new HashSet<>(); + JsonNode purchases = terms.get("purchases"); + if (purchases == null || !purchases.isArray() || purchases.isEmpty()) { + violations.add(prefix + "product changes cannot be verified without purchases"); + return; + } + purchases.forEach(p -> { + String id = text(p, "product_id"); + if (id != null) present.add(id); + }); + refinement.productChanges().forEach((id, action) -> { + if (unsatisfiedProducts.containsKey(id)) return; + if (("include".equals(action) && !present.contains(id)) + || ("omit".equals(action) && present.contains(id))) { + violations.add(prefix + "product change is not satisfied for " + id); + } + }); + } + } - String currency = currencyNode.asText(); - if (!budget.currency().equals(currency)) { - violations.add("result[" + index - + "] budget currency mismatch: constraint=" + budget.currency() - + " proposal=" + currency); + private static void verifyBudget(JsonNode terms, TotalBudgetConstraint constraint, + String prefix, List violations) { + JsonNode budget = terms.get("total_budget"); + if (budget == null || !budget.isObject() || !budget.path("amount").isNumber() + || !budget.path("currency").isTextual()) { + violations.add(prefix + "total_budget constraint cannot be verified"); return; } + BigDecimal amount = budget.get("amount").decimalValue(); + if (amount.signum() < 0 + || !constraint.currency().equals(budget.get("currency").textValue()) + || (constraint.min() != null && amount.compareTo(constraint.min()) < 0) + || (constraint.max() != null && amount.compareTo(constraint.max()) > 0)) { + violations.add(prefix + "total_budget constraint is not satisfied"); + } + } - BigDecimal amount = amountNode.decimalValue(); - if (budget.min() != null && amount.compareTo(budget.min()) < 0) { - violations.add("result[" + index - + "] budget " + amount + " below constraint min " + budget.min()); + private static void verifyCpm(JsonNode terms, CpmConstraint constraint, + String prefix, List violations) { + JsonNode purchases = terms.get("purchases"); + if (purchases == null || !purchases.isArray() || purchases.isEmpty()) { + violations.add(prefix + "cpm constraint cannot be verified"); + return; } - if (budget.max() != null && amount.compareTo(budget.max()) > 0) { - violations.add("result[" + index - + "] budget " + amount + " above constraint max " + budget.max()); + for (JsonNode purchase : purchases) { + JsonNode pricing = purchase.get("pricing"); + String model = pricing == null ? null : text(pricing, "pricing_model"); + String currency = pricing == null ? null : text(pricing, "currency"); + JsonNode price = pricing == null ? null : pricing.get("fixed_price"); + if (!("cpm".equals(model) || "vcpm".equals(model)) + || !constraint.currency().equals(currency) || price == null || !price.isNumber() + || price.decimalValue().signum() < 0 + || price.decimalValue().compareTo(constraint.max()) > 0) { + violations.add(prefix + "cpm constraint is not satisfied"); + return; + } } } - private static void verifyCpmCeiling(JsonNode terms, CpmConstraint cpm, - int index, List violations) { + private static void verifyImpressions(JsonNode terms, ImpressionsConstraint constraint, + String prefix, List violations) { JsonNode purchases = terms.get("purchases"); - if (purchases == null || !purchases.isArray()) return; + if (purchases == null || !purchases.isArray() || purchases.isEmpty()) { + violations.add(prefix + "impressions constraint cannot be verified"); + return; + } + BigDecimal total = BigDecimal.ZERO; + for (JsonNode purchase : purchases) { + JsonNode impressions = purchase.get("impressions"); + if (impressions == null || !impressions.isNumber()) { + violations.add(prefix + "impressions constraint cannot be verified"); + return; + } + total = total.add(impressions.decimalValue()); + } + if (total.compareTo(constraint.min()) < 0) { + violations.add(prefix + "impressions constraint is not satisfied"); + } + } - for (int p = 0; p < purchases.size(); p++) { - JsonNode pricing = purchases.get(p).get("pricing"); - if (pricing == null) continue; + private static void verifyFlight(JsonNode terms, FlightConstraint constraint, + String prefix, List violations) { + try { + if (constraint.startNoLaterThan() != null) { + String start = text(terms, "start_time"); + if (start == null || "asap".equals(start) + || OffsetDateTime.parse(start).isAfter(constraint.startNoLaterThan())) { + violations.add(prefix + "flight start constraint is not satisfied"); + } + } + if (constraint.endNoEarlierThan() != null) { + String end = text(terms, "end_time"); + if (end == null + || OffsetDateTime.parse(end).isBefore(constraint.endNoEarlierThan())) { + violations.add(prefix + "flight end constraint is not satisfied"); + } + } + } catch (DateTimeParseException e) { + violations.add(prefix + "flight constraint cannot be verified"); + } + } - JsonNode model = pricing.get("pricing_model"); - if (model == null) continue; - String modelStr = model.asText(); - if (!"cpm".equals(modelStr) && !"vcpm".equals(modelStr)) continue; + private static Set requestedConstraints(ProposalRefinement refinement) { + Set requested = new HashSet<>(); + RefinementConstraints c = refinement.constraints(); + if (c == null) return requested; + if (c.totalBudget() != null) requested.add("total_budget"); + if (c.cpm() != null) requested.add("cpm"); + if (c.impressions() != null) requested.add("impressions"); + if (c.flight() != null) requested.add("flight"); + return requested; + } - JsonNode currencyNode = pricing.get("currency"); - if (currencyNode != null && !cpm.currency().equals(currencyNode.asText())) continue; + private static List proposals(RefinementResult result) { + return switch (result) { + case RefinementResult.Revised revised -> revised.proposals(); + case RefinementResult.Partial partial -> partial.proposals(); + case RefinementResult.Finalized finalized -> finalized.proposal() == null + ? List.of() : List.of(finalized.proposal()); + case RefinementResult.Unable ignored -> List.of(); + }; + } - JsonNode fixedPrice = pricing.get("fixed_price"); - if (fixedPrice != null && fixedPrice.decimalValue().compareTo(cpm.max()) > 0) { - violations.add("result[" + index + "] purchase[" + p - + "] CPM " + fixedPrice.decimalValue() - + " exceeds constraint max " + cpm.max()); - } - } + private static List unsatisfiedConstraints(RefinementResult result) { + List value = switch (result) { + case RefinementResult.Partial partial -> partial.unsatisfiedConstraints(); + case RefinementResult.Unable unable -> unable.unsatisfiedConstraints(); + default -> null; + }; + return value == null ? List.of() : value; } - 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 Map unsatisfiedProductChanges(RefinementResult result) { + Map value = switch (result) { + case RefinementResult.Partial partial -> partial.unsatisfiedProductChanges(); + case RefinementResult.Unable unable -> unable.unsatisfiedProductChanges(); + default -> null; + }; + return value == null ? Map.of() : value; } - private static JsonNode extractProposal(RefinementResult result) { + private static ProposalRefinementReason reasonCode(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; + case RefinementResult.Partial partial -> partial.reasonCode(); + case RefinementResult.Unable unable -> unable.reasonCode(); + default -> null; }; } + + private static RefinementAction effectiveAction(ProposalRefinement refinement) { + return refinement.action() == null ? RefinementAction.REVISE : refinement.action(); + } + + private static void checkStatus(JsonNode proposal, String expected, + String prefix, List violations) { + if (!expected.equals(text(proposal, "proposal_status"))) { + violations.add(prefix + "proposal_status must be " + expected); + } + } + + private static String text(JsonNode node, String field) { + if (node == null) return null; + JsonNode value = node.get(field); + return value != null && value.isTextual() ? value.textValue() : 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 index 3fea3d6..6613a9e 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java @@ -1,19 +1,14 @@ 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 org.erdtman.jcs.JsonCanonicalizer; -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 @@ -69,140 +64,21 @@ public static boolean areDistinct(JsonNode termsA, JsonNode 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(). + * RFC 8785 JCS canonicalization delegated to the Java reference + * implementation. Keeping number serialization in the reference library + * avoids cross-language digest drift at IEEE-754 edge cases. */ static byte[] canonicalize(JsonNode node) { + if (node == null || !node.isObject()) { + throw new IllegalArgumentException("commercial_terms must be an object"); + } try { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - writeCanonical(node, out); - return out.toByteArray(); + return new JsonCanonicalizer(node.toString()).getEncodedUTF8(); } 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); diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TotalBudgetConstraint.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TotalBudgetConstraint.java index d714d6b..93d516f 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TotalBudgetConstraint.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TotalBudgetConstraint.java @@ -27,6 +27,9 @@ public record TotalBudgetConstraint( if (min == null && max == null) { throw new IllegalArgumentException("budget must specify at least one bound"); } + if ((min != null && min.signum() < 0) || (max != null && max.signum() < 0)) { + throw new IllegalArgumentException("budget bounds must be non-negative"); + } if (min != null && max != null && min.compareTo(max) > 0) { throw new IllegalArgumentException("budget min must not exceed max"); } diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/UnsupportedRefinementException.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/UnsupportedRefinementException.java new file mode 100644 index 0000000..706097e --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/UnsupportedRefinementException.java @@ -0,0 +1,18 @@ +package org.adcontextprotocol.adcp.negotiation; + +/** Preflight failure for a typed dimension omitted by seller capabilities. */ +public final class UnsupportedRefinementException extends IllegalArgumentException { + + private static final long serialVersionUID = 1L; + + private final transient UnsupportedRefinementDetails details; + + public UnsupportedRefinementException(UnsupportedRefinementDetails details) { + super("unsupported refinement dimension: " + details.unsupportedDimension()); + this.details = details; + } + + public UnsupportedRefinementDetails details() { + return details; + } +} diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java index 99b783c..c0e7949 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java @@ -46,7 +46,7 @@ void cpm_constraint_round_trips_via_jackson() throws Exception { @Test void impressions_constraint_requires_non_negative_min() { var ic = new ImpressionsConstraint(100_000); - assertEquals(100_000, ic.min()); + assertEquals(new BigDecimal("100000"), ic.min()); } @Test diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java index 0bc50c3..cfb9a90 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java @@ -1,7 +1,5 @@ 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; @@ -126,48 +124,36 @@ void cancellation_refinement() { @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) + .alternatives(11) .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) + .alternatives(1) .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) + .alternatives(5) .build(); - assertEquals(5, refinement.alternatives().get("count").asInt()); + assertEquals(5, refinement.alternatives().count()); } @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()) @@ -175,7 +161,7 @@ void rejects_alternatives_exceeding_seller_ceiling() { .addRefinement(ProposalRefinement.builder("p-1") .action(RefinementAction.REVISE) .ask("options") - .alternatives(alt) + .alternatives(5) .build()) .build()); } @@ -193,18 +179,15 @@ void revise_with_constraints() { @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) + .alternatives(3) .build(); assertEquals("p-1", refinement.proposalId()); @@ -215,7 +198,7 @@ void builder_creates_multi_field_refinement() { @Test void idempotency_replay_response() throws Exception { - var mapper = new ObjectMapper(); + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); String json = """ { "status": "completed", @@ -223,7 +206,7 @@ void idempotency_replay_response() throws Exception { "results": [{ "source_proposal_id": "src-1", "outcome": "revised", - "proposal": {"proposal_id": "p-new", "parent_proposal_id": "src-1", "proposal_status": "draft"} + "proposals": [{"proposal_id": "p-new", "parent_proposal_id": "src-1", "proposal_status": "draft"}] }] } """; @@ -232,4 +215,34 @@ void idempotency_replay_response() throws Exception { assertTrue(response.isCompleted()); assertEquals(Boolean.TRUE, response.replayed()); } + + @Test + void rejects_seller_batch_ceiling_above_protocol_max() { + assertThrows(IllegalArgumentException.class, () -> + RefineProposalsRequest.builder().maxBatchSize(26)); + } + + @Test + void accepts_protocol_maximum_of_25() { + var builder = RefineProposalsRequest.builder().idempotencyKey(validKey()); + for (int i = 0; i < 25; i++) { + builder.addRefinement(ProposalRefinement.revise("p-" + i, "change")); + } + assertEquals(25, builder.build().refinements().size()); + } + + @Test + void capability_rejects_undeclared_dimension_with_typed_details() { + var capability = new RefinementCapability(Set.of("total_budget"), null); + var exception = assertThrows(UnsupportedRefinementException.class, () -> + RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .capability(capability) + .addRefinement(ProposalRefinement.builder("p-1") + .productChanges(java.util.Map.of("prod-1", "include")) + .build()) + .build()); + assertEquals("product_changes", exception.details().unsupportedDimension()); + assertEquals(List.of("total_budget"), exception.details().supportedDimensions()); + } } diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java index 1cc47a7..891ea97 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -15,129 +16,59 @@ class RefinementResultTest { @Test void sealed_interface_permits_four_outcomes() { assertTrue(RefinementResult.class.isSealed()); - - Class[] permitted = RefinementResult.class.getPermittedSubclasses(); - assertNotNull(permitted); - assertEquals(4, permitted.length); + assertEquals(4, RefinementResult.class.getPermittedSubclasses().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"); - + void revised_round_trips_plural_proposals() throws Exception { String json = """ - { - "source_proposal_id": "p-1", - "outcome": "revised", - "proposal": {"proposal_id": "p-new", "proposal_status": "draft"} - } + {"source_proposal_id":"p-1","outcome":"revised","proposals":[ + {"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()); + var revised = assertInstanceOf(RefinementResult.Revised.class, result); + assertEquals("p-new", revised.proposals().getFirst().get("proposal_id").asText()); assertEquals(RefinementOutcome.REVISED, revised.outcome()); - assertEquals("p-new", revised.proposal().get("proposal_id").asText()); } @Test - void partial_round_trips_with_unsatisfied_constraints() throws Exception { + void partial_round_trips_machine_readable_failures() 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"] - } + {"source_proposal_id":"p-2","outcome":"partial", + "proposals":[{"proposal_id":"p-new","proposal_status":"draft"}], + "reason_code":"constraint_unsatisfiable","reason":"CPM too high", + "unsatisfied_constraints":["cpm"], + "unsatisfied_product_changes":{"prod-2":"include"}} """; - - 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()); + var partial = assertInstanceOf(RefinementResult.Partial.class, + mapper.readValue(json, RefinementResult.class)); + assertEquals(ProposalRefinementReason.CONSTRAINT_UNSATISFIABLE, partial.reasonCode()); assertEquals(List.of("cpm"), partial.unsatisfiedConstraints()); + assertEquals(Map.of("prod-2", "include"), partial.unsatisfiedProductChanges()); } @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()); + void all_reason_codes_round_trip() throws Exception { + for (ProposalRefinementReason reason : ProposalRefinementReason.values()) { + String json = """ + {"source_proposal_id":"p","outcome":"unable", + "reason_code":"%s","reason":"bounded reason"%s} + """.formatted(reason.toWire(), + reason == ProposalRefinementReason.CONSTRAINT_UNSATISFIABLE + ? ",\"unsatisfied_constraints\":[\"cpm\"]" : ""); + var unable = assertInstanceOf(RefinementResult.Unable.class, + mapper.readValue(json, RefinementResult.class)); + assertEquals(reason, unable.reasonCode()); + } } @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); - + ObjectNode proposal = mapper.createObjectNode().put("proposal_id", "p-rt"); + RefinementResult original = new RefinementResult.Revised( + "src-1", List.of(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); + assertEquals("src-1", mapper.readValue(json, RefinementResult.class).sourceProposalId()); } } diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java index 672e417..5b1cff4 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java @@ -4,7 +4,10 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; @@ -13,362 +16,154 @@ 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"))); + void valid_revised_response_passes() { + var request = request(ProposalRefinement.revise("src-1", "lower price")); + var response = response(new RefinementResult.Revised( + "src-1", List.of(proposal("new-1", "src-1", 10)), null)); + assertEquals(List.of(), ResponseVerifier.verify(request, response)); } @Test - void detects_ordering_mismatch() throws Exception { - var request = RefineProposalsRequest.builder() - .idempotencyKey(key()) + void verifies_order_count_fresh_identity_lineage_and_digest() { + 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); + ObjectNode bad = proposal("src-2", "wrong-parent", 10); + bad.put("terms_digest", "sha256:bad"); + var response = response(new RefinementResult.Revised( + "src-2", List.of(bad), null)); List violations = ResponseVerifier.verify(request, response); - - assertTrue(violations.stream().anyMatch(v -> v.contains("does not match request"))); + assertContains(violations, "result count"); + assertContains(violations, "request order"); + assertContains(violations, "fresh proposal_id"); + assertContains(violations, "parent_proposal_id"); + assertContains(violations, "terms_digest"); } @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); + void alternatives_require_exact_count_and_distinct_terms() { + var request = request(ProposalRefinement.builder("src-1").alternatives(2).build()); + var response = response(new RefinementResult.Revised("src-1", List.of( + proposal("new-1", "src-1", 10), + proposal("new-2", "src-1", 10)), null)); + assertContains(ResponseVerifier.verify(request, response), "duplicates commercial_terms"); + + var shortResponse = response(new RefinementResult.Revised( + "src-1", List.of(proposal("new-3", "src-1", 11)), null)); + assertContains(ResponseVerifier.verify(request, shortResponse), "expected 2"); + } - assertTrue(violations.stream().anyMatch(v -> v.contains("finalize batches"))); + @Test + void typed_constraints_are_fail_closed_for_every_purchase() { + var constraints = new RefinementConstraints( + new TotalBudgetConstraint(new BigDecimal("5000"), new BigDecimal("10000"), "USD"), + new CpmConstraint(new BigDecimal("12"), "USD"), + new ImpressionsConstraint(new BigDecimal("100000")), null); + var request = request(ProposalRefinement.reviseWithConstraints("src-1", constraints)); + ObjectNode p = proposal("new-1", "src-1", 10); + ObjectNode terms = (ObjectNode) p.get("commercial_terms"); + terms.set("total_budget", mapper.createObjectNode() + .put("amount", 7500).put("currency", "USD")); + // Missing impressions and pricing must fail closed, not be skipped. + terms.putArray("purchases").addObject().put("product_id", "prod-1"); + p.put("terms_digest", TermsDigest.compute(terms)); + List violations = ResponseVerifier.verify(request, + response(new RefinementResult.Revised("src-1", List.of(p), null))); + assertContains(violations, "cpm constraint"); + assertContains(violations, "impressions constraint cannot be verified"); } @Test - void detects_missing_lineage() throws Exception { - var request = RefineProposalsRequest.builder() - .idempotencyKey(key()) - .addRefinement(ProposalRefinement.revise("src-1", "test")) + void partial_enforces_failure_subsets_precedence_and_unlisted_constraints() { + var constraints = new RefinementConstraints( + new TotalBudgetConstraint(null, new BigDecimal("100"), "USD"), null, null, null); + var refinement = ProposalRefinement.builder("src-1") + .constraints(constraints) + .productChanges(Map.of("prod-1", "include")) .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"))); + var request = request(refinement); + var partial = new RefinementResult.Partial( + "src-1", List.of(proposal("new-1", "src-1", 10)), + ProposalRefinementReason.COMMERCIALLY_DECLINED, "no", + List.of("made_up"), Map.of("prod-2", "omit"), null, null); + List violations = ResponseVerifier.verify(request, response(partial)); + assertContains(violations, "unrequested constraint"); + assertContains(violations, "unrequested product change"); + assertContains(violations, "constraint_unsatisfiable precedence"); + assertContains(violations, "total_budget constraint cannot be verified"); + assertContains(violations, "product changes cannot be verified"); } @Test - void detects_wrong_proposal_status_for_finalized() throws Exception { - var request = RefineProposalsRequest.builder() - .idempotencyKey(key()) + void finalize_is_atomic_committed_and_expiry_aware() { + var request = RefineProposalsRequest.builder().idempotencyKey(key()) .addRefinement(ProposalRefinement.finalize("src-1")) + .addRefinement(ProposalRefinement.finalize("src-2")) .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"))); + ObjectNode committed = proposal("new-1", "src-1", 10); + committed.put("proposal_status", "committed"); + committed.put("expires_at", "2026-01-01T00:00:00Z"); + var response = response( + new RefinementResult.Finalized("src-1", committed), + new RefinementResult.Unable("src-2", + ProposalRefinementReason.HOLD_UNAVAILABLE, "sold", null, null, null)); + List violations = ResponseVerifier.verify( + request, response, Instant.parse("2026-08-30T00:00:00Z")); + assertContains(violations, "hold is expired"); + assertContains(violations, "holds are not atomic"); } @Test - void skips_verification_for_async_responses() throws Exception { - var request = RefineProposalsRequest.builder() - .idempotencyKey(key()) - .addRefinement(ProposalRefinement.revise("src-1", "test")) + void fully_rolled_back_finalize_failure_is_valid() { + var request = RefineProposalsRequest.builder().idempotencyKey(key()) + .addRefinement(ProposalRefinement.finalize("src-1")) + .addRefinement(ProposalRefinement.finalize("src-2")) .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()); + var response = response( + new RefinementResult.Unable("src-1", + ProposalRefinementReason.HOLD_UNAVAILABLE, "sold", null, null, null), + new RefinementResult.Unable("src-2", + ProposalRefinementReason.BATCH_ABORTED, "sibling failed", null, null, null)); + assertEquals(List.of(), ResponseVerifier.verify(request, response)); } @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")); + void submitted_response_has_no_results() { + var request = request(ProposalRefinement.revise("src-1", "test")); + var submitted = new RefineProposalsResponse(null, null, "submitted", + "task-1", null, null, null, null, null, null); + assertEquals(List.of(), ResponseVerifier.verify(request, submitted)); } - @Test - void detects_budget_constraint_violation() throws Exception { - var budgetConstraint = new TotalBudgetConstraint( - new java.math.BigDecimal("5000"), new java.math.BigDecimal("10000"), "USD"); - var constraints = new RefinementConstraints(budgetConstraint, null, null, null); - - var request = RefineProposalsRequest.builder() - .idempotencyKey(key()) - .addRefinement(ProposalRefinement.reviseWithConstraints("src-1", constraints)) - .build(); - - ObjectNode proposal = draftProposal("new-1", "src-1"); - ObjectNode totalBudget = mapper.createObjectNode(); - totalBudget.put("amount", 15000); - totalBudget.put("currency", "USD"); - ObjectNode terms = mapper.createObjectNode(); - terms.set("total_budget", totalBudget); - proposal.set("commercial_terms", terms); - - 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("above constraint max"))); + private RefineProposalsRequest request(ProposalRefinement refinement) { + return RefineProposalsRequest.builder().idempotencyKey(key()) + .addRefinement(refinement).build(); } - @Test - void budget_within_bounds_passes() throws Exception { - var budgetConstraint = new TotalBudgetConstraint( - new java.math.BigDecimal("5000"), new java.math.BigDecimal("10000"), "USD"); - var constraints = new RefinementConstraints(budgetConstraint, null, null, null); - - var request = RefineProposalsRequest.builder() - .idempotencyKey(key()) - .addRefinement(ProposalRefinement.reviseWithConstraints("src-1", constraints)) - .build(); - - ObjectNode proposal = draftProposal("new-1", "src-1"); - ObjectNode totalBudget = mapper.createObjectNode(); - totalBudget.put("amount", 7500); - totalBudget.put("currency", "USD"); - ObjectNode terms = mapper.createObjectNode(); - terms.set("total_budget", totalBudget); - proposal.set("commercial_terms", terms); - - 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 = new java.util.ArrayList<>(); - ResponseVerifier.verifyConstraintSatisfaction(request, response, violations); - - assertTrue(violations.isEmpty(), "Expected no constraint violations but got: " + violations); + private RefineProposalsResponse response(RefinementResult... results) { + return new RefineProposalsResponse(List.of(results), List.of(), "completed", + null, null, null, null, null, null, null); } - @Test - void detects_cpm_ceiling_violation() throws Exception { - var cpmConstraint = new CpmConstraint(new java.math.BigDecimal("10.00"), "USD"); - var constraints = new RefinementConstraints(null, cpmConstraint, null, null); - - var request = RefineProposalsRequest.builder() - .idempotencyKey(key()) - .addRefinement(ProposalRefinement.reviseWithConstraints("src-1", constraints)) - .build(); - - ObjectNode proposal = draftProposal("new-1", "src-1"); - ObjectNode pricing = mapper.createObjectNode(); - pricing.put("pricing_model", "cpm"); - pricing.put("fixed_price", 15.00); - pricing.put("currency", "USD"); - ObjectNode purchase = mapper.createObjectNode(); - purchase.set("pricing", pricing); - ObjectNode terms = mapper.createObjectNode(); - terms.set("purchases", mapper.createArrayNode().add(purchase)); + private ObjectNode proposal(String id, String parent, int price) { + ObjectNode terms = mapper.createObjectNode().put("price", price); + ObjectNode proposal = mapper.createObjectNode() + .put("proposal_id", id) + .put("parent_proposal_id", parent) + .put("proposal_status", "draft"); proposal.set("commercial_terms", terms); - - 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 = new java.util.ArrayList<>(); - ResponseVerifier.verifyConstraintSatisfaction(request, response, violations); - - assertTrue(violations.stream().anyMatch(v -> v.contains("CPM") && v.contains("exceeds"))); + proposal.put("terms_digest", TermsDigest.compute(terms)); + return proposal; } - // -- 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 static String key() { + return "idem-" + UUID.randomUUID().toString().replace("-", ""); } - 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; + private static void assertContains(List violations, String expected) { + assertTrue(violations.stream().anyMatch(v -> v.contains(expected)), + () -> "Expected '" + expected + "' in " + violations); } } diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java index bf21503..4784105 100644 --- a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java @@ -13,172 +13,41 @@ 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)); + void digest_is_base64url_deterministic_and_key_order_independent() { + ObjectNode a = mapper.createObjectNode().put("currency", "USD").put("amount", 50000); + ObjectNode b = mapper.createObjectNode().put("amount", 50000).put("currency", "USD"); + String digest = TermsDigest.compute(a); + assertEquals(digest, TermsDigest.compute(b)); + assertTrue(digest.matches("sha256:[A-Za-z0-9_-]{43}")); + assertTrue(TermsDigest.verify(digest, b)); } @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)); + void digest_rejects_tampering_and_bad_prefix() { + ObjectNode original = mapper.createObjectNode().put("price", 10.5); + ObjectNode changed = mapper.createObjectNode().put("price", 15.0); + assertFalse(TermsDigest.verify(TermsDigest.compute(original), changed)); + assertFalse(TermsDigest.verify("md5:abc", original)); + assertFalse(TermsDigest.verify(null, original)); } @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)); + void reference_jcs_handles_utf16_key_order_escaping_and_ieee754_numbers() throws Exception { + var input = mapper.readTree(""" + {"z":"hello\\nworld","a":{"small":1e-7,"large":1.5e21,"whole":1.5e10}} + """); + String canonical = new String(TermsDigest.canonicalize(input), StandardCharsets.UTF_8); + assertEquals("{\"a\":{\"large\":1.5e+21,\"small\":1e-7,\"whole\":15000000000}," + + "\"z\":\"hello\\nworld\"}", canonical); } @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")); + void distinctness_compares_canonical_commercial_terms() { + assertTrue(TermsDigest.areDistinct( + mapper.createObjectNode().put("price", 10), + mapper.createObjectNode().put("price", 20))); + assertFalse(TermsDigest.areDistinct( + mapper.createObjectNode().put("price", 10), + mapper.createObjectNode().put("price", 10))); } } diff --git a/docs/proposal-negotiation.md b/docs/proposal-negotiation.md new file mode 100644 index 0000000..7f5efbb --- /dev/null +++ b/docs/proposal-negotiation.md @@ -0,0 +1,44 @@ +# Proposal negotiation + +The SDK models `refine_proposals` as task-level errors plus sealed per-proposal +outcomes. Always discover `proposal_refinement.supported_dimensions` first, pass +the resulting `RefinementCapability` to `RefineProposalsRequest.Builder`, and run +`ResponseVerifier.verify` before selecting or finalizing a returned draft. + +Run the public constrained-seller scenario with: + +```shell +export ADCP_AUTH_TOKEN="..." +./gradlew :adcp-cli:run --args='proposal-negotiation' +``` + +The example requests three alternatives, verifies the deterministic two-draft +`partial` counteroffer, then changes the count to two with a new idempotency key. +Exact transport retries must reuse the original key and byte-equivalent request. + +## Seller registration and atomic finalize + +Register a `ProposalHandler` with `AdcpServerBuilder.proposalHandler`. The server +validates cardinality, batch shape, typed dimensions, application preflight, and +the returned lineage/digests before responding. Revise calls go to `refine`. +Homogeneous finalize batches go only to `finalizeAtomically`; its default fails +closed. Invoke the supplied operation inside the transaction and commit only +after it returns, so SDK response validation happens before persistence. + +Use `ProposalSuccessor.stamp` for draft successors and +`ProposalSuccessor.stampFinalized` for committed holds. Both assign a fresh ID, +preserve parent lineage, require commercial terms, and recompute the RFC 8785 +digest. + +## Legacy compatibility limits + +Legacy `get_products` refinement and compact `refine_proposals` are not generally +interchangeable. A discovery `budget_range` filters candidate products, while +`constraints.total_budget` is a hard post-generation assertion over complete +commercial terms. Never map one to the other, move a typed constraint into +`ask`, weaken inclusive bounds, or convert currency. + +Likewise, compact finalize may be adapted only when the legacy operation can +guarantee byte-equivalent complete commercial terms and atomic holds. If any +requested field has no lossless mapping, reject the entire operation before +dispatch; do not silently drop it or repair terms after mutation. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 53430e2..84f9586 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ jackson = "2.20.1" slf4j = "2.0.17" jspecify = "1.0.0" gson = "2.14.0" +jcs = "1.1" # Schema validation (RFC §Schema validation). json-schema-validator = "1.5.6" @@ -50,6 +51,7 @@ jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-dat slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +jcs = { module = "io.github.erdtman:java-json-canonicalization", version.ref = "jcs" } # Validation json-schema-validator = { module = "com.networknt:json-schema-validator", version.ref = "json-schema-validator" } From e4a4dc408278f03e2db0455c6a0e1e7fa0de0f10 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 30 Aug 2026 09:38:45 +0200 Subject: [PATCH 5/6] fix(ci): register Gradle artifacts with Changesets --- .changeset/config.json | 6 ++- adcp-cli/package.json | 5 ++ adcp-kotlin/package.json | 5 ++ adcp-mutiny/package.json | 5 ++ adcp-reactor/package.json | 5 ++ adcp-server/package.json | 5 ++ adcp-spring-boot-starter/package.json | 5 ++ adcp-testing/package.json | 5 ++ adcp/package.json | 5 ++ package-lock.json | 66 +++++++++++++++++++++++++++ package.json | 10 ++++ 11 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 adcp-cli/package.json create mode 100644 adcp-kotlin/package.json create mode 100644 adcp-mutiny/package.json create mode 100644 adcp-reactor/package.json create mode 100644 adcp-server/package.json create mode 100644 adcp-spring-boot-starter/package.json create mode 100644 adcp-testing/package.json create mode 100644 adcp/package.json diff --git a/.changeset/config.json b/.changeset/config.json index 3c5b58a..08e0b10 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,9 @@ "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["adcp-sdk-java-tools"] + "ignore": ["adcp-sdk-java-tools"], + "privatePackages": { + "version": true, + "tag": false + } } diff --git a/adcp-cli/package.json b/adcp-cli/package.json new file mode 100644 index 0000000..4c71cf1 --- /dev/null +++ b/adcp-cli/package.json @@ -0,0 +1,5 @@ +{ + "name": "adcp-cli", + "version": "0.1.0", + "private": true +} diff --git a/adcp-kotlin/package.json b/adcp-kotlin/package.json new file mode 100644 index 0000000..a3a67fa --- /dev/null +++ b/adcp-kotlin/package.json @@ -0,0 +1,5 @@ +{ + "name": "adcp-kotlin", + "version": "0.1.0", + "private": true +} diff --git a/adcp-mutiny/package.json b/adcp-mutiny/package.json new file mode 100644 index 0000000..72ee5a5 --- /dev/null +++ b/adcp-mutiny/package.json @@ -0,0 +1,5 @@ +{ + "name": "adcp-mutiny", + "version": "0.1.0", + "private": true +} diff --git a/adcp-reactor/package.json b/adcp-reactor/package.json new file mode 100644 index 0000000..ba1d6bd --- /dev/null +++ b/adcp-reactor/package.json @@ -0,0 +1,5 @@ +{ + "name": "adcp-reactor", + "version": "0.1.0", + "private": true +} diff --git a/adcp-server/package.json b/adcp-server/package.json new file mode 100644 index 0000000..f2f83c4 --- /dev/null +++ b/adcp-server/package.json @@ -0,0 +1,5 @@ +{ + "name": "adcp-server", + "version": "0.1.0", + "private": true +} diff --git a/adcp-spring-boot-starter/package.json b/adcp-spring-boot-starter/package.json new file mode 100644 index 0000000..f3d3c62 --- /dev/null +++ b/adcp-spring-boot-starter/package.json @@ -0,0 +1,5 @@ +{ + "name": "adcp-spring-boot-starter", + "version": "0.1.0", + "private": true +} diff --git a/adcp-testing/package.json b/adcp-testing/package.json new file mode 100644 index 0000000..9462483 --- /dev/null +++ b/adcp-testing/package.json @@ -0,0 +1,5 @@ +{ + "name": "adcp-testing", + "version": "0.1.0", + "private": true +} diff --git a/adcp/package.json b/adcp/package.json new file mode 100644 index 0000000..be3bb58 --- /dev/null +++ b/adcp/package.json @@ -0,0 +1,5 @@ +{ + "name": "adcp", + "version": "0.1.0", + "private": true +} diff --git a/package-lock.json b/package-lock.json index 12899b3..72a347f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,16 @@ "packages": { "": { "name": "adcp-sdk-java-tools", + "workspaces": [ + "adcp", + "adcp-server", + "adcp-testing", + "adcp-spring-boot-starter", + "adcp-cli", + "adcp-reactor", + "adcp-mutiny", + "adcp-kotlin" + ], "devDependencies": { "@adcp/sdk": "7.2.0", "@changesets/cli": "^2.29.7", @@ -13,6 +23,30 @@ "conventional-changelog-conventionalcommits": "^8.0.0" } }, + "adcp": { + "version": "0.1.0" + }, + "adcp-cli": { + "version": "0.1.0" + }, + "adcp-kotlin": { + "version": "0.1.0" + }, + "adcp-mutiny": { + "version": "0.1.0" + }, + "adcp-reactor": { + "version": "0.1.0" + }, + "adcp-server": { + "version": "0.1.0" + }, + "adcp-spring-boot-starter": { + "version": "0.1.0" + }, + "adcp-testing": { + "version": "0.1.0" + }, "node_modules/@a2a-js/sdk": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@a2a-js/sdk/-/sdk-0.3.13.tgz", @@ -934,6 +968,38 @@ "node": ">= 0.6" } }, + "node_modules/adcp": { + "resolved": "adcp", + "link": true + }, + "node_modules/adcp-cli": { + "resolved": "adcp-cli", + "link": true + }, + "node_modules/adcp-kotlin": { + "resolved": "adcp-kotlin", + "link": true + }, + "node_modules/adcp-mutiny": { + "resolved": "adcp-mutiny", + "link": true + }, + "node_modules/adcp-reactor": { + "resolved": "adcp-reactor", + "link": true + }, + "node_modules/adcp-server": { + "resolved": "adcp-server", + "link": true + }, + "node_modules/adcp-spring-boot-starter": { + "resolved": "adcp-spring-boot-starter", + "link": true + }, + "node_modules/adcp-testing": { + "resolved": "adcp-testing", + "link": true + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", diff --git a/package.json b/package.json index ff327be..0a7bb04 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,16 @@ "private": true, "name": "adcp-sdk-java-tools", "description": "Node-side tooling for the AdCP Java SDK repo — commitlint and Changesets. Not published; not the SDK.", + "workspaces": [ + "adcp", + "adcp-server", + "adcp-testing", + "adcp-spring-boot-starter", + "adcp-cli", + "adcp-reactor", + "adcp-mutiny", + "adcp-kotlin" + ], "scripts": { "changeset": "changeset", "commitlint": "commitlint" From 18c52c3628596f19f981e1d0c9990031e1fc5e2d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 30 Aug 2026 09:40:07 +0200 Subject: [PATCH 6/6] fix(ci): drop obsolete Changesets ignore --- .changeset/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/config.json b/.changeset/config.json index 08e0b10..530fae3 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,7 +7,7 @@ "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["adcp-sdk-java-tools"], + "ignore": [], "privatePackages": { "version": true, "tag": false