Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""require globally unique buyer-agent bearer identifiers

Revision ID: 0003
Revises: 0002
Create Date: 2026-07-29

``api_key_id`` is a bearer credential, so it must identify exactly one
commercial identity and tenant. The preflight deliberately stops the
migration before changing indexes when legacy duplicates exist; operators
must rotate or remove duplicates rather than letting the database choose an
arbitrary owner.
"""

from __future__ import annotations

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import context, op

revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
if not context.is_offline_mode():
connection = op.get_bind()
duplicate_count = connection.execute(
sa.text(
"SELECT COUNT(*) FROM ("
"SELECT api_key_id FROM buyer_agents "
"WHERE api_key_id IS NOT NULL "
"GROUP BY api_key_id HAVING COUNT(*) > 1"
") AS duplicate_credentials"
)
).scalar_one()
if duplicate_count:
raise RuntimeError(
"Cannot enforce buyer_agents.api_key_id uniqueness: "
f"found {duplicate_count} duplicated credential identifier(s). "
"Rotate or remove duplicate bearer credentials, then rerun the migration."
)

# Create first so a concurrent/legacy duplicate makes the migration fail
# while the old lookup index remains available. PostgreSQL then rolls the
# transaction back; offline SQL retains the same safe ordering.
op.create_index(
"buyer_agents_api_key_uidx",
"buyer_agents",
["api_key_id"],
unique=True,
postgresql_where=sa.text("api_key_id IS NOT NULL"),
sqlite_where=sa.text("api_key_id IS NOT NULL"),
)
op.drop_index("buyer_agents_api_key_idx", table_name="buyer_agents")


def downgrade() -> None:
op.drop_index("buyer_agents_api_key_uidx", table_name="buyer_agents")
op.create_index(
"buyer_agents_api_key_idx",
"buyer_agents",
["api_key_id"],
unique=False,
postgresql_where=sa.text("api_key_id IS NOT NULL"),
sqlite_where=sa.text("api_key_id IS NOT NULL"),
)
18 changes: 15 additions & 3 deletions examples/v3_reference_seller/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,18 @@ def _build_context_factory():

def build(meta: RequestMetadata) -> ToolContext:
ctx = auth_context_factory(meta)
# Pin tenant from SubdomainTenantMiddleware. Subdomain wins for
# tenant routing; the validator's tenant_id is only the token's
# home tenant and may not match the host the request came in on.
# A bearer token is valid only on its home tenant's host. Letting
# the subdomain silently replace the authenticated tenant would
# allow the same credential value to be rebound to a buyer row in
# another tenant.
tenant = current_tenant()
if tenant is not None:
if ctx.tenant_id is not None and ctx.tenant_id != tenant.id:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two defects on this line.

The guard raises from a hook no transport projects. serve.py:2656 calls context_factory(meta) above the try whose except ADCPError at :2682 builds the conformant envelope, and a2a_server.py:302 has the same shape. Verified at head:

{"result":{"content":[{"text":"Error executing tool get_products: AdcpError[PERMISSION_DENIED / terminal]: Bearer credential is not valid for this tenant.","type":"text"}],"isError":true}}

No adcp_error, no code, no recovery, and the exception repr is what lands in the tool text — for a condition any holder of a valid token triggers by changing the request host. On A2A the same raise escapes execute() and becomes a JSON-RPC transport error: one rule, two non-conformant and mutually different envelopes.

The condition fails open. ctx.tenant_id is not None and ... means deployments that leave Principal.tenant_id unset — documented as supported at src/adcp/server/auth.py:148-152 — still silently rebind the credential to whatever tenant the Host names, which is what the comment above says it prevents. Fail closed when a host tenant was resolved and the token carries none.

Root: the rule is framework-shaped and the framework neither enforces nor offers it. Both identities are visible at one point (the auth ContextVar holds the token's tenant, tenant_router.current_tenant() the host's), and the framework prints the vulnerable recipe twice — tenant_router.py:55-61 and tenant_registry.py:770-776. Do the comparison in auth_context_factory (or ship a subdomain_auth_context_factory), update both docstrings, and delete this check. test_bearer_context_rejects_cross_tenant_rebinding calls the factory directly and asserts only excinfo.value.code, so the wire shape and both allow paths (tenant_id is None must still pin; tenant_id == tenant.id must pin, not reject) are uncovered.

raise AdcpError(
"PERMISSION_DENIED",
message="Bearer credential is not valid for this tenant.",
recovery="terminal",
)
ctx = replace(ctx, tenant_id=tenant.id)

# Upgrade bearer-flow auth_info with a typed ApiKeyCredential
Expand Down Expand Up @@ -151,6 +158,11 @@ async def _load_token_map(sessionmaker) -> dict[str, Principal]:
select(BuyerAgentRow).where(BuyerAgentRow.api_key_id.is_not(None))
)
for row in result.scalars():
if row.api_key_id in token_map:
raise RuntimeError(
"Duplicate buyer-agent api_key_id detected; bearer credentials "
"must identify exactly one tenant."
)
token_map[row.api_key_id] = Principal(
caller_identity=row.agent_url,
tenant_id=row.tenant_id,
Expand Down
6 changes: 4 additions & 2 deletions examples/v3_reference_seller/src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,11 @@ class BuyerAgent(Base):
UniqueConstraint("tenant_id", "agent_url", name="buyer_agents_tenant_agent_uk"),
Index("buyer_agents_tenant_idx", "tenant_id"),
Index(
"buyer_agents_api_key_idx",
"buyer_agents_api_key_uidx",
"api_key_id",
postgresql_where=(api_key_id.is_not(None)), # type: ignore[has-type]
unique=True,
postgresql_where=(api_key_id.is_not(None)),
sqlite_where=(api_key_id.is_not(None)),
),
)

Expand Down
155 changes: 98 additions & 57 deletions examples/v3_reference_seller/src/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,11 +287,38 @@ async def resolve(
if ref is not None:
account_id = ref.get("account_id")

principal: str | None = None
if auth_info is not None:
principal = getattr(auth_info, "principal", None)
if not principal:
raise AdcpError(
"AUTH_REQUIRED",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two spec problems on this remap. AdCP 3.1.8 marks AUTH_REQUIRED DeprecatedenumDescriptions.AUTH_REQUIRED: "use AUTH_MISSING (no credentials presented) or AUTH_INVALID (credentials presented and rejected)" — and the condition here is exactly "no credentials presented", so the code is AUTH_MISSING (which is in the pinned enum). And enumMetadata gives AUTH_REQUIRED: correctable, so the recovery="terminal" three lines down is wrong either way.

The remap is also broader than it looks: the old code returned ACCOUNT_NOT_FOUND / correctable / field="account.account_id" with a message telling the buyer what to send, and the explicit-account_id arm did not require a principal at all. Every unauthenticated resolve now gets a terminal code. Nothing asserts either half — deleting this whole block leaves pytest examples/v3_reference_seller/tests/ at 59 passed, 2 deselected. Emit AUTH_MISSING with recovery="correctable", cite schemas/cache/3.1/enums/error-code.json@3.1.8 at the raise site, and add store.resolve({"account_id": "x"}, None) plus the brand-shaped-ref case asserting code and recovery.

message="Account resolution requires an authenticated buyer-agent principal.",
recovery="terminal",
)

async with sessionmaker() as session:
ba_result = await session.execute(
select(BuyerAgentRow).where(
BuyerAgentRow.tenant_id == tenant.id,
BuyerAgentRow.agent_url == principal,
)
)
buyer_agent = ba_result.scalar_one_or_none()
if buyer_agent is None:
raise AdcpError(
"ACCOUNT_NOT_FOUND",
message=(
f"No buyer agent matches principal {principal!r} "
f"under tenant {tenant.id!r}."
),
recovery="terminal",
)
if account_id:
result = await session.execute(
select(AccountRow).where(
AccountRow.tenant_id == tenant.id,
AccountRow.buyer_agent_id == buyer_agent.id,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This predicate is the new cross-buyer isolation guarantee, and no test observes it. test_account_store_explicit_id_is_bound_to_authenticated_buyer stubs session.execute with AsyncMock(side_effect=[buyer_result, missing_account_result]), so the second query returns None regardless of its WHERE clause — what it proves is that resolve now issues two queries, not the ownership binding.

Verified: deleting this line and running pytest examples/v3_reference_seller/tests/ gives 59 passed, 2 deselected. The cross-buyer read path reopens with a green suite, and the storyboard job cannot substitute because it drives only correctly-owned accounts. The sibling predicate on the brand-shaped path at :347 has the same exposure.

The shape to copy is already in this diff: test_account_store_upsert_cannot_overwrite_another_buyers_account uses a real AccountRow and asserts foreign.buyer_agent_id == "ba_other" afterwards. Use a real sqlite/asyncpg session with two AccountRows in tenant t_acme, one owned by ba_caller and one by ba_other: resolving ba_other's account_id as ba_caller must be ACCOUNT_NOT_FOUND, and ba_caller's own must still resolve.

AccountRow.account_id == account_id,
AccountRow.status == "active",
)
Expand All @@ -313,39 +340,6 @@ async def resolve(
# buyer agent. The buyer-agent's agent_url is the
# `principal` field on auth_info — populated by the
# framework's auth middleware from the validated bearer.
principal: str | None = None
if auth_info is not None:
principal = getattr(auth_info, "principal", None)
if not principal:
raise AdcpError(
"ACCOUNT_NOT_FOUND",
message=(
"Request did not include `account.account_id` "
"and no authenticated buyer-agent principal was "
"available to resolve a brand-shaped reference. "
"Send `account.account_id` explicitly, or "
"authenticate with a bearer token bound to a "
"seeded buyer agent."
),
recovery="correctable",
field="account.account_id",
)
ba_result = await session.execute(
select(BuyerAgentRow).where(
BuyerAgentRow.tenant_id == tenant.id,
BuyerAgentRow.agent_url == principal,
)
)
buyer_agent = ba_result.scalar_one_or_none()
if buyer_agent is None:
raise AdcpError(
"ACCOUNT_NOT_FOUND",
message=(
f"No buyer agent matches principal {principal!r} "
f"under tenant {tenant.id!r}."
),
recovery="terminal",
)
acct_result = await session.execute(
select(AccountRow)
.where(
Expand Down Expand Up @@ -447,6 +441,16 @@ async def upsert(
session.add(new_row)
action: str = "created"
else:
if existing.buyer_agent_id != buyer_agent_row.id:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This raises inside the for incoming in refs loop, inside async with session.begin(), so a batch where any single entry names another buyer's account aborts the call and rolls back the entries that already succeeded. Verified with a two-entry batch (entry 1 new, entry 2 foreign):

OPERATION-LEVEL RAISE: {'code':'ACCOUNT_NOT_FOUND', ..., 'field':'accounts'}
entry-1 rows lost; session.add had been called for: ['one.example::op.example']

schemas/cache/3.1/account/sync-accounts-response.json @ 3.1.8 reserves the operation-level channel for "complete failure"; the per-entry channel is accounts[i].action="failed" + status="rejected" + errors[]. It is also inconsistent with what this same PR does one layer up — tenant_store.upsert emits a per-entry ACCOUNT_NOT_FOUND row for the identical cross-boundary condition.

Append a SyncAccountsResultRow(action="failed", status="rejected", errors=[...]) and continue. field="accounts" is also unindexed where the spec's convention is an exact path (FIELD_NOT_PERMITTED: "error.field MUST identify the exact offending field path (e.g., packages[0].budget)") — point it at accounts[<i>]. The new test uses a single-entry batch, so add the mixed-batch case asserting the clean entry still lands.

raise AdcpError(
"ACCOUNT_NOT_FOUND",
message=(
f"Account {natural_account_id!r} is not visible "
"to the authenticated buyer agent."
),
recovery="terminal",
field="accounts",
)
existing.billing = billing_value
existing.billing_entity = billing_entity_payload
existing.sandbox = bool(incoming.sandbox)
Expand Down Expand Up @@ -826,8 +830,8 @@ def __init__(
# ``client_request_id``, so the seller has to track the mapping
# to (a) echo the buyer's id in ``list_creatives`` and (b)
# translate before calling ``attach_creative`` upstream.
self._creative_id_map: dict[str, str] = {} # buyer_id → upstream_id
self._creative_id_reverse: dict[str, str] = {} # upstream_id → buyer_id
self._creative_id_map: dict[tuple[str, str], str] = {}
self._creative_id_reverse: dict[tuple[str, str], str] = {}
# AccountStore is always wired. ``app.main`` passes the
# MOCK_AD_SERVER_URL env so resolved accounts route at the JS
# mock-server fixture. Tests that bypass the AccountStore (by
Expand Down Expand Up @@ -861,6 +865,35 @@ def _client(self, ctx: RequestContext) -> UpstreamHttpClient:
treat_404_as_none=False,
)

@staticmethod
def _account_scope(ctx: RequestContext) -> str:
"""Return the stable account boundary for seller-local state."""
tenant_id = str(ctx.account.metadata.get("tenant_id") or "")
return f"{tenant_id}:{ctx.account.id}"

async def _get_owned_order(
self,
ctx: RequestContext,
client: UpstreamHttpClient,
*,
network_code: str,
order_id: str,
) -> dict[str, Any]:
"""Fetch an order and fail closed unless it belongs to the account."""
order = await upstream_helpers.get_order(
client, network_code=network_code, order_id=order_id
)
expected_advertiser = ctx.account.metadata["advertiser_id"]
if order.get("advertiser_id") != expected_advertiser:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This ownership check is bypassable with the repo's own seed data. seed.py:117-142 seeds a_acme_1 (buyer agent ba_acme_signed) and a_acme_2 (ba_acme_bearer) with the same ext = {"network_code": "net_premium_us", "advertiser_id": "adv_volta_motors"} — two buyer agents, one advertiser id, so the comparison passes straight across the account boundary. The gate guards update_media_buy (:1394), get_media_buy_delivery (:1685, reads another buyer's spend and impressions) and provide_performance_feedback (:1912, writes conversions onto another buyer's campaign). test_update_media_buy_rejects_foreign_advertiser_order only exercises a different advertiser id.

The stronger identity already exists in this class: create_media_buy records _account_scope(ctx) on the buy state at :1110-1112, and the creative maps were correctly re-keyed to (account_scope, id). Pick one identity — buyer_agent_id is what the DB enforces — and check that.

Two more edges here. An absent advertiser_id on the upstream GET /orders/{id} is indistinguishable from a foreign order, so upstream schema drift becomes a silent total MEDIA_BUY_NOT_FOUND outage that looks spec-correct; an absent field should be an operator-visible SERVICE_UNAVAILABLE. And the comment on the next line claims foreign and nonexistent ids are indistinguishable, but the envelopes differ:

FOREIGN: {'code':'MEDIA_BUY_NOT_FOUND','message':"Media buy 'ord_foreign' was not found.",'recovery':'terminal','field':'media_buy_id'}
MISSING: {'code':'MEDIA_BUY_NOT_FOUND','message':'upstream GET /v1/orders/ord_missing failed: 404','recovery':'terminal'}

3.1.8's *_NOT_FOUND uniform-response rule (schemas/cache/3.1/enums/error-code.json@3.1.8) requires the response shape itself not be the oracle. Route both arms through one constructor and assert foreign.to_wire() == missing.to_wire(); the three new tests assert only excinfo.value.code.

# Keep foreign and nonexistent ids indistinguishable.
raise AdcpError(
"MEDIA_BUY_NOT_FOUND",
message=f"Media buy {order_id!r} was not found.",
recovery="terminal",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recovery="terminal" contradicts the pinned spec: schemas/cache/3.1/enums/error-code.json#enumMetadata @ 3.1.8 gives MEDIA_BUY_NOT_FOUND: correctable, and core/error.json calls recovery the normative carrier of recovery semantics. A conforming buyer classifying on it treats a fixable-and-resend condition as escalate-to-human.

The repo already owns the abstraction: src/adcp/decisioning/errors.py binds each code to its enumMetadata recovery (MediaBuyNotFoundError → correctable, verified) and its module docstring says "Recovery values are normative — they MUST match the enumMetadata block." Raise MediaBuyNotFoundError instead of the base class here and at the sibling new sites (platform.py:295, app.py:117).

field="media_buy_id",
)
return order

def _record(self, method: str, args: dict[str, Any]) -> None:
"""Record an outbound upstream call on the wired
:class:`MockAdServer`, if any.
Expand Down Expand Up @@ -1074,6 +1107,9 @@ async def create_media_buy(self, req: CreateMediaBuyRequest, ctx: RequestContext
)

order_id: str = order["order_id"]
self._buy_state.setdefault(order_id, {"packages": {}, "canceled": False})[

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The creative maps were re-keyed to (account_scope, id); _buy_state keeps the bare order_id key with account_scope written into the value, and that field is checked at one of eight read sites (:1625). :1331, :1422, :1499, :1640, :1704, :1791 and :1819 read or mutate _buy_state[order_id] / _buy_revisions[order_id] with no scope check. Today they are safe only because upstream order ids are globally unique in the mock and the callers are separately gated by _get_owned_order — which is the "safe only if" invariant that should live in the key rather than in a reviewer's head.

Also: setdefault(order_id, ...)["account_scope"] = ... silently rebinds an existing entry's owner instead of refusing, and _account_scope at :871 builds the scope from ctx.account.metadata.get("tenant_id") or "", so an Account constructed without that metadata key yields a tenant-less scope instead of failing.

Key _buy_state and _buy_revisions by (owner_key, order_id) like the creative maps so no reader can forget the check, and derive the key once from a typed metadata model — Account is generic in TMeta, so a TypedDict with required tenant_id / account_id / advertiser_id / network_code / buyer_agent_id turns roughly ten metadata["..."] / .get(...) or "" sites into checked attribute access. Neither the write here nor the gate at :1625 is exercised: replacing the :1626 comparison with == "NEVER_MATCHES" leaves 59 passed, 2 deselected.

"account_scope"
] = self._account_scope(ctx)
approval_task_id: str | None = order.get("approval_task_id")
# Sync fast path — the upstream may auto-approve on creation
# for non-guaranteed delivery (rare, but possible).
Expand Down Expand Up @@ -1358,7 +1394,7 @@ async def update_media_buy(

# Validate the media buy exists upstream. The SDK maps a 404 onto
# ``MEDIA_BUY_NOT_FOUND`` automatically.
await upstream_helpers.get_order(client, network_code=network_code, order_id=media_buy_id)
await self._get_owned_order(ctx, client, network_code=network_code, order_id=media_buy_id)

# Validate referenced packages exist on the order. The mock's
# ``serializeOrder`` strips ``line_items`` from ``GET /orders/{id}``
Expand Down Expand Up @@ -1441,7 +1477,8 @@ async def update_media_buy(
# before issuing attach_creative. Pass through unchanged
# when no mapping is known (the upstream will surface
# a 404 → CREATIVE_NOT_FOUND).
upstream_creative_id = self._creative_id_map.get(creative_id, creative_id)
creative_key = (self._account_scope(ctx), creative_id)
upstream_creative_id = self._creative_id_map.get(creative_key, creative_id)
await upstream_helpers.attach_creative(
client,
network_code=network_code,
Expand Down Expand Up @@ -1524,7 +1561,8 @@ async def sync_creatives(
# the seller treats the second call as "creative already
# known, just acknowledge". The buyer's intent for the new
# placement flows through the ``assignments`` field below.
if creative.creative_id in self._creative_id_map:
creative_key = (self._account_scope(ctx), creative.creative_id)
if creative_key in self._creative_id_map:
results.append(
SyncCreativeResult.model_validate(
{
Expand Down Expand Up @@ -1555,8 +1593,10 @@ async def sync_creatives(
)
upstream_id = str(upstream_resp.get("creative_id") or "")
if upstream_id:
self._creative_id_map[creative.creative_id] = upstream_id
self._creative_id_reverse[upstream_id] = creative.creative_id
self._creative_id_map[creative_key] = upstream_id
self._creative_id_reverse[(self._account_scope(ctx), upstream_id)] = (
creative.creative_id
)
results.append(
SyncCreativeResult.model_validate(
{
Expand All @@ -1575,14 +1615,16 @@ async def sync_creatives(
package_id = getattr(assignment, "package_id", None)
if not buyer_creative_id or not package_id:
continue
upstream_creative_id = self._creative_id_map.get(buyer_creative_id, buyer_creative_id)
creative_key = (self._account_scope(ctx), buyer_creative_id)
upstream_creative_id = self._creative_id_map.get(creative_key, buyer_creative_id)
# Find the owning order via the shadow store: package_ids are
# globally unique (upstream line_item ids).
owning_order_id = next(
(
oid
for oid, state in self._buy_state.items()
if package_id in state.get("packages", {})
if state.get("account_scope") == self._account_scope(ctx)
and package_id in state.get("packages", {})
),
None,
)
Expand Down Expand Up @@ -1640,6 +1682,9 @@ async def get_media_buy_delivery(
client = self._client(ctx)
for order_id in media_buy_ids:
try:
order_meta = await self._get_owned_order(
ctx, client, network_code=network_code, order_id=order_id
)
upstream_row = await upstream_helpers.get_delivery(
client, network_code=network_code, order_id=order_id
)
Expand All @@ -1654,19 +1699,7 @@ async def get_media_buy_delivery(
# the order so we project the correct AdCP MediaBuyStatus
# — completed / canceled / rejected buys would otherwise
# all surface as 'active' to the buyer.
try:
order_meta = await upstream_helpers.get_order(
client, network_code=network_code, order_id=order_id
)
upstream_status = order_meta.get("status", "")
except AdcpError as exc:
if exc.code == "MEDIA_BUY_NOT_FOUND":
# Delivery row exists but order is gone — odd,
# surface as 'active' so the row is at least
# well-formed; the operator's audit log will catch it.
upstream_status = ""
else:
raise
upstream_status = order_meta.get("status", "")
wire_status = _DELIVERY_STATUS_MAP.get(upstream_status, "active")
buy_state = self._buy_state.get(order_id, {})
if buy_state.get("canceled"):
Expand Down Expand Up @@ -1876,6 +1909,9 @@ async def provide_performance_feedback(
],
}
client = self._client(ctx)
await self._get_owned_order(
ctx, client, network_code=network_code, order_id=req.media_buy_id
)
await upstream_helpers.post_conversions(
client,
network_code=network_code,
Expand Down Expand Up @@ -1960,7 +1996,10 @@ async def list_creatives(
filters = getattr(req, "filters", None)
wanted_ids = list(getattr(filters, "creative_ids", None) or []) if filters else []
if wanted_ids:
upstream_wanted = {self._creative_id_map.get(cid, cid) for cid in wanted_ids}
account_scope = self._account_scope(ctx)
upstream_wanted = {
self._creative_id_map.get((account_scope, cid), cid) for cid in wanted_ids
}
upstream_creatives = [
c for c in upstream_creatives if c.get("creative_id") in upstream_wanted
]
Expand All @@ -1971,7 +2010,9 @@ async def list_creatives(
# Surface the buyer's original creative_id when the seller
# owns the mapping; falls back to the upstream id when the
# creative was synced outside this seller instance.
"creative_id": self._creative_id_reverse.get(c["creative_id"], c["creative_id"]),
"creative_id": self._creative_id_reverse.get(
(self._account_scope(ctx), c["creative_id"]), c["creative_id"]
),
"name": c["name"],
"format_id": {"agent_url": agent_url, "id": c.get("format_id", "")},
"status": _project_creative_status(c.get("status", "active")),
Expand Down
Loading
Loading