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,64 @@
package org.adcontextprotocol.adcp.server.negotiation;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.adcontextprotocol.adcp.negotiation.TermsDigest;

import java.util.Objects;
import java.util.UUID;

/**
* Creates immutable successor proposals with correct lineage and digest.
*
* <p>Every proposal produced by {@code refine_proposals} must carry
* {@code parent_proposal_id} equal to the request's source, a fresh
* {@code proposal_id}, and a {@code terms_digest} matching its
* {@code commercial_terms}. This utility enforces those invariants.
*/
public final class ProposalSuccessor {

private ProposalSuccessor() {}

/**
* Stamps a draft proposal node with immutable lineage fields.
* Sets {@code proposal_id}, {@code parent_proposal_id}, {@code proposal_status},
* and recomputes {@code terms_digest} from {@code commercial_terms}.
*
* @param draft mutable proposal node to stamp
* @param sourceProposalId the source proposal this was forked from
* @return the same node, mutated, for chaining
*/
public static ObjectNode stamp(ObjectNode draft, String sourceProposalId) {
Objects.requireNonNull(draft, "draft is required");
Objects.requireNonNull(sourceProposalId, "sourceProposalId is required");

if (!draft.has("proposal_id") || draft.get("proposal_id").isNull()) {
draft.put("proposal_id", UUID.randomUUID().toString());
}
draft.put("parent_proposal_id", sourceProposalId);

if (!draft.has("proposal_status")) {
draft.put("proposal_status", "draft");
}

JsonNode terms = draft.get("commercial_terms");
if (terms != null && !terms.isNull()) {
draft.put("terms_digest", TermsDigest.compute(terms));
}

return draft;
}

/**
* Stamps a committed (finalized) proposal. Sets status to "committed"
* and requires {@code expires_at}.
*/
public static ObjectNode stampFinalized(ObjectNode draft, String sourceProposalId,
String expiresAt) {
Objects.requireNonNull(expiresAt, "expiresAt is required for finalized proposals");
stamp(draft, sourceProposalId);
draft.put("proposal_status", "committed");
draft.put("expires_at", expiresAt);
return draft;
}
}
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,57 @@
package org.adcontextprotocol.adcp.server.negotiation;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.adcontextprotocol.adcp.negotiation.TermsDigest;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class ProposalSuccessorTest {

private final ObjectMapper mapper = new ObjectMapper();

@Test
void stamp_sets_lineage_and_digest() {
ObjectNode terms = mapper.createObjectNode().put("price", 42);
ObjectNode draft = mapper.createObjectNode();
draft.set("commercial_terms", terms);

ProposalSuccessor.stamp(draft, "parent-123");

assertEquals("parent-123", draft.get("parent_proposal_id").asText());
assertEquals("draft", draft.get("proposal_status").asText());
assertNotNull(draft.get("proposal_id"));
assertTrue(TermsDigest.verify(draft.get("terms_digest").asText(), terms));
}

@Test
void stamp_preserves_existing_proposal_id() {
ObjectNode draft = mapper.createObjectNode();
draft.put("proposal_id", "keep-this");

ProposalSuccessor.stamp(draft, "parent-1");

assertEquals("keep-this", draft.get("proposal_id").asText());
}

@Test
void stamp_finalized_sets_committed_status_and_expiry() {
ObjectNode terms = mapper.createObjectNode().put("total", 10000);
ObjectNode draft = mapper.createObjectNode();
draft.set("commercial_terms", terms);

ProposalSuccessor.stampFinalized(draft, "src-1", "2026-12-31T23:59:59Z");

assertEquals("committed", draft.get("proposal_status").asText());
assertEquals("2026-12-31T23:59:59Z", draft.get("expires_at").asText());
assertEquals("src-1", draft.get("parent_proposal_id").asText());
}

@Test
void rejects_null_source() {
ObjectNode draft = mapper.createObjectNode();
assertThrows(NullPointerException.class,
() -> ProposalSuccessor.stamp(draft, null));
}
}
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;
Loading
Loading