Skip to content

Commit 7f919cc

Browse files
bokelleyclaude
andauthored
feat(server): createRosterAccountStore — Shape C explicit AccountStore for publisher-curated rosters (#471)
* feat(decisioning): create_roster_account_store — Shape C explicit AccountStore for publisher-curated rosters Port of JS @adcp/sdk@6.7's createRosterAccountStore (eda2648f). Adds a factory for adopters who curate accounts out-of-band (admin UI, config file, publisher-managed DB row) and want the SDK to provide AccountStore plumbing instead of writing ExplicitAccounts(loader=...) by hand. Pairs with the existing reference adapters: - SingletonAccounts (Shape derived) — single-platform - ExplicitAccounts (Shape explicit, loader-driven) — multi-tenant - create_roster_account_store (Shape C) — fixed publisher allowlist Behavior: - resolve(ref): account_id-arm hits dict lookup; misses, natural-key refs, and ref-less calls return None - list(): returns the full roster — the roster IS the allowlist, auth filtering happens upstream of this layer - upsert / sync_governance: fail closed per-entry with PERMISSION_DENIED rather than silent no-op (which would lie) or operation-level raise (which would fail the whole batch on one entry) Roster is copied into MappingProxyType at construction so external mutation of the input dict cannot widen the allowlist post-construction. Construction validates every value's id matches its key. Diverges from the JS shape on one detail: JS takes a lookup callback for DB-backed point queries; the Python port takes the roster mapping directly. Adopters with a DB-backed roster wrap ExplicitAccounts(loader) or implement AccountStore directly — the in-memory roster is the common case and the indirection isn't pulling its weight here. Refs #459, #452. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(roster-store): conform to AccountStore Protocol + clarify list-last comment Address code-review BLOCKER: resolve(ref, ctx) didn't match the AccountStore Protocol (which uses resolve(ref, auth_info=None)) — the framework dispatcher would TypeError on the first request. Changed resolve to (ref, auth_info=None); auth_info is unused in the roster store body since the roster IS the allowlist. Also: - Added Protocol/dispatcher-shape tests (isinstance + auth_info kwarg call + positional call). - Added by-id upsert test confirming SyncAccountsResultRow shape. - Verified the list-last ordering: dropping it produces 6 mypy errors (Function 'list' is not valid as a type) because mypy does class-scope name resolution statically even with future annotations. Kept the ordering, rewrote the comment to state the actual reason. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 62b58dc commit 7f919cc

3 files changed

Lines changed: 623 additions & 0 deletions

File tree

src/adcp/decisioning/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ def create_media_buy(
126126
PropertyListReference,
127127
ResourceResolver,
128128
)
129+
from adcp.decisioning.roster_store import (
130+
create_roster_account_store,
131+
)
129132
from adcp.decisioning.serve import (
130133
create_adcp_server_from_platform,
131134
serve,
@@ -310,6 +313,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
310313
"bearer_only_registry",
311314
"compose_method",
312315
"create_adcp_server_from_platform",
316+
"create_roster_account_store",
313317
"create_translation_map",
314318
"create_upstream_http_client",
315319
"require_account_match",
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
"""Roster-backed :class:`AccountStore` factory for ``resolution='explicit'``
2+
publisher-curated platforms.
3+
4+
Use when the publisher curates accounts out-of-band (admin UI, config
5+
file, publisher-managed DB row) and buyers pass ``account_id`` on every
6+
request. The adopter holds a fixed allowlist of accounts; the SDK
7+
provides the :class:`AccountStore` plumbing.
8+
9+
Pairs with the existing reference adapters. Pick by asking *who creates
10+
the account?*:
11+
12+
* **Buyer self-onboards via** ``sync_accounts`` — multi-tenant adopter
13+
store (custom :class:`AccountStore` impl). Framework owns persistence;
14+
the buyer's first request to a tenant-scoped tool resolves from a
15+
prior sync. (LinkedIn, some retail-media operators.)
16+
* **Upstream OAuth API owns the roster** — :class:`ExplicitAccounts`
17+
with a loader that calls the upstream ``/me/adaccounts``. Returns
18+
whatever the OAuth bearer is authorized for. (Snap, Meta, TikTok.)
19+
* **Publisher ops curates the roster out-of-band** —
20+
:func:`create_roster_account_store` (this module). Adopter keeps the
21+
persistence layer; the SDK provides the AccountStore. (Most SSPs,
22+
broadcasters, and retail-media networks where AE/CSM provisions the
23+
account in an internal admin tool before the buyer ever calls.)
24+
25+
Design notes
26+
------------
27+
28+
* **Roster IS the allowlist.** Auth-based filtering happens upstream of
29+
this layer — the framework's account-resolution gate enforces
30+
principal-vs-account scope. The store does not consult ``ctx`` to
31+
filter ``list``.
32+
* **Immutable post-construction.** The input dict is copied into an
33+
internal :class:`MappingProxyType` so external mutation of the
34+
caller's dict cannot widen the allowlist after the fact. Adopters
35+
who need a dynamic roster wrap :func:`create_roster_account_store`
36+
in their own factory and rebuild on change.
37+
* **Write paths fail closed per-entry.** :meth:`upsert` and
38+
:meth:`sync_governance` return ``PERMISSION_DENIED`` for every input
39+
entry rather than silently no-oping (which would lie to the buyer
40+
about their write succeeding) or operation-level raising (which
41+
would fail the whole batch on a single bad entry). Per-entry
42+
rejection matches the wire shape and lets the buyer correlate the
43+
failure to their request entry.
44+
* **Resolve returns None on miss.** ``{brand, operator}``-shaped refs
45+
and ref-less calls return ``None`` — publisher-curated platforms
46+
expect explicit ids. Adopters who need a synth tenant for
47+
``list_creative_formats`` / ``provide_performance_feedback`` /
48+
``preview_creative``, or natural-key resolution, wrap ``resolve``.
49+
50+
Example::
51+
52+
from adcp.decisioning import Account, create_roster_account_store
53+
54+
store = create_roster_account_store(
55+
roster={
56+
"acct_alpha": Account(id="acct_alpha", name="Alpha", status="active"),
57+
"acct_beta": Account(id="acct_beta", name="Beta", status="active"),
58+
},
59+
)
60+
"""
61+
62+
from __future__ import annotations
63+
64+
from collections.abc import Mapping
65+
from types import MappingProxyType
66+
from typing import TYPE_CHECKING, Any, Generic, Literal
67+
68+
from typing_extensions import TypeVar
69+
70+
from adcp.decisioning.helpers import ref_account_id
71+
from adcp.decisioning.types import (
72+
Account,
73+
SyncAccountsResultRow,
74+
SyncGovernanceEntry,
75+
SyncGovernanceResultRow,
76+
)
77+
78+
if TYPE_CHECKING:
79+
from adcp.decisioning.accounts import ResolveContext
80+
from adcp.decisioning.context import AuthInfo
81+
from adcp.types import AccountReference
82+
83+
__all__ = ["create_roster_account_store"]
84+
85+
#: Per-platform metadata generic. Defaults to ``dict[str, Any]`` for
86+
#: adopters who don't define a typed metadata shape.
87+
TMeta = TypeVar("TMeta", default=dict[str, Any])
88+
89+
_DENIED_MESSAGE = (
90+
"roster-backed account store does not support upsert; "
91+
"the publisher curates accounts out-of-band"
92+
)
93+
_DENIED_MESSAGE_GOVERNANCE = (
94+
"roster-backed account store does not support sync_governance; "
95+
"the publisher curates accounts out-of-band"
96+
)
97+
98+
99+
class _RosterAccountStore(Generic[TMeta]):
100+
"""``AccountStore`` implementation backed by an immutable in-memory
101+
roster.
102+
103+
Constructed via :func:`create_roster_account_store`. The class is
104+
not part of the public API; adopters reference the factory.
105+
"""
106+
107+
resolution: Literal["explicit"] = "explicit"
108+
109+
def __init__(self, roster: Mapping[str, Account[TMeta]]) -> None:
110+
# Copy into a plain dict, then wrap in MappingProxyType so the
111+
# store's view is decoupled from the caller's input. Two layers
112+
# of protection: external mutation of the input dict can't
113+
# reach our copy, and adopter code that gets a reference to
114+
# ``self._roster`` can't mutate it either.
115+
copied: dict[str, Account[TMeta]] = dict(roster)
116+
for key, account in copied.items():
117+
if account.id != key:
118+
raise ValueError(
119+
f"roster key {key!r} does not match Account.id "
120+
f"{account.id!r}; every roster value's id must match "
121+
f"its key"
122+
)
123+
self._roster: Mapping[str, Account[TMeta]] = MappingProxyType(copied)
124+
125+
async def resolve(
126+
self,
127+
ref: AccountReference | None,
128+
auth_info: AuthInfo | None = None,
129+
) -> Account[TMeta] | None:
130+
"""Resolve a wire reference to a roster :class:`Account`.
131+
132+
``account_id``-arm refs hit a dict lookup; misses, natural-key
133+
refs, and ref-less calls return ``None``. The framework
134+
projects ``None`` to ``ACCOUNT_NOT_FOUND`` on the wire.
135+
136+
Signature mirrors the :class:`AccountStore` Protocol's
137+
``resolve(ref, auth_info=None)`` — the framework dispatcher
138+
passes ``auth_info`` as a keyword argument. ``auth_info`` is
139+
accepted for Protocol parity but unused: the roster IS the
140+
allowlist, no auth-based filtering at this layer.
141+
"""
142+
del auth_info # roster is the allowlist; no per-principal filtering
143+
account_id = ref_account_id(ref)
144+
if account_id is None:
145+
return None
146+
return self._roster.get(account_id)
147+
148+
async def upsert(
149+
self,
150+
refs: list[AccountReference],
151+
ctx: ResolveContext | None = None,
152+
) -> list[SyncAccountsResultRow]:
153+
"""Reject every entry with ``PERMISSION_DENIED``.
154+
155+
``sync_accounts`` is a buyer-driven write path; on a roster-
156+
backed store the adopter curates accounts out-of-band, so the
157+
buyer cannot write. Per-entry rejection (not operation-level
158+
throw) so a multi-account batch sees the rejection per row,
159+
matching the wire shape.
160+
"""
161+
del ctx
162+
rows: list[SyncAccountsResultRow] = []
163+
for ref in refs:
164+
brand = _ref_brand(ref)
165+
operator = _ref_operator(ref)
166+
rows.append(
167+
SyncAccountsResultRow(
168+
brand=brand,
169+
operator=operator,
170+
action="failed",
171+
status="failed",
172+
errors=[
173+
{
174+
"code": "PERMISSION_DENIED",
175+
"message": _DENIED_MESSAGE,
176+
"recovery": "terminal",
177+
}
178+
],
179+
)
180+
)
181+
return rows
182+
183+
async def sync_governance(
184+
self,
185+
entries: list[SyncGovernanceEntry],
186+
ctx: ResolveContext | None = None,
187+
) -> list[SyncGovernanceResultRow]:
188+
"""Reject every entry with ``PERMISSION_DENIED``.
189+
190+
``sync_governance`` registers buyer-supplied governance agent
191+
endpoints per-account; on a roster-backed store the adopter
192+
doesn't model buyer-supplied governance bindings. Per-entry
193+
rejection so a multi-account batch surfaces the rejection per
194+
row.
195+
"""
196+
del ctx
197+
return [
198+
SyncGovernanceResultRow(
199+
account=entry.account,
200+
status="failed",
201+
errors=[
202+
{
203+
"code": "PERMISSION_DENIED",
204+
"message": _DENIED_MESSAGE_GOVERNANCE,
205+
"recovery": "terminal",
206+
}
207+
],
208+
)
209+
for entry in entries
210+
]
211+
212+
# ``list`` MUST be declared LAST in this class. mypy resolves
213+
# annotations in class scope; once ``list`` is defined as a method,
214+
# any subsequent method whose annotations reference ``list[...]``
215+
# binds to the method, not the builtin — even with
216+
# ``from __future__ import annotations`` (lazy string evaluation
217+
# at runtime doesn't change static-analysis class-scope lookup).
218+
# Keeping ``list`` last means ``upsert``/``sync_governance`` above
219+
# see the builtin.
220+
async def list(
221+
self,
222+
filter: dict[str, Any] | None = None,
223+
ctx: ResolveContext | None = None,
224+
) -> list[Account[TMeta]]:
225+
"""Return every roster entry.
226+
227+
Adopters who need filtering (status, sandbox, pagination) wrap
228+
``list`` and post-filter the returned list — the roster store
229+
does not interpret ``filter`` because the typical roster
230+
cardinality (single-digit to low-thousands of accounts per
231+
publisher) is small enough that in-memory filtering at the
232+
adopter layer is fine.
233+
"""
234+
del filter, ctx
235+
return list(self._roster.values())
236+
237+
238+
def _ref_brand(ref: AccountReference | None) -> dict[str, Any]:
239+
"""Extract ``brand`` from a natural-key ref; empty dict otherwise.
240+
241+
The wire schema requires ``brand`` on every
242+
:class:`SyncAccountsResultRow`. For id-arm refs we don't have a
243+
brand, so we return an empty dict — the row is ``failed`` anyway,
244+
and the buyer correlates by request order.
245+
"""
246+
if ref is None:
247+
return {}
248+
brand = getattr(ref, "brand", None)
249+
if brand is None:
250+
return {}
251+
if hasattr(brand, "model_dump"):
252+
return brand.model_dump(mode="json", exclude_none=True) # type: ignore[no-any-return]
253+
if isinstance(brand, dict):
254+
return brand
255+
return {}
256+
257+
258+
def _ref_operator(ref: AccountReference | None) -> str:
259+
"""Extract ``operator`` from a natural-key ref; empty string
260+
otherwise. Same fallback rationale as :func:`_ref_brand`."""
261+
if ref is None:
262+
return ""
263+
operator = getattr(ref, "operator", None)
264+
return operator if isinstance(operator, str) else ""
265+
266+
267+
def create_roster_account_store(
268+
*,
269+
roster: Mapping[str, Account[TMeta]],
270+
) -> _RosterAccountStore[TMeta]:
271+
"""Build an :class:`AccountStore` backed by a fixed publisher-
272+
curated roster.
273+
274+
The returned object conforms to the :class:`AccountStore` Protocol
275+
plus the optional :class:`AccountStoreList`,
276+
:class:`AccountStoreUpsert`, and :class:`AccountStoreSyncGovernance`
277+
Protocols. ``upsert`` and ``sync_governance`` fail closed with
278+
``PERMISSION_DENIED`` per entry — adopters who need to support
279+
write paths use a custom :class:`AccountStore` implementation
280+
instead.
281+
282+
:param roster: Mapping from ``account_id`` → :class:`Account`. Each
283+
value's ``id`` MUST match its key; mismatch raises
284+
:class:`ValueError` at construction. The mapping is copied into
285+
an internal immutable view, so subsequent mutation of the
286+
caller's dict does not affect the store.
287+
288+
:returns: An :class:`AccountStore` whose:
289+
290+
* :meth:`resolve` returns the roster entry for an
291+
``account_id``-arm ref, ``None`` otherwise.
292+
* :meth:`list` returns every roster entry.
293+
* :meth:`upsert` rejects every input entry with
294+
``PERMISSION_DENIED``.
295+
* :meth:`sync_governance` rejects every input entry with
296+
``PERMISSION_DENIED``.
297+
298+
:raises ValueError: When any roster value's ``id`` does not match
299+
its dict key.
300+
"""
301+
return _RosterAccountStore(roster)

0 commit comments

Comments
 (0)