From 2789b532b8685e1cfa480063dd54d263ee44f8a7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 10 Oct 2026 13:20:22 +0000 Subject: [PATCH 1/4] feat(schema): trust.json v1 identifies agents by origin and carries no keys P1 of #8118. An agent's identity is the canonical origin of its url, one agent per origin. Remove agents[].jwks_uri and its default (keys come from the Web Bot Auth directory at the agent's origin) and the planned identity.trust_url back-pointer. Add an optional key_thumbprints pin that only narrows keys, and profiles.adcp.signing_profiles so an operator can refuse 3.2-profile signatures. Grant agents[].url matches by origin. Examples move to one origin per agent; the one-agent-per-origin validator and new negative cases run in npm run test:schemas. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/trust-json-origin-identity.md | 5 +++ docs/reference/experimental-status.mdx | 2 +- docs/reference/trust-json.mdx | 24 +++++++----- package.json | 2 +- .../examples/agency.json | 3 +- .../examples/brand-house.json | 2 +- .../examples/direct-publisher.json | 3 +- .../examples/governance-vendor.json | 10 ++--- .../examples/multi-protocol-org.json | 10 ++--- .../examples/network-acknowledgements.json | 4 +- .../examples/network.json | 10 ++--- specs/brand-identity-trust-split/validate.cjs | 29 ++++++++++++-- static/schemas/source/trust/v1/trust.json | 39 ++++++++++++++----- tests/trust-json-v1.test.cjs | 12 ++++++ 14 files changed, 104 insertions(+), 51 deletions(-) create mode 100644 .changeset/trust-json-origin-identity.md create mode 100644 tests/trust-json-v1.test.cjs diff --git a/.changeset/trust-json-origin-identity.md b/.changeset/trust-json-origin-identity.md new file mode 100644 index 00000000000..bcc4367c18e --- /dev/null +++ b/.changeset/trust-json-origin-identity.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +trust.json v1 (experimental): an agent's identity is the canonical origin of its `url`, one agent per origin, and 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 from the `url` description. Add an optional `agents[].key_thumbprints` pin that only narrows keys, and `profiles.adcp.signing_profiles` (`wba`, `adcp-rfc9421`) so an operator can refuse 3.2-profile signatures for an agent. Grant `agents[].url` matches by canonical origin. Examples move to one origin per agent, and a validator for the one-agent-per-origin rule 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..2ffb2c0bf46 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. 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)), 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. | 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..2d74983247f 100644 --- a/docs/reference/trust-json.mdx +++ b/docs/reference/trust-json.mdx @@ -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`, an optional `key_thumbprints` pin, and per-protocol `profiles` (the `adcp` profile adds `signing_profiles`). One agent per origin | | `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's identity is the canonical origin of its `url`, so 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`. `key_thumbprints` can only narrow the keys an agent may sign with; it never adds 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/package.json b/package.json index cc432d569bb..d1539a364ab 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..4bb53a1d812 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,21 @@ 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; +// One agent per origin (specs/agent-identity-3-3.md R1): the canonical origin of agents[].url is the identity. +const sharedOrigins = doc => { + const seen = new Map(); const dupes = []; + for (const a of doc.agents || []) { + let origin; try { origin = new URL(a.url).origin; } catch { continue; } + if (seen.has(origin)) dupes.push(origin); else seen.set(origin, a.url); + } + return dupes; +}; +const valid = (id, doc) => ajv.validate(id, doc) && sharedOrigins(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 || { shared_origins: sharedOrigins(doc) }, null, 1)); } } // negative cases const neg = [ @@ -26,6 +37,16 @@ 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'] }] }], + ['malformed key thumbprint', { agents: [{ url: 'https://a.example/mcp', roles: ['adcp:buying'], key_thumbprints: ['not-a-thumbprint'] }] }], + ['unknown signing profile', { agents: [{ url: 'https://a.example/mcp', roles: ['adcp:buying'], profiles: { adcp: { signing_profiles: ['adcp-agent-url'] } } }] }], ]; -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: pins and signing profiles are accepted on distinct origins +const pos = { agents: [ + { url: 'https://buyer.a.example/mcp', roles: ['adcp:buying'], key_thumbprints: ['NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs'], profiles: { adcp: { signing_profiles: ['wba'] } } }, + { url: 'https://gov.a.example/mcp', roles: ['adcp:governance'] }, +] }; +{ const ok = valid('/schemas/trust/v1/trust.json', pos); console.log(ok ? 'PASS' : 'FAIL', 'pins and signing profiles on distinct origins'); 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..bbd5b847fe0 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[]), each identified by its origin, 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. Keys are not part of this document: an agent's signing keys come from the key directory at its own origin (Web Bot Auth). 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: keys come only from the key directory at an agent's origin.", "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. The agent's identity is the canonical origin of `url` (scheme, host, port): one agent per origin. A document that lists two agents[] entries sharing an origin is invalid; JSON Schema cannot express this, so validators check it separately. Signing keys are published 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 canonical origin is the agent's identity and MUST NOT be shared with another agents[] entry. Its host eTLD+1 MUST equal the eTLD+1 of the organization this record speaks for. Compare with URL canonicalization, never byte equality. (Planned, specs/agent-identity-3-3.md, not enforced in 3.3 until a consumer lands: verifiers locate this record from the agent's origin at https://{eTLD+1}/.well-known/trust.json, with no back-pointer from the agent.)" }, "roles": { "type": "array", @@ -155,9 +155,15 @@ "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." + "key_thumbprints": { + "type": "array", + "description": "Optional key pin. RFC 7638 JWK SHA-256 thumbprints (base64url, no padding) of the keys this agent may sign with. When present, a verifier accepts only directory keys whose thumbprint is listed: the pin narrows the keys published at the agent's origin and never adds a key. Lets the operator limit what a compromised or taken-over origin can sign. (Planned, not enforced in 3.3 until a consumer lands.)", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "minItems": 1, + "uniqueItems": true }, "countries": { "type": "array", @@ -176,7 +182,7 @@ "properties": { "adcp": { "type": "object", - "description": "AdCP profile for an agent entry. Carries the rights-agent fields from brand.json brand_agent_entry.", + "description": "AdCP profile for an agent entry. Carries the rights-agent fields from brand.json brand_agent_entry and the agent's AdCP signing-profile declaration.", "properties": { "available_uses": { "type": "array", @@ -191,6 +197,19 @@ "$ref": "/schemas/enums/right-type.json" }, "minItems": 1 + }, + "signing_profiles": { + "type": "array", + "description": "AdCP request-signing profiles this agent signs with: `wba` (the Web Bot Auth profile) and `adcp-rfc9421` (the 3.2 profile, keys through brand.json). When present and it does not include `adcp-rfc9421`, verifiers refuse 3.2-profile signatures for any agent URL on this agent's origin, whether or not they have bound it, so a stale 3.2 key cannot be used after the operator moves to `wba`. Absent means undeclared. (Planned, specs/agent-identity-3-3.md R4, not enforced in 3.3 until a consumer lands.)", + "items": { + "type": "string", + "enum": [ + "wba", + "adcp-rfc9421" + ] + }, + "minItems": 1, + "uniqueItems": true } }, "additionalProperties": false @@ -212,7 +231,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. Grants never carry keys: an agent's keys come only from the key directory at its own origin. A relying party matches the acting agent against `url` by canonical origin, the agent's identity.", "properties": { "url": { "$ref": "#/definitions/https_url" @@ -456,7 +475,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: keys come only from the key directory at an agent's origin.", "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); +}); From 15d262f1fa94239c252746e90b1be7a8da72f4fc Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 12 Oct 2026 10:33:52 +0000 Subject: [PATCH 2/4] feat(schema): apply review to trust.json origin identity (P1) Per Konstantin Mirin's review: drop signing_profiles; keep key_thumbprints optional and checked at binding and grant evaluation; state "no keys" once and match every agent URL in the record by canonical origin, with the adagents.json comparison staying a canonical URL. Define canonical origin once in url-canonicalization.mdx with a Form column in "Where it applies". The validator now uses the SDK's canonicalization, checks acknowledgements, and rejects a root-dot duplicate and an empty DNS label. Remove the stale capability-pointer wording. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/trust-json-origin-identity.md | 2 +- docs/reference/experimental-status.mdx | 2 +- docs/reference/trust-json.mdx | 8 ++-- docs/reference/url-canonicalization.mdx | 35 +++++++++------ specs/brand-identity-trust-split/validate.cjs | 44 ++++++++++++------- static/schemas/source/trust/v1/trust.json | 35 +++++---------- 6 files changed, 67 insertions(+), 59 deletions(-) diff --git a/.changeset/trust-json-origin-identity.md b/.changeset/trust-json-origin-identity.md index bcc4367c18e..960a24d04b6 100644 --- a/.changeset/trust-json-origin-identity.md +++ b/.changeset/trust-json-origin-identity.md @@ -2,4 +2,4 @@ "adcontextprotocol": minor --- -trust.json v1 (experimental): an agent's identity is the canonical origin of its `url`, one agent per origin, and 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 from the `url` description. Add an optional `agents[].key_thumbprints` pin that only narrows keys, and `profiles.adcp.signing_profiles` (`wba`, `adcp-rfc9421`) so an operator can refuse 3.2-profile signatures for an agent. Grant `agents[].url` matches by canonical origin. Examples move to one origin per agent, and a validator for the one-agent-per-origin rule runs in `npm run test:schemas`. Nothing consumes trust.json yet. See `specs/agent-identity-3-3.md` and #8118. +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. Add an optional `agents[].key_thumbprints` pin that only narrows keys, checked at binding and grant evaluation. 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 2ffb2c0bf46..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, grants, and acknowledgements is published ahead of the behavior that would use 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)), 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 2d74983247f..c322d6255f1 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 | @@ -31,7 +31,7 @@ The record answers one question: which agents does this organization run, who ma | Field | Purpose | |---|---| -| `agents[]` | Agents the organization runs: `url`, namespaced `roles`, optional `protocols`, `countries`, an optional `key_thumbprints` pin, and per-protocol `profiles` (the `adcp` profile adds `signing_profiles`). One agent per origin | +| `agents[]` | Agents the organization runs: `url`, namespaced `roles`, optional `protocols`, `countries`, an optional `key_thumbprints` pin, and per-protocol `profiles`. One agent per origin | | `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 | @@ -117,9 +117,9 @@ An ad network that accepts publisher grants by reference, with the bulk entries ## Identity and keys -An agent's identity is the canonical origin of its `url`, so 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. +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`. `key_thumbprints` can only narrow the keys an agent may sign with; it never adds 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. +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 optional `key_thumbprints` pin is checked when a seller binds the agent or evaluates a grant through its entry, not on the signature path, and it can only narrow the agent's keys. 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 diff --git a/docs/reference/url-canonicalization.mdx b/docs/reference/url-canonicalization.mdx index 87821194188..cea5242c6ae 100644 --- a/docs/reference/url-canonicalization.mdx +++ b/docs/reference/url-canonicalization.mdx @@ -32,20 +32,27 @@ 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) | 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 +66,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 +75,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/specs/brand-identity-trust-split/validate.cjs b/specs/brand-identity-trust-split/validate.cjs index 4bb53a1d812..210ec2cd304 100644 --- a/specs/brand-identity-trust-split/validate.cjs +++ b/specs/brand-identity-trust-split/validate.cjs @@ -10,21 +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; -// One agent per origin (specs/agent-identity-3-3.md R1): the canonical origin of agents[].url is the identity. -const sharedOrigins = doc => { - const seen = new Map(); const dupes = []; +// 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 || []) { - let origin; try { origin = new URL(a.url).origin; } catch { continue; } - if (seen.has(origin)) dupes.push(origin); else seen.set(origin, a.url); + 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); } - return dupes; + 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) && sharedOrigins(doc).length === 0; +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 = valid(id, doc); - console.log(ok ? 'PASS' : 'FAIL', f); if (!ok) { fail++; console.log(JSON.stringify(ajv.errors || { shared_origins: sharedOrigins(doc) }, null, 1)); } + console.log(ok ? 'PASS' : 'FAIL', f); if (!ok) { fail++; console.log(JSON.stringify(ajv.errors || ruleViolations(doc), null, 1)); } } // negative cases const neg = [ @@ -39,14 +47,20 @@ const neg = [ ['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'] }] }], ['malformed key thumbprint', { agents: [{ url: 'https://a.example/mcp', roles: ['adcp:buying'], key_thumbprints: ['not-a-thumbprint'] }] }], - ['unknown signing profile', { agents: [{ url: 'https://a.example/mcp', roles: ['adcp:buying'], profiles: { adcp: { signing_profiles: ['adcp-agent-url'] } } }] }], + ['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 = valid('/schemas/trust/v1/trust.json', doc); console.log(ok ? 'UNEXPECTED PASS' : 'rejects', name); if (ok) fail++; } -// positive: pins and signing profiles are accepted on distinct origins -const pos = { agents: [ - { url: 'https://buyer.a.example/mcp', roles: ['adcp:buying'], key_thumbprints: ['NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs'], profiles: { adcp: { signing_profiles: ['wba'] } } }, - { url: 'https://gov.a.example/mcp', roles: ['adcp:governance'] }, -] }; -{ const ok = valid('/schemas/trust/v1/trust.json', pos); console.log(ok ? 'PASS' : 'FAIL', 'pins and signing profiles on distinct origins'); if (!ok) { fail++; console.log(JSON.stringify(ajv.errors, null, 1)); } } +// positive: a key pin on one origin, an agent on another, and an acknowledgement matched by origin +const pos = { + agents: [ + { url: 'https://buyer.a.example/mcp', roles: ['adcp:buying'], key_thumbprints: ['NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs'] }, + { 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', 'pin, 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 bbd5b847fe0..2cf0adc9f60 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 (agents[]), each identified by its origin, 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. Keys are not part of this document: an agent's signing keys come from the key directory at its own origin (Web Bot Auth). See RFC #7809 and specs/agent-identity-3-3.md.", + "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 the key directory at an agent's origin.", + "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 agent's identity is the canonical origin of `url` (scheme, host, port): one agent per origin. A document that lists two agents[] entries sharing an origin is invalid; JSON Schema cannot express this, so validators check it separately. Signing keys are published in the key directory at that origin (`/.well-known/http-message-signatures-directory`, Web Bot Auth), never in this document.", + "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, which may carry a path. Its canonical origin is the agent's identity and MUST NOT be shared with another agents[] entry. Its host eTLD+1 MUST equal the eTLD+1 of the organization this record speaks for. Compare with URL canonicalization, never byte equality. (Planned, specs/agent-identity-3-3.md, not enforced in 3.3 until a consumer lands: verifiers locate this record from the agent's origin at https://{eTLD+1}/.well-known/trust.json, with no back-pointer from the agent.)" + "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", @@ -157,7 +157,7 @@ }, "key_thumbprints": { "type": "array", - "description": "Optional key pin. RFC 7638 JWK SHA-256 thumbprints (base64url, no padding) of the keys this agent may sign with. When present, a verifier accepts only directory keys whose thumbprint is listed: the pin narrows the keys published at the agent's origin and never adds a key. Lets the operator limit what a compromised or taken-over origin can sign. (Planned, not enforced in 3.3 until a consumer lands.)", + "description": "Optional key pin: RFC 7638 JWK SHA-256 thumbprints (base64url, no padding). When present, the key that verified a request must be listed for a binding or grant through this entry to succeed; it is checked there, not on the signature path. It narrows the directory's keys and never adds one. (Planned, specs/agent-identity-3-3.md R3a, not enforced until a consumer lands.)", "items": { "type": "string", "pattern": "^[A-Za-z0-9_-]{43}$" @@ -182,7 +182,7 @@ "properties": { "adcp": { "type": "object", - "description": "AdCP profile for an agent entry. Carries the rights-agent fields from brand.json brand_agent_entry and the agent's AdCP signing-profile declaration.", + "description": "AdCP profile for an agent entry. Carries the rights-agent fields from brand.json brand_agent_entry.", "properties": { "available_uses": { "type": "array", @@ -197,19 +197,6 @@ "$ref": "/schemas/enums/right-type.json" }, "minItems": 1 - }, - "signing_profiles": { - "type": "array", - "description": "AdCP request-signing profiles this agent signs with: `wba` (the Web Bot Auth profile) and `adcp-rfc9421` (the 3.2 profile, keys through brand.json). When present and it does not include `adcp-rfc9421`, verifiers refuse 3.2-profile signatures for any agent URL on this agent's origin, whether or not they have bound it, so a stale 3.2 key cannot be used after the operator moves to `wba`. Absent means undeclared. (Planned, specs/agent-identity-3-3.md R4, not enforced in 3.3 until a consumer lands.)", - "items": { - "type": "string", - "enum": [ - "wba", - "adcp-rfc9421" - ] - }, - "minItems": 1, - "uniqueItems": true } }, "additionalProperties": false @@ -231,7 +218,7 @@ }, "granted_agent": { "type": "object", - "description": "An agent a grant is limited to. Grants never carry keys: an agent's keys come only from the key directory at its own origin. A relying party matches the acting agent against `url` by canonical origin, the agent's identity.", + "description": "An agent a grant is limited to, matched by canonical origin. Grants never carry keys.", "properties": { "url": { "$ref": "#/definitions/https_url" @@ -332,11 +319,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": [ @@ -442,7 +429,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" } @@ -475,7 +462,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 the key directory at an agent's origin.", + "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" } From b58aab5a37a3a2581277515be0604a5778465a99 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 12 Oct 2026 14:41:12 +0000 Subject: [PATCH 3/4] feat(schema): drop the key_thumbprints pin from trust.json v1 Per review: an optional second key list breaks WBA rotation and contradicts "keys live only in the origin's directory". Subdomain takeover is handled by delisting and seller-side key-set alarms, as for jwks_uri hosts in 3.2. The validator now rejects a key pin. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/trust-json-origin-identity.md | 2 +- docs/reference/trust-json.mdx | 4 ++-- specs/brand-identity-trust-split/validate.cjs | 8 ++++---- static/schemas/source/trust/v1/trust.json | 10 ---------- 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/.changeset/trust-json-origin-identity.md b/.changeset/trust-json-origin-identity.md index 960a24d04b6..db26aa545fc 100644 --- a/.changeset/trust-json-origin-identity.md +++ b/.changeset/trust-json-origin-identity.md @@ -2,4 +2,4 @@ "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. Add an optional `agents[].key_thumbprints` pin that only narrows keys, checked at binding and grant evaluation. 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. +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/trust-json.mdx b/docs/reference/trust-json.mdx index c322d6255f1..4db551ce5e0 100644 --- a/docs/reference/trust-json.mdx +++ b/docs/reference/trust-json.mdx @@ -31,7 +31,7 @@ The record answers one question: which agents does this organization run, who ma | Field | Purpose | |---|---| -| `agents[]` | Agents the organization runs: `url`, namespaced `roles`, optional `protocols`, `countries`, an optional `key_thumbprints` pin, and per-protocol `profiles`. One agent per origin | +| `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 | @@ -119,7 +119,7 @@ An ad network that accepts publisher grants by reference, with the bulk entries 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 optional `key_thumbprints` pin is checked when a seller binds the agent or evaluates a grant through its entry, not on the signature path, and it can only narrow the agent's keys. 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. +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 diff --git a/specs/brand-identity-trust-split/validate.cjs b/specs/brand-identity-trust-split/validate.cjs index 210ec2cd304..98b21ddd0df 100644 --- a/specs/brand-identity-trust-split/validate.cjs +++ b/specs/brand-identity-trust-split/validate.cjs @@ -49,18 +49,18 @@ const neg = [ ['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'] }] }], - ['malformed key thumbprint', { agents: [{ url: 'https://a.example/mcp', roles: ['adcp:buying'], key_thumbprints: ['not-a-thumbprint'] }] }], + ['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 = valid('/schemas/trust/v1/trust.json', doc); console.log(ok ? 'UNEXPECTED PASS' : 'rejects', name); if (ok) fail++; } -// positive: a key pin on one origin, an agent on another, and an acknowledgement matched by origin +// positive: two agents on distinct origins, and an acknowledgement matched by origin const pos = { agents: [ - { url: 'https://buyer.a.example/mcp', roles: ['adcp:buying'], key_thumbprints: ['NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs'] }, + { 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', 'pin, distinct origins, acknowledgement matched by origin'); if (!ok) { fail++; console.log(JSON.stringify(ajv.errors, null, 1)); } } +{ 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 2cf0adc9f60..207a5b35ed0 100644 --- a/static/schemas/source/trust/v1/trust.json +++ b/static/schemas/source/trust/v1/trust.json @@ -155,16 +155,6 @@ "description": { "type": "string" }, - "key_thumbprints": { - "type": "array", - "description": "Optional key pin: RFC 7638 JWK SHA-256 thumbprints (base64url, no padding). When present, the key that verified a request must be listed for a binding or grant through this entry to succeed; it is checked there, not on the signature path. It narrows the directory's keys and never adds one. (Planned, specs/agent-identity-3-3.md R3a, not enforced until a consumer lands.)", - "items": { - "type": "string", - "pattern": "^[A-Za-z0-9_-]{43}$" - }, - "minItems": 1, - "uniqueItems": true - }, "countries": { "type": "array", "description": "ISO 3166-1 alpha-2 country codes where this agent operates. Omit for global.", From f5973ac0267b1b94d714c55fc4e3ad8fc2511350 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 12 Oct 2026 15:52:25 +0000 Subject: [PATCH 4/4] docs(reference): list the WBA origin comparisons in url-canonicalization's table Per review: the three canonical-origin comparisons R0 names (WBA identity, format-id.agent_url and authorized_agents[].url against a WBA signer) now have rows, pointing to R0 until the WBA profile page lands in P2. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/url-canonicalization.mdx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/reference/url-canonicalization.mdx b/docs/reference/url-canonicalization.mdx index cea5242c6ae..ec5590abfa5 100644 --- a/docs/reference/url-canonicalization.mdx +++ b/docs/reference/url-canonicalization.mdx @@ -53,6 +53,9 @@ Use the canonical origin only where the table below says a comparison is by orig | 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.