From 84b19dcf14fd7535793441d10b6924920c928452 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 19:22:39 -0400 Subject: [PATCH 1/2] feat(decisioning): align AccountStore.resolution literal with JS + Tier 1 docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 design feedback (salesagent + cross-language parity audit): the Python decisioning module's adopter surface drifts from the JS reference at ``src/lib/server/decisioning/account.ts``. Aligning now, hours post-foundation merge, before any adopter locks in. Pre-release alignment (4.4.0 release PR is held; not on PyPI yet): * ``AccountStore.resolution`` literal: ``'singleton' → 'derived'``, ``'from_auth' → 'implicit'``. ``'explicit'`` unchanged. Class names (``SingletonAccounts``, ``FromAuthAccounts``) kept — they're Python- ergonomic with no JS counterpart to drift from. The literal is what the framework reads at server boot for ``validate_platform`` deployment checks; cross-language parity matters because both SDKs read the same wire-shape concept. NOT a ``feat!:`` because the foundation hasn't shipped to PyPI in 4.4.0 form — the held release PR #328 carries this rename alongside the foundation, so external adopters never see the old literal values. Tier 1 documentation additions (3 of 4 from salesagent's ``adcp-decisioning-design-feedback.md``; the 4th — StateReader UserWarning — was already implemented in round-4 via ``_NotYetWiredStateReader._warn_once``): * ``accounts.py`` module docstring: "Spec-agent vs auth-layer principal" section. Resolves the load-bearing ambiguity adopters hit — what string AuthInfo.principal carries varies by auth shape (agent_url for AdCP v3 signed-request, OAuth subject for bearer, mTLS subject for client-cert). Adopters wiring FromAuthAccounts decide what their auth middleware projects. * ``RequestContext`` docstring: "Identifier disambiguation — when to use which" table. ``account.id`` (data ownership), ``auth_principal`` (caller identity), ``caller_identity`` (framework-managed cache scope key, never read directly), ``tenant_id`` (transport routing). Salesagent flagged this as cognitively heavy. * ``TaskHandoff`` docstring: "What it's for / not for" section. Steers adopters away from human-driven HITL workflows where the background fn would block on a person's clicks. v6.1 may add a ``ctx.handoff_to_human()`` primitive; for v6.0, the recommended pattern for queued approvals is ``input-required`` + adopter- owned webhook emission, not TaskHandoff. Tier 2 items (multi-tenant ``hello_publisher.py``, ``AdagentsAccountStore``, built-in auth adapter wrappers like ``SignedRequestAuth``/``BearerAuth``/``MtlsAuth``) deferred to 4.5.0 with proper RFCs — better design data once salesagent's actual port exists as the reference. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/decisioning/accounts.py | 60 ++++++++++++++++++++++---------- src/adcp/decisioning/context.py | 44 +++++++++++++++++++++++ src/adcp/decisioning/handler.py | 5 +-- src/adcp/decisioning/types.py | 34 ++++++++++++++++-- tests/test_decisioning_types.py | 12 ++++--- 5 files changed, 129 insertions(+), 26 deletions(-) diff --git a/src/adcp/decisioning/accounts.py b/src/adcp/decisioning/accounts.py index 8d81ca232..a1379af70 100644 --- a/src/adcp/decisioning/accounts.py +++ b/src/adcp/decisioning/accounts.py @@ -1,20 +1,40 @@ """Account resolution: ``AccountStore`` Protocol + three reference impls. -Adopters pick a resolution mode at registration time: - -* :class:`SingletonAccounts` — single-process / single-platform - deployments (Innovid training-agent, single-publisher proof-of-concept). - Synthesizes ``account.id`` per verified principal so idempotency - scopes correctly across distinct callers. -* :class:`ExplicitAccounts` — multi-tenant where the URL or request - body identifies the account (``/tenants/``, ``account.account_id`` - in body). Resolves by the wire reference. -* :class:`FromAuthAccounts` — multi-tenant or single-tenant where the - verified auth principal identifies the account (signed-request bound, - OAuth bearer bound). Resolves by ``ctx.auth_info.principal``. +Adopters pick a resolution mode at registration time. The mode literal +mirrors the JS-side ``AccountStore.resolution`` field +(``src/lib/server/decisioning/account.ts``) for cross-language parity: + +* :class:`SingletonAccounts` (``resolution='derived'``) — single-process + / single-platform deployments (Innovid training-agent, single-publisher + proof-of-concept). Synthesizes ``account.id`` per verified principal + so idempotency scopes correctly across distinct callers. +* :class:`ExplicitAccounts` (``resolution='explicit'``) — multi-tenant + where the URL or request body identifies the account + (``/tenants/``, ``account.account_id`` in body). Resolves by the + wire reference. +* :class:`FromAuthAccounts` (``resolution='implicit'``) — multi-tenant + or single-tenant where the verified auth principal identifies the + account (signed-request bound, OAuth bearer bound). Resolves by + ``ctx.auth_info.principal``. Adopters with shapes that don't fit these three implement the :class:`AccountStore` Protocol directly. + +Spec-agent vs auth-layer principal: + AdCP v3 introduced agent-to-agent flows where the calling principal + IS an agent identified by a stable ``agent_url`` (the agent's + well-known URL acting as a global identifier) and a key id (``kid``) + on the request signature. The framework's ``AuthInfo.principal`` + is the verified-principal opaque string the auth layer surfaces — + typically the ``agent_url`` for signed-request agents, or the + subject claim for OAuth bearer tokens, or the mTLS subject for + client-cert flows. Adopters wiring ``FromAuthAccounts`` MUST decide + what string the auth middleware projects onto + ``ctx.auth_info.principal``; the framework treats it opaquely. For + AdCP v3 signed-request flows, the convention is the calling agent's + ``agent_url``; that's what your loader callable sees as the lookup + key. See ``adcp.adagents`` for the spec validator that ties an + ``agent_url`` to a seller's published ``adagents.json`` whitelist. """ from __future__ import annotations @@ -37,18 +57,22 @@ class AccountStore(Protocol, Generic[TMeta]): The framework calls :meth:`resolve` for every tool dispatch (before the handler method runs). Adopters in ``'explicit'`` mode - use ``ref.account_id`` from the wire; ``'from_auth'`` mode reads + use ``ref.account_id`` from the wire; ``'implicit'`` mode reads ``ctx.auth_info`` to look up the principal-bound account; - ``'singleton'`` mode synthesizes a per-principal account from the + ``'derived'`` mode synthesizes a per-principal account from the one platform. The :attr:`resolution` literal is a structural attribute the framework reads at server boot — used by :func:`validate_platform` to fail fast on misconfigured deployments (e.g. - ``'singleton'`` registered into a multi-tenant ``TenantRegistry``). + ``'derived'`` registered into a multi-tenant ``TenantRegistry``). + Mirrors the JS-side literal for cross-language parity: + ``'explicit'`` (wire ref drives lookup), ``'implicit'`` (verified + auth principal drives lookup), ``'derived'`` (single-platform with + per-principal id synthesis). """ - resolution: Literal["explicit", "from_auth", "singleton"] + resolution: Literal["explicit", "implicit", "derived"] def resolve( self, @@ -111,7 +135,7 @@ class TrainingAgentSeller(DecisioningPlatform): right TypedDict / dataclass instance. """ - resolution: Literal["singleton"] = "singleton" + resolution: Literal["derived"] = "derived" def __init__( self, @@ -236,7 +260,7 @@ class MeasurementVendor(DecisioningPlatform): :class:`Account` instance. Sync or async. """ - resolution: Literal["from_auth"] = "from_auth" + resolution: Literal["implicit"] = "implicit" def __init__( self, diff --git a/src/adcp/decisioning/context.py b/src/adcp/decisioning/context.py index ada44dd8b..5d912c9ad 100644 --- a/src/adcp/decisioning/context.py +++ b/src/adcp/decisioning/context.py @@ -102,6 +102,50 @@ class RequestContext(ToolContext, Generic[TMeta]): returned :class:`TaskHandoff` via type-identity and projects it to the wire ``Submitted`` envelope. + **Identifier disambiguation — when to use which:** + + The context carries four identifier-shaped fields. Each has a + distinct role; mixing them up is the most common adopter bug. + + +---------------------+-----------------------------+--------------------------------+ + | Field | What it answers | Read it for | + +=====================+=============================+================================+ + | ``account.id`` | "Whose data is this?" | Routing the request to the | + | | The resolved tenant / | right adapter, scoping DB | + | | account that owns the call. | reads, audit logs. | + +---------------------+-----------------------------+--------------------------------+ + | ``auth_principal`` | "Who's calling?" | Per-principal ACLs within an | + | | The verified caller's | account ("can principal X | + | | identity label | mutate this buy?"). | + | | (``agent_url`` for AdCP v3 | | + | | signed-request agents, | | + | | OAuth subject for bearer | | + | | flows, mTLS subject for | | + | | client-cert flows). | | + +---------------------+-----------------------------+--------------------------------+ + | ``caller_identity`` | "What's the cache scope?" | NEVER read directly in adopter | + | | Composite framework-set key | code. The framework's | + | | (``store.qualname: | idempotency middleware reads | + | | account.id``) used by the | this. Mutating it breaks | + | | idempotency middleware. | replay-cache scoping. | + +---------------------+-----------------------------+--------------------------------+ + | ``tenant_id`` | "Which transport tenant?" | Multi-tenant transport routing | + | | Inherited from | (host header, URL path). | + | | :class:`ToolContext`. Set | Usually equals ``account.id`` | + | | by the transport layer | for explicit-resolution | + | | before dispatch. | adopters; can diverge for | + | | | derived/implicit modes. | + +---------------------+-----------------------------+--------------------------------+ + + Common patterns: + + * Routing to the right adapter? → ``ctx.account.metadata.adapter`` + (typed via the ``TMeta`` generic). + * Authorization check? → ``ctx.auth_principal`` (who's calling) + against ``ctx.account.id`` (whose data they're touching). + * Idempotency scope? → don't touch; the framework owns this. + * Logging request provenance? → log all four; they're cheap. + :param state: Sync reads of framework-owned in-flight workflow state. Default is :class:`adcp.decisioning.state._NotYetWiredStateReader` — returns empty values + emits one-time UserWarning per diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 49ddc08cf..67471a9c5 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -162,8 +162,9 @@ async def _resolve_account( Pulls auth info from ``ctx.metadata['auth_info']`` when the operator's ``context_factory`` populates it; otherwise None. Adopter ``AccountStore`` impls handle missing-auth cases per - their own resolution mode (singleton tolerates None; - from_auth raises ``AUTH_INVALID``; explicit resolves by ref). + their own resolution mode (``'derived'`` tolerates None; + ``'implicit'`` raises ``AUTH_INVALID``; ``'explicit'`` resolves + by ref). ``AccountStore.resolve`` takes a dict — convert the typed Pydantic ``AccountReference`` via ``model_dump()`` so adopter store impls see a normalized shape. diff --git a/src/adcp/decisioning/types.py b/src/adcp/decisioning/types.py index 354e49b69..3683edb6a 100644 --- a/src/adcp/decisioning/types.py +++ b/src/adcp/decisioning/types.py @@ -167,8 +167,38 @@ def create_media_buy(self, req, ctx): if self._is_pre_approved(req, ctx.account): # Sync fast path — return Success directly return CreateMediaBuySuccess(media_buy_id="mb_1", ...) - # HITL slow path — hand off to background trafficker review + # Framework-async slow path — hand off to background work return ctx.handoff_to_task(self._review_async) + + **What TaskHandoff is for** — short, framework-mediated async work + where the adopter awaits an external system (DSP API call, + classifier inference, third-party brand-safety scan, generative + creative render) inside a coroutine. The handoff fn runs in the + same process, the framework awaits it, persists the terminal + artifact, and emits a webhook on completion. Typical wall-clock: + seconds to minutes. + + **What TaskHandoff is NOT for** — human-driven HITL workflows where + a real person eventually clicks "approve" in a queue. The handoff + fn would either block the framework's background runner indefinitely + (until the human acts), or poll an external queue (which doesn't + fit the "fn returns terminal artifact" contract). For human-approval + workflows, the recommended pattern is: + + 1. Adopter persists the in-flight buy in their own DB and returns + ``input-required`` (NOT a TaskHandoff) on the synchronous arm, + carrying a stable ``task_id`` they allocated. + 2. Trafficker UI flips the row to approved/rejected on its own + schedule. + 3. Adopter's webhook emitter (or a polling worker) fires the + terminal webhook to the buyer when the human acts. + + The adopter owns the whole lifecycle in the human-driven case; the + framework's TaskHandoff projector exists only for the "fn returns + terminal artifact within a reasonable wall-clock" shape. v6.1 may + add a richer ``ctx.handoff_to_human()`` primitive for the queued- + approval pattern; for v6.0, keep human approvals out of the + TaskHandoff path. """ __slots__ = ("_fn",) @@ -236,7 +266,7 @@ class Account(Generic[TMeta]): typechecks inside method bodies. The framework's idempotency middleware scopes its cache by - ``account.id``. Adopters in 'singleton' resolution mode MUST + ``account.id``. Adopters in ``'derived'`` resolution mode MUST synthesize per-principal IDs (e.g. ``f"training-agent:{principal}"``) or buyer-to-buyer cache leakage is possible — see :class:`adcp.decisioning.SingletonAccounts`. diff --git a/tests/test_decisioning_types.py b/tests/test_decisioning_types.py index 4419b2bef..234279f53 100644 --- a/tests/test_decisioning_types.py +++ b/tests/test_decisioning_types.py @@ -285,10 +285,14 @@ def test_account_store_protocol_runtime_checkable() -> None: def test_account_store_resolution_literal() -> None: """``resolution`` is a structural literal the framework reads at - server boot for ``validate_platform`` checks.""" - assert SingletonAccounts(account_id="x").resolution == "singleton" + server boot for ``validate_platform`` checks. Mirrors the JS-side + literal (``src/lib/server/decisioning/account.ts``) for + cross-language parity: ``'explicit'`` (wire ref drives lookup), + ``'implicit'`` (verified auth principal drives lookup), + ``'derived'`` (single-platform with per-principal id synthesis).""" + assert SingletonAccounts(account_id="x").resolution == "derived" assert ExplicitAccounts(loader=lambda _x: Account(id="y")).resolution == "explicit" - assert FromAuthAccounts(loader=lambda _x: Account(id="z")).resolution == "from_auth" + assert FromAuthAccounts(loader=lambda _x: Account(id="z")).resolution == "implicit" # ---- DecisioningPlatform contract ---- @@ -305,4 +309,4 @@ class HelloSeller(DecisioningPlatform): s = HelloSeller() assert s.capabilities.specialisms == ["sales-non-guaranteed"] - assert s.accounts.resolution == "singleton" + assert s.accounts.resolution == "derived" From 366ff478d383171e6b817f1758da4edc69d62cf1 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 30 Apr 2026 19:36:37 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(decisioning):=20address=20expert=20revi?= =?UTF-8?q?ew=20of=20#330=20=E2=80=94=20stale=20literals,=20table=20render?= =?UTF-8?q?,=20doc=20citations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-reviewer + docs-expert findings on the round-5 alignment PR: - 3 stale literal references missed by the rename pass: * accounts.py AccountStore.resolve docstring (``'singleton'`` / ``'from_auth'`` references) * context.py auth_info field docstring * handler.py _extract_auth_info comment - RequestContext identifier table: * Composite cache-key format was incorrect (said ``store.qualname: account.id``; actual is ``.:`` per dispatch.py:457). The ``__module__`` prefix is load-bearing for cross-package collision isolation. * "NEVER read directly in adopter code" claim was overstated — audit logging and rate-limiting code legitimately reads ``ctx.caller_identity``. Tightened to "treat as opaque; don't parse, compare, or rewrite" so the contract matches reality. * Replaced the reST grid table with a definition-list format — the original had a 1-char width mismatch on the ``caller_identity`` row that would have failed pdoc3's grid-table parser and fallen back to literal-block rendering. Definition lists render correctly in pdoc3 + ``help()`` + IDE hover without width-counting. - TaskHandoff "what it's NOT for" section: * Added concrete pointers for the human-driven HITL pattern — ``input-required`` status from ``schemas/cache/enums/task-status.json`` + per-tool ``*_async_response_input_required`` envelopes; webhook primitives in ``adcp.webhooks`` (payload builders) + ``adcp.webhook_sender`` (HMAC-SHA256 signing + IP-pinned delivery). Worked example deferred to ``hello_publisher.py`` in 4.5.0. - accounts.py spec-agent vs auth-principal section: * Tightened the signed-request claim — the SDK's ``adcp.signing`` primitives verify the signature, but the wiring that writes ``AuthInfo.principal = agent_url`` lives in adopter middleware today (or the built-in ``SignedRequestAuth`` adapter wrapper that lands in 4.5.0). Adopters wiring this manually before then should follow the convention so ``adcp.adagents`` reads the right key. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/decisioning/accounts.py | 31 +++++++++------- src/adcp/decisioning/context.py | 61 ++++++++++++++++---------------- src/adcp/decisioning/handler.py | 2 +- src/adcp/decisioning/types.py | 19 +++++++--- 4 files changed, 65 insertions(+), 48 deletions(-) diff --git a/src/adcp/decisioning/accounts.py b/src/adcp/decisioning/accounts.py index a1379af70..d61b52a30 100644 --- a/src/adcp/decisioning/accounts.py +++ b/src/adcp/decisioning/accounts.py @@ -26,15 +26,22 @@ well-known URL acting as a global identifier) and a key id (``kid``) on the request signature. The framework's ``AuthInfo.principal`` is the verified-principal opaque string the auth layer surfaces — - typically the ``agent_url`` for signed-request agents, or the - subject claim for OAuth bearer tokens, or the mTLS subject for - client-cert flows. Adopters wiring ``FromAuthAccounts`` MUST decide - what string the auth middleware projects onto - ``ctx.auth_info.principal``; the framework treats it opaquely. For - AdCP v3 signed-request flows, the convention is the calling agent's - ``agent_url``; that's what your loader callable sees as the lookup - key. See ``adcp.adagents`` for the spec validator that ties an - ``agent_url`` to a seller's published ``adagents.json`` whitelist. + the documented convention is ``agent_url`` for signed-request + agents, OAuth subject claim for bearer tokens, mTLS subject for + client-cert flows. Adopters wiring ``FromAuthAccounts`` decide + what string their auth middleware projects onto + ``ctx.auth_info.principal``; the framework treats it opaquely. + + The SDK's signing primitives in :mod:`adcp.signing` verify the + request signature against a JWKS provider; it's the adopter's + middleware (today) or the framework's built-in + ``SignedRequestAuth`` adapter wrapper (Tier 2, lands in 4.5.0) + that takes the verified result and writes ``AuthInfo.principal = + agent_url`` onto the dispatch context. Adopters wiring this + manually before 4.5.0 ships should follow that convention to keep + ``adcp.adagents`` (the spec validator that ties an ``agent_url`` + to a seller's published ``adagents.json`` whitelist) reading the + right key. """ from __future__ import annotations @@ -84,10 +91,10 @@ def resolve( :param ref: The wire reference object (typically ``request.account`` carrying ``account_id`` / ``account_ref``). ``None`` for tools that don't carry an - explicit account ref — adopters in ``'singleton'`` / - ``'from_auth'`` modes ignore it. + explicit account ref — adopters in ``'derived'`` / + ``'implicit'`` modes ignore it. :param auth_info: Verified principal info. ``None`` for - unauthenticated requests (dev / ``'singleton'`` fixtures). + unauthenticated requests (dev / ``'derived'`` fixtures). :raises adcp.decisioning.AdcpError: ``code='ACCOUNT_NOT_FOUND'`` when the resolution can't produce a valid account. diff --git a/src/adcp/decisioning/context.py b/src/adcp/decisioning/context.py index 5d912c9ad..5b5d91ee6 100644 --- a/src/adcp/decisioning/context.py +++ b/src/adcp/decisioning/context.py @@ -92,7 +92,7 @@ class RequestContext(ToolContext, Generic[TMeta]): sets ``caller_identity = account.id`` so caching scopes per resolved account, not per raw auth principal. :param auth_info: Optional verified principal info. ``None`` when - the request is unauthenticated (dev / 'singleton' fixtures). + the request is unauthenticated (dev / ``'derived'`` fixtures). :param now: Monotonic timestamp for the request — adopters use this rather than ``datetime.now()`` directly so tests can inject deterministic clocks. @@ -107,35 +107,36 @@ class RequestContext(ToolContext, Generic[TMeta]): The context carries four identifier-shaped fields. Each has a distinct role; mixing them up is the most common adopter bug. - +---------------------+-----------------------------+--------------------------------+ - | Field | What it answers | Read it for | - +=====================+=============================+================================+ - | ``account.id`` | "Whose data is this?" | Routing the request to the | - | | The resolved tenant / | right adapter, scoping DB | - | | account that owns the call. | reads, audit logs. | - +---------------------+-----------------------------+--------------------------------+ - | ``auth_principal`` | "Who's calling?" | Per-principal ACLs within an | - | | The verified caller's | account ("can principal X | - | | identity label | mutate this buy?"). | - | | (``agent_url`` for AdCP v3 | | - | | signed-request agents, | | - | | OAuth subject for bearer | | - | | flows, mTLS subject for | | - | | client-cert flows). | | - +---------------------+-----------------------------+--------------------------------+ - | ``caller_identity`` | "What's the cache scope?" | NEVER read directly in adopter | - | | Composite framework-set key | code. The framework's | - | | (``store.qualname: | idempotency middleware reads | - | | account.id``) used by the | this. Mutating it breaks | - | | idempotency middleware. | replay-cache scoping. | - +---------------------+-----------------------------+--------------------------------+ - | ``tenant_id`` | "Which transport tenant?" | Multi-tenant transport routing | - | | Inherited from | (host header, URL path). | - | | :class:`ToolContext`. Set | Usually equals ``account.id`` | - | | by the transport layer | for explicit-resolution | - | | before dispatch. | adopters; can diverge for | - | | | derived/implicit modes. | - +---------------------+-----------------------------+--------------------------------+ + ``account.id`` — "whose data is this?" + The resolved tenant / account that owns the call. Read it to + route the request to the right adapter instance, scope your + DB queries, and stamp audit logs. + + ``auth_principal`` — "who's calling?" + The verified caller's identity label. The string varies by + auth shape: ``agent_url`` for AdCP v3 signed-request agents + (the documented convention; the SDK's signed-request adapter + wrappers ship in 4.5.0), OAuth subject claim for bearer + flows, mTLS subject for client-cert flows. Read it for + per-principal ACLs *within* an account ("can principal X + mutate this buy?"). + + ``caller_identity`` — "what's the cache scope key?" + Composite framework-set key + (``.:``) used by + the idempotency middleware to scope the replay cache. + Treat as opaque. Adopter code may log or forward it + (rate-limiting, audit) but should not parse, compare, or + rewrite it — the format is framework-internal and any + adopter assumption about its shape will break when the + scope-key composition changes. + + ``tenant_id`` — "which transport tenant?" + Inherited from :class:`ToolContext`; set by the transport + layer before dispatch (typically from the host header or URL + path on multi-tenant deployments). Usually equals + ``account.id`` for ``'explicit'``-resolution adopters; can + diverge for ``'derived'`` / ``'implicit'`` modes. Common patterns: diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 67471a9c5..1554ab956 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -197,7 +197,7 @@ def _extract_auth_info(ctx: ToolContext) -> AuthInfo | None: principal/scope info. Adopter conventions vary; this helper checks for an ``adcp.auth_info`` key — Stage 3 ``serve()`` wiring sets this from the canonical principal. Returns None when no auth key - is present (dev / singleton fixtures). + is present (dev / ``'derived'`` fixtures). """ raw = ctx.metadata.get("adcp.auth_info") if ctx.metadata else None if isinstance(raw, AuthInfo): diff --git a/src/adcp/decisioning/types.py b/src/adcp/decisioning/types.py index 3683edb6a..37fe9ea93 100644 --- a/src/adcp/decisioning/types.py +++ b/src/adcp/decisioning/types.py @@ -187,18 +187,27 @@ def create_media_buy(self, req, ctx): 1. Adopter persists the in-flight buy in their own DB and returns ``input-required`` (NOT a TaskHandoff) on the synchronous arm, - carrying a stable ``task_id`` they allocated. + carrying a stable ``task_id`` they allocated. The + ``input-required`` status is in the spec task-status enum + (``schemas/cache/enums/task-status.json``); the per-tool + ``*_async_response_input_required`` envelopes are in + :mod:`adcp.types`. 2. Trafficker UI flips the row to approved/rejected on its own schedule. - 3. Adopter's webhook emitter (or a polling worker) fires the - terminal webhook to the buyer when the human acts. + 3. Adopter's webhook emitter fires the terminal webhook to the + buyer when the human acts. Use the SDK's webhook primitives in + :mod:`adcp.webhooks` (payload builders) + + :mod:`adcp.webhook_sender` (HMAC-SHA256 signing + IP-pinned + transport delivery) — same wire shape the framework uses on the + TaskHandoff path. The adopter owns the whole lifecycle in the human-driven case; the framework's TaskHandoff projector exists only for the "fn returns terminal artifact within a reasonable wall-clock" shape. v6.1 may add a richer ``ctx.handoff_to_human()`` primitive for the queued- - approval pattern; for v6.0, keep human approvals out of the - TaskHandoff path. + approval pattern; a worked end-to-end example lands with the + multi-tenant ``hello_publisher.py`` example in 4.5.0. For v6.0, + keep human approvals out of the TaskHandoff path. """ __slots__ = ("_fn",)