Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
9 changes: 9 additions & 0 deletions .changeset/fail-closed-supply-path-vectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"adcontextprotocol": minor
---

Publish canonical supply-path verification golden vectors and an explicit evaluator semantics version. Clarify fail-closed owner-sold carriage verification for affirmative collection constraints, dangling property references, unknown or unevaluated authorization constraints, revocation precedence, agent URL identity and independent domain-level paths.

Retain authority-scoped revocations and authoritative-location pins in persistent registry state. Refuse stale or non-authoritative cache provenance and require explicit publisher attribution in cross-origin shared catalogs. Expose cache observation times and resolved URLs.

Add publisher_domain attribution to shared collection declarations and require it for cross-origin owner evidence. Preserve successful-fetch provenance through failed crawls and provide an operator command for independently confirmed authority migrations.
8 changes: 5 additions & 3 deletions docs/governance/property/managed-networks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -640,13 +640,13 @@ Validator behavior:

- Validators MUST treat any publisher domain in `revoked_publisher_domains[]` as no-longer-authorized, **regardless** of whether the same domain still appears in any `authorized_agents[].publisher_properties[].publisher_domain` / `.publisher_domains[]`, in `authorized_agents[].properties[].publisher_domain` (`inline_properties` authorization type), or in top-level `properties[].publisher_domain` elsewhere in the file. The revocation list takes precedence — this lets a network ship a revocation without redeploying every selector entry.
- Validators with a cached prior version of the file MUST apply the revocation as of the validator's `last_updated` adoption time, not the entry's `revoked_at` (which can be in the past for catch-up entries).
- **Append-only durability on the validator side.** A `publisher_domain` that a validator has ever observed in a `revoked_publisher_domains[]` entry MUST be held as revoked for **7 days from the earliest `revoked_at` the validator has ever observed for that domain**, even if the entry is missing from a subsequent fetch. Durability keys on `publisher_domain` alone — keying on the `(publisher_domain, revoked_at)` tuple would let an attacker re-emit the same domain with a slightly mutated `revoked_at` (one second earlier, say) and present a "fresh" tuple the validator has never observed, bypassing the hold. Use `revoked_at` only to set the clock origin, not to identify the durability key. This places durability on the validator's cached state rather than on the network's retention SHOULD, and closes the rollback gap: an attacker who re-serves a stale file with `revoked_publisher_domains[]` removed and `last_updated` advanced cannot reinstate a previously-revoked publisher inside the validator's 7-day window. New revocations seen for the first time in the current fetch (no prior observation) start the 7-day clock from now.
- **Persistence across restart.** Validators SHOULD persist observed revocation entries (`{publisher_domain, earliest_revoked_at, first_observed_at}`) to durable storage. An in-memory-only validator that restarts inside the 7-day window MAY accept a rolled-back file because it has no record of the prior observation; operators SHOULD treat this as a known limitation and either persist the index or accept the residual risk. The 7-day window is measured from the *validator's* first observation, not from process start.
- **Append-only durability on the validator side.** A `publisher_domain` that a validator has ever observed in a `revoked_publisher_domains[]` entry MUST be held as revoked for **7 days from the validator's first observation of that domain's revocation**, even if the entry is missing from a subsequent fetch. Durability keys on `publisher_domain` within the publisher authority — keying on the `(publisher_domain, revoked_at)` tuple would let an attacker re-emit the same domain with a slightly mutated `revoked_at` (one second earlier, say) and present a "fresh" tuple the validator has never observed, bypassing the hold. Record `revoked_at` for evidence; it neither identifies the durability key nor resets the first-observation clock. This places durability on the validator's cached state rather than on the network's retention SHOULD, and closes the rollback gap: an attacker who re-serves a stale file with `revoked_publisher_domains[]` removed and `last_updated` advanced cannot reinstate a previously-revoked publisher inside the validator's 7-day window. New revocations seen for the first time in the current fetch (no prior observation) start the 7-day clock from now.
- **Persistence across restart.** Validators SHOULD persist observed revocation entries (`{authority, publisher_domain, revoked_at, first_observed_at}`) to durable storage. An in-memory-only validator that restarts inside the 7-day window MAY accept a rolled-back file because it has no record of the prior observation; operators SHOULD treat this as a known limitation and either persist the index or accept the residual risk. The 7-day window is measured from the *validator's* first observation, not from process start.
- The network SHOULD retain each `revoked_publisher_domains[]` entry for at least 7 days after `revoked_at` so validators that didn't observe the entry during its first appearance still pick it up on the next refresh.

For multi-publisher exits or routine churn under a normal commercial relationship-end, networks SHOULD use `reason: "relationship_ended"` so validator change-detection can suppress alerts on routine revocations and route the rest for review.

**Re-authorizing a previously-revoked publisher.** There is no `revoked_until` field and no un-revoke verb. To re-authorize, the network removes the entry from `revoked_publisher_domains[]` *after* the validator-side 7-day durability window has elapsed since `revoked_at`. Removing the entry sooner is a no-op on validators that observed the original revocation (they hold the revocation locally for the remainder of the window). For time-bounded compliance pulls where same-week reinstatement is operationally required, prefer running the revocation as a `reason: "compliance_violation"` and coordinating reinstatement out of band; the schema deliberately does not give the network a re-authorize-before-7-days back door, which would be the same surface as a rollback attack.
**Re-authorizing a previously-revoked publisher.** There is no `revoked_until` field and no un-revoke verb. To re-authorize, the network removes the entry from `revoked_publisher_domains[]` *after* the validator-side 7-day durability window has elapsed since the validator's first observation. Removing the entry sooner is a no-op on validators that observed the original revocation (they hold the revocation locally for the remainder of the window). For time-bounded compliance pulls where same-week reinstatement is operationally required, prefer running the revocation as a `reason: "compliance_violation"` and coordinating reinstatement out of band; the schema deliberately does not give the network a re-authorize-before-7-days back door, which would be the same surface as a rollback attack.

**Extension fields on revocation entries have no normative effect.** `revoked_publisher_domains[]` items use the project-wide `additionalProperties: true` policy, but validators MUST ignore unknown fields on these entries — extensions cannot loosen revocation semantics or carry side-channel reinstatement signals.

Expand All @@ -662,6 +662,8 @@ Validators SHOULD also apply revocation **synchronously during manifest ingest**

This last property is what makes pointer-swap distinct from a generic integrity failure: the whole point of the `authoritative_location` pattern is that publishers are *allowed* to change where they delegate, so the validator cannot treat any pointer change as adversarial without breaking legitimate delegation handoffs. The network-CDN threat is wide-and-shallow (one compromise, every publisher hijacked); the pointer-swap threat is narrow-and-deep (one publisher hijacked, but through a surface the network cannot monitor). Both are in scope.

A validator MUST establish its baseline authoritative-location pin only after successfully fetching and validating a single-hop authoritative manifest. An unavailable target, invalid manifest, or chained pointer MUST NOT establish or replace that pin. A read-only check against an existing pin does not reserve authority; successful admission MUST atomically recheck the pin before committing it.

Validators MUST treat a changed `authoritative_location` as a high-severity event, not a routine refresh. Concretely:

- Validators MUST NOT auto-adopt a changed `authoritative_location`. Continue serving the previously cached authoritative file (subject to the 7-day cap above) while the change is under confirmation. This is the minimum normative floor; the SHOULDs below specify how confirmation is obtained.
Expand Down
15 changes: 15 additions & 0 deletions docs/media-buy/product-discovery/collections-and-installments.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ A collection is a persistent, publisher-controlled content program that produces
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `collection_id` | string | Yes | Publisher-assigned identifier. Declared in the publisher's `adagents.json`. Products reference collections via `collections` selectors. Use distribution identifiers for cross-seller matching. |
| `publisher_domain` | string | Conditional | Owning publisher namespace. Required when a collection is served from a cross-origin shared authoritative document; optional in a publisher-origin document. |
| `kind` | string | No | What kind of content program: `series` (TV/podcast), `publication` (print issues), `event_series` (live events), `rotation` (DOOH), `channel` (continuously programmed audio/video stream). Defaults to `series`. |
| `name` | string | Yes | Human-readable collection name |
| `description` | string | No | What the collection is about |
Expand Down Expand Up @@ -253,6 +254,14 @@ sales path: it is the machine-readable form of the carriage contract's
existing owner-sales clause, and the upgrade path from an
`inventorypartnerdomain` line they typically already publish.

### Canonical verification semantics

The registry supply-path endpoint returns `semantics_version: "1"`. Its evaluator and authoritative SDK implementations consume the same [canonical golden vectors](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/test-vectors/supply-path/vectors.json). Consumers MUST NOT treat results from older evaluator semantics as equivalent to version 1.

Version 1 requires an affirmative host `collections` selector naming the owner. A broad property grant with no collection constraint does not attest owner-sold carriage. Every claimed host property ID must resolve, and the matching grant must cover all of them. Identifier-only distribution entries remain valid discovery data, but cannot prove a host property binding. Missing or unknown authorization discriminators and their companion selectors fail closed on both the owner's and host's agent declarations. Malformed selectors and country, effective-time or placement constraints that cannot be evaluated in the supplied context also fail closed. Revocations take precedence over declarations. Agent URL identity follows the protocol canonicalization rules and preserves case-sensitive paths and query values.

A query omitting `collection_id` returns the strongest complete individual collection path. It never combines one collection's carriage with another collection's authorization. The weaker `host_delegated` state retains its documented owner declarations plus host inventory-partner evidence requirements; it does not require a host `adagents.json`, prove concrete property coverage, or override revocation.

Two verified paths can legitimately overlap. In a revenue-share deal the
host's agent and the owner's agent may both offer avails on the same property
and collection; in an inventory-split deal they sell disjoint avails.
Expand Down Expand Up @@ -285,6 +294,12 @@ priority are not discovery or authorization data. Each seller applies those
terms when constructing and pricing its products; buyers see the resulting
offer, not how the participating parties divide the proceeds.

The registry admits cached manifests for supply-path legs only when their provenance is a direct publisher-origin fetch or a publisher `authoritative_location` delegation, the observation is no older than seven days, and its cache expiry has not elapsed. Manager and community discovery results need additional publisher authority proofs and are not enforcement evidence. Successful-fetch provenance is stored atomically with the manifest, separately from last-attempt diagnostics; cache rows created before this provenance was recorded must refresh before they can establish authority. Registry sources expose `owner_fetched_at`, `host_fetched_at`, and resolved URLs separately from the verdict's `checked_at`.

Both authoritative verification and the registry retain observed revocations by authority and publisher domain for seven days from first observation; changing `revoked_at` does not restart that clock. The registry uses the existing persistent SDK document store so workers and restarts share the hold and authoritative-location pin. A changed pin rejects until an operator independently confirms the publisher migration; an operator can run `npx tsx server/scripts/approve-supply-path-authority.ts <publisher-domain> <https-location> --confirm-publisher-migration` with `DATABASE_URL` after independently confirming the migration. This calls `approveSupplyPathAuthorityChange` while preserving revocations. Never run it from an automatic retry. Properties read from a cross-origin shared authoritative document must explicitly attribute their publisher. IAB evidence allows at most three exact-HTTPS-origin redirects, with bounded bytes and time per fetch.

Collections in a cross-origin authoritative document must explicitly name their owning `publisher_domain`, just as shared host properties must identify their publisher. A domain pointing at an unrelated public catalog cannot borrow that catalog's collections, including through weaker inventory-partner evidence. Publisher-origin collection declarations may omit the field. Owner agent grants in shared cross-origin documents must also explicitly select that owner through `collections[].publisher_domain`; an absent `collections` constraint cannot authorize every member. Within that owner namespace, omitted `collection_ids` still grants all collections.

## The installment object

An installment is a specific installment of a collection. Not all installments will be known in advance — a weekly podcast may only have next week's installment scheduled, and some installments may be tentative (a playoff Game 7 depends on Game 6).
Expand Down
1 change: 1 addition & 0 deletions docs/reference/test-vectors/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ SDKs SHOULD fetch versioned paths where available and record the version under t

| Set | What it pins | Source | CDN |
|---|---|---|---|
| [`supply-path`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/supply-path) | Fail-closed owner-sold supply-path states, leg diagnostics, collection/property scope, revocations and applicable IAB evidence | `static/compliance/source/test-vectors/supply-path/` | `/compliance/latest/test-vectors/supply-path/` |
| [`request-signing`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/request-signing) | RFC 9421 request-signing profile: canonical signature base, covered components, signature params, tag namespace, alg allowlist, `adcp_use` discriminator, replay dedup, revocation, content-digest semantics, and URL canonicalization | `static/compliance/source/test-vectors/request-signing/` | `/compliance/latest/test-vectors/request-signing/` |
| [`webhook-signing`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/webhook-signing) | RFC 9421 webhook-signing profile: required covered components (content-digest mandatory — no `forbidden` opt-out), `adcp/webhook-signing/v1` tag, webhook-valid `adcp_use` set (`request-signing` plus deprecated `webhook-signing`), `webhook_signature_*` error taxonomy; shares `@target-uri` canonicalization with `request-signing` | `static/compliance/source/test-vectors/webhook-signing/` | `/compliance/latest/test-vectors/webhook-signing/` |
| [`plan-hash`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/plan-hash) | JCS canonicalization of the `plan_hash` preimage: required-only baseline, full-optional, bookkeeping-stripped, omitted-vs-explicit-null, array-order sensitivity, `ext.trace_id` distinctness, Unicode non-normalization (RFC 8785 §3.2.5) | `static/compliance/source/test-vectors/plan-hash/` | `/compliance/latest/test-vectors/plan-hash/` |
Expand Down
21 changes: 21 additions & 0 deletions server/scripts/approve-supply-path-authority.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** Operator-only confirmation after independently verifying a publisher migration. */
import { initializeDatabase, closeDatabase } from '../src/db/client.js';
import { domain } from '../src/services/supply-path-input.js';
import { approveSupplyPathAuthorityChange } from '../src/services/supply-path-authority-state.js';

const usage = 'Usage: npx tsx server/scripts/approve-supply-path-authority.ts <publisher-domain> <https-location> --confirm-publisher-migration';
async function main(): Promise<void> {
const args = process.argv.slice(2);
if (args.length === 1 && args[0] === '--help') { console.log(usage); return; }
const publisher = domain(args[0]);
if (args.length !== 3 || !publisher || args[2] !== '--confirm-publisher-migration') throw new Error(usage);
const location = new URL(args[1]!);
if (location.protocol !== 'https:' || location.username || location.password || location.hash || (location.port && location.port !== '443') || location.href.length > 8192) throw new Error('Invalid authoritative location');
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required');
initializeDatabase({ connectionString: process.env.DATABASE_URL });
try {
await approveSupplyPathAuthorityChange(publisher, location.href);
console.log(`Confirmed authoritative location for ${publisher}; existing revocation holds are preserved.`);
} finally { await closeDatabase(); }
}
main().catch(error => { console.error(error instanceof Error ? error.message : 'Authority confirmation failed'); process.exitCode = 1; });
16 changes: 15 additions & 1 deletion server/src/adagents-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,15 @@ export class AdAgentsManager {

// Check if this is a URL reference
let wasUrlReference = false;
if (typeof adagentsData === 'object' && adagentsData !== null && 'authoritative_location' in adagentsData &&
(typeof adagentsData.authoritative_location !== 'string' || 'authorized_agents' in adagentsData || 'superseded_by' in adagentsData)) {
result.errors.push({
field: 'authoritative_location',
message: 'Authoritative location must be a URL-only reference, without inline agents or another pointer',
severity: 'error',
});
return result;
}
if (this.isUrlReference(adagentsData)) {
wasUrlReference = true;
// Follow the reference to get the authoritative file
Expand Down Expand Up @@ -595,6 +604,10 @@ export class AdAgentsManager {
});
return null;
}
if (parsedUrl.href.includes('#')) {
result.errors.push({ field: 'authoritative_location', message: 'Authoritative location must not contain a URL fragment', severity: 'error' });
return null;
}
} catch {
result.errors.push({
field: 'authoritative_location',
Expand Down Expand Up @@ -656,7 +669,8 @@ export class AdAgentsManager {
}

// Ensure the authoritative file is not also a reference (prevent infinite loops)
if (this.isUrlReference(authData)) {
if (typeof authData === 'object' && authData !== null &&
('authoritative_location' in authData || 'superseded_by' in authData)) {
result.errors.push({
field: 'authoritative_location',
message: 'Authoritative file cannot be another URL reference (nested references not allowed)',
Expand Down
Loading
Loading