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
143 changes: 143 additions & 0 deletions adcp/v3/legacypurchase/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# adcp/v3/legacypurchase

Durable coordinator for redeeming a deprecated AdCP 3.2 `products_available`
`legacy_create` purchase continuation — the Go SDK-local equivalent of the
protocol's `continueLegacyPurchase(CompatibilityPurchaseCoordinatorInput)`.

Spec: [`specs/legacy-compact-lifecycle-compatibility.md`](https://github.com/adcontextprotocol/adcp/blob/main/specs/legacy-compact-lifecycle-compatibility.md)
("Products-only brief compatibility" / `legacy_create`), added by
[adcp#6733](https://github.com/adcontextprotocol/adcp/pull/6733) and shipped
in the `3.2.0-beta.9` schema bundle this SDK pins at
[`adcp/v3/schemas/VERSION`](../schemas/VERSION). Tracks issue
[adcp-go#466](https://github.com/adcontextprotocol/adcp-go/issues/466).

## What this is for

AdCP 3.2 splits legacy `get_products`-with-a-brief compound purchase flows
into compact lifecycle tasks. When an application's compatibility
projection layer can only offer the caller a `products_available` outcome
with a `legacy_create` continuation (no truthful account-scoped feed/pricing
fence is available), that continuation is single-use, principal- and
account-bound, and short-lived. This package is the durable coordinator
that:

- records the continuation's bound facts the moment it is offered
(`Store.RegisterContinuation`);
- validates a later redemption attempt against every one of those facts,
atomically claims the continuation exactly once, invokes your legacy
`create_media_buy` call, and durably records the terminal outcome so an
exact `idempotency_key` retry returns the same result instead of buying
twice (`Store.ContinueLegacyPurchase`).

```go
store := legacypurchase.New(legacypurchase.Options{
Backend: legacypurchase.NewMemoryBackend(time.Minute, 24*time.Hour),
})

// At the moment your compatibility projection decides to offer
// products_available with a legacy_create continuation:
err := store.RegisterContinuation(ctx, &legacypurchase.Continuation{
Token: continuationToken,
Principal: authenticatedPrincipalID,
Account: account,
SourceADCPVersion: "3.0",
ExpiresAt: time.Now().Add(5 * time.Minute),
ProductIDs: []string{"seller-product-1"},
Losses: []string{legacypurchase.LossFeedVersionNotAtomic, legacypurchase.LossPricingVersionNotAtomic},
ObservedPayload: observedProductsJSON, // the compact_projection.products payload actually returned
})

// Later, when the caller redeems it:
ctx = idempotency.WithPrincipal(ctx, authenticatedPrincipalID)
result, err := store.ContinueLegacyPurchase(ctx, input, func(ctx context.Context, legacyReq json.RawMessage) ([]byte, error) {
return callLegacySellerCreateMediaBuy(ctx, legacyReq) // exactly-once
})
```

## Scope

Implemented and tested:

- `Store.RegisterContinuation` / `Store.ContinueLegacyPurchase` — the
coordinator API.
- `Backend` — the pluggable durable-store interface.
- `MemoryBackend` — a fully concurrency-safe in-process reference
implementation.
- Every binding check the spec states: principal, account (including the
`legacy_create_request`-carried account field cross-check, with AdCP
2.5's no-wire-account-field carve-out), expiry, exact loss-set
acceptance, selected-product-ID subset-and-equality against the
request's explicit packages.
- Atomic single-use claim, proven under `-race` with concurrent distinct
`idempotency_key`s racing the same token (`store_race_test.go`).
- Deterministic replay: an exact retry (same `idempotency_key`, same
payload) after success or terminal failure returns the recorded result,
never a fresh `Executor` call.
- Fail-closed crash reconciliation: a claim left `StatePending` past
`Options.PendingLeaseTimeout` returns `AmbiguousClaimError` with recovery
guidance rather than being silently retried or silently expiring.
- The `products-only-brief-compatibility` vectors from the AdCP 3.2 schema
bundle (`testdata/products-only-brief-compatibility/`, see its
`PROVENANCE.md`), run end to end in `vectors_test.go`, plus the negative
cases (product substitution, package-selection drift, incomplete/stale
loss consent, wrong account, expiry) the vector bundle's own README
documents as SDK-suite-constructed.

Deliberately deferred — disclosed, not silently dropped:

1. **A persistent (e.g. Postgres) `Backend`.** This package ships the
interface and `MemoryBackend` only, matching how
[`adcp/v3/idempotency`](../idempotency)'s Postgres adapter and
[`adcp/v3/signing/pgreplay`](../signing) shipped as their own follow-on
work. Tracked in
[adcp-go#482](https://github.com/adcontextprotocol/adcp-go/issues/482).
2. **Full per-source-version (2.5/3.0/3.1) `create_media_buy` request
schema validation.** This package enforces the structural rules tied
directly to atomicity and single-use-claim safety (explicit-package
mode, exact package-product-ID match, the account cross-check), not a
complete replica of each legacy version's request schema — `adcp/v3` is
an AdCP 3.x-only module and does not vendor those schemas. **Your
`Executor` remains responsible for full legacy-request validation**
before or as part of calling the real legacy seller.
3. **The reverse compact-seller → legacy-buyer server-side facade** (the
spec's "Established buyers against a compact-backed seller" section,
and `vectors.json`'s `reverse_compatibility_cases`). Materially separate
scope from this buyer-side coordinator — tracked in the same
[adcp-go#482](https://github.com/adcontextprotocol/adcp-go/issues/482).

Not a gap: `listed_purchase` continuations pass seller-issued,
account-scoped feed/pricing values straight into ordinary `buy_products`
per the spec — there is no durable continuation state for this coordinator
to claim on that path.

## Migration guidance — what remains application-owned

- **Minting the continuation.** This package does not decide *when* to
offer `products_available`/`legacy_create` versus a native
`request_proposals` result, or compute `ObservedPayload` — that is your
compatibility-projection logic, per the spec's classification rules.
`RegisterContinuation` only requires the result to be complete
(non-empty `ObservedPayload`, both required atomic-fence losses present).
- **Legacy request construction and validation.** Building
`legacy_create_request` for the caller's selected products, and
validating it in full against the source version's actual
`create_media_buy` schema, stays application-owned (see deferred item 2
above). This package only enforces explicit-package mode and product-ID
agreement before calling your `Executor`.
- **The actual legacy seller call.** `Executor` is where you place the real
HTTP/MCP call to the legacy seller (or your own legacy facade). This
package guarantees it runs at most once per claimed continuation; it does
not implement retries, timeouts, or transport concerns for that call —
bring your own `http.Client` / MCP client conventions.
- **Crash reconciliation.** `AmbiguousClaimError` tells you a claim's
outcome is unknown; *how* to reconcile it (e.g. querying the legacy
seller's own idempotent `get_media_buys` for a buyer-ref/idempotency
marker your `Executor` embedded) is necessarily seller-specific and stays
application-owned. Embed a stable, discoverable marker in your
`legacy_create_request` (e.g. `buyer_ref`) specifically so this
reconciliation is possible.
- **Backend durability and retention.** `MemoryBackend` is process-local —
a restart loses in-flight and recent continuation state. Production
deployments needing cross-restart or cross-instance durability need a
persistent `Backend` (deferred item 1 above) or their own implementation
of the `Backend` interface.
54 changes: 54 additions & 0 deletions adcp/v3/legacypurchase/backend.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package legacypurchase

import (
"context"
"time"
)

// Backend is the pluggable durable-store surface for legacy_create
// continuation coordination. Implementations MUST be safe for concurrent
// use, and MUST implement PutContinuation, ClaimPending, CompletePending,
// and FailPending as atomic operations against their storage engine (e.g. a
// unique constraint on Token plus an `UPDATE ... WHERE token = $1 AND
// state = 'offered'` for ClaimPending, mirroring
// adcp/v3/idempotency.Backend's PutIfAbsent contract).
//
// This is a three-state claim/commit/fail FSM rather than idempotency's
// single PutIfAbsent because redeeming a legacy_create continuation is a
// two-phase operation: claim the token, then call an external legacy
// seller whose outcome is not known at claim time. The atomic-claim and
// record-outcome steps cannot be the same write — a crash between them
// must be observable as StatePending, not silently lost (double-purchase
// risk) or silently fabricated as a result.
type Backend interface {
// PutContinuation durably records a newly offered continuation.
// Returns *DuplicateTokenError if rec.Token already exists.
PutContinuation(ctx context.Context, rec *ContinuationRecord) error

// GetContinuation returns the current record for token, or (nil, nil)
// on miss.
GetContinuation(ctx context.Context, token string) (*ContinuationRecord, error)

// ClaimPending atomically transitions token from StateOffered to
// StatePending, recording claimantKey (the idempotency_key redeeming
// it) and requestHash (a canonical hash of the full redemption input,
// for retry-conflict detection). claimed=false means the token was not
// in StateOffered at the time of the attempt — it does not exist, or
// it has already been claimed by this or another idempotency_key. The
// returned rec is the current record either way, so the caller can
// proceed without a second round trip.
ClaimPending(ctx context.Context, token, claimantKey, requestHash string, claimedAt time.Time) (rec *ContinuationRecord, claimed bool, err error)

// CompletePending atomically transitions a StatePending record claimed
// by claimantKey to StateCommitted, storing result. ok=false if the
// record is not currently StatePending and claimed by claimantKey
// (defensive — should not happen absent a caller bug or a second
// coordinator instance racing on the same token, which ClaimPending's
// atomicity already prevents).
CompletePending(ctx context.Context, token, claimantKey string, result []byte, completedAt time.Time) (rec *ContinuationRecord, ok bool, err error)

// FailPending is CompletePending's terminal-failure counterpart:
// StatePending -> StateFailed, recording errCode/errMessage as
// recovery guidance for the caller.
FailPending(ctx context.Context, token, claimantKey, errCode, errMessage string, failedAt time.Time) (rec *ContinuationRecord, ok bool, err error)
}
99 changes: 99 additions & 0 deletions adcp/v3/legacypurchase/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Package legacypurchase implements the SDK-local durable coordinator for
// redeeming a deprecated AdCP 3.2 "products_available" legacy_create
// purchase continuation — the Go SDK-local equivalent of the protocol
// contract's continueLegacyPurchase(CompatibilityPurchaseCoordinatorInput).
//
// # Source of the contract
//
// This package implements the normative rules in
// specs/legacy-compact-lifecycle-compatibility.md ("Products-only brief
// compatibility" / "legacy_create") from adcontextprotocol/adcp, added by
// https://github.com/adcontextprotocol/adcp/pull/6733 (merged 2026-08-20,
// resolving https://github.com/adcontextprotocol/adcp/issues/6716) and
// shipped in the AdCP 3.2.0-beta.9 schema bundle this SDK pins at
// adcp/v3/schemas/VERSION. CompatibilityPurchaseCoordinatorInput's field
// shape mirrors media-buy/legacy-purchase-continuation-input.json from that
// bundle exactly.
//
// The Go type is hand-written rather than generated: the schema is marked
// x-adcp-sdk-local (it is explicitly not an AdCP wire tool — "the input
// object MUST NOT be sent to the seller") and is not reachable by $ref from
// any tool request/response schema, so adcp/v3/schemas/generate.py's
// auto-discovery does not produce a Go type for it. Verified by downloading
// the pinned 3.2.0-beta.9 bundle and regenerating adcp/v3/types_gen.go
// locally: no CompatibilityPurchaseCoordinatorInput or PurchaseContinuation
// type is emitted, confirming this is by design rather than a generator
// gap.
//
// # Design precedent
//
// This coordinator follows the same claim-once, pluggable-Backend shape as
// adcp/v3/idempotency (PutIfAbsent-based replay) and
// adcp/v3/signing/pgreplay (atomic-insert replay-cap enforcement) — the two
// existing "durable, pluggable, atomic-claim" packages in this codebase.
// It widens that shape from a single PutIfAbsent into an explicit
// three-state FSM (StateOffered -> StatePending -> StateCommitted or
// StateFailed) because a legacy purchase continuation is a genuinely
// two-phase operation: claim the token, then execute an external legacy
// create_media_buy call whose outcome is not yet known at claim time. A
// crash between those two steps must be *observable* as StatePending
// rather than silently vanishing (double-purchase risk) or silently
// resolving on its own (a fabricated result) — see AmbiguousClaimError.
//
// # Scope of this package
//
// Implemented and tested here:
// - Store.RegisterContinuation: durably records a legacy_create
// continuation's bound facts (principal, account, source version,
// expiry, product IDs, required loss set, and the observed
// product/pricing payload it was minted against) at the moment an
// application's compatibility-projection layer decides to offer one.
// - Store.ContinueLegacyPurchase: validates a
// CompatibilityPurchaseCoordinatorInput against every binding rule the
// spec states (principal, account, expiry, exact loss-set acceptance,
// selected-product-ID subset-and-equality against the legacy_create
// request's explicit packages), atomically claims the token exactly
// once, invokes a caller-supplied Executor, and durably records the
// terminal result so an exact idempotency_key retry returns the
// deterministic prior result rather than re-invoking Executor or the
// legacy seller.
// - Backend: the pluggable durable-store interface, plus MemoryBackend,
// a fully concurrency-safe in-process reference implementation.
//
// Deliberately deferred, disclosed rather than silently omitted:
//
// 1. A persistent (e.g. Postgres) Backend implementation. This package
// ships the interface and a well-tested in-memory reference backend
// only, mirroring how adcp/v3/idempotency's Postgres adapter and
// adcp/v3/signing/pgreplay shipped as follow-on work after their
// respective in-memory/interface PRs. A distributed Backend is real,
// separable work (schema design, connection lifecycle, a real
// concurrency proof against a live database) — tracked in
// https://github.com/adcontextprotocol/adcp-go/issues/482.
//
// 2. Full per-source-version (AdCP 2.5 / 3.0 / 3.1) JSON Schema validation
// of legacy_create_request against each version's actual
// create-media-buy-request schema. This package enforces the
// structural invariants the spec ties directly to atomicity and
// single-use-claim safety — explicit-package mode, and that the
// request's distinct package product IDs exactly equal
// selected_product_ids — plus the account cross-check when the source
// version's request schema carries an account field. It does not
// replicate each legacy version's full request schema, which this SDK
// (adcp/v3, an AdCP 3.x-only module) does not vendor. Application code
// remains responsible for full legacy-version request validation
// before or inside its Executor.
//
// 3. The reverse compact-seller -> legacy-buyer server-side facade (the
// spec's "Established buyers against a compact-backed seller"
// section). This is seller-side adapter work — preserving a 3.2
// seller's deprecated get_products/create_media_buy facades for
// established buyers — a materially different and separable scope from
// the buyer-side coordinator this package implements. Tracked in the
// same https://github.com/adcontextprotocol/adcp-go/issues/482.
//
// listed_purchase is not a gap: per the spec, a listed_purchase
// continuation carries a seller-issued, account-scoped feed/pricing fence
// straight into ordinary buy_products, with no durable continuation state
// to claim. There is nothing for this coordinator to do on that path.
package legacypurchase
Loading
Loading