diff --git a/SCHEMA_DELTAS.md b/SCHEMA_DELTAS.md index f839b6c4f..0f0db1c28 100644 --- a/SCHEMA_DELTAS.md +++ b/SCHEMA_DELTAS.md @@ -1,13 +1,3 @@ # Generated-types delta -## Field changes - -- `adagents.py` - - **classes removed**: Reason, RevokedPublisherDomain - - `AdcpAgentsAuthorization2`: `-revoked_publisher_domains` -- `core/publisher_property_selector.py` - - **classes removed**: PublisherDomain - - `PublisherPropertySelector1`: `-publisher_domains` - - `PublisherPropertySelector3`: `-publisher_domains` -- `tmp/identity_match_request.py` - - `IdentityMatchRequest`: `+seller_agent_url` +_No field-shape changes detected._ diff --git a/schemas/cache/3.0/adagents.json b/schemas/cache/3.0/adagents.json index 3db7e0620..748e2c9bb 100644 --- a/schemas/cache/3.0/adagents.json +++ b/schemas/cache/3.0/adagents.json @@ -89,6 +89,40 @@ }, "minItems": 1 }, + "revoked_publisher_domains": { + "type": "array", + "description": "Publisher domains explicitly removed from this managed network. Validators MUST treat any publisher domain listed here as no-longer-authorized, taking precedence over any appearance of the same domain in `authorized_agents[].publisher_properties[].publisher_domain` / `.publisher_domains[]` or in top-level `properties[].publisher_domain`. Lets a network propagate per-publisher revocations on the next refresh instead of waiting for the file-level 7-day cache cap. Validators MUST hold previously-observed `(publisher_domain, revoked_at)` tuples for 7 days from the validator's first observation, even if the entry vanishes from a subsequent fetch \u2014 this closes the rollback gap where an attacker re-serves a stale file with the revocation removed. Networks SHOULD retain entries for at least 7 days after `revoked_at` so validators that didn't observe the original entry still pick it up on refresh.", + "items": { + "type": "object", + "properties": { + "publisher_domain": { + "type": "string", + "description": "Publisher domain being revoked. Matches against the same canonicalized form used in `publisher_properties[].publisher_domain`.", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, + "revoked_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when this publisher was revoked. Validators MAY use this to order revocations against their own cached state." + }, + "reason": { + "type": "string", + "enum": [ + "relationship_ended", + "compliance_violation", + "publisher_request", + "other" + ], + "description": "Reason for revocation. Operator-internal self-classification for review routing \u2014 not a public accusation. `relationship_ended` is the routine commercial case. `compliance_violation` SHOULD be used only when the network has itself determined the publisher is out of policy; for un-adjudicated third-party allegations (regulator inquiries, advertiser complaints, ongoing investigations), use `other` to avoid making a discoverable adverse statement. `publisher_request` is for publisher-initiated exits." + } + }, + "required": [ + "publisher_domain", + "revoked_at" + ], + "additionalProperties": true + } + }, "collections": { "type": "array", "description": "Collections produced or distributed by this publisher. Declares the content programs whose inventory is sold through authorized agents. Products in get_products responses reference these collections by collection_id.", diff --git a/schemas/cache/3.0/core/publisher-property-selector.json b/schemas/cache/3.0/core/publisher-property-selector.json index 67e7856c3..309e43971 100644 --- a/schemas/cache/3.0/core/publisher-property-selector.json +++ b/schemas/cache/3.0/core/publisher-property-selector.json @@ -1,39 +1,72 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Publisher Property Selector", - "description": "Selects properties from a publisher's adagents.json. Used for both product definitions and agent authorization. Supports three selection patterns: all properties, specific IDs, or by tags.", + "description": "Selects properties from a publisher's adagents.json. Used for both product definitions and agent authorization. Supports three selection patterns: all properties, specific IDs, or by tags. Each selector targets one publisher via `publisher_domain` (string) or a fan-out across many publishers that share the same selector via `publisher_domains` (array). Exactly one of `publisher_domain` or `publisher_domains` MUST be present. When `publisher_domains` is used, the selector is logically equivalent to repeating the same entry once per listed domain.", "discriminator": { "propertyName": "selection_type" }, "oneOf": [ { "type": "object", - "description": "Select all properties from the publisher domain", + "description": "Select all properties from one publisher domain, or from each publisher domain when `publisher_domains` is used.", "properties": { "publisher_domain": { "type": "string", - "description": "Domain where publisher's adagents.json is hosted (e.g., 'cnn.com')", + "description": "Domain where publisher's adagents.json is hosted (e.g., 'cnn.com'). XOR with `publisher_domains` \u2014 exactly one MUST be present on each `publisher_properties[]` entry; both-present and neither-present both fail validation.", "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" }, + "publisher_domains": { + "type": "array", + "description": "Compact form for fanning the same selector across many publishers (e.g., a managed network listing every publisher it represents). Each entry is the domain where that publisher's adagents.json is hosted. Each listed domain MUST be canonicalized to lowercase (the `pattern` already rejects uppercase). Mutually exclusive with `publisher_domain`. Each listed domain counts as explicitly scoped for the `managerdomain` fallback safety rule.", + "items": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, + "minItems": 1, + "uniqueItems": true + }, "selection_type": { "type": "string", "const": "all", - "description": "Discriminator indicating all properties from this publisher are included" + "description": "Discriminator indicating all properties from each addressed publisher are included" } }, "required": [ - "publisher_domain", "selection_type" ], + "allOf": [ + { + "not": { + "required": [ + "publisher_domain", + "publisher_domains" + ] + } + }, + { + "anyOf": [ + { + "required": [ + "publisher_domain" + ] + }, + { + "required": [ + "publisher_domains" + ] + } + ] + } + ], "additionalProperties": true }, { "type": "object", - "description": "Select specific properties by ID", + "description": "Select specific properties by ID. Single-publisher only \u2014 property IDs are publisher-scoped, so the compact `publisher_domains[]` form is intentionally NOT available for this selector. Use multiple `publisher_properties[]` entries (one per publisher) when each publisher's ID set differs.", "properties": { "publisher_domain": { "type": "string", - "description": "Domain where publisher's adagents.json is hosted (e.g., 'cnn.com')", + "description": "Domain where publisher's adagents.json is hosted (e.g., 'cnn.com').", "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" }, "selection_type": { @@ -59,13 +92,23 @@ }, { "type": "object", - "description": "Select properties by tag membership", + "description": "Select properties by tag membership. With `publisher_domains`, the same `property_tags` predicate is resolved against each listed publisher's adagents.json \u2014 the common managed-network case where every represented site tags inventory with a shared label.", "properties": { "publisher_domain": { "type": "string", - "description": "Domain where publisher's adagents.json is hosted (e.g., 'cnn.com')", + "description": "Domain where publisher's adagents.json is hosted (e.g., 'cnn.com'). XOR with `publisher_domains` \u2014 exactly one MUST be present on each `publisher_properties[]` entry; both-present and neither-present both fail validation.", "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" }, + "publisher_domains": { + "type": "array", + "description": "Compact form for fanning the same tag predicate across many publishers (canonical managed-network shape). Each entry is the domain where that publisher's adagents.json is hosted. Each listed domain MUST be canonicalized to lowercase (the `pattern` already rejects uppercase). Mutually exclusive with `publisher_domain`. Each listed domain counts as explicitly scoped for the `managerdomain` fallback safety rule.", + "items": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, + "minItems": 1, + "uniqueItems": true + }, "selection_type": { "type": "string", "const": "by_tag", @@ -73,7 +116,7 @@ }, "property_tags": { "type": "array", - "description": "Property tags from the publisher's adagents.json. Selector covers all properties with these tags", + "description": "Property tags resolved against each addressed publisher's adagents.json. Selector covers all properties carrying any of these tags.", "items": { "$ref": "property-tag.json" }, @@ -81,10 +124,33 @@ } }, "required": [ - "publisher_domain", "selection_type", "property_tags" ], + "allOf": [ + { + "not": { + "required": [ + "publisher_domain", + "publisher_domains" + ] + } + }, + { + "anyOf": [ + { + "required": [ + "publisher_domain" + ] + }, + { + "required": [ + "publisher_domains" + ] + } + ] + } + ], "additionalProperties": true } ] diff --git a/schemas/cache/3.0/manifest.json b/schemas/cache/3.0/manifest.json index dce77a196..ed5f650ef 100644 --- a/schemas/cache/3.0/manifest.json +++ b/schemas/cache/3.0/manifest.json @@ -1185,4 +1185,4 @@ ] } } -} +} \ No newline at end of file diff --git a/schemas/patches/01-adagents-revoked-publisher-domains.patch b/schemas/patches/01-adagents-revoked-publisher-domains.patch new file mode 100644 index 000000000..da5659914 --- /dev/null +++ b/schemas/patches/01-adagents-revoked-publisher-domains.patch @@ -0,0 +1,59 @@ +# Patch: adagents.json revoked_publisher_domains[] +# Reason: forward-looking add — the SDK's dict-layer helpers +# (validate_revoked_publisher_domain_entry, filter_revoked_selectors) +# implement the contract today. This patch restores the field on the +# Pydantic-model layer so adopters get parity across the two paths. +# Filed: PR #753 (closes #729) +# Upstream status: landed in 3.1.0-beta.x (not 3.0.x). The SDK is pinned +# to 3.0.x via ADCP_VERSION, so the field stays patched until the SDK +# moves to a 3.1 floor. Verified against +# adcontextprotocol/adcp:dist/schemas/3.1.0-beta.2/adagents.json. +# Drop when: SDK pins ADCP_VERSION to 3.1.x AND the upstream 3.1 schema +# ships the same shape (verify by-eye before delete). If upstream +# restructures the field, this patch will fail "BROKEN" and need +# updating against the new shape. +diff --git a/schemas/cache/3.0/adagents.json b/schemas/cache/3.0/adagents.json +index 3db7e062..333a7c6f 100644 +--- a/schemas/cache/3.0/adagents.json ++++ b/schemas/cache/3.0/adagents.json +@@ -89,6 +89,40 @@ + }, + "minItems": 1 + }, ++ "revoked_publisher_domains": { ++ "type": "array", ++ "description": "Publisher domains explicitly removed from this managed network. Validators MUST treat any publisher domain listed here as no-longer-authorized, taking precedence over any appearance of the same domain in `authorized_agents[].publisher_properties[].publisher_domain` / `.publisher_domains[]` or in top-level `properties[].publisher_domain`. Lets a network propagate per-publisher revocations on the next refresh instead of waiting for the file-level 7-day cache cap. Validators MUST hold previously-observed `(publisher_domain, revoked_at)` tuples for 7 days from the validator's first observation, even if the entry vanishes from a subsequent fetch — this closes the rollback gap where an attacker re-serves a stale file with the revocation removed. Networks SHOULD retain entries for at least 7 days after `revoked_at` so validators that didn't observe the original entry still pick it up on refresh.", ++ "items": { ++ "type": "object", ++ "properties": { ++ "publisher_domain": { ++ "type": "string", ++ "description": "Publisher domain being revoked. Matches against the same canonicalized form used in `publisher_properties[].publisher_domain`.", ++ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" ++ }, ++ "revoked_at": { ++ "type": "string", ++ "format": "date-time", ++ "description": "ISO 8601 timestamp when this publisher was revoked. Validators MAY use this to order revocations against their own cached state." ++ }, ++ "reason": { ++ "type": "string", ++ "enum": [ ++ "relationship_ended", ++ "compliance_violation", ++ "publisher_request", ++ "other" ++ ], ++ "description": "Reason for revocation. Operator-internal self-classification for review routing — not a public accusation. `relationship_ended` is the routine commercial case. `compliance_violation` SHOULD be used only when the network has itself determined the publisher is out of policy; for un-adjudicated third-party allegations (regulator inquiries, advertiser complaints, ongoing investigations), use `other` to avoid making a discoverable adverse statement. `publisher_request` is for publisher-initiated exits." ++ } ++ }, ++ "required": [ ++ "publisher_domain", ++ "revoked_at" ++ ], ++ "additionalProperties": true ++ } ++ }, + "collections": { + "type": "array", + "description": "Collections produced or distributed by this publisher. Declares the content programs whose inventory is sold through authorized agents. Products in get_products responses reference these collections by collection_id.", diff --git a/schemas/patches/02-publisher-property-selector-publisher-domains.patch b/schemas/patches/02-publisher-property-selector-publisher-domains.patch new file mode 100644 index 000000000..588a91ddb --- /dev/null +++ b/schemas/patches/02-publisher-property-selector-publisher-domains.patch @@ -0,0 +1,169 @@ +# Patch: publisher-property-selector publisher_domains[] compact form +# Reason: forward-looking add — the SDK's dict-layer helpers +# (validate_publisher_properties_item, _fanout_publisher_properties, +# get_properties_by_agent) implement the contract today. This patch +# restores the field on the Pydantic-model layer so adopters get +# parity across the two paths. +# Filed: PR #753 (closes #729) +# Upstream status: landed in 3.1.0-beta.x (not 3.0.x). The SDK is pinned +# to 3.0.x via ADCP_VERSION, so the field stays patched until the SDK +# moves to a 3.1 floor. Verified against +# adcontextprotocol/adcp:dist/schemas/3.1.0-beta.2/core/publisher-property-selector.json. +# Drop when: SDK pins ADCP_VERSION to 3.1.x AND the upstream 3.1 schema +# ships the same shape (verify by-eye before delete). If upstream +# restructures the field, this patch will fail "BROKEN" and need +# updating against the new shape. +diff --git a/schemas/cache/3.0/core/publisher-property-selector.json b/schemas/cache/3.0/core/publisher-property-selector.json +index 67e7856c..4aeb6b29 100644 +--- a/schemas/cache/3.0/core/publisher-property-selector.json ++++ b/schemas/cache/3.0/core/publisher-property-selector.json +@@ -1,39 +1,72 @@ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Publisher Property Selector", +- "description": "Selects properties from a publisher's adagents.json. Used for both product definitions and agent authorization. Supports three selection patterns: all properties, specific IDs, or by tags.", ++ "description": "Selects properties from a publisher's adagents.json. Used for both product definitions and agent authorization. Supports three selection patterns: all properties, specific IDs, or by tags. Each selector targets one publisher via `publisher_domain` (string) or a fan-out across many publishers that share the same selector via `publisher_domains` (array). Exactly one of `publisher_domain` or `publisher_domains` MUST be present. When `publisher_domains` is used, the selector is logically equivalent to repeating the same entry once per listed domain.", + "discriminator": { + "propertyName": "selection_type" + }, + "oneOf": [ + { + "type": "object", +- "description": "Select all properties from the publisher domain", ++ "description": "Select all properties from one publisher domain, or from each publisher domain when `publisher_domains` is used.", + "properties": { + "publisher_domain": { + "type": "string", +- "description": "Domain where publisher's adagents.json is hosted (e.g., 'cnn.com')", ++ "description": "Domain where publisher's adagents.json is hosted (e.g., 'cnn.com'). XOR with `publisher_domains` — exactly one MUST be present on each `publisher_properties[]` entry; both-present and neither-present both fail validation.", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, ++ "publisher_domains": { ++ "type": "array", ++ "description": "Compact form for fanning the same selector across many publishers (e.g., a managed network listing every publisher it represents). Each entry is the domain where that publisher's adagents.json is hosted. Each listed domain MUST be canonicalized to lowercase (the `pattern` already rejects uppercase). Mutually exclusive with `publisher_domain`. Each listed domain counts as explicitly scoped for the `managerdomain` fallback safety rule.", ++ "items": { ++ "type": "string", ++ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" ++ }, ++ "minItems": 1, ++ "uniqueItems": true ++ }, + "selection_type": { + "type": "string", + "const": "all", +- "description": "Discriminator indicating all properties from this publisher are included" ++ "description": "Discriminator indicating all properties from each addressed publisher are included" + } + }, + "required": [ +- "publisher_domain", + "selection_type" + ], ++ "allOf": [ ++ { ++ "not": { ++ "required": [ ++ "publisher_domain", ++ "publisher_domains" ++ ] ++ } ++ }, ++ { ++ "anyOf": [ ++ { ++ "required": [ ++ "publisher_domain" ++ ] ++ }, ++ { ++ "required": [ ++ "publisher_domains" ++ ] ++ } ++ ] ++ } ++ ], + "additionalProperties": true + }, + { + "type": "object", +- "description": "Select specific properties by ID", ++ "description": "Select specific properties by ID. Single-publisher only — property IDs are publisher-scoped, so the compact `publisher_domains[]` form is intentionally NOT available for this selector. Use multiple `publisher_properties[]` entries (one per publisher) when each publisher's ID set differs.", + "properties": { + "publisher_domain": { + "type": "string", +- "description": "Domain where publisher's adagents.json is hosted (e.g., 'cnn.com')", ++ "description": "Domain where publisher's adagents.json is hosted (e.g., 'cnn.com').", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, + "selection_type": { +@@ -59,13 +92,23 @@ + }, + { + "type": "object", +- "description": "Select properties by tag membership", ++ "description": "Select properties by tag membership. With `publisher_domains`, the same `property_tags` predicate is resolved against each listed publisher's adagents.json — the common managed-network case where every represented site tags inventory with a shared label.", + "properties": { + "publisher_domain": { + "type": "string", +- "description": "Domain where publisher's adagents.json is hosted (e.g., 'cnn.com')", ++ "description": "Domain where publisher's adagents.json is hosted (e.g., 'cnn.com'). XOR with `publisher_domains` — exactly one MUST be present on each `publisher_properties[]` entry; both-present and neither-present both fail validation.", + "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" + }, ++ "publisher_domains": { ++ "type": "array", ++ "description": "Compact form for fanning the same tag predicate across many publishers (canonical managed-network shape). Each entry is the domain where that publisher's adagents.json is hosted. Each listed domain MUST be canonicalized to lowercase (the `pattern` already rejects uppercase). Mutually exclusive with `publisher_domain`. Each listed domain counts as explicitly scoped for the `managerdomain` fallback safety rule.", ++ "items": { ++ "type": "string", ++ "pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$" ++ }, ++ "minItems": 1, ++ "uniqueItems": true ++ }, + "selection_type": { + "type": "string", + "const": "by_tag", +@@ -73,7 +116,7 @@ + }, + "property_tags": { + "type": "array", +- "description": "Property tags from the publisher's adagents.json. Selector covers all properties with these tags", ++ "description": "Property tags resolved against each addressed publisher's adagents.json. Selector covers all properties carrying any of these tags.", + "items": { + "$ref": "property-tag.json" + }, +@@ -81,10 +124,33 @@ + } + }, + "required": [ +- "publisher_domain", + "selection_type", + "property_tags" + ], ++ "allOf": [ ++ { ++ "not": { ++ "required": [ ++ "publisher_domain", ++ "publisher_domains" ++ ] ++ } ++ }, ++ { ++ "anyOf": [ ++ { ++ "required": [ ++ "publisher_domain" ++ ] ++ }, ++ { ++ "required": [ ++ "publisher_domains" ++ ] ++ } ++ ] ++ } ++ ], + "additionalProperties": true + } + ] diff --git a/schemas/patches/03-manifest-signal-owned-discovery-only.patch b/schemas/patches/03-manifest-signal-owned-discovery-only.patch new file mode 100644 index 000000000..173593285 --- /dev/null +++ b/schemas/patches/03-manifest-signal-owned-discovery-only.patch @@ -0,0 +1,42 @@ +# Patch: manifest.json signal_owned activate_signal removal +# Reason: signal_owned (owned signal agents) is a discovery-only +# specialism — agents that own their own signals don't need a +# marketplace activation step. Upstream's 3.0.12 manifest includes +# activate_signal in both ``activate_signal.specialisms`` AND +# ``signal_owned.exercised_tools``, which would (a) require owned +# signal agents to implement activate_signal (defeating the purpose +# of the specialism) and (b) make conformance runners exercise +# marketplace activation for that specialism. +# Filed: PR #792 ("fix: allow owned signals discovery without activation") +# Upstream status: SDK self-correction — the upstream 3.0.12 manifest +# reflects the older two-specialism activate_signal model. The SDK's +# signal_owned platform is discovery-only by design. +# Drop when: upstream manifest removes signal_owned from +# activate_signal.specialisms AND activate_signal from +# signal_owned.exercised_tools (verify by-eye against the latest +# adcontextprotocol/adcp:dist/schemas/3.0.x/manifest.json). +--- a/schemas/cache/3.0/manifest.json 2026-05-13 06:39:52 ++++ b/schemas/cache/3.0/manifest.json 2026-05-22 06:36:26 +@@ -20,8 +20,7 @@ + "response_schema": "signals/activate-signal-response.json", + "async_response_schemas": [], + "specialisms": [ +- "signal_marketplace", +- "signal_owned" ++ "signal_marketplace" + ] + }, + "build_creative": { +@@ -1181,10 +1180,9 @@ + "get_signals" + ], + "exercised_tools": [ +- "activate_signal", + "get_adcp_capabilities", + "get_signals" + ] + } + } +-} ++} +\ No newline at end of file diff --git a/schemas/patches/README.md b/schemas/patches/README.md new file mode 100644 index 000000000..c3a5f093e --- /dev/null +++ b/schemas/patches/README.md @@ -0,0 +1,131 @@ +# schemas/patches/ + +Tracked, reviewable patches applied to the regenerated schema cache after +`make regenerate-schemas` (i.e., after `scripts/sync_schemas.py` extracts +the upstream protocol bundle into `schemas/cache/{bundle_key}/`). + +## Why this exists + +The schema cache under `schemas/cache/` is **upstream-verbatim** by design. +`make regenerate-schemas` blows it away and re-extracts the protocol +bundle on every run. That's the right default — it guarantees the cache +matches the published protocol byte-for-byte and prevents silent drift. + +But sometimes the SDK needs to carry a forward-looking surface (a field +the dict-layer helpers expose today, expected to land upstream soon) or +a workaround for an upstream regression. Before this directory existed, +the only option was to hand-edit `schemas/cache/` directly and hope +nobody ran `make regenerate-schemas` until upstream caught up. + +That bet lost on PR #791: PR #753 hand-patched `revoked_publisher_domains[]` +and `publisher_domains[]` (compact form) onto the 3.0 cache anticipating +they'd land in 3.0.10+; upstream chose to put them in 3.1.0-beta.x +instead; the regen to 3.0.12 silently overwrote both patches; the +Pydantic-model layer lost the fields while the dict helpers kept +implementing them. Nobody caught it until the regen diff was reviewed +line-by-line. + +This directory plus the post-process step in `sync_schemas.py` make +the pattern explicit: hand-rolled diffs are tracked, named, and applied +*after* a clean regen. CI fails loudly if a patch is dead (upstream +landed it) or broken (upstream restructured the file). + +## File layout + +``` +schemas/patches/ +├── README.md # this file +└── 01-publisher-domains-compact.patch # numbered prefix → lex order +└── 02-revoked-publisher-domains.patch +``` + +Patches apply in **lex (alphanumeric) order**. Use numbered prefixes +(`01-`, `02-`, …) when ordering matters (e.g., a later patch depends +on a path the earlier patch added). Most patches are independent and +the prefix is just a sort key. + +## Patch file format + +Each `.patch` file is a unified diff (the format `git diff` and +`diff -u` emit) plus a comment header. `patch(1)` applies them with +`-p1` from the repo root. + +```diff +# Patch: publisher_domains compact form on publisher-property-selector +# Reason: forward-looking add — the SDK's dict-layer helpers +# (validate_publisher_properties_item, _fanout_publisher_properties) +# implement the contract today; this patch restores the field on +# the Pydantic-model layer so adopters get parity. +# Filed: PR #753 +# Upstream status: landed in 3.1.0-beta.x (not 3.0.x); SDK is pinned to +# 3.0.x via ADCP_VERSION, so the field stays patched until the SDK +# moves to a 3.1 floor. +# Drop when: SDK pins to 3.1.x AND upstream 3.1 ships the same shape. + +--- a/schemas/cache/3.0/core/publisher-property-selector.json ++++ b/schemas/cache/3.0/core/publisher-property-selector.json +@@ -... +``` + +The header is plain comment lines starting with `#`. The unified-diff +body starts with `--- a/` / `+++ b/` paths relative to the repo root. + +## Lifecycle of a patch + +A patch is **alive** when it applies cleanly against the regenerated +upstream cache. The sync script applies it and continues. + +A patch is **dead** when it reverse-applies cleanly — meaning the +upstream cache already contains the patch's target shape. Upstream +landed the field. The sync script fails loudly with the patch name +and the directive to delete the file with a documented rationale. +Silently no-op'ing here would let a stale `.patch` linger in the +directory forever. + +A patch is **broken** when neither forward- nor reverse-application +works. Upstream restructured the file in a way the patch can't +follow. The sync script fails loudly with the patch name and the +operator must either update the patch hunks against the new shape or +delete the patch outright with a documented rationale (e.g. "upstream +removed this surface; SDK helpers also removed"). + +## Adding a new patch + +1. Make the hand-edit on `schemas/cache/{bundle_key}/.json` + locally. +2. Run `git diff schemas/cache/{bundle_key}/.json > schemas/patches/NN-name.patch`. +3. Edit the patch file to add a header (Patch / Reason / Filed / + Upstream status / Drop when). +4. Stage both the patch file and the patched cache file. +5. CI's `check-schema-drift` will run `sync_schemas.py` (which now + includes patch application) and confirm the patched cache matches + the checked-in shape. + +The cache file lives in the working tree alongside the patch because +adopters who don't run regen-with-patches still need a functional +cache. The patch is the audit-trail of how it got that way. + +## Removing a dead patch + +When the sync script reports "Patch X is dead — upstream landed this +change": + +1. Delete the `.patch` file with a commit message referencing the + upstream version that landed the feature. +2. Run `make regenerate-schemas` to confirm the cache now matches + upstream-verbatim with no patch applied. +3. If consumer code (Pydantic models, dict helpers, tests) depended + on the pre-upstream shape, fold those updates into the same + commit or a follow-up. + +## Anti-patterns + +- **Don't edit `schemas/cache/` without a corresponding `.patch` + file.** Next regen overwrites the edit. The infrastructure exists + precisely to make this impossible to forget. +- **Don't bundle multiple unrelated diffs in one `.patch` file.** + One file per logical change. Numbered prefixes order them. +- **Don't omit the `Drop when:` line.** A patch with no exit criterion + becomes permanent technical debt. If you genuinely can't articulate + when this would be removable, you probably shouldn't be patching + upstream at all. diff --git a/scripts/sync_schemas.py b/scripts/sync_schemas.py index e7492132a..d7cd64e56 100755 --- a/scripts/sync_schemas.py +++ b/scripts/sync_schemas.py @@ -45,6 +45,7 @@ REPO_ROOT = Path(__file__).parent.parent CACHE_DIR = REPO_ROOT / "schemas" / "cache" +PATCHES_DIR = REPO_ROOT / "schemas" / "patches" SKILLS_DIR = REPO_ROOT / "skills" VERSION_FILE = REPO_ROOT / "src" / "adcp" / "ADCP_VERSION" @@ -261,6 +262,137 @@ def replace_cache_from_bundle(bundle_root: Path, bundle_key: str) -> int: return sum(1 for _ in dest.rglob("*") if _.is_file()) +def apply_tracked_patches() -> int: + """Apply every ``schemas/patches/*.patch`` file to the freshly-extracted + schema cache, in lex order. + + Patches are unified diffs with a comment header (Patch / Reason / Filed + / Upstream status / Drop when). See ``schemas/patches/README.md`` for + the convention. + + Each patch resolves to one of three states: + + 1. **Alive** — applies cleanly. The script applies it and continues. + 2. **Dead** — already applied (the patch reverse-applies cleanly). + Upstream landed this change. Script fails loudly with the directive + to delete the patch with a documented rationale; silently no-op'ing + would let stale patches linger forever. + 3. **Broken** — neither forward- nor reverse-application succeeds. + Upstream restructured the target file. Script fails loudly; the + operator must either update the patch hunks against the new shape + or delete the patch outright. + + Returns the number of patches applied. ``0`` is a valid, expected + state when ``schemas/patches/`` is empty (e.g., immediately after the + infrastructure lands, before any patches are filed against it). + + Exits the process on any patch failure — matches the rest of + ``sync_schemas.py``'s fail-loud posture so CI surfaces patch breakage + instead of producing a quietly-divergent cache. + """ + if not PATCHES_DIR.is_dir(): + # Directory doesn't exist yet — no patches, nothing to do. Don't + # create the directory here; that's an explicit setup step. + return 0 + + patch_files = sorted(PATCHES_DIR.glob("*.patch")) + if not patch_files: + return 0 + + print(f"\nApplying {len(patch_files)} tracked patch(es) from schemas/patches/...") + applied = 0 + for patch_path in patch_files: + state = _classify_patch(patch_path) + if state == "alive": + _apply_patch(patch_path) + print(f" ✓ Applied: {patch_path.name}") + applied += 1 + elif state == "dead": + print( + f"\n✗ Patch is DEAD (upstream already has this change): " + f"{patch_path.name}\n" + " Upstream landed the patched shape — the patch is no longer\n" + " needed. Delete the .patch file with a commit message naming\n" + " the upstream version that landed the feature, and fold any\n" + " consumer-code updates (Pydantic models, dict helpers, tests)\n" + " that depended on the pre-upstream shape.", + file=sys.stderr, + ) + sys.exit(1) + else: # broken + print( + f"\n✗ Patch is BROKEN (neither forward- nor reverse-applies): " + f"{patch_path.name}\n" + " Upstream restructured the target file in a way the patch\n" + " cannot follow. Either update the patch hunks against the\n" + " new shape or delete the patch outright with a documented\n" + " rationale (e.g. 'upstream removed this surface; SDK\n" + " helpers also removed').", + file=sys.stderr, + ) + sys.exit(1) + return applied + + +def _classify_patch(patch_path: Path) -> str: + """Classify a patch as ``"alive"``, ``"dead"``, or ``"broken"``. + + Uses ``patch --dry-run`` from the repo root (paths in the diff are + repo-root-relative under the ``-p1`` strip convention). Tries forward + first; on forward-failure, tries reverse to distinguish dead from broken. + """ + if _patch_dry_run(patch_path, reverse=False): + return "alive" + if _patch_dry_run(patch_path, reverse=True): + return "dead" + return "broken" + + +def _patch_dry_run(patch_path: Path, *, reverse: bool) -> bool: + """Return True iff ``patch --dry-run`` reports the patch can apply. + + ``--silent`` suppresses the "Hunk #N succeeded at line M" chatter on + the success path so the script's own output stays the signal. On + failure ``patch`` prints to stderr and returns non-zero — we capture + both and discard since the caller only needs the boolean. + """ + args = ["patch", "-p1", "--dry-run", "--silent", "--force"] + if reverse: + args.append("--reverse") + args += ["-i", str(patch_path)] + result = subprocess.run( + args, + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + + +def _apply_patch(patch_path: Path) -> None: + """Apply a patch for real. Raises on any failure. + + Pre-classification guarantees the dry-run succeeded, so the only way + this can fail at this point is a TOCTOU change on the working tree + between ``_classify_patch`` and here — vanishingly unlikely under CI + but worth surfacing rather than swallowing. + """ + args = ["patch", "-p1", "--silent", "--force", "-i", str(patch_path)] + result = subprocess.run( + args, + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + f"Patch {patch_path.name} passed dry-run but failed to apply: " + f"{result.stderr.strip() or result.stdout.strip()}" + ) + + def sync_skills_from_bundle(bundle_root: Path, skills_dir: Path) -> int: """Sync protocol-managed skills from the bundle into skills_dir. @@ -480,6 +612,21 @@ def main() -> None: ) sys.exit(1) + # Apply tracked hand-patches once, AFTER every bundle (primary + + # previews) has been extracted into ``schemas/cache/``. Doing this in + # the main entry point — not per-bundle inside ``_sync_one`` — keeps + # patch state coherent: a patch against ``schemas/cache/3.0/...`` + # would otherwise apply on the primary 3.0 pass and then misclassify + # as "dead" on the subsequent 3.1 preview pass (its target file is + # already patched). One pass at the end avoids that artifact. + # + # See schemas/patches/README.md for the patch-file convention and the + # lifecycle a patch follows (alive → dead → broken). Failure modes + # exit non-zero from inside ``apply_tracked_patches``. + patch_count = apply_tracked_patches() + if patch_count: + print(f"\n✓ Applied {patch_count} tracked patch(es) from schemas/patches/") + if __name__ == "__main__": main() diff --git a/src/adcp/types/_generated.py b/src/adcp/types/_generated.py index 801777537..9d968e176 100644 --- a/src/adcp/types/_generated.py +++ b/src/adcp/types/_generated.py @@ -10,7 +10,7 @@ DO NOT EDIT MANUALLY. Generated from: https://github.com/adcontextprotocol/adcp/tree/main/schemas -Generation date: 2026-05-22 08:52:13 UTC +Generation date: 2026-05-22 10:39:28 UTC """ # ruff: noqa: E501, I001 @@ -242,6 +242,8 @@ Country, DelegationType, PlacementTags, + Reason, + RevokedPublisherDomain, SignalTag, SignalTags, Tags, @@ -747,6 +749,7 @@ VerificationItem, ) from adcp.types.generated_poc.core.publisher_property_selector import ( + PublisherDomain, PublisherPropertySelector, PublisherPropertySelector1, PublisherPropertySelector2, @@ -966,7 +969,6 @@ PreviewRender3, ) from adcp.types.generated_poc.creative.sync_creatives_async_response_input_required import ( - Reason, SyncCreativesInputRequired, ) from adcp.types.generated_poc.creative.sync_creatives_async_response_submitted import ( @@ -1356,7 +1358,6 @@ MraidVersion, NegativeKeywords, PrimaryCountry, - PublisherDomain, ReportingDeliveryMethod, RequestSigning, Signals, @@ -7130,6 +7131,7 @@ "Results1", "Results3", "RevocationNotification", + "RevokedPublisherDomain", "Right", "Right2", "Right7", diff --git a/src/adcp/types/generated_poc/adagents.py b/src/adcp/types/generated_poc/adagents.py index c7eb61cb8..2c475dc74 100644 --- a/src/adcp/types/generated_poc/adagents.py +++ b/src/adcp/types/generated_poc/adagents.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: adagents.json -# timestamp: 2026-05-22T08:52:07+00:00 +# timestamp: 2026-05-22T10:38:59+00:00 from __future__ import annotations @@ -95,6 +95,38 @@ class Contact(AdCPBaseModel): ] = None +class Reason(Enum): + relationship_ended = 'relationship_ended' + compliance_violation = 'compliance_violation' + publisher_request = 'publisher_request' + other = 'other' + + +class RevokedPublisherDomain(AdCPBaseModel): + model_config = ConfigDict( + extra='allow', + ) + publisher_domain: Annotated[ + str, + Field( + description='Publisher domain being revoked. Matches against the same canonicalized form used in `publisher_properties[].publisher_domain`.', + pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', + ), + ] + revoked_at: Annotated[ + AwareDatetime, + Field( + description='ISO 8601 timestamp when this publisher was revoked. Validators MAY use this to order revocations against their own cached state.' + ), + ] + reason: Annotated[ + Reason | None, + Field( + description='Reason for revocation. Operator-internal self-classification for review routing — not a public accusation. `relationship_ended` is the routine commercial case. `compliance_violation` SHOULD be used only when the network has itself determined the publisher is out of policy; for un-adjudicated third-party allegations (regulator inquiries, advertiser complaints, ongoing investigations), use `other` to avoid making a discoverable adverse statement. `publisher_request` is for publisher-initiated exits.' + ), + ] = None + + class Tags(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -644,6 +676,12 @@ class AdcpAgentsAuthorization2(AdCPBaseModel): min_length=1, ), ] = None + revoked_publisher_domains: Annotated[ + list[RevokedPublisherDomain] | None, + Field( + description="Publisher domains explicitly removed from this managed network. Validators MUST treat any publisher domain listed here as no-longer-authorized, taking precedence over any appearance of the same domain in `authorized_agents[].publisher_properties[].publisher_domain` / `.publisher_domains[]` or in top-level `properties[].publisher_domain`. Lets a network propagate per-publisher revocations on the next refresh instead of waiting for the file-level 7-day cache cap. Validators MUST hold previously-observed `(publisher_domain, revoked_at)` tuples for 7 days from the validator's first observation, even if the entry vanishes from a subsequent fetch — this closes the rollback gap where an attacker re-serves a stale file with the revocation removed. Networks SHOULD retain entries for at least 7 days after `revoked_at` so validators that didn't observe the original entry still pick it up on refresh." + ), + ] = None collections: Annotated[ list[collection.Collection] | None, Field( diff --git a/src/adcp/types/generated_poc/core/publisher_property_selector.py b/src/adcp/types/generated_poc/core/publisher_property_selector.py index 0e356d094..ac974e35a 100644 --- a/src/adcp/types/generated_poc/core/publisher_property_selector.py +++ b/src/adcp/types/generated_poc/core/publisher_property_selector.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/publisher_property_selector.json -# timestamp: 2026-05-22T08:52:07+00:00 +# timestamp: 2026-05-22T10:38:59+00:00 from __future__ import annotations @@ -12,21 +12,34 @@ from . import property_id, property_tag +class PublisherDomain(RootModel[str]): + root: Annotated[ + str, Field(pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$') + ] + + class PublisherPropertySelector1(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) publisher_domain: Annotated[ - str, + str | None, Field( - description="Domain where publisher's adagents.json is hosted (e.g., 'cnn.com')", + description="Domain where publisher's adagents.json is hosted (e.g., 'cnn.com'). XOR with `publisher_domains` — exactly one MUST be present on each `publisher_properties[]` entry; both-present and neither-present both fail validation.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), - ] + ] = None + publisher_domains: Annotated[ + list[PublisherDomain] | None, + Field( + description="Compact form for fanning the same selector across many publishers (e.g., a managed network listing every publisher it represents). Each entry is the domain where that publisher's adagents.json is hosted. Each listed domain MUST be canonicalized to lowercase (the `pattern` already rejects uppercase). Mutually exclusive with `publisher_domain`. Each listed domain counts as explicitly scoped for the `managerdomain` fallback safety rule.", + min_length=1, + ), + ] = None selection_type: Annotated[ Literal['all'], Field( - description='Discriminator indicating all properties from this publisher are included' + description='Discriminator indicating all properties from each addressed publisher are included' ), ] = 'all' @@ -38,7 +51,7 @@ class PublisherPropertySelector2(AdCPBaseModel): publisher_domain: Annotated[ str, Field( - description="Domain where publisher's adagents.json is hosted (e.g., 'cnn.com')", + description="Domain where publisher's adagents.json is hosted (e.g., 'cnn.com').", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), ] @@ -57,19 +70,26 @@ class PublisherPropertySelector3(AdCPBaseModel): extra='allow', ) publisher_domain: Annotated[ - str, + str | None, Field( - description="Domain where publisher's adagents.json is hosted (e.g., 'cnn.com')", + description="Domain where publisher's adagents.json is hosted (e.g., 'cnn.com'). XOR with `publisher_domains` — exactly one MUST be present on each `publisher_properties[]` entry; both-present and neither-present both fail validation.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), - ] + ] = None + publisher_domains: Annotated[ + list[PublisherDomain] | None, + Field( + description="Compact form for fanning the same tag predicate across many publishers (canonical managed-network shape). Each entry is the domain where that publisher's adagents.json is hosted. Each listed domain MUST be canonicalized to lowercase (the `pattern` already rejects uppercase). Mutually exclusive with `publisher_domain`. Each listed domain counts as explicitly scoped for the `managerdomain` fallback safety rule.", + min_length=1, + ), + ] = None selection_type: Annotated[ Literal['by_tag'], Field(description='Discriminator indicating selection by property tags') ] = 'by_tag' property_tags: Annotated[ list[property_tag.PropertyTag], Field( - description="Property tags from the publisher's adagents.json. Selector covers all properties with these tags", + description="Property tags resolved against each addressed publisher's adagents.json. Selector covers all properties carrying any of these tags.", min_length=1, ), ] @@ -81,7 +101,7 @@ class PublisherPropertySelector( root: Annotated[ PublisherPropertySelector1 | PublisherPropertySelector2 | PublisherPropertySelector3, Field( - description="Selects properties from a publisher's adagents.json. Used for both product definitions and agent authorization. Supports three selection patterns: all properties, specific IDs, or by tags.", + description="Selects properties from a publisher's adagents.json. Used for both product definitions and agent authorization. Supports three selection patterns: all properties, specific IDs, or by tags. Each selector targets one publisher via `publisher_domain` (string) or a fan-out across many publishers that share the same selector via `publisher_domains` (array). Exactly one of `publisher_domain` or `publisher_domains` MUST be present. When `publisher_domains` is used, the selector is logically equivalent to repeating the same entry once per listed domain.", discriminator='selection_type', title='Publisher Property Selector', ), diff --git a/tests/test_sync_schemas.py b/tests/test_sync_schemas.py index 3c28c9196..c818cb51c 100644 --- a/tests/test_sync_schemas.py +++ b/tests/test_sync_schemas.py @@ -420,3 +420,207 @@ def test_env_override_rejects_protocol_suffix_with_trailing_slash( fresh_mod = importlib.util.module_from_spec(fresh_spec) with pytest.raises(ValueError, match="ends with '/protocol'"): fresh_spec.loader.exec_module(fresh_mod) # type: ignore[union-attr] + + +# --------------------------------------------------------------------------- +# Patches/ post-process — alive / dead / broken classification + apply +# --------------------------------------------------------------------------- + + +def _write_patch(patches_dir: Path, name: str, body: str) -> Path: + """Helper: write a unified-diff patch file with a comment header.""" + path = patches_dir / name + path.write_text(body, encoding="utf-8") + return path + + +class TestApplyTrackedPatches: + """Patch state machine — verifies the alive/dead/broken classifier + fails loudly on dead and broken patches, and applies alive ones + cleanly. The cache directory is monkey-patched per test so we never + touch the real schemas/cache/.""" + + def _wire_isolated_dirs( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> tuple[Path, Path]: + """Point CACHE_DIR, PATCHES_DIR, and REPO_ROOT at a temp workspace. + + ``apply_tracked_patches`` shells out to ``patch -p1`` from + ``REPO_ROOT`` and reads from ``PATCHES_DIR``; redirecting all + three keeps the test hermetic. + """ + repo = tmp_path / "repo" + cache = repo / "schemas" / "cache" + patches = repo / "schemas" / "patches" + cache.mkdir(parents=True) + patches.mkdir(parents=True) + monkeypatch.setattr(_mod, "REPO_ROOT", repo) + monkeypatch.setattr(_mod, "CACHE_DIR", cache) + monkeypatch.setattr(_mod, "PATCHES_DIR", patches) + return cache, patches + + def test_empty_patches_dir_is_noop( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + # Initial state immediately after the infrastructure lands — + # patches/ exists with only a README, no .patch files. Must + # return 0 cleanly so the sync script doesn't fail-loud on the + # back-compat path. + _cache, patches = self._wire_isolated_dirs(monkeypatch, tmp_path) + # README.md is fine; only .patch files are picked up. + (patches / "README.md").write_text("# notes\n", encoding="utf-8") + assert _mod.apply_tracked_patches() == 0 + + def test_missing_patches_dir_is_noop( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + # patches/ doesn't exist at all (fresh checkout before the + # directory is created). Same back-compat path — return 0. + repo = tmp_path / "repo" + repo.mkdir() + monkeypatch.setattr(_mod, "REPO_ROOT", repo) + monkeypatch.setattr(_mod, "PATCHES_DIR", repo / "schemas" / "patches") + assert _mod.apply_tracked_patches() == 0 + + def test_alive_patch_applies_cleanly( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + # Build a real target file and a unified diff that adds a field + # to it. The classifier should report "alive" and the apply + # step should write the new bytes. + cache, patches = self._wire_isolated_dirs(monkeypatch, tmp_path) + target_rel = "schemas/cache/3.0/test.json" + target = cache / "3.0" / "test.json" + target.parent.mkdir(parents=True) + target.write_text('{"a": 1}\n', encoding="utf-8") + + _write_patch( + patches, + "01-add-b.patch", + f"""# Patch: add field b +# Reason: test fixture +# Drop when: never (test-only) +--- a/{target_rel} ++++ b/{target_rel} +@@ -1 +1 @@ +-{{"a": 1}} ++{{"a": 1, "b": 2}} +""", + ) + + assert _mod.apply_tracked_patches() == 1 + assert '"b": 2' in target.read_text(encoding="utf-8") + + def test_dead_patch_fails_loudly( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + # Simulate upstream landing the patch: the target file already + # has the patched shape. Forward-apply fails; reverse-apply + # succeeds → classified as "dead". The script must exit + # non-zero and the stderr must name the patch so the operator + # knows which file to delete. + cache, patches = self._wire_isolated_dirs(monkeypatch, tmp_path) + target_rel = "schemas/cache/3.0/test.json" + target = cache / "3.0" / "test.json" + target.parent.mkdir(parents=True) + # File is already in the post-patch state. + target.write_text('{"a": 1, "b": 2}\n', encoding="utf-8") + + _write_patch( + patches, + "01-add-b.patch", + f"""# Patch: add field b +# Reason: test fixture +# Drop when: never (test-only) +--- a/{target_rel} ++++ b/{target_rel} +@@ -1 +1 @@ +-{{"a": 1}} ++{{"a": 1, "b": 2}} +""", + ) + + with pytest.raises(SystemExit) as exc_info: + _mod.apply_tracked_patches() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "DEAD" in captured.err + assert "01-add-b.patch" in captured.err + + def test_broken_patch_fails_loudly( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str] + ) -> None: + # Upstream restructured the file so neither forward- nor + # reverse-apply works. Must exit non-zero with the patch name + # surfaced so the operator can update or remove it. + cache, patches = self._wire_isolated_dirs(monkeypatch, tmp_path) + target_rel = "schemas/cache/3.0/test.json" + target = cache / "3.0" / "test.json" + target.parent.mkdir(parents=True) + # File contents differ from both the patch's pre- and post-state. + target.write_text('{"completely": "different"}\n', encoding="utf-8") + + _write_patch( + patches, + "01-add-b.patch", + f"""# Patch: add field b +# Reason: test fixture +# Drop when: never (test-only) +--- a/{target_rel} ++++ b/{target_rel} +@@ -1 +1 @@ +-{{"a": 1}} ++{{"a": 1, "b": 2}} +""", + ) + + with pytest.raises(SystemExit) as exc_info: + _mod.apply_tracked_patches() + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "BROKEN" in captured.err + assert "01-add-b.patch" in captured.err + + def test_patches_apply_in_lex_order( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + # Two patches against the same file; the second one depends on + # the line layout the first creates. Iff applied in lex order, + # both succeed. Pins the ordering convention so a future + # refactor (e.g. os.listdir order) can't break it. + cache, patches = self._wire_isolated_dirs(monkeypatch, tmp_path) + target_rel = "schemas/cache/3.0/test.json" + target = cache / "3.0" / "test.json" + target.parent.mkdir(parents=True) + target.write_text("a\n", encoding="utf-8") + + _write_patch( + patches, + "01-first.patch", + f"""# Patch: first +# Reason: test fixture +# Drop when: never +--- a/{target_rel} ++++ b/{target_rel} +@@ -1 +1,2 @@ + a ++b +""", + ) + _write_patch( + patches, + "02-second.patch", + f"""# Patch: second (depends on 01-first having added 'b') +# Reason: test fixture +# Drop when: never +--- a/{target_rel} ++++ b/{target_rel} +@@ -1,2 +1,3 @@ + a + b ++c +""", + ) + + assert _mod.apply_tracked_patches() == 2 + assert target.read_text(encoding="utf-8") == "a\nb\nc\n"