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..02e52df 100644 --- a/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java @@ -4,6 +4,8 @@ import org.adcontextprotocol.adcp.error.ConfigurationError; import org.adcontextprotocol.adcp.http.AdcpHttpClient; import org.adcontextprotocol.adcp.http.SsrfPolicy; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest; +import org.adcontextprotocol.adcp.negotiation.RefineProposalsResponse; import org.adcontextprotocol.adcp.schema.AdcpObjectMapperFactory; import org.adcontextprotocol.adcp.transport.CallToolOptions; import org.adcontextprotocol.adcp.transport.ProtocolClient; @@ -129,6 +131,20 @@ public T callNamedTool(String toolName, Object request, return callTool(toolName, toArgs(request), responseType); } + // -- Proposal negotiation (3.2) -- + + /** + * Refines one or more proposals: creates draft revisions or finalizes + * drafts into held committed snapshots. + * + * @param request the refinement request + * @return the refinement response (synchronous or async) + */ + public RefineProposalsResponse refineProposals(RefineProposalsRequest request) { + return callNamedTool("refine_proposals", request, + RefineProposalsResponse.class); + } + // -- Lifecycle -- /** Returns the agent config this client is bound to. */ diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ChangeKind.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ChangeKind.java new file mode 100644 index 0000000..fb6f088 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ChangeKind.java @@ -0,0 +1,31 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * The kind of successor proposal to create when refining an accepted proposal. + */ +public enum ChangeKind { + + AMENDMENT("amendment"), + CANCELLATION("cancellation"); + + private final String wire; + + ChangeKind(String wire) { + this.wire = wire; + } + + @JsonValue + public String toWire() { + return wire; + } + + public static ChangeKind fromWire(String value) { + return switch (value) { + case "amendment" -> AMENDMENT; + case "cancellation" -> CANCELLATION; + default -> throw new IllegalArgumentException("Unknown change kind: " + value); + }; + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/CpmConstraint.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/CpmConstraint.java new file mode 100644 index 0000000..5d5acaa --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/CpmConstraint.java @@ -0,0 +1,24 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.math.BigDecimal; + +/** + * CPM ceiling constraint: every purchase must be priced at fixed CPM/vCPM + * in the given currency at or under max. + * + * @param max maximum CPM value + * @param currency ISO 4217 currency code + */ +public record CpmConstraint( + @JsonProperty("max") BigDecimal max, + @JsonProperty("currency") String currency) { + + public CpmConstraint { + if (max == null) throw new IllegalArgumentException("cpm max is required"); + if (currency == null || currency.isBlank()) { + throw new IllegalArgumentException("cpm currency is required"); + } + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/FlightConstraint.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/FlightConstraint.java new file mode 100644 index 0000000..7f12649 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/FlightConstraint.java @@ -0,0 +1,26 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jspecify.annotations.Nullable; + +import java.time.OffsetDateTime; + +/** + * Flight timing constraint, checked against the envelope's + * {@code start_time}/{@code end_time}. An "asap" start never + * satisfies a {@code startNoLaterThan} bound. + * + * @param startNoLaterThan campaign must start on or before this time + * @param endNoEarlierThan campaign must end on or after this time + */ +public record FlightConstraint( + @Nullable @JsonProperty("start_no_later_than") OffsetDateTime startNoLaterThan, + @Nullable @JsonProperty("end_no_earlier_than") OffsetDateTime endNoEarlierThan) { + + public FlightConstraint { + if (startNoLaterThan == null && endNoEarlierThan == null) { + throw new IllegalArgumentException( + "flight constraint must specify at least one bound"); + } + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ImpressionsConstraint.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ImpressionsConstraint.java new file mode 100644 index 0000000..3d24ade --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ImpressionsConstraint.java @@ -0,0 +1,16 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Minimum summed impressions constraint across all purchases. + * + * @param min minimum total impressions required + */ +public record ImpressionsConstraint( + @JsonProperty("min") long min) { + + public ImpressionsConstraint { + if (min < 0) throw new IllegalArgumentException("impressions min must be non-negative"); + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java new file mode 100644 index 0000000..5d313b3 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java @@ -0,0 +1,67 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; + +/** + * A single refinement operation within a {@link RefineProposalsRequest}. + * + *

Revising with structured criteria and/or semantic instructions creates + * a new draft; finalizing changes no terms. Refining an accepted proposal + * creates a draft amendment or cancellation proposal. + * + * @param proposalId the source proposal to refine + * @param action revise or finalize + * @param changeKind amendment (default) or cancellation; only valid for accepted sources + * @param instructions semantic commercial changes or cancellation reason + * @param criteria structured changes; each present field replaces that criterion + */ +public record ProposalRefinement( + @JsonProperty("proposal_id") String proposalId, + @Nullable @JsonProperty("action") RefinementAction action, + @Nullable @JsonProperty("change_kind") ChangeKind changeKind, + @Nullable @JsonProperty("instructions") String instructions, + @Nullable @JsonProperty("criteria") JsonNode criteria) { + + public ProposalRefinement { + Objects.requireNonNull(proposalId, "proposal_id is required"); + if (proposalId.isBlank()) { + throw new IllegalArgumentException("proposal_id must not be blank"); + } + } + + /** + * Creates a revise refinement with instructions. + */ + public static ProposalRefinement revise(String proposalId, String instructions) { + return new ProposalRefinement(proposalId, RefinementAction.REVISE, + null, instructions, null); + } + + /** + * Creates a revise refinement with structured criteria. + */ + public static ProposalRefinement reviseWithCriteria(String proposalId, JsonNode criteria) { + return new ProposalRefinement(proposalId, RefinementAction.REVISE, + null, null, criteria); + } + + /** + * Creates a finalize refinement (no term changes, reserves inventory). + */ + public static ProposalRefinement finalize(String proposalId) { + return new ProposalRefinement(proposalId, RefinementAction.FINALIZE, + null, null, null); + } + + /** + * Creates a cancellation refinement against an accepted proposal. + */ + public static ProposalRefinement cancel(String proposalId, String reason) { + return new ProposalRefinement(proposalId, RefinementAction.REVISE, + ChangeKind.CANCELLATION, reason, null); + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java new file mode 100644 index 0000000..4ab0e49 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java @@ -0,0 +1,143 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +/** + * Request payload for the {@code refine_proposals} tool. + * + *

Builds a batch of refinement operations (revise or finalize) with + * capability-aware validation: the builder enforces supported dimensions, + * seller ceilings, and protocol cardinality before transport. + * + * @param idempotencyKey client-generated key for retry safety (16-255 chars, alphanumeric + _.-) + * @param refinements ordered refinement operations, one per source proposal + * @param contextId optional context ID for the refinement session + * @param context optional context object + * @param governanceContext optional governance/compliance context + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RefineProposalsRequest( + @JsonProperty("idempotency_key") String idempotencyKey, + @JsonProperty("refinements") List refinements, + @Nullable @JsonProperty("context_id") String contextId, + @Nullable @JsonProperty("context") JsonNode context, + @Nullable @JsonProperty("governance_context") String governanceContext) { + + private static final Pattern IDEMPOTENCY_KEY_PATTERN = + Pattern.compile("^[A-Za-z0-9_.:-]{16,255}$"); + + public RefineProposalsRequest { + Objects.requireNonNull(idempotencyKey, "idempotency_key is required"); + if (!IDEMPOTENCY_KEY_PATTERN.matcher(idempotencyKey).matches()) { + throw new IllegalArgumentException( + "idempotency_key must match [A-Za-z0-9_.:-]{16,255}"); + } + Objects.requireNonNull(refinements, "refinements is required"); + if (refinements.isEmpty()) { + throw new IllegalArgumentException("refinements must not be empty"); + } + refinements = List.copyOf(refinements); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private @Nullable String idempotencyKey; + private final List refinements = new ArrayList<>(); + private @Nullable String contextId; + private @Nullable JsonNode context; + private @Nullable String governanceContext; + private int maxBatchSize = 25; + + private Builder() {} + + public Builder idempotencyKey(String idempotencyKey) { + this.idempotencyKey = Objects.requireNonNull(idempotencyKey); + return this; + } + + public Builder addRefinement(ProposalRefinement refinement) { + this.refinements.add(Objects.requireNonNull(refinement)); + return this; + } + + public Builder refinements(List refinements) { + this.refinements.clear(); + this.refinements.addAll(refinements); + return this; + } + + public Builder contextId(String contextId) { + this.contextId = contextId; + return this; + } + + public Builder context(JsonNode context) { + this.context = context; + return this; + } + + public Builder governanceContext(String governanceContext) { + this.governanceContext = governanceContext; + return this; + } + + /** + * Sets the maximum batch size (default 25 per protocol spec). + * The seller may advertise a lower ceiling. + */ + public Builder maxBatchSize(int maxBatchSize) { + if (maxBatchSize < 1) { + throw new IllegalArgumentException("maxBatchSize must be positive"); + } + this.maxBatchSize = maxBatchSize; + return this; + } + + public RefineProposalsRequest build() { + validateBatch(); + return new RefineProposalsRequest( + idempotencyKey, refinements, contextId, + context, governanceContext); + } + + private void validateBatch() { + if (refinements.size() > maxBatchSize) { + throw new IllegalArgumentException( + "batch size " + refinements.size() + + " exceeds maximum " + maxBatchSize); + } + + // Enforce unique proposal IDs + Set ids = new HashSet<>(); + for (ProposalRefinement r : refinements) { + if (!ids.add(r.proposalId())) { + throw new IllegalArgumentException( + "duplicate proposal_id in batch: " + r.proposalId()); + } + } + + // Finalize batches must be homogeneous + boolean hasFinalize = refinements.stream() + .anyMatch(r -> r.action() == RefinementAction.FINALIZE); + boolean hasRevise = refinements.stream() + .anyMatch(r -> r.action() == RefinementAction.REVISE); + if (hasFinalize && hasRevise) { + throw new IllegalArgumentException( + "a batch containing finalize must contain only finalize entries"); + } + } + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsResponse.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsResponse.java new file mode 100644 index 0000000..dedca41 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsResponse.java @@ -0,0 +1,46 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Response from the {@code refine_proposals} tool. + * + *

Synchronous completions carry {@code results} and {@code products}. + * Asynchronous responses carry a {@code taskId} for polling. + * + * @param results ordered results, one per requested refinement + * @param products compact canonical products referenced by the results + * @param status "completed" or "submitted" (for async) + * @param taskId non-null when status is "submitted" + * @param message optional human-readable status message + * @param errors optional error array from the response + * @param replayed true when this is a replayed idempotent response + */ +public record RefineProposalsResponse( + @Nullable @JsonProperty("results") List results, + @Nullable @JsonProperty("products") List products, + @Nullable @JsonProperty("status") String status, + @Nullable @JsonProperty("task_id") String taskId, + @Nullable @JsonProperty("message") String message, + @Nullable @JsonProperty("errors") List errors, + @Nullable @JsonProperty("adcp_version") String adcpVersion, + @Nullable @JsonProperty("replayed") Boolean replayed) { + + /** + * Whether this is a synchronous completed response. + */ + public boolean isCompleted() { + return "completed".equals(status) || (results != null && taskId == null); + } + + /** + * Whether this response was deferred for async processing. + */ + public boolean isAsync() { + return taskId != null; + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementAction.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementAction.java new file mode 100644 index 0000000..4ce3155 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementAction.java @@ -0,0 +1,35 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Actions that can be taken on a proposal during refinement. + * + *

{@code REVISE} creates a new draft snapshot with changed commercial terms. + * {@code FINALIZE} targets a draft and creates a committed snapshot without + * changing terms, reserving inventory until expires_at. + */ +public enum RefinementAction { + + REVISE("revise"), + FINALIZE("finalize"); + + private final String wire; + + RefinementAction(String wire) { + this.wire = wire; + } + + @JsonValue + public String toWire() { + return wire; + } + + public static RefinementAction fromWire(String value) { + return switch (value) { + case "revise" -> REVISE; + case "finalize" -> FINALIZE; + default -> throw new IllegalArgumentException("Unknown refinement action: " + value); + }; + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java new file mode 100644 index 0000000..d38e59a --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java @@ -0,0 +1,35 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jspecify.annotations.Nullable; + +import java.util.Set; + +/** + * Declares a seller's refinement capabilities, advertised in the + * agent's capability manifest. + * + *

The capability value dimension is {@code product_changes} + * (renamed from draft-era {@code product_selection}). + * + * @param supportedDimensions the refinement dimensions this seller supports + * @param maxBatchSize maximum entries per refinement request (default: 25) + * @param supportsFinalize whether this seller supports the finalize action + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RefinementCapability( + @Nullable @JsonProperty("supported_dimensions") Set supportedDimensions, + @Nullable @JsonProperty("max_batch_size") Integer maxBatchSize, + @Nullable @JsonProperty("supports_finalize") Boolean supportsFinalize) { + + /** Protocol default batch size when not declared by the seller. */ + public static final int DEFAULT_MAX_BATCH_SIZE = 25; + + /** The capability dimension key used in the agent manifest. */ + public static final String DIMENSION_KEY = "product_changes"; + + public int effectiveMaxBatchSize() { + return maxBatchSize != null ? maxBatchSize : DEFAULT_MAX_BATCH_SIZE; + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementOutcome.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementOutcome.java new file mode 100644 index 0000000..4c8bddf --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementOutcome.java @@ -0,0 +1,39 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Possible outcomes for a single refinement entry in a response. + * + *

Precedence rule for reason codes: {@code constraint_unsatisfiable} wins + * whenever a typed constraint failed; typed failures never surface as + * {@code commercially_declined}. + */ +public enum RefinementOutcome { + + REVISED("revised"), + PARTIAL("partial"), + FINALIZED("finalized"), + UNABLE("unable"); + + private final String wire; + + RefinementOutcome(String wire) { + this.wire = wire; + } + + @JsonValue + public String toWire() { + return wire; + } + + public static RefinementOutcome fromWire(String value) { + return switch (value) { + case "revised" -> REVISED; + case "partial" -> PARTIAL; + case "finalized" -> FINALIZED; + case "unable" -> UNABLE; + default -> throw new IllegalArgumentException("Unknown refinement outcome: " + value); + }; + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementResult.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementResult.java new file mode 100644 index 0000000..074bc63 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementResult.java @@ -0,0 +1,106 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Sealed result for a single refinement entry in a response. + * + *

Discriminated on the {@code outcome} field. Pattern matching: + *

{@code
+ * switch (result) {
+ *     case RefinementResult.Revised r -> handleRevised(r);
+ *     case RefinementResult.Partial p -> handlePartial(p);
+ *     case RefinementResult.Finalized f -> handleFinalized(f);
+ *     case RefinementResult.Unable u -> handleUnable(u);
+ * }
+ * }
+ */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "outcome") +@JsonSubTypes({ + @JsonSubTypes.Type(value = RefinementResult.Revised.class, name = "revised"), + @JsonSubTypes.Type(value = RefinementResult.Partial.class, name = "partial"), + @JsonSubTypes.Type(value = RefinementResult.Finalized.class, name = "finalized"), + @JsonSubTypes.Type(value = RefinementResult.Unable.class, name = "unable") +}) +public sealed interface RefinementResult { + + String sourceProposalId(); + + RefinementOutcome outcome(); + + /** + * A successful full revision. The returned proposal is a draft with + * all constraints satisfied. + */ + record Revised( + @JsonProperty("source_proposal_id") String sourceProposalId, + @JsonProperty("proposal") JsonNode proposal, + @Nullable @JsonProperty("targeting_resolution") JsonNode targetingResolution + ) implements RefinementResult { + @Override + public RefinementOutcome outcome() { + return RefinementOutcome.REVISED; + } + } + + /** + * A partial revision. The proposal is a draft but some constraints + * could not be fully satisfied. {@code unsatisfiedConstraints} names + * which constraint keys from the request were not met. + * + *

Invariant: every constraint not listed in + * {@code unsatisfiedConstraints} is fully satisfied by this draft. + */ + record Partial( + @JsonProperty("source_proposal_id") String sourceProposalId, + @JsonProperty("proposal") JsonNode proposal, + @JsonProperty("notes") String notes, + @Nullable @JsonProperty("unsatisfied_constraints") List unsatisfiedConstraints, + @Nullable @JsonProperty("suggestions") List suggestions, + @Nullable @JsonProperty("targeting_resolution") JsonNode targetingResolution + ) implements RefinementResult { + @Override + public RefinementOutcome outcome() { + return RefinementOutcome.PARTIAL; + } + } + + /** + * Successful finalization: inventory is reserved, the proposal is + * now committed with a firm {@code expires_at}. + */ + record Finalized( + @JsonProperty("source_proposal_id") String sourceProposalId, + @JsonProperty("proposal") JsonNode proposal + ) implements RefinementResult { + @Override + public RefinementOutcome outcome() { + return RefinementOutcome.FINALIZED; + } + } + + /** + * The refinement could not be performed. The reason indicates why. + * + *

Reason code precedence: {@code constraint_unsatisfiable} wins + * whenever a typed constraint failed; typed failures never surface + * as {@code commercially_declined}. + */ + record Unable( + @JsonProperty("source_proposal_id") String sourceProposalId, + @JsonProperty("reason") String reason, + @Nullable @JsonProperty("notes") String notes, + @Nullable @JsonProperty("suggestions") List suggestions + ) implements RefinementResult { + @Override + public RefinementOutcome outcome() { + return RefinementOutcome.UNABLE; + } + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java new file mode 100644 index 0000000..a85759d --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java @@ -0,0 +1,275 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Verification utilities for {@code refine_proposals} responses. + * + *

Checks response ordering, budget and product constraints, unique + * alternatives, machine-readable failure subsets, and finalize/expiry + * semantics per the AdCP 3.2 specification. + * + *

All methods return a list of violations found. An empty list + * means the response passed verification. + */ +public final class ResponseVerifier { + + private ResponseVerifier() {} + + /** + * Runs all verification checks on a completed response. + * + * @param request the original request + * @param response the response to verify + * @return list of violation descriptions (empty if valid) + */ + public static List verify(RefineProposalsRequest request, + RefineProposalsResponse response) { + List violations = new ArrayList<>(); + + if (!response.isCompleted() || response.results() == null) { + return violations; + } + + verifyResultOrdering(request, response, violations); + verifyFinalizeHomogeneity(response, violations); + verifyLineage(request, response, violations); + verifyUniqueProposalIds(response, violations); + verifyPartialInvariant(response, violations); + verifyOutcomeConstraints(response, violations); + + return violations; + } + + /** + * Verifies that results preserve request ordering: each result's + * source_proposal_id matches the corresponding refinement entry. + */ + public static void verifyResultOrdering(RefineProposalsRequest request, + RefineProposalsResponse response, + List violations) { + List results = response.results(); + List refinements = request.refinements(); + + if (results == null) return; + + if (results.size() != refinements.size()) { + violations.add("result count " + results.size() + + " does not match refinement count " + refinements.size()); + return; + } + + for (int i = 0; i < results.size(); i++) { + String expectedId = refinements.get(i).proposalId(); + String actualId = results.get(i).sourceProposalId(); + if (!expectedId.equals(actualId)) { + violations.add("result[" + i + "] source_proposal_id '" + + actualId + "' does not match request '" + + expectedId + "'"); + } + } + } + + /** + * Verifies that if any result is finalized, all results are finalized + * (atomic finalize batch invariant). + */ + public static void verifyFinalizeHomogeneity(RefineProposalsResponse response, + List violations) { + List results = response.results(); + if (results == null || results.isEmpty()) return; + + boolean anyFinalized = results.stream() + .anyMatch(r -> r.outcome() == RefinementOutcome.FINALIZED); + if (!anyFinalized) return; + + for (int i = 0; i < results.size(); i++) { + if (results.get(i).outcome() != RefinementOutcome.FINALIZED) { + violations.add("result[" + i + "] is " + results.get(i).outcome() + + " but batch contains finalized results" + + " (finalize batches must be all-or-none)"); + } + } + } + + /** + * Verifies lineage: every returned proposal must carry a + * parent_proposal_id equal to the entry's source_proposal_id. + */ + public static void verifyLineage(RefineProposalsRequest request, + RefineProposalsResponse response, + List violations) { + List results = response.results(); + if (results == null) return; + + for (int i = 0; i < results.size(); i++) { + RefinementResult result = results.get(i); + JsonNode proposal = extractProposal(result); + if (proposal == null) continue; + + JsonNode parentId = proposal.get("parent_proposal_id"); + if (parentId == null || parentId.isNull()) { + violations.add("result[" + i + "] proposal is missing parent_proposal_id"); + } else if (!parentId.asText().equals(result.sourceProposalId())) { + violations.add("result[" + i + "] parent_proposal_id '" + + parentId.asText() + + "' does not match source_proposal_id '" + + result.sourceProposalId() + "'"); + } + } + } + + /** + * Verifies that all returned proposal_ids are unique (no duplicates + * across alternatives). + */ + public static void verifyUniqueProposalIds(RefineProposalsResponse response, + List violations) { + List results = response.results(); + if (results == null) return; + + Set seen = new HashSet<>(); + for (int i = 0; i < results.size(); i++) { + JsonNode proposal = extractProposal(results.get(i)); + if (proposal == null) continue; + + JsonNode pid = proposal.get("proposal_id"); + if (pid != null && !pid.isNull()) { + if (!seen.add(pid.asText())) { + violations.add("result[" + i + "] duplicate proposal_id: " + + pid.asText()); + } + } + } + } + + /** + * Verifies the partial invariant: on a partial result, every + * constraint not listed in unsatisfied_constraints is fully + * satisfied by the draft. + * + *

Currently checks that unsatisfied_constraints is present + * and non-empty for partial outcomes. + */ + public static void verifyPartialInvariant(RefineProposalsResponse response, + List violations) { + List results = response.results(); + if (results == null) return; + + for (int i = 0; i < results.size(); i++) { + if (results.get(i) instanceof RefinementResult.Partial partial) { + if (partial.unsatisfiedConstraints() == null + || partial.unsatisfiedConstraints().isEmpty()) { + violations.add("result[" + i + + "] is partial but has no unsatisfied_constraints"); + } + } + } + } + + /** + * Verifies outcome-specific structural constraints: + * - revised: must have proposal with status=draft, no reason/notes + * - partial: must have proposal with status=draft, must have notes + * - finalized: must have proposal with status=committed and expires_at + * - unable: must have reason, must not have proposal + */ + public static void verifyOutcomeConstraints(RefineProposalsResponse response, + List violations) { + List results = response.results(); + if (results == null) return; + + for (int i = 0; i < results.size(); i++) { + RefinementResult result = results.get(i); + switch (result) { + case RefinementResult.Revised r -> { + if (r.proposal() == null || r.proposal().isNull()) { + violations.add("result[" + i + "] revised but missing proposal"); + } else { + checkProposalStatus(r.proposal(), "draft", i, violations); + } + } + case RefinementResult.Partial p -> { + if (p.proposal() == null || p.proposal().isNull()) { + violations.add("result[" + i + "] partial but missing proposal"); + } else { + checkProposalStatus(p.proposal(), "draft", i, violations); + } + if (p.notes() == null || p.notes().isBlank()) { + violations.add("result[" + i + "] partial but missing notes"); + } + } + case RefinementResult.Finalized f -> { + if (f.proposal() == null || f.proposal().isNull()) { + violations.add("result[" + i + "] finalized but missing proposal"); + } else { + checkProposalStatus(f.proposal(), "committed", i, violations); + JsonNode expiresAt = f.proposal().get("expires_at"); + if (expiresAt == null || expiresAt.isNull()) { + violations.add("result[" + i + + "] finalized proposal missing expires_at"); + } + } + } + case RefinementResult.Unable u -> { + if (u.reason() == null || u.reason().isBlank()) { + violations.add("result[" + i + "] unable but missing reason"); + } + } + } + } + } + + /** + * Verifies that all returned proposals have valid terms_digest values + * matching their commercial_terms. + */ + public static List verifyDigests(RefineProposalsResponse response) { + List violations = new ArrayList<>(); + List results = response.results(); + if (results == null) return violations; + + for (int i = 0; i < results.size(); i++) { + JsonNode proposal = extractProposal(results.get(i)); + if (proposal == null) continue; + + JsonNode digestNode = proposal.get("terms_digest"); + JsonNode termsNode = proposal.get("commercial_terms"); + + if (digestNode != null && !digestNode.isNull() + && termsNode != null && !termsNode.isNull()) { + if (!TermsDigest.verify(digestNode.asText(), termsNode)) { + violations.add("result[" + i + "] terms_digest does not match " + + "recomputed SHA-256 of commercial_terms"); + } + } + } + + return violations; + } + + private static void checkProposalStatus(JsonNode proposal, String expected, + int index, List violations) { + JsonNode status = proposal.get("proposal_status"); + if (status == null || status.isNull()) { + violations.add("result[" + index + "] proposal missing proposal_status"); + } else if (!expected.equals(status.asText())) { + violations.add("result[" + index + "] expected proposal_status '" + + expected + "' but got '" + status.asText() + "'"); + } + } + + private static JsonNode extractProposal(RefinementResult result) { + return switch (result) { + case RefinementResult.Revised r -> r.proposal(); + case RefinementResult.Partial p -> p.proposal(); + case RefinementResult.Finalized f -> f.proposal(); + case RefinementResult.Unable u -> null; + }; + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java new file mode 100644 index 0000000..3fea3d6 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java @@ -0,0 +1,213 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.jspecify.annotations.Nullable; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; + +/** + * Computes and verifies {@code terms_digest} values per the AdCP 3.2 + * normative digest specification. + * + *

Format: {@code sha256:} + base64url(SHA-256(JCS(commercial_terms))), + * where JCS is RFC 8785 JSON Canonicalization Scheme. + * + *

Buyer helpers should recompute and compare rather than trusting + * the string. Alternative distinctness is defined as distinct + * {@code commercial_terms}. + */ +public final class TermsDigest { + + private static final String PREFIX = "sha256:"; + + private TermsDigest() {} + + /** + * Computes the canonical digest for a commercial_terms JSON node. + * + * @return "sha256:" + base64url(SHA-256(JCS(commercialTerms))) + */ + public static String compute(JsonNode commercialTerms) { + byte[] canonical = canonicalize(commercialTerms); + byte[] hash = sha256(canonical); + String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + return PREFIX + encoded; + } + + /** + * Verifies that a digest string matches the computed digest of + * the given commercial terms. + * + * @return true if the digest is valid + */ + public static boolean verify(@Nullable String digest, JsonNode commercialTerms) { + if (digest == null || !digest.startsWith(PREFIX)) { + return false; + } + String expected = compute(commercialTerms); + return MessageDigest.isEqual( + digest.getBytes(StandardCharsets.UTF_8), + expected.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Checks whether two proposals have distinct commercial terms by + * comparing their canonical digests. + */ + public static boolean areDistinct(JsonNode termsA, JsonNode termsB) { + return !compute(termsA).equals(compute(termsB)); + } + + /** + * RFC 8785 JCS canonicalization. Sorts object keys lexicographically + * by UTF-16 code unit order, and formats numbers per ES2015 rules. + * + *

This is a self-contained implementation to avoid a runtime + * dependency on org.webpki.jcs for the common case. The JCS number + * formatting corner cases (very large/small doubles) follow the + * ES2015 spec rather than Java's Double.toString(). + */ + static byte[] canonicalize(JsonNode node) { + try { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeCanonical(node, out); + return out.toByteArray(); + } catch (IOException e) { + throw new IllegalArgumentException("failed to canonicalize JSON", e); + } + } + + private static void writeCanonical(JsonNode node, ByteArrayOutputStream out) + throws IOException { + switch (node.getNodeType()) { + case OBJECT -> { + ObjectNode obj = (ObjectNode) node; + List keys = new ArrayList<>(); + obj.fieldNames().forEachRemaining(keys::add); + // JCS: sort by UTF-16 code unit order (String.compareTo) + Collections.sort(keys); + + out.write('{'); + boolean first = true; + for (String key : keys) { + if (!first) out.write(','); + first = false; + writeCanonicalString(key, out); + out.write(':'); + writeCanonical(obj.get(key), out); + } + out.write('}'); + } + case ARRAY -> { + ArrayNode arr = (ArrayNode) node; + out.write('['); + boolean first = true; + for (int i = 0; i < arr.size(); i++) { + if (!first) out.write(','); + first = false; + writeCanonical(arr.get(i), out); + } + out.write(']'); + } + case STRING -> writeCanonicalString(node.textValue(), out); + case NUMBER -> { + // JCS number serialization per ES2015 (RFC 8785 §3.2.2.3) + double d = node.doubleValue(); + if (Double.isNaN(d) || Double.isInfinite(d)) { + throw new IOException("JCS does not support NaN or Infinity"); + } + if (d == 0.0) { + // Normalize -0 to 0 + out.write('0'); + } else { + long asLong = node.longValue(); + if (d == (double) asLong && !node.isDouble() + && !node.isFloat() + && Math.abs(asLong) < (1L << 53)) { + out.write(Long.toString(asLong).getBytes(StandardCharsets.UTF_8)); + } else { + // For decimal values, we use the representation that + // round-trips through parseDouble — per JCS spec. + String repr = jcsNumberString(d); + out.write(repr.getBytes(StandardCharsets.UTF_8)); + } + } + } + case BOOLEAN -> out.write( + (node.booleanValue() ? "true" : "false") + .getBytes(StandardCharsets.UTF_8)); + case NULL -> out.write("null".getBytes(StandardCharsets.UTF_8)); + default -> throw new IOException("Unsupported JSON node type: " + node.getNodeType()); + } + } + + private static void writeCanonicalString(String s, ByteArrayOutputStream out) + throws IOException { + out.write('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"' -> out.write("\\\"".getBytes(StandardCharsets.UTF_8)); + case '\\' -> out.write("\\\\".getBytes(StandardCharsets.UTF_8)); + case '\b' -> out.write("\\b".getBytes(StandardCharsets.UTF_8)); + case '\f' -> out.write("\\f".getBytes(StandardCharsets.UTF_8)); + case '\n' -> out.write("\\n".getBytes(StandardCharsets.UTF_8)); + case '\r' -> out.write("\\r".getBytes(StandardCharsets.UTF_8)); + case '\t' -> out.write("\\t".getBytes(StandardCharsets.UTF_8)); + default -> { + if (c < 0x20) { + out.write(String.format("\\u%04x", (int) c) + .getBytes(StandardCharsets.UTF_8)); + } else { + out.write(String.valueOf(c).getBytes(StandardCharsets.UTF_8)); + } + } + } + } + out.write('"'); + } + + /** + * ES2015-compliant number-to-string conversion for JCS. + * Produces the shortest decimal representation that round-trips + * through parseDouble, per RFC 8785 section 3.2.2.3. + */ + static String jcsNumberString(double d) { + if (d == 0.0) return "0"; + if (d == (long) d && Math.abs(d) < 1e21) { + return Long.toString((long) d); + } + String s = Double.toString(d); + if (s.contains("E") || s.contains("e")) { + // ES2015: lowercase 'e', no '+' sign, strip ".0" before 'e' + // e.g. Java "1.0E-7" → JCS "1e-7" + s = s.toLowerCase().replace("+", ""); + int eIdx = s.indexOf('e'); + String mantissa = s.substring(0, eIdx); + String exponent = s.substring(eIdx); + if (mantissa.endsWith(".0")) { + mantissa = mantissa.substring(0, mantissa.length() - 2); + } + return mantissa + exponent; + } + return s; + } + + private static byte[] sha256(byte[] data) { + try { + return MessageDigest.getInstance("SHA-256").digest(data); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError("SHA-256 is required by the JDK spec", e); + } + } +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/UnsupportedRefinementDetails.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/UnsupportedRefinementDetails.java new file mode 100644 index 0000000..20c169c --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/UnsupportedRefinementDetails.java @@ -0,0 +1,21 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * Typed error details for {@code UNSUPPORTED_FEATURE} when a refinement + * dimension is not supported by the seller. + * + *

Follows the {@code error-details/unsupported-refinement-dimension.json} + * schema: carries the unsupported dimension and echoes back the seller's + * supported dimensions for typed error recovery. + * + * @param unsupportedDimension the dimension the buyer requested + * @param supportedDimensions the dimensions the seller actually supports + */ +public record UnsupportedRefinementDetails( + @JsonProperty("unsupported_dimension") String unsupportedDimension, + @JsonProperty("supported_dimensions") List supportedDimensions) { +} diff --git a/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/package-info.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/package-info.java new file mode 100644 index 0000000..c5bdf99 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/package-info.java @@ -0,0 +1,13 @@ +/** + * Buyer and seller proposal negotiation APIs for AdCP 3.2. + * + *

This package provides first-class types for the {@code refine_proposals} + * tool: sealed outcome models, capability-aware request builders, response + * verification utilities, and digest verification. + * + * @see org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest + * @see org.adcontextprotocol.adcp.negotiation.RefinementResult + * @see org.adcontextprotocol.adcp.negotiation.ResponseVerifier + */ +@org.jspecify.annotations.NullMarked +package org.adcontextprotocol.adcp.negotiation; diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java new file mode 100644 index 0000000..840d378 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java @@ -0,0 +1,100 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import static org.junit.jupiter.api.Assertions.*; + +class ConstraintsTest { + + private final ObjectMapper mapper = new ObjectMapper() + .findAndRegisterModules(); + + @Test + void cpm_constraint_requires_max_and_currency() { + var cpm = new CpmConstraint(new BigDecimal("12.50"), "USD"); + assertEquals(new BigDecimal("12.50"), cpm.max()); + assertEquals("USD", cpm.currency()); + } + + @Test + void cpm_constraint_rejects_null_max() { + assertThrows(IllegalArgumentException.class, + () -> new CpmConstraint(null, "USD")); + } + + @Test + void cpm_constraint_rejects_blank_currency() { + assertThrows(IllegalArgumentException.class, + () -> new CpmConstraint(BigDecimal.TEN, "")); + } + + @Test + void cpm_constraint_round_trips_via_jackson() throws Exception { + var cpm = new CpmConstraint(new BigDecimal("8.25"), "EUR"); + String json = mapper.writeValueAsString(cpm); + var back = mapper.readValue(json, CpmConstraint.class); + + assertEquals(cpm.max().compareTo(back.max()), 0); + assertEquals(cpm.currency(), back.currency()); + } + + @Test + void impressions_constraint_requires_non_negative_min() { + var ic = new ImpressionsConstraint(100_000); + assertEquals(100_000, ic.min()); + } + + @Test + void impressions_constraint_rejects_negative() { + assertThrows(IllegalArgumentException.class, + () -> new ImpressionsConstraint(-1)); + } + + @Test + void impressions_constraint_round_trips() throws Exception { + var ic = new ImpressionsConstraint(500_000); + String json = mapper.writeValueAsString(ic); + var back = mapper.readValue(json, ImpressionsConstraint.class); + + assertEquals(ic.min(), back.min()); + } + + @Test + void flight_constraint_requires_at_least_one_bound() { + assertThrows(IllegalArgumentException.class, + () -> new FlightConstraint(null, null)); + } + + @Test + void flight_constraint_accepts_start_only() { + var start = OffsetDateTime.of(2026, 10, 1, 0, 0, 0, 0, ZoneOffset.UTC); + var fc = new FlightConstraint(start, null); + assertEquals(start, fc.startNoLaterThan()); + assertNull(fc.endNoEarlierThan()); + } + + @Test + void flight_constraint_accepts_both_bounds() { + var start = OffsetDateTime.of(2026, 10, 1, 0, 0, 0, 0, ZoneOffset.UTC); + var end = OffsetDateTime.of(2026, 12, 31, 23, 59, 59, 0, ZoneOffset.UTC); + var fc = new FlightConstraint(start, end); + + assertEquals(start, fc.startNoLaterThan()); + assertEquals(end, fc.endNoEarlierThan()); + } + + @Test + void flight_constraint_round_trips() throws Exception { + var start = OffsetDateTime.of(2026, 10, 1, 0, 0, 0, 0, ZoneOffset.UTC); + var fc = new FlightConstraint(start, null); + String json = mapper.writeValueAsString(fc); + var back = mapper.readValue(json, FlightConstraint.class); + + assertEquals(fc.startNoLaterThan(), back.startNoLaterThan()); + } +} diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java new file mode 100644 index 0000000..e190cbb --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java @@ -0,0 +1,123 @@ +package org.adcontextprotocol.adcp.negotiation; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class RefineProposalsRequestTest { + + private static String validKey() { + return "idem-" + UUID.randomUUID().toString().replace("-", ""); + } + + @Test + void builder_creates_valid_single_revise_request() { + var request = RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .addRefinement(ProposalRefinement.revise("p-1", "lower CPM to $8")) + .build(); + + assertEquals(1, request.refinements().size()); + assertEquals("p-1", request.refinements().get(0).proposalId()); + assertEquals(RefinementAction.REVISE, request.refinements().get(0).action()); + } + + @Test + void builder_creates_batch_finalize_request() { + var request = RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .addRefinement(ProposalRefinement.finalize("p-1")) + .addRefinement(ProposalRefinement.finalize("p-2")) + .addRefinement(ProposalRefinement.finalize("p-3")) + .build(); + + assertEquals(3, request.refinements().size()); + request.refinements().forEach(r -> + assertEquals(RefinementAction.FINALIZE, r.action())); + } + + @Test + void rejects_null_idempotency_key() { + var builder = RefineProposalsRequest.builder() + .addRefinement(ProposalRefinement.revise("p-1", "test")); + + assertThrows(NullPointerException.class, builder::build); + } + + @Test + void rejects_short_idempotency_key() { + assertThrows(IllegalArgumentException.class, () -> + RefineProposalsRequest.builder() + .idempotencyKey("too-short") + .addRefinement(ProposalRefinement.revise("p-1", "test")) + .build()); + } + + @Test + void rejects_empty_refinements() { + assertThrows(IllegalArgumentException.class, () -> + RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .build()); + } + + @Test + void rejects_mixed_finalize_and_revise_batch() { + assertThrows(IllegalArgumentException.class, () -> + RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .addRefinement(ProposalRefinement.finalize("p-1")) + .addRefinement(ProposalRefinement.revise("p-2", "change CPM")) + .build()); + } + + @Test + void rejects_duplicate_proposal_ids() { + assertThrows(IllegalArgumentException.class, () -> + RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .addRefinement(ProposalRefinement.revise("p-1", "first")) + .addRefinement(ProposalRefinement.revise("p-1", "second")) + .build()); + } + + @Test + void rejects_batch_exceeding_max_size() { + var builder = RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .maxBatchSize(2); + + builder.addRefinement(ProposalRefinement.revise("p-1", "a")); + builder.addRefinement(ProposalRefinement.revise("p-2", "b")); + builder.addRefinement(ProposalRefinement.revise("p-3", "c")); + + assertThrows(IllegalArgumentException.class, builder::build); + } + + @Test + void refinements_list_is_immutable() { + var request = RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .addRefinement(ProposalRefinement.revise("p-1", "test")) + .build(); + + assertThrows(UnsupportedOperationException.class, () -> + request.refinements().add(ProposalRefinement.revise("p-2", "x"))); + } + + @Test + void cancellation_refinement() { + var request = RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .addRefinement(ProposalRefinement.cancel("p-1", "budget reallocated")) + .build(); + + var refinement = request.refinements().get(0); + assertEquals(RefinementAction.REVISE, refinement.action()); + assertEquals(ChangeKind.CANCELLATION, refinement.changeKind()); + } +} diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java new file mode 100644 index 0000000..1cc47a7 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java @@ -0,0 +1,143 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class RefinementResultTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void sealed_interface_permits_four_outcomes() { + assertTrue(RefinementResult.class.isSealed()); + + Class[] permitted = RefinementResult.class.getPermittedSubclasses(); + assertNotNull(permitted); + assertEquals(4, permitted.length); + } + + @Test + void revised_round_trips_via_jackson() throws Exception { + ObjectNode proposal = mapper.createObjectNode(); + proposal.put("proposal_id", "p-new"); + proposal.put("proposal_status", "draft"); + + String json = """ + { + "source_proposal_id": "p-1", + "outcome": "revised", + "proposal": {"proposal_id": "p-new", "proposal_status": "draft"} + } + """; + + RefinementResult result = mapper.readValue(json, RefinementResult.class); + assertInstanceOf(RefinementResult.Revised.class, result); + + RefinementResult.Revised revised = (RefinementResult.Revised) result; + assertEquals("p-1", revised.sourceProposalId()); + assertEquals(RefinementOutcome.REVISED, revised.outcome()); + assertEquals("p-new", revised.proposal().get("proposal_id").asText()); + } + + @Test + void partial_round_trips_with_unsatisfied_constraints() throws Exception { + String json = """ + { + "source_proposal_id": "p-2", + "outcome": "partial", + "proposal": {"proposal_id": "p-new", "proposal_status": "draft"}, + "notes": "CPM constraint could not be fully met", + "unsatisfied_constraints": ["cpm"] + } + """; + + RefinementResult result = mapper.readValue(json, RefinementResult.class); + assertInstanceOf(RefinementResult.Partial.class, result); + + RefinementResult.Partial partial = (RefinementResult.Partial) result; + assertEquals("CPM constraint could not be fully met", partial.notes()); + assertEquals(List.of("cpm"), partial.unsatisfiedConstraints()); + } + + @Test + void finalized_round_trips() throws Exception { + String json = """ + { + "source_proposal_id": "p-3", + "outcome": "finalized", + "proposal": { + "proposal_id": "p-committed", + "proposal_status": "committed", + "expires_at": "2026-10-01T00:00:00Z" + } + } + """; + + RefinementResult result = mapper.readValue(json, RefinementResult.class); + assertInstanceOf(RefinementResult.Finalized.class, result); + assertEquals(RefinementOutcome.FINALIZED, result.outcome()); + } + + @Test + void unable_round_trips_with_reason() throws Exception { + String json = """ + { + "source_proposal_id": "p-4", + "outcome": "unable", + "reason": "hold_unavailable", + "notes": "Inventory no longer available for this flight" + } + """; + + RefinementResult result = mapper.readValue(json, RefinementResult.class); + assertInstanceOf(RefinementResult.Unable.class, result); + + RefinementResult.Unable unable = (RefinementResult.Unable) result; + assertEquals("hold_unavailable", unable.reason()); + assertNotNull(unable.notes()); + } + + @Test + void serialize_then_deserialize_round_trip() throws Exception { + ObjectNode proposal = mapper.createObjectNode(); + proposal.put("proposal_id", "p-rt"); + proposal.put("proposal_status", "draft"); + + RefinementResult original = new RefinementResult.Revised("src-1", proposal, null); + + String json = mapper.writeValueAsString(original); + assertTrue(json.contains("\"outcome\":\"revised\"")); + assertTrue(json.contains("\"source_proposal_id\":\"src-1\"")); + + RefinementResult deserialized = mapper.readValue(json, RefinementResult.class); + assertInstanceOf(RefinementResult.Revised.class, deserialized); + assertEquals("src-1", deserialized.sourceProposalId()); + } + + @Test + void pattern_matching_exhaustiveness() throws Exception { + String json = """ + { + "source_proposal_id": "p-5", + "outcome": "unable", + "reason": "commercially_declined" + } + """; + + RefinementResult result = mapper.readValue(json, RefinementResult.class); + + String label = switch (result) { + case RefinementResult.Revised r -> "revised: " + r.sourceProposalId(); + case RefinementResult.Partial p -> "partial: " + p.notes(); + case RefinementResult.Finalized f -> "finalized: " + f.sourceProposalId(); + case RefinementResult.Unable u -> "unable: " + u.reason(); + }; + + assertEquals("unable: commercially_declined", label); + } +} diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java new file mode 100644 index 0000000..9f21d65 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.java @@ -0,0 +1,259 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class ResponseVerifierTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + private static String key() { + return "idem-" + UUID.randomUUID().toString().replace("-", ""); + } + + @Test + void valid_revised_response_passes_verification() throws Exception { + var request = RefineProposalsRequest.builder() + .idempotencyKey(key()) + .addRefinement(ProposalRefinement.revise("src-1", "lower CPM")) + .build(); + + ObjectNode proposal = draftProposal("new-1", "src-1"); + String json = """ + { + "status": "completed", + "results": [{ + "source_proposal_id": "src-1", + "outcome": "revised", + "proposal": %s + }], + "products": [] + } + """.formatted(proposal.toString()); + + var response = mapper.readValue(json, RefineProposalsResponse.class); + List violations = ResponseVerifier.verify(request, response); + + assertTrue(violations.isEmpty(), "Expected no violations but got: " + violations); + } + + @Test + void detects_result_count_mismatch() throws Exception { + var request = RefineProposalsRequest.builder() + .idempotencyKey(key()) + .addRefinement(ProposalRefinement.revise("src-1", "test")) + .addRefinement(ProposalRefinement.revise("src-2", "test")) + .build(); + + String json = """ + { + "status": "completed", + "results": [{ + "source_proposal_id": "src-1", + "outcome": "unable", + "reason": "commercially_declined" + }], + "products": [] + } + """; + + var response = mapper.readValue(json, RefineProposalsResponse.class); + List violations = ResponseVerifier.verify(request, response); + + assertTrue(violations.stream().anyMatch(v -> v.contains("result count"))); + } + + @Test + void detects_ordering_mismatch() throws Exception { + var request = RefineProposalsRequest.builder() + .idempotencyKey(key()) + .addRefinement(ProposalRefinement.revise("src-1", "a")) + .addRefinement(ProposalRefinement.revise("src-2", "b")) + .build(); + + String json = """ + { + "status": "completed", + "results": [ + {"source_proposal_id": "src-2", "outcome": "unable", "reason": "test"}, + {"source_proposal_id": "src-1", "outcome": "unable", "reason": "test"} + ], + "products": [] + } + """; + + var response = mapper.readValue(json, RefineProposalsResponse.class); + List violations = ResponseVerifier.verify(request, response); + + assertTrue(violations.stream().anyMatch(v -> v.contains("does not match request"))); + } + + @Test + void detects_mixed_finalize_in_response() throws Exception { + var request = RefineProposalsRequest.builder() + .idempotencyKey(key()) + .addRefinement(ProposalRefinement.finalize("src-1")) + .addRefinement(ProposalRefinement.finalize("src-2")) + .build(); + + ObjectNode committed = committedProposal("new-1", "src-1"); + + // Construct a response where one result is finalized but the other + // is revised (violates atomicity) + String json = """ + { + "status": "completed", + "results": [ + { + "source_proposal_id": "src-1", + "outcome": "finalized", + "proposal": %s + }, + { + "source_proposal_id": "src-2", + "outcome": "unable", + "reason": "hold_unavailable" + } + ], + "products": [] + } + """.formatted(committed.toString()); + + var response = mapper.readValue(json, RefineProposalsResponse.class); + List violations = ResponseVerifier.verify(request, response); + + assertTrue(violations.stream().anyMatch(v -> v.contains("finalize batches"))); + } + + @Test + void detects_missing_lineage() throws Exception { + var request = RefineProposalsRequest.builder() + .idempotencyKey(key()) + .addRefinement(ProposalRefinement.revise("src-1", "test")) + .build(); + + // Proposal without parent_proposal_id + ObjectNode proposal = mapper.createObjectNode(); + proposal.put("proposal_id", "new-1"); + proposal.put("proposal_status", "draft"); + + String json = """ + { + "status": "completed", + "results": [{ + "source_proposal_id": "src-1", + "outcome": "revised", + "proposal": %s + }], + "products": [] + } + """.formatted(proposal.toString()); + + var response = mapper.readValue(json, RefineProposalsResponse.class); + List violations = ResponseVerifier.verify(request, response); + + assertTrue(violations.stream() + .anyMatch(v -> v.contains("parent_proposal_id"))); + } + + @Test + void detects_wrong_proposal_status_for_finalized() throws Exception { + var request = RefineProposalsRequest.builder() + .idempotencyKey(key()) + .addRefinement(ProposalRefinement.finalize("src-1")) + .build(); + + // Return a "draft" instead of "committed" for a finalized outcome + ObjectNode proposal = draftProposal("new-1", "src-1"); + proposal.put("expires_at", "2026-10-01T00:00:00Z"); + + String json = """ + { + "status": "completed", + "results": [{ + "source_proposal_id": "src-1", + "outcome": "finalized", + "proposal": %s + }], + "products": [] + } + """.formatted(proposal.toString()); + + var response = mapper.readValue(json, RefineProposalsResponse.class); + List violations = ResponseVerifier.verify(request, response); + + assertTrue(violations.stream() + .anyMatch(v -> v.contains("proposal_status") && v.contains("committed"))); + } + + @Test + void skips_verification_for_async_responses() throws Exception { + var request = RefineProposalsRequest.builder() + .idempotencyKey(key()) + .addRefinement(ProposalRefinement.revise("src-1", "test")) + .build(); + + String json = """ + {"status": "submitted", "task_id": "task-123"} + """; + + var response = mapper.readValue(json, RefineProposalsResponse.class); + List violations = ResponseVerifier.verify(request, response); + + assertTrue(violations.isEmpty()); + } + + @Test + void digest_verification_catches_mismatch() throws Exception { + ObjectNode terms = mapper.createObjectNode(); + terms.put("price", 10); + + ObjectNode proposal = mapper.createObjectNode(); + proposal.put("proposal_id", "p-1"); + proposal.put("parent_proposal_id", "src-1"); + proposal.put("proposal_status", "draft"); + proposal.set("commercial_terms", terms); + proposal.put("terms_digest", "sha256:AAAA_wrong_digest"); + + String json = """ + { + "status": "completed", + "results": [{ + "source_proposal_id": "src-1", + "outcome": "revised", + "proposal": %s + }], + "products": [] + } + """.formatted(proposal.toString()); + + var response = mapper.readValue(json, RefineProposalsResponse.class); + List violations = ResponseVerifier.verifyDigests(response); + + assertFalse(violations.isEmpty()); + assertTrue(violations.get(0).contains("terms_digest")); + } + + // -- helpers -- + + private ObjectNode draftProposal(String id, String parentId) { + ObjectNode p = mapper.createObjectNode(); + p.put("proposal_id", id); + p.put("parent_proposal_id", parentId); + p.put("proposal_status", "draft"); + return p; + } + + private ObjectNode committedProposal(String id, String parentId) { + ObjectNode p = draftProposal(id, parentId); + p.put("proposal_status", "committed"); + p.put("expires_at", "2026-12-31T23:59:59Z"); + return p; + } +} diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java new file mode 100644 index 0000000..bf21503 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java @@ -0,0 +1,184 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class TermsDigestTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void compute_returns_sha256_prefixed_digest() { + ObjectNode terms = mapper.createObjectNode(); + terms.put("total_budget", 50000); + terms.put("currency", "USD"); + + String digest = TermsDigest.compute(terms); + + assertTrue(digest.startsWith("sha256:")); + // base64url: no padding, no +, no / + String encoded = digest.substring(7); + assertFalse(encoded.contains("=")); + assertFalse(encoded.contains("+")); + assertFalse(encoded.contains("/")); + } + + @Test + void compute_is_deterministic() { + ObjectNode terms = mapper.createObjectNode(); + terms.put("currency", "USD"); + terms.put("total_budget", 50000); + + String digest1 = TermsDigest.compute(terms); + String digest2 = TermsDigest.compute(terms); + + assertEquals(digest1, digest2); + } + + @Test + void compute_is_key_order_independent() { + ObjectNode terms1 = mapper.createObjectNode(); + terms1.put("currency", "USD"); + terms1.put("total_budget", 50000); + + ObjectNode terms2 = mapper.createObjectNode(); + terms2.put("total_budget", 50000); + terms2.put("currency", "USD"); + + assertEquals(TermsDigest.compute(terms1), TermsDigest.compute(terms2)); + } + + @Test + void verify_succeeds_for_matching_digest() { + ObjectNode terms = mapper.createObjectNode(); + terms.put("price", 10.5); + + String digest = TermsDigest.compute(terms); + + assertTrue(TermsDigest.verify(digest, terms)); + } + + @Test + void verify_fails_for_tampered_terms() { + ObjectNode original = mapper.createObjectNode(); + original.put("price", 10.5); + + String digest = TermsDigest.compute(original); + + ObjectNode tampered = mapper.createObjectNode(); + tampered.put("price", 15.0); + + assertFalse(TermsDigest.verify(digest, tampered)); + } + + @Test + void verify_fails_for_null_digest() { + ObjectNode terms = mapper.createObjectNode(); + assertFalse(TermsDigest.verify(null, terms)); + } + + @Test + void verify_fails_for_wrong_prefix() { + ObjectNode terms = mapper.createObjectNode(); + assertFalse(TermsDigest.verify("md5:abc", terms)); + } + + @Test + void areDistinct_detects_different_terms() { + ObjectNode terms1 = mapper.createObjectNode(); + terms1.put("price", 10); + + ObjectNode terms2 = mapper.createObjectNode(); + terms2.put("price", 20); + + assertTrue(TermsDigest.areDistinct(terms1, terms2)); + } + + @Test + void areDistinct_detects_identical_terms() { + ObjectNode terms1 = mapper.createObjectNode(); + terms1.put("price", 10); + + ObjectNode terms2 = mapper.createObjectNode(); + terms2.put("price", 10); + + assertFalse(TermsDigest.areDistinct(terms1, terms2)); + } + + @Test + void jcs_canonicalization_handles_nested_objects() { + ObjectNode inner = mapper.createObjectNode(); + inner.put("b", 2); + inner.put("a", 1); + + ObjectNode outer = mapper.createObjectNode(); + outer.put("z", "last"); + outer.set("nested", inner); + + byte[] canonical = TermsDigest.canonicalize(outer); + String result = new String(canonical, StandardCharsets.UTF_8); + + // Keys should be sorted: nested before z, and within nested: a before b + assertTrue(result.indexOf("\"nested\"") < result.indexOf("\"z\"")); + assertTrue(result.indexOf("\"a\"") < result.indexOf("\"b\"")); + } + + @Test + void jcs_number_string_handles_integers() { + assertEquals("0", TermsDigest.jcsNumberString(0.0)); + assertEquals("42", TermsDigest.jcsNumberString(42.0)); + assertEquals("-17", TermsDigest.jcsNumberString(-17.0)); + } + + @Test + void jcs_number_string_handles_decimals() { + String result = TermsDigest.jcsNumberString(3.14); + assertEquals("3.14", result); + } + + @Test + void jcs_number_string_strips_dot_zero_in_exponent() { + // Java Double.toString(1e-7) → "1.0E-7"; JCS requires "1e-7" + assertEquals("1e-7", TermsDigest.jcsNumberString(1e-7)); + assertEquals("1e-20", TermsDigest.jcsNumberString(1e-20)); + } + + @Test + void jcs_number_string_keeps_fractional_exponent() { + // 1.5e10 = 15000000000 → integer path + assertEquals("15000000000", TermsDigest.jcsNumberString(1.5e10)); + // 1.5e21 stays exponential since > 1e21 + assertEquals("1.5e21", TermsDigest.jcsNumberString(1.5e21)); + } + + @Test + void jcs_handles_string_escaping() { + ObjectNode node = mapper.createObjectNode(); + node.put("msg", "hello\nworld\t\"quoted\""); + + byte[] canonical = TermsDigest.canonicalize(node); + String result = new String(canonical, StandardCharsets.UTF_8); + + assertTrue(result.contains("\\n")); + assertTrue(result.contains("\\t")); + assertTrue(result.contains("\\\"")); + } + + @Test + void jcs_handles_boolean_and_null() { + ObjectNode node = mapper.createObjectNode(); + node.put("flag", true); + node.putNull("empty"); + + byte[] canonical = TermsDigest.canonicalize(node); + String result = new String(canonical, StandardCharsets.UTF_8); + + assertTrue(result.contains("true")); + assertTrue(result.contains("null")); + } +}