From 153b7c4915a763bd7437c73e79946ccc45642bcd Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 22 May 2026 06:18:04 -0400 Subject: [PATCH 1/2] feat(schemas): patches/ post-process infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-edits to the regenerated schema cache used to get silently overwritten by ``make regenerate-schemas`` — that's exactly how PR #753's forward-looking ``revoked_publisher_domains[]`` + ``publisher_domains[]`` compact-form patches got wiped on PR #791's bump to 3.0.12. The patches were invisible to anyone running the regen, and the loss only surfaced on line-by-line diff review of the regen output. This introduces ``schemas/patches/`` as a tracked, reviewable layer of hand-edits applied AFTER the upstream-verbatim extraction in ``scripts/sync_schemas.py``. Each ``.patch`` file is a unified diff with a comment header (Patch / Reason / Filed / Upstream status / Drop when), applied in lex order from the repo root via ``patch -p1``. The state machine in ``apply_tracked_patches`` classifies each patch as: - **Alive** — forward-applies cleanly → apply, continue. - **Dead** — reverse-applies cleanly (upstream landed it) → exit non-zero with the patch name + directive to delete the file. - **Broken** — neither direction applies → exit non-zero with the patch name + directive to either update the hunks or remove the patch. Dead and broken both fail loudly because silently no-op'ing on dead would let stale ``.patch`` files linger forever, and silently skipping broken would let the SDK ship a cache whose patched fields don't actually exist in the working tree. Patch-application runs ONCE at the end of ``main()`` (after all primary + preview bundles have been extracted), not inside ``_sync_one`` per-bundle. Per-bundle would misclassify a 3.0 patch as "dead" during the subsequent 3.1 preview pass because the cache wouldn't reset between passes. The existing ``make check-schema-drift`` target picks up patch-apply without changes — it already re-runs ``sync_schemas.py`` and diffs the cache. With patches in the directory, the diff now validates that patches still apply AND the resulting cache matches what's checked in. The directory ships empty in this commit. The two #753-restoration patches follow in a separate commit once PR #791 (3.0.12 regen) lands on main — they need the post-regen cache as the diff base. Refs #791, #753 Co-Authored-By: Claude Opus 4.7 (1M context) --- schemas/patches/README.md | 131 ++++++++++++++++++++++++ scripts/sync_schemas.py | 147 ++++++++++++++++++++++++++ tests/test_sync_schemas.py | 204 +++++++++++++++++++++++++++++++++++++ 3 files changed, 482 insertions(+) create mode 100644 schemas/patches/README.md 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/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" From f0ff82bcd19ca90d8ec0c18b64783f75065d15a8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 22 May 2026 06:40:21 -0400 Subject: [PATCH 2/2] feat(schemas): restore #753 + #792 hand-edits via tracked patches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three patches restore prior hand-edits to the 3.0 schema cache that were silently dropped on the 3.0.7 → 3.0.12 regen (#791): 01-adagents-revoked-publisher-domains.patch ``adagents.json`` gains top-level ``revoked_publisher_domains[]`` with the Reason enum. SDK 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 both code paths. Filed in PR #753 — upstream took the shape into 3.1.0-beta.x rather than 3.0.x, so the patch stays alive until the SDK moves to a 3.1 floor. 02-publisher-property-selector-publisher-domains.patch ``publisher-property-selector.json`` gains optional ``publisher_domains[]`` (compact form) on the `all` and `by_tag` selectors, XOR with the singular ``publisher_domain``. SDK helpers (``_fanout_publisher_properties``, ``validate_publisher_properties_item``, ``get_properties_by_agent``) implement the contract; same Pydantic- layer parity restoration. Filed in PR #753 — same upstream-status as patch 01 (landed in 3.1.0-beta.x). 03-manifest-signal-owned-discovery-only.patch ``manifest.json`` removes ``signal_owned`` from ``activate_signal.specialisms`` and ``activate_signal`` from ``signal_owned.exercised_tools``. Owned signal agents are discovery-only by design; upstream's 3.0.12 manifest still includes the old two-specialism shape, which would require owned signal agents to implement ``activate_signal`` (defeating the specialism purpose) and make conformance runners exercise marketplace activation. Filed in PR #792 — SDK self-correction. Drops when upstream updates the manifest shape. Each patch carries its own audit-trail header (Patch / Reason / Filed / Upstream status / Drop when) so the next reader has full context without needing git archaeology. See ``schemas/patches/README.md`` for the convention. Pydantic regen (``make regenerate-schemas``) picks up all three restored fields: src/adcp/types/_generated.py — RevokedPublisherDomain, PublisherDomain (per-arm) src/adcp/types/generated_poc/adagents.py src/adcp/types/generated_poc/core/publisher_property_selector.py SCHEMA_DELTAS.md now reports "no field-shape changes detected" because the post-patch generated types match the prior committed state. PR #791 had recorded the deletions as part of its regen; this commit reverses them via the patches infra. Tests: ``pytest tests/`` — 5026 passed, 30 skipped, 1 xfailed. ruff + mypy clean. Refs #753, #791, #792 Co-Authored-By: Claude Opus 4.7 (1M context) --- SCHEMA_DELTAS.md | 12 +- schemas/cache/3.0/adagents.json | 34 ++++ .../3.0/core/publisher-property-selector.json | 88 +++++++-- schemas/cache/3.0/manifest.json | 2 +- ...1-adagents-revoked-publisher-domains.patch | 59 ++++++ ...-property-selector-publisher-domains.patch | 169 ++++++++++++++++++ ...manifest-signal-owned-discovery-only.patch | 42 +++++ src/adcp/types/_generated.py | 8 +- src/adcp/types/generated_poc/adagents.py | 40 ++++- .../core/publisher_property_selector.py | 42 +++-- 10 files changed, 458 insertions(+), 38 deletions(-) create mode 100644 schemas/patches/01-adagents-revoked-publisher-domains.patch create mode 100644 schemas/patches/02-publisher-property-selector-publisher-domains.patch create mode 100644 schemas/patches/03-manifest-signal-owned-discovery-only.patch 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/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', ),