diff --git a/.changeset/trust-json-origin-identity.md b/.changeset/trust-json-origin-identity.md new file mode 100644 index 00000000000..db26aa545fc --- /dev/null +++ b/.changeset/trust-json-origin-identity.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +trust.json v1 (experimental): an agent is identified by the canonical origin of its `url`, one agent per origin, and every agent URL in the record (agents, grant narrowing, acknowledgements) matches by origin. The record carries no keys. Remove `agents[].jwks_uri` and its `/.well-known/jwks.json` default (keys come from the Web Bot Auth key directory at the agent's origin), and drop the planned `identity.trust_url` back-pointer. Define "canonical origin" once in `url-canonicalization.mdx`, with a Form column in its "Where it applies" table. Examples move to one origin per agent, and a validator for the one-agent-per-origin and acknowledgement rules, using the SDK's canonicalization, runs in `npm run test:schemas`. Nothing consumes trust.json yet. See `specs/agent-identity-3-3.md` and #8118. diff --git a/docs/reference/experimental-status.mdx b/docs/reference/experimental-status.mdx index 7565d8e7424..33b7d3cab3c 100644 --- a/docs/reference/experimental-status.mdx +++ b/docs/reference/experimental-status.mdx @@ -124,7 +124,7 @@ Schemas marked with `x-status: experimental` are the authoritative source. The c | `media_buy.reporting_delivery` | **Reliable Reporting 1.0**: [`get_reporting_status`](/docs/media-buy/task-reference/get_reporting_status), opt-in [`sync_reporting_status`](/docs/media-buy/task-reference/sync_reporting_status), [`sync_reporting_receipts`](/docs/media-buy/task-reference/sync_reporting_receipts), `sync_accounts.reporting_delivery_configs[]`, reporting notifications, and the `media_buy.reporting_delivery` capability block | Proper-name AdCP 3.2 reporting reliability surface (#6911), explicitly discovered through `reliable_reporting_version: "1.0"`. Core, Managed Delivery, and Reconciled Billing have separate capability-gated conformance storyboards. The [consumer-status migration](/docs/reference/migration/reliable-reporting-status-loop) is an active breaking-change notice: current source adds an opt-in task, which becomes required Core only in the next eligible minor after October 24, 2026. Its `content_mismatch` status, stale-`received` grace window, buyer posting deadline, issue lifecycle fields, escalation contact, and the reserved `authoritative_party` field ([#7440](https://github.com/adcontextprotocol/adcp/issues/7440)) are all additive within that experimental surface. [Superseded snapshot compaction](/docs/reference/migration/3-2-to-3-3#superseded-reporting-snapshots-may-be-compacted) ([#8081](https://github.com/adcontextprotocol/adcp/issues/8081)) is a second active breaking-change notice: sellers may compact older superseded snapshot content only in the next eligible minor after November 20, 2026. Multi-party production operation and provider-specific compatibility still need implementation experience before freezing. | | `creative.broadcast_loudness` | The optional `loudness` object (`target_lufs`, `tolerance_db`, `max_true_peak_dbfs`, `standard`) on the `video_hosted` canonical format params (schema: `formats/canonical/video_hosted.json`), documented in [Broadcast creative](/docs/creative/channels/broadcast#loudness-experimental) (#8011) | New broadcast-television delivery requirement with very few adopters so far. The `standard` enum (`ebu_r128`, `atsc_a85`, `custom`) is expected to grow, and the object shape may be reconciled with the flat `loudness_*` fields on `audio_hosted` in a later 3.x release. The `mxf` container and the `mpeg2` / `avc_intra` / `xavc` codec values added alongside it are stable additive enum values, not part of this experimental surface. | | `media_buy.daypart_granularity` | The `start_time` / `end_time` clock-time fields on `core/daypart-target.json` (and so on every `daypart_targets` entry), the `time_granularity` field in `overlay_support.daypart_targets` and `required_overlay_support.daypart_targets`, and the `enums/daypart-time-granularity.json` enum ([#6707](https://github.com/adcontextprotocol/adcp/issues/6707)). See [Minute-resolution dayparts](/docs/media-buy/advanced-topics/targeting#minute-resolution-dayparts-experimental). | Minute-level schedules for linear TV (programme-junction breaks, overnight broadcast days) arrive with one named adopter and are expected to be reshaped by the network/station targeting work in [#5679](https://github.com/adcontextprotocol/adcp/issues/5679). The `HH:MM` representation, the overnight rule (a window belongs to its start day), the three-value granularity ladder, and the error code for an unsupported granularity (today `UNSUPPORTED_FEATURE`, which may be superseded by the targeting-dimension codes in [#6978](https://github.com/adcontextprotocol/adcp/issues/6978)) may change before they freeze. The whole-hour `start_hour` / `end_hour` fields stay stable, although they are no longer individually schema-required. | -| `identity.trust_json` | The `trust/v1/trust.json` and `trust/v1/trust-acknowledgements.json` schemas, described in [trust.json (experimental)](/docs/reference/trust-json) ([RFC #7809](https://github.com/adcontextprotocol/adcp/issues/7809), [#7819](https://github.com/adcontextprotocol/adcp/pull/7819)). **Schemas only: nothing consumes this in 3.3.** No task, capability field, resolver, or storyboard reads it, there is no `identity.trust_url` capability field, `brand.json` behavior is unchanged, and no one is required to publish `/.well-known/trust.json`. Because no seller implements anything from this surface, there is nothing to declare in `experimental_features`: publishing a `trust.json` is not implementing this surface, and declaring `identity.trust_json` has no defined meaning until a consumer lands. | The document shape for agents, keys, grants, and acknowledgements is published ahead of the behavior that would use it, so implementers can review it. The document name and its `/.well-known` path may change before graduation: the JournalList `trust.txt` naming outreach is still open. Resolver precedence, no-downgrade rules, and the capability pointer are planned for a later release and may reshape the fields. | +| `identity.trust_json` | The `trust/v1/trust.json` and `trust/v1/trust-acknowledgements.json` schemas, described in [trust.json (experimental)](/docs/reference/trust-json) ([RFC #7809](https://github.com/adcontextprotocol/adcp/issues/7809), [#7819](https://github.com/adcontextprotocol/adcp/pull/7819)). **Schemas only: nothing consumes this in 3.3.** No task, capability field, resolver, or storyboard reads it, there is no `identity.trust_url` capability field, `brand.json` behavior is unchanged, and no one is required to publish `/.well-known/trust.json`. Because no seller implements anything from this surface, there is nothing to declare in `experimental_features`: publishing a `trust.json` is not implementing this surface, and declaring `identity.trust_json` has no defined meaning until a consumer lands. | The document shape for agents, grants, and acknowledgements is published ahead of the behavior that would use it, so implementers can review it. Agents are identified by origin, one per origin, and the record carries no keys ([`specs/agent-identity-3-3.md`](https://github.com/adcontextprotocol/adcp/blob/main/specs/agent-identity-3-3.md)). The document name and its `/.well-known` path may change before graduation: the JournalList `trust.txt` naming outreach is still open. The record's location is derived from the agent's origin, so no capability pointer is planned. Resolver and no-downgrade rules land with the first consumer and may reshape the fields. | Graduation progress and notice of upcoming breaking changes will be called out in the [release notes](/docs/reference/release-notes) for each 3.x release starting with 3.0 GA. diff --git a/docs/reference/trust-json.mdx b/docs/reference/trust-json.mdx index 12bb84e1259..4db551ce5e0 100644 --- a/docs/reference/trust-json.mdx +++ b/docs/reference/trust-json.mdx @@ -17,7 +17,7 @@ description: "Experimental trust.json v1 schemas, shipped in AdCP 3.3 as schemas |---|---| | Feature id | `identity.trust_json` (see [experimental status](/docs/reference/experimental-status#current-experimental-surfaces)) | | Schemas | `trust/v1/trust.json`, `trust/v1/trust-acknowledgements.json`; both carry `x-status: experimental` | -| Capability field | None. `identity.trust_url` is not part of 3.3 | +| Capability field | None, and none is planned: the record's location is derived from the agent's origin | | Name and path | May change before graduation | | `v1` in the path | A path namespace, not a stability promise. While the schemas are experimental they may change in breaking ways | @@ -27,11 +27,11 @@ Because no seller implements a task or capability from this surface, there is no ## What the schemas describe -The record answers one question: which agents does this organization run, with which keys, who may act for it, and whose grants does it accept. The core is protocol-neutral. Roles and scopes are namespaced tokens such as `adcp:sales`, and AdCP defines the `adcp` namespace as the first profile. +The record answers one question: which agents does this organization run, who may act for it, and whose grants does it accept. The core is protocol-neutral. Roles and scopes are namespaced tokens such as `adcp:sales`, and AdCP defines the `adcp` namespace as the first profile. | Field | Purpose | |---|---| -| `agents[]` | Agents the organization runs: `url`, namespaced `roles`, optional `protocols`, `jwks_uri`, `countries`, and per-protocol `profiles` | +| `agents[]` | Agents the organization runs: `url`, namespaced `roles`, optional `protocols`, `countries`, and per-protocol `profiles`. One agent per origin, and no keys | | `grants[]` | Authority the organization delegates: `grantee` domain, optional `agents[]` narrowing, namespaced `scopes`, `countries`, `valid_from` and `valid_until`. Grants never carry keys | | `exclusive_grants` | When true, `grants[]` is exhaustive for the scopes it names | | `acknowledgements[]` or `acknowledgements_url` (not both) | Grants the organization accepts, by reference: `grantor`, `agent_url`, and `via`. Terms stay in the grantor's document | @@ -54,8 +54,7 @@ A publisher that runs one sales agent. Validated against the schema: { "url": "https://ads.streamhaus.example/mcp", "roles": ["adcp:sales"], - "protocols": ["mcp"], - "jwks_uri": "https://ads.streamhaus.example/.well-known/jwks.json" + "protocols": ["mcp"] } ] } @@ -79,7 +78,7 @@ A brand house that delegates authority and runs no agents. A grant names a grant { "grantee": "sentinel-governance.example", "scopes": ["adcp:governance"], - "agents": [{ "url": "https://gov.sentinel-governance.example/t/7f3a9c" }] + "agents": [{ "url": "https://7f3a9c.sentinel-governance.example/mcp" }] } ] } @@ -93,9 +92,8 @@ An ad network that accepts publisher grants by reference, with the bulk entries "last_updated": "2026-09-30T00:00:00Z", "agents": [ { - "url": "https://northwind.example/mcp/streamhaus", - "roles": ["adcp:sales"], - "jwks_uri": "https://keys.northwind.example/jwks/streamhaus.json" + "url": "https://streamhaus.northwind.example/mcp", + "roles": ["adcp:sales"] } ], "acknowledgements_url": "https://northwind.example/.well-known/trust-acknowledgements.json" @@ -109,7 +107,7 @@ An ad network that accepts publisher grants by reference, with the bulk entries "acknowledgements": [ { "grantor": "streamhaus.example", - "agent_url": "https://northwind.example/mcp/streamhaus", + "agent_url": "https://streamhaus.northwind.example/mcp", "via": "adcp:adagents", "profiles": { "adcp": { "delegation_type": "delegated" } } } @@ -117,11 +115,17 @@ An ad network that accepts publisher grants by reference, with the bulk entries } ``` +## Identity and keys + +An agent is identified by the [canonical origin](/docs/reference/url-canonicalization#canonical-origin) of its `url`, and every agent URL in the record (agents, grant narrowing, acknowledgements) matches by origin. A record lists at most one agent per origin. A vendor that runs an agent per tenant gives each tenant its own subdomain, as the examples above do. The schema cannot express the one-agent-per-origin rule, so validators check it separately. + +The record carries no keys. An agent publishes its signing keys in the Web Bot Auth key directory at its own origin, `/.well-known/http-message-signatures-directory`. The record also carries no key pin: a second list of keys would break Web Bot Auth key rotation, where a new key is published alongside the old one. This follows the design in [`specs/agent-identity-3-3.md`](https://github.com/adcontextprotocol/adcp/blob/main/specs/agent-identity-3-3.md) (tracking issue [#8118](https://github.com/adcontextprotocol/adcp/issues/8118)). Until a consumer lands, these rules describe the document shape only. + ## What is not in 3.3 These are planned for a later release and are deliberately absent. The MUST and SHOULD wording in the schema descriptions records the intended design for them and is not enforced by anything today. -- The `identity.trust_url` capability field. +- Resolution from an agent's origin to its trust record. The planned design derives the record's location from the origin, so there is no `identity.trust_url` back-pointer. - Resolver changes, the agent-to-trust-record binding, precedence over `brand.json`, and no-downgrade rules. - Storyboard dual-source modes and the seller-setup rewrite. - Any requirement to publish `/.well-known/trust.json`. diff --git a/docs/reference/url-canonicalization.mdx b/docs/reference/url-canonicalization.mdx index 87821194188..ec5590abfa5 100644 --- a/docs/reference/url-canonicalization.mdx +++ b/docs/reference/url-canonicalization.mdx @@ -32,20 +32,30 @@ The canonicalization applies RFC 3986 §6.2.2 (syntax-based normalization) and After all eight steps, comparison is byte-for-byte. Implementations MUST NOT apply additional transformations before comparison. +## Canonical origin + +The **canonical origin** of a URL is its scheme and authority after steps 1 to 4, written `scheme://authority`, with no path, query, or fragment. `HTTPS://User@Seller.Example.COM.:443/mcp?x=1` has the canonical origin `https://seller.example.com`. A URL that steps 1 to 4 reject has no canonical origin. + +Use the canonical origin only where the table below says a comparison is by origin. Everywhere else, compare the full canonical URL. + ## Where it applies -| Surface | Comparison | Reference | -|---|---|---| -| Request signing | `@target-uri` canonical output signed and verified | [Signed Requests (Transport Layer)](/docs/building/by-layer/L1/security#signed-requests-transport-layer) | -| Signed-Agent identity | Candidate Agent URL vs operator `brand.json` `agents[].url`; the verified canonical URL is the stable Agent subject | [Agent key publication](/docs/building/by-layer/L1/security#agent-key-publication) | -| TMP seller authorization | `seller_agent.agent_url` vs `authorized_agents[].url` | [TMP Sync-Time Validation](/docs/trusted-match/specification#sync-time-validation) | -| TMP provider resolution | `ProviderEntry.agent_url` vs router's registered provider endpoint | [TMP Product Integration](/docs/trusted-match/specification#product-integration) | -| `adagents.json` lookups | Any caller asking "is this agent authorized for this property?" | [adagents.json schema](https://adcontextprotocol.org/schemas/v3/adagents.json) | -| `format-id` resolution | `format-id.agent_url` against the URL an agent publishes for its formats | [format-id schema](https://adcontextprotocol.org/schemas/v3/core/format-id.json) | -| `adagents.json` `authoritative_location` indirection | Following the pointer; the target URL MUST canonicalize the same way | [Managed networks](/docs/governance/property/managed-networks#security-considerations) | -| Provenance verifier allowlist | `verify_agent.agent_url` vs `creative_policy.accepted_verifiers[].agent_url` | [Provenance Verification](/docs/governance/creative/provenance-verification#the-verifier-contract-seller-publishes-buyer-represents-seller-confirms) | -| Governance-agent acceptance | Candidate `governance_agents[].url` vs `accepted_governance_agents.any_of[].agent_url` | [`sync_governance`](/docs/accounts/tasks/sync_governance#seller-acceptance-of-governance-agents) | -| Any registry with a URL primary key | Canonical form is the key; raw input is not | - | +| Surface | Comparison | Form | Reference | +|---|---|---|---| +| Request signing | `@target-uri` canonical output signed and verified | Canonical URL | [Signed Requests (Transport Layer)](/docs/building/by-layer/L1/security#signed-requests-transport-layer) | +| Signed-Agent identity | Candidate Agent URL vs operator `brand.json` `agents[].url`; the verified canonical URL is the stable Agent subject | Canonical URL | [Agent key publication](/docs/building/by-layer/L1/security#agent-key-publication) | +| TMP seller authorization | `seller_agent.agent_url` vs `authorized_agents[].url` | Canonical URL | [TMP Sync-Time Validation](/docs/trusted-match/specification#sync-time-validation) | +| TMP provider resolution | `ProviderEntry.agent_url` vs router's registered provider endpoint | Canonical URL | [TMP Product Integration](/docs/trusted-match/specification#product-integration) | +| `adagents.json` lookups | Any caller asking "is this agent authorized for this property?" | Canonical URL | [adagents.json schema](https://adcontextprotocol.org/schemas/v3/adagents.json) | +| `format-id` resolution | `format-id.agent_url` against the URL an agent publishes for its formats | Canonical URL | [format-id schema](https://adcontextprotocol.org/schemas/v3/core/format-id.json) | +| `adagents.json` `authoritative_location` indirection | Following the pointer; the target URL MUST canonicalize the same way | Canonical URL | [Managed networks](/docs/governance/property/managed-networks#security-considerations) | +| Provenance verifier allowlist | `verify_agent.agent_url` vs `creative_policy.accepted_verifiers[].agent_url` | Canonical URL | [Provenance Verification](/docs/governance/creative/provenance-verification#the-verifier-contract-seller-publishes-buyer-represents-seller-confirms) | +| Governance-agent acceptance | Candidate `governance_agents[].url` vs `accepted_governance_agents.any_of[].agent_url` | Canonical URL | [`sync_governance`](/docs/accounts/tasks/sync_governance#seller-acceptance-of-governance-agents) | +| Any registry with a URL primary key | Canonical form is the key; raw input is not | Canonical URL | - | +| trust.json agents (experimental) | `agents[].url` uniqueness, grant `agents[].url` narrowing, and acknowledgement `agent_url`: one agent per origin | Canonical origin | [trust.json](/docs/reference/trust-json#identity-and-keys) | +| WBA identity (proposed, 3.3) | The origin named by the covered `Signature-Agent` member, compared with a bound identity or a trust.json agent entry | Canonical origin | [`specs/agent-identity-3-3.md` R0](https://github.com/adcontextprotocol/adcp/blob/main/specs/agent-identity-3-3.md#r0-canonical-origin-p1-url-canonicalizationmdx) | +| `format-id.agent_url` and a WBA signer (proposed, 3.3) | A WBA signer matches when the canonical origin of `agent_url` equals the signer's origin. The `format-id` resolution row above stays a canonical URL | Canonical origin | [`specs/agent-identity-3-3.md` R0](https://github.com/adcontextprotocol/adcp/blob/main/specs/agent-identity-3-3.md#r0-canonical-origin-p1-url-canonicalizationmdx) | +| `authorized_agents[].url` and a WBA signer (proposed, 3.3) | A WBA signer matches by canonical origin. The `adagents.json` rows above stay canonical URLs for the 3.2 profile | Canonical origin | [`specs/agent-identity-3-3.md` R0](https://github.com/adcontextprotocol/adcp/blob/main/specs/agent-identity-3-3.md#r0-canonical-origin-p1-url-canonicalizationmdx) | Canonicalization is for matching, not safe reflection. A disclosed governance-agent rejection derives `attempted_agent_origin` from the parsed URL and strips userinfo, path, query, and fragment. It never returns the raw candidate URL; an opaque rejection returns no attempted endpoint at all. @@ -59,7 +69,7 @@ The [request-signing profile](/docs/building/by-layer/L1/security#signed-request ## Conformance vectors -The [`canonicalization.json`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/canonicalization.json) set exercises every rule above with fixed `{ input_url, expected_target_uri, expected_authority }` triples, plus malformed-authority rejection cases. Non-signing callers compare against `expected_target_uri` only — `expected_authority` is the HTTP-header-derived form used by the signing profile. SDKs implementing any of the surfaces in the table above SHOULD run this set on every commit; canonicalization divergence is silent until a production interop bug surfaces. +The [`canonicalization.json`](https://adcontextprotocol.org/compliance/latest/test-vectors/request-signing/canonicalization.json) set exercises every rule above with fixed `{ input_url, expected_target_uri, expected_authority }` triples, plus malformed-authority rejection cases. The expected canonical origin of each case is the lowercased scheme of `input_url`, then `://`, then `expected_authority`; a case that rejects the authority has no canonical origin. Non-signing callers compare against `expected_target_uri` only — `expected_authority` is the HTTP-header-derived form used by the signing profile. SDKs implementing any of the surfaces in the table above SHOULD run this set on every commit; canonicalization divergence is silent until a production interop bug surfaces. ## Common pitfalls @@ -68,6 +78,6 @@ The [`canonicalization.json`](https://adcontextprotocol.org/compliance/latest/te - **Collapsing consecutive slashes.** `/admin//foo` and `/admin/foo` are different canonical forms. A producer that collapses and a comparer that doesn't (or vice versa) opens a path-confusion attack. - **Re-encoding the query.** Query-string normalization looks tempting but is forbidden. `?x=1&y=2` and `?y=2&x=1` are different canonical forms. - **Trailing `?` with empty query.** `https://host/p?` and `https://host/p` are different. Preserve whichever the producer sent. Publishers registering URLs in `adagents.json` or similar registries should paste them without a trailing `?` unless they intend the empty-query form. -- **Registering an origin for a path-mounted agent.** Canonicalization removes only dot segments, never real path segments. An `authorized_agents[].url` of `https://agent.example.com` does not match a buyer connected to `https://agent.example.com/mcp`. Register the full agent URL, and list each agent URL separately when MCP and A2A are served at different paths. See [Agent URL matching](/docs/governance/property/adagents#agent-url-matching). +- **Registering an origin for a path-mounted agent.** Canonicalization removes only dot segments, never real path segments. This applies to every canonical-URL comparison in the table above; only the rows marked canonical origin compare by origin. An `authorized_agents[].url` of `https://agent.example.com` does not match a buyer connected to `https://agent.example.com/mcp`. Register the full agent URL, and list each agent URL separately when MCP and A2A are served at different paths. See [Agent URL matching](/docs/governance/property/adagents#agent-url-matching). - **Forgetting the fragment strip.** Fragments never participate in identifier comparison. - **Mixing `http://` and `https://`.** Scheme is preserved, not coerced. Publishers registering an `authorized_agents[].url` MUST use `https://` for anything meant to be reachable on the public internet — an `http://` entry will fail to match an `https://` caller and vice versa, and non-HTTPS URLs have no transport-integrity guarantee. diff --git a/package.json b/package.json index 18e0d7f6fec..e901cd2207a 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "verify:cdn-artifacts-cutover": "node scripts/verify-cdn-artifacts-cutover.mjs", "typecheck": "tsc --project server/tsconfig.json --noEmit && npm run typecheck:fixed-trace-rollout-tests", "typecheck:fixed-trace-rollout-tests": "node scripts/typecheck-fixed-trace-rollout-tests.mjs", - "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/catalog-ingestion-capabilities.test.cjs tests/sdk-implementation-metadata.test.cjs tests/seller-optimized-package-controls.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/dooh-allocation.test.cjs tests/identity-absence-coherence.test.cjs tests/execution-requirements.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/canonical-forecast-point-parity.test.cjs tests/creative-revisions.test.cjs tests/creative-delivery-contracts.test.cjs tests/tracker-execution-contracts.test.cjs tests/tracker-execution-package-integration.test.cjs tests/metric-identity-coherence.test.cjs tests/print-metrics.test.cjs tests/daypart-time-of-day.test.cjs tests/audience-size-range.test.cjs tests/viewable-rate-optimization-goal.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/inventory-delivery-reporting-contract.test.cjs tests/property-delivery-conformance.test.cjs tests/lint-schema-enum-drift.test.cjs tests/format-asset-type-parity.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs tests/buyer-reason.test.cjs tests/error-recovery-vectors.test.cjs tests/reporting-status-contract.test.cjs tests/reporting-reconciliation-fixture.test.cjs tests/reporting-core-fixture.test.cjs tests/reporting-native-version-ref-bounds.test.cjs tests/definition-pin-vectors.test.cjs tests/refine-proposals-expected-confirmation-at.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", + "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/catalog-ingestion-capabilities.test.cjs tests/sdk-implementation-metadata.test.cjs tests/seller-optimized-package-controls.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/dooh-allocation.test.cjs tests/identity-absence-coherence.test.cjs tests/execution-requirements.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/canonical-forecast-point-parity.test.cjs tests/creative-revisions.test.cjs tests/creative-delivery-contracts.test.cjs tests/tracker-execution-contracts.test.cjs tests/tracker-execution-package-integration.test.cjs tests/metric-identity-coherence.test.cjs tests/print-metrics.test.cjs tests/daypart-time-of-day.test.cjs tests/audience-size-range.test.cjs tests/viewable-rate-optimization-goal.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/inventory-delivery-reporting-contract.test.cjs tests/property-delivery-conformance.test.cjs tests/lint-schema-enum-drift.test.cjs tests/format-asset-type-parity.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs tests/buyer-reason.test.cjs tests/error-recovery-vectors.test.cjs tests/reporting-status-contract.test.cjs tests/reporting-reconciliation-fixture.test.cjs tests/reporting-core-fixture.test.cjs tests/reporting-native-version-ref-bounds.test.cjs tests/definition-pin-vectors.test.cjs tests/trust-json-v1.test.cjs tests/refine-proposals-expected-confirmation-at.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", "test:performance-feedback": "node --test --test-force-exit --test-timeout=30000 tests/performance-feedback-contract.test.cjs", "test:dist-schema-version-ids": "node --test --test-force-exit --test-timeout=30000 tests/dist-schema-version-ids.test.cjs", "test:examples": "node tests/example-validation-simple.test.cjs && npm run test:tmp-context-merge", diff --git a/specs/brand-identity-trust-split/examples/agency.json b/specs/brand-identity-trust-split/examples/agency.json index 1809267d4ef..32ff98921fd 100644 --- a/specs/brand-identity-trust-split/examples/agency.json +++ b/specs/brand-identity-trust-split/examples/agency.json @@ -4,8 +4,7 @@ "agents": [ { "url": "https://agent.pinnacle-agency.example/adcp", - "roles": ["adcp:buying"], - "jwks_uri": "https://agent.pinnacle-agency.example/.well-known/jwks.json" + "roles": ["adcp:buying"] } ], "grants": [ diff --git a/specs/brand-identity-trust-split/examples/brand-house.json b/specs/brand-identity-trust-split/examples/brand-house.json index 49bb5d59c53..e08449879d4 100644 --- a/specs/brand-identity-trust-split/examples/brand-house.json +++ b/specs/brand-identity-trust-split/examples/brand-house.json @@ -13,7 +13,7 @@ { "grantee": "sentinel-governance.example", "scopes": ["adcp:governance"], - "agents": [{ "url": "https://gov.sentinel-governance.example/t/7f3a9c" }] + "agents": [{ "url": "https://7f3a9c.sentinel-governance.example/mcp" }] } ] } diff --git a/specs/brand-identity-trust-split/examples/direct-publisher.json b/specs/brand-identity-trust-split/examples/direct-publisher.json index f43864eaab8..dba10de83f3 100644 --- a/specs/brand-identity-trust-split/examples/direct-publisher.json +++ b/specs/brand-identity-trust-split/examples/direct-publisher.json @@ -5,8 +5,7 @@ { "url": "https://ads.streamhaus.example/mcp", "roles": ["adcp:sales"], - "protocols": ["mcp"], - "jwks_uri": "https://ads.streamhaus.example/.well-known/jwks.json" + "protocols": ["mcp"] } ] } diff --git a/specs/brand-identity-trust-split/examples/governance-vendor.json b/specs/brand-identity-trust-split/examples/governance-vendor.json index 628bcf18bfe..02928e6bd51 100644 --- a/specs/brand-identity-trust-split/examples/governance-vendor.json +++ b/specs/brand-identity-trust-split/examples/governance-vendor.json @@ -3,14 +3,12 @@ "last_updated": "2026-09-30T00:00:00Z", "agents": [ { - "url": "https://gov.sentinel-governance.example/t/7f3a9c", - "roles": ["adcp:governance"], - "jwks_uri": "https://keys.sentinel-governance.example/t/7f3a9c/jwks.json" + "url": "https://7f3a9c.sentinel-governance.example/mcp", + "roles": ["adcp:governance"] }, { - "url": "https://gov.sentinel-governance.example/t/b21e04", - "roles": ["adcp:governance"], - "jwks_uri": "https://keys.sentinel-governance.example/t/b21e04/jwks.json" + "url": "https://b21e04.sentinel-governance.example/mcp", + "roles": ["adcp:governance"] } ] } diff --git a/specs/brand-identity-trust-split/examples/multi-protocol-org.json b/specs/brand-identity-trust-split/examples/multi-protocol-org.json index 4bb77c7fc8c..b3128e775b5 100644 --- a/specs/brand-identity-trust-split/examples/multi-protocol-org.json +++ b/specs/brand-identity-trust-split/examples/multi-protocol-org.json @@ -3,16 +3,14 @@ "last_updated": "2026-09-30T00:00:00Z", "agents": [ { - "url": "https://agents.lumen-outfitters.example/brand", + "url": "https://brand-agent.lumen-outfitters.example/mcp", "roles": ["adcp:brand"], - "protocols": ["mcp"], - "jwks_uri": "https://agents.lumen-outfitters.example/.well-known/jwks/brand.json" + "protocols": ["mcp"] }, { - "url": "https://agents.lumen-outfitters.example/support", + "url": "https://support-agent.lumen-outfitters.example/mcp", "roles": ["acme-commerce:customer_service"], - "protocols": ["a2a"], - "jwks_uri": "https://agents.lumen-outfitters.example/.well-known/jwks/support.json" + "protocols": ["a2a"] } ], "grants": [ diff --git a/specs/brand-identity-trust-split/examples/network-acknowledgements.json b/specs/brand-identity-trust-split/examples/network-acknowledgements.json index 15232b428c3..444e4d60ed4 100644 --- a/specs/brand-identity-trust-split/examples/network-acknowledgements.json +++ b/specs/brand-identity-trust-split/examples/network-acknowledgements.json @@ -4,13 +4,13 @@ "acknowledgements": [ { "grantor": "streamhaus.example", - "agent_url": "https://northwind.example/mcp/streamhaus", + "agent_url": "https://streamhaus.northwind.example/mcp", "via": "adcp:adagents", "profiles": { "adcp": { "delegation_type": "delegated" } } }, { "grantor": "pinnacle-news.example", - "agent_url": "https://northwind.example/mcp/pinnacle-news", + "agent_url": "https://pinnacle-news.northwind.example/mcp", "via": "adcp:adagents" } ] diff --git a/specs/brand-identity-trust-split/examples/network.json b/specs/brand-identity-trust-split/examples/network.json index 88f7d59f6bf..ac4ce089473 100644 --- a/specs/brand-identity-trust-split/examples/network.json +++ b/specs/brand-identity-trust-split/examples/network.json @@ -3,14 +3,12 @@ "last_updated": "2026-09-30T00:00:00Z", "agents": [ { - "url": "https://northwind.example/mcp/streamhaus", - "roles": ["adcp:sales"], - "jwks_uri": "https://keys.northwind.example/jwks/streamhaus.json" + "url": "https://streamhaus.northwind.example/mcp", + "roles": ["adcp:sales"] }, { - "url": "https://northwind.example/mcp/pinnacle-news", - "roles": ["adcp:sales"], - "jwks_uri": "https://keys.northwind.example/jwks/pinnacle-news.json" + "url": "https://pinnacle-news.northwind.example/mcp", + "roles": ["adcp:sales"] } ], "acknowledgements_url": "https://northwind.example/.well-known/trust-acknowledgements.json" diff --git a/specs/brand-identity-trust-split/validate.cjs b/specs/brand-identity-trust-split/validate.cjs index 857fcfd149c..98b21ddd0df 100644 --- a/specs/brand-identity-trust-split/validate.cjs +++ b/specs/brand-identity-trust-split/validate.cjs @@ -1,4 +1,5 @@ -// Validates the proposal examples against the experimental trust/v1 schemas under static/schemas/source/trust/v1/ (not wired into npm test). +// Validates the proposal examples against the experimental trust/v1 schemas under static/schemas/source/trust/v1/, +// plus the one-agent-per-origin rule that JSON Schema cannot express. Run by tests/trust-json-v1.test.cjs. // Run: node specs/brand-identity-trust-split/validate.cjs const Ajv = require('ajv'); const addFormats = require('ajv-formats'); const fs = require('fs'); const path = require('path'); @@ -9,11 +10,29 @@ const walk = d => fs.readdirSync(d, { withFileTypes: true }).flatMap(e => e.isDi for (const f of walk(src)) { try { const s = JSON.parse(fs.readFileSync(f)); if (s.$id) ajv.addSchema(s); } catch {} } const dir = __dirname; let fail = 0; +// Rules JSON Schema cannot express (specs/agent-identity-3-3.md R0, R1). Canonical origin is defined in +// docs/reference/url-canonicalization.mdx: scheme and authority after steps 1-4, using the SDK's implementation. +const { canonicalAuthority } = require('@adcp/sdk/signing'); +const canonicalOrigin = url => { try { return `${new URL(url).protocol.toLowerCase()}//${canonicalAuthority(url, '3.2')}`; } catch { return null; } }; +const ruleViolations = doc => { + const out = []; const origins = new Set(); + for (const a of doc.agents || []) { + const o = canonicalOrigin(a.url); + if (!o) out.push(`agent url has no canonical origin: ${a.url}`); + else if (origins.has(o)) out.push(`two agents share the origin ${o}`); + else origins.add(o); + } + for (const ack of doc.acknowledgements || []) { + if (!origins.has(canonicalOrigin(ack.agent_url))) out.push(`acknowledgement agent_url matches no agents[] entry: ${ack.agent_url}`); + } + return out; +}; +const valid = (id, doc) => ajv.validate(id, doc) && (id.endsWith('trust-acknowledgements.json') || ruleViolations(doc).length === 0); for (const f of fs.readdirSync(path.join(dir, 'examples'))) { const doc = JSON.parse(fs.readFileSync(path.join(dir, 'examples', f))); const id = f.includes('acknowledgements') ? '/schemas/trust/v1/trust-acknowledgements.json' : '/schemas/trust/v1/trust.json'; - const ok = ajv.validate(id, doc); - console.log(ok ? 'PASS' : 'FAIL', f); if (!ok) { fail++; console.log(JSON.stringify(ajv.errors, null, 1)); } + const ok = valid(id, doc); + console.log(ok ? 'PASS' : 'FAIL', f); if (!ok) { fail++; console.log(JSON.stringify(ajv.errors || ruleViolations(doc), null, 1)); } } // negative cases const neg = [ @@ -26,6 +45,22 @@ const neg = [ ['unnamespaced role', { agents: [{ url: 'https://a.example/mcp', roles: ['sales'] }] }], ['unknown adcp scope', { grants: [{ grantee: 'a.example', scopes: ['adcp:everything'] }] }], ['acknowledgement without via', { acknowledgements: [{ grantor: 'p.example', agent_url: 'https://a.example/mcp' }] }], + ['agent carrying jwks_uri', { agents: [{ url: 'https://a.example/mcp', roles: ['adcp:sales'], jwks_uri: 'https://a.example/.well-known/jwks.json' }] }], + ['two agents sharing an origin', { agents: [{ url: 'https://a.example/one', roles: ['adcp:sales'] }, { url: 'https://a.example/two', roles: ['adcp:buying'] }] }], + ['two agents sharing an origin through a root dot', { agents: [{ url: 'https://a.example./one', roles: ['adcp:sales'] }, { url: 'https://a.example/two', roles: ['adcp:buying'] }] }], + ['agent url with an empty DNS label', { agents: [{ url: 'https://a.example../x', roles: ['adcp:buying'] }] }], + ['agent carrying a key pin', { agents: [{ url: 'https://a.example/mcp', roles: ['adcp:buying'], key_thumbprints: ['NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs'] }] }], + ['signing_profiles is not part of the schema', { agents: [{ url: 'https://a.example/mcp', roles: ['adcp:buying'], profiles: { adcp: { signing_profiles: ['web-bot-auth'] } } }] }], + ['acknowledgement for an agent the record does not list', { agents: [{ url: 'https://a.example/mcp', roles: ['adcp:sales'] }], acknowledgements: [{ grantor: 'p.example', agent_url: 'https://b.example/mcp', via: 'adcp:adagents' }] }], ]; -for (const [name, doc] of neg) { const ok = ajv.validate('/schemas/trust/v1/trust.json', doc); console.log(ok ? 'UNEXPECTED PASS' : 'rejects', name); if (ok) fail++; } +for (const [name, doc] of neg) { const ok = valid('/schemas/trust/v1/trust.json', doc); console.log(ok ? 'UNEXPECTED PASS' : 'rejects', name); if (ok) fail++; } +// positive: two agents on distinct origins, and an acknowledgement matched by origin +const pos = { + agents: [ + { url: 'https://buyer.a.example/mcp', roles: ['adcp:buying'] }, + { url: 'https://Gov.A.Example:443/mcp', roles: ['adcp:governance'] }, + ], + acknowledgements: [{ grantor: 'p.example', agent_url: 'https://gov.a.example/other-path', via: 'adcp:adagents' }], +}; +{ const ok = valid('/schemas/trust/v1/trust.json', pos); console.log(ok ? 'PASS' : 'FAIL', 'distinct origins, acknowledgement matched by origin'); if (!ok) { fail++; console.log(JSON.stringify(ajv.errors, null, 1)); } } process.exit(fail ? 1 : 0); diff --git a/static/schemas/source/trust/v1/trust.json b/static/schemas/source/trust/v1/trust.json index 409a5ef4b80..207a5b35ed0 100644 --- a/static/schemas/source/trust/v1/trust.json +++ b/static/schemas/source/trust/v1/trust.json @@ -3,7 +3,7 @@ "$id": "/schemas/trust/v1/trust.json", "title": "trust.json", "x-status": "experimental", - "description": "An organization's agent trust record, hosted at /.well-known/trust.json on the organization's own host. Protocol-neutral core: the agents the organization runs and their keys (agents[]), the authority it delegates to others (grants[]), and the grants it has accepted from others (acknowledgements[]). Protocol-specific semantics live in namespaced roles and scopes (e.g. 'adcp:sales') and in per-protocol `profiles` objects; this draft defines the `adcp` profile. The organization a record speaks for is the host serving /.well-known/trust.json — never a value inside the document. Versioned independently of any one protocol. Normative-sounding language (MUST, SHOULD) in these descriptions records the intended design for a later release and is not enforced by anything in 3.3. Third parties, crawlers and registries MUST NOT treat a published trust.json as authority in 3.3. The v1 in the path is a namespace, not a stability promise: while x-status is experimental the schema may change in breaking ways. EXPERIMENTAL (x-status: experimental, feature id identity.trust_json): schemas only. Nothing in AdCP 3.3 reads, requires, or verifies this document, and brand.json behavior is unchanged. The document name and its /.well-known path may change before graduation (the JournalList trust.txt naming outreach is still open), so do not treat the file name as frozen. See RFC #7809.", + "description": "An organization's agent trust record, hosted at /.well-known/trust.json on the organization's own host. Protocol-neutral core: the agents the organization runs (agents[]), the authority it delegates to others (grants[]), and the grants it has accepted from others (acknowledgements[]). Protocol-specific semantics live in namespaced roles and scopes (e.g. 'adcp:sales') and in per-protocol `profiles` objects; this draft defines the `adcp` profile. The organization a record speaks for is the host serving /.well-known/trust.json — never a value inside the document. Versioned independently of any one protocol. Normative-sounding language (MUST, SHOULD) in these descriptions records the intended design for a later release and is not enforced by anything in 3.3. Third parties, crawlers and registries MUST NOT treat a published trust.json as authority in 3.3. The v1 in the path is a namespace, not a stability promise: while x-status is experimental the schema may change in breaking ways. EXPERIMENTAL (x-status: experimental, feature id identity.trust_json): schemas only. Nothing in AdCP 3.3 reads, requires, or verifies this document, and brand.json behavior is unchanged. The document name and its /.well-known path may change before graduation (the JournalList trust.txt naming outreach is still open), so do not treat the file name as frozen. An agent URL anywhere in this record identifies the agent by its canonical origin (see docs/reference/url-canonicalization.mdx): one agent per origin. See RFC #7809 and specs/agent-identity-3-3.md.", "definitions": { "domain": { "type": "string", @@ -111,7 +111,7 @@ }, "open_profiles": { "type": "object", - "description": "Per-protocol data keyed by namespace. Consumers MUST ignore namespaces they do not implement. Nothing in profiles is key material or authority for another namespace: keys come only from an agent's jwks_uri.", + "description": "Per-protocol data keyed by namespace. Consumers MUST ignore namespaces they do not implement. Nothing in profiles is key material or authority for another namespace.", "propertyNames": { "pattern": "^[a-z0-9-]+$" }, @@ -121,11 +121,11 @@ }, "agent": { "type": "object", - "description": "An agent this organization runs. The canonical `url` is the agent's stable identity once the resolution chain and a signature verify.", + "description": "An agent this organization runs, identified by the canonical origin of `url`. One agent per origin. Its signing keys are published only in the key directory at that origin (`/.well-known/http-message-signatures-directory`, Web Bot Auth), never in this document.", "properties": { "url": { "$ref": "#/definitions/https_url", - "description": "Agent endpoint URL. Its host eTLD+1 MUST equal the eTLD+1 of the host serving this document. Compare with URL canonicalization, never byte equality. For the listing to count, the agent MUST point back at this document through its protocol's discovery surface (for AdCP, a planned get_adcp_capabilities identity.trust_url field that is not defined in 3.3)." + "description": "Agent endpoint URL, which may carry a path. Its host eTLD+1 MUST equal the eTLD+1 of the organization this record speaks for." }, "roles": { "type": "array", @@ -155,10 +155,6 @@ "description": { "type": "string" }, - "jwks_uri": { - "$ref": "#/definitions/https_url", - "description": "HTTPS URL of this agent's JWKS (RFC 7517). MAY be omitted only when the agent url's host equals the host serving this document AND no other agents[] entry shares that origin; verifiers then default to /.well-known/jwks.json on that origin. Otherwise REQUIRED." - }, "countries": { "type": "array", "description": "ISO 3166-1 alpha-2 country codes where this agent operates. Omit for global.", @@ -212,7 +208,7 @@ }, "granted_agent": { "type": "object", - "description": "An agent URL a grant is limited to. Grants never carry keys: an agent's keys always resolve through its own operator's trust.json.", + "description": "An agent a grant is limited to, matched by canonical origin. Grants never carry keys.", "properties": { "url": { "$ref": "#/definitions/https_url" @@ -313,11 +309,11 @@ }, "agent_url": { "$ref": "#/definitions/https_url", - "description": "The granted agent. MUST canonically equal an agents[] entry in this same document; an acknowledgement for an agent this organization does not run is invalid and MUST be ignored." + "description": "The granted agent. MUST match an agents[] entry in this same document by canonical origin; an acknowledgement for an agent this organization does not run is invalid and MUST be ignored." }, "via": { "$ref": "#/definitions/grant_source", - "description": "Where the grant is published. For 'adcp:adagents', verifiers resolve https://{grantor}/.well-known/adagents.json (following authoritative_location and managed-network rules) and require an authorized_agents[] entry whose canonical url equals agent_url and that reaches the grantor under the reachable-publisher rule. One acknowledgement covers every entry for that agent URL, whatever its authorization_type." + "description": "Where the grant is published. For 'adcp:adagents', verifiers resolve https://{grantor}/.well-known/adagents.json (following authoritative_location and managed-network rules) and require an authorized_agents[] entry whose canonical url equals agent_url and that reaches the grantor under the reachable-publisher rule. One acknowledgement covers every entry for that agent URL, whatever its authorization_type. The adagents.json comparison is by canonical URL, as that file defines (see docs/reference/url-canonicalization.mdx), not by origin." }, "profiles": { "allOf": [ @@ -423,7 +419,7 @@ }, "agents": { "type": "array", - "description": "Agents this organization runs. url values MUST be unique after canonicalization; verifiers reject ambiguous matches.", + "description": "Agents this organization runs.", "items": { "$ref": "#/definitions/agent" } @@ -456,7 +452,7 @@ }, "ext": { "type": "object", - "description": "Vendor extensions, namespaced as ext.{vendor}. The top level is closed so it cannot become a catch-all. Never key material or authority: keys come only from an agent's jwks_uri.", + "description": "Vendor extensions, namespaced as ext.{vendor}. The top level is closed so it cannot become a catch-all. Never key material or authority.", "additionalProperties": { "type": "object" } diff --git a/tests/trust-json-v1.test.cjs b/tests/trust-json-v1.test.cjs new file mode 100644 index 00000000000..2d5e44d86aa --- /dev/null +++ b/tests/trust-json-v1.test.cjs @@ -0,0 +1,12 @@ +// Runs the trust.json v1 example and negative-case validator, including the +// one-agent-per-origin rule that JSON Schema cannot express. +const { test } = require('node:test'); +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +test('trust.json v1 examples validate and negative cases are rejected', () => { + const script = path.join(__dirname, '..', 'specs', 'brand-identity-trust-split', 'validate.cjs'); + const run = spawnSync(process.execPath, [script], { encoding: 'utf8' }); + assert.strictEqual(run.status, 0, run.stdout + run.stderr); +});