Module adcp.acceptance
Advisory buyer resolution for seller acceptance-policy catalogs.
The seller remains authoritative for every transaction. This module resolves the optional discovery catalog as untrusted remote configuration, verifies all content and registry pins, and reports only a conservative preflight outcome. Missing information never becomes an allow decision.
Classes
class AcceptancePolicyAssessment (**data: Any)-
Expand source code
class AcceptancePolicyAssessment(BaseModel): """Advisory policy preflight; an exact seller response remains authoritative.""" model_config = ConfigDict(extra="forbid", frozen=True) outcome: AcceptancePolicyOutcome advisory: Literal[True] = True profile_ids: tuple[str, ...] = () matching_rule_ids: tuple[str, ...] = () requirements: tuple[AcceptancePolicyRequirement, ...] = () diagnostics: tuple[AcceptancePolicyDiagnostic, ...] = ()Advisory policy preflight; an exact seller response remains authoritative.
Create a new model by parsing and validating input data from keyword arguments.
Raises [
ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.selfis explicitly positional-only to allowselfas a field name.Ancestors
- pydantic.main.BaseModel
Class variables
var advisory : Literal[True]var diagnostics : tuple[AcceptancePolicyDiagnostic, ...]var matching_rule_ids : tuple[str, ...]var model_configvar outcome : AcceptancePolicyOutcomevar profile_ids : tuple[str, ...]var requirements : tuple[AcceptancePolicyRequirement1 | AcceptancePolicyRequirement2 | AcceptancePolicyRequirement3 | AcceptancePolicyRequirement4 | AcceptancePolicyRequirement5 | AcceptancePolicyRequirement6 | AcceptancePolicyRequirement7 | AcceptancePolicyRequirement8 | AcceptancePolicyRequirement9 | AcceptancePolicyRequirement10 | AcceptancePolicyRequirement11 | AcceptancePolicyRequirement12 | AcceptancePolicyRequirement13 | AcceptancePolicyRequirement14 | AcceptancePolicyRequirement15 | AcceptancePolicyRequirement16 | AcceptancePolicyRequirement17, ...]
class AcceptancePolicyDiagnostic (**data: Any)-
Expand source code
class AcceptancePolicyDiagnostic(BaseModel): """Bounded diagnostic that never includes remote policy prose.""" model_config = ConfigDict(extra="forbid", frozen=True) code: AcceptancePolicyDiagnosticCode profile_id: str | None = Field(default=None, max_length=255) policy_id: str | None = Field(default=None, max_length=255) rule_id: str | None = Field(default=None, max_length=255)Bounded diagnostic that never includes remote policy prose.
Create a new model by parsing and validating input data from keyword arguments.
Raises [
ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.selfis explicitly positional-only to allowselfas a field name.Ancestors
- pydantic.main.BaseModel
Class variables
var code : AcceptancePolicyDiagnosticCodevar model_configvar policy_id : str | Nonevar profile_id : str | Nonevar rule_id : str | None
class AcceptancePolicyDiagnosticCode (*args, **kwds)-
Expand source code
class AcceptancePolicyDiagnosticCode(str, Enum): """Stable machine-readable reasons an assessment could not be conclusive.""" capability_unavailable = "capability_unavailable" invalid_discovery = "invalid_discovery" invalid_profile_ids = "invalid_profile_ids" unsafe_catalog_url = "unsafe_catalog_url" catalog_timeout = "catalog_timeout" catalog_fetch_failed = "catalog_fetch_failed" catalog_too_large = "catalog_too_large" catalog_digest_mismatch = "catalog_digest_mismatch" catalog_invalid_json = "catalog_invalid_json" catalog_schema_invalid = "catalog_schema_invalid" duplicate_profile_id = "duplicate_profile_id" profile_unresolved = "profile_unresolved" profile_digest_mismatch = "profile_digest_mismatch" profile_invalid = "profile_invalid" policy_unresolved = "policy_unresolved" policy_version_mismatch = "policy_version_mismatch" policy_digest_mismatch = "policy_digest_mismatch" policy_invalid = "policy_invalid" invalid_region_alias = "invalid_region_alias" partial_coverage = "partial_coverage" incomplete_context = "incomplete_context"Stable machine-readable reasons an assessment could not be conclusive.
Ancestors
- builtins.str
- enum.Enum
Class variables
var catalog_digest_mismatchvar catalog_fetch_failedvar catalog_invalid_jsonvar catalog_schema_invalidvar catalog_timeoutvar catalog_too_largevar duplicate_profile_idvar incomplete_contextvar invalid_discoveryvar invalid_profile_idsvar invalid_region_aliasvar partial_coveragevar policy_digest_mismatchvar policy_invalidvar policy_unresolvedvar policy_version_mismatchvar profile_digest_mismatchvar profile_invalidvar profile_unresolvedvar unsafe_catalog_url
class AcceptancePolicyOutcome (*args, **kwds)-
Expand source code
class AcceptancePolicyOutcome(str, Enum): """Conservative advisory result for the contemplated seller action.""" allowed = "allowed" prohibited = "prohibited" requires_disclosure = "requires_disclosure" requires_setup = "requires_setup" requires_review = "requires_review" unknown = "unknown"Conservative advisory result for the contemplated seller action.
Ancestors
- builtins.str
- enum.Enum
Class variables
var allowedvar prohibitedvar requires_disclosurevar requires_reviewvar requires_setupvar unknown
class AcceptancePolicyRegistry (*args, **kwargs)-
Expand source code
class AcceptancePolicyRegistry(Protocol): """Minimal registry surface used to resolve immutable policy versions.""" async def resolve_policy(self, policy_id: str, version: str | None = None) -> Policy | None: """Resolve one exact registry policy version."""Minimal registry surface used to resolve immutable policy versions.
Ancestors
- typing.Protocol
- typing.Generic
Methods
async def resolve_policy(self, policy_id: str, version: str | None = None) ‑> Policy | None-
Expand source code
async def resolve_policy(self, policy_id: str, version: str | None = None) -> Policy | None: """Resolve one exact registry policy version."""Resolve one exact registry policy version.
class AcceptancePolicyResolution (**data: Any)-
Expand source code
class AcceptancePolicyResolution(BaseModel): """Verified catalog projection for seller-default and product profiles.""" model_config = ConfigDict(extra="forbid", frozen=True) catalog: AcceptancePolicyCatalog | None = None profiles: tuple[AcceptancePolicyProfile, ...] = () profile_ids: tuple[str, ...] = () diagnostics: tuple[AcceptancePolicyDiagnostic, ...] = () from_cache: bool = False @property def resolved(self) -> bool: return self.catalog is not None and not self.diagnosticsVerified catalog projection for seller-default and product profiles.
Create a new model by parsing and validating input data from keyword arguments.
Raises [
ValidationError][pydantic_core.ValidationError] if the input data cannot be validated to form a valid model.selfis explicitly positional-only to allowselfas a field name.Ancestors
- pydantic.main.BaseModel
Class variables
var catalog : AcceptancePolicyCatalog | Nonevar diagnostics : tuple[AcceptancePolicyDiagnostic, ...]var from_cache : boolvar model_configvar profile_ids : tuple[str, ...]var profiles : tuple[AcceptancePolicyProfile, ...]
Instance variables
prop resolved : bool-
Expand source code
@property def resolved(self) -> bool: return self.catalog is not None and not self.diagnostics
class AcceptancePolicyResolver (*,
registry: AcceptancePolicyRegistry | None = None,
timeout_seconds: float = 5.0,
max_body_bytes: int = 1048576,
max_cache_entries: int = 64,
monotonic: Callable[[], float] = <built-in function monotonic>,
now: Callable[[], datetime] = <function AcceptancePolicyResolver.<lambda>>)-
Expand source code
class AcceptancePolicyResolver: """Resolve and conservatively assess seller acceptance-policy discovery. Catalogs are cached only when the caller supplies the seller's advertised ``cache_ttl_seconds``. The cache key includes both canonical URL and digest; call :meth:`invalidate_capabilities` when a ``capabilities.changed`` event is received. """ def __init__( self, *, registry: AcceptancePolicyRegistry | None = None, timeout_seconds: float = DEFAULT_ACCEPTANCE_CATALOG_TIMEOUT_SECONDS, max_body_bytes: int = DEFAULT_ACCEPTANCE_CATALOG_MAX_BYTES, max_cache_entries: int = DEFAULT_ACCEPTANCE_CATALOG_CACHE_ENTRIES, monotonic: Callable[[], float] = time.monotonic, now: Callable[[], datetime] = lambda: datetime.now(timezone.utc), ) -> None: if timeout_seconds <= 0: raise ValueError("timeout_seconds must be greater than zero") if max_body_bytes <= 0: raise ValueError("max_body_bytes must be greater than zero") if max_cache_entries <= 0: raise ValueError("max_cache_entries must be greater than zero") self._registry = registry or RegistryClient(timeout=timeout_seconds) self._owns_registry = registry is None self._timeout_seconds = timeout_seconds self._max_body_bytes = max_body_bytes self._max_cache_entries = max_cache_entries self._monotonic = monotonic self._now = now self._cache: OrderedDict[tuple[str, str, str | None], _CacheEntry] = OrderedDict() self._cache_lock = asyncio.Lock() async def __aenter__(self) -> AcceptancePolicyResolver: return self async def __aexit__(self, *_exc: object) -> None: await self.aclose() async def aclose(self) -> None: """Close the internally created registry client, if any.""" if self._owns_registry and isinstance(self._registry, RegistryClient): await self._registry.close() async def invalidate_capabilities(self) -> None: """Drop every catalog cached under an older capability document.""" async with self._cache_lock: self._cache.clear() async def resolve( self, discovery: AcceptancePolicyDiscovery | Mapping[str, Any] | None, *, product_profile_ids: ( AcceptancePolicyProfileIds | Sequence[str | AcceptancePolicyProfileId] ) = (), cache_ttl_seconds: int | None = None, capabilities_version: str | None = None, ) -> AcceptancePolicyResolution: """Verify the catalog and resolve all selected profiles and policy pins.""" resolution, _ = await self._resolve_selected_profiles( discovery, product_profile_ids=product_profile_ids, cache_ttl_seconds=cache_ttl_seconds, capabilities_version=capabilities_version, ) return resolution async def _resolve_selected_profiles( self, discovery: AcceptancePolicyDiscovery | Mapping[str, Any] | None, *, product_profile_ids: AcceptancePolicyProfileIds | Sequence[str | AcceptancePolicyProfileId], cache_ttl_seconds: int | None, capabilities_version: str | None, ) -> tuple[AcceptancePolicyResolution, tuple[_ResolvedProfile, ...]]: if discovery is None: return ( AcceptancePolicyResolution( diagnostics=(_diag(AcceptancePolicyDiagnosticCode.capability_unavailable),) ), (), ) try: discovery_model = TypeAdapter(AcceptancePolicyDiscovery).validate_python(discovery) except (ValidationError, ValueError, TypeError): return ( AcceptancePolicyResolution( diagnostics=(_diag(AcceptancePolicyDiagnosticCode.invalid_discovery),) ), (), ) try: product_ids = self._normalize_product_profile_ids(product_profile_ids) except (ValidationError, ValueError, TypeError): return ( AcceptancePolicyResolution( diagnostics=(_diag(AcceptancePolicyDiagnosticCode.invalid_profile_ids),) ), (), ) url = str(discovery_model.catalog_url) digest = discovery_model.catalog_digest try: document, from_cache = await self._load_catalog( url, digest, cache_ttl_seconds=cache_ttl_seconds, capabilities_version=capabilities_version, ) except _CatalogFailure as exc: return AcceptancePolicyResolution(diagnostics=(exc.diagnostic,)), () selected_ids: list[str] = [] for value in discovery_model.default_profile_ids or (): selected_ids.append(_root_string(value)) selected_ids.extend(product_ids) selected_ids = list(dict.fromkeys(selected_ids)) if not selected_ids: return ( AcceptancePolicyResolution( catalog=document.model, from_cache=from_cache, diagnostics=(_diag(AcceptancePolicyDiagnosticCode.profile_unresolved),), ), (), ) profiles: list[_ResolvedProfile] = [] diagnostics: list[AcceptancePolicyDiagnostic] = [] policy_cache: dict[tuple[str, str], Mapping[str, Any] | None] = {} for profile_id in selected_ids: resolved_profile: _ResolvedProfile | None = document.local_profiles.get(profile_id) if resolved_profile is None: ref = document.registry_refs.get(profile_id) if ref is None: diagnostics.append( _diag( AcceptancePolicyDiagnosticCode.profile_unresolved, profile_id=profile_id, ) ) continue resolved_profile, issue = await self._resolve_registry_profile(ref, policy_cache) if issue is not None: diagnostics.append(issue) continue if resolved_profile is None: diagnostics.append( _diag( AcceptancePolicyDiagnosticCode.profile_unresolved, profile_id=profile_id, ) ) continue issue = await self._verify_profile_policy_refs(resolved_profile, policy_cache) if issue is not None: diagnostics.append(issue) continue profiles.append(resolved_profile) resolved_profiles = tuple(profiles) return ( AcceptancePolicyResolution( catalog=document.model, profiles=tuple(profile.model for profile in resolved_profiles), profile_ids=tuple(profile.model.profile_id for profile in resolved_profiles), diagnostics=tuple(diagnostics), from_cache=from_cache, ), resolved_profiles, ) async def assess( self, discovery: AcceptancePolicyDiscovery | Mapping[str, Any] | None, context: AcceptanceContext | Mapping[str, Any], *, applies_to: AcceptancePolicySurface, product_profile_ids: ( AcceptancePolicyProfileIds | Sequence[str | AcceptancePolicyProfileId] ) = (), cache_ttl_seconds: int | None = None, capabilities_version: str | None = None, ) -> AcceptancePolicyAssessment: """Return an advisory outcome for one typed decision surface.""" resolution, resolved_profiles = await self._resolve_selected_profiles( discovery, product_profile_ids=product_profile_ids, cache_ttl_seconds=cache_ttl_seconds, capabilities_version=capabilities_version, ) if resolution.diagnostics or resolution.catalog is None or not resolution.profiles: return AcceptancePolicyAssessment( outcome=AcceptancePolicyOutcome.unknown, profile_ids=resolution.profile_ids, diagnostics=resolution.diagnostics, ) if applies_to not in _SURFACES: return AcceptancePolicyAssessment( outcome=AcceptancePolicyOutcome.unknown, profile_ids=resolution.profile_ids, diagnostics=(_diag(AcceptancePolicyDiagnosticCode.incomplete_context),), ) try: context_model = TypeAdapter(AcceptanceContext).validate_python(context) except (ValidationError, ValueError, TypeError): return AcceptancePolicyAssessment( outcome=AcceptancePolicyOutcome.unknown, profile_ids=resolution.profile_ids, diagnostics=(_diag(AcceptancePolicyDiagnosticCode.incomplete_context),), ) return self._assess_profiles(resolved_profiles, context_model, applies_to) @staticmethod def _normalize_product_profile_ids( value: AcceptancePolicyProfileIds | Sequence[str | AcceptancePolicyProfileId], ) -> list[str]: raw = value.root if isinstance(value, RootModel) else value if isinstance(raw, (str, bytes)) or not isinstance(raw, Sequence): raise TypeError("product_profile_ids must be an array") if not raw: return [] model = TypeAdapter(AcceptancePolicyProfileIds).validate_python(raw) return [_root_string(item) for item in model.root] async def _load_catalog( self, url: str, digest: str, *, cache_ttl_seconds: int | None, capabilities_version: str | None, ) -> tuple[_CatalogDocument, bool]: canonical_url = self._validate_catalog_url(url) key = (canonical_url, digest, capabilities_version) now = self._monotonic() async with self._cache_lock: self._prune_cache(now) cached = self._cache.get(key) if cached is not None and cached.expires_at > now: self._cache.move_to_end(key) return cached.document, True body = await self._fetch_catalog(canonical_url) actual = f"sha256:{hashlib.sha256(body).hexdigest()}" if not _digest_equal(actual, digest): raise _CatalogFailure(_diag(AcceptancePolicyDiagnosticCode.catalog_digest_mismatch)) try: parsed = json.loads(body) except (RecursionError, UnicodeDecodeError, json.JSONDecodeError): raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.catalog_invalid_json) ) from None if not isinstance(parsed, dict): raise _CatalogFailure(_diag(AcceptancePolicyDiagnosticCode.catalog_schema_invalid)) validator = get_named_validator(_CATALOG_SCHEMA) if validator is None: raise _CatalogFailure(_diag(AcceptancePolicyDiagnosticCode.catalog_schema_invalid)) try: schema_error = next(validator.iter_errors(parsed), None) except (RecursionError, TypeError, ValueError): raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.catalog_schema_invalid) ) from None if schema_error is not None: raise _CatalogFailure(_diag(AcceptancePolicyDiagnosticCode.catalog_schema_invalid)) try: model = TypeAdapter(AcceptancePolicyCatalog).validate_python(parsed) except (ValidationError, ValueError, TypeError): raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.catalog_schema_invalid) ) from None document = self._build_catalog_document(model, parsed) if cache_ttl_seconds is not None and cache_ttl_seconds > 0: expires_at = self._monotonic() + cache_ttl_seconds async with self._cache_lock: self._cache[key] = _CacheEntry(document=document, expires_at=expires_at) self._cache.move_to_end(key) self._prune_cache(self._monotonic()) while len(self._cache) > self._max_cache_entries: self._cache.popitem(last=False) return document, False def _build_catalog_document( self, model: AcceptancePolicyCatalog, raw: Mapping[str, Any], ) -> _CatalogDocument: local_profiles: dict[str, _ResolvedProfile] = {} registry_refs: dict[str, Mapping[str, Any]] = {} raw_profiles = raw.get("profiles") or [] for item in raw_profiles: assert isinstance(item, Mapping) profile_id = item.get("profile_id") if not isinstance(profile_id, str) or profile_id in local_profiles: raise _CatalogFailure(_diag(AcceptancePolicyDiagnosticCode.duplicate_profile_id)) try: profile_model = TypeAdapter(AcceptancePolicyProfile).validate_python(item) except (ValidationError, ValueError, TypeError): raise _CatalogFailure( _diag( AcceptancePolicyDiagnosticCode.profile_invalid, profile_id=profile_id, ) ) from None self._validate_profile(item, profile_model) local_profiles[profile_id] = _ResolvedProfile(profile_model, item) for item in raw.get("registry_profiles") or []: assert isinstance(item, Mapping) profile_id = item.get("profile_id") if ( not isinstance(profile_id, str) or profile_id in registry_refs or profile_id in local_profiles ): raise _CatalogFailure(_diag(AcceptancePolicyDiagnosticCode.duplicate_profile_id)) registry_refs[profile_id] = item return _CatalogDocument( model=model, raw=raw, local_profiles=local_profiles, registry_refs=registry_refs, ) def _validate_profile( self, raw: Mapping[str, Any], model: AcceptancePolicyProfile, ) -> None: profile_id = model.profile_id try: calculated = _profile_digest(raw) except (TypeError, ValueError, rfc8785.CanonicalizationError): raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.profile_invalid, profile_id=profile_id) ) from None if not _digest_equal(calculated, model.content_digest): raise _CatalogFailure( _diag( AcceptancePolicyDiagnosticCode.profile_digest_mismatch, profile_id=profile_id, ) ) refs = [ref.policy_id for ref in model.policy_refs] if len(refs) != len(set(refs)): raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.profile_invalid, profile_id=profile_id) ) rule_ids = [rule.rule_id for rule in model.rules] if len(rule_ids) != len(set(rule_ids)): raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.profile_invalid, profile_id=profile_id) ) ref_ids = set(refs) aliases = set((raw.get("region_aliases") or {}).keys()) for rule in model.rules: if any(_root_string(value) not in aliases for value in rule.jurisdiction_groups or ()): raise _CatalogFailure( _diag( AcceptancePolicyDiagnosticCode.invalid_region_alias, profile_id=profile_id, rule_id=rule.rule_id, ) ) if any(_root_string(value) not in ref_ids for value in rule.policy_ids or ()): raise _CatalogFailure( _diag( AcceptancePolicyDiagnosticCode.profile_invalid, profile_id=profile_id, rule_id=rule.rule_id, ) ) scope = raw.get("scope") if isinstance(scope, Mapping): if any(str(value) not in aliases for value in scope.get("jurisdiction_groups") or ()): raise _CatalogFailure( _diag( AcceptancePolicyDiagnosticCode.invalid_region_alias, profile_id=profile_id, ) ) async def _resolve_registry_profile( self, ref: Mapping[str, Any], policy_cache: dict[tuple[str, str], Mapping[str, Any] | None], ) -> tuple[_ResolvedProfile | None, AcceptancePolicyDiagnostic | None]: profile_id = cast(str, ref["profile_id"]) policy_id = cast(str, ref["policy_id"]) version = cast(str, ref["policy_version"]) policy, issue = await self._verified_policy(policy_id, version, policy_cache) if issue is not None: return None, issue.model_copy(update={"profile_id": profile_id}) assert policy is not None if not _digest_equal(cast(str, policy["content_digest"]), cast(str, ref["policy_digest"])): return None, _diag( AcceptancePolicyDiagnosticCode.policy_digest_mismatch, profile_id=profile_id, policy_id=policy_id, ) raw_profile = policy.get("acceptance_profile") if not isinstance(raw_profile, Mapping): return None, _diag( AcceptancePolicyDiagnosticCode.profile_unresolved, profile_id=profile_id, policy_id=policy_id, ) try: profile = TypeAdapter(AcceptancePolicyProfile).validate_python(raw_profile) self._validate_profile(raw_profile, profile) except (ValidationError, ValueError, TypeError, _CatalogFailure): return None, _diag( AcceptancePolicyDiagnosticCode.profile_invalid, profile_id=profile_id, policy_id=policy_id, ) if profile.profile_id != profile_id or profile.version != ref["profile_version"]: return None, _diag( AcceptancePolicyDiagnosticCode.profile_unresolved, profile_id=profile_id, policy_id=policy_id, ) if not _digest_equal(profile.content_digest, cast(str, ref["profile_digest"])): return None, _diag( AcceptancePolicyDiagnosticCode.profile_digest_mismatch, profile_id=profile_id, policy_id=policy_id, ) return _ResolvedProfile(profile, raw_profile), None async def _verify_profile_policy_refs( self, profile: _ResolvedProfile, policy_cache: dict[tuple[str, str], Mapping[str, Any] | None], ) -> AcceptancePolicyDiagnostic | None: for ref in profile.model.policy_refs: policy, issue = await self._verified_policy(ref.policy_id, ref.version, policy_cache) if issue is not None: return issue.model_copy(update={"profile_id": profile.model.profile_id}) assert policy is not None if not _digest_equal(cast(str, policy["content_digest"]), ref.content_digest): return _diag( AcceptancePolicyDiagnosticCode.policy_digest_mismatch, profile_id=profile.model.profile_id, policy_id=ref.policy_id, ) return None async def _verified_policy( self, policy_id: str, version: str, cache: dict[tuple[str, str], Mapping[str, Any] | None], ) -> tuple[Mapping[str, Any] | None, AcceptancePolicyDiagnostic | None]: key = (policy_id, version) if key not in cache: try: value = await asyncio.wait_for( self._registry.resolve_policy(policy_id, version), timeout=self._timeout_seconds, ) except asyncio.CancelledError: raise except Exception: value = None cache[key] = None if value is None else _model_mapping(value) policy = cache[key] if policy is None: return None, _diag( AcceptancePolicyDiagnosticCode.policy_unresolved, policy_id=policy_id, ) if policy.get("policy_id") != policy_id or policy.get("version") != version: return None, _diag( AcceptancePolicyDiagnosticCode.policy_version_mismatch, policy_id=policy_id, ) canonical = policy.get("canonical_content") claimed = policy.get("content_digest") if not isinstance(canonical, Mapping) or not isinstance(claimed, str): return None, _diag( AcceptancePolicyDiagnosticCode.policy_invalid, policy_id=policy_id, ) try: calculated = _sha256_jcs(canonical) except (TypeError, ValueError, rfc8785.CanonicalizationError): return None, _diag( AcceptancePolicyDiagnosticCode.policy_invalid, policy_id=policy_id, ) if not _digest_equal(calculated, claimed): return None, _diag( AcceptancePolicyDiagnosticCode.policy_digest_mismatch, policy_id=policy_id, ) return policy, None def _validate_catalog_url(self, url: str) -> str: try: parts = urlsplit(url) if ( len(url) > 2048 or any(ord(character) < 0x20 or ord(character) == 0x7F for character in url) or parts.scheme.lower() != "https" or not parts.hostname or parts.username is not None or parts.password is not None or parts.fragment ): raise ValueError return canonicalize_target_uri(parts.geturl()) except (ValueError, UnicodeError): raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.unsafe_catalog_url) ) from None async def _fetch_catalog(self, url: str) -> bytes: async def fetch() -> bytes: try: transport = await asyncio.to_thread(build_async_ip_pinned_transport, url) except Exception: raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.unsafe_catalog_url) ) from None async with httpx.AsyncClient( transport=transport, trust_env=False, follow_redirects=False, timeout=self._timeout_seconds, headers={"Accept": "application/json", "Accept-Encoding": "gzip, deflate"}, ) as client: try: async with client.stream("GET", url) as response: if 300 <= response.status_code < 400: raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.unsafe_catalog_url) ) if response.status_code < 200 or response.status_code >= 300: raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.catalog_fetch_failed) ) try: return await async_read_decoded_limited_bytes( response, limit=self._max_body_bytes, ) except ResponseTooLargeError: raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.catalog_too_large) ) from None except ValueError: raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.catalog_fetch_failed) ) from None except _CatalogFailure: raise except httpx.HTTPError: raise _CatalogFailure( _diag(AcceptancePolicyDiagnosticCode.catalog_fetch_failed) ) from None try: return await asyncio.wait_for(fetch(), timeout=self._timeout_seconds) except asyncio.CancelledError: raise except asyncio.TimeoutError: raise _CatalogFailure(_diag(AcceptancePolicyDiagnosticCode.catalog_timeout)) from None def _prune_cache(self, now: float) -> None: for key in [key for key, entry in self._cache.items() if entry.expires_at <= now]: self._cache.pop(key, None) def _assess_profiles( self, profiles: Sequence[_ResolvedProfile], context: AcceptanceContext, applies_to: AcceptancePolicySurface, ) -> AcceptancePolicyAssessment: diagnostics: list[AcceptancePolicyDiagnostic] = [] matched: list[AcceptancePolicyRule] = [] uncertain = False now = self._now() if now.tzinfo is None: now = now.replace(tzinfo=timezone.utc) for profile in profiles: if profile.model.coverage.value == "partial": uncertain = True diagnostics.append( _diag( AcceptancePolicyDiagnosticCode.partial_coverage, profile_id=profile.model.profile_id, ) ) elif not self._complete_scope_covers(profile, context, applies_to): uncertain = True diagnostics.append( _diag( AcceptancePolicyDiagnosticCode.incomplete_context, profile_id=profile.model.profile_id, ) ) for rule in profile.model.rules: match = self._rule_match(profile, rule, context, applies_to, now) if match is True: matched.append(rule) elif match is None: uncertain = True diagnostics.append( _diag( AcceptancePolicyDiagnosticCode.incomplete_context, profile_id=profile.model.profile_id, rule_id=rule.rule_id, ) ) prohibited = [rule for rule in matched if rule.disposition.value == "prohibited"] requirements = tuple( requirement for rule in matched if rule.disposition.value == "conditional" for requirement in (rule.requirements or ()) ) rule_ids = tuple(rule.rule_id for rule in matched) profile_ids = tuple(profile.model.profile_id for profile in profiles) if prohibited: outcome = AcceptancePolicyOutcome.prohibited elif uncertain: outcome = AcceptancePolicyOutcome.unknown elif requirements: outcome = self._requirements_outcome(requirements) else: outcome = AcceptancePolicyOutcome.allowed return AcceptancePolicyAssessment( outcome=outcome, profile_ids=profile_ids, matching_rule_ids=rule_ids, requirements=requirements, diagnostics=tuple(dict.fromkeys(diagnostics)), ) def _complete_scope_covers( self, profile: _ResolvedProfile, context: AcceptanceContext, applies_to: AcceptancePolicySurface, ) -> bool: scope = profile.raw.get("scope") if not isinstance(scope, Mapping): return False if applies_to not in scope.get("applies_to", ()): return False subjects = context.subjects or () if not subjects: return False categories = {subject.subject_category for subject in subjects} if not categories.issubset(set(scope.get("subject_categories", ()))): return False if scope.get("all_jurisdictions") is True: return True delivery = {_root_string(item) for item in context.delivery_jurisdictions or ()} if not delivery: return False covered = set(scope.get("jurisdictions", ())) aliases = profile.raw.get("region_aliases") or {} for group in scope.get("jurisdiction_groups", ()): covered.update(aliases.get(group, ())) return delivery.issubset(covered) def _rule_match( self, profile: _ResolvedProfile, rule: AcceptancePolicyRule, context: AcceptanceContext, applies_to: AcceptancePolicySurface, now: datetime, ) -> bool | None: if applies_to not in {str(value.value) for value in rule.applies_to}: return False if rule.effective_at is not None and now < rule.effective_at: return False if rule.expires_at is not None and now >= rule.expires_at: return False subjects = [ subject for subject in context.subjects or () if subject.subject_category == rule.subject_category ] if not context.subjects: return None if not subjects: return False if rule.subject_facets: expected = {_root_string(item) for item in rule.subject_facets} unknown_facets = False for subject in subjects: if not subject.subject_facets: unknown_facets = True continue if not expected.isdisjoint( {_root_string(facet) for facet in subject.subject_facets} ): break else: return None if unknown_facets else False if rule.advertiser_roles: expected_roles = {_root_string(item) for item in rule.advertiser_roles} actual_roles = {_root_string(item) for item in context.advertiser_roles or ()} if not actual_roles: return None if expected_roles.isdisjoint(actual_roles): return False if rule.jurisdictions or rule.jurisdiction_groups: expected_jurisdictions = {_root_string(item) for item in rule.jurisdictions or ()} aliases = profile.raw.get("region_aliases") or {} for group in rule.jurisdiction_groups or (): expected_jurisdictions.update(aliases.get(_root_string(group), ())) actual_jurisdictions = { _root_string(item) for item in context.delivery_jurisdictions or () } if not actual_jurisdictions: return None if expected_jurisdictions.isdisjoint(actual_jurisdictions): return False return True @staticmethod def _requirements_outcome( requirements: Sequence[AcceptancePolicyRequirement], ) -> AcceptancePolicyOutcome: kinds = { str(requirement.model_dump(mode="json").get("kind", "")) for requirement in requirements } review = { "prior_authorization", "sales_assisted", "targeting_restriction", "creative_restriction", "destination_restriction", "format_restriction", "time_restriction", "custom", } setup = { "advertiser_verification", "advertiser_eligibility", "certification", "license", "account_setup", } if kinds & review: return AcceptancePolicyOutcome.requires_review if kinds & setup: return AcceptancePolicyOutcome.requires_setup return AcceptancePolicyOutcome.requires_disclosureResolve and conservatively assess seller acceptance-policy discovery.
Catalogs are cached only when the caller supplies the seller's advertised
cache_ttl_seconds. The cache key includes both canonical URL and digest; call :meth:invalidate_capabilitieswhen acapabilities.changedevent is received.Methods
async def aclose(self) ‑> None-
Expand source code
async def aclose(self) -> None: """Close the internally created registry client, if any.""" if self._owns_registry and isinstance(self._registry, RegistryClient): await self._registry.close()Close the internally created registry client, if any.
async def assess(self,
discovery: AcceptancePolicyDiscovery | Mapping[str, Any] | None,
context: AcceptanceContext | Mapping[str, Any],
*,
applies_to: AcceptancePolicySurface,
product_profile_ids: AcceptancePolicyProfileIds | Sequence[str | AcceptancePolicyProfileId] = (),
cache_ttl_seconds: int | None = None,
capabilities_version: str | None = None) ‑> AcceptancePolicyAssessment-
Expand source code
async def assess( self, discovery: AcceptancePolicyDiscovery | Mapping[str, Any] | None, context: AcceptanceContext | Mapping[str, Any], *, applies_to: AcceptancePolicySurface, product_profile_ids: ( AcceptancePolicyProfileIds | Sequence[str | AcceptancePolicyProfileId] ) = (), cache_ttl_seconds: int | None = None, capabilities_version: str | None = None, ) -> AcceptancePolicyAssessment: """Return an advisory outcome for one typed decision surface.""" resolution, resolved_profiles = await self._resolve_selected_profiles( discovery, product_profile_ids=product_profile_ids, cache_ttl_seconds=cache_ttl_seconds, capabilities_version=capabilities_version, ) if resolution.diagnostics or resolution.catalog is None or not resolution.profiles: return AcceptancePolicyAssessment( outcome=AcceptancePolicyOutcome.unknown, profile_ids=resolution.profile_ids, diagnostics=resolution.diagnostics, ) if applies_to not in _SURFACES: return AcceptancePolicyAssessment( outcome=AcceptancePolicyOutcome.unknown, profile_ids=resolution.profile_ids, diagnostics=(_diag(AcceptancePolicyDiagnosticCode.incomplete_context),), ) try: context_model = TypeAdapter(AcceptanceContext).validate_python(context) except (ValidationError, ValueError, TypeError): return AcceptancePolicyAssessment( outcome=AcceptancePolicyOutcome.unknown, profile_ids=resolution.profile_ids, diagnostics=(_diag(AcceptancePolicyDiagnosticCode.incomplete_context),), ) return self._assess_profiles(resolved_profiles, context_model, applies_to)Return an advisory outcome for one typed decision surface.
async def invalidate_capabilities(self) ‑> None-
Expand source code
async def invalidate_capabilities(self) -> None: """Drop every catalog cached under an older capability document.""" async with self._cache_lock: self._cache.clear()Drop every catalog cached under an older capability document.
async def resolve(self,
discovery: AcceptancePolicyDiscovery | Mapping[str, Any] | None,
*,
product_profile_ids: AcceptancePolicyProfileIds | Sequence[str | AcceptancePolicyProfileId] = (),
cache_ttl_seconds: int | None = None,
capabilities_version: str | None = None) ‑> AcceptancePolicyResolution-
Expand source code
async def resolve( self, discovery: AcceptancePolicyDiscovery | Mapping[str, Any] | None, *, product_profile_ids: ( AcceptancePolicyProfileIds | Sequence[str | AcceptancePolicyProfileId] ) = (), cache_ttl_seconds: int | None = None, capabilities_version: str | None = None, ) -> AcceptancePolicyResolution: """Verify the catalog and resolve all selected profiles and policy pins.""" resolution, _ = await self._resolve_selected_profiles( discovery, product_profile_ids=product_profile_ids, cache_ttl_seconds=cache_ttl_seconds, capabilities_version=capabilities_version, ) return resolutionVerify the catalog and resolve all selected profiles and policy pins.