diff --git a/.changeset/config.json b/.changeset/config.json index 3c5b58a..530fae3 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": [], + "privatePackages": { + "version": true, + "tag": false + } } diff --git a/.changeset/negotiation-proposal-apis.md b/.changeset/negotiation-proposal-apis.md new file mode 100644 index 0000000..0569f45 --- /dev/null +++ b/.changeset/negotiation-proposal-apis.md @@ -0,0 +1,15 @@ +--- +"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 + +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-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/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-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/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-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/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-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/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-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/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-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 new file mode 100644 index 0000000..556d3d8 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalHandler.java @@ -0,0 +1,126 @@ +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.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. + * + *

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);
+ *     }
+ *
+ *     @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: + *

    + *
  • Advertising supported dimensions to buyers
  • + *
  • Preflight validation of incoming requests
  • + *
  • Capability gating in the server builder
  • + *
+ */ + RefinementCapability capability(); + + /** + * Handles a batch of refinement operations. + * + *

The framework has already validated: + *

    + *
  • Idempotency key format
  • + *
  • Batch size within the declared ceiling
  • + *
  • Finalize-only batch homogeneity
  • + *
  • Unique proposal IDs within the batch
  • + *
+ * + *

The handler is responsible for: + *

    + *
  • Loading and validating source proposals
  • + *
  • Creating immutable successor proposals
  • + *
  • Computing digest/lineage fields
  • + *
  • Atomic finalize transactions
  • + *
  • Idempotent replay detection
  • + *
+ * + * @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); + + /** + * 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 + * 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/ProposalSuccessor.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessor.java new file mode 100644 index 0000000..c1d9f7a --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessor.java @@ -0,0 +1,72 @@ +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.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +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"); + + // 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")) { + draft.put("proposal_status", "draft"); + } + + JsonNode terms = draft.get("commercial_terms"); + if (terms == null || !terms.isObject()) { + throw new IllegalArgumentException("commercial_terms object is required"); + } + 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"); + 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); + return draft; + } +} diff --git a/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/package-info.java b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/package-info.java new file mode 100644 index 0000000..5cbb620 --- /dev/null +++ b/adcp-server/src/main/java/org/adcontextprotocol/adcp/server/negotiation/package-info.java @@ -0,0 +1,7 @@ +/** + * Server-side handler registration and capability declaration for + * proposal refinement. Commercial decisions are delegated to + * application callbacks via {@link ProposalHandler}. + */ +@org.jspecify.annotations.NullMarked +package org.adcontextprotocol.adcp.server.negotiation; diff --git a/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/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 new file mode 100644 index 0000000..88663e7 --- /dev/null +++ b/adcp-server/src/test/java/org/adcontextprotocol/adcp/server/negotiation/ProposalSuccessorTest.java @@ -0,0 +1,73 @@ +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_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"); + + assertNotEquals("keep-this", draft.get("proposal_id").asText()); + assertNotEquals("parent-1", 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)); + } + + @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-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/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/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-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 new file mode 100644 index 0000000..8ed1b5c --- /dev/null +++ b/adcp-testing/src/main/java/org/adcontextprotocol/adcp/testing/negotiation/NegotiationFixtures.java @@ -0,0 +1,210 @@ +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 org.adcontextprotocol.adcp.negotiation.RefinementConstraints; +import org.adcontextprotocol.adcp.negotiation.TotalBudgetConstraint; + +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.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)); + + 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(); + } + + /** 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() { + 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)); + } + + /** 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 -- + + /** + * 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.putArray("proposals").add(proposal); + + ObjectNode response = MAPPER.createObjectNode(); + response.put("status", "completed"); + response.putArray("results").add(result); + response.putArray("products"); + + 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 reasonCode) { + ObjectNode result = MAPPER.createObjectNode(); + result.put("source_proposal_id", sourceProposalId); + result.put("outcome", "unable"); + 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"); + 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-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/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/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/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/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..d41cd19 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/CpmConstraint.java @@ -0,0 +1,26 @@ +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 || 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/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..46e02a6 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ImpressionsConstraint.java @@ -0,0 +1,23 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.math.BigDecimal; + +/** + * Minimum summed impressions constraint across all purchases. + * + * @param min minimum total impressions required + */ +public record ImpressionsConstraint( + @JsonProperty("min") BigDecimal min) { + + public ImpressionsConstraint { + 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 new file mode 100644 index 0000000..3a725a1 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ProposalRefinement.java @@ -0,0 +1,171 @@ +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.Map; +import java.util.Objects; + +/** + * A single refinement operation within a {@link RefineProposalsRequest}. + * + *

Maps to {@code proposal-refinement.json}. Use {@link #builder(String)} + * for multi-field requests; factory methods cover common single-concern cases. + * + * @param proposalId the source proposal to refine + * @param action revise or finalize + * @param changeKind amendment (default) or cancellation; only valid for accepted sources + * @param ask semantic commercial changes or cancellation reason + * @param criteria structured discovery changes; each present field replaces that criterion + * @param constraints typed hard requirements (budget, CPM, impressions, flight) + * @param productChanges product IDs mapped to include/omit actions + * @param alternatives alternatives request ({@code {"count": 2..10}}) + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ProposalRefinement( + @JsonProperty("proposal_id") String proposalId, + @Nullable @JsonProperty("action") RefinementAction action, + @Nullable @JsonProperty("change_kind") ChangeKind changeKind, + @Nullable @JsonProperty("ask") String ask, + @Nullable @JsonProperty("criteria") JsonNode criteria, + @Nullable @JsonProperty("constraints") RefinementConstraints constraints, + @Nullable @JsonProperty("product_changes") Map productChanges, + @Nullable @JsonProperty("alternatives") AlternativesRequest alternatives) { + + /** Protocol maximum for alternatives.count. */ + 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 (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"); + } + } + } + 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"); + } + } + + /** + * Creates a revise refinement with a semantic ask. + */ + public static ProposalRefinement revise(String proposalId, String ask) { + return new ProposalRefinement(proposalId, RefinementAction.REVISE, + null, ask, null, null, null, null); + } + + /** + * Creates a revise refinement with structured criteria. + */ + public static ProposalRefinement reviseWithCriteria(String proposalId, JsonNode criteria) { + return new ProposalRefinement(proposalId, RefinementAction.REVISE, + null, null, criteria, null, null, null); + } + + /** + * Creates a revise refinement with typed constraints. + */ + public static ProposalRefinement reviseWithConstraints( + String proposalId, RefinementConstraints constraints) { + return new ProposalRefinement(proposalId, RefinementAction.REVISE, + null, null, null, constraints, null, null); + } + + /** + * Creates a finalize refinement (no term changes, reserves inventory). + */ + public static ProposalRefinement finalize(String proposalId) { + return new ProposalRefinement(proposalId, RefinementAction.FINALIZE, + null, null, null, null, null, null); + } + + /** + * Creates a cancellation refinement against an accepted proposal. + */ + public static ProposalRefinement cancel(String proposalId, String reason) { + return new ProposalRefinement(proposalId, RefinementAction.REVISE, + ChangeKind.CANCELLATION, reason, null, null, null, null); + } + + public static Builder builder(String proposalId) { + return new Builder(proposalId); + } + + public static final class Builder { + private final String proposalId; + private @Nullable RefinementAction action; + private @Nullable ChangeKind changeKind; + private @Nullable String ask; + private @Nullable JsonNode criteria; + private @Nullable RefinementConstraints constraints; + private @Nullable Map productChanges; + private @Nullable AlternativesRequest 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(Map productChanges) { + this.productChanges = productChanges; + return this; + } + + 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 new file mode 100644 index 0000000..bee7580 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequest.java @@ -0,0 +1,260 @@ +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( + @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("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}$"); + + 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"); + } + 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 @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() {} + + 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; + } + + 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 || maxBatchSize > PROTOCOL_MAX_REFINEMENTS) { + throw new IllegalArgumentException( + "maxBatchSize must be 1-" + PROTOCOL_MAX_REFINEMENTS); + } + this.maxBatchSize = maxBatchSize; + return this; + } + + /** + * Sets the seller's alternatives ceiling (default 10 per protocol spec). + */ + public Builder maxAlternatives(int maxAlternatives) { + if (maxAlternatives < 2 || maxAlternatives > ProposalRefinement.MAX_ALTERNATIVES) { + throw new IllegalArgumentException("maxAlternatives must be 2-10"); + } + this.maxAlternatives = maxAlternatives; + return this; + } + + public RefineProposalsRequest build() { + validateBatch(); + return new RefineProposalsRequest( + adcpVersion, adcpMajorVersion, idempotencyKey, refinements, contextId, + context, governanceContext, pushNotificationConfig); + } + + private void validateBatch() { + validateBatchShape(refinements, maxBatchSize); + if (supportedDimensions != null) { + validateCapabilities(refinements, supportedDimensions, maxAlternatives); + } else { + validateAlternatives(refinements, maxAlternatives); + } + } + + 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; + } + } + + 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( + "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"); + } + } + + 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 new file mode 100644 index 0000000..b46991b --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsResponse.java @@ -0,0 +1,57 @@ +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 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( + @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("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. + */ + public boolean isCompleted() { + return "completed".equals(status) || (results != null && taskId == null); + } + + /** + * Whether this response was deferred for async processing. + */ + public boolean isAsync() { + return "submitted".equals(status); + } +} 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..6d63124 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementCapability.java @@ -0,0 +1,44 @@ +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.Objects; +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 maxAlternatives maximum alternatives.count the seller accepts (default: 10) + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record RefinementCapability( + @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 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() { + 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/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..69d4e3b --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/RefinementResult.java @@ -0,0 +1,132 @@ +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; +import java.util.Map; + +/** + * 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("proposals") List proposals, + @Nullable @JsonProperty("targeting_resolution") JsonNode targetingResolution + ) implements RefinementResult { + public Revised { + proposals = List.copyOf(proposals); + } + + @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("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; + } + } + + /** + * 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_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 + ) 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 new file mode 100644 index 0000000..dc8c578 --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifier.java @@ -0,0 +1,420 @@ +package org.adcontextprotocol.adcp.negotiation; + +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; + +/** Fail-closed verification for {@code refine_proposals} responses. */ +public final class ResponseVerifier { + + private ResponseVerifier() {} + + /** Verifies a response using the current time for committed-hold expiry. */ + public static List verify(RefineProposalsRequest request, + RefineProposalsResponse response) { + 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; + } + + List results = response.results(); + List refinements = request.refinements(); + if (results.size() != refinements.size()) { + violations.add("result count " + results.size() + + " does not match refinement count " + refinements.size()); + } + + 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); + ProposalRefinement refinement = refinements.get(i); + String prefix = "result[" + i + "] "; + + 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"); + } + + 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; + } + + 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)); + } + case RefinementResult.Partial partial -> { + if (proposals.isEmpty()) { + violations.add(prefix + "partial is missing proposals"); + } + if (proposals.size() > expected) { + violations.add(prefix + "partial returned more proposals than requested"); + } + 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 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"); + } + } + } + } + + 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); + } + } + 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"); + } + } + + 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 (!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 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); + } + } + 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); + } + }); + } + } + + 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"); + } + } + + 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; + } + 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 verifyImpressions(JsonNode terms, ImpressionsConstraint constraint, + String prefix, List violations) { + JsonNode purchases = terms.get("purchases"); + 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"); + } + } + + 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"); + } + } + + 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; + } + + 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(); + }; + } + + 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 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 ProposalRefinementReason reasonCode(RefinementResult result) { + return switch (result) { + 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 new file mode 100644 index 0000000..6613a9e --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TermsDigest.java @@ -0,0 +1,89 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.databind.JsonNode; +import org.jspecify.annotations.Nullable; +import org.erdtman.jcs.JsonCanonicalizer; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; + +/** + * 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 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 { + return new JsonCanonicalizer(node.toString()).getEncodedUTF8(); + } catch (IOException e) { + throw new IllegalArgumentException("failed to canonicalize JSON", e); + } + } + + 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/TotalBudgetConstraint.java b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TotalBudgetConstraint.java new file mode 100644 index 0000000..93d516f --- /dev/null +++ b/adcp/src/main/java/org/adcontextprotocol/adcp/negotiation/TotalBudgetConstraint.java @@ -0,0 +1,37 @@ +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 && 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/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/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/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..c0e7949 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ConstraintsTest.java @@ -0,0 +1,178 @@ +package org.adcontextprotocol.adcp.negotiation; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import static org.junit.jupiter.api.Assertions.*; + +class ConstraintsTest { + + private final ObjectMapper mapper = new ObjectMapper() + .findAndRegisterModules(); + + @Test + void cpm_constraint_requires_max_and_currency() { + var cpm = new CpmConstraint(new BigDecimal("12.50"), "USD"); + assertEquals(new BigDecimal("12.50"), cpm.max()); + assertEquals("USD", cpm.currency()); + } + + @Test + void cpm_constraint_rejects_null_max() { + assertThrows(IllegalArgumentException.class, + () -> new CpmConstraint(null, "USD")); + } + + @Test + void cpm_constraint_rejects_blank_currency() { + assertThrows(IllegalArgumentException.class, + () -> new CpmConstraint(BigDecimal.TEN, "")); + } + + @Test + void cpm_constraint_round_trips_via_jackson() throws Exception { + var cpm = new CpmConstraint(new BigDecimal("8.25"), "EUR"); + String json = mapper.writeValueAsString(cpm); + var back = mapper.readValue(json, CpmConstraint.class); + + assertEquals(cpm.max().compareTo(back.max()), 0); + assertEquals(cpm.currency(), back.currency()); + } + + @Test + void impressions_constraint_requires_non_negative_min() { + var ic = new ImpressionsConstraint(100_000); + assertEquals(new BigDecimal("100000"), ic.min()); + } + + @Test + void impressions_constraint_rejects_negative() { + assertThrows(IllegalArgumentException.class, + () -> new ImpressionsConstraint(-1)); + } + + @Test + void impressions_constraint_round_trips() throws Exception { + var ic = new ImpressionsConstraint(500_000); + String json = mapper.writeValueAsString(ic); + var back = mapper.readValue(json, ImpressionsConstraint.class); + + assertEquals(ic.min(), back.min()); + } + + @Test + void flight_constraint_requires_at_least_one_bound() { + assertThrows(IllegalArgumentException.class, + () -> new FlightConstraint(null, null)); + } + + @Test + void flight_constraint_accepts_start_only() { + var start = OffsetDateTime.of(2026, 10, 1, 0, 0, 0, 0, ZoneOffset.UTC); + var fc = new FlightConstraint(start, null); + assertEquals(start, fc.startNoLaterThan()); + assertNull(fc.endNoEarlierThan()); + } + + @Test + void flight_constraint_accepts_both_bounds() { + var start = OffsetDateTime.of(2026, 10, 1, 0, 0, 0, 0, ZoneOffset.UTC); + var end = OffsetDateTime.of(2026, 12, 31, 23, 59, 59, 0, ZoneOffset.UTC); + var fc = new FlightConstraint(start, end); + + assertEquals(start, fc.startNoLaterThan()); + assertEquals(end, fc.endNoEarlierThan()); + } + + @Test + void flight_constraint_round_trips() throws Exception { + var start = OffsetDateTime.of(2026, 10, 1, 0, 0, 0, 0, ZoneOffset.UTC); + var fc = new FlightConstraint(start, null); + String json = mapper.writeValueAsString(fc); + var back = mapper.readValue(json, FlightConstraint.class); + + assertEquals(fc.startNoLaterThan(), back.startNoLaterThan()); + } + + @Test + void budget_constraint_requires_currency() { + assertThrows(IllegalArgumentException.class, + () -> new TotalBudgetConstraint(BigDecimal.TEN, null, null)); + } + + @Test + void budget_constraint_requires_at_least_one_bound() { + assertThrows(IllegalArgumentException.class, + () -> new TotalBudgetConstraint(null, null, "USD")); + } + + @Test + void budget_constraint_rejects_min_exceeding_max() { + assertThrows(IllegalArgumentException.class, + () -> new TotalBudgetConstraint( + new BigDecimal("10000"), new BigDecimal("5000"), "USD")); + } + + @Test + void budget_constraint_accepts_valid_range() { + var bc = new TotalBudgetConstraint( + new BigDecimal("5000"), new BigDecimal("10000"), "USD"); + assertEquals(new BigDecimal("5000"), bc.min()); + assertEquals(new BigDecimal("10000"), bc.max()); + assertEquals("USD", bc.currency()); + } + + @Test + void budget_constraint_accepts_max_only() { + var bc = new TotalBudgetConstraint(null, new BigDecimal("50000"), "EUR"); + assertNull(bc.min()); + assertEquals(new BigDecimal("50000"), bc.max()); + } + + @Test + void budget_constraint_round_trips() throws Exception { + var bc = new TotalBudgetConstraint( + new BigDecimal("1000"), new BigDecimal("5000"), "GBP"); + String json = mapper.writeValueAsString(bc); + var back = mapper.readValue(json, TotalBudgetConstraint.class); + + assertEquals(0, bc.min().compareTo(back.min())); + assertEquals(0, bc.max().compareTo(back.max())); + assertEquals(bc.currency(), back.currency()); + } + + @Test + void refinement_constraints_requires_at_least_one() { + assertThrows(IllegalArgumentException.class, + () -> new RefinementConstraints(null, null, null, null)); + } + + @Test + void refinement_constraints_accepts_single_dimension() { + var rc = new RefinementConstraints( + new TotalBudgetConstraint(null, new BigDecimal("10000"), "USD"), + null, null, null); + assertNotNull(rc.totalBudget()); + assertNull(rc.cpm()); + } + + @Test + void refinement_constraints_round_trips() throws Exception { + var rc = new RefinementConstraints( + new TotalBudgetConstraint(new BigDecimal("1000"), null, "USD"), + new CpmConstraint(new BigDecimal("8.50"), "USD"), + new ImpressionsConstraint(100_000), + null); + String json = mapper.writeValueAsString(rc); + var back = mapper.readValue(json, RefinementConstraints.class); + + assertNotNull(back.totalBudget()); + assertNotNull(back.cpm()); + assertNotNull(back.impressions()); + assertNull(back.flight()); + } +} diff --git a/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java new file mode 100644 index 0000000..cfb9a90 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefineProposalsRequestTest.java @@ -0,0 +1,248 @@ +package org.adcontextprotocol.adcp.negotiation; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +class RefineProposalsRequestTest { + + private static String validKey() { + return "idem-" + UUID.randomUUID().toString().replace("-", ""); + } + + @Test + void builder_creates_valid_single_revise_request() { + var request = RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .addRefinement(ProposalRefinement.revise("p-1", "lower CPM to $8")) + .build(); + + assertEquals(1, request.refinements().size()); + assertEquals("p-1", request.refinements().get(0).proposalId()); + assertEquals(RefinementAction.REVISE, request.refinements().get(0).action()); + } + + @Test + void builder_creates_batch_finalize_request() { + var request = RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .addRefinement(ProposalRefinement.finalize("p-1")) + .addRefinement(ProposalRefinement.finalize("p-2")) + .addRefinement(ProposalRefinement.finalize("p-3")) + .build(); + + assertEquals(3, request.refinements().size()); + request.refinements().forEach(r -> + assertEquals(RefinementAction.FINALIZE, r.action())); + } + + @Test + void rejects_null_idempotency_key() { + var builder = RefineProposalsRequest.builder() + .addRefinement(ProposalRefinement.revise("p-1", "test")); + + assertThrows(NullPointerException.class, builder::build); + } + + @Test + void rejects_short_idempotency_key() { + assertThrows(IllegalArgumentException.class, () -> + RefineProposalsRequest.builder() + .idempotencyKey("too-short") + .addRefinement(ProposalRefinement.revise("p-1", "test")) + .build()); + } + + @Test + void rejects_empty_refinements() { + assertThrows(IllegalArgumentException.class, () -> + RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .build()); + } + + @Test + void rejects_mixed_finalize_and_revise_batch() { + assertThrows(IllegalArgumentException.class, () -> + RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .addRefinement(ProposalRefinement.finalize("p-1")) + .addRefinement(ProposalRefinement.revise("p-2", "change CPM")) + .build()); + } + + @Test + void rejects_duplicate_proposal_ids() { + assertThrows(IllegalArgumentException.class, () -> + RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .addRefinement(ProposalRefinement.revise("p-1", "first")) + .addRefinement(ProposalRefinement.revise("p-1", "second")) + .build()); + } + + @Test + void rejects_batch_exceeding_max_size() { + var builder = RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .maxBatchSize(2); + + builder.addRefinement(ProposalRefinement.revise("p-1", "a")); + builder.addRefinement(ProposalRefinement.revise("p-2", "b")); + builder.addRefinement(ProposalRefinement.revise("p-3", "c")); + + assertThrows(IllegalArgumentException.class, builder::build); + } + + @Test + void refinements_list_is_immutable() { + var request = RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .addRefinement(ProposalRefinement.revise("p-1", "test")) + .build(); + + assertThrows(UnsupportedOperationException.class, () -> + request.refinements().add(ProposalRefinement.revise("p-2", "x"))); + } + + @Test + void cancellation_refinement() { + var request = RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .addRefinement(ProposalRefinement.cancel("p-1", "budget reallocated")) + .build(); + + var refinement = request.refinements().get(0); + assertEquals(RefinementAction.REVISE, refinement.action()); + assertEquals(ChangeKind.CANCELLATION, refinement.changeKind()); + } + + @Test + void rejects_alternatives_count_exceeding_protocol_max() { + assertThrows(IllegalArgumentException.class, () -> + ProposalRefinement.builder("p-1") + .action(RefinementAction.REVISE) + .ask("give me options") + .alternatives(11) + .build()); + } + + @Test + void rejects_alternatives_count_below_minimum() { + assertThrows(IllegalArgumentException.class, () -> + ProposalRefinement.builder("p-1") + .action(RefinementAction.REVISE) + .alternatives(1) + .build()); + } + + @Test + void accepts_valid_alternatives_count() { + var refinement = ProposalRefinement.builder("p-1") + .action(RefinementAction.REVISE) + .ask("five options") + .alternatives(5) + .build(); + + assertEquals(5, refinement.alternatives().count()); + } + + @Test + void rejects_alternatives_exceeding_seller_ceiling() { + assertThrows(IllegalArgumentException.class, () -> + RefineProposalsRequest.builder() + .idempotencyKey(validKey()) + .maxAlternatives(3) + .addRefinement(ProposalRefinement.builder("p-1") + .action(RefinementAction.REVISE) + .ask("options") + .alternatives(5) + .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 constraints = new RefinementConstraints( + new TotalBudgetConstraint(null, new BigDecimal("50000"), "EUR"), + new CpmConstraint(new BigDecimal("12.50"), "EUR"), + null, null); + var refinement = ProposalRefinement.builder("p-1") + .action(RefinementAction.REVISE) + .ask("lower CPM with alternatives") + .constraints(constraints) + .alternatives(3) + .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 com.fasterxml.jackson.databind.ObjectMapper(); + String json = """ + { + "status": "completed", + "replayed": true, + "results": [{ + "source_proposal_id": "src-1", + "outcome": "revised", + "proposals": [{"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()); + } + + @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 new file mode 100644 index 0000000..891ea97 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/RefinementResultTest.java @@ -0,0 +1,74 @@ +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.Map; + +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()); + assertEquals(4, RefinementResult.class.getPermittedSubclasses().length); + } + + @Test + void revised_round_trips_plural_proposals() throws Exception { + String json = """ + {"source_proposal_id":"p-1","outcome":"revised","proposals":[ + {"proposal_id":"p-new","proposal_status":"draft"}]} + """; + RefinementResult result = mapper.readValue(json, RefinementResult.class); + var revised = assertInstanceOf(RefinementResult.Revised.class, result); + assertEquals("p-new", revised.proposals().getFirst().get("proposal_id").asText()); + assertEquals(RefinementOutcome.REVISED, revised.outcome()); + } + + @Test + void partial_round_trips_machine_readable_failures() throws Exception { + String json = """ + {"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"}} + """; + 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 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().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\"")); + 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 new file mode 100644 index 0000000..5b1cff4 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/ResponseVerifierTest.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.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.*; + +class ResponseVerifierTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + 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 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(); + 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); + assertContains(violations, "result count"); + assertContains(violations, "request order"); + assertContains(violations, "fresh proposal_id"); + assertContains(violations, "parent_proposal_id"); + assertContains(violations, "terms_digest"); + } + + @Test + 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"); + } + + @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 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(); + 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 finalize_is_atomic_committed_and_expiry_aware() { + var request = RefineProposalsRequest.builder().idempotencyKey(key()) + .addRefinement(ProposalRefinement.finalize("src-1")) + .addRefinement(ProposalRefinement.finalize("src-2")) + .build(); + 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 fully_rolled_back_finalize_failure_is_valid() { + var request = RefineProposalsRequest.builder().idempotencyKey(key()) + .addRefinement(ProposalRefinement.finalize("src-1")) + .addRefinement(ProposalRefinement.finalize("src-2")) + .build(); + 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 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)); + } + + private RefineProposalsRequest request(ProposalRefinement refinement) { + return RefineProposalsRequest.builder().idempotencyKey(key()) + .addRefinement(refinement).build(); + } + + private RefineProposalsResponse response(RefinementResult... results) { + return new RefineProposalsResponse(List.of(results), List.of(), "completed", + null, null, null, null, null, null, null); + } + + 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); + proposal.put("terms_digest", TermsDigest.compute(terms)); + return proposal; + } + + private static String key() { + return "idem-" + UUID.randomUUID().toString().replace("-", ""); + } + + 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 new file mode 100644 index 0000000..4784105 --- /dev/null +++ b/adcp/src/test/java/org/adcontextprotocol/adcp/negotiation/TermsDigestTest.java @@ -0,0 +1,53 @@ +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 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 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 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 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" } 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"