Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/negotiation-proposal-apis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"adcp": minor
"adcp-server": minor
"adcp-testing": minor
---

feat(negotiation): add first-class buyer and seller proposal APIs for AdCP 3.2

Introduces the `negotiation` package with sealed outcome models, capability-aware
request builders, terms digest verification (RFC 8785 JCS), response verification
utilities, and server-side handler interface for `refine_proposals`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package org.adcontextprotocol.adcp.server.negotiation;

import org.adcontextprotocol.adcp.negotiation.ProposalRefinement;
import org.adcontextprotocol.adcp.negotiation.RefinementCapability;
import org.adcontextprotocol.adcp.negotiation.RefinementResult;
import org.adcontextprotocol.adcp.server.AdcpContext;
import org.jspecify.annotations.Nullable;

import java.util.List;

/**
* Server-side handler for proposal refinement operations.
*
* <p>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.
*
* <p>Example:
* <pre>{@code
* public class MyProposalHandler implements ProposalHandler {
* @Override
* public RefinementCapability capability() {
* return new RefinementCapability(
* Set.of("product_changes", "total_budget"),
* 10, true);
* }
*
* @Override
* public List<RefinementResult> refine(
* List<ProposalRefinement> refinements,
* String idempotencyKey, AdcpContext ctx) {
* // commercial logic here
* }
* }
* }</pre>
*/
public interface ProposalHandler {

/**
* Declares this seller's refinement capabilities.
*
* <p>The returned capability is used for:
* <ul>
* <li>Advertising supported dimensions to buyers</li>
* <li>Preflight validation of incoming requests</li>
* <li>Capability gating in the server builder</li>
* </ul>
*/
RefinementCapability capability();

/**
* Handles a batch of refinement operations.
*
* <p>The framework has already validated:
* <ul>
* <li>Idempotency key format</li>
* <li>Batch size within the declared ceiling</li>
* <li>Finalize-only batch homogeneity</li>
* <li>Unique proposal IDs within the batch</li>
* </ul>
*
* <p>The handler is responsible for:
* <ul>
* <li>Loading and validating source proposals</li>
* <li>Creating immutable successor proposals</li>
* <li>Computing digest/lineage fields</li>
* <li>Atomic finalize transactions</li>
* <li>Idempotent replay detection</li>
* </ul>
*
* @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<RefinementResult> refine(List<ProposalRefinement> refinements,
String idempotencyKey, AdcpContext ctx);

/**
* Optional hook called before the batch is dispatched to
* {@link #refine}. Returns null to proceed, or an error
* message to reject the batch.
*
* <p>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<ProposalRefinement> refinements,
String idempotencyKey, AdcpContext ctx) {
return null;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package org.adcontextprotocol.adcp.testing.negotiation;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.adcontextprotocol.adcp.negotiation.CpmConstraint;
import org.adcontextprotocol.adcp.negotiation.FlightConstraint;
import org.adcontextprotocol.adcp.negotiation.ImpressionsConstraint;
import org.adcontextprotocol.adcp.negotiation.ProposalRefinement;
import org.adcontextprotocol.adcp.negotiation.RefineProposalsRequest;
import org.adcontextprotocol.adcp.negotiation.TermsDigest;

import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;

/**
* Shared test fixtures for proposal negotiation tests.
*
* <p>Provides pre-built request/response objects for common scenarios:
* single revise, batch finalize, partial outcomes, mixed-batch rejection,
* and constraint variations.
*/
public final class NegotiationFixtures {

private static final ObjectMapper MAPPER = new ObjectMapper();

private NegotiationFixtures() {}

public static String randomIdempotencyKey() {
return "idem-" + UUID.randomUUID().toString().replace("-", "");
}

// -- Proposals --

public static ObjectNode draftProposal(String proposalId, String parentProposalId) {
ObjectNode proposal = MAPPER.createObjectNode();
proposal.put("proposal_id", proposalId);
proposal.put("parent_proposal_id", parentProposalId);
proposal.put("proposal_status", "draft");
proposal.put("name", "Test Plan " + proposalId);

ObjectNode terms = MAPPER.createObjectNode();
terms.put("total_budget", 50000);
terms.put("currency", "USD");
proposal.set("commercial_terms", terms);
proposal.put("terms_digest", TermsDigest.compute(terms));

proposal.putArray("allocations").addObject()
.put("product_id", "prod-1")
.put("allocation_percentage", 100);
return proposal;
}

public static ObjectNode committedProposal(String proposalId,
String parentProposalId) {
ObjectNode proposal = draftProposal(proposalId, parentProposalId);
proposal.put("proposal_status", "committed");
proposal.put("expires_at",
OffsetDateTime.now(ZoneOffset.UTC).plusHours(24).toString());
return proposal;
}

// -- Requests --

public static RefineProposalsRequest singleReviseRequest(String proposalId) {
return RefineProposalsRequest.builder()
.idempotencyKey(randomIdempotencyKey())
.addRefinement(ProposalRefinement.revise(
proposalId, "Lower CPM to $8 and extend flight by 2 weeks"))
.build();
}

public static RefineProposalsRequest batchFinalizeRequest(List<String> proposalIds) {
var builder = RefineProposalsRequest.builder()
.idempotencyKey(randomIdempotencyKey());
for (String id : proposalIds) {
builder.addRefinement(ProposalRefinement.finalize(id));
}
return builder.build();
}

// -- Constraints --

public static CpmConstraint standardCpmCeiling() {
return new CpmConstraint(new BigDecimal("12.50"), "USD");
}

public static ImpressionsConstraint minimumImpressions() {
return new ImpressionsConstraint(100_000);
}

public static FlightConstraint q4Flight() {
return new FlightConstraint(
OffsetDateTime.of(2026, 10, 1, 0, 0, 0, 0, ZoneOffset.UTC),
OffsetDateTime.of(2026, 12, 31, 23, 59, 59, 0, ZoneOffset.UTC));
}

// -- Response fragments --

/**
* Builds a JSON string for a completed refine_proposals response
* with a single revised result.
*/
public static String revisedResponseJson(String sourceProposalId,
String newProposalId) {
ObjectNode proposal = draftProposal(newProposalId, sourceProposalId);

ObjectNode result = MAPPER.createObjectNode();
result.put("source_proposal_id", sourceProposalId);
result.put("outcome", "revised");
result.set("proposal", proposal);

ObjectNode response = MAPPER.createObjectNode();
response.put("status", "completed");
response.putArray("results").add(result);
response.putArray("products");

return response.toString();
}

/**
* Builds a JSON string for an "unable" result with a given reason.
*/
public static String unableResponseJson(String sourceProposalId, String reason) {
ObjectNode result = MAPPER.createObjectNode();
result.put("source_proposal_id", sourceProposalId);
result.put("outcome", "unable");
result.put("reason", reason);

ObjectNode response = MAPPER.createObjectNode();
response.put("status", "completed");
response.putArray("results").add(result);
response.putArray("products");

return response.toString();
}
}
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 16 additions & 0 deletions adcp/src/main/java/org/adcontextprotocol/adcp/AdcpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -129,6 +131,20 @@ public <T> 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. */
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.adcontextprotocol.adcp.negotiation;

import com.fasterxml.jackson.annotation.JsonProperty;

import java.math.BigDecimal;

/**
* CPM ceiling constraint: every purchase must be priced at fixed CPM/vCPM
* in the given currency at or under max.
*
* @param max maximum CPM value
* @param currency ISO 4217 currency code
*/
public record CpmConstraint(
@JsonProperty("max") BigDecimal max,
@JsonProperty("currency") String currency) {

public CpmConstraint {
if (max == null) throw new IllegalArgumentException("cpm max is required");
if (currency == null || currency.isBlank()) {
throw new IllegalArgumentException("cpm currency is required");
}
}
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.adcontextprotocol.adcp.negotiation;

import com.fasterxml.jackson.annotation.JsonProperty;

/**
* Minimum summed impressions constraint across all purchases.
*
* @param min minimum total impressions required
*/
public record ImpressionsConstraint(
@JsonProperty("min") long min) {

public ImpressionsConstraint {
if (min < 0) throw new IllegalArgumentException("impressions min must be non-negative");
}
}
Loading
Loading