Skip to content
Merged
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
15 changes: 15 additions & 0 deletions .changeset/typed-proposal-negotiation-storyboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"adcontextprotocol": minor
---

Add typed proposal negotiation compliance storyboard.

- Add `typed_proposal_negotiation.yaml` exercising the AdCP 3.2 typed
negotiation lifecycle through `refine_proposals`: capability-gated
constraint satisfaction (total_budget, product_changes, alternatives),
partial invariant, unsupported dimension rejection, finalize atomicity,
idempotent replay, immutable lineage, digest-verified acceptance,
amendment, cancellation, double-finalize rejection, and multi-source batch.
- Register the scenario in the media-buy seller `index.yaml`.

Refs #6559
10 changes: 5 additions & 5 deletions docs/contributing/storyboard-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ The lint still accepts a bare top-level `brand.domain` as a fallback because the

The authoritative list lives in `scripts/lint-storyboard-scoping.cjs` as `TENANT_SCOPED_TASKS`. A parity test (`tests/lint-storyboard-scoping.test.cjs`) asserts every task registered in the training agent's `HANDLER_MAP` appears in either `TENANT_SCOPED_TASKS` or `EXEMPT_FROM_LINT`. If you add a new tool to the dispatch table and forget to classify it, the parity test fails — you won't get silent drift.

Rule of thumb: if the task's **request schema has a required globally-unique scope-ID** (`plan_id`, `rights_id`, `standards_id`, `list_id`, `event_source_id`), the seller can resolve the tenant from that ID alone — envelope identity is redundant and the lint does not require it (see `EXEMPT_FROM_LINT` bucket (c)).
Rule of thumb: if the task's **request schema has a required globally-unique scope-ID** (`plan_id`, `rights_id`, `standards_id`, `list_id`, `event_source_id`, or proposal IDs inside `refinements`/`declines`), the seller can resolve the tenant from that ID alone — envelope identity is redundant and the lint does not require it (see `EXEMPT_FROM_LINT` bucket (c)).

Everything else falls into `TENANT_SCOPED_TASKS`: create/update mutations without a scope-ID, list/get operations that don't carry a single resource ID, resource-standards calls without `standards_id` in schema, etc. These must carry envelope `account { brand, operator }`.

Expand All @@ -65,13 +65,13 @@ When a step captures a value into `$context` via `context_outputs` and a later s

Other exempt categories: payload-array-keyed sync tasks (`sync_accounts`, `sync_governance`, `sync_catalogs`, `sync_event_sources`), global discovery (`get_adcp_capabilities`, plus deprecated `list_creative_formats` compatibility tests), global catalog reads (`get_brand_identity`, `get_rights`, `update_rights`), and the `comply_test_controller` sandbox primitive.

### Why ID-scoped tasks are exempt but storyboards still carry identity
### Why ID-scoped tasks are exempt

`check_governance`, `report_plan_outcome`, `acquire_rights`, `log_event`, `calibrate_content`, `validate_content_delivery`, and `validate_property_delivery` all require a globally-unique ID (`plan_id`, `rights_id`, `standards_id`, etc.) that was previously provisioned with brand context. At the spec level, a real seller resolves the ID → tenant via their own lookup; the envelope doesn't need to repeat the identity.
`check_governance`, `report_plan_outcome`, `acquire_rights`, `log_event`, `calibrate_content`, `validate_content_delivery`, `validate_property_delivery`, `refine_proposals`, and `decline_proposals` all require one or more globally-unique IDs that were previously provisioned with brand context. At the spec level, a real seller resolves each ID → tenant via its own lookup; the envelope doesn't need to repeat the identity.

The training agent's `sessionKeyFromArgs` routes by envelope identity. A storyboard that **drops** identity on an ID-scoped task lands in `open:default` and fails to find the plan/rights/standards — so storyboards carry envelope identity anyway, and the lint just won't enforce it.
Most legacy ID-scoped storyboards still carry envelope identity because the training agent's `sessionKeyFromArgs` uses it for sandbox routing. The split proposal-lifecycle schemas deliberately forbid `account` on `refine_proposals` and `decline_proposals`; for those tools, the training agent reverse-resolves the proposal IDs to the originating account scope.

This is a sandbox routing convention, not a spec claim. Production sellers resolve tenant from the authenticated principal (bearer/OAuth/HMAC), not from envelope payload — see [Tenant resolution](/docs/building/integration/authentication#tenant-resolution). They don't need envelope identity on ID-scoped tasks and wouldn't rely on it if present. Building a cross-session reverse index in the training agent just to move identity off the wire would be sandbox plumbing without spec meaning.
The remaining envelope identity is a sandbox routing convention, not a spec claim. Production sellers resolve tenant from the authenticated principal (bearer/OAuth/HMAC), not from envelope payload — see [Tenant resolution](/docs/building/integration/authentication#tenant-resolution). They don't need envelope identity on ID-scoped tasks and wouldn't rely on it if present.

## Intentionally cross-tenant probes

Expand Down
1 change: 1 addition & 0 deletions scripts/build-compliance.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ function lintStoryboardIdempotency(sourceDir, schemasDir) {
const duplicateGeneratedKeyAllowedReplayKeys = new Set([
'protocols/media-buy/scenarios/get_products_async.yaml:$context.submitted_products_idempotency_key',
'protocols/media-buy/scenarios/proposal_finalize.yaml:$context.finalize_idempotency_key',
'protocols/media-buy/scenarios/typed_proposal_negotiation.yaml:$context.finalize_idempotency_key',
]);

function isGeneratedIdempotencyKey(value, generatedContextNames) {
Expand Down
2 changes: 2 additions & 0 deletions scripts/lint-storyboard-sample-request-schema.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ function discriminatorFor(payload, schema) {
// idempotency-key patterns in the schemas. Substitutions typically resolve
// to ids at runtime, so a UUID is a safer default than a short sentinel.
const STRING_PLACEHOLDER = '00000000-0000-4000-8000-000000000000';
const SHA256_DIGEST_PLACEHOLDER = `sha256:${'A'.repeat(43)}`;

/**
* Build a schema-typed placeholder for a substitution string. The placeholder
Expand All @@ -177,6 +178,7 @@ function placeholderFor(schema, depth = 0) {
if (resolved.format === 'email') return 'placeholder@example.com';
if (Array.isArray(resolved.enum) && resolved.enum.length > 0) return resolved.enum[0];
if (typeof resolved.const !== 'undefined') return resolved.const;
if (resolved.pattern === '^sha256:[A-Za-z0-9_-]{43}$') return SHA256_DIGEST_PLACEHOLDER;
// Object variant nested inside a oneOf/anyOf at a location where the
// author's substitution will resolve to that shape at runtime. Synthesize
// the concrete shape instead of returning a string that fails required.
Expand Down
18 changes: 10 additions & 8 deletions scripts/lint-storyboard-scoping.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,6 @@ const TENANT_SCOPED_TASKS = new Set([
'get_products',
'list_products',
'request_proposals',
'refine_proposals',
'decline_proposals',
'get_signals',
'activate_signal',
'sync_audiences',
Expand Down Expand Up @@ -106,13 +104,15 @@ const TENANT_SCOPED_TASKS = new Set([
* - `validate_content_delivery` — required `standards_id`
* - `validate_property_delivery` — required `list_id` (schema also
* has optional `account`)
* - `refine_proposals` — required proposal IDs in `refinements`
* - `decline_proposals` — required proposal IDs in `declines`
*
* Storyboard authors still carry envelope identity on these tasks as a
* sandbox routing convention — `sessionKeyFromArgs` routes by envelope
* identity, so a storyboard that drops it would land in `open:default`.
* The lint simply doesn't require it. Production sellers resolve the ID
* via the authenticated principal, not the envelope payload, so there is
* no spec-level gap to close. See docs/contributing/storyboard-authoring.md.
* Most legacy storyboards still carry envelope identity on these tasks as
* a sandbox routing convention. `refine_proposals` and `decline_proposals`
* deliberately forbid it; the training agent reverse-resolves their
* proposal IDs. Production sellers resolve every such ID via the
* authenticated principal, not an envelope payload. See
* docs/contributing/storyboard-authoring.md.
*/
const EXEMPT_FROM_LINT = new Set([
// (a) Payload-array-keyed sync tasks
Expand All @@ -139,6 +139,8 @@ const EXEMPT_FROM_LINT = new Set([
'calibrate_content',
'validate_content_delivery',
'validate_property_delivery',
'refine_proposals',
'decline_proposals',
]);

// Compatibility-only handlers that are not AdCP tasks. creative_approval is
Expand Down
1 change: 1 addition & 0 deletions static/compliance/source/protocols/media-buy/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ requires_scenarios:
- media_buy_seller/bidding_policy_placement
- media_buy_seller/bidding_cost_binding
- media_buy_seller/package_automatic_bidding
- media_buy_seller/typed_proposal_negotiation

narrative: |
You run a sell-side platform — a publisher, SSP, retail media network, or any system that
Expand Down
Loading
Loading