From 297c17a3c3c5f5fefcef3ee94b0492ac87dc12b5 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Wed, 3 Jun 2026 18:07:39 +0600 Subject: [PATCH 01/16] Dockerfile update: Uncommented the SKIP_NGINX and SKIP_CRON env --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 19f6a8ed0..713e654b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -167,11 +167,11 @@ ENV ADCP_HOST=0.0.0.0 # core/main.py serves MCP, A2A, and the Flask admin from one Starlette # binary on $ADCP_PORT. The bundled nginx thread in run_all_services.py # is unused on this fork — kept off via SKIP_NGINX=true. -#ENV SKIP_NGINX=false +ENV SKIP_NGINX=false # Server-owned adapter schedulers replace the bundled supercronic inventory # sweep in the default container runtime. Operators can still opt back into # cron by overriding this, but should not run both mechanisms together. -#ENV SKIP_CRON=true +ENV SKIP_CRON=false # Expose the unified python port directly. Fly.io / upstream proxy # talks to this port; no in-image reverse proxy. From 5dd93f52e423fb33f92e1a7ce58ef45f9f81f07d Mon Sep 17 00:00:00 2001 From: chinmoy Date: Wed, 3 Jun 2026 20:10:55 +0600 Subject: [PATCH 02/16] fix healthcheck command --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 713e654b6..1389553a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -179,7 +179,8 @@ EXPOSE 8000 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8080/health || exit 1 + CMD ["python", "scripts/healthcheck.py", "8000"] + # Use venv Python directly as entrypoint (prepares for hardened images that lack bash) ENTRYPOINT ["/app/.venv/bin/python", "scripts/deploy/run_all_services.py"] From c9852d5bb57a0c5fb23eb3201d5f81f38050aa28 Mon Sep 17 00:00:00 2001 From: chinmoy Date: Thu, 4 Jun 2026 15:04:32 +0600 Subject: [PATCH 03/16] stopped overriding the ADCP_SALES_PORT to different port --- Dockerfile | 2 +- scripts/deploy/run_all_services.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1389553a7..2750790de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -178,7 +178,7 @@ ENV SKIP_CRON=false EXPOSE 8000 # Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ +HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=5 \ CMD ["python", "scripts/healthcheck.py", "8000"] diff --git a/scripts/deploy/run_all_services.py b/scripts/deploy/run_all_services.py index e3a94656a..022d17dcc 100644 --- a/scripts/deploy/run_all_services.py +++ b/scripts/deploy/run_all_services.py @@ -225,9 +225,9 @@ def run_migrations(): def run_mcp_server(): """Run the MCP server.""" - print("Starting MCP server on port 8080...") + port = os.environ.get("ADCP_SALES_PORT", "8000") + print(f"Starting MCP server on port {port}...") env = os.environ.copy() - env["ADCP_SALES_PORT"] = "8080" proc = subprocess.Popen( [sys.executable, "scripts/run_server.py"], env=env, @@ -245,10 +245,9 @@ def run_mcp_server(): def exec_mcp_server(): """Replace this wrapper process with the unified MCP/A2A/Admin server.""" - print("Starting MCP server on port 8080...") - env = os.environ.copy() - env["ADCP_SALES_PORT"] = "8080" - os.execvpe(sys.executable, [sys.executable, "scripts/run_server.py"], env) + port = os.environ.get("ADCP_SALES_PORT", "8000") + print(f"Starting MCP server on port {port}...") + os.execvpe(sys.executable, [sys.executable, "scripts/run_server.py"], os.environ.copy()) def run_nginx(): From fc5d2717989a45550057c9116dd91bed3436ca44 Mon Sep 17 00:00:00 2001 From: chinmoy Date: Thu, 4 Jun 2026 16:01:48 +0600 Subject: [PATCH 04/16] reduce cache size and pool size --- src/admin/app.py | 1 + src/core/database/database_session.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/admin/app.py b/src/admin/app.py index 1dc8a391d..06634b6a9 100644 --- a/src/admin/app.py +++ b/src/admin/app.py @@ -281,6 +281,7 @@ def __call__(self, environ, start_response): cache_config = { "CACHE_TYPE": "SimpleCache", # In-memory cache (good for single-process deployments) "CACHE_DEFAULT_TIMEOUT": 300, # 5 minutes default + "CACHE_THRESHOLD": 50, # Evict old entries before accumulating large Response objects } app.config.update(cache_config) cache = Cache(app) diff --git a/src/core/database/database_session.py b/src/core/database/database_session.py index 9c47dab5a..ca835ddaa 100644 --- a/src/core/database/database_session.py +++ b/src/core/database/database_session.py @@ -144,8 +144,8 @@ def get_engine(): # Direct PostgreSQL settings (no PgBouncer) _engine = create_engine( connection_string, - pool_size=10, # Base connections in pool - max_overflow=20, # Additional connections beyond pool_size + pool_size=5, # Base connections in pool + max_overflow=5, # Additional connections beyond pool_size pool_timeout=pool_timeout, # Seconds to wait for connection from pool pool_recycle=3600, # Recycle connections after 1 hour pool_pre_ping=True, # Test connections before use From 4db5916fd361702fd60278e95aec93c9d45022ed Mon Sep 17 00:00:00 2001 From: chinmoy Date: Thu, 4 Jun 2026 16:06:42 +0600 Subject: [PATCH 05/16] hardcoded set SKIP_NGINX to True --- scripts/deploy/run_all_services.py | 3 ++- src/admin/blueprints/auth.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/deploy/run_all_services.py b/scripts/deploy/run_all_services.py index 022d17dcc..eca5a684d 100644 --- a/scripts/deploy/run_all_services.py +++ b/scripts/deploy/run_all_services.py @@ -375,7 +375,8 @@ def main(): threads = [] skip_cron = os.environ.get("SKIP_CRON", "false").lower() == "true" - skip_nginx = os.environ.get("SKIP_NGINX", "false").lower() == "true" + # TODO: remove hardcoded skip_nginx value + skip_nginx = True #os.environ.get("SKIP_NGINX", "false").lower() == "true" if skip_cron and skip_nginx: # In the single-process runtime used by e2e and most deployments, run # the ASGI server as PID 1 after migrations/init. Keeping a Python diff --git a/src/admin/blueprints/auth.py b/src/admin/blueprints/auth.py index 8cddd36d0..baaa401aa 100644 --- a/src/admin/blueprints/auth.py +++ b/src/admin/blueprints/auth.py @@ -402,7 +402,8 @@ def google_auth(): # Only add /admin prefix in production mode with nginx (not in Docker standalone) # SKIP_NGINX=true indicates Docker standalone mode without nginx reverse proxy - skip_nginx = os.environ.get("SKIP_NGINX", "").lower() == "true" + # TODO: remove hardcoded skip_nginx value + skip_nginx = True # os.environ.get("SKIP_NGINX", "false").lower() == "true" production = os.environ.get("PRODUCTION", "").lower() == "true" if not skip_nginx and production and "/admin/" not in base_url: From 9328d3653b7a53cfbe2aee81cb3abf886d6a3d30 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 4 Jun 2026 16:31:38 +0600 Subject: [PATCH 06/16] Change ADCP_PORT to 8000 in Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 2750790de..e8e279550 100644 --- a/Dockerfile +++ b/Dockerfile @@ -161,7 +161,7 @@ ENV PYTHONPATH="/app" ENV PYTHONUNBUFFERED=1 # Default port -ENV ADCP_PORT=8080 +ENV ADCP_PORT=8000 ENV ADCP_HOST=0.0.0.0 # core/main.py serves MCP, A2A, and the Flask admin from one Starlette From 156171f799c5877c72100dc7c7461db4d453a1c9 Mon Sep 17 00:00:00 2001 From: chinmoy Date: Thu, 4 Jun 2026 16:52:08 +0600 Subject: [PATCH 07/16] reverted pool size and cache size --- src/admin/app.py | 2 +- src/core/database/database_session.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/admin/app.py b/src/admin/app.py index 06634b6a9..a3c5df1bf 100644 --- a/src/admin/app.py +++ b/src/admin/app.py @@ -281,7 +281,7 @@ def __call__(self, environ, start_response): cache_config = { "CACHE_TYPE": "SimpleCache", # In-memory cache (good for single-process deployments) "CACHE_DEFAULT_TIMEOUT": 300, # 5 minutes default - "CACHE_THRESHOLD": 50, # Evict old entries before accumulating large Response objects + # "CACHE_THRESHOLD": 50, # Evict old entries before accumulating large Response objects } app.config.update(cache_config) cache = Cache(app) diff --git a/src/core/database/database_session.py b/src/core/database/database_session.py index ca835ddaa..9c47dab5a 100644 --- a/src/core/database/database_session.py +++ b/src/core/database/database_session.py @@ -144,8 +144,8 @@ def get_engine(): # Direct PostgreSQL settings (no PgBouncer) _engine = create_engine( connection_string, - pool_size=5, # Base connections in pool - max_overflow=5, # Additional connections beyond pool_size + pool_size=10, # Base connections in pool + max_overflow=20, # Additional connections beyond pool_size pool_timeout=pool_timeout, # Seconds to wait for connection from pool pool_recycle=3600, # Recycle connections after 1 hour pool_pre_ping=True, # Test connections before use From c6d89f06bf5712f9b3d83936ba470767a8ce28f0 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 4 Jun 2026 18:44:53 +0600 Subject: [PATCH 08/16] Removed Hardcoded SKIP_NGINX value --- scripts/deploy/run_all_services.py | 3 +-- src/admin/blueprints/auth.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/scripts/deploy/run_all_services.py b/scripts/deploy/run_all_services.py index eca5a684d..022d17dcc 100644 --- a/scripts/deploy/run_all_services.py +++ b/scripts/deploy/run_all_services.py @@ -375,8 +375,7 @@ def main(): threads = [] skip_cron = os.environ.get("SKIP_CRON", "false").lower() == "true" - # TODO: remove hardcoded skip_nginx value - skip_nginx = True #os.environ.get("SKIP_NGINX", "false").lower() == "true" + skip_nginx = os.environ.get("SKIP_NGINX", "false").lower() == "true" if skip_cron and skip_nginx: # In the single-process runtime used by e2e and most deployments, run # the ASGI server as PID 1 after migrations/init. Keeping a Python diff --git a/src/admin/blueprints/auth.py b/src/admin/blueprints/auth.py index baaa401aa..e98a72f52 100644 --- a/src/admin/blueprints/auth.py +++ b/src/admin/blueprints/auth.py @@ -402,8 +402,7 @@ def google_auth(): # Only add /admin prefix in production mode with nginx (not in Docker standalone) # SKIP_NGINX=true indicates Docker standalone mode without nginx reverse proxy - # TODO: remove hardcoded skip_nginx value - skip_nginx = True # os.environ.get("SKIP_NGINX", "false").lower() == "true" + skip_nginx = os.environ.get("SKIP_NGINX", "false").lower() == "true" production = os.environ.get("PRODUCTION", "").lower() == "true" if not skip_nginx and production and "/admin/" not in base_url: From 11d67e49d9322340d6cd96c7e837e8364f80ddc3 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 4 Jun 2026 22:13:44 +0600 Subject: [PATCH 09/16] skip_cron hardcoded value set to false --- Dockerfile | 2 +- scripts/deploy/run_all_services.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index e8e279550..8795ecaf9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -171,7 +171,7 @@ ENV SKIP_NGINX=false # Server-owned adapter schedulers replace the bundled supercronic inventory # sweep in the default container runtime. Operators can still opt back into # cron by overriding this, but should not run both mechanisms together. -ENV SKIP_CRON=false +ENV SKIP_CRON=True # Expose the unified python port directly. Fly.io / upstream proxy # talks to this port; no in-image reverse proxy. diff --git a/scripts/deploy/run_all_services.py b/scripts/deploy/run_all_services.py index 022d17dcc..4119a7902 100644 --- a/scripts/deploy/run_all_services.py +++ b/scripts/deploy/run_all_services.py @@ -374,7 +374,10 @@ def main(): # Start services in threads threads = [] - skip_cron = os.environ.get("SKIP_CRON", "false").lower() == "true" + #skip_cron = os.environ.get("SKIP_CRON", "false").lower() == "true" + + #TODO: Remove skip_cron hardcoded value + skip_cron = False skip_nginx = os.environ.get("SKIP_NGINX", "false").lower() == "true" if skip_cron and skip_nginx: # In the single-process runtime used by e2e and most deployments, run From 4d07b83e8f8dbe717a0408c5237dc327aba50afa Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Fri, 5 Jun 2026 12:33:27 +0600 Subject: [PATCH 10/16] update healthcheck retry --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 431e1eaf3..9523fffa1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -178,8 +178,8 @@ ENV SKIP_CRON=false EXPOSE 8000 # Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8080/health || exit 1 +HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \ + CMD ["python", "scripts/healthcheck.py", "8000"] # Use venv Python directly as entrypoint (prepares for hardened images that lack bash) ENTRYPOINT ["/app/.venv/bin/python", "scripts/deploy/run_all_services.py"] From d093bcc3a91d99c70426779d8bf27b6c19d1cee7 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Fri, 5 Jun 2026 16:32:35 +0600 Subject: [PATCH 11/16] gam create service account access set to the api_mode = true --- src/admin/blueprints/gam.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/admin/blueprints/gam.py b/src/admin/blueprints/gam.py index 70b8d9ac6..4d9c61119 100644 --- a/src/admin/blueprints/gam.py +++ b/src/admin/blueprints/gam.py @@ -752,7 +752,7 @@ def reset_stuck_sync(tenant_id): @gam_bp.route("/create-service-account", methods=["POST"]) @log_admin_action("create_gam_service_account") -@require_tenant_access(role=("admin",)) +@require_tenant_access(api_mode=True, role=("admin",)) def create_service_account(tenant_id): """Create a GCP service account for GAM integration. From da26784bd9eefcc54dfa3da117fe9e9af9d1b842 Mon Sep 17 00:00:00 2001 From: chinmoy Date: Mon, 8 Jun 2026 17:32:16 +0600 Subject: [PATCH 12/16] added gam sync button --- src/admin/blueprints/buyer_routing.py | 47 ++++++++++++++++++ templates/buyer_routing.html | 71 ++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/src/admin/blueprints/buyer_routing.py b/src/admin/blueprints/buyer_routing.py index ae50ac7cc..886745785 100644 --- a/src/admin/blueprints/buyer_routing.py +++ b/src/admin/blueprints/buyer_routing.py @@ -18,6 +18,7 @@ from __future__ import annotations import logging +import threading import uuid from datetime import UTC, datetime @@ -43,10 +44,12 @@ AdvertiserRoutingRule, GamAdvertiser, Principal, + SyncJob, Tenant, ) from src.core.database.repositories.gam_sync import GAMSyncRepository from src.core.database.repositories.tenant_config import TenantConfigRepository +from src.services.gam_advertisers_sync import sync_advertisers from src.services.recent_buyers_service import compute_recent_buyers logger = logging.getLogger(__name__) @@ -627,3 +630,47 @@ def list_principals(tenant_id: str): rows = tenant_repo.list_principals() principals = [{"principal_id": p.principal_id, "name": p.name} for p in rows] return jsonify({"principals": principals}) + + +@buyer_routing_bp.route( + "//buyer-routing/api/sync-advertisers", + methods=["POST"], + strict_slashes=False, +) +@require_tenant_access(api_mode=True, role=("admin", "member")) +def trigger_advertiser_sync(tenant_id: str): + """Trigger a background GAM advertisers sync from the buyer-routing page. + + Creates a pending SyncJob, spawns a daemon thread, and returns the + sync_id so the client can poll + ``GET /tenant//gam/sync-status/`` for progress. + """ + started_at = datetime.now(UTC) + sync_id = f"sync_{tenant_id}_advertisers_{int(started_at.timestamp() * 1_000_000)}" + + with get_db_session() as session: + tenant = session.scalars(select(Tenant).filter_by(tenant_id=tenant_id)).first() + if tenant is None: + return _api_error_json("tenant_not_found", f"Tenant {tenant_id!r} does not exist", 404) + + job = SyncJob( + sync_id=sync_id, + tenant_id=tenant_id, + adapter_type="google_ad_manager", + sync_type="advertisers", + status="pending", + started_at=started_at, + triggered_by="admin_ui", + triggered_by_id="sync_advertisers_button", + ) + session.add(job) + session.commit() + + def _run() -> None: + try: + sync_advertisers(tenant_id, sync_id=sync_id) + except Exception: + logger.exception("[%s] advertiser sync thread failed", sync_id) + + threading.Thread(target=_run, daemon=True, name=f"adv-sync-{sync_id}").start() + return jsonify({"sync_id": sync_id}) diff --git a/templates/buyer_routing.html b/templates/buyer_routing.html index 5de37a67d..acd223bcf 100644 --- a/templates/buyer_routing.html +++ b/templates/buyer_routing.html @@ -414,7 +414,11 @@

Routing rules

-

Advertisers

+
+

Advertisers

+ +
+

Every GAM advertiser synced from your network. Assign a buyer agent to surface that advertiser's orders and line items in the agent's @@ -968,6 +972,71 @@

Add routing rule

}); }); + // ---------- Sync advertisers ---------- + const syncAdvBtn = document.querySelector('[data-action="sync-advertisers"]'); + const syncAdvStatus = document.querySelector('[data-sync-advertisers-status]'); + const gamStatusBase = scriptRoot + '/tenant/' + encodeURIComponent(tenantId) + '/gam/sync-status/'; + + function pollAdvertiserSyncStatus(syncId) { + var intervalId = setInterval(function () { + fetch(gamStatusBase + encodeURIComponent(syncId), { + credentials: 'same-origin', + headers: { 'Accept': 'application/json' }, + }) + .then(function (r) { return r.json(); }) + .then(function (body) { + if (body.status === 'completed') { + clearInterval(intervalId); + var s = body.summary || {}; + var msg = 'Sync complete — ' + (s.upserted || 0) + ' upserted, ' + (s.soft_deleted || 0) + ' inactive.'; + showToast(msg); + window.location.reload(); + } else if (body.status === 'failed') { + clearInterval(intervalId); + syncAdvStatus.textContent = 'Sync failed: ' + (body.error || 'unknown error'); + syncAdvStatus.style.color = 'var(--sa-danger)'; + syncAdvBtn.disabled = false; + syncAdvBtn.textContent = '↺ Sync from GAM'; + } + }) + .catch(function () { /* transient — keep polling */ }); + }, 2000); + } + + if (syncAdvBtn) { + syncAdvBtn.addEventListener('click', function () { + syncAdvBtn.disabled = true; + syncAdvBtn.textContent = '↺ Syncing…'; + syncAdvStatus.style.display = 'block'; + syncAdvStatus.style.color = 'var(--sa-text-muted)'; + syncAdvStatus.textContent = 'Starting sync…'; + + fetch(baseUrl + '/api/sync-advertisers', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + }) + .then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); }) + .then(function (resp) { + if (!resp.ok) { + syncAdvStatus.textContent = resp.body.message || 'Failed to start sync.'; + syncAdvStatus.style.color = 'var(--sa-danger)'; + syncAdvBtn.disabled = false; + syncAdvBtn.textContent = '↺ Sync from GAM'; + return; + } + syncAdvStatus.textContent = 'Sync in progress…'; + pollAdvertiserSyncStatus(resp.body.sync_id); + }) + .catch(function () { + syncAdvStatus.textContent = 'Network error — try again.'; + syncAdvStatus.style.color = 'var(--sa-danger)'; + syncAdvBtn.disabled = false; + syncAdvBtn.textContent = '↺ Sync from GAM'; + }); + }); + } + // Advertiser → agent assignment selects document.querySelectorAll('[data-action="assign-agent"]').forEach(function (select) { select.addEventListener('change', async function () { From 66c42e1e4d975b5ee9ba8e9d7a10f6ccd747f8e8 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Tue, 9 Jun 2026 14:46:39 +0600 Subject: [PATCH 13/16] fix saveGAMConfig() issue now auto-create the row when it's missing. --- src/services/gcp_service_account_service.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/services/gcp_service_account_service.py b/src/services/gcp_service_account_service.py index 64187cbc7..97c2c2e90 100644 --- a/src/services/gcp_service_account_service.py +++ b/src/services/gcp_service_account_service.py @@ -57,7 +57,7 @@ from sqlalchemy import select from src.core.database.database_session import get_db_session -from src.core.database.models import AdapterConfig +from src.core.database.models import AdapterConfig, Tenant logger = logging.getLogger(__name__) @@ -141,12 +141,19 @@ def create_service_account_for_tenant(self, tenant_id: str, display_name: str | Exception: If service account creation fails """ with get_db_session() as session: - # Get adapter config + # Verify tenant exists before touching adapter config + tenant = session.scalars(select(Tenant).filter_by(tenant_id=tenant_id)).first() + if not tenant: + raise ValueError(f"Tenant {tenant_id} not found") + + # Get or create adapter config — service account creation is the first step + # of GAM setup, so the row may not exist yet. stmt = select(AdapterConfig).filter_by(tenant_id=tenant_id) adapter_config = session.scalars(stmt).first() if not adapter_config: - raise ValueError(f"Tenant {tenant_id} not found or has no adapter config") + adapter_config = AdapterConfig(tenant_id=tenant_id, adapter_type="google_ad_manager") + session.add(adapter_config) # Check if service account already exists if adapter_config.gam_service_account_email: From c1e0da357efb5fd685487cada2ee1f2efc0fa38d Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Tue, 9 Jun 2026 15:58:35 +0600 Subject: [PATCH 14/16] gam creating service account error message improvment --- src/admin/blueprints/gam.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/admin/blueprints/gam.py b/src/admin/blueprints/gam.py index 4d9c61119..88cb63c40 100644 --- a/src/admin/blueprints/gam.py +++ b/src/admin/blueprints/gam.py @@ -799,7 +799,22 @@ def create_service_account(tenant_id): except Exception as e: logger.error(f"Error creating service account for tenant {tenant_id}: {e}", exc_info=True) - return jsonify({"success": False, "error": f"Failed to create service account: {str(e)}"}), 500 + error_str = str(e) + if "SERVICE_DISABLED" in error_str and "iam.googleapis.com" in error_str: + import re + + match = re.search(r"activationUrl[^\"]*\"([^\"]+)\"", error_str) + activation_url = match.group(1) if match else "https://console.developers.google.com/apis/api/iam.googleapis.com/overview" + return jsonify( + { + "success": False, + "error": ( + "The IAM API is not enabled in the configured GCP project. " + f"Enable it at: {activation_url} — then wait ~2 minutes and retry." + ), + } + ), 500 + return jsonify({"success": False, "error": f"Failed to create service account: {error_str}"}), 500 except Exception as e: logger.error(f"Error in create_service_account endpoint: {e}", exc_info=True) From b30207d97873fbac27ed7559e17b6c1a460935d4 Mon Sep 17 00:00:00 2001 From: Sadrul Islam Toaha Date: Thu, 11 Jun 2026 16:09:11 +0600 Subject: [PATCH 15/16] Merge master fork branch update (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#353): emit status on update_media_buy pause/resume/cancel responses (#359) The ``media_buy_state_machine`` storyboard's ``pause_buy``/``resume_buy``/ ``cancel_buy`` steps assert ``field_present @ /status`` on the ``update_media_buy`` wire response. Buyers need the resulting status to confirm the lifecycle transition without an extra ``get_media_buys`` round-trip. Three response-construction sites were omitting ``status``: 1. The cancel path (``media_buy_update.py``) — set ``status="canceled"`` on the ``UpdateMediaBuySuccess``. 2. The pause/resume path — set ``status="paused"`` or ``status="active"`` based on the request's ``paused`` flag. 3. The manual-approval deferred path — surface the buy's CURRENT persisted status (the update hasn't transitioned the buy yet — it's pending human approval). Read ``current_buy.status`` directly rather than via ``_compute_status`` so the path is robust to mocked test fixtures whose ``start_time``/``end_time`` aren't real datetimes. Verified with the local storyboard run: * Before: ``state_transitions: passed=false`` — ``✗ Response includes updated status: Field not found at path: status`` * After: ``state_transitions: passed=true`` (pause + resume + cancel all green) The ``terminal_enforcement`` scenario still fails — it expects ``INVALID_STATE`` code on attempts to pause/resume/cancel a terminal buy. That's a separate spec gap (no ``AdCPInvalidStateError`` class yet) and out of scope for #353. Three regression tests pin the new behavior: ``test_pause_response_includes_status_paused``, ``test_resume_response_includes_status_active``, ``test_cancel_response_includes_status_canceled``. Co-authored-by: Claude Opus 4.7 (1M context) * fix(#354): resolve tenant_id from auth_info on list_accounts/sync_accounts (#360) ``SalesagentAccountStore._identity_from_ctx`` was reading tenant_id exclusively from the ``adcp.server.auth.current_tenant`` ContextVar, which ``BearerTokenAuthMiddleware`` sets but which doesn't propagate across the MCP stateful-session task boundary. Every list_accounts / sync_accounts call from an authenticated buyer landed with ``tenant_id=None`` and surfaced as ``ACCOUNT_NOT_FOUND`` / "no tenant resolved on the request context." The same store's ``resolve()`` path already had the fix: use :meth:`_tenant_from_principal` which falls back to ``auth_info.principal`` → DB lookup. Mirroring that chain inside ``_identity_from_ctx`` makes list/sync task-safe. Verified locally with ``adcp localmcp list_accounts --json`` (now returns ``accounts: []`` instead of crashing) and with the full ``pagination_integrity_list_accounts`` storyboard run (all three scenarios — capability_discovery, setup, pagination_walk — green). Co-authored-by: Claude Opus 4.7 (1M context) * feat(#352): emit proposals[] on get_products when buying_mode=brief (#361) The ``media_buy_seller/proposal_finalize/get_products_brief`` storyboard asserts that ``get_products`` calls with ``buying_mode='brief'`` return a ``proposals[]`` array carrying at least one ``Proposal`` with a ``proposal_id`` buyers can echo into ``create_media_buy(proposal_id=...)`` to execute the bundle. Pre-PR the proposal manager forwarded directly to ``_get_products_impl`` and never emitted ``proposals``. v1 strategy: split budget evenly across every product the publisher returned. Each ``ProductAllocation`` references a real ``product_id`` and ``pricing_option_id`` from the response, percentages sum to exactly 100 (compensate for ``100/3`` non-termination on the final allocation rather than 99.99-rounded), and the proposal gets a fresh ``proposal_id`` per call. Only ``buying_mode='brief'`` triggers the proposal — wholesale and refine opt out per spec. Empty product list short-circuits to no proposal (the spec model requires ``min_length=1`` on allocations). Future allocation strategies (weighted, refine-loaded drafts) plug into the same ``_build_v1_brief_proposal`` seam without touching the manager. ## Verified * Storyboard ``media_buy_seller/proposal_finalize/get_products_brief``: PASS — every assertion green including ``field_present @ /proposals[0]/proposal_id``. * 10 new unit tests in ``test_proposal_manager_brief.py`` pin builder invariants (sum=100 across 1/2/3-product splits, unique proposal_id per call, RootModel unwrapping, optional pricing_option_id). * Full unit suite: 4295 passed. Co-authored-by: Claude Opus 4.7 (1M context) * fix(#338): use url_for and relabel button to "Advertisers" on webhooks page (#367) The "Manage Webhooks" button in templates/webhooks.html had two bugs: 1. The link used `{{ script_name }}` but the route that renders the page (`operations.py:710`) does not pass `script_name`. In embedded iframe context that template variable is undefined, so the link resolved to an unprefixed path and 404'd. 2. The destination is the per-tenant principals list, not a webhook management page — webhooks are per-principal. The label "Manage Webhooks" was misleading. Use `url_for()` so script-root resolution is automatic, and relabel the button to "Advertisers" with a users icon to match its actual target. The user reaches webhook management by clicking into an advertiser. Co-authored-by: Claude Opus 4.7 (1M context) * fix(#362): hide AXE Set Key controls on embedded tenants (#368) `AdapterConfig` is platform-managed on embedded tenants (only `gam_sandbox_advertiser_id` is in `PUBLISHER_WRITABLE_FIELDS`), and the `/settings/adapter` POST is intentionally not opted into `allow_embedded_writes`. The Targeting Criteria Browser still rendered the three "Set Include/Exclude/Macro Key" buttons + dropdowns + manual entry, so clicking them returned 403 and the toast read "Failed to save include key configuration." Hide the editor block on embedded tenants and replace it with a "Managed by platform" notice that points users at the upstream Tenant Management API. The targeting-key browsing/preview UI below the card stays visible — operators may want to look up keys when authoring products. Null-guard `populateAxeDropdowns` and `updateAxeKeyStatus` against the now-absent select/status elements so the page JS doesn't throw when the card body renders the alert variant. Co-authored-by: Claude Opus 4.7 (1M context) * fix(embedded): opt remaining read-only POST probes into embedded-write gate (#372) Follow-up sweep to #365. The embedded-write gate keys off HTTP verb, so every POST under `require_tenant_access` without `allow_embedded_writes=True` returns 403 `embedded_writes_not_permitted` on embedded tenants — even when the handler is a read-only probe that never touches the DB. #365 fixed the two AI/Logfire probes called out in Laure's bug report. Sweep covers the rest of the same class: - `tenants.test_slack` — sends a test webhook, never writes - `adapters.test_freewheel_connection` — validates OAuth client_credentials against FreeWheel; reads AdapterConfig fallback secret, never writes - `adapters.test_triton_connection` — validates JWT login against Triton; reads AdapterConfig fallback secret, never writes - `adapters.test_broadstreet_connection` — validates API key against Broadstreet network endpoint, never writes - `settings.test_domain_access` — looks up tenant access for an email and flashes the result, never writes Each handler was inspected to confirm zero DB writes before adding the opt-in. The model-layer guard in `embedded_tenant_guard.py` remains in force as defense-in-depth — any accidental Tenant/AdapterConfig write from these paths would still be caught at commit time. Longer-term: the verb-based gate misclassifying probes is a design smell. A `probe=True` decorator argument that the gate honors would be more durable than per-route opt-in. Filing as a follow-up — out of scope for this sweep. Co-authored-by: Claude Opus 4.7 (1M context) * fix(#365): allow AI/Logfire test-connection probes on embedded tenants (#369) The "Test Connection" action for AI providers and Logfire on the Integrations tab failed with "Failed: embedded_writes_not_permitted" on embedded tenants, because the verb-based embedded-write gate classifies any POST under `require_tenant_access` as a mutation. `test_ai_connection` and `test_logfire_connection` are read-only probes — they validate credentials against the upstream provider and never write tenant state. Opt them into `allow_embedded_writes=True`; the model-layer guard in `embedded_tenant_guard.py` remains in force as defense-in-depth. Also fix the test-result handlers in `templates/tenant_settings.html` to render `data.message || data.error` instead of `data.error` alone. Gate envelopes (and any future role-gate rejections) return both a stable code in `error` and a human-readable string in `message`; the old code surfaced the stable code, which read as gibberish to users. Sweep finding (left as follow-up): the same verb-based-gate trap exists on `tenants.test_slack`, `adapters.test_freewheel_connection`, `adapters.test_triton_connection`, `adapters.test_broadstreet_connection`, and `settings.test_domain_access`. Each is a read-only probe that could opt in with the same flag. Co-authored-by: Claude Opus 4.7 (1M context) * fix(#363): unblock Policies & Workflows writes on embedded tenants (#370) On embedded tenants every field in the Policies & Workflows tab (Brand Manifest Policy, Naming Conventions, Approval Workflows, Measurement Providers, Product Ranking, Auto-approval thresholds) silently reverted on save. Two compounding bugs: 1. Route blocked at the boundary. `/settings/business-rules` POST used `@require_tenant_access(role=("admin",))` without `allow_embedded_writes=True`, so the verb-based gate returned 403 `embedded_writes_not_permitted` before the handler ran. 2. JS treated the 403 HTML error page as success. `saveBusinessRules` in `tenant_settings.js` content-type-branched: any HTML response with no `.flash-messages` container fell through to `window.location.reload()`. Flask's default 403 error page has no flash messages → reload-as-success → user sees their fields revert with no error. Affected every 4xx/5xx on that route. Fix three layers: - Add `allow_embedded_writes=True` to `update_business_rules`. Per Sprint 5 design (`docs/design/embedded-mode-sprint-5.md` §"Pattern: shared business logic with the UI"), business rules are publisher-managed and edited via the proxied admin UI; the management API exposes the same writes. - Add the per-column business-rules surface to `PUBLISHER_WRITABLE_FIELDS[Tenant]` (13 fields covering naming templates, approval mode, creative review settings, AI policy, advertising policy, brand manifest policy, product ranking prompt, human review flag). Platform-identity columns (name, billing_plan, is_active, subdomain, external_*) stay locked. - Add `gam_manual_approval_required` / `mock_manual_approval_required` to `PUBLISHER_WRITABLE_FIELDS[AdapterConfig]` — these mirror `tenant.human_review_required` onto adapter config and are written by the same handler. - Restructure `saveBusinessRules` to check `response.ok` BEFORE content-type branching. Non-2xx responses now surface the error (parsing flash messages from HTML when available, falling back to the status code) instead of silently reloading. Added four guard tests in `test_managed_tenant_api.py::TestWriteGuard`: business-rules columns write, manual-approval adapter columns write, platform-identity columns stay blocked, and an end-to-end check via the mock adapter sync field. Co-authored-by: Claude Opus 4.7 (1M context) * fix(#364): explain empty Allowed Principals dropdown on embedded tenants (#371) * fix(#364): explain empty Allowed Principals dropdown on embedded tenants On embedded tenants the "Allowed Principals (Advertisers)" multi-select on Create Product rendered only "No principals configured" — a dead end. The Buyer Agents section in Settings hides the "Add Buyer Agent" button on embedded tenants (this is correct: Principal provisioning is platform-managed via the Tenant Management API), so publishers had no path to populate the dropdown. Two compounding things made the UI misleading: 1. The empty-state placeholder didn't distinguish embedded from open instances. Publishers saw the same "No principals configured" text that suggests they can fix it themselves. 2. Comments in `tenant_settings.html` and `buyer_advertiser_routing.py` claimed Principals are "auto-created on first request by the embedded-mode auth bypass, which reads X-Identity-Buyer-Principal-Id". That mechanism does not exist — grep `src/` for the header returns zero matches. Anyone tracing the empty dropdown ran into a dead-end comment that confidently pointed at a code path that isn't there. Fix: - In `add_product.html` and `add_product_gam.html`, replace the disabled `` with a context-aware empty state. Embedded tenants get an explainer that Principals are provisioned by the platform via the Tenant Management API; open instances get a pointer to Settings → Buyer Agents. - Rewrite the misleading comment block in `tenant_settings.html` around the advertisers section and the user-visible "auto-created from request headers" line — state plainly that embedded Principal provisioning goes through the platform API. - Fix the matching dead-pointer comment in `buyer_advertiser_routing.py` near the access-grant logic. Option B (platform-managed) per `docs/design/embedded-mode-sprint-5.md` contract. Option A (re-enable UI authoring) would have been a write-guard expansion that contradicts the existing `{% if not embedded_view %}` gate on "Add Buyer Agent" — and the model guard doesn't list Principal at all, so it's the UI gate alone holding the line. Not the right place to flip the contract. Terminology cleanup ("Allowed Principals" vs "Buyer Agents" vs "Advertisers") is deliberately left for a follow-up issue — that's a larger UX project than a bug fix. Co-Authored-By: Claude Opus 4.7 (1M context) * test(#364): update assertions to match corrected embedded-mode copy The original test asserted on the misleading "auto-created from request headers" copy that #364 removed (because the auto-create mechanism does not exist — see #364 PR description). Update the assertions to match the new, accurate copy that explains platform-API provisioning. Also refresh the class docstring to drop the same misleading claim about header-based auto-creation. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * fix(#374): coerce update_media_buy status to wire enum at response boundary (#375) The manual-approval path on ``update_media_buy`` read ``MediaBuy.status`` straight from the DB column and surfaced it on the ``UpdateMediaBuySuccess`` response. The persisted column accepts a broader set than the AdCP wire enum — ``draft`` (model default) and ``pending_approval`` (manual-approval create path) are both valid in storage but not in ``MediaBuyStatus``. fastmcp's request-/response-side Pydantic validation rejected the response with ``INVALID_REQUEST[status]: Input should be 'pending_creatives', 'pending_start', 'active', 'paused', 'completed', 'rejected' or 'canceled'``, which surfaced as an E2E failure on ``test_complete_campaign_lifecycle_with_webhooks`` (#374) and on every PR's CI run after the manual-approval status-emission was added in #353. Fix: - Add ``_to_wire_status`` in ``media_buy_list.py``. Takes any input (``str | MediaBuyStatus | None``) and returns either a wire-valid string from the seven-member enum, or ``None`` for values the wire rejects. Case-insensitive on string input. - Apply it at the manual-approval response site in ``update_media_buy.py``. ``current_status`` is now guaranteed wire-valid (or ``None``) before reaching ``UpdateMediaBuySuccess``. The other three response-status sites (cancel, pause/resume, final ``_compute_status`` path) already emit values from the wire enum by construction. Tests: - ``TestToWireStatus`` (6 cases): wire-valid passthrough, case insensitivity, persisted-only rejection (``draft``, ``pending_approval``), ``None``/empty/non-string handling. - ``test_manual_approval_response_coerces_non_wire_db_status_to_none``: end-to-end behavior — a persisted ``pending_approval`` does not leak to the response. - ``test_manual_approval_response_preserves_wire_valid_db_status``: wire-valid statuses still pass through unchanged. Verified locally: - Failing E2E ``test_complete_campaign_lifecycle_with_webhooks`` passes against the full Docker stack. - ``tox -e unit`` (4314 tests) and ``tox -e integration`` (1030 update_media_buy-adjacent tests) both green. Fixes #374. Co-authored-by: Claude Opus 4.7 (1M context) * fix(transport): env-gated stateless MCP mode for multi-replica deploys (#376) The MCP Python SDK's ``StreamableHTTPSessionManager`` stores ``_server_instances`` as a process-local dict. Multi-replica deployments without sticky LB routing on ``Mcp-Session-Id`` see ``tools/list`` and ``tools/call`` randomly 404 with "Session not found" when a request lands on a replica that didn't handle ``initialize``. A 10-attempt probe against the Wonderstruck deployment confirmed the dice roll: ``initialize`` always 200 (creates session on whichever replica answers); ``tools/list`` and ``tools/call`` with the same session ID succeeded only when they happened to land on the same replica (~50/50 each). Yesterday's compliance baseline (170 steps, 12 tools discovered) caught the deployment during a single-replica window; today the same baseline rerun returned 0 tools because ``discoverAgentProfile`` calls ``initialize`` → ``tools/list`` in tight succession, and ``tools/list`` lost the affinity coin flip half the time. ``serve()`` has supported ``stateless_http: bool`` since adcp 5.0 (``adcp/server/serve.py:2053`` sets ``mcp.settings.stateless_http`` from the kwarg unconditionally, so ``FASTMCP_STATELESS_HTTP`` env alone has no effect — the kwarg overrides FastMCP's reader). This plumbs the kwarg through ``_serve_kwargs`` gated on ``ADCP_STATELESS_HTTP``: * Unset / falsy → stateful (default). Single-replica prod, local dev, in-process tests, and the compliance-runner storyboard sweep keep the session-reuse perf optimization. * ``ADCP_STATELESS_HTTP=true`` → stateless. Each request creates a fresh transport context; multi-replica works without sticky LB. Per the FastMCP deployment doc (https://gofastmcp.com/v2/deployment/http): stateless mode is the recommended pattern for horizontal scaling — cookie-based stickiness is unreliable because most MCP clients use ``fetch()`` and drop ``Set-Cookie``. Header-based stickiness on ``Mcp-Session-Id`` would also work (the AdCP SDK forwards the header cleanly) and would keep session-reuse perf on prod compliance runs; this env var doesn't preclude that — the deployment chooses by setting / unsetting ``ADCP_STATELESS_HTTP``. Tests verify the env var maps to the kwarg correctly across true / false / unset and case variants. Existing ``test_serve_kwargs_middleware_order.py`` extended with the new ``stateless_http``-focused cases. Co-authored-by: Claude Opus 4.7 (1M context) * chore(deps): bump adcp 5.2.0 → 5.3.0 (#379) Picks up: - SellerA2AClient for in-process A2A handler testing (#694) - PgBuyerAgentRegistry.with_caching() factory (#692) - v3 storyboard CI gate that actually asserts (#693) - Sequence[T] widening on response-only list fields (#635) - Composed lifespan preservation when public_url is callable (#680) - ads.txt MANAGERDOMAIN fallback discovery (#704/#705) - validate_adagents_structure helper (#708) - webhook_signing.supported boot validator (#695) Audited the codebase for workarounds the bump should now obsolete. One real candidate: AgentCardPublicUrlMiddleware (190 LOC) — #680 means transport="both" + callable public_url now works. Replacing it with a public_url=resolver callable will land separately. Two workarounds the bump can't eliminate, filed upstream: - serve(lifespan=) hook missing — adcp-client-python#709 - cross-class entity overrides still need type:ignore[assignment] — adcp-client-python#710 Co-authored-by: Claude Opus 4.7 (1M context) * fix(#377): four-state aao_status_kind + permissive unbound resolution (#380) Wonderstruck-class publishers ship bare ``authorized_agents`` entries (``{url, authorized_for}`` only, no ``authorization_type``) alongside a top-level ``properties[]`` block. The AdCP SDK's strict resolver returns ``[]`` for these, so: - Publisher Partnerships chip rendered "Pending 0/0" — misleading operators into thinking the publisher hadn't authorized us yet. - Products UI used to bind anyway via a homegrown heuristic, then a prior pass tightened it to match the SDK — regressing Wonderstruck. This change introduces a four-state ``PublisherPartnerStatusKind`` (``authorized`` | ``unbound`` | ``pending`` | ``no_properties`` | ``unreachable``) and an explicit permissive resolution path: - ``aao_lookup_service.get_publisher_partner_status`` uses the SDK strictly first; falls back to ``unbound`` only when our entry is bare and the file has top-level properties. Surfaces a conformance hint so operators can nudge the publisher to add a typed binding. - ``property_discovery_service._extract_properties`` mirrors the same classification and, on the unbound branch, gates top-level properties to those carrying a ``type=domain`` identifier matching the publisher_domain — closes the attack vector where a publisher could bare-list us + claim arbitrary app/podcast/DOOH bundle IDs. - Shared shape helpers in ``src/services/_adagents_shapes.py`` (``is_bare_entry``, ``find_agent_entry``, ``top_level_properties``) cover the full schema selector set including ``signal_ids`` / ``signal_tags``. - New nullable ``aao_status_kind`` column on ``publisher_partners`` — legacy NULL rows fall back to the existing derivation in ``_partner_to_dict`` so the rollout is safe under rolling deploys. - JS chip styles for ``unbound`` ("Authorized (non-conformant file)") and ``no_properties`` ("No properties listed"). Upstream issues filed in parallel for ecosystem alignment: - adcontextprotocol/adcp#4478 — typed ``authorization_type: "all_top_level_properties"`` variant so publishers have a spec-conformant shape; once shipped we can deprecate the local permissive shim. - adcontextprotocol/adcp-client-python#711 — permissive resolver API. - adcontextprotocol/adcp-client#1721 — TS SDK per-agent resolution + permissive mode for cross-SDK consistency. Fixes #377 Co-authored-by: Claude Opus 4.7 (1M context) * fix(compliance): residual fixes from 7.1.0 probe — INVALID_REQUEST, INVALID_STATE, WWW-Authenticate (#383) * fix(compliance): residual fixes from 7.1.0 probe — INVALID_REQUEST, INVALID_STATE, WWW-Authenticate Closes three residual storyboard failures observed in the 7.1.0 comply() re-probe against Wonderstruck after #348/#349 fixes deployed: 1. **error_compliance/nonexistent_product** — pre-dispatch validation in ``_create_media_buy_impl`` raised ``ValueError`` (past start_time, reversed dates, etc.) and the outer ``except (ValueError, PermissionError)`` handler emitted ``Error(code="VALIDATION_ERROR")``. ``VALIDATION_ERROR`` is not in the AdCP 3.0 ``STANDARD_ERROR_CODES`` enum, so buyer agents walking the enum for self-correction silently drop the error. Change wire code to spec-canonical ``INVALID_REQUEST``. Storyboard expects ``PRODUCT_NOT_FOUND``, ``PRODUCT_UNAVAILABLE``, or ``INVALID_REQUEST``; sibling ``reversed_dates_error`` accepts ``VALIDATION_ERROR`` or ``INVALID_REQUEST``. ``INVALID_REQUEST`` is the only value in both sets and is the spec-canonical choice. 2. **media_buy_state_machine/pause_canceled_buy** — ``_update_media_buy_impl`` had a terminal-state guard on cancel (re-cancel raises ``AdCPNotCancellableError``) but the pause/resume branch dispatched straight to the adapter. Spec requires rejection with ``/adcp_error/code == "INVALID_STATE"`` for pause-of-canceled. New exception ``AdCPInvalidStateError`` (``error_code="INVALID_STATE"``, recovery ``correctable``, 422) covers the symmetric guard. Fires BEFORE adapter dispatch on both terminal states (``canceled``, ``completed``) for both actions (``paused=True``, ``paused=False``). Idempotency-spec friendly: same payload yields the same wire code on retry regardless of which adapter would have handled the transition. 3. **security_baseline/probe_unauth** — RFC 6750 §3 requires a ``WWW-Authenticate: Bearer`` header on every 401 from a Bearer-protected resource. Upstream ``adcp.server.auth.BearerTokenAuthMiddleware`` on the MCP leg returns 401 without the header for missing/invalid tokens; the A2A leg and ``SigningVerifyMiddleware`` already emit it correctly. New ``WWWAuthenticateMiddleware`` (in ``core/middleware/``) wraps the ASGI ``send`` callable and injects the bare ``Bearer`` challenge on 401 responses missing the header. Case-insensitive presence check so stacking is safe; no-op on 2xx / 3xx / 4xx-other / 5xx so a 403 doesn't confuse buyers about which auth scheme to apply. Registered AFTER ``AdminWSGIMount`` so Google-OAuth-gated admin paths short-circuit before the buyer-protocol challenge sees them. Bundled together because they ship in a single redeploy cycle and the PR-title-check enforces one Conventional Commit prefix per PR; the three fixes are independent at the code level (different files, different behavioural surfaces, different tests). ## Residuals still open (not in this PR) - ``pagination_integrity_list_accounts/first_page`` — ``has_more`` returns false on a 3-seeded list with ``max_results=2``. Pagination logic in ``_apply_pagination`` is correct in isolation; the storyboard's seed→list chain isn't reaching the impl with the expected request shape. Needs separate investigation with end-to-end repro. - ``media_buy_seller/proposal_finalize/get_products_refine`` — refine path on ``get_products`` returns no ``proposals[]``. ``SalesAgentProposalManager.refine_products`` raises ``UNSUPPORTED_FEATURE``. Substantial feature work, separate PR. - ``security_baseline/assert_mechanism`` — likely fixed transitively by the ``WWW-Authenticate`` header; re-probe after deploy will confirm. ## Verification - ``make quality`` — 4292 passed, 14 skipped, 19 xfailed - New targeted tests: 30/30 (15 middleware × scope cases, 6 INVALID_STATE behavioural × class cases, 9 INVALID_REQUEST schema cases) - Existing ``test_max_daily_spend_exceeded`` updated to expect the new wire code per the change description - Structural guards (transport-agnostic-impl, no-toolerror-in-impl, etc.) pass; the new middleware is a salesagent-side ASGI wrapper, not in ``_impl`` scope Co-Authored-By: Claude Opus 4.7 (1M context) * docs(comply): mark WWWAuthenticateMiddleware as workaround for adcp-client-python#712 The upstream defect is in ``adcp/server/auth.py:411`` — ``BearerTokenAuthMiddleware._unauthenticated`` emits a ``JSONResponse`` with ``status_code=401`` but no ``WWW-Authenticate`` header. The sibling ``A2ABearerAuthMiddleware._send_unauthenticated`` in the same file (line 1024) gets it right. Filed at adcontextprotocol/adcp-client-python#712. Documents the deletion plan: when the upstream fix ships and we bump ``adcp``, the middleware's case-insensitive presence check makes it a no-op, so the order is safe — bump → re-probe → remove the middleware and its registration in a follow-up PR. No code change. Comments only. Co-Authored-By: Claude Opus 4.7 (1M context) * review(comply): code-reviewer nits from PR #383 Fixes two factually-wrong claims and adds a regression guard, all flagged by the code-reviewer pass: 1. ``media_buy_create.py:2418`` comment claimed "``VALIDATION_ERROR`` is not in the spec enum and gets dropped by buyer agents walking ``STANDARD_ERROR_CODES``." It IS in the enum (``adcp/types/generated_poc/enums/error_code.py:46``). Replace with the actual justification — the storyboard-intersection argument — and add a forward note about the dead ``PermissionError`` catch path (no code inside this try raises it today; if a future principal- ownership check moves in, split the except so PermissionError maps to ``PERMISSION_DENIED``). 2. ``test_invalid_request_envelope_on_validation_failure.py`` carried the same wrong claim in its module docstring. Rewrite to reflect the actual intersection argument. 3. Add ``test_www_authenticate_runs_after_admin_mount_and_before_signing`` to ``test_serve_kwargs_middleware_order.py`` — pins ``WWWAuthenticateMiddleware`` between ``AdminWSGIMount`` (so admin Google-OAuth 401s don't get a misleading Bearer challenge) and ``SigningVerifyMiddleware`` (so signing-emitted 401s flow through the injector). A future refactor that moves the middleware either direction surfaces here instead of silently breaking RFC 6750 §3 compliance. No behaviour change. Quality: 4293 passed. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * feat(proposal): implement v1 refine_products + flip capabilities.refine=True (#385) * feat(proposal): implement v1 refine_products + flip capabilities.refine=True Closes the ``media_buy_seller/proposal_finalize/get_products_refine`` storyboard failure observed in the 7.1.0 comply() probe against Wonderstruck. ## What changed * ``SalesAgentProposalManager.refine_products`` now has a real implementation instead of raising ``UNSUPPORTED_FEATURE``. Delegates to ``_get_products_impl`` for products, decorates the response with a fresh ``Proposal`` via the existing ``_build_v1_brief_proposal`` even-split allocator, and populates ``refinement_applied[]`` from the buyer's ``refine[]`` asks. * ``ProposalCapabilities.refine`` flipped from ``False`` to ``True``. The framework router now dispatches ``buying_mode='refine'`` requests to ``refine_products`` instead of falling through to ``get_products`` (which never populated ``refinement_applied``). * New ``_build_v1_refinement_applied`` helper: dispatches each refine entry's ``scope`` (``request`` / ``product`` / ``proposal``) to the matching ``RefinementApplied{1,2,3}`` variant. Status is uniformly ``applied`` with a v1-acknowledgement note explaining the response carries a fresh-but-unchanged-strategy proposal. Forward-compat: unknown scopes and malformed entries (e.g. product-scope without ``product_id``) are silently dropped rather than crashing the response. ## v1 vs v2 semantics v1 is explicitly acknowledgement-shaped. Storyboard validation is ``field_present @ /proposals`` and ``response_schema`` — both satisfied without semantic refinement. The note in each ``refinement_applied`` entry signals the v1 limitation so buyers see honest behaviour: the proposal is fresh but the allocation hasn't been re-strategized from the ask content. v2 will swap the even-split for an allocation that actually honors asks (drop product / shift budget / shape targeting) once ``ProposalStore`` is wired to load the prior draft by ``proposal_id``. The wire contract stays stable across v1/v2. ## Tests Mirrors the pattern in ``test_proposal_manager_brief.py``: * ``TestSalesAgentProposalManagerCapabilities`` pins ``capabilities.refine=True`` and the unchanged sales_specialism. * ``TestBuildV1RefinementApplied`` covers every scope variant, multi-entry ordering preservation, malformed-entry drop, unknown- scope drop, and RootModel-wrapped entry unwrap. * ``TestRefinementAppliedNote`` pins the buyer-facing breadcrumb so a future content swap is intentional. 11 new tests; quality green (4273 passed, 14 skipped, 19 xfailed). Co-Authored-By: Claude Opus 4.7 (1M context) * review(refine): cap buyer-supplied echo + drop dead fallback (PR #385 nits) Addresses three review items: **security-reviewer L1 (Should-Fix): bound buyer-supplied refine echo.** ``RefinementApplied2.product_id`` and ``RefinementApplied3.proposal_id`` are typed ``str`` with no length cap in the adcp library, so an adversarial buyer could ship 10MB ids and force us to hold them through Pydantic validation and echo them back. Added two caps in ``core/proposal/manager.py``: * ``_MAX_REFINE_ID_LEN = 256`` — per-id length cap; oversize ids are DROPPED (not truncated — truncation corrupts id semantics for downstream correlation). Real AdCP ids look like ``prop_abc123`` / ``prod_video_outdoor``; 256 chars leaves generous headroom. * ``_MAX_REFINE_ENTRIES = 50`` — array length cap, slice up front so an N-million-entry array can't drive allocation pressure even before the per-entry loop runs. * New ``_is_safe_id`` helper centralizes the per-id check (also catches non-str values, defense-in-depth for callers bypassing Pydantic). **code-reviewer nit 1: drop dead ``or getattr(req, "refine", None)``.** ``_coerce_to_request_model`` returns a ``GetProductsRequest`` Pydantic model that always has the ``refine`` attribute (default ``None``), so the fallback can never fire. Simplified to ``getattr(req_model, "refine", None) or []``. **code-reviewer nit 2: drop over-promised telemetry comment.** The dropped-scope comment claimed "missing telemetry" without actually emitting any. Replaced with the honest framing — forward-compat for spec additions, known v1 limitation tracked for v2 telemetry — and matched the docstring's "silently dropped" claim. ## New tests (6) ``TestRefineEchoLengthCaps`` in ``test_proposal_manager_refine.py``: * ``test_oversized_product_id_dropped`` — 257-char id (cap+1) dropped * ``test_oversized_proposal_id_dropped`` — same cap on proposal scope * ``test_empty_product_id_dropped`` — zero-length symmetry * ``test_max_length_id_accepted`` — boundary at exactly 256 chars * ``test_excess_array_length_truncated`` — 100-entry array → 50 echoed * ``test_non_string_product_id_dropped`` — defense-in-depth for non-str Quality green: 4279 passed, 14 skipped, 19 xfailed. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * fix(#336): enable Add Publisher on embedded view + fix(scheduler): DetachedInstanceError (#392) * fix(#336): enable Add Publisher on embedded view Embedded tenants couldn't add publisher partners — the UI hid the controls and the API 403'd direct POSTs. Without publishers there are no AuthorizedProperty rows, which empties the property selector and blocks Create Product on embedded tenants. PublisherPartner is not in the model-layer guard's locked set (embedded_tenant_guard locks only Tenant core columns, AdapterConfig, and signing creds), so publisher-partner mutations are publisher-managed by definition. Apply the same opt-in pattern as PR #340: pass allow_embedded_writes=True on the four mutation routes (add / delete / sync / refresh) and drop the redundant _reject_if_embedded helper. Template: unhide the +Add Publisher / Refresh-all buttons and the modal; update the "Platform-managed" banner to scope only to the agent URL (which IS platform-managed) rather than the partner roster. Tests: flip TestPublisherPartnershipsReadonlyOnEmbedded → TestPublisherPartnershipsEditableOnEmbedded; add positive coverage test_managed_tenant_can_add_publisher_partner under TestEmbeddedViewAllowsPublisherManagedWrites. Move the api-mode JSON envelope assertion and gate-polarity check to the OIDC enable route (still platform-managed, api_mode=True). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(scheduler): DetachedInstanceError on multi-buy delivery batch Production trace (2026-05-08): the daily delivery-report batch succeeded for the first media buy with a reporting_webhook, then raised DetachedInstanceError on media_buy.tenant for every subsequent buy in the same batch. Root cause: each iteration calls _get_media_buy_delivery_impl, which opens its own ``with get_db_session()``. Because get_db_session uses a scoped_session, the inner ``scoped.remove()`` closes the SAME session the outer batch loop is using, detaching every MediaBuy row loaded by MediaBuyRepository.get_all_by_statuses. The first iteration happens to complete before the inner remove() fires; iteration 2+ hits a detached instance on the next relationship access. Fix: eager-load MediaBuy.tenant via joinedload in the scheduler's fetch. The tenant value is materialized into the instance state and survives detach, so media_buy.tenant returns the cached Tenant without lazy-loading through a closed session. Added eager_load_tenant=True parameter on MediaBuyRepository.get_all_by_statuses (default False so the media_buy_status_scheduler caller — which doesn't access tenant — doesn't pay the JOIN cost). Regression test reproduces the production trace exactly: two media buys with reporting_webhook configured; without the fix, only one webhook is sent and the second iteration raises DetachedInstanceError. Co-Authored-By: Claude Opus 4.7 (1M context) * test(#335): regression tests for product-save validation paths The user reported "Internal Server Error" when saving a product without selecting a Property, expecting a validation flash instead. PR #340 closed #335 by fixing the embedded-write 403 that the storefront proxy was misreporting; these tests document and lock in the post-fix contract so the bug can't return undetected. Six new scenarios under tests/admin/test_product_creation_integration.py: - test_add_product_without_property_returns_validation_error_not_500: POST with name + pricing, no property selection. Asserts 200 + the "Please select at least one property tag" flash text + no leaked Product row. - test_add_product_malformed_inputs_never_return_500 (parametrized): only_name, name_and_pricing_only, invalid_pricing_rate, invalid_property_mode, property_ids_mode_no_selection. Every case must surface a validation response, never a raw 500. Both tests share a new ``authenticated_admin`` fixture that uses UserFactory (CLAUDE.md Pattern #8) — re-loads the tenant inside the factory's session to avoid DetachedInstanceError from the test_tenant fixture's closed session. All 17 product-create + delivery-webhook integration tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) * review: tighten scheduler regression + bound display_name + pin guard layer Addresses code-review and security-review feedback on the three preceding commits before merge. Scheduler test (code-review C2): tie the regression assertion to the actual failure mode via caplog. Without this, the test depended on ``await_count`` as a second-order signal; the new check catches ``DetachedInstanceError`` directly so a future refactor that changes the send count for unrelated reasons can't silently mask the bug. Guard layer consistency (code-review I4): new test ``test_publisher_partner_not_locked_at_model_layer`` exercises a PublisherPartner write on an embedded tenant without the ``management_api_caller`` bypass. If a future change adds the model to ``embedded_tenant_guard``'s locked set, this test fails with a pointer to remove ``allow_embedded_writes=True`` from the four publisher_partners routes. Companion note added in embedded_tenant_guard.py near the existing locked-table listeners. Display-name length cap (security nit 1): add a 255-char gate on ``display_name`` in ``add_publisher_partner`` so a hostile or buggy embedded caller can't persist multi-MB strings that later render into admin UI / API responses. Filed #391 for the systemic scoped_session/nested-get_db_session() trap surfaced during scheduler triage (code-review C1). The scheduler fix in ac3a3a7b is the right immediate patch; the underlying trap needs its own redesign and is tracked separately. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * fix(#357): use url_for() for tenant admin links so embedded-mode mounts work (#393) * fix(#357): use url_for() for tenant admin links so embedded-mode mounts work The Setup Checklist (and other admin surfaces) emitted bare /tenant//... hrefs. Under the Storefront's /storefront/salesagent mount, those resolved against the storefront host instead of the proxied salesagent path and returned 404 from the parent app. Service layer (setup_checklist_service.py, dashboard_service.py, business_activity_service.py) now builds URLs via flask.url_for() so the emitted hrefs include SCRIPT_NAME automatically. Templates that previously hand-prepended {{ request.script_root }} are migrated to url_for() in the same pass for consistency with CLAUDE.md Pattern #6. SetupChecklistService runs from two transports: Flask (admin UI) and Starlette via adcp.server.serve() (MCP/A2A). validate_setup_complete() fires inside _create_media_buy_impl on the non-Flask path, where url_for() would raise RuntimeError. _build_url() catches that and returns None; validate_setup_complete only reads task['name'], so the gate behavior is unchanged. Added tests/unit/test_setup_checklist_no_flask_context.py to pin this contract. JS string interpolations (`${tenantId}/...`) are intentionally untouched — url_for can't help with runtime IDs, and CLAUDE.md Pattern #6 already endorses `scriptRoot + path` for that case. One pre-existing FIXME left: tenant_settings.html /settings/raw form posts to a route that has no handler; touched only with a clarifying comment, not a behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(#357): also tolerate BuildError when url_for runs in a foreign Flask app The tenant_management_api blueprint runs as its own Flask app (tests/integration/test_managed_tenant_api.py:54 — a bare Flask() with only that blueprint registered). When SetupChecklistService is invoked from that app (via tenant_status_service → /status), url_for() for admin-UI endpoints raises werkzeug.routing.BuildError, not RuntimeError, because the endpoint isn't registered there. _build_url now catches both. The management API never reads action_url (it surfaces configure_path from a static map in tenant_status_service._CONFIGURE_PATHS), so None is correct. Adds TestServiceWorksInForeignFlaskApp to pin the contract — Flask context exists, but the endpoint can't be built. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * chore(deps): bump adcp 5.3.0 → 5.4.0; drop three local workarounds (#394) * chore(deps): bump adcp 5.3.0 → 5.4.0; drop three local workarounds 5.4.0 ships every upstream issue I filed yesterday plus a bonus. ## Drops with the bump | Workaround | Upstream fix | |---|---| | ``core/middleware/www_authenticate.py`` (74 LOC) — injected ``WWW-Authenticate: Bearer`` on 401 because the MCP-leg ``BearerTokenAuthMiddleware._unauthenticated`` returned ``JSONResponse(status_code=401)`` without the header | adcp-client-python#712 → #715: ``_unauthenticated`` now emits the header on both the MCP dispatch path AND the ASGI ``_send_unauthenticated`` path, matching what the A2A leg already did | | ``core/idempotency._ReplayMarkingStore`` (~120 LOC + private-symbol coupling to ``_WRAPPED_FUNCTIONS`` / ``_clone_response`` / ``_resolve_call_args`` / ``_to_dict`` / ``CachedResponse``) — reimplemented the full ``IdempotencyStore.wrap`` body inline to inject ``replayed: true`` on cache hits per AdCP L1/security rule 4 | adcp-client-python#714 → #717: ``IdempotencyStore.wrap`` now does ``response["replayed"] = True`` on the cache-hit branch natively | | ``mcp_header_name="x-adcp-auth"`` + ``mcp_bearer_prefix_required=False`` — the MCP leg accepted ONLY the custom legacy header, EXCLUDING the spec-canonical ``Authorization: Bearer``. Caused ``security_baseline/probe_api_key`` storyboard failures | adcp-client-python#720 → #721: ``Authorization: Bearer`` is always accepted; ``mcp_legacy_header_aliases=[...]`` is a purely additive opt-in for adopters with deployed legacy clients | Net diff: -533 LOC including the obsolete test files. ## Auth shape after bump Old (broken for spec-compliant clients): ```python BearerTokenAuth( validate_token=_validate_token, mcp_header_name="x-adcp-auth", mcp_bearer_prefix_required=False, ) ``` New (spec compliance + zero break for legacy clients): ```python BearerTokenAuth( validate_token=_validate_token, mcp_legacy_header_aliases=["x-adcp-auth"], ) ``` ``Authorization: Bearer `` is now accepted on both legs by default (the spec carrier per RFC 6750). The ``x-adcp-auth`` legacy header keeps working unchanged for any early-adopter MCP client still on it. Migration is a one-way drift with no flag day. ## Files removed - ``core/middleware/www_authenticate.py`` - ``core/tests/test_idempotency_replay_marking.py`` - ``tests/unit/test_www_authenticate_middleware.py`` - The ``WWWAuthenticateMiddleware``-ordering test in ``tests/unit/test_serve_kwargs_middleware_order.py`` ## Verification - ``make quality``: 4294 passed, 14 skipped, 19 xfailed - Existing ``_ReplayMarkingStore`` callers in ``get_idempotency_store()`` swapped to plain ``IdempotencyStore`` — same constructor signature, upstream provides the injection - ``test_serve_kwargs_middleware_order.py`` updated to drop the ``WWWAuthenticateMiddleware``-position pin (middleware no longer exists) - After deploy, the compliance probe's ``security_baseline/probe_api_key`` and ``assert_mechanism`` storyboard steps should flip to pass — closes the auth-header gap we filed as bokelley/salesagent#386 (which can now be closed as "fixed upstream") ## Closes (when deployed) - bokelley/salesagent#386 — multi-header auth (now native via #720) - The remaining compliance-probe residual on ``security_baseline`` (3 → 1 failure; only ``proposal_finalize/create_media_buy`` left, tracked separately as #387) ## Doesn't pick up - 5.4.0's ``LazyPlatformRouter.proposal_stores=`` / ``proposal_store_factory=`` (#722/#724) — this is the wiring point for bokelley/salesagent#387. Separate PR. Co-Authored-By: Claude Opus 4.7 (1M context) * review(deps): switch test fixtures to new BearerTokenAuth shape (PR #394 nit) Code-reviewer flagged that two test files still constructed ``BearerTokenAuth`` with the legacy ``mcp_header_name`` / ``mcp_bearer_prefix_required`` kwargs even though production swapped to ``mcp_legacy_header_aliases=[...]`` in this PR's main commit. The tests passed against 5.4.0 (back-compat shim works) but emitted ``DeprecationWarning`` and stopped mirroring the production config — production now ACCEPTS ``Authorization: Bearer`` on the MCP leg alongside ``x-adcp-auth``, but these test fixtures still wired the old exclusive-header semantics. ## Changes * ``tests/unit/test_per_leg_bearer_auth.py``: ``_production_auth`` and ``_build_mcp_app`` updated to the new shape. The inner ``BearerTokenAuthMiddleware`` construction now passes ``legacy_header_aliases=auth.resolved_mcp_legacy_aliases()`` and ``legacy_aliases_bearer_prefix_required=auth.legacy_aliases_bearer_prefix_required`` in place of the deprecated ``header_name`` / ``bearer_prefix_required`` pair. Mirrors what adcp.server.serve._wrap_mcp_with_auth does natively against 5.4.0. * ``tests/unit/test_agent_card_auth_scheme.py``: ``_production_auth`` same swap; module-level docstring updated to reflect that both legs now default to ``Authorization`` and the MCP leg additively accepts ``x-adcp-auth`` for legacy adopters (not as an exclusive override). ## Verification * 10/10 targeted tests pass * ``make quality`` — 4294 passed, 14 skipped, 19 xfailed; warning count dropped from 117 to 105 (the deprecation warnings are gone) No behavior change beyond what PR #394's main commit ships. Tests now exercise the same wire shape production uses. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * chore(logs): strip debug instrumentation + collapse audit fan-out (#395) Production log volume on Fly was 70%+ noise. Three categories of offender, all in our code (not the adcp SDK): src/core/context_manager.py - Delete leftover ``console.print`` debug blocks: 🔍 PRE-COMMIT WEBHOOK DEBUG (16 lines per workflow step update), 🔍 POST-COMMIT WEBHOOK DEBUG (5 lines), and the 🚀 WEBHOOK / ⚠️ WEBHOOK SKIPPED chatter. These read as leftover instrumentation from a past debugging session and fire on every workflow step status change. - Convert remaining ``console.print`` calls to ``logger.debug`` (lifecycle events: context/step creation, object linking, webhook dispatch) or ``logger.warning`` / ``logger.exception`` (errors). - Drop the unused ``rich.console.Console`` import and module-level ``console = Console()`` singleton. src/core/helpers/adapter_helpers.py - Demote ``[ADAPTER_SELECT]`` / ``[ADAPTER_CONFIG]`` from ``logger.info`` to ``logger.debug`` (10 call sites). These are tracing-grade fields, not operational signals — they should be off in production unless someone is actively debugging adapter selection. src/core/audit_logger.py - Collapse the per-detail audit fan-out: instead of one ``logger.info`` per ``details`` dict key (N+1 lines per audit event), emit a single ``" |
"`` line. Full details still persist to ``AuditLog.details`` for structured queries — the per-line fan-out was legible in local tail but flooded production stdout. The ``adcp.audit`` logger name is shared with the SDK's ``LoggingAuditSink`` but the noisy emissions come from our own ``audit_logger`` in ``src/core/audit_logger.py``, not the SDK — so nothing to file upstream. Co-authored-by: Claude Opus 4.7 (1M context) * chore(logs): drop /mcp+/health access spam, rate-limit geo warning, fix trafficker_id log bug (#397) Three followups from the audit pass on production fly logs. src/core/logging_config.py - Add ``UvicornAccessNoiseFilter`` and attach it to ``uvicorn.access`` in both production (JSON) and development (standard) modes. The filter drops 2xx GET/POST/HEAD/OPTIONS access lines on /mcp[/] and /health — the two endpoints hit constantly by storefront MCP pollers and Fly's TCP+HTTP health checks. 4xx/5xx still surface so auth failures and server errors aren't buried. Other paths (admin UI, /a2a, /.well-known, /mcp-debug, etc.) are unaffected. Behavioral contract pinned by 18 parametrized tests in tests/unit/test_uvicorn_access_filter.py. src/adapters/gam/managers/targeting.py - Rate-limit the "Could not load geo mappings file" + "Using empty geo mappings" warnings to once per process lifetime via a module-level flag. Each ``GAMTargetingManager`` instance fires on every adapter selection, so the same warning flooded the log on every GAM-tenant request. The underlying file-not-found is still tracked in #396 (it means GAM geo targeting silently produces empty results in prod and needs a packaging-side fix). src/adapters/google_ad_manager.py - Fix the "Could not auto-detect trafficker_id: User instance has no attribute 'get'" warning. The googleads SOAP client returns a zeep complex object — it supports __getitem__ and attribute access but NOT ``.get()``. The old code called ``current_user.get('name', 'Unknown')`` inside the success-log f-string, which raised AttributeError AFTER ``self.trafficker_id`` was already assigned. The ID was being detected correctly all along; only the success log was failing and producing a misleading warning on every request. Switched to ``getattr`` for the optional ``name`` field. Filed #396 to track the underlying production OOM kill on the iad machine and the missing ``gam_geo_mappings.json`` packaging issue — both infrastructure-level and out of scope here. Co-authored-by: Claude Opus 4.7 (1M context) * feat(proposal): wire Postgres-backed ProposalStore for create_media_buy(proposal_id=…) (#390) * feat(proposal): wire Postgres-backed ProposalStore for create_media_buy(proposal_id=…) Closes the proposal-lookup gap that made ``proposal_finalize/create_media_buy`` fail with ``INVALID_REQUEST: Invalid budget: 0.0``. Without a wired :class:`ProposalStore`, the framework's ``proposal_dispatch`` had no backing for the buyer's ``proposal_id`` and ``create_media_buy`` landed in package-derivation with zero packages. Pieces: - ``proposals`` table (migration ``r0s1t2u3v4w5``) — mirrors the v1.5 ``ProposalRecord`` dataclass with multi-tenant scoping and a partial unique on ``(account_id, media_buy_id) WHERE media_buy_id IS NOT NULL`` for reverse-index lookups - :class:`SalesAgentProposalStore` — implements every :class:`adcp.decisioning.proposal_store.ProposalStore` Protocol method (put_draft / get / commit / try_reserve_consumption / finalize_consumption / release_consumption / mark_consumed / discard / get_by_media_buy_id) against the new table. Atomic CAS via ``SELECT … FOR UPDATE`` serializes parallel callers. Cross-tenant probes collapse to ``None`` / ``PROPOSAL_NOT_FOUND`` per the Protocol's principal-enumeration defense. - :class:`_LazyPlatformRouterWithStore` — thin subclass that adds the ``proposal_store_for_tenant`` accessor the framework's ``proposal_dispatch`` duck-types. Upstream :class:`LazyPlatformRouter` doesn't expose it (only the eager :class:`PlatformRouter` does, via ``proposal_stores=``). - Wired into ``build_router()`` — single shared store across tenants; isolation runs inside the store on ``expected_account_id``. v1 lifecycle compromise (documented in the store docstring): the storyboard flow goes brief → create_media_buy WITHOUT an intermediate finalize step, but the framework's :meth:`try_reserve_consumption` requires the proposal to be in ``committed`` state. The store auto-commits at ``put_draft`` time with a 7-day ``expires_at`` so the buyer flow unblocks today. The Protocol surface is unchanged — only the internal lifecycle state differs. When the manager declares ``finalize=True`` in v2, swap to canonical ``draft`` + explicit commit. Tests: - ``tests/integration/test_proposal_store.py`` — 15 integration tests against real Postgres covering put_draft auto-commit, payload round-trip, refine overwrite, cross-tenant probe defense (get + try_reserve), two-phase consumption lifecycle, atomic CAS double-reservation rejection, reverse-index lookup with ``expected_account_id`` enforcement, idempotent release/discard - ``tests/unit/test_lazy_router_with_proposal_store.py`` — 3 unit tests pinning the router subclass's accessor wiring - ``tests/unit/test_proposal_store_attributes.py`` — 2 unit tests pinning ``is_durable=True`` (production-mode gate) and the 7-day default hold window Refs #387 Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(proposal): adopt adcp 5.4 — drop workarounds, use upstream surface Upstream shipped both items we filed during #387: - adcp-client-python#722 → 5.4: LazyPlatformRouter accepts ``proposal_stores=`` and ``proposal_store_factory=``. Deletes our ``_LazyPlatformRouterWithStore`` subclass. - adcp-client-python#723 → 5.4: ``ProposalCapabilities.auto_commit_on_put_draft`` shipped option B from the issue. The framework now calls ``store.commit`` immediately after ``put_draft`` for opted-in managers. Deletes our store-side ``state=COMMITTED`` workaround in ``put_draft``. Migration: - Bump ``adcp>=5.4.0``. - ``SalesAgentProposalManager.capabilities`` declares ``auto_commit_on_put_draft=True``; framework owns the DRAFT → COMMITTED promotion via ``auto_commit_ttl_seconds=604800`` (7-day default, matches our prior store-side hold window). - ``core/main.build_router`` calls ``LazyPlatformRouter(...)`` directly with ``proposal_store_factory=lambda _tid: shared_store``. Factory shape over eager dict because the store is a single shared instance — eager dict would force boot-time tenant enumeration and miss tenants registered after boot. - ``SalesAgentProposalStore.put_draft`` writes spec-canonical ``draft`` state with ``expires_at=None``. The ``_committed_hold`` constructor param and the 7-day default are gone — the framework's ``auto_commit_ttl_seconds`` capability owns the TTL. Tests: - Integration: 16 tests rewritten — put_draft asserts DRAFT (not COMMITTED), reservation lifecycle tests use a ``_put_and_commit`` helper that mirrors the framework's auto-commit dispatch, new ``TestCommit`` class covers commit promotion + idempotency + payload-drift rejection, new test pins that put_draft on a COMMITTED record raises ``INTERNAL_ERROR`` per Protocol. - Unit: deleted ``test_lazy_router_with_proposal_store.py`` (no subclass to test); trimmed ``test_proposal_store_attributes.py`` to the durability flag only (the 7-day default belongs to the framework now). Refs #387 Co-Authored-By: Claude Opus 4.7 (1M context) * fix(proposal): address review — split compound account_id, account-scoped locks, fail-closed unscoped methods Review feedback on PR #390: **B1 (blocker): _resolve_tenant_id_for_account returned account_id verbatim.** SalesagentAccountStore.resolve mints ``f"{tenant_id}:{ref}"`` (``ref`` defaults to ``"default"``; storyboard runs use ``"acct_demo"``). The framework passes ``ctx.account.id`` straight into ``put_draft``, so every prod ``put_draft`` would FK-violate on ``proposals.tenant_id``. Fixed: split on ``":"`` and take the prefix. New integration test ``test_put_draft_handles_compound_account_id`` regresses this — uses the real shape the framework emits. **Security MAJOR (×3): try_reserve / finalize / release did SELECT FOR UPDATE then filtered account_id in Python.** Cross-tenant probes acquired the row lock, leaking existence via timing AND providing a DoS primitive against legitimate same-tenant operations. Fixed: ``account_id`` moved into the WHERE clause so cross-tenant probes never acquire the lock. Two new integration tests pin the behavior: - test_finalize_cross_tenant_collapses_to_internal_error - test_release_cross_tenant_is_noop (verifies foreign tenant's release doesn't roll back the owner's CONSUMING reservation) **Security MAJOR (×2): discard() and mark_consumed() Protocol signatures lack ``expected_account_id``.** Any caller obtaining a ``proposal_id`` could destroy / terminate another tenant's proposal. Neither is called by adcp 5.4's ``proposal_dispatch`` today; fixed: both raise ``NotImplementedError`` with an ERROR log. Future framework versions that begin calling them surface loudly before reaching prod. Two new tests pin the fail-closed behavior. **MAJOR M3: _serialize_recipes silently passed dicts through.** Violates "No quiet failures" (CLAUDE.md). Fixed: raises TypeError on non-Pydantic input — caller has to pass typed Recipe instances. **MINOR m3: lazy imports inside every method.** Hoisted ``ProposalRecord``, ``ProposalState``, ``AdcpError`` to module level — no circular import; the salesagent already imports the library at module-load time elsewhere. **NIT n2/n3: stale temporal references.** Dropped "v1 auto-commit workaround landed before #723 and is gone" from the store docstring and "v1 auto-commits at put_draft time" from the Proposal model docstring. Per CLAUDE.md: don't document the prior behavior. **M2 partial coverage: end-to-end account_id shape test added.** ``test_put_draft_handles_compound_account_id`` exercises the realistic ``"tenant_id:default"`` shape the framework actually emits. Full end-to-end (HTTP → proposal_dispatch → store) deferred to compliance probe post-deploy — the unit layer pins every store-side invariant. 24 integration + unit tests pass; ``make quality`` clean (4311 tests). Co-Authored-By: Claude Opus 4.7 (1M context) * review(proposal): expires_at guard + 8 lock-in tests cherry-picked from PR #398 Two additions from @bokelley's parallel #398 work that #390 lacked: **1. Defense-in-depth expires_at check inside try_reserve_consumption.** Security reviewer L1 finding on #398: a buyer holding a COMMITTED proposal past its ``expires_at`` could reserve and finalize indefinitely. The framework's ``proposal_dispatch._hydrate_proposal_context`` checks expiry on the get-side, but ``try_reserve_consumption`` is reachable from dispatch paths that bypass that filter (and from adopter callers that go straight to the store). New three-line guard inside the existing row lock raises ``PROPOSAL_EXPIRED`` with ``recovery="correctable"``. Mirrors upstream :class:`InMemoryProposalStore._evict_expired_locked` but surfaces the event rather than silently deleting so audit trails survive. **2. mark_consumed restored as implemented Protocol method.** Earlier fail-closed pattern was over-cautious for a Protocol method the framework doesn't currently call. Now matches the upstream :class:`InMemoryProposalStore.mark_consumed` shape verbatim, with a WARNING audit log on every call so unexpected invocations are visible. Documented Protocol-signature gap (no ``expected_account_id``) — same upstream constraint that :meth:`discard` has; ``discard`` stays fail-closed because the user's follow-up list didn't include it. **Tests (9 added, 1 replaced):** - test_reserve_past_expires_at_raises_expired (locks in #1) - test_release_silent_no_op_on_missing - test_release_silent_no_op_on_cross_account - test_finalize_idempotent_on_consumed_matching_media_buy - test_finalize_mismatched_media_buy_raises - test_mark_consumed_promotes_to_consumed - test_mark_consumed_idempotent_on_matching - test_mark_consumed_mismatched_raises - test_mark_consumed_unknown_raises_internal_error - Replaced ``test_mark_consumed_raises_not_implemented`` with the four ``TestMarkConsumed`` cases above All cherry-picked from #398's test suite (locked-in shapes already correct in #390's code per @bokelley's close comment). 32 integration + unit tests pass; ``make quality`` clean (4311 tests). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * refactor(types): adopt SchemaVariant for 12 cross-class schema overrides (#400) adcp 5.4.0 #718 ships ``SchemaVariant[T]`` + a mypy plugin that rewrites the annotation to ``Any`` for override-compat purposes, retiring the ``# type: ignore[assignment]`` stamps adopters used to carry on cross-class entity overrides. The 12 sites in src/core/schemas/ all match the cross-class pattern the marker targets: - 4× geo_*_exclude — parent declares Geo{Country,Region,Metro, PostalArea}ExcludeItem; we substitute the inclusion variant - 2× creatives — parent declares CreativeAsset; we substitute our extended Creative - 1× deployments — parent declares Deployments; we substitute SignalDeployment - 1× media_buys — parent declares MediaBuy; we substitute the GetMediaBuysMediaBuy delivery-context view - 1× ext — parent declares ExtensionObject; we use dict - 1× sync_creatives.creatives — parent's CreativeAsset; we use our local CreativeAsset subclass - 1× query_summary — parent's QuerySummary; we use our local - 1× media_buy_deliveries / 1× creatives in delivery.py — delivery-context views mypy.ini gets ``adcp.types.mypy_plugin`` added to the plugins line alongside the existing sqlalchemy + pydantic plugins. Tradeoff (documented upstream): inside the override, mypy sees the field as ``Any``. ``typing.cast(list[T], self.field)`` recovers precise inference at call sites that need it. None of the touched sites currently rely on inside-override inference at usage sites, so no cast() is needed for this change. make quality: 4319 passed, 14 skipped, 19 xfailed. Co-authored-by: Claude Opus 4.7 (1M context) * refactor: drop SchedulerLifespanMiddleware, use serve(on_startup=, on_shutdown=) (#401) adcp 5.4.0 #713 ships native lifespan hooks on ``serve(transport='both')``, which is exactly what the middleware was hand-rolling. The middleware intercepted ASGI ``lifespan.startup`` / ``lifespan.shutdown`` scope events to fire scheduler start/stop coroutines because earlier SDK versions didn't expose a user-supplied lifespan extension point. Now they do. The SDK's ``on_startup`` / ``on_shutdown`` kwargs take the same ``Callable[[], Awaitable[None]]`` shape that ``_start_schedulers`` and ``_stop_schedulers`` already had, so the swap is mechanical: - Drop ``SchedulerLifespanMiddleware`` from the ``asgi_middleware`` list. - Pass ``on_startup=[_start_schedulers]`` / ``on_shutdown=[_stop_schedulers]`` in ``_serve_kwargs()``, conditional on ``include_scheduler`` (tests still skip). - Delete ``core/middleware/scheduler_lifespan.py`` (61 LOC). - Update the ``_serve_kwargs`` docstring to reference the SDK hook instead. The middleware ran scheduler shutdown with a 10s ``asyncio.wait_for`` guard; the SDK fires hooks unguarded. Our ``_stop_schedulers`` already caps its own awaitables (delivery + media-buy status schedulers each join their internal task groups with a bounded timeout), so dropping the outer wait_for is fine — it was defensive double-bookkeeping. make quality: 4319 passed, 14 skipped, 19 xfailed. Closes the second of three local rip-outs unlocked by the adcp 5.4.0 bump. The third (AgentCardPublicUrlMiddleware → public_url callable) lands separately. Co-authored-by: Claude Opus 4.7 (1M context) * refactor: swap AgentCardPublicUrlMiddleware for public_url callable (#402) The salesagent middleware existed because earlier SDK versions either hardcoded ``http://localhost:{port}/`` into the agent card with no override hook (pre-5.0) or crashed ``transport='both'`` startup when ``public_url`` was a callable (5.2.0, ``AttributeError: 'function' object has no attribute 'router'``). adcp 5.3.0 #680 fixed the composed-lifespan crash. 5.4.0 has confirmed the callable path works under ``transport='both'`` in production. The SDK's ``serve(public_url=PublicUrlResolver)`` is now the right primitive for per-request agent-card URL derivation. ## What lands - ``core/main._resolve_public_url(request) -> str`` — pure function with the same header-precedence rules the middleware enforced: PUBLIC_URL env > X-Forwarded-Host > Host, X-Forwarded-Proto for scheme, ``http://`` for loopback / ``https://`` otherwise. - Wired as ``"public_url": _resolve_public_url`` in ``_serve_kwargs``. - Drop the middleware from the ``asgi_middleware`` list. - Delete ``core/middleware/agent_card_public_url.py`` (189 LOC). - Replace ``test_agent_card_public_url_middleware.py`` with 13 tests of the new resolver covering: X-Forwarded-Host precedence, Host fallback, comma-chain stripping, proto override, https default, loopback http exception (matches SDK's ``_validate_card_url``), PUBLIC_URL env override, no-headers fallback. - Update ``test_serve_kwargs_middleware_order`` — replace the middleware-present assertion with a ``public_url is callable`` assertion. ## Net diff -442 LOC (mostly the middleware + ASGI plumbing tests it required) +195 LOC (resolver doc + resolver tests + updated order test) = -247 LOC net. ## What stays the same in production behavior - PUBLIC_URL env takes precedence (single-host deploys unchanged). - X-Forwarded-Host derives multi-tenant subdomain URLs (same as before). - X-Forwarded-Proto controls scheme. - Loopback hosts get ``http://`` (the SDK's _validate_card_url enforces this — non-loopback ``http`` returns 500 from the SDK). ## What's different (intentional) - The middleware refused to rewrite non-loopback URLs (defensive pass-through). The resolver always derives the URL afresh. This is safer: with the static-public_url fallback gone, the resolver is the single source of truth and there's no "what gets rewritten vs passed through" branching to reason about. - Response-body buffering and content-length recalculation are gone — the SDK builds the card from the resolver's URL directly. make quality: 4322 passed, 14 skipped, 19 xfailed. Closes the third of three local rip-outs unlocked by the adcp 5.4.0 bump (after SchemaVariant migration and SchedulerLifespanMiddleware removal). Co-authored-by: Claude Opus 4.7 (1M context) * feat(ops): add tenant export/import for legacy → embedded migration (#403) Reflection-based export/import for tenant-scoped data. Walks SQLAlchemy metadata to discover all 41 tenant-scoped tables (including transitive chains like media_packages → media_buys, strategy_states → strategies, object_workflow_mapping → workflow_steps), then exports/imports rows in FK-dependency order inside a single transaction. Built for moving clients from legacy hosting to embedded mode (flip is_embedded=True) on the same Postgres deployment. Also supports cross-deployment moves via target-tenant-id retargeting and a strip-secrets mode that wipes Fernet ciphertext + plaintext bearer credentials (admin_token, slack/audit/hitl webhook URLs, GAM refresh token, push_notification_configs auth, webhook subscription secret hash, creative/signals agent auth_credentials, ai_config api_key). principals.access_token is intentionally preserved so buyers' MCP/A2A integrations keep working post-import. Safety: - alembic_revision pinned in the bundle; import refuses on schema mismatch - pre-flight collision check on subdomain, virtual_host, principals.access_token raises TenantImportCollisionError with precise message instead of opaque IntegrityError - strict column filtering when alembic revisions match (drops are a bug, not noise); --allow-schema-drift downgrades to warning - Core-level inserts bypass the embedded_tenant_guard ORM listeners; the operator-CLI trust boundary is the equivalent privilege level - export bundle written 0600 (contains tenant secrets) - import writes an audit_logs row capturing operator, mode, flip-to-embedded, target_tenant_id, row counts - explicit rollback on any import-path failure CLIs: scripts/ops/export_tenant.py acme --out acme.json [--strip-secrets] scripts/ops/import_tenant.py acme.json --mode=replace --flip-to-embedded scripts/ops/import_tenant.py acme.json --target-tenant-id new --allow-schema-drift scripts/ops/import_tenant.py acme.json --dry-run 19 integration tests covering discovery, round-trip, collision modes, embedded flip, retargeting, strip-secrets (encrypted + plaintext bearer), strict filtering, schema mismatch, audit log emission. Co-authored-by: Claude Opus 4.7 (1M context) * fix(admin-mount): serve /robots.txt as public Disallow / instead of 401 from A2A (#407) Crawlers and probes hitting `GET /robots.txt` on the API host fell through to the inner A2A app, where BearerTokenAuth 401'd them. Production logs filled with `"GET /robots.txt HTTP/1.1" 401 Unauthorized` (plus a paired `adcp.server.auth` JSON line per rejection), and well-behaved crawlers got an inconsistent signal — 401 is not a stable "do not crawl" answer. robots.txt is a host-level resource, not a per-tenant one, so neither Flask nor A2A is the right owner. `AdminWSGIMount` already short- circuits an analogous static response (the apex `/` → `/signup` 302), so colocate the robots short-circuit there: - `GET`/`HEAD /robots.txt` → 200 `text/plain` with `User-agent: *\nDisallow: /\n` and `cache-control: public, max-age=86400` - non-safe methods (POST, etc.) fall through unchanged — the short circuit only covers actual crawler probes Tests: four scenarios in `TestAdminWSGIMountRobotsTxt` covering the GET body+headers, HEAD-returns-no-body contract, POST falling through, and the bug itself (the request must not reach the inner A2A app on a non-admin host). Co-authored-by: Claude Opus 4.7 (1M context) * chore(embedded-guard): inline auth-flag diagnostics in rejection error (#408) Surfaces session/connection auth-flag state directly in the EmbeddedTenantWriteError message so SyncJob.error_message (and the status widget that renders it) shows exactly why the guard fired without log-diving. Distinguishes the three failure modes: - session_present=False → object was detached at flush time - session_flags={all None/False} → flag never set on this session - session_flags={one True} → guard misread the flag (should be impossible) No behavior change beyond the longer error text. * chore(logs): drop /mcp 401 access spam in UvicornAccessNoiseFilter (#410) Anonymous internet traffic hammers /mcp constantly (bot probes, misconfigured clients), and every rejection emits two log lines: - one uvicorn access line ("POST /mcp HTTP/1.1" 401 Unauthorized) - one structured ``adcp.server.auth`` line ("a2a auth rejected" …) PR #397's filter deliberately kept 4xx/5xx so auth failures weren't buried, but the structured log already captures the signal — the access line is dupe noise. In production logs this is by far the dominant source of /mcp-related log volume. Per-surface status-code policy now: * /mcp[/] — drop 2xx AND 401. Other 4xx (403/404/422) and all 5xx still log; those indicate a real problem worth investigating. * /health — drop 2xx only. A 4xx/5xx on the health surface always means a config or platform bug worth seeing. Implementation splits the single regex into two named patterns so the status-code carve-out per surface stays readable. Test reshuffle: * test_drops_noise — new combined parametrize: /mcp 2xx + /mcp 401 + /health 2xx (one row per cause). * test_keeps_real_signal — /mcp non-401 4xx (403, 404, 422), /mcp 5xx, /health non-2xx (401, 503), and /.well-known/oauth-protected-resource 401 (the OAuth dance start, which is signal not noise). Co-authored-by: Claude Opus 4.7 (1M context) * feat(freewheel): full Publisher API adapter — auth, inventory sync, targeting, formats (#381) * chore(freewheel): capture & anonymize publisher API fixtures Adds 56 anonymized FreeWheel Publisher API response fixtures covering /services/v3/ (XML, commercial: advertisers, campaigns, insertion_orders, placements, agencies) and /services/v4/ (JSON, inventory: sites, site_sections, site_groups, series, videos, video_groups, inventory_packages). Includes the capture and anonymization scripts under scripts/dev/freewheel/ so fixtures can be regenerated when the test bearer token rotates (7-day TTL). Both scripts read identifying constants from env vars rather than embedding them, keeping the source tree free of publisher-specific identifiers. .env.template documents the two new optional vars. Anonymization scrubs PII (sales person, trafficker, content credits) and publisher-identifying values (network_id, advertiser_id, content/title fields, external Salesforce IDs) while preserving referential integrity via deterministic memoized replacements. Structural fields (statuses, stages, currencies, budget shapes, schedules, link hrefs) are preserved verbatim so fixtures remain useful as wire-format ground truth for the upcoming adapter client. No production code wired yet — these fixtures are the foundation for the FreeWheel adapter client rewrite (next change). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): rewrite adapter for real /services/v3+v4 API surface Replaces the skeletal OAuth-client-credentials client (wrong path shape, wrong content type, wrong auth model) with a bearer-token client that matches the actual FreeWheel Publisher API surface verified end-to-end against a publisher's test network: - /services/v4/* (JSON, inventory taxonomy: sites, sections, series, videos, video groups, inventory packages) — read-only - /services/v3/* (XML, commercial entities: advertisers, campaigns, insertion orders, placements, agencies) — full reads + verified create_campaign/delete_campaign writes Module layout under src/adapters/freewheel/: _transport.py — bearer auth, accept negotiation, status mapping _inventory.py — v4 JSON inventory client _commercial.py — v3 XML commercial client _pagination.py — shared page-walking iterator (DRY) entities.py — Pydantic models for both surfaces client.py — FreeWheelClient facade composing the above Connection config is now a single api_token field (7-day TTL, no refresh flow — rotate when expiry approaches). Migration of existing tenants will require manual reconfiguration once any are provisioned. Tests: - 37 new unit tests across transport / inventory / commercial replaying captured fixtures from tests/fixtures/data/freewheel/ - Updated config schema + adapter + roundtrip integration tests Adapter-level wiring (create_media_buy/update_media_buy/check_status using the new client) is intentionally deferred to a follow-up PR; live mode still returns pending_credentials until that integration lands. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): add insertion-order, placement, and campaign-update writes Completes the v3 write surface so adapters and external callers can construct a full campaign hierarchy. All endpoints verified end-to-end against the publisher's test network (probe entities created with clearly tagged names, then deleted): - POST /services/v3/insertion_order — min body: name + campaign_id. Server defaults: stage=NOT_BOOKED, currency=EUR. Auto-attaches an assigned_user from the bearer token's identity (silently dropped by our model's extra="ignore" config). - POST /services/v3/placement — min body: name + insertion_order_id. Server defaults: status=IN_ACTIVE, placement_type=NORMAL. - PUT /services/v3/campaign/{id} — partial update. PATCH returns 405, so v3 uses PUT semantics for "only fields in the body are modified". - DELETE /services/v3/insertion_order/{id} and DELETE /services/v3/placement/{id} — hard deletes, same shape as campaign delete. Adds put_xml() to the transport (POST handler already existed) and a matching test for the PUT method. 6 new commercial client tests covering create+delete for IO and placement, and the partial-update semantics for update_campaign (verifies only passed fields appear in the request body). create_media_buy wiring still uses the pending_credentials stub — that work belongs in the adapter-mapping PR where we decide how AdCP Package maps onto FreeWheel's Campaign→IO→Placement hierarchy. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): wire create_media_buy and check_status to live v3 API Replaces the pending_credentials stub in create_media_buy with the real v3 write flow per Mapping A: AdCP MediaBuy → FW Insertion Order (the commercial transaction) AdCP Package → FW Placement (one per package, child of the IO) FW Campaign → per-buy wrapper above the IO In live mode the adapter now: 1. Creates a FW Campaign named after the AdCP buy (po_number-derived or timestamp), parented to the principal's freewheel advertiser_id. 2. Creates a FW Insertion Order under that campaign. 3. Creates one FW Placement per AdCP Package under the IO. 4. Returns ``media_buy_id = "freewheel_{io.id}"`` — the IO is the unit of commerce, so it's what subsequent calls reference. check_media_buy_status now fetches the IO (not the Campaign) and reports its ``stage`` (NOT_BOOKED, BOOKED, etc.), which is where IO booking state lives in v3. Falls back to ``status`` for safety. FreeWheelError from any of the three create calls is translated to a CreateMediaBuyError with code ``upstream_error``. Partial-failure orphans (e.g. Campaign created then IO fails) are not cleaned up in v1 — they sit as IN_ACTIVE entities and don't deliver. A best-effort rollback is a future refinement. Deferred (each its own follow-up, flagged in the adapter docstring): - update_media_buy live wiring (needs update_io/update_placement probes) - add_creative_assets (v3 /creative endpoint returned 404 in probes — the creative surface is somewhere we haven't mapped) - get_media_buy_delivery (reporting lives on a different API surface) Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): add update_insertion_order/update_placement and document scope blockers Adds the v3 partial-update verbs to the commercial client, both verified end-to-end against the publisher's test network (probe entities created and deleted): - update_insertion_order(id, **fields) — PUT /services/v3/insertion_order/{id}. Supports nested-dict fields like ``budget={"budget_model": ..., "impression": ...}`` for impression-target adjustments. - update_placement(id, **fields) — PUT /services/v3/placement/{id}. The delivery-level pause/resume mechanism (status=IN_ACTIVE / ACTIVE). Extends _build_xml to handle one level of nested dicts so partial body updates with nested elements (budget, schedule) serialise correctly. Adapter-level update_media_buy wiring intentionally NOT included — two publisher-scope blockers surfaced during probes that need resolution before the adapter can wire cleanly: 1. Per-package operations need AdCP package_id -> FW placement_id lookup. v3 /placements doesn't honour ?insertion_order_id filter (returns full network list); no nested-collection endpoint at v3; v4 has the nested form but our token gets a 403 IAM deny. 2. Per-package budget changes don't fit FW's data model — budget lives on the IO, not on the placement. Would need a different mapping (one-IO-per-package) or per-package tracking we don't have. Creative endpoints discovered at v4 (creatives, creative_assets, assets, ad_assets, asset_versions, creative_versions all 403 IAM-deny). v3 has no creative paths (404). add_creative_assets wiring blocked on publisher granting creative scopes. Documented in the adapter docstring so the next conversation with the publisher has the asks ready. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(freewheel): clarify the creative endpoint split (publisher vs demand) After a docs deep-dive prompted by the user pointing at the FreeWheel Marketplace Creatives reference, we now have a clearer picture of the two-API creative model and the AdCP semantic mismatch it implies. Publisher-side (the API our bearer is for): PUT /services/v4/mkpl_creatives/{id} body: approval_status (Approved|Rejected|Pending) + approval_notes This is a *moderation* workflow, not a creation workflow. The buyer registers the creative through their own DSP; it shows up in the publisher's marketplace queue; the publisher (us, via Talpa's token) approves or rejects it. AdCP's sync_creatives (buyer registering creatives) therefore has no direct publisher-side equivalent — the adapter's approval surface maps to AdCP's creative review/approval flow, not its creation flow. Three sibling type-specific endpoints exist alongside the unified one (mkpl_exchange_programmatic_creatives, mkpl_private_direct_sold_creatives, mkpl_private_programmatic_creatives). All 403 IAM-deny on our token — the ask to Mathijs becomes specific: grant scope on ``/services/v4/mkpl_creatives``. Buyer-side (POST /demand/v1/accounts/{seat_id}/ads) is the FreeWheel Demand/Beeswax product. Out of scope for publisher-token-driven integration — Talpa as a publisher wouldn't have a Demand seat to delegate. No code change beyond the adapter docstring — just capturing the finding so the next round of asks to the publisher is precise. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): add v4 creative_resources client (full CRUD scope verified) After two earlier docs links pointed at the wrong API surface (Demand v1 and Marketplace approval), the user surfaced https://api-docs.freewheel.tv/publisher/reference/creative-management-api-v4 which gave us the correct path: /services/v4/creative_resources. Probing shows our existing publisher bearer is fully entitled there — we just hadn't tried the right name. Verified end-to-end (2026-05-12): - GET /services/v4/creative_resources (list, 70 creatives) - GET /services/v4/creative_resources/{id} (single, ``{creative: {...}}`` envelope) - POST /services/v4/creative_resources (auth+validation reaches us) - PUT /services/v4/creative_resources/{id} (auth+validation reaches us) - ?include=renditions exposes the nested VAST tag URIs inline. Exposed on the client as ``client.creatives`` with list_creatives, get_creative, and iter_creatives. CRUD writes (POST/PUT/DELETE) are deferred to a follow-up commit so we can probe shapes against the live API with a create+cleanup pattern. Still scope-blocked (publisher must grant): - /services/v4/creative_instances — creative <-> placement linkage, needed to actually attach a creative to a placement so it delivers. - /services/v4/creative_renditions — standalone rendition collection. - /services/v4/mkpl_creatives — marketplace creative approval. Supporting changes: - entities.py: Creative + Rendition + CreativeMessage models. Extended PaginatedResponse with AliasChoices so both pagination conventions work (total_count/total_page for inventory, total/total_pages for creative_resources). - capture_fixtures.py: added creative_resources to the v4 walk. - anonymize_fixtures.py: advertiser_ids / agency_ids (list-of-int) plus uri and clearcast_note added to the scrub list. VAST URIs replaced with example.invalid placeholders so the third-party ad-server hostnames don't leak. - tests/helpers/freewheel_replay.py: shared make_response / replay_session helpers extracted from the three client test files to satisfy the code-duplication guard. Fixture file churn comes from the deterministic counter-based anonymiser picking different fake-name values now that creative_resources is in the input set; semantic content is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): live smoke test + fix XML empty-element coercion Adds a @pytest.mark.live integration test that exercises the whole FreeWheel client stack against the real publisher API: token info, inventory reads, commercial reads, creative reads, and a full Campaign → IO → Placement create-and-delete cycle. Skipped by default, runs only when FREEWHEEL_TEST_API_KEY + FREEWHEEL_TEST_ADVERTISER_ID are set. Running the test surfaced a real bug. The live API returns campaigns with ```` when no agency is assigned (empty XML element), and our ``_element_to_dict`` was emitting ``""`` for those — which Pydantic ``int | None`` fields couldn't coerce. Fixed by mapping empty leaf elements to ``None`` instead of ``""`` so optional scalar fields validate cleanly across the board. The earlier BeforeValidator on nested model fields (schedule/budget) becomes redundant for the empty-element case but stays in place as a safety net. Live test results against Talpa's network (2026-05-12): TestAuthAndConnectivity.test_token_info_returns_user_and_expiry PASS TestInventoryReads.test_list_sites_returns_entities PASS TestInventoryReads.test_list_videos_returns_entities PASS TestCommercialReads.test_list_advertisers_includes_test_advertiser PASS TestCreativeReads.test_list_creatives_returns_entities PASS TestWriteRoundTrip.test_full_create_and_delete_cycle PASS The write round-trip creates Campaign → IO → Placement, fetches the IO back, then deletes everything in reverse order. All six assertions land and all three deletes succeed — Mapping A wires correctly end-to-end through to the real API. Registered a ``live`` pytest marker so the suite doesn't need @pytest.mark.skipif boilerplate on every test. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): OAuth2 password-grant auth (api_token kept as escape hatch) Adds the canonical FreeWheel auth flow — username + password — alongside the pre-existing pre-minted api_token path. Production users now enter credentials once; the transport mints a bearer at POST /auth/token on first use, caches it with TTL tracking, and auto-refreshes on 401 or expiry. The api_token field is kept as an escape hatch for cases where a partner has provisioned a token out-of-band (our Talpa setup), or for testing without managing real credentials. Exactly one of (username + password) or api_token is required, enforced at three layers: - Pydantic model_validator on FreeWheelConnectionConfig - Constructor check in FreeWheelTransport - Init check in FreeWheelAdapter (live mode only) Transport behaviour: - api_token mode: bearer used directly, 401 propagates to caller. - password-grant mode: mint via POST /auth/token (data: grant_type=password, user_id, password). 401 triggers exactly one refresh + retry before propagating, in case the cached token rolled prematurely. expires_in is honoured with a 1-hour refresh leeway (or expires_in/2, whichever is smaller). UI: connection_config.html now has a "Sign-in Credentials (recommended)" section with User ID + Password fields, plus an "Advanced: pre-minted bearer token"
block for the escape hatch. The Save flow rejects submissions that have neither path. The Test Connection flow reports ``auth_mode: password_grant`` vs ``pre_minted_token`` in its response. Tenant-status reporting accepts either auth path. Config save/update endpoints accept username, password, and api_token, reject ciphertext replay on both secret fields, and pass the merged config to FreeWheelConnectionConfig for validation. Tests: - 15 new unit tests (password-grant mint + cache + 401-refresh + retry + error paths, plus full schema validation coverage for both auth paths). - Integration roundtrip tests cover both auth modes. - Live API test continues to pass via the api_token path (we don't have Talpa's username/password to exercise the password-grant path against the real API; that's unit-tested only until a real user/password pair shows up). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): inventory taxonomy sync into local cache Adds a publisher-internal cache of FreeWheel inventory so the adapter's product setup UI can pick targeting from FW taxonomy without round-tripping to the FW API on every page render. New ``freewheel_inventory`` table (alembic 7c3073bd70cf), keyed by ``(tenant_id, entity_type, entity_id)`` with a denormalised name/parent_id plus a full JSON-blob ``raw_json`` payload. Stores eight entity kinds: - site (v4) - site_section (v4) - site_group (v4) - series (v4) - video_group (v4) - ad_unit_package (v4, with nested ad_units folded out) - ad_unit (v4, denormalised from ad_unit_packages — bare /ad_units/{id} is 403-denied on our scope, so we read them through their packages) - ad_unit_node (v3 XML; binds placement → ad_unit, read-only at v3) - standard_attribute (v4 reference data — TV ratings, languages, etc.) Individual Videos are NOT synced (4,613+ items on Talpa's network; query on-demand if a product needs to drill into a specific asset). This table is NOT exposed to AdCP buyers. Buyer-facing property discovery goes through the AAO lookup path (adagents.json + brand.json, via src/services/aao_lookup_service.py). The cache exists purely for the publisher's product configuration UI. See #378 for the cleanup of the deprecated AuthorizedProperty / PropertyTag tables that this design intentionally bypasses. Components: - alembic 7c3073bd70cf — create freewheel_inventory table - src/core/database/models.py — FreeWheelInventory ORM model - src/adapters/freewheel/inventory_sync.py — FreeWheelInventorySync service: walks every readable family, upserts via Postgres ON CONFLICT DO UPDATE. Per-family errors are captured in SyncResult rather than aborting (partial-success policy — some tenants will have uneven scope coverage across families). - POST /api/tenant//adapters/freewheel/sync-inventory — admin endpoint that reads the stored config, instantiates a client, and triggers the sync. - templates/adapters/freewheel/connection_config.html — "Sync Inventory Now" button + status display showing per-entity-type counts. - 7 new unit tests covering SyncResult dataclass, the dispatch orchestration with a mock client, partial-failure semantics, and the standard_attributes flat-dict code path. Verified end-to-end against Talpa's live network: 2,542 entities synced in one call (29 sites, 51 site_sections, 96 site_groups, 324 series, 507 video_groups, 2 ad_unit_packages, 385 ad_unit_nodes, 1148 standard_attributes), then re-runs upsert cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): product setup UI driven by synced inventory cache The FreeWheel product config schema and template are rebuilt around the actual FW data model. Instead of asking publishers to type comma-separated "placement IDs" (the old shape, which never made sense for our adapter since placements get created per buy), the new product setup UI pulls choices from the local ``freewheel_inventory`` cache: - Sites (delivery destinations) - Site Sections (optional sub-section scoping) - Video Groups (audience-segmented content — Talpa's primary targeting primitive: "DOELGROEP INDEX 150+", etc.) - Series (specific shows) - Ad Unit Package (slot bundle: Pre-Mid, Pre-Mid-Post) - TV Ratings (content rating restrictions, from standard_attributes) The picker template (templates/adapters/freewheel/product_config.html) populates each picker that loads from the freewheel_inventory cache via the existing GET /api/tenant//adapters/freewheel/inventory endpoint. The adapter's dry-run _line_item_payload echoes every list dimension so operators can verify intent in dry-run logs before flipping to live mode. Note: custom_targeting (the FW v4 custom_keys API) is still gated by scope on our token — kept as the escape hatch under an Advanced
, but most use cases that would have needed it on GAM are covered by the structured fields above. ## FreeWheelAdapter.get_creative_formats() — six canonical VAST formats New src/adapters/freewheel/formats.py declares six static AdCP-shaped formats covering pre/mid/post-roll × 15s/30s: freewheel_video_15s_pre_roll freewheel_video_30s_pre_roll freewheel_video_15s_mid_roll freewheel_video_30s_mid_roll freewheel_video_15s_post_roll freewheel_video_30s_post_roll Each format declares a single VAST tag URL asset and {vast: true} delivery hint. Validated against adcp.types.Format on every test run. Declared statically (Option A) rather than synthesised from synced data because (a) AdCP's format registry is mostly static, (b) the six combinations cover the common buyer case for video VAST forwarding, and (c) static format IDs stay stable across inventory-sync runs so buyer references don't break when Talpa edits their ad_unit_packages. Tests: - 6 unit tests for the static format list and Format schema validation - Schema test for the new product config fields, full round-trip via model_dump → model_validate 4328 unit tests pass. Live FW integration test still green via api_token escape hatch. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): surface synced inventory via get_available_inventory + README refresh Two free wins that need no FW scope grant: - Override AdServerAdapter.get_available_inventory() to serve the AI product configurator from the freewheel_inventory cache. Returns placements (ad_unit_packages), ad_units (sites + site_sections), targeting_options (standard_attributes grouped by taxonomy key), the static VAST creative specs, and cache properties. Live-verified against Talpa: 2 packages, 80 ad units, 15 targeting groups, 6 formats, 1148 attributes. - Rewrite docs/adapters/freewheel/README.md to match what's actually shipped: password-grant auth (with api_token escape hatch), full inventory sync taxonomy, 18-dimension product config, live coverage matrix, and the layered scope-grant ask (Tier 1: lifecycle; Tier 2: reporting; Tier 3: operator UX; Tier 4: future). Previous README still described the client_credentials path we abandoned and claimed skeleton-only status. Test: tests/unit/test_freewheel_adapter.py::TestGetAvailableInventory covers shape and grouping semantics with mocked repository. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): reporting cache scaffold — read paths + stub sync (scope-pending) Build everything that's anchored on AdCP's contract (which is fixed) so day-of-scope the only new work is the actual FW Reporting API client. Adds: - Migration + ORM: freewheel_placement_stats cache table (per-placement impressions/spend_micros/completed_views/clicks/currency/delivery_status, keyed by (tenant_id, placement_id), with IO-scoped index for delivery aggregation). Spend stored in micros to dodge floating-point drift. - Repository (FreeWheelPlacementStatsRepository): tenant-scoped reads via get_by_placement_ids() and list_by_insertion_order(), plus a Postgres ON CONFLICT bulk_upsert() for the sync job to call. - get_packages_snapshot(): reads from cache, returns Snapshot per package. Missing rows surface as None so callers render a "no data" state rather than fail. Staleness derived from row.as_of. Delivery status mapped to the AdCP DeliveryStatus enum where the FW value maps cleanly. - get_media_buy_delivery(): aggregates per-placement rows into DeliveryTotals + by_package list. Empty cache falls through to the base helper's zero-response shape. - FreeWheelReportingSync stub: raises ReportingScopeNotGranted with a pointer to the README scope ask. Schedulers can catch this and degrade gracefully — read paths already tolerate the empty-cache state. Tests pin the read-side contract (tests/unit/test_freewheel_reporting_cache.py, 7 cases). When FW grants Tier 2 scope, the only new work is implementing the four private methods on FreeWheelReportingSync (submit_job, poll_job, fetch_results, parse_rows) against the real Query Reporting endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(tenant-mgmt-api): typed adapter configs + GET /adapters discovery Two gaps that blocked Scope3 from using anything beyond GAM + Mock through the typed tenant-management API: 1. The AdapterConfig discriminated union in src/admin/api_schemas/ tenant_management.py only listed GAM + Mock. Typed embedder clients couldn't POST type="freewheel" / "triton" / "broadstreet" — spectree validation rejected anything else, even though the legacy /api/tenant//adapter-config endpoint (operator-facing) handled them. Adds: - FreeWheelAdapterConfig (with the username+password OR api_token cross-field rule) - TritonAdapterConfig (auth_type + creds + base/login URLs) - BroadstreetAdapterConfig (network_id + api_key) Secrets use SecretStr. Persistence round-trips through each adapter's own connection schema so Fernet encryption lands consistently in AdapterConfig.config_json — same path the legacy endpoint uses. 2. No way to discover what adapters this Sales Agent instance supports. Adds GET /api/v1/tenant-management/adapters returning the full catalog per adapter type: name, description, default_channels, capabilities (mirrors AdapterCapabilities), and the connection_schema JSON Schema so embedders can validate locally before POSTing. Sourced from ADAPTER_REGISTRY so new adapters auto-appear once they're registered and have a typed AdapterConfig member. Test plan: - tests/unit/test_tenant_management_schemas.py: 13 new schema-level tests covering each typed config's happy path + rejection paths + discriminator routing through ProvisionTenantRequest. - tests/integration/test_tenant_management_api_integration.py: 2 new endpoint tests (catalog shape + auth gate). - Regenerated docs/api/tenant-management-openapi.{json,yaml}. Co-Authored-By: Claude Opus 4.7 (1M context) * docs(adapters): adapter playbook — phase-by-phase checklist for new adapters 11-phase walkthrough of everything needed to add a new ad-server adapter end-to-end: pre-work API probing, adapter package scaffolding, inventory + reporting caches, three-place registration (registry / typed API config / discovery catalog), admin UI, admin endpoints, test coverage, docs, OpenAPI regeneration, smoke + quality gates, common gotchas, ship. FreeWheel is called out as the reference implementation with specific file pointers per step. Captures the lessons from PR #381 — stale uvicorn imports, migration head collisions, DeliveryStatus enum mismatch, BuildKit stale-deps surprise, the DRY guard ratchet, etc. Also fixes the stale FreeWheel description in docs/adapters/README.md (client_credentials → password grant) and surfaces the new playbook from the index. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(tenant-mgmt-api): add tier flag to adapter discovery (mock=test, rest=live) Emma flagged that the discovery endpoint exposes Mock to embedders — which is real (it's a registered adapter we use in tests and demos) but should never appear in a production storefront's picker. Adds: - ``tier`` field on AdapterCatalogEntry: ``"live"`` (production adapter) or ``"test"`` (simulated/dev-only). Mock is the only ``"test"`` adapter today; everything else is ``"live"``. - ``?tier=live`` and ``?tier=test`` query filter on GET /adapters so production storefronts can opt out of seeing the test surface server-side (rather than having every embedder filter client-side). Unknown values return 400. Default behaviour returns all adapters with their tier tag so dev consoles keep seeing the full set. Production storefronts pass ?tier=live. Test plan: - 3 new endpoint tests in test_tenant_management_api_integration.py (live filter excludes Mock, test filter returns only Mock, invalid value rejected with 400) — all green. - Existing catalog assertion updated to check the new tier field. - Regenerated docs/api/tenant-management-openapi.{json,yaml}. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(adapters): park Triton — APIs not production-ready, surface removed Triton told us their TAP Media Buying API isn't production-ready (2026-05). Surface-removal approach (vs. full deletion) so we can restore via revert when their APIs come back. Removed from every customer-facing surface: - ADAPTER_REGISTRY: triton + triton_digital entries dropped, so tenants cannot select 'triton' as ad_server (legacy POST /tenants now rejects it via an ADAPTER_REGISTRY membership check; spectree POST /tenants/provision rejects via the discriminated AdapterConfig union). - AdapterConfig discriminated union: TritonAdapterConfig removed. - Discovery catalog (_ADAPTER_CATALOG_METADATA + _ADAPTER_CONFIG_TYPED): triton excluded from GET /api/v1/tenant-management/adapters. - tenant_settings.html: picker card hidden (with a comment pointing at the parked module path for restoration). - adapters.py blueprint: test_triton_connection endpoint removed. - docs/adapters/README.md: Triton section + table row replaced with a short parked-state notice. - docs/adapters/triton/README.md → README.parked.md with a header explaining the parked state. Kept parked (so restoration is a revert, not a rebuild): - src/adapters/triton/* — the adapter module + client + targeting - tests/unit/test_triton_*.py — direct-construction tests still run; TestRegistry tests flipped to assert parked-state behaviour. - templates/adapters/triton/* — connection + product templates unreachable but preserved. - Alembic migrations — unchanged. Existing tenants whose adapter_type is already 'triton' (if any) remain operable: the update path in tenant_management_api.py preserves their config_json handling. Tests: - test_tenant_management_schemas.py: removed TritonAdapterConfig happy- path tests; added test_provision_request_rejects_parked_triton_adapter to pin the embedder-side rejection. - test_tenant_management_api_integration.py: catalog assertions exclude triton from both the all-adapters and tier=live responses. - test_new_product_filters.py + test_triton_adapter.py registry tests flipped to assert the parked-state behaviour. - 4,367 passed / 14 skipped / 19 xfailed — all green. - Regenerated docs/api/tenant-management-openapi.{json,yaml}. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(adapters): permission probes — catch IAM gaps at connect, not mid-campaign Brian flagged that operators today discover missing upstream permissions when a campaign fails halfway through, instead of seeing them at connect time. This adds a structured permission-check primitive on AdServerAdapter and a live FreeWheel implementation. The pattern: 1. AdServerAdapter.check_permissions() returns a PermissionsReport with per-endpoint PermissionCheck entries: name, description, granted, required vs nice-to-have, feature label (creative_trafficking, delivery_reporting, etc.), and a detail string for failed probes. 2. fully_operational rolls up to True only when every required probe passes. Optional probes can deny without blocking the rollup — surfaces partial-scope state correctly. 3. Each adapter implements its own probe — auth flows differ enough that a generic HTTP prober doesn't fit. Base class returns an empty report so adapters that haven't implemented yet behave as "no checks declared, fully_operational=True". FreeWheel implementation probes 14 endpoints covering auth, inventory sync, commercial CRUD, creative trafficking, reporting, audiences, targeting profiles, and webhooks — every AdCP feature path. Live probe against Talpa correctly reports our current state: 9 required probes granted, 1 required denied (/services/v4/ads — the creative trafficking blocker), 4 optional denied (reporting + audiences + targeting profiles + webhooks). Probe semantics that took some thought: - 4xx validation (400/404/422) counts as GRANTED — endpoint accepts the call, just needs different params. Our minimal probes intentionally send empty payloads so we don't accidentally mutate state. - 401/403 count as denied (real scope gap). - Auth-token failures bail the whole pass with report.error set; we don't paint every endpoint as "denied" when the real problem is a bad token, that'd mislead operators. New admin endpoint: POST /api/tenant//adapters//check-permissions Loads the configured adapter, runs check_permissions(), returns the JSON report. Read-only (every probe is a GET) so opts into the embedded-write gate. Available to admin or member roles. Test plan: - tests/unit/test_freewheel_permissions.py: 11 cases covering dry-run short-circuit, granted/denied semantics, the validation-error edge case, 401 mapping, auth failure handling, probe target cleanup, and the every-check-has-a-feature invariant. All pass. - Live-verified against Talpa: correctly identifies /services/v4/ads as the one required denial blocking creative trafficking. - make quality: 4,378 passed / 14 skipped / 19 xfailed. Follow-up not in this PR: UI rendering of the checklist on the adapter settings page; surfacing fully_operational on the discovery catalog. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): permission-check UI + correct creative_trafficking model Brian flagged that FW's docs don't actually have an "Ad" concept and asked us to verify. Re-reading the creative_instances POST docs revealed the parameter ad_id is described as "The Ad Unit Node ID to link Creative" — there is no separate Ad object. The /services/v4/ads scope we were chasing was misdirected. Verified live: POST /services/v4/creative_instances with ad_id= and creative_id= returns 201 Created with FW auto-deriving placement_id on the response. The entire creative trafficking flow is unblocked today. UI: - Added "Check API Permissions" button to the FreeWheel adapter settings page. Hits POST /api/tenant//adapters/freewheel/check-permissions, renders a per-feature checklist showing granted/denied with the probe target endpoint and the AWS API Gateway deny detail when the scope is missing. Operators see at-connect-time which AdCP features will work, instead of discovering missing scopes mid-campaign. Code: - check_permissions probe list: dropped v4_ads (wasn't needed); kept v4_creative_instances as the required probe with a comment explaining the ad_id ↔ ad_unit_node_id alias. - Unit tests updated to use creative_instances as the canonical required probe (11 cases still passing). - Live probe against Talpa now reports fully_operational=true. Only Tier-3/4 nice-to-haves remain denied (reporting, audiences, targeting profiles, webhooks). Docs: - README "Scope grants still needed" rewritten: Tier 1 ads grant is removed; Tier 1 is now Query Reporting (path TBD). Added a "What we no longer need to ask for" section explaining the ad_id alias and the v4-doesn't-exist-for-commercial finding so the next person looking at this doesn't go down the same dead ends. - Coverage matrix: add_creative_assets flipped from 🟡 partial (blocked) to ✅ unblocked; associate_creatives from ⏳ blocked to 🟡 wired-ready (FW writes work — adapter just needs the ad_unit_node lookup chain from cache, which is a follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): pinpoint Reporting API location, update scope ask + probes Probed FW's full host surface to find where their Reporting API actually lives — turns out it's at api.freewheel.tv/reporting/* (singular, host root, NOT under /services/v*). Every /reporting/* path returns the AWS API Gateway IAM-deny payload for our test user, confirming the resources exist and only a scope grant is needed. Verified surface (all currently denied): POST /reporting/jobs — submit async job GET /reporting/jobs/{id} — poll status GET /reporting/jobs/{id}/result(s)/download — fetch CSV/JSON GET /reporting/queries + /saved_queries — saved-query CRUD GET /reporting/dimensions + /metrics — schema introspection Adjacent host-root paths (/reports, /reporting at the top, /insights, /analytics, /graphql, etc.) all returned nginx-level HTML denies rather than AWS IAM-deny, confirming /reporting/* is the actual surface and others are dead-end aliases. Code: - check_permissions(): swapped the wrong /services/v4/reports probe for two correct /reporting/* probes (schema introspection + job submit). - reporting_sync.py: dropped TBD docstring; now documents the full /reporting/* surface map so day-of-scope is just filling in the four private methods. - Unit test parameter updated to match the new probe path. Docs: - README "Scope grants still needed" now lists the specific endpoints to ask for. Updates the Mathijs ask from "we don't know where reporting lives" to "grant our user IAM access to /reporting/* — specific paths listed". Live probe (against Talpa, user 35696) cleanly shows fully_operational=true plus two new entries under feature=delivery_reporting both denied, ready to flip the moment scope arrives. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(migrations): re-point FW merge revision to chain through r0s1t2u3v4w5 origin/main added migration r0s1t2u3v4w5_add_proposals_table.py which descends from 8820c87e8ae3 — the same parent our merge revision 190d6e98754b already chained from. That made them siblings and produced two migration heads, which the test_architecture_single_migration_head guard catches at quality-gates time. CI was tripping on the same check because migrate.py refused to apply with "Multiple head revisions are present", which cascaded into every DB-touching integration + E2E test. Re-point 190d6e98754b's main-side parent from 8820c87e8ae3 to r0s1t2u3v4w5 (which itself descends through 8820c87e8ae3 → 17423a1b551e → base). Graph converges to a single head; alembic history is linear-ish again. Verified locally: $ uv run alembic heads 190d6e98754b (head) $ make quality 4,438 passed / 14 skipped / 19 xfailed The commit message comment in the migration is updated to note that the main-side parent will move forward as new migrations land on main — each subsequent origin/main merge re-points this parent again. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): implement Reporting client + wire sync end-to-end Builds the Query Reporting API client speculative-but-defensive against the /reporting/* surface we mapped via probing. Day-of-scope this fires real reports against FW; day-zero it raises ReportingScopeNotGranted cleanly on the 403. Code: - src/adapters/freewheel/_reporting.py: FreeWheelReportingClient with submit_job / get_job / wait_for_completion / fetch_results. JobSpec (Pydantic) serialises POST /reporting/jobs bodies. JobStatus parses enum-strings case-insensitively and clamps unknown values to UNKNOWN so a new FW status doesn't break the polling loop. JobState parses both snake_case and camelCase payloads defensively, preserves the raw dict for fields we don't yet know about. ColumnMap is a single tunable for FW's result column names — day-of-scope edit one place when we see the real column labels. - src/adapters/freewheel/reporting_sync.py: FreeWheelReportingSync.run() now actually orchestrates submit/poll/fetch/upsert. ForbiddenError caught once at the top and re-raised as ReportingScopeNotGranted so callers get a clean signal. Cache upsert via the existing FreeWheelPlacementStatsRepository.bulk_upsert. - src/adapters/freewheel/_transport.py: added post_json + delete_json helpers (existing v3 surface only had post_xml + delete_xml). - src/admin/blueprints/adapters.py: new POST endpoint /api/tenant//adapters/freewheel/sync-reporting — admin-only, same shape as sync-inventory. Returns 503 with scope_pending=true when the upstream IAM-denies us so the UI can render the right copy. - templates/adapters/freewheel/connection_config.html: added "Sync Reporting Now" button + syncFreeWheelReporting() JS. UI lives between Inventory Sync and API Permissions so the operator's first three buttons match the natural flow: connect → inventory → reporting. Tests (tests/unit/test_freewheel_reporting_client.py — 27 new): - JobSpec serialisation (minimum + with filters) - JobStatus parsing (5 known values + unknown clamps to UNKNOWN + None) - JobState parsing (snake_case + camelCase + preserves raw + error_message) - parse_row (default map, string-numbers coercion, missing fields, garbage input, custom ColumnMap remap, as_of fallback to now) - submit_job round-trips the request body - wait_for_completion (immediate-terminal, polls PENDING→RUNNING→COMPLETED, timeout raises with last state, CANCELED is terminal) - fetch_results (inline rows, alternate keys 'rows'/'results'/'data', raises when job not complete) Cache test updated (tests/unit/test_freewheel_reporting_cache.py): the scope-handling tests now patch transport.post_json to raise FreeWheelForbiddenError, matching production behaviour rather than the old "raises unconditionally" stub. Live verified (against Talpa, user 35696): sync.run() correctly attempts POST /reporting/jobs, FW returns 403, our code catches it once and raises ReportingScopeNotGranted with the friendly message. Day-of- scope the same code path will fire and run the actual report — if FW's request shape differs from our spec, ColumnMap + JobSpec serialisation have explicit edit points. Docs: README live-coverage matrix updated: get_media_buy_delivery / get_packages_snapshot: ⏳ stub → 🟡 wired (reads cache; populated by sync once scope lands) make quality: 4,465 passed / 14 skipped / 19 xfailed (gained 27 tests). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(freewheel): stop false-zero delivery webhooks when reporting cache is empty Brian flagged that the DeliveryWebhookScheduler runs hourly automatically, calls adapter.get_media_buy_delivery() per active media buy, and fires webhooks to buyers — but our FW adapter was returning zero-delivery responses when the placement_stats cache was empty (which is the steady state today, since the Reporting API scope is still pending). Buyers polling AdCP or subscribing to delivery webhooks would see fake "delivering=0 impressions" signals every hour, which is misleading. Fix: introduce a soft-error signal that distinguishes "integration healthy, no data YET" from "integration broken": - New AdServerAdapter base exception DeliveryDataUnavailable. Adapters raise it when they have no data to report but nothing is actually wrong upstream — typical causes: cache not yet populated, upstream reporting scope still pending. Shareable across adapters. - FreeWheelAdapter.get_media_buy_delivery now raises DeliveryDataUnavailable when the placement_stats cache has no rows for the requested insertion order, instead of returning zeros via the base _empty_delivery_response helper. - _get_media_buy_delivery_impl catches DeliveryDataUnavailable separately from the generic adapter-error catch-all. The clean error surfaces as a GetMediaBuyDeliveryResponse with errors=[Error( code="data_unavailable")] — no audit log, no warning-level noise, just an info-level "data not yet available" log. - DeliveryWebhookScheduler's soft-skip set widened from just {"media_buy_status_excluded"} to also include "data_unavailable" — same info-level skip, no false-zero webhook fires. Test (tests/unit/test_freewheel_reporting_cache.py): the empty-cache test flipped from "returns zero response" to "raises DeliveryDataUnavailable with media_buy_id set". This pins the contract that AdCP layer + scheduler depend on. Defer-list captured in expanded comment on #382: the proper fix for this whole area (per-adapter buttons → shared scheduler + uniform adapter contract + /admin/scheduling page) is significant scope and should be its own PR after #381 merges. This change is the minimum-surgical fix to stop bad signals today. make quality: 4,465 passed / 14 skipped / 19 xfailed. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel ui): proactive scope-pending banner on adapter settings page Operators were finding out about missing FW scope two ways: by clicking "Check API Permissions" (which they wouldn't do unprompted), or by a buyer reporting "I'm seeing data_unavailable for delivery." Both are surprises. Surface the state on page load instead. Adds a banner above the FW configuration form that auto-runs check_permissions and renders one of three states: - (no banner): fully_operational, nothing surprising. - (warn, amber): reporting scope is denied. Banner explains buyers will see data_unavailable until granted; other features work normally. Reporting is technically a "nice-to-have" probe in our report shape, but its absence has real operator-visible consequences worth flagging. - (error, red): a required probe failed. Banner lists the missing features. Other denied nice-to-haves alone (targeting_profiles, audiences, webhooks) don't trigger the banner — they're true optionals and would become noise. They remain visible in "Check API Permissions" for operators who care. Banner has a "See full permissions checklist →" link that scrolls down and triggers the existing on-demand probe so the operator sees the full per-endpoint breakdown. Quietly no-ops on auth-level failure / no creds (those are surfaced by the credentials section already) and on transient probe failures (page should still load). make quality: 4,465 passed. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(freewheel): wire creative trafficking + stale-cache freshness banner Two gaps closed in one commit so the FW adapter is feature-complete for the buyer-facing flow (create buy → traffic creatives → see delivery) before #381 merges. ### Creative trafficking — end-to-end Earlier we proved /services/v4/creative_instances POST works (via Brian's docs-read showing ad_id = ad_unit_node_id). Now actually wired: - src/adapters/freewheel/_creatives.py: added create_creative, delete_creative, create_creative_instance, delete_creative_instance. Two wire-shape gotchas captured (verified live against Talpa): * POST creative_resources: body must be wrapped under {"creative": {...}}; flat body returns 400 "Creative Node is missing". Response is doubly-wrapped: {"data": {"creative": {...}}}. * POST creative_instances: ``ad_id`` is FW's param name but its docs say "The Ad Unit Node ID to link Creative." Response auto- populates placement_id (FW derives it from the ad_unit_node). - src/adapters/freewheel/adapter.py: * add_creative_assets: POSTs one creative_resource per AdCP asset, stamps the AdCP id onto FW external_id for lineage, returns AssetStatus(creative_id=, status="approved"). * associate_creatives: looks up ad_unit_node_ids per placement from the inventory cache, POSTs one creative_instance per (node, creative) pair. Per-binding result rows so callers see partial successes. Skipped placements (no cached ad_unit_nodes → run inventory sync first) get a clear message rather than silent failures. Live cycle verified end-to-end against Talpa: create_creative → create_creative_instance → delete_creative_instance → delete_creative, all clean. ### Stale-cache freshness banner Two new repository methods (latest_sync_at) on the inventory + placement- stats repos. New GET /api/tenant//adapters/freewheel/cache-freshness endpoint returns last_synced_at + age_seconds + stale flag + threshold for both caches. Threshold defaults: 24h inventory, 2h reporting. UI: second banner above the FW config form (alongside the scope-pending banner). Renders only when something needs flagging: - blue (info): cache never synced — onboarding gap - amber (warn): cache stale — sync probably broken - no banner: everything fresh ### Test infra cleanup Extracted tests/helpers/freewheel_adapter_patches.py::patch_freewheel_db so the same FreeWheelInventoryRepository + get_db_session monkeypatch block isn't duplicated across test modules. Both test_freewheel_adapter.py and test_freewheel_creative_trafficking.py now use the helper. Duplication guard happy again. ### Tests - tests/unit/test_freewheel_creatives.py: +4 cases pinning the write surface (wrapped POST body, ad_id semantics, DELETE paths). - tests/unit/test_freewheel_creative_trafficking.py: 9 cases — dry-run + live + fan-out + partial-failure + missing-inventory-skip. - All existing tests still pass via the shared helper. make quality: 4,477 passed (gained 12). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * fix(audit): stop double-encoding audit_logs.details + repair migration (#409) * fix(audit): stop double-encoding audit_logs.details + repair migration log_security_violation passed details=json.dumps({...}) into the JSONB column. JSONType.process_bind_param then serialized that already-stringified JSON again, so the row landed with a JSONB value of type 'string' instead of 'object'. Strict readers (notably tenant_export.py) refuse those rows, which blocked tenant exports on every tenant that had ever had a security violation logged — ~1,272 rows across multiple production tenants. Fix: - src/core/audit_logger.py:282 — pass the dict directly; JSONType handles serialization. One-line change. - alembic migration s1t2u3v4w5x6 — repair existing rows with UPDATE audit_logs SET details = ((details::jsonb) #>> '{}')::jsonb WHERE details IS NOT NULL AND jsonb_typeof(details::jsonb) = 'string'; Idempotent: re-running matches zero rows on a clean DB. Downgrade is intentionally unsupported (re-encoding would re-introduce the bug); raises NotImplementedError with an explanation rather than silently corrupting. - tests/integration/test_audit_logger_details_shape.py — regression test asserting log_security_violation persists details as a JSONB object, not a JSON string. Checks both the ORM read (dict) and Postgres jsonb_typeof = 'object'. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(migration): make s1t2u3v4w5x6 downgrade a true no-op The previous downgrade raised NotImplementedError to refuse re-corrupting repaired rows, but that broke test_managed_tenant_migrations_roundtrip which drives the chain backward to verify reversibility. Replace the raise with a SQL NOTICE. The body stays non-empty (migration- completeness guard happy), the data fix stays in place on downgrade (repaired rows are schema-compatible with all prior revisions), and the roundtrip test can step through. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * fix(migration): reconcile divergent alembic heads from PR #381 + #409 (#412) PR #381 (freewheel placement stats, head 190d6e98754b) and PR #409 (audit_logs.details repair, head s1t2u3v4w5x6) both branched off r0s1t2u3v4w5 and merged into main ~15 minutes apart. The second to land didn't rebase onto the first, so main shipped with two alembic heads. `alembic upgrade head` refuses a divergent graph without an explicit target, so the embedded-salesagent migrate job exits 1 and crashloops on rollout (helm reports BackoffLimitExceeded). Adds an empty merge revision (d0c3c40fdd41) with both heads as parents. Pure graph reconciliation — no schema or data changes. Merge migrations are exempt from the migration-completeness guard by design (tests/unit/test_architecture_migration_completeness.py:6). After this lands, `alembic heads` returns a single head and the migrate job can `upgrade head` without ambiguity. Verified: - `uv run alembic heads` → d0c3c40fdd41 (head) [single] - test_architecture_single_migration_head ✓ - test_architecture_migration_completeness (6 tests) ✓ Co-authored-by: Claude Opus 4.7 (1M context) * feat(adapters): shared sync orchestration + uniform contract (#382) (#411) * feat(adapters): uniform sync contract on AdServerAdapter (Stage 1 of #382) Defines what the shared AdapterSyncScheduler (later stages) will call on every adapter. Today's per-adapter sync surfaces (GAM's background_sync_service hardcoded to GAM, FreeWheel's per-adapter buttons) will all migrate behind this contract in Stages 2-3. Adds: - AdapterCapabilities.supports_reporting_sync flag. Distinct from supports_realtime_reporting (which is a buyer-facing capability the scheduler doesn't care about); this controls whether the scheduler should periodically run adapter.run_reporting_sync(). Default False so adopting the contract is opt-in. - AdapterSyncResult dataclass — uniform return shape for both sync kinds. counts (free-form per-kind tally) + errors (partial-failure capture) + metadata (job_id for reporting, etc.) so the scheduler can persist results without knowing per-adapter internals. - AdServerAdapter.run_inventory_sync() / run_reporting_sync() with NotImplementedError defaults whose messages tell operators how to fix it ("override the method, or flip the capability flag off"). - AdServerAdapter.latest_inventory_sync_at() / latest_reporting_sync_at() returning None by default. Adapters with caches override to expose the most-recent last_synced_at — uniform freshness signal for the /admin/scheduling UI. Test plan (tests/unit/test_adapter_sync_contract.py — 9 cases): - AdapterSyncResult math + metadata - Capability flag defaults + independence - Default NotImplementedError raises with actionable message - Default freshness accessors return None - Override returning AdapterSyncResult is accepted as the contract Also pulls in a pre-existing reformat of embedded_tenant_guard.py that ruff flagged on a fresh main checkout. make quality: 4,500 passed / 14 skipped / 19 xfailed. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(adapters): wire FW + GAM to the sync contract (Stage 2 of #382) FW implements the full contract; GAM declares capabilities + freshness accessor. Their actual sync methods stay as-is — Stage 3 will refactor background_sync_service to dispatch through the contract uniformly. ### FreeWheel — full contract - supports_inventory_sync (already on) + supports_reporting_sync (new). - run_inventory_sync(): wraps FreeWheelInventorySync, converts its internal SyncResult to AdapterSyncResult. Dry-run returns a soft- failed result so the scheduler doesn't try to interpret an exception. - run_reporting_sync(): wraps FreeWheelReportingSync. Catches ReportingScopeNotGranted specifically and surfaces metadata.scope_pending =True so the shared scheduler can render that differently from a generic failure. Catches the broader ReportingError too. - latest_inventory_sync_at(): reads FreeWheelInventoryRepository. latest_sync_at() (already exists from PR #381). - latest_reporting_sync_at(): reads FreeWheelPlacementStatsRepository. latest_sync_at() (same). ### Google Ad Manager — capability + freshness only - supports_inventory_sync flipped on; supports_reporting_sync stays False because GAM doesn't have a separate reporting sync — line item stats are written by gam_orders_service inside the inventory sync run. - latest_inventory_sync_at(): reads the most-recent completed SyncJob row for this tenant. The existing background_sync_service already writes there, so we get freshness for free. - run_inventory_sync() NOT implemented yet — GAM's sync is async/ threaded and refactoring it cleanly to the synchronous contract is Stage 3. Today calling it raises the base NotImplementedError; Stage 3 makes it work. ### Tests tests/unit/test_freewheel_sync_contract.py (6 cases): - Capabilities declared correctly - Dry-run returns soft-failed AdapterSyncResult (both kinds) - Scope-pending caught → metadata.scope_pending=True - Freshness accessors wire through to the right repos make quality: 4,506 passed (gained 6 over Stage 1). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(adapters): shared adapter sync orchestration (Stage 3 of #382) Routes the FreeWheel per-adapter sync buttons through a new shared ``adapter_sync_orchestration`` service that owns AdapterConfig lookup, adapter construction, SyncJob persistence, and uniform error surfacing. Stage 4 (the /admin/scheduling page) reads from the SyncJob table and the Stage 5 scheduler will call the same orchestration on a cadence — both get every adapter's sync runs for free now. ### Orchestration (``src/services/adapter_sync_orchestration.py``) - ``execute_sync(adapter, tenant_id, sync_kind, triggered_by, ...)`` — creates a SyncJob row (status="running"), dispatches to ``adapter.run_inventory_sync()`` or ``run_reporting_sync()``, persists the AdapterSyncResult onto the row (status="completed"/"failed", counts + errors + metadata in progress JSON, first error stamped to error_message). Catches adapter exceptions defensively so a misbehaving adapter doesn't break the scheduler loop. - ``execute_adapter_sync(tenant_id, adapter_type, sync_kind, triggered_by, run_kwargs)`` — convenience wrapper that resolves AdapterConfig + constructs the adapter, then calls execute_sync. The entry point per- adapter buttons + the scheduler both go through. - ``AdapterDoesNotSupportSyncKind`` raised when capabilities flag is off — fail-fast at the boundary so it's a 4xx-shaped error, not a 5xx. - ``SyncExecutionResult.scope_pending`` convenience property reading metadata.scope_pending so UI can render the awaiting-scope state. - Supports ``run_kwargs`` so adapter-specific run params (FW's placement_ids / start_date / end_date) flow through. ### FreeWheel endpoint refactor (``src/admin/blueprints/adapters.py``) - ``sync_freewheel_inventory`` + ``sync_freewheel_reporting`` no longer instantiate FreeWheelInventorySync / FreeWheelReportingSync directly — they call ``_execute_freewheel_sync`` which dispatches through the shared orchestration. Response shape preserved so the existing FW settings UI still works. ``sync_id`` added to the response so future UIs (Stage 4) can link from the FW page to the SyncJob detail view. - ``run_reporting_sync`` on the adapter now accepts ``placement_ids``, ``start_date``, ``end_date`` kwargs (forwarded by the orchestration's ``run_kwargs``). The contract method declares them; FreeWheelReportingSync already supported them. ### GAM (unchanged in this stage) GAM's async ``background_sync_service`` still owns its own SyncJob row writes — the two patterns coexist and write to the same table so the Stage 4 UI sees a unified feed. Migrating GAM behind the synchronous contract requires a chunkier refactor (threaded → coroutine, progress polling) and is deliberately deferred. ### Tests - tests/unit/test_adapter_sync_orchestration.py (3 cases) — capability gating + unknown sync_kind rejection. Pure unit, no DB. - tests/integration/test_adapter_sync_orchestration.py (3 cases) — SyncJob persistence on success, on soft failure (scope_pending), and on adapter exceptions. - Shared helper at tests/helpers/sync_orchestration.py to satisfy the DRY guard (mock-adapter construction was identical across the two). make quality: 4,509 passed (gained 9 over Stages 1+2). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(admin): cross-tenant adapter sync scheduling page (Stage 4 of #382) Adds /admin/scheduling — super-admin view of every configured (tenant, adapter, sync_kind) triple with last-run status, freshness verdict, and a Run Now button that dispatches through the Stage 3 orchestrator. Builds on Stages 1-3: * Stage 1 contract defined supports_inventory_sync / supports_reporting_sync * Stage 2 wired FW (both kinds) + GAM (inventory only) to the contract * Stage 3 introduced execute_adapter_sync / SyncJob persistence Pieces: - SyncJobAdminRepository — cross-tenant queries (latest_per_kind, latest_for_triples, list_recent). Distinct class so the existing SyncJobRepository keeps its tenant-isolation invariant. - AdapterConfigAdminRepository — list_all() joins Tenant.name for the matrix's display column. - src/services/sync_scheduling_view.py — assembles SchedulingRow list driven by AdapterCapabilities flags (capability gating happens at the matrix layer, not at the per-row template). - src/admin/blueprints/scheduling.py — GET /admin/scheduling, GET /admin/api/scheduling/jobs (matrix JSON for refresh), GET /admin/api/scheduling/recent (history log), POST /admin/api/scheduling/run (dispatches via execute_adapter_sync). - templates/scheduling.html — table + in-page JS refresh on Run Now click. Surfaces scope_pending separately (503) from generic failure (500). - Super-admin nav link added to templates/base.html. Freshness thresholds (24h inventory, 2h reporting) match adapters.py::freewheel_cache_freshness so the per-tenant and cross-tenant views agree. A failed run leaves stale=True since the underlying cache wasn't refreshed. Tests: 12 unit (capability filtering, stale verdict, dict shape) + 7 integration (cross-tenant queries, matrix end-to-end, stale verdict against backdated rows) + 8 endpoint (auth gating, JSON shape, Run Now dispatch + scope_pending + unconfigured tenant). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(scheduler): hourly cross-tenant reporting sync (Stage 5 of #382) Adds AdapterReportingSyncScheduler — fixed-interval (1h default, configurable via ADAPTER_REPORTING_SYNC_INTERVAL) loop that: 1. Lists every (tenant, adapter) pair whose adapter declares supports_reporting_sync=True (FW only today). 2. Skips tenants whose last successful reporting sync is within REPORTING_STALE_AFTER (2h) — keeps the scheduler off the freshness threshold's hot path and prevents thundering-herd retries. 3. Skips tenants with an in-flight (status=running) sync — don't pile on if a previous cycle is still working. 4. Dispatches eligible pairs through execute_adapter_sync() so the resulting SyncJob rows show up in the Stage 4 /admin/scheduling view. Wired into core/main.py's _start_schedulers / _stop_schedulers so the lifespan hook on adcp.server.serve() boots it alongside the existing delivery-webhook + media-buy-status schedulers. Stage 3 sidefix: removed the bogus tenant_id= kwarg from the stub Principal construction in execute_adapter_sync(). Principal schema doesn't accept tenant_id and Pydantic's extra=forbid raised ValidationError on every scheduled run. Tests: 10 unit (eligibility filtering, run_once dispatch, lifecycle) + 5 integration (real-DB eligibility queries, end-to-end run_once against a stub adapter wired through the real orchestrator). DRY: extracted ``cancel_scheduler_task`` to _scheduler_lifecycle.py rather than inline the cancel/await/CancelledError dance — the existing two schedulers' grandfathered duplication stays, but new schedulers use the helper. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(#382): address expert review feedback Three reviewers (code, security, ad-tech product) flagged 10 items on PR #411. All addressed: Blockers: * Fix raw select(SyncJob) in GAM adapter latest_inventory_sync_at() — use new SyncJobRepository.latest_completed_at() instead. * Refactor execute_sync to use a proper `with get_db_session()` block via a helper that takes the Session — no more manual __enter__()/close. Security: * triggered_by_id captures the super-admin's email so SyncJob rows carry per-actor audit attribution for cross-tenant Run Now actions. * New _sanitize_error_message() strips PEM blocks, JWTs, and refresh_token/api_key key-value strings before persisting to SyncJob.error_message — bounds the field at 500 chars as a second line of defense against credential bleed in the cross-tenant scheduling view. Product / UX: * Three-state freshness (ok / warning / critical) replaces the binary stale flag. ok = within warning window; warning = past warning, not yet critical; critical = past critical window OR failed OR never run. Old `stale` property kept as back-compat alias. * Per-adapter freshness thresholds on AdapterCapabilities (inventory_freshness_warning/critical, reporting_freshness_warning/ critical). Each adapter picks its cadence; the matrix reads it directly instead of using module-level constants. * GAM rows now carry a notes="reporting bundled with inventory sync" string driven by capabilities.reporting_bundled_with_inventory, so admins don't see "no reporting row" and worry. * Run Now is now async: enqueue_adapter_sync pre-creates a SyncJob with status="queued", returns the sync_id immediately, and the daemon thread does the work. Endpoint returns 202; UI polls /admin/api/scheduling/recent for terminal state. Code quality: * Extract _patch_eligibility_layer helper in the scheduler unit tests — replaces 5 near-identical 3-line monkeypatch blocks. * SyncExecutionResult.to_json_payload() factors the canonical JSON body so future adapter buttons reuse one shape. * Reporting sync scheduler also skips ``status=queued`` rows so the scheduler can't race the async enqueue dispatch path. Tests: +5 sanitizer/payload unit tests, +1 enqueue integration test, +1 queued-row skip test. Existing tests adjusted for three-state freshness verdict and 202 response shape. 4541 unit + 25 integration all green. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * feat(admin): surface 500 tracebacks + warn on missing storefront prefix (#413) * chore(main-unblock): fix-format embedded_tenant_guard.py ``make quality`` fails on a stale format-check for src/core/database/embedded_tenant_guard.py — landed unformatted in PR #408. Re-running ``ruff format`` collapses two multi-line conditionals to single lines (cosmetic; behavior unchanged). (An earlier draft of this commit also added a no-op alembic merge migration for the two divergent heads from PR #381 + PR #409, but PR #412 landed the same fix on origin/main in the meantime — that migration was dropped on rebase.) Co-Authored-By: Claude Opus 4.7 (1M context) * feat(admin): surface uncaught exceptions + warn on missing storefront prefix Two diagnostic improvements to make the create-product-500-style failures visible. Reported from the agentic storefront iframe: POST /storefront/psa/tenant//products/add → HTML "Internal Server Error" The response body shape (`Error
Internal Server
Error
`) is Express's default 500 page — the storefront proxy rendered it, not salesagent. Salesagent itself had zero visibility: no 500 error handler was registered, so any Flask exception bubbled up to WSGIMiddleware → Starlette → uvicorn as a generic 500, and the upstream proxy substituted its own page on top. Result: we couldn't tell apart "salesagent threw" vs "storefront mistranslated salesagent's response," and there was no traceback to grep for. Change 1: register an @errorhandler(Exception) on the admin Flask app that logs the full traceback + request context (method, path, endpoint, tenant_id from view_args, user_email best-effort) with a short correlation ID, and returns a response that includes the ID. JSON clients (Accept: application/json) get a structured envelope. HTTPExceptions (404/403/etc.) pass through unwrapped — those are intentional, not internal errors. The handler swallows its OWN exceptions on the user/tenant lookup paths at logger.debug level so the OUTER traceback is what surfaces. Change 2: register a before_request hook that logs WARNING when embedded auth headers are present (X-Identity-Subject) but neither X-Forwarded-Prefix nor X-Script-Name is set. Per docs/integration/embedded-mode-identity-contract.md:124 the upstream proxy is required to send this header so url_for() generates URLs that resolve back through the storefront mount. Without it, a ``redirect(url_for("products.list_products", ...))`` emits ``Location: /tenant//products/`` — the iframe follows it to the storefront's own origin, which has no route at that path, and the storefront 500s on its own router. The warning surfaces the misconfiguration in salesagent logs so the storefront operator sees the gap instead of guessing why redirects land outside their iframe. The TESTING-config bypass on the warning keeps legacy tests quiet (they already match the pre-#32 contract). The 500 handler runs under TESTING because PROPAGATE_EXCEPTIONS=False explicitly opts back in. Tests (8 new in src/admin/tests/integration/test_admin_app.py): * TestUncaughtExceptionHandler (4): - synthetic crash returns 500 with Error ID body + handler logs GET/path/tenant_id=None/exc_info traceback - tenant-scoped crash captures tenant_id from view_args (acme) - Accept: application/json gets {error, error_id} envelope - 404 from werkzeug is NOT wrapped (HTTPException pass-through) * TestEmbeddedMissingPrefixWarning (4): - X-Identity-Subject without X-Forwarded-Prefix → WARNING logged - X-Forwarded-Prefix set → no warning - Plain non-embedded request → no warning - X-Script-Name (alternate header) also satisfies → no warning What this does NOT do: fix the underlying redirect bug. If the storefront proxy is missing X-Forwarded-Prefix, redirects still land outside the iframe mount. The salesagent can't safely infer the prefix from request data alone — that's the storefront integrator's fix per the documented contract. What this PR DOES is make the failure mode loud + diagnoseable instead of opaque. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * fix(tenant-export): suspend user triggers during bulk tenant delete (#414) The prevent_empty_pricing_options trigger (BEFORE DELETE on pricing_options) enforces "every product must have ≥1 pricing option" — sensible for piecemeal edits, wrong for bulk tenant deletes where the parent product is also being removed. Surfaced during the first production dry-run of import_tenant for tenant_wonderstruck: sqlalchemy.exc.InternalError: (psycopg2.errors.RaiseException) Cannot delete last pricing option for product prod_291a023d CONTEXT: PL/pgSQL function prevent_empty_pricing_options() [SQL: DELETE FROM pricing_options WHERE pricing_options.tenant_id = %(tenant_id_1)s] Other validation triggers may exhibit the same pattern; rather than enumerate each one, suspend ALL user triggers on tenant-scoped tables for the delete loop. FK and system triggers stay enabled (DISABLE TRIGGER USER preserves those), so referential integrity is unaffected. Fix: - _suspend_user_triggers(connection, table_names) context manager: ALTER TABLE ... DISABLE TRIGGER USER on enter, ENABLE on exit. DDL is transactional in Postgres, so a rollback restores triggers; the explicit re-enable in finally handles the commit case. Owner-level privilege (no SUPERUSER required, unlike SET session_replication_role). - delete_tenant_data wraps its delete loop in the context manager. Test coverage (tests/integration/test_tenant_export_import.py): - test_delete_succeeds_through_blocking_trigger: installs a trigger that mimics prevent_empty_pricing_options on pricing_options; verifies it fires on a piecemeal delete (sanity check), then verifies delete_tenant_data succeeds through it. - test_triggers_reenabled_after_delete: verifies user triggers fire normally for other tenants after delete_tenant_data returns. Co-authored-by: Claude Opus 4.7 (1M context) * fix(tenant-export): strip + remap autoincrement int PKs when retargeting (#415) Same-deployment clone via --target-tenant-id collided on autoincrement integer PKs (gam_inventory.id, audit_logs.log_id, etc.) because the bundle preserved values still owned by the source tenant. Surfaced during a clone test of tenant_wonderstruck: sqlalchemy.exc.IntegrityError: duplicate key value violates unique constraint "gam_inventory_pkey" DETAIL: Key (id)=(1449737) already exists. Cross-deployment moves (without --target-tenant-id) are unaffected — the target doesn't own those IDs. Fix: - _is_autoincrement_int_pk / _autoincrement_pk_column: detect single-column Integer/BigInteger PKs whose autoincrement is not explicitly False. Catches 13 tables in the current schema including explicit autoincrement=True columns and SQLAlchemy 2.0's "auto" default. - import_tenant: when target_tenant_id is set, strip those PK values from rows before insert (Postgres re-allocates from the sequence) and use INSERT ... RETURNING to build an old→new ID map per table. Walk FK graph for each child and rewrite any FK column whose target was remapped. Only one FK in the current schema points at an autoincrement int PK (products.inventory_profile_id → inventory_profiles.id) but the remap is generic. - target_tenant_id unset: behavior unchanged (PKs preserved). The pre-flight collision check still catches business-unique conflicts (subdomain, virtual_host, access_token). Tests (tests/integration/test_tenant_export_import.py TestCloneOnSameDeployment): - test_clone_with_source_alive_succeeds: seed source tenant with an inventory_profile and a product whose inventory_profile_id FKs to it. Rewrite subdomain + access_token in the bundle (real clone workflow), retarget to a new tenant_id while source is still alive, assert both tenants coexist, the clone got a NEW inventory_profile.id, and the clone's product.inventory_profile_id points at the clone's profile. - test_retarget_without_source_still_works: existing retarget-after- delete path still passes; FK referenced by the clone resolves to a profile in the clone, never to a stale source ID. Co-authored-by: Claude Opus 4.7 (1M context) * fix(tenant-export): remap globally-unique string PKs on retarget (closes #416) (#417) PR #415 handled autoincrement int PK collisions on same-deployment clones. Different column shape, same class of bug surfaced next: proposals.proposal_id is a globally-unique string PK so it can't strip-and-let-Postgres-allocate. RuntimeError: Insert failed on table 'proposals': duplicate key value violates unique constraint "proposals_pkey" DETAIL: Key (proposal_id)=(prop_1f5de7d5abb4) already exists. Affected single-column string PKs in this schema: proposals.proposal_id, media_buys.media_buy_id, creative_reviews.review_id, creative_assignments. assignment_id, webhook_subscriptions.webhook_id, sync_jobs.sync_id, contexts.context_id, workflow_steps.step_id, strategies.strategy_id, users.user_id. Fix: - _globally_unique_string_pk_column(table): detect single-column non- composite String/Text PKs. Excludes columns named tenant_id (e.g. adapter_config.tenant_id is the table's only PK and is set by _retarget_tenant_id upstream — remapping would overwrite that value with an uncorrelated UUID, breaking the FK to tenants). - _mint_id(old, column): generate a fresh opaque ID. Preserves any clean alphanumeric prefix (mb_, prop_, etc.) so logs stay readable. Falls back to bare UUID4 hex when no prefix. Truncates to column length. - _build_string_pk_remap: eagerly mint new IDs for every affected table before any insert. String PKs can't be allocated by Postgres so the map must exist up-front; the unified FK-rewrite loop then handles both int (lazy, filled via RETURNING) and string (eager) remaps generically. - import_tenant: applies own-PK rewrite for tables with string PKs in addition to the existing FK-rewrite + int-PK strip/RETURNING flow. Composite PKs that include tenant_id (products(tenant_id, product_id), principals(tenant_id, principal_id), creatives(...), accounts(...), agent_account_access(...), currency_limits(...)) are unaffected because changing tenant_id already changes the tuple uniqueness. JSON-embedded ID references are still out of scope (documented limitation in #416). Operators doing same-deployment clones for staging snapshots should grep their JSON content for stale IDs if they need full fidelity. Tests (tests/integration/test_tenant_export_import.py TestCloneOnSameDeployment): - test_clone_remaps_string_pk_and_rewrites_fk: seed source with a media_buy + two media_packages whose composite PK references media_buy_id. Rewrite subdomain + access_token in the bundle. Retarget to a new tenant_id with source alive. Assert clone gets a NEW media_buy_id with the "mb_" prefix preserved, both packages re-link to the clone's media_buy_id (composite PK uniqueness preserved via FK rewrite), source rows untouched. Closes #416. Co-authored-by: Claude Opus 4.7 (1M context) * feat(admin): add ALLOW_SIGNUPS env var to close self-service registration (#418) Hosted cluster needs to stop accepting new signups while existing tenants are migrated. ALLOW_SIGNUPS=false renders a "signups closed" page on /signup and short-circuits /signup/start, /signup/onboarding, and /signup/provision. Defaults to true so existing deployments are unaffected. Co-authored-by: Claude Opus 4.7 (1M context) * feat(admin): hide Buyer Agents tab on embedded; rename Settings → Tenant Settings (#420) Sprint 7 IA cleanup — Phase 1a + 1b. On embedded tenants the Settings → Buyer Agents tab showed only read-only data already surfaced (and editable) on Buyer Routing, plus a "platform-managed" banner — duplicate noise rather than a useful surface. Hide the sidebar nav tab and the ``
`` section entirely on embedded. Rename the Configure menu entry "Settings" → "Tenant Settings" so its scope is unambiguous before later phases empty it out further. Reverses the Sprint 4 "read-only directory stays visible permanently" call (docs/design/embedded-mode-sprint-4-ui-hardening.md "Terminology pin") now that Sprint 5 made Buyer Routing the canonical home for advertiser→buyer-agent mappings. Also fixes a Phase-1a-introduced bug: the setup checklist ``principals_created`` task pointed to ``/settings#advertisers`` (now hidden on embedded) and told operators to do something they can't (Principal provisioning is platform-managed). Skip the task on embedded in both ``_check_critical_tasks`` and ``_build_critical_tasks``. New design doc captures the full IA endgame and remaining phases (entity promotion to Configure peers, fold-ins, hide Tenant Settings entirely once it's down to Account + Ad Server + Danger Zone). Co-authored-by: Claude Opus 4.7 (1M context) * fix(admin): scope dashboard activity ledger to last 7 days (#421) The "Last 7 days" label on the tenant dashboard activity ledger was purely cosmetic — `_activity_ledger` ran `select(AuditLog) ORDER BY timestamp DESC LIMIT 8` with no time bound, so a quiet tenant would show months-old entries. The "time" column also only rendered HH:mm, which made an event from December look like one from today. - Filter via AuditLogRepository.list_filtered(from_date=now-7d) - Render same-day rows as HH:mm, older rows as "Mon DD HH:mm" - Drop the now-unneeded raw-select allowlist entry Co-authored-by: Claude Opus 4.7 (1M context) * fix(admin): exempt S2S API blueprints from cross-origin CSRF guard (#423) * fix(admin): exempt S2S API blueprints from cross-origin CSRF guard The global before_request CSRF guard in src/admin/app.py rejected POSTs to /api/v1/tenant-management/* and /api/v1/sync/* when the caller (an internal service) did not set Origin or Referer headers. These blueprints are header-authed (X-Tenant-Management-API-Key / X-API-Key) and set no session cookie, so cookie-riding CSRF cannot apply. The per-route @require_api_key_auth decorator still enforces the API key, so bypassing the Origin check does not weaken auth. Bypass is path-based (not header-based) so an attacker can't escape CSRF on cookie-authed admin routes by forging a header. Observed symptom: agentic-api provisioning calls failed with "Refusing cross-origin admin POST to /api/v1/tenant-management/ tenants/provision — origin=None referer=None" / HTTP 403. * chore(admin): address review notes on CSRF guard docs - Drop stale `_SAME_ORIGIN_HEADERS` reference in the TESTING bypass comment (symbol doesn't exist anywhere in the tree). - Correct the parenthetical on `test_cookieless_post_bypasses_csrf` — FlaskClient does persist cookies across requests; the test relies on per-test fixture freshness, not on the client being stateless. Surfaced by independent review on PR #423. No behavior change. * refactor(admin): drop redundant X-Identity-Subject CSRF bypass (#424) The cookie-presence structural bypass added in #423 already subsumes embedded mode: per docs/integration/embedded-mode-operational.md §4, embedded-mode auth is stateless via X-Identity-* headers and the upstream proxy never sets a session cookie on the salesagent. The explicit X-Identity-Subject branch was belt-and-suspenders for a scenario that the documented contract forbids. Tests stay green — the embedded-mode regression test now covers the same shape (X-Identity-Subject + no cookie) via the cookieless bypass instead of the dedicated branch. Docstring updated to explain the new reasoning. Co-authored-by: Claude Opus 4.7 (1M context) * feat(springserve): add SpringServe (Magnite) ad-server adapter — direct CTV/OLV/audio integration for Talpa (#427) * feat(springserve): add SpringServe (Magnite) adapter — Stage 1 skeleton + auth Direct-to-ad-server adapter for SpringServe at console.springserve.com, positioned as the publisher-side path that avoids Magnite's SSP-level AdCP agent fees. First customer is Talpa Network for audio inventory (Radio 538 / Sky Radio / Radio 10) with video as the strategic priority. Stage 1 scope: - Email + password authentication with 2-hour token cache (POST /api/v0/auth) - Raw token in Authorization header (not Bearer — SpringServe quirk) - Connection + product config schemas with Fernet-encrypted secrets - Static VAST format declarations: 6 video + 4 audio (audio = first-class, same demand-tag API surface, MIME-discriminated via Format.type) - AdServerAdapter subclass with dry-run for every method; live paths return pending_credentials until Stage 2 wires the writes - Permission probe matrix covering campaigns, demand_tags, videos, supply_tags, supply_partners, report - Live smoke test verified against operator's account: ✅ auth + campaigns + demand_tags + videos ❌ supply_tags + supply_partners (scope grant pending) ⏳ report (POST-only; Stage 4 replaces probe shape) Shared helpers extracted to satisfy the DRY guard: - src/adapters/_secret_fields.py — Fernet encrypt/decrypt helpers - src/adapters/_format_helpers.py — vast_format() builder - src/adapters/_token_cache.py — BearerTokenCache - AdServerAdapter._new_permissions_report + _walk_permission_probes - tests/helpers/adapter_test_helpers.py — sample request/package factories and stub_http_response FreeWheel adapter refactored to consume the same helpers — no behaviour change, just deduplication. Plan in .context/springserve-adapter-plan.md tracks Stages 2-5 (live Campaign + DemandTag writes, creatives, reporting cache, inventory cache + admin UI). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(springserve): Stage 2 — live Campaign + Demand Tag writes Wires the full create_media_buy → check_status → pause/resume cycle against the live SpringServe API. Mapping A: AdCP MediaBuy → SpringServe Campaign AdCP Package → SpringServe Demand Tag (one per package, parented to the campaign by ``campaign_id``) media_buy_id → ``springserve_`` New entities/clients (shapes captured from live probes against the operator's account on 2026-05-14): - ``entities.Campaign`` + ``entities.DemandTag`` Pydantic models with ``extra="allow"`` so the 38-field Campaign + 220-field DemandTag responses round-trip without losing data. - ``SpringServeCampaignsClient`` — POST / GET / PUT / DELETE /campaigns. - ``SpringServeDemandTagsClient`` — POST / GET / PUT / DELETE /demand_tags. Auto-flips ``country_targeting`` to "White List" when ``country_codes`` is set; coerces rate to string ("27.0"); formats start_date/end_date in SpringServe's ISO-microsecond-Z convention. - ``targeting.build_demand_tag_targeting`` — flattens AdCP geo/device overlays onto demand-tag fields directly (NOT a wrapper "targeting" dict; SpringServe doesn't have one). Supply targeting goes through ``demand_tag_priorities: [{supply_tag_id, priority, tier}]``. Adapter behaviour: - ``create_media_buy`` POSTs one campaign + N demand tags, all created paused (Stage 3 binds creatives and flips them active). - ``check_media_buy_status`` reads the campaign and maps ``is_active`` to AdCP status (active/paused). - ``update_media_buy`` supports pause_media_buy / resume_media_buy (campaign-level) and pause_package / resume_package (demand-tag level, found by ``secondary_code=package_id`` scan of the campaign's demand tags). update_package_budget returns ``unsupported_action`` (Stage 4 wires the budgets nested object). - Audio vs video routing comes from the AdCP Format's id prefix (``springserve_audio_*`` → demand_tag.format="audio"); no denormalised flag. Live scope status — POST scope is NOT yet granted on the operator's test account. Stage 2 unit tests verify the code with a mocked client; the live cycle test skips with a clear scope-grant message until SpringServe enables write access. README documents the exact ask. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(springserve): Stage 3 — creatives via POST /videos + demand-tag binding Wires ``add_creative_assets`` and ``associate_creatives`` against the SpringServe ``/videos`` endpoint. SpringServe hosts both video and audio on the same endpoint, discriminated by ``creative_format`` (``video`` | ``audio``) and ``creative_content_type`` (``video/mp4``, ``audio/mp4``, ``audio/mpeg``). - ``SpringServeCreativesClient`` — POST / GET / PUT / DELETE /videos. Uses the remote-URL ingest path (``creative_remote_url``); SpringServe pulls and transcodes the hosted asset. Multipart upload (≤500MB) is available on the same endpoint but deferred until needed. - ``entities.VideoCreative`` — Pydantic model with ``extra="allow"`` so the 66-field response round-trips losslessly. The "VideoCreative" ``type`` label is preserved as SS-internal metadata even on audio. - Adapter routing — ``_asset_media_type()`` reads the AdCP Format's id prefix (``springserve_audio_*``) and the asset's own ``content_type`` hint; produces matching SpringServe MIME types. No denormalised flag. - ``associate_creatives`` writes the single-creative path (``demand_tag.creative_id``) and flips the tag active. Multiple creatives per tag get the LAST one wired; earlier ones are recorded as ``skipped`` with a message. Rotation via ``line_item_ratios`` is deferred. Stage 3 status: code complete, blocked on the same write-scope grant that blocks Stage 2 (POST /videos returns 403 today). The live cycle test (Campaign → DemandTag → Creative → bind → cleanup) skips with a clear scope-grant message until SpringServe enables write scope. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(springserve): Stage 4 — reporting cache + sync Wires get_media_buy_delivery + get_packages_snapshot to read from a local ``springserve_demand_tag_stats`` cache populated by a periodic SpringServe Reporting API sync. Empty cache raises ``DeliveryDataUnavailable`` (matches the FreeWheel contract — no fake zeros). New surfaces: - Alembic migration ``ss01a1b2c3d4`` creating the ``springserve_demand_tag_stats`` table keyed by ``(tenant_id, demand_tag_id)``. Spend in currency-minor-unit micros. - ORM model + ``SpringServeDemandTagStatsRepository`` (tenant-scoped reads, ON CONFLICT bulk upsert, latest_sync_at). - ``SpringServeReportingClient`` — sync POST /report + async submit + poll-until-done + fetch-rows. ColumnMap-driven row parsing so the day-of-scope schema reveal is a config tweak, not a code change. - ``SpringServeReportingSync`` orchestrator. Picks sync vs async based on window length (>1 day → async). Translates SpringServeForbiddenError into a clean ``ReportingScopeNotGranted`` for the scheduler. - Adapter ``run_reporting_sync`` returns ``AdapterSyncResult`` with ``scope_pending`` metadata when the grant isn't there yet, so the shared scheduler keeps trying without exception spam. - Adapter ``latest_reporting_sync_at`` surfaces freshness for the ``/admin/scheduling`` page. Refactors that fell out (DRY-driven): - ``AdServerAdapter._aggregate_stat_rows_to_delivery_response`` — shared by FreeWheel + SpringServe to turn ORM stat rows into an ``AdapterGetMediaBuyDeliveryResponse``. - ``AdServerAdapter._platform_status_to_delivery_status`` — shared string-to-enum translation for delivery status. - ``AdServerAdapter._wrap_sync_run`` — shared scope/error/result wrapping for ``run_reporting_sync``-style callables. Status: code complete; pre-flight on the same scope grant requested for Stages 2–3. POST /report returns 403 on the operator's account today; the sync raises ``ReportingScopeNotGranted`` and the read path raises ``DeliveryDataUnavailable`` until both are fixed by the scope grant + first sync run. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(springserve): Stage 5 — inventory cache + admin UI + typed embedder config Closes the full SpringServe adapter loop: operators can pick the adapter in tenant settings, save credentials, sync inventory, probe permissions, and configure products against synced supply tags. Embedders (Scope3 storefront) can provision SpringServe tenants through the typed discriminated-union AdapterConfig surface. New surfaces: - Alembic migration ``ss02e5f6a7b8`` creating the ``springserve_inventory`` table keyed by ``(tenant_id, entity_type, entity_id)``. JSONB raw_json on Postgres, plain JSON elsewhere. - ``SpringServeInventory`` ORM model + ``SpringServeInventoryRepository`` (tenant-scoped list/upsert/clear, latest_sync_at). - ``SpringServeSupplyClient`` — read-only client over /supply_partners + /supply_tags. - ``SpringServeInventorySync`` — paginated walker that upserts both entity types into the cache; raises ``SupplyScopeNotGranted`` cleanly when scope is denied. - Adapter ``run_inventory_sync`` + ``latest_inventory_sync_at`` + ``get_available_inventory`` (reads from cache, no live API calls). - ``SpringServeAdapterConfig`` in the discriminated-union ``AdapterConfig`` for the typed embedder API; secrets via SecretStr, model-validated exactly-one credential path. - Tenant Management API plumbing: catalog metadata + typed config registry + ``_adapter_config_to_dict`` + ``_persist_adapter_config`` (Fernet-encrypted round-trip through SpringServeConnectionConfig matching the FreeWheel pattern). - Admin UI templates: connection_config.html (form + Save + Test Connection + Sync Inventory + Check API Permissions buttons) and product_config.html (supply_tag + supply_partner pickers loading from the cache). - ``templates/tenant_settings.html`` picker card. - Blueprint endpoints under ``/api/tenant//adapters/springserve/``: test-connection, inventory, sync-inventory, check-permissions. - Regenerated ``docs/api/tenant-management-openapi.{json,yaml}``. Status: code complete; supply-side reads return 403 today so the live sync raises ``SupplyScopeNotGranted`` until SpringServe enables supply read scope on the operator's account. The product config UI pickers will show empty lists until the first successful sync. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(springserve): address review must-fix items Two parallel reviews (code-reviewer + security-reviewer) converged on the same must-fix list before shipping the SpringServe adapter: H1 — Replace MagicMock principal in check_springserve_permissions src/admin/blueprints/adapters.py Delete the redundant SpringServe-specific check-permissions handler. Permission probes now go through the generic /api/tenant//adapters//check-permissions endpoint, which constructs a real typed Principal stub instead of a MagicMock. The MagicMock would have returned truthy for every attribute access, silently masking tenant-isolation bugs if the adapter ever read a principal field outside the (very narrow) probe path. H2 — Remove ``self.tenant_id or "default"`` fallbacks in the adapter src/adapters/springserve/adapter.py Seven cache read/write sites had ``or "default"`` fallbacks. The base AdServerAdapter.__init__ already enforces tenant_id is set, so the fallback was dead code -- but if a real "default" tenant ever exists (it does for demo-mode), a regression that lost tenant_id would have silently read or upserted into another tenant's cache. Code-reviewer IMPORTANT #4 — add_creative_assets KeyError src/adapters/springserve/adapter.py asset["creative_id"] raised KeyError mid-loop, abandoning every remaining asset. Use asset.get("creative_id") with explicit empty- string fallback + an early failed-status append, so a bad asset only fails its own slot. Security-reviewer M1 — https-only allow-list for creative_remote_url src/adapters/springserve/adapter.py Buyer-supplied URLs are forwarded server-side to SpringServe's fetcher. Reject non-https schemes (file://, http://, ftp://) and obvious private hosts (localhost, 127.0.0.1, RFC1918 ranges) at the adapter boundary so we can't be turned into a free SSRF oracle by a malicious AdCP buyer. Tests added: 4 new cases covering missing creative_id, non-https URL, loopback URL, RFC1918 URL. All 40 SpringServe adapter unit tests pass; make quality green (4708 total). Remaining recommendations from the two reviews are backlog (inventory-cache pruning of deleted entries, targeting-discriminator auto-flip on escape-hatch payloads, audio content-type validation, note-field stored-prompt-injection mitigation, smoke-test SPRINGSERVE_TEST_ALLOW_WRITE confirmation env) -- tracked separately, not blocking on this PR. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(springserve): add springserve to catalog-assertion test sets The Stage 5 commit registered ``springserve`` in ``_ADAPTER_CATALOG_METADATA`` + ``_ADAPTER_CONFIG_TYPED`` (so the ``GET /api/v1/tenant-management/adapters`` endpoint returns it), but forgot to update the two integration tests that assert on the exact catalog set: - ``test_list_adapters_returns_supported_catalog`` - ``test_list_adapters_tier_filter_excludes_mock_from_live`` Both now include ``springserve`` in the expected sets. Verified both pass locally against agent-db. CI's "Integration (other)" job should go green on the next run. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * feat(proposal): adopt adcp 5.5.0 — framework derivation + PgProposalStore swap (supersedes #419) (#422) * feat(proposal): adopt adcp 5.5.0 framework package derivation adcp-client-python#732 shipped framework-level package derivation for ``create_media_buy(proposal_id=…)`` calls with empty ``packages[]``. ``maybe_hydrate_recipes_for_create_media_buy`` now distributes ``total_budget.amount`` across the reserved proposal's ``allocations[]`` by percentage and mutates ``req.packages`` before the seller adapter runs — opt-in via ``ProposalCapabilities.derive_packages_from_allocations``. Adoption is a 15-line diff: * Bump ``adcp>=5.5.0`` in ``pyproject.toml``. * Set ``derive_packages_from_allocations=True`` on ``SalesAgentProposalManager.capabilities``. Supersedes #406 (the local derivation helper + delegate hook). That PR shipped the same behavior as a salesagent-internal module on the bet that upstream auto-injection would land within a sprint — it did, faster than expected (5.5.0 tagged ~24h after PR #406 opened). The local code becomes dead weight the moment the capability flag flips, so adopting the upstream and closing #406 as superseded is the cleaner outcome. ## Why this PR is small The PR that opens this hole at the framework level is the bigger story: ``adcp-client-python#732`` ships **both** ``PgProposalStore`` (durable Protocol implementation, mirrors our ``SalesAgentProposalStore``) AND the derivation hook adopted here. We're taking the derivation half this PR and deferring the store swap to a follow-up: * ``PgProposalStore`` requires an ``AsyncConnectionPool`` (psycopg3). The psycopg3 pool is already in the dep tree (``IdempotencyStore.PgBackend`` uses it), but the pool lifecycle is wired for the idempotency surface only — adding a second consumer wants intentional design, not an incidental wire-up. * The schema migration to upstream's ``(account_id, proposal_id)`` PK + drop of our ``tenant_id`` FK column wants a cascade-delete decision we haven't made yet (FK on a hypothetical accounts table vs. application-layer cleanup vs. TTL expiry). adcp-client-python#738 filed to document the AccountStore-layer encoding seam clarifies the layering question; the cascade design is downstream of that. So: take the well-shaped half now, defer the half that wants design. ## Compliance impact After deploy, the compliance probe's ``media_buy_seller/proposal_finalize/create_media_buy`` storyboard should flip fail → pass — same outcome PR #406 would have produced, but with the framework owning the math instead of us. ## Reviewer cross-reference The derivation helper in PR #406 was reviewed twice (code-reviewer + security-reviewer) and the math + edge cases were verified. Upstream's ``derive_packages_from_proposal`` ships the same shape (pin-the-last absorber, 2dp rounding, allocation-sum guard, malformed-entry rejection) plus a few we didn't have (single ``pricing_options[]`` auto-pick on proposal persist when omitted, multi-option products requiring explicit selection). Net behavior is identical or stricter; no regression risk. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(proposal): swap SalesAgentProposalStore → upstream PgProposalStore Completes the adcp 5.5.0 adoption started in #419. adcp-client-python#732 shipped :class:`PgProposalStore` — the durable ``ProposalStore`` Protocol implementation our local :class:`SalesAgentProposalStore` (PR #390) was mirroring. With upstream owning the CAS, cross-tenant rejection, TTL bookkeeping, and ``ON CONFLICT`` upsert semantics, the salesagent implementation becomes ~280 LOC of code we don't need to maintain. ## Three design decisions, resolved **Async pool wiring.** Process-singleton ``AsyncConnectionPool`` modeled on the existing :mod:`core.idempotency` pattern. Lazy first-call open so the pool's worker tasks bind to ``serve()``'s loop, not whatever transient loop happens to construct the store. New module: :mod:`core.decisioning.proposal_store`. **Schema migration.** Drop our existing ``proposals`` table; rebuild matching upstream's expected shape — ``(account_id, proposal_id)`` PK, ``COLLATE "C"`` on text columns for byte-order index ordering, state ``CHECK`` constraint, partial ``ix_proposals_expires_at`` index for TTL sweep. PR #390 deployed ~24h before this migration with near-zero production data; acceptable loss. **Cascade-delete strategy.** ``tenant_id`` survives as a generated column derived from ``account_id`` via ``split_part(account_id, ':', 1)``. The existing FK + ``ON DELETE CASCADE`` to ``tenants`` stays intact, so :func:`scripts.seed_demo_tenant._delete_tenant_rows` and :func:`src.admin.tenant_management_api.delete_tenant` keep working without change. Consistent with the layering principle from adcontextprotocol/adcp-client-python#738: the encoding seam stays at :class:`SalesagentAccountStore.resolve()`, and we just leverage the same encoding inside our own table for our own FK target. ``PgProposalStore`` uses explicit-column INSERTs, so the generated column is invisible to upstream. ## What goes away * ``src/core/database/repositories/proposal_store.py`` — 547 LOC, including the wrong-layer ``_resolve_tenant_id_for_account`` parse flagged in adcp-client-python#738. The Protocol-level layering is now enforced by deletion. * ``src/core/database/models.py:Proposal`` ORM class — 56 LOC. The ``proposals`` table is now owned by ``PgProposalStore``'s psycopg3 path; no SQLAlchemy model needed. * ``tests/integration/test_proposal_store.py`` — 720 LOC, 16 tests. Upstream's conformance suite at ``tests/conformance/decisioning/test_pg_proposal_store.py`` covers the same invariants against real Postgres. * ``tests/unit/test_proposal_store_attributes.py`` — 20 LOC. Pinned ``is_durable``; upstream owns the attribute. Net delete: ~980 LOC across removed code + 363 LOC of new schema / wiring / migration = real shrinkage of ~620 LOC. ## Tenant export caveat Added ``proposals`` to :data:`tenant_export.EXCLUDED_TABLES`. The generated ``tenant_id`` column rejects direct ``INSERT`` writes (PG ``GENERATED ALWAYS``), and the alternative — strip-on-import + auto-derive — would carry stale in-flight proposal state across deployments that the target's ``PgProposalStore`` should re-mint via fresh ``get_products`` calls anyway. Documented inline. ## Verification * ``make quality``: 4545 passed, 14 skipped, 19 xfailed * ``tox -e integration``: 241 passed * Migration head: ``t2u3v4w5x6y7`` (single head, succeeds ``d0c3c40fdd41``) ## Stacks on #419 (adcp 5.5.0 bump + ``derive_packages_from_allocations=True``). This PR shares its base; merging order: #419 first, then this. Co-Authored-By: Claude Opus 4.7 (1M context) * review(proposal): address review notes on PR #422 Code-reviewer and security-reviewer both verdicted "ship as-is" with one should-fix and a couple of notes. Folding the should-fix in here; the two follow-ups go to separate tracking issues. ## Should-fix: integration_db.py missing proposal-store reset ``tests/fixtures/integration_db.py`` resets ``core.idempotency`` and ``src.core.signing.replay_store`` between per-test databases but had no equivalent for ``core.decisioning.proposal_store``. Same bug class as PR #134: the proposal-store singleton would cache an ``AsyncConnectionPool`` bound to test N's DSN, then test N+1 would acquire a connection to the (now-dropped) per-test DB and ``PoolTimeout`` after 30s. Latent today — the dedicated proposal-store integration suite went away with ``SalesAgentProposalStore`` — but the moment someone adds an integration test that hits a proposal-aware tool, they'd get a 30s-then-mysterious-failure. Cheap to fix preventively. Added ``_reset_proposal_store()`` mirroring the existing pair and wired it into both the setup and teardown reset blocks. ## Note: reset_for_tests docstring Security review flagged that ``reset_for_tests`` doesn't await pool close — true, and it's intentional (the workers are bound to a foreign loop, awaiting close from sync teardown would deadlock or orphan). Made the rationale explicit in the docstring and noted the test-side escape hatch (``await close_proposal_store()`` first if reusing the process). ## Deferred (separate issues) - **Compliance gap on tenant export** — proposals excluded from bundles (because the generated ``tenant_id`` column rejects direct INSERT). Drafts are ephemeral and committed proposals are referenced by ``media_buy_id`` which IS exported, so the privacy story is clean. GDPR right-to-portability / acquisition-transfer scenarios may want proposal history regardless; filing a follow-up issue rather than expanding scope here. - **CHECK constraint on tenants.tenant_id rejecting colons** — the generated-column FK design promotes ``account_id`` well-formedness to a tenant-isolation invariant. A tenant with a colon in its ``tenant_id`` could conflate with a non-compound ``account_id`` via ``split_part(..., ':', 1)``. Not exploitable today (admin UI mints slug-shaped IDs, every write path routes through ``SalesagentAccountStore.resolve()``), but defense in depth. Filing a follow-up rather than touching the tenants schema in this PR. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(proposal): open PgProposalStore pool in serve()'s on_startup CI surfaced ``PoolClosed: the pool 'pool-1' is not open yet`` on every proposal-path dispatch — the lazy ``AsyncConnectionPool(open=False)`` construction at first ``get_proposal_store()`` call never wired an ``open()`` anywhere. Production server and the in-process test harness both run ``serve()``'s native lifespan hooks (adcp 5.4.0 #713), so an ``on_startup=[open_proposal_store]`` entry is the right hook to bind the pool to the live event loop. Added ``open_proposal_store()`` async helper in ``core.decisioning.proposal_store`` and wired it (alongside the existing ``close_proposal_store`` on ``on_shutdown``) into ``_serve_kwargs``. Always runs regardless of ``include_scheduler`` — the pool is tied to ``serve()``'s loop, not to background-job lifecycle. Resolves the E2E + ``Integration (other)`` failures observed on PR #422 after retargeting to ``main`` (the test surfaces that exercise ``update_media_buy`` / ``get_media_buy_delivery`` both hit ``maybe_hydrate_recipes_for_media_buy_id`` which acquires from the pool). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(proposal): forward on_startup/on_shutdown through build_app() to in-process tests The previous fix added ``open_proposal_store`` to ``_serve_kwargs``'s ``on_startup`` list, which works in production where ``main()`` calls ``serve()`` (which natively threads lifespan hooks into the composed app). But ``build_app()`` (used by the in-process test harness and any ASGITransport-based test) bypasses ``serve()`` and calls ``_build_mcp_and_a2a_app`` directly — it wasn't forwarding the lifespan kwargs, so the pool open hook never fired in tests. Result: CI's E2E + ``Integration (other)`` + ``Integration (infra)`` suites continued to hit ``PoolClosed: the pool 'pool-1' is not open yet`` on any proposal-touching dispatch even after the prior fix. ``_build_mcp_and_a2a_app`` already accepts ``on_startup`` / ``on_shutdown`` kwargs (adcp 5.4.0 #713 wired them through). Pass them explicitly from ``_serve_kwargs``'s already-built tuples. Production path (``main`` → ``serve``) was already correct; this only affects the in-process test surface. Locally ``make quality`` still shows 4545 passed. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(proposal): lazy-open the pool on first async method call Prior approach wired ``open_proposal_store`` into ``on_startup``, which fires once per process at app startup. That works in production but is fragile under per-test database rebuild: the harness creates one app per process (lifespan fires once with whatever ``DATABASE_URL`` was live at that moment), and subsequent integration tests rebuild the store singleton (via ``_reset_proposal_store``) against per-test DBs without re-firing lifespan — so the rebuilt pool stays closed. Switch to a ``_LazyOpenPgProposalStore`` subclass that opens its pool on the first async method call, mirroring ``core.idempotency._LazyBootstrapPgBackend``. Each rebuilt singleton opens its own pool against its current DSN on first use; the pool binds to whichever event loop dispatches it. Mutex-guarded so concurrent first-callers don't race the side effect. Dropped the ``open_proposal_store`` lifespan entry from both ``_serve_kwargs``'s ``on_startup`` and ``build_app``'s explicit forwarding — no longer needed. ``close_proposal_store`` stays on ``on_shutdown`` to drain in-flight connections cleanly. Local ``make quality`` still shows 4545 passed. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(tests): reset pool-holding singletons in conftest_db integration_db There are two ``integration_db`` fixtures with the same name: * ``tests/fixtures/integration_db.py:make_integration_db`` — added ``_reset_proposal_store`` calls in the prior review-fix commit. * ``tests/conftest_db.py:integration_db`` — the one most integration tests actually use. Had no pool-singleton resets at all. This commit adds the resets to the conftest fixture so per-test DATABASE_URL changes propagate into rebuilt singletons. Without it, the first integration test in a CI worker opens the proposal pool against its own DSN; subsequent tests' DBs are unreachable because the pool is bound to the first test's (now-dropped) DSN, and ``maybe_hydrate_recipes_for_media_buy_id`` raises ``PoolTimeout`` mid-dispatch. Resolves the test-stale-DSN root cause behind PR #422's E2E and Integration (other) / (infra) failures. The lazy-open subclass on its own wasn't enough — it opens the pool correctly, but the pool was already bound to a stale DSN at construction time. Iterates over a tuple of (idempotency, replay, proposal) module names so the three pool-holding singletons stay aligned without copy-paste. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(proposal): pass table_name="proposals" to PgProposalStore Real root cause of the E2E + Integration (other) / (infra) failures across PR #422: ``PgProposalStore`` defaults its table to ``adcp_proposal_drafts``, but our migration (``t2u3v4w5x6y7_swap_to_pg_proposal_store_schema``) creates the table as ``proposals``. Every proposal-path dispatch raised ``psycopg.errors.UndefinedTable: relation 'adcp_proposal_drafts' does not exist``, which the framework's a2a_server caught generically as "Skill execution failed: update_media_buy" — never reaching the delegate's typed error translation. This bug was masked by red herrings in the earlier debug cycle: ``PoolClosed`` warnings from zombie pools (resolved by lazy-open subclass + conftest_db reset), per-test DSN binding (real concern, real fix), ``PoolTimeout`` from the underlying ``UndefinedTable`` keeping the connection in a bad state. The lazy-open + reset fixes are still load-bearing for the broader stale-DSN problem; this one-line ``table_name="proposals"`` adds the missing piece. Two valid options were: * Rename upstream table identifier to match by passing ``table_name=`` * Rename our migration to use ``adcp_proposal_drafts`` Picked the first because PR #390 already shipped ``proposals`` as the salesagent-internal name (referenced by admin tooling docs, audit trails, ops queries). Renaming would force a no-op rename migration through every existing tenant with zero functional gain. Schema columns + indexes already match upstream's expected shape; only the table identifier differs, and ``PgProposalStore`` makes that configurable specifically for this case. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(proposal): re-add Proposal ORM as table-creation-only schema mirror PR #422's swap deleted the ``Proposal`` ORM class on the reasoning that ``PgProposalStore`` owns the table via raw psycopg3. That was correct for runtime but broke the test path: ``tests/conftest_db.py:integration_db`` creates per-test DBs via ``Base.metadata.create_all`` (NOT Alembic), so removing the ORM class removed the table from ``Base.metadata``, and every proposal-path integration test raised ``psycopg.errors.UndefinedTable: relation 'proposals' does not exist``. Re-adds the ``Proposal`` class with the upstream schema shape: * ``(account_id, proposal_id)`` compound PK * ``state`` CHECK constraint * JSONB columns for recipes / payload * TIMESTAMPTZ + ``server_default=func.now()`` for timestamps * Generated ``tenant_id`` via :class:`sqlalchemy.Computed` (matches the migration's ``GENERATED ALWAYS AS (split_part(...)) STORED`` clause) * FK + ``ON DELETE CASCADE`` to ``tenants`` * Partial unique on (account_id, media_buy_id) for reverse lookup * Partial expires_at index for TTL sweep Production runs Alembic (migration ``t2u3v4w5x6y7``) — the source of truth for the schema. The ORM class is **table-creation-only**: no salesagent code may query through it. Docstring spells out the "do not write SQLAlchemy queries against this" rule for future maintainers. The columns produce DDL equivalent to the migration except for ``COLLATE "C"`` (a byte-order index perf optimization that's test-irrelevant — tests don't care about index lookup speed and SQLAlchemy can't express COLLATE cleanly at column-declaration time). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(migration): rebase proposals migration onto ss02e5f6a7b8 The SpringServe inventory migration (``ss02e5f6a7b8``) landed on main during this PR's review cycle, creating a multi-head conflict with ``t2u3v4w5x6y7``. Both descended from ``d0c3c40fdd41``. Re-pointed this migration's ``down_revision`` to ``ss02e5f6a7b8`` to linearize the chain. The springserve migrations don't touch ``proposals``, so a clean rebase is equivalent to ``alembic merge`` here and keeps the history flat. ``alembic heads`` now shows ``t2u3v4w5x6y7`` as the single head. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * feat(embedded): EMBEDDED_CAPABILITIES flag + Tenant Settings section gating (Sprint 7 Phase 4a+4b) (#428) * docs(embedded): reframe sprint 7 phase 4 around capability flags Drops the coarse "hide Tenant Settings entirely on embedded" framing. The salesagent is heading toward headless: the storefront progressively absorbs every workflow that isn't ad-server-specific. Per-workflow migration needs per-workflow ownership, not a static template hide. Phase 4 now ships as four steps: - 4a: EMBEDDED_CAPABILITIES env var + capability_owner() helper - 4b: per-subsection {% if publisher_owns('X') %} gates + 403 POSTs - 4c: hard not-embedded gates for signing keys + OIDC (no publisher answer ever makes sense) - 4d: collapse Tenant Settings page once all subsections are storefront-owned Flags are instance-level, not per-tenant — one embedded instance is one storefront. Defaults to all-publisher so existing tenants behave identically until the operator opts each workflow in. Open instances treat the env var as a no-op. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(embedded): add EMBEDDED_CAPABILITIES flag infrastructure Phase 4a of sprint 7 IA cleanup. Adds capability_owner() and publisher_owns() helpers that read an instance-level EMBEDDED_CAPABILITIES env var (JSON), declaring which workflows the upstream storefront has absorbed on this embedded instance. Defaults to all-publisher so existing embedded tenants behave identically at upgrade. Open instances treat the env var as a no-op — capability gating only applies when MANAGED_INSTANCE=true. Malformed JSON, non-object shapes, or values outside {publisher, storefront} raise ValueError at call time (fail loud — silently leaving every workflow on the publisher side would be the worst failure mode). Both helpers are registered as Jinja globals so templates can gate subsections in phase 4b: `{% if publisher_owns('creative_approval') %}`. No template changes in this phase — pure infrastructure, mergeable alone. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(embedded): gate Settings subsections by capability flag Phase 4b of Sprint 7 IA cleanup. Wraps each migrating Tenant Settings subsection in {% if publisher_owns('') %} and adds matching 403 guards in the POST handlers. When a storefront takes over a workflow (creative approval, Slack, advertising policy, product ranking, AI services, creative/signals agents) on an embedded instance, the publisher's UI loses that section and direct POSTs return 403. Eight capabilities gated: - creative_approval (approval workflow + creative review subsections) - advertising_policy - product_ranking - slack - ai_services (sub-form + test endpoints + model picker) - creative_agents (whole blueprint via before_request hook) - signals_agents (whole blueprint via before_request hook) - brand_manifest (reserved for when the field is rendered) Defense-in-depth: business-rules POST inspects which form fields are present and 403s if any field belongs to a storefront-owned capability. Currency/measurement/naming fields stay publisher-writable. Drive-by: - Extracted insert_embedded_test_tenant + cleanup_embedded_test_tenant to tests/integration/_embedded_helpers.py — the canonical tenant kwargs lived in test_embedded_ui_hardening.py and were about to be triplicated. - Moved embedded_app + embedded_client fixtures to tests/integration/conftest.py (pytest needs them there to avoid ruff F811 on imported fixtures). - Removed now-stale _insert_tenant allowlist entry in the repository- pattern guard. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) * feat(embedded): hard-hide signing keys + OIDC on embedded + review-driven fixes (Sprint 7 Phase 4c) (#430) * feat(embedded): hard-hide signing keys + OIDC on embedded instances Phase 4c of Sprint 7 IA cleanup. Some surfaces never make sense on embedded tenants regardless of which storefront is the wrapper, so they don't get capability flags — they're hard-gated on either ``not embedded_view`` (per-tenant rendering) or ``not is_managed_instance()`` (instance-wide blueprint registration). Signing keys (per-tenant gate): - Nav entry hidden in Tenant Settings on embedded tenants - Section markup omitted on embedded tenants - POST /signing-keys/generate returns 403 on embedded tenants - POST /signing-keys//rotate-out returns 403 on embedded tenants The salesagent doesn't issue webhooks under its own domain in embedded mode (the storefront signs), so self-signing inside the storefront's perimeter is dead code. Open tenants retain the full surface. OIDC blueprint (instance-wide gate): - ``oidc_bp`` is not registered when ``MANAGED_INSTANCE=true`` - /auth/oidc/* routes 404 on embedded instances Embedded identity comes from X-Identity-* headers, not per-tenant OIDC config, so the entire OIDC config surface is irrelevant. Open instances keep it. 8 integration tests cover all paths (signing keys hidden + 403 on embedded; visible + writable on open; OIDC 404 on managed; OIDC routes exist on open). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(embedded): address Phase 4 review blockers — brand_manifest gate + OIDC + update_general Code review + security review of #428/#429 found two blockers that ship a real regression and two H-level defense-in-depth holes. Fix what's worth fixing before merge: 1. brand_manifest section missing template gate (code review #1). The Brand Manifest Policy