diff --git a/alembic/versions/378299ad502f_change_brand_manifest_policy_default_to_.py b/alembic/versions/378299ad502f_change_brand_manifest_policy_default_to_.py
new file mode 100644
index 000000000..209a6a110
--- /dev/null
+++ b/alembic/versions/378299ad502f_change_brand_manifest_policy_default_to_.py
@@ -0,0 +1,50 @@
+"""change_brand_manifest_policy_default_to_require_auth
+
+Revision ID: 378299ad502f
+Revises: 6f05f4179c33
+Create Date: 2025-10-29 02:52:27.162501
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "378299ad502f"
+down_revision: str | Sequence[str] | None = "6f05f4179c33"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Upgrade schema - change brand_manifest_policy default to require_auth.
+
+ This reflects the correct default for most publishers: standard B2B model
+ where advertisers must sign up to see products and pricing.
+
+ Existing tenants keep their current settings (no data migration).
+ """
+ # Change the server default for new tenants
+ op.alter_column(
+ "tenants",
+ "brand_manifest_policy",
+ server_default="require_auth",
+ existing_type=sa.String(50),
+ existing_nullable=False,
+ comment="Product discovery access policy: require_auth (standard B2B - signup to see pricing), require_brand (brand context required for bespoke products), public (generic products visible to all)",
+ )
+
+
+def downgrade() -> None:
+ """Downgrade schema - revert to require_brand default."""
+ op.alter_column(
+ "tenants",
+ "brand_manifest_policy",
+ server_default="require_brand",
+ existing_type=sa.String(50),
+ existing_nullable=False,
+ comment="Brand manifest requirement policy: public (no auth, no pricing), require_auth (auth required, no brand manifest), require_brand (auth + brand manifest required)",
+ )
diff --git a/alembic/versions/6f05f4179c33_add_brand_manifest_policy_to_tenants.py b/alembic/versions/6f05f4179c33_add_brand_manifest_policy_to_tenants.py
new file mode 100644
index 000000000..6eb5f4595
--- /dev/null
+++ b/alembic/versions/6f05f4179c33_add_brand_manifest_policy_to_tenants.py
@@ -0,0 +1,40 @@
+"""add_brand_manifest_policy_to_tenants
+
+Revision ID: 6f05f4179c33
+Revises: 319e6b366151
+Create Date: 2025-10-28 18:27:53.361639
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "6f05f4179c33"
+down_revision: str | Sequence[str] | None = "319e6b366151"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # Add brand_manifest_policy column with default 'require_brand'
+ # This preserves existing behavior (strictest policy) for current tenants
+ op.add_column(
+ "tenants",
+ sa.Column(
+ "brand_manifest_policy",
+ sa.String(50),
+ nullable=False,
+ server_default="require_brand",
+ comment="Brand manifest requirement policy: public (no auth, no pricing), require_auth (auth required, no brand manifest), require_brand (auth + brand manifest required)",
+ ),
+ )
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ op.drop_column("tenants", "brand_manifest_policy")
diff --git a/docker-compose.yml b/docker-compose.yml
index e716ea17d..5f6fbb1f2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -86,6 +86,7 @@ services:
GAM_OAUTH_CLIENT_SECRET: ${GAM_OAUTH_CLIENT_SECRET:-}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
+ GOOGLE_OAUTH_REDIRECT_URI: ${GOOGLE_OAUTH_REDIRECT_URI:-}
APPROXIMATED_API_KEY: ${APPROXIMATED_API_KEY:-}
APPROXIMATED_PROXY_IP: ${APPROXIMATED_PROXY_IP:-37.16.24.200}
APPROXIMATED_BACKEND_URL: ${APPROXIMATED_BACKEND_URL:-adcp-sales-agent.fly.dev}
diff --git a/schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta b/schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta
index bd247b373..1db9d58cd 100644
--- a/schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta
+++ b/schemas/v1/_schemas_v1_core_webhook-payload_json.json.meta
@@ -1,6 +1,7 @@
{
- "etag": "W/\"68ffaa97-1ce0\"",
- "last-modified": "Mon, 27 Oct 2025 17:23:35 GMT",
- "downloaded_at": "2025-10-27T20:50:42.491975",
- "schema_ref": "/schemas/v1/core/webhook-payload.json"
+ "etag": "W/\"69013dbf-1ce0\"",
+ "last-modified": "Tue, 28 Oct 2025 22:03:43 GMT",
+ "downloaded_at": "2025-10-28T18:20:43.362874",
+ "schema_ref": "/schemas/v1/core/webhook-payload.json",
+ "content_hash": "3037a3a80eb9e20dc2034a6701bf871e944f910e3ea1fec0885361ed06ace8eb"
}
diff --git a/schemas/v1/_schemas_v1_enums_task-type_json.json.meta b/schemas/v1/_schemas_v1_enums_task-type_json.json.meta
index 091746cb1..b3906a9fe 100644
--- a/schemas/v1/_schemas_v1_enums_task-type_json.json.meta
+++ b/schemas/v1/_schemas_v1_enums_task-type_json.json.meta
@@ -1,6 +1,7 @@
{
- "etag": "W/\"68ffaa97-531\"",
- "last-modified": "Mon, 27 Oct 2025 17:23:35 GMT",
- "downloaded_at": "2025-10-27T20:50:43.126772",
- "schema_ref": "/schemas/v1/enums/task-type.json"
+ "etag": "W/\"69013dbf-531\"",
+ "last-modified": "Tue, 28 Oct 2025 22:03:43 GMT",
+ "downloaded_at": "2025-10-28T18:20:43.896965",
+ "schema_ref": "/schemas/v1/enums/task-type.json",
+ "content_hash": "7d0bd6c33ff8e507f1b2c642c00f92bbbd30f4db489f9be7fe07a10276ee604f"
}
diff --git a/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json b/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json
index 27cd99883..af0391e1f 100644
--- a/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json
+++ b/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json
@@ -56,8 +56,6 @@
"additionalProperties": false
}
},
- "required": [
- "brand_manifest"
- ],
+ "required": [],
"additionalProperties": false
}
diff --git a/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json.meta b/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json.meta
index 3a7b2e45a..ba9d28dce 100644
--- a/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json.meta
+++ b/schemas/v1/_schemas_v1_media-buy_get-products-request_json.json.meta
@@ -1,7 +1,7 @@
{
- "etag": "W/\"68ffaa97-7b9\"",
- "last-modified": "Mon, 27 Oct 2025 17:23:35 GMT",
- "downloaded_at": "2025-10-28T16:15:28.603429",
+ "etag": "W/\"69013dbf-7a1\"",
+ "last-modified": "Tue, 28 Oct 2025 22:03:43 GMT",
+ "downloaded_at": "2025-10-28T18:20:43.943318",
"schema_ref": "/schemas/v1/media-buy/get-products-request.json",
- "content_hash": "3de0c3f8a90fb2f1aacc80c973ccebe53737107fcea7c64abb69a33eaa3c7caf"
+ "content_hash": "1aee2a71dbf4212648696c52b98d69be4f9678bb12fed78b093357337e0a47c0"
}
diff --git a/src/admin/blueprints/policy.py b/src/admin/blueprints/policy.py
index 713a62d87..1a40e035f 100644
--- a/src/admin/blueprints/policy.py
+++ b/src/admin/blueprints/policy.py
@@ -71,6 +71,9 @@ def index(tenant_id):
policy_settings = default_policies.copy()
policy_settings.update(tenant_policies)
+ # Get brand manifest policy
+ brand_manifest_policy = tenant.brand_manifest_policy
+
# Get recent policy checks from audit log
stmt = (
select(AuditLog)
@@ -124,6 +127,7 @@ def index(tenant_id):
tenant_id=tenant_id,
tenant_name=tenant_name,
policy_settings=policy_settings,
+ brand_manifest_policy=brand_manifest_policy,
recent_checks=recent_checks,
pending_reviews=pending_reviews,
)
@@ -186,11 +190,18 @@ def parse_textarea_lines(field_name):
config["policy_settings"] = policy_settings
+ # Get brand_manifest_policy from form
+ brand_manifest_policy = request.form.get("brand_manifest_policy", "require_auth")
+ # Validate policy value
+ if brand_manifest_policy not in ["public", "require_auth", "require_brand"]:
+ brand_manifest_policy = "require_auth" # Default to standard B2B model
+
# Update database
with get_db_session() as db_session:
tenant = db_session.scalars(select(Tenant).filter_by(tenant_id=tenant_id)).first()
if tenant:
tenant.policy_settings = json.dumps(policy_settings)
+ tenant.brand_manifest_policy = brand_manifest_policy
db_session.commit()
return redirect(url_for("policy.index", tenant_id=tenant_id))
diff --git a/src/core/database/models.py b/src/core/database/models.py
index 6a404e57c..942d2edb1 100644
--- a/src/core/database/models.py
+++ b/src/core/database/models.py
@@ -75,6 +75,12 @@ class Tenant(Base, JSONValidatorMixin):
nullable=True,
comment="Advertising policy configuration with prohibited categories, tactics, and advertisers",
)
+ brand_manifest_policy: Mapped[str] = mapped_column(
+ String(50),
+ nullable=False,
+ server_default="require_auth",
+ comment="Product discovery access policy: require_auth (standard B2B - signup to see pricing), require_brand (brand context required for bespoke products), public (generic products visible to all)",
+ )
# Naming templates (business rules - shared across all adapters)
order_name_template: Mapped[str | None] = mapped_column(
diff --git a/src/core/tools/products.py b/src/core/tools/products.py
index 43719b395..8f543ba25 100644
--- a/src/core/tools/products.py
+++ b/src/core/tools/products.py
@@ -95,7 +95,45 @@ async def _get_products_impl(req: GetProductsRequestGenerated, context: Context)
principal = get_principal_object(principal_id) if principal_id else None
principal_data = principal.model_dump() if principal else None
- # Extract offering text from brand_manifest
+ # Get tenant's brand manifest policy
+ brand_manifest_policy = tenant.get("brand_manifest_policy", "require_auth")
+
+ # Validate brand manifest based on policy
+ # Policy options:
+ # - "public": No auth required, no brand_manifest required (but no pricing shown)
+ # - "require_auth": Auth required, brand_manifest optional
+ # - "require_brand": Auth + brand_manifest both required (strictest, default)
+
+ if brand_manifest_policy == "require_brand":
+ # Strictest: require both auth and brand_manifest
+ if not principal_id:
+ raise ToolError("Authentication required. This tenant requires authentication to access product catalog.")
+ if not req.brand_manifest:
+ raise ToolError(
+ "brand_manifest required. This tenant requires brand information to provide product recommendations."
+ )
+ elif brand_manifest_policy == "require_auth":
+ # Middle: require auth but brand_manifest is optional
+ if not principal_id:
+ raise ToolError("Authentication required. This tenant requires authentication to access product catalog.")
+ # brand_manifest is optional in this mode
+ elif brand_manifest_policy == "public":
+ # Most permissive: no auth required, no brand_manifest required
+ # Pricing will be stripped later for anonymous users
+ pass
+ else:
+ # Unknown policy - default to strictest for safety
+ logger.warning(
+ f"Unknown brand_manifest_policy '{brand_manifest_policy}' for tenant {tenant['tenant_id']}, defaulting to require_brand"
+ )
+ if not principal_id:
+ raise ToolError("Authentication required. This tenant requires authentication to access product catalog.")
+ if not req.brand_manifest:
+ raise ToolError(
+ "brand_manifest required. This tenant requires brand information to provide product recommendations."
+ )
+
+ # Extract offering text from brand_manifest (if provided)
offering = None
if req.brand_manifest:
if isinstance(req.brand_manifest, str):
@@ -110,8 +148,9 @@ async def _get_products_impl(req: GetProductsRequestGenerated, context: Context)
elif isinstance(req.brand_manifest, dict):
offering = req.brand_manifest.get("name", "")
+ # If no brand_manifest provided, use a generic offering for context
if not offering:
- raise ToolError("brand_manifest must provide brand information")
+ offering = "Generic advertising campaign"
# Skip strict validation in test environments (allow simple test values)
@@ -490,10 +529,25 @@ async def _get_products_impl(req: GetProductsRequestGenerated, context: Context)
except Exception as e:
logger.warning(f"Failed to annotate pricing options with adapter support: {e}")
- # Filter pricing data for anonymous users
- if principal_id is None: # Anonymous user
- # Remove pricing data from products for anonymous users
- # Set to empty list to hide pricing (will be excluded during serialization)
+ # Filter pricing data based on policy and authentication
+ # Policy determines who can see pricing:
+ # - "public": No pricing for anyone (including authenticated users)
+ # - "require_auth": Pricing only for authenticated users
+ # - "require_brand": Pricing only for authenticated users with brand_manifest
+
+ should_show_pricing = False
+ if brand_manifest_policy == "public":
+ # Public policy: never show pricing (encourages users to authenticate for pricing)
+ should_show_pricing = False
+ elif brand_manifest_policy == "require_auth":
+ # Require auth: show pricing only if authenticated
+ should_show_pricing = principal_id is not None
+ elif brand_manifest_policy == "require_brand":
+ # Require brand: show pricing only if authenticated AND brand_manifest provided
+ should_show_pricing = principal_id is not None and req.brand_manifest is not None
+
+ if not should_show_pricing:
+ # Remove pricing data from products
for product in modified_products:
product.pricing_options = []
@@ -634,7 +688,9 @@ def safe_json_parse(value):
# Convert pricing_options ORM objects to Pydantic objects
pricing_options = []
- logger.info(f"Product {product.name} ({product.product_id}) has {len(product.pricing_options)} pricing options loaded")
+ logger.info(
+ f"Product {product.name} ({product.product_id}) has {len(product.pricing_options)} pricing options loaded"
+ )
for po in product.pricing_options:
fixed_str = "fixed" if po.is_fixed else "auction"
pricing_option_data = {
diff --git a/templates/policy_settings.html b/templates/policy_settings.html
index 1a1891d28..3024e709a 100644
--- a/templates/policy_settings.html
+++ b/templates/policy_settings.html
@@ -47,6 +47,29 @@
Policy Configuration
+
+
+
+
+
+
+ Standard B2B (recommended): Advertisers sign up to see products and pricing. Most common model.
+ Brand context required: Advertisers provide brand info before seeing products. For highly customized/bespoke products.
+ Public marketplace: Products visible to everyone (pricing hidden). For generic, commoditized ad products.
+ Note: Future versions will support per-product settings. Currently applies to all products.
+
+