Skip to content

Commit 9fa0f28

Browse files
bokelleyclaude
andcommitted
fix-pack(roster-store): conform to AccountStore Protocol + clarify list-last comment
resolve(ref, ctx) didn't match the Protocol on main (resolve(ref, auth_info=None)), so the framework dispatcher's resolve(ref_dict, auth_info=auth_info) kwarg call would TypeError before reaching the dict lookup. Mirror the signature from the existing reference impls (SingletonAccounts / ExplicitAccounts / FromAuthAccounts). auth_info is unused — the roster IS the allowlist — but the parameter is required for Protocol parity. Tests added: isinstance(store, AccountStore) Protocol check, resolve called with auth_info= as a kwarg (matches dispatcher), resolve called positionally without auth_info, plus an upsert path for _by_id refs verifying the failed-row shape conforms to SyncAccountsResultRow. Reviewer flagged the list-last comment as cargo-cult given from __future__ import annotations. Investigated: the annotations are lazified at runtime but mypy still resolves them in class scope, so list-as-method does shadow the builtin for subsequent annotations. Kept the ordering; rewrote the comment to explain the actual reason (mypy class-scope lookup, not runtime annotation evaluation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 195d2bd commit 9fa0f28

2 files changed

Lines changed: 80 additions & 17 deletions

File tree

src/adcp/decisioning/roster_store.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777

7878
if TYPE_CHECKING:
7979
from adcp.decisioning.accounts import ResolveContext
80+
from adcp.decisioning.context import AuthInfo
8081
from adcp.types import AccountReference
8182

8283
__all__ = ["create_roster_account_store"]
@@ -124,18 +125,21 @@ def __init__(self, roster: Mapping[str, Account[TMeta]]) -> None:
124125
async def resolve(
125126
self,
126127
ref: AccountReference | None,
127-
ctx: ResolveContext | None = None,
128+
auth_info: AuthInfo | None = None,
128129
) -> Account[TMeta] | None:
129130
"""Resolve a wire reference to a roster :class:`Account`.
130131
131132
``account_id``-arm refs hit a dict lookup; misses, natural-key
132133
refs, and ref-less calls return ``None``. The framework
133134
projects ``None`` to ``ACCOUNT_NOT_FOUND`` on the wire.
134135
135-
``ctx`` is accepted for Protocol parity but unused — the
136-
roster is the allowlist, no auth-based filtering at this layer.
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.
137141
"""
138-
del ctx # roster is the allowlist; no per-principal filtering
142+
del auth_info # roster is the allowlist; no per-principal filtering
139143
account_id = ref_account_id(ref)
140144
if account_id is None:
141145
return None
@@ -205,12 +209,14 @@ async def sync_governance(
205209
for entry in entries
206210
]
207211

208-
# ``list`` is declared LAST in this class deliberately. Its name
209-
# shadows the built-in ``list`` in class scope, so any subsequent
210-
# method whose annotations use ``list[...]`` would resolve the
211-
# method, not the built-in. Keeping it at the end means
212-
# ``upsert``/``sync_governance`` annotations above resolve
213-
# correctly.
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.
214220
async def list(
215221
self,
216222
filter: dict[str, Any] | None = None,

tests/test_roster_store.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818

1919
from adcp.decisioning import (
2020
Account,
21+
AccountStore,
22+
AuthInfo,
2123
ResolveContext,
24+
SyncAccountsResultRow,
2225
SyncGovernanceEntry,
2326
create_roster_account_store,
2427
)
@@ -50,7 +53,7 @@ def _make_roster() -> dict[str, Account]:
5053
def test_resolve_hit_returns_account() -> None:
5154
"""ref carrying a known ``account_id`` returns the roster entry."""
5255
store = create_roster_account_store(roster=_make_roster())
53-
result = asyncio.run(store.resolve(_by_id("acct_alpha"), ResolveContext()))
56+
result = asyncio.run(store.resolve(_by_id("acct_alpha")))
5457
assert result is not None
5558
assert result.id == "acct_alpha"
5659
assert result.name == "Alpha"
@@ -60,7 +63,7 @@ def test_resolve_miss_returns_none() -> None:
6063
"""ref carrying an unknown ``account_id`` returns ``None`` —
6164
fall-through path the framework projects to ``ACCOUNT_NOT_FOUND``."""
6265
store = create_roster_account_store(roster=_make_roster())
63-
result = asyncio.run(store.resolve(_by_id("acct_unknown"), ResolveContext()))
66+
result = asyncio.run(store.resolve(_by_id("acct_unknown")))
6467
assert result is None
6568

6669

@@ -69,9 +72,7 @@ def test_resolve_natural_key_returns_none() -> None:
6972
curated rosters are queried by explicit id only. Adopters wanting
7073
natural-key resolution wrap ``resolve``."""
7174
store = create_roster_account_store(roster=_make_roster())
72-
result = asyncio.run(
73-
store.resolve(_by_natural_key("alpha.example.com", "alpha.example.com"), ResolveContext())
74-
)
75+
result = asyncio.run(store.resolve(_by_natural_key("alpha.example.com", "alpha.example.com")))
7576
assert result is None
7677

7778

@@ -81,10 +82,41 @@ def test_resolve_none_ref_returns_none() -> None:
8182
the helper returns ``None`` and adopters wrap to synthesize a
8283
publisher singleton when needed."""
8384
store = create_roster_account_store(roster=_make_roster())
84-
result = asyncio.run(store.resolve(None, ResolveContext()))
85+
result = asyncio.run(store.resolve(None))
8586
assert result is None
8687

8788

89+
def test_resolve_accepts_auth_info_kwarg() -> None:
90+
"""The framework dispatcher calls ``accounts.resolve(ref_dict,
91+
auth_info=auth_info)`` — i.e. ``auth_info`` is a keyword argument
92+
on every dispatch path. Verify the roster store accepts that exact
93+
call shape (and ignores ``auth_info`` because the roster IS the
94+
allowlist)."""
95+
store = create_roster_account_store(roster=_make_roster())
96+
auth = AuthInfo(kind="signed_request", principal="agent_foo", scopes=["read"])
97+
result = asyncio.run(store.resolve(_by_id("acct_alpha"), auth_info=auth))
98+
assert result is not None
99+
assert result.id == "acct_alpha"
100+
101+
102+
def test_resolve_positional_no_auth_info() -> None:
103+
"""Positional single-arg calls (no ``auth_info``) keep working —
104+
matches the Protocol's ``auth_info=None`` default."""
105+
store = create_roster_account_store(roster=_make_roster())
106+
result = asyncio.run(store.resolve(_by_id("acct_beta")))
107+
assert result is not None
108+
assert result.id == "acct_beta"
109+
110+
111+
def test_store_conforms_to_account_store_protocol() -> None:
112+
"""``AccountStore`` is ``runtime_checkable``; the framework's
113+
boot-time platform validator calls ``isinstance(store,
114+
AccountStore)``. Any structural drift between the roster store's
115+
``resolve`` signature and the Protocol breaks that check."""
116+
store = create_roster_account_store(roster=_make_roster())
117+
assert isinstance(store, AccountStore)
118+
119+
88120
def test_resolution_literal_is_explicit() -> None:
89121
"""Boot-time platform validation reads ``store.resolution`` to
90122
fail fast on misconfigured deployments. Roster stores are
@@ -149,6 +181,31 @@ def test_upsert_empty_refs_returns_empty() -> None:
149181
assert rows == []
150182

151183

184+
def test_upsert_denies_by_id_refs_with_conformant_row_shape() -> None:
185+
"""The typical ``sync_accounts`` shape carries ``account_id``-arm
186+
refs (buyer pre-selected an account id, calling sync to bind
187+
governance / verify exists). Roster stores reject those entries
188+
too — accounts are publisher-curated. Verify the failed row's
189+
shape conforms to :class:`SyncAccountsResultRow` (instance type +
190+
required fields populated, so the framework's wire projector
191+
won't crash on a missing field)."""
192+
store = create_roster_account_store(roster=_make_roster())
193+
rows = asyncio.run(
194+
store.upsert([_by_id("acct_alpha"), _by_id("acct_unknown")], ctx=ResolveContext())
195+
)
196+
assert len(rows) == 2
197+
for row in rows:
198+
assert isinstance(row, SyncAccountsResultRow)
199+
assert row.action == "failed"
200+
assert row.status == "failed"
201+
assert row.errors is not None
202+
assert row.errors[0]["code"] == "PERMISSION_DENIED"
203+
# id-arm refs don't carry brand/operator; failed rows surface
204+
# empty defaults (the buyer correlates by request order).
205+
assert row.brand == {}
206+
assert row.operator == ""
207+
208+
152209
def test_upsert_echoes_brand_operator_for_natural_key_refs() -> None:
153210
"""``SyncAccountsResultRow.brand`` and ``operator`` are required
154211
on the wire. For natural-key refs we echo them back so the buyer
@@ -257,5 +314,5 @@ def test_external_mutation_does_not_leak_into_store() -> None:
257314
assert ids == {"acct_alpha", "acct_beta"}
258315
assert "acct_attacker" not in ids
259316

260-
attacker = asyncio.run(store.resolve(_by_id("acct_attacker"), ResolveContext()))
317+
attacker = asyncio.run(store.resolve(_by_id("acct_attacker")))
261318
assert attacker is None

0 commit comments

Comments
 (0)