[codex] Add product-scoped signal targeting groups#5009
Conversation
204ec7b to
54c07c4
Compare
There was a problem hiding this comment.
Approving. SignalRef as the canonical named-signal identity is the right shape, the product-scoped Boolean composition baseline is portable, and the new schema invariants (options/rules → signal_targeting_allowed: true; rules require options) cover the fixed/default-only echo case.
Things I checked
- SignalRef scope hygiene. All three branches in
static/schemas/source/core/signal-ref.jsonenforce cross-scope rejection vianot.anyOfagainst the legacysource/idkeys plus the other scopes' anchors. The 12 carry-over rejection cases intests/composed-schema-validation.test.cjs:631-685exercise every branch. - Product invariants. The two
allOfclauses instatic/schemas/source/core/product.json:535-561are coherent. Fixed/default-only products still setsignal_targeting_allowed: trueper the field description (product.json:168-172) and the tests atcomposed-schema-validation.test.cjs:715-750lock the gate both directions. - Legacy flat
signal_targetingrejection instatic/schemas/source/core/targeting.json:444-447is a defensive guard, not a regression —git show fe6d7e658:static/schemas/source/core/targeting.jsonhas zerosignal_targetingreferences pre-PR. - Required-list swap on
get_signals.signals[].signal_id→signal_ref, withdata_providerandpricing_optionsrelaxed out. Consistent with the beta-cycle rename pattern from the brand-protocolstatusfield swaps in3.1.0-beta.3(CHANGELOG.md:85), but see follow-up below. - Test vector. New
signal-targeting-groups-echocase instatic/test-vectors/media-buy/package-status-targeting-overlay-echo.json:127-225exercises bothanyandnonechild groups withpricing_option_idandsignal_agent_segment_idecho. - Docs↔schema coherence.
docs/media-buy/advanced-topics/targeting.mdx:23-535,docs/media-buy/task-reference/create_media_buy.mdx:535-617, anddocs/signals/tasks/get_signals.mdxuse the newsignal_refshape consistently. No drift. - Two expert verdicts:
code-reviewerApprove-with-caveats;ad-tech-protocol-expertsound-with-caveats. Neither flagged a blocker.
Follow-ups (non-blocking — file as issues)
- Numeric expression
min_value <= max_valueis prose-only.static/schemas/source/core/signal-targeting-expression.json:59,79claims the constraint as MUST but no structuralif/then. Same gap pre-existed insignal-targeting.json:58-79. Either tighten or downgrade to SHOULD. scope: "product"is reachable fromget_signalsresponses with no product context.signals/get-signals-response.json:27-30permits the fullsignal-ref.jsononeOfincludingscope: "product", butget-signals-request.jsonhas noproduct_idscoping field — a seller can emit product-local refs the buyer cannot resolve. Either narrow the response'ssignal_refoneOf to non-product scopes, or add request-side product scoping with anif/then.signal_refdeclared but not required onsignal.*events instatic/schemas/source/core/wholesale-feed-event.json:250-251,289-291,334-335,391-393. The PR description says events "now carrysignal_ref" but a producer can omit it and stay valid. Tighten the required list onsignal.created/signal.updated, or document thatsignal_agent_segment_idremains the canonical key andsignal_refis best-effort.- Test gaps for the new structural constraints:
product-signal-targeting-option.json'srequires_activation → signal_agent_segment_idif/then;targeting.json'snot.required: [signal_targeting]legacy-shape rejection;signal-targeting-expression.json's numericanyOf [min_value | max_value]. The carry-over hygiene coverage is strong; these three new invariants deserve symmetric assertions. signal_sourcevsdata_providertie-break is correctly stated insignal-ref.json:71("usedata_providereven if the catalog publisher is also the seller or signal source") but is unenforceable on the wire. Worth a buyer-side reconciliation note — try both before failing.- Negative test vector for
selection_group_rules(signal-targeting-rules.json:43-85) — the "one selection_group + one targeting_mode per child group" rule is runtime-only. The current vector covers the happy path; a mixed-group rejection vector would lock implementers in. - Changeset transparency. The
required[]swap onget_signals.signals[]is a more aggressive move than the additive-deprecate Option E used formedia_buy_statusin beta.3 (CHANGELOG.md:85). Consistent with beta-cycle precedent, but worth one sentence in the changeset body acknowledging the divergence so beta-line integrators aren't surprised.
Minor nits (non-blocking)
not.anyOfcarry-over lists insignal-ref.json:27-35, 59-66, 90-97are maintenance-coupled. When a fourth scope arrives, every existing branch needs itsnot.anyOfextended. Worth a one-line comment on the file or a lint check.
Safe to merge.
|
Strong direction. The product-scoped signal targeting model maps directly onto a real case we're shipping at iHeartMedia.For linear broadcast radio, the seller agent is structurally better positioned to bundle inventory + audience than the buyer is to compose them separately — not because portable third-party segments can't apply (they can, and AudioGraph supports this), but because the planning complexity lives on the sell side. Broadcast inventory is scheduled across time: dayparts, weekly rotations, avail windows. The buyer isn't equipped to reason about which audience will be available when, what reach looks like across a weekly schedule, or how current avails map to targeting goals. That planning expertise belongs to the seller. The audience selection is product-scoped not because the signal itself is non-portable, but because the intersection of that signal with available inventory across time is something only the seller can resolve.AudioGraph (launching at Cannes Lions 2026) is exactly this shape: matched listening signals across broadcast, streaming, and podcast, resolved into audience profiles and planned against iHeartMedia's broadcast schedule. A buyer agent could bring a portable third-party segment — but without the seller's planning layer, it can't know what that segment looks like against this week's avails, across this daypart mix, at this reach target. scope: "product" is the right primitive. The question is whether it carries enough semantics to express this.Three questions on the current design:
|
|
Thanks for the AudioGraph context — the iHeartMedia broadcast case is exactly the shape this design was built around, and the three questions surface real edges worth addressing directly. Q1 — Temporal planning as a product-scope dimension You're right that the current framing of The practical guidance for AudioGraph is: if the audience profiles are portable across products (matched listening signals that mean the same thing across broadcast, streaming, podcast), they belong in Q2 — Mandatory audience selection
// Product requires at least one audience signal
{
"signal_targeting_rules": {
"selection_mode": "required"
}
}For mixed cases (mandatory audience tier, optional exclusions), use {
"signal_targeting_rules": {
"selection_mode": "optional",
"selection_group_rules": [
{
"selection_group": "audience_tier",
"selection_mode": "required",
"min_selected_signals": 1
}
]
}
}Child groups with Q3 — Cross-product audience consistency
// signal_targeting_options entry on each iHeartMedia product
{
"signal_ref": {
"scope": "data_provider",
"data_provider_domain": "iheartmedia.com",
"signal_id": "audiograph_commuters"
},
"name": "AudioGraph Commuters",
"value_type": "binary",
"pricing_options": [...]
}Buyers reference the same The broadcast-specific daypart-availability signal is the one case that genuinely warrants Generated by Claude Code |
54c07c4 to
e61c5b2
Compare
There was a problem hiding this comment.
LGTM. Follow-ups noted below. signal_ref as the canonical named-signal identity across discovery, targeting, audience selectors, and wholesale events is the right shape — one discriminator (scope) does the work that SignalId.source + four overlapping fields were doing badly.
Things I checked
- SignalRef branch hygiene.
static/schemas/source/core/signal-ref.jsonL27-34 / L59-65 / L90-96 — each scope branch hasnot.anyOfagainst the carry-over fields from the other branches plus the legacy SignalId fields (source,id).tests/composed-schema-validation.test.cjsL631-685 exercises six rejection paths. Clean. - Product allOf invariants.
static/schemas/source/core/product.jsonL535-562 —if signal_targeting_options OR signal_targeting_rules → signal_targeting_allowed: true, andsignal_targeting_rules → signal_targeting_options. Tests at L743-760 exercise both rejections.additionalProperties: truedoesn't leak the bag. - Targeting overlay closes the legacy door.
core/targeting.jsonL444-447 rejects the flatsignal_targetingfield; groups-only on the wire. The hard cutover is what makes the buy-time shape coherent. value=trueconstant for binary in groups.signal-targeting-expression.jsonL24-27 hardcodesvalue: { const: true }while the legacysignal-targeting.jsonL23-26 still allows boolean. Intentional — include/exclude lives on the parent group operator, sovalue: falsewould be a second way to express exclusion. Documented at expression L5.- Semver.
minoron 3.1-beta absorbs the breaking surfaces — flatsignal_targetingrejection, requiredsignal_refon get-signals responses, audience-selector swap. Pre-release is the right window for this. - oneOf walker. All new discriminated unions (
signal-ref,signal-targeting-expression,package-signal-targetingallOf-wrap) carry explicit discriminator blocks. No new undiscriminatedoneOfintroduced.
Follow-ups (non-blocking — file as issues)
-
wholesale-feed-event.json$defs/signal contradicts the get_signals relaxation. L593-603 still requiresdata_providerandpricing_options. The changeset advertises the relaxation specifically for source-native, free, and bundled signals —get-signals-response.jsonL104-112 dropped those fields from required, but the same signal shape onsignal.created/signal.updatedwebhook payloads still requires them. A source-native signal emitted via the webhook now fails validation. Drop both from the required list, or document why the wholesale-feed shape is stricter. -
index.jsonregistry incomplete. L361 registerssignal-refandsignal-targeting-expressionbut not the five other new schemas:product-signal-targeting-option,signal-targeting-rules,package-signal-targeting,package-signal-targeting-group,package-signal-targeting-groups. The analogous legacy entries (signal-id,signal-targeting,data-provider-signal-selector) are all registered. Downstream codegen and the docs portal discover schemas via this index. -
whats-new-in-3-1.mdxdoesn't headline the new packaging. Nine new schema files and a wire-affecting reshape of the targeting overlay, but the doc only mentions SignalRef sideways in the Wholesale-signals row. Add asignal_targeting_groupssection. -
Glossary gaps.
docs/reference/glossary.mdxhas no entries for Signal Targeting Options, Signal Targeting Rules, Signal Targeting Group, Selection Group, or Resolution Model (direct_targeting/seller_planned). These are new wire-level concepts buyers need to look up. -
Test coverage gaps in
composed-schema-validation.test.cjs. Five SignalRef carry-over rejections missing (productscope vsagent_url/id;signal_sourcescope vsagent_url/id). No test exercises therequires_activation→signal_agent_segment_idrequired arm atproduct-signal-targeting-option.jsonL111-125. No rejection tests againstpackage-signal-targeting-groupscomposition (missing top-levelall, missing childoperator, binaryvalue: false, emptysignals[]).
Minor nits (non-blocking)
-
get_signals.signal_refs+signal_idsco-presence is undefined.signals/get-signals-request.jsonL151-168 accepts any combination. Add either anotclause forbidding both or a "if both present, signal_refs wins" sentence in the description. -
docs/signals/tasks/get_signals.mdxL908+ example omitssignal_refon the signal entry. Response schema now requires it; the example shows the deprecated shape. Either addsignal_refor label the block as "legacy response shape." -
docs/signals/key-concepts.mdxL67-79. "Signal references" table showsproductscope as justsignal_id, omitting the requiredscopediscriminator. Persignal-ref.jsonL26, bothscopeandsignal_idare required. -
scope: \"data_provider\"vsscope: \"signal_source\"disambiguation.signal-ref.jsonL71 says usedata_providerwhen catalog-published "even if the catalog publisher is also the seller or signal source." The rule is right but a worked example would prevent two implementers picking different scopes for the same signal. -
signal-targeting.jsonnumeric branch lacks theanyOf: [{required: [min_value]}, {required: [max_value]}]guard that the newsignal-targeting-expression.jsonL80-83 has. Pre-existing, but worth aligning while you're in the neighborhood.
e61c5b2 to
1c9aa17
Compare
There was a problem hiding this comment.
Hold. The shape of the change is right, but the changeset is minor and three wire schemas plus the overlay reject-clause break existing valid 3.x traffic without the deprecated-alias soft launch that get-signals-response.json does correctly. Either bump the changeset to major or mirror that soft-launch pattern on the other rename sites.
Things I checked
signal_refdiscriminated union — clean.discriminator: scope+oneOf+ per-branchnot.anyOfreject incompatible namespace carry-over (data_provider_domain,signal_source_url,agent_url, legacysource/id).tests/composed-schema-validation.test.cjs:630-690exercises the branch hygiene end-to-end.- New oneOf shapes (
signal-ref.json,signal-targeting-expression.json,package-signal-targeting-groups.json) all carrydiscriminator.propertyName, so the audit walker should classify them asdiscriminated. No baseline ratchet needed for the new files. targeting_overlay.signal_targeting_groupscomposition —operator: "all"const at the top, childanyfor include groups, childnonefor exclusion.(any of include) AND NOT (any of exclude)is the right portable baseline.Product.signal_targeting_*schema invariants atstatic/schemas/source/core/product.json:545-600correctly gate options/source/rules behindsignal_targeting_allowed: true, require either inline options or an explicitget_signals-backed source for rules, and require inline options for a product-backed source.data_provider_signalsdeprecation — additive, properly markeddeprecated: true. No required-field flips on that field.
Must fix before merge
-
minorchangeset on schemas that break previously-valid wire payloads..changeset/signal-targeting-overlay.md:2declaresminor, but the diff renamessignal_id→signal_refas a required field onstatic/schemas/source/core/audience-selector.json:30,60,90,static/schemas/source/core/signal-targeting.json:28,53,78, andstatic/schemas/source/core/wholesale-feed-event.json:594— with no aliasedsignal_idretained. Any 3.0/3.1 client that serializes{ "signal_id": {...}, "value_type": "binary" }against these schemas will now fail validation. Theget-signals-response.json:22-30site does this correctly —signal_idis retained as optional withdeprecated: trueandsignal_refjoins it on the response. Either upgrade the changeset tomajor, or extend that soft-launch pattern to the three rename sites. Theget_signalspattern is the model; copy it. -
targeting.json:444-447not.required: ["signal_targeting"]actively rejects production traffic. Same root cause. Buyers emitting the legacy flattargeting_overlay.signal_targetingarray — which was valid before this PR — will now hit a schema rejection oncreate_media_buyandupdate_media_buy. This is intentional per the PR description, but intentional ≠ non-breaking. Pair this with amajorbump, or downgrade to a deprecation note + grader rule until 4.0. -
wholesale-feed-event.jsoninline signal $def at lines 593-603 still requiresdata_providerandpricing_options. The newget-signals-response.json:104-112drops both fromrequired[](so source-native, free, bundled, or caller-hidden cases work). The wholesale-feed event description at line 519 explicitly says "same wire shape as an item in get_signals.signals[]" — the two have drifted. Either dropdata_providerandpricing_optionsfrom the inlinerequired[]here, or amend the comment.
Follow-ups (non-blocking — file as issues)
product.json:584-600validation invariant misaligned withsignal_targeting_options_sourcedefault. Line 172 declares"default": "product", and the "product-backed source requires inline options" clause guards onrequired: ["signal_targeting_options_source"]. JSON Schema does not materialize defaults during validation, so a product withsignal_targeting_allowed: trueand no explicitsignal_targeting_options_sourceand no inline options will validate cleanly even though runtime resolution would treat it as"product"(and therefore require inline options). Drop thedefault, or add an extraallOfclause that fires whensignal_targeting_allowed: trueand the source field is absent.scripts/oneof-discriminators.baseline.json:159listscore/audience-selector.jsonasdangerous. The in-place rename probably keeps the file disjoint in the same way, so the audit should still pass, but ifnpm run test:oneof-discriminatorsflips classification post-edit, runscripts/audit-oneof.mjs --updateand ratchet the baseline in this PR.- Test coverage gap on the targeting overlay reject-clause.
tests/composed-schema-validation.test.cjsadds positive and negative coverage onsignal-ref.jsonandproduct.jsonbut no test asserts thatcore/targeting.jsonrejects a payload containing the legacy flatsignal_targetingarray. Given (2) is the single most behavior-changing line in the PR, lock it with a rejection test. - Test coverage gap on
signal-ref.jsonrequired-field branches. No negative test forscope: "data_provider"missingdata_provider_domainorscope: "signal_source"missingsignal_source_url. Three more lines. signal_targeting_rules.selection_group_rulesagainst real GAM-style backends. The "exactly oneselection_groupand onetargeting_modeper child group" rule is clean for sellers who can publish their plane partitioning, but it asks the buyer to know it. Worth sanity-checking with the GAM-backed adopters before locking it.resolution_model: "seller_planned"naming. Programmatic shops call this "guaranteed" or "PG".seller_plannedis fine inside AdCP but worth a one-liner in the glossary mapping to the prevailing industry terms.
Minor nits
glossary.mdx:26Audience Selector entry is stale. Reads "either a signal reference (pointing to a specificsignal_id) or a natural language description." Schema issignal_refnow; the value_type variants (binary/categorical/numeric) and the description branch should both be mentioned.signal-ref.jsondiscriminatorkeyword inside draft-07. OpenAPI keyword, ignored by strict draft-07 validators. AJV withdiscriminator: truehonors it; theoneOf+const: scopedoes the real work. A$commentclarifying it is advisory would help future maintainers.
Once the rename surfaces match the get_signals soft-launch pattern (or the changeset bumps to major), and the wholesale-feed-event.json required[] is reconciled with get-signals-response.json, this is mergeable.
1c9aa17 to
d0afd3f
Compare
There was a problem hiding this comment.
LGTM. Follow-ups noted below. Clean discriminator design and the not.anyOf carry-over guards on signal-ref.json are the right shape — the three scope branches are correctly disjoint and reject the legacy namespace fields per branch.
Things I checked
signal-ref.jsononeOfis discriminated onscope; each branch blocks the other branches' namespace keys (productblocksdata_provider_domain, signal_source_url, agent_url, source, id;data_providerblocksagent_url, signal_source_url, source, id;signal_sourceblocksdata_provider_domain, agent_url, source, id). Right shape.product.jsonallOfinvariant at L535-553:signal_targeting_options ∨ signal_targeting_rules ⇒ signal_targeting_allowed: const true. Forces the wholesale "rules only, no inline options" path to still declare the surface.targeting.jsonroot-levelnot.required: signal_targetingcorrectly rejects the legacy flat field. Belt-and-braces — the prior schema had nosignal_targetingproperty to begin with, so this is purely defensive against accidental client emission.package-status-targeting-overlay-echo.jsontest vector matches the new shape: top-leveloperator: "all", childany/none,signal_ref.scope: "product",value_type: "binary",value: true.composed-schema-validation.test.cjsexercises thenot.anyOfcarry-over rejection and the productallOfinvariant including the wholesale-rules-without-options path.index.jsonregisters the seven new schemas.get_signalsresponse now requiressignal_refand relaxesdata_provider+pricing_optionsfrom required to optional — consistent with the PR description's source-native / free / bundled cases.
Follow-ups (non-blocking — file as issues)
-
Required-set swap is technically a breaking wire change on a
minorchangeset.get-signals-response.signals[].requiredpreviously includedsignal_id+data_provider; now requiressignal_refand drops both. Same swap onaudience-selector.json,signal-targeting.json, andwholesale-feed-event.json$defs/signal.required. A pre-PR conforming server emitting onlysignal_idfails the new schema. Defensible asminorbecausesignal_refis the canonical replacement and the legacy field is retained as deprecated, but the changeset should explicitly call this out as a required-field swap with a migration note — or, more conservatively, accept either viaanyOf: [{required: [signal_ref]}, {required: [signal_id]}]for one minor cycle so 3.0 emitters don't fail validation against 3.1 schemas.ad-tech-protocol-expert: sound-with-caveats. -
wholesale-feed-event.json$defs/signalvsget-signals-response.signals[]parity. L519 description says "the same wire shape as an item inget_signals.signals[]", but L593-603 still requiresdata_providerandpricing_optionswhile the get-signals shape now relaxes both. Either drop them fromrequiredon the$defs/signalto keep the stated parity, or update the description to admit the divergence. -
signal_refis optional on signal. webhook payloads.*wholesale-feed-event.jsonL250-251, L289-291, L334-336, L391-393 add a siblingsignal_refnext tosignal_agent_segment_idbut don't require it on the foursignal.*event branches. The PR positionssignal_refas the canonical identity surface — leaving it optional on these payloads erodes that claim. Consider requiring it onsignal.created/signal.updatedat minimum. -
Audit baseline note drift.
scripts/oneof-discriminators.baseline.json:159-163foraudience-selector.json##/oneOfstill saysreq=[type,signal_id,value_type,...]even though the variants now usesignal_ref. Rank staysdangerous/variants=4 so--checkwon't fail, but the note is now misleading. Runnode scripts/audit-oneof.mjs --updateto refresh. -
signal-targeting-rules.jsonselection_group_rules.selection_modedescription omitsoptional. L70-78 enumerates[optional, required, fixed]but the prose only narratesrequiredandfixed. Add a one-line explanation ofoptionalso consumers don't infer the enum was meant to be 2-value.
Minor nits (non-blocking)
signal-ref.jsonproductscope description. L13 says thesignal_idMUST match aProduct.signal_targeting_options[].signal_ref.signal_idfor that product. Worth one example illustrating that scopeproductids are not portable across products even when the string value coincides — easy reader trap.
Safe to merge.
d0afd3f to
bd541f8
Compare
There was a problem hiding this comment.
Block. The changeset says minor, but three wire surfaces flip a required field from signal_id → signal_ref with no oneOf compat branch — any pre-cutover client emitting the old shape fails validation. AdCP semver says a breaking wire change ships major (or carries a transition path); this does neither.
The new SignalRef shape is the right one. scope as resolution-path discriminator is correct, the not.anyOf namespace-rejection on each branch is clean, and product-scoped composition is the right place for it (sellers route products through different ad servers). Land it — but land it honestly.
Must fix
-
Changeset is wrong, or schemas are. Pick one.
static/schemas/source/core/audience-selector.json:30,60,90flipsrequired: [..., "signal_id", ...]→[..., "signal_ref", ...]on every signal branch.static/schemas/source/signals/get-signals-response.json:109-117addssignal_refto required and dropssignal_idfrom required —signal_idis kept as deprecated optional, butsignal_refis now mandatory, so a seller emitting only the legacy shape fails validation.static/schemas/source/core/wholesale-feed-event.json:598-608requiressignal_refon every$defs/signalinstance (catalog enroll/update/price/remove at lines 250, 289, 334, 391). The PR description claims a deprecation window forSignalId; the schemas don't actually implement that. Fix one of: (a) bump tomajorwith a migration note in the changeset, or (b) widen the three surfaces tooneOf signal_id | signal_reffor one minor while old clients catch up.ad-tech-protocol-expertgraded thissound-with-caveatsconditional on those surfaces being pre-GA — confirm that and update the changeset prose, or fix the schemas. -
wholesale-feed-event.json↔get-signals-response.jsondrift.static/schemas/source/core/wholesale-feed-event.json:519describes$defs/signalas "same wire shape as an item inget_signals.signals[]", but the required list at 598-608 still mandatesdata_providerandpricing_options, both of whichget-signals-response.json:109-117just relaxed to optional. Either propagate the relaxation here or amend the description; one validator written against both surfaces will mis-reject the other.
Follow-ups (non-blocking — file as issues)
static/schemas/source/index.jsonregisterssignal-ref,signal-listing, andsignal-targeting-expressionbut notsignal-targeting-rules,product-signal-targeting-option,package-signal-targeting,package-signal-targeting-group, orpackage-signal-targeting-groups. SDK generators driven off the registry won't surface the new composition types.- Wholesale menu source ambiguity. A product carrying
signal_targeting_allowed: true+signal_targeting_rules+ no inlinesignal_targeting_optionsputs the burden on the buyer to know "go callget_signals." Worth an explicitsignal_menu_source: inline | wholesale_feedenum onsignal_targeting_rules— modeled afterformat_ids/format_optionsdual-emission contract onproduct.json:42-50, which carries an SDK-enforced same-canonical invariant. resolution_model: "seller_planned"auditability. Sellers MUST echo appliedsignal_targeting_groupson package state, which lets auditors detect "ignored entirely," but not "covertly direct-targeted under aseller_plannedlabel." Optionalresolution_evidencefield on package state would close that hole.tests/composed-schema-validation.test.cjs:636-685asserts thenot.anyOfcarry-over rejection per scope (good — line 647 quotes'SignalRef product scope rejects SignalId source carry-over'). No positive vector coverstargeting.json:444-447's flat-signal_targetingreject; the 187 new lines don't actually exercise that invariant. Worth adding.
Things I checked
- New schemas use proper
discriminatorpropertyName (scopeon signal-ref,value_typeon signal-targeting-expression). Each oneOf branch in signal-ref carriesnot.anyOfrejecting incompatible namespace fields —data_provider_domain,signal_source_url,agent_url,source, legacyid. product.json:543-560allOf invariant enforcessignal_targeting_allowed: truewhensignal_targeting_optionsorsignal_targeting_rulesis present. Right shape.targeting.json:444-447rejects the legacy flatsignal_targetingoverlay field. Right call — forces groups-only buy-time composition.scripts/oneof-discriminators.baseline.jsonunchanged — the new oneOfs are properly discriminated, so the audit walker is happy.signal-targeting.json(orphaned — no other schema $refs it) hadsignal_id→signal_refrenamed cleanly; doc/index alignment is fine for the orphan.signal-listing.json:60-80allOf if/then correctly enforces inlinename+value_typeforscope: "product"but lets data-provider and signal-source refs ride on the catalog's authoritative definition.
Minor nits (non-blocking)
- Normative prose in description fields.
signal-targeting-rules.json:54carries "Sellers MUST reject duplicate, mixed, or collapsed groups…" as schema description text rather than as a JSON Schema constraint. Move to anx-adcp-validationblock or accept that it's a server-side rule. Either is fine; pick one.
Bump the changeset to major, widen the three surfaces with a oneOf compat branch, or document a transition path that matches the PR description's claim. Pick one and ship.
bd541f8 to
9dbc80e
Compare
There was a problem hiding this comment.
Approving. Changeset is minor, schemas and docs are coherent, the SignalRef discriminator is properly fenced, and the product-invariant allOf matches the test asserts.
Things I checked
static/schemas/source/core/signal-ref.json:7-99—scopediscriminator withconst+requiredon every branch; per-branchnot.anyOfrejects namespace carry-over (data_provider_domain,signal_source_url,agent_url,source,id). Walker will classify as discriminated. All three scopes covered positive + negative attests/composed-schema-validation.test.cjs:632-685.static/schemas/source/core/product.json:543-560—allOf if/thencouplessignal_targeting_options/signal_targeting_rulestosignal_targeting_allowed: true. Both directions (missing + explicitfalse) rejected attests/composed-schema-validation.test.cjs:805-838.static/schemas/source/core/product-signal-targeting-option.json:26—required: ["signal_ref"]correctly overrides the looseranyOf [signal_ref, signal_id]inherited fromsignal-listing; legacysignal_idon product options is rejected attests/composed-schema-validation.test.cjs:777-784.static/schemas/source/signals/get-signals-response.json:109-120—signal_id,data_provider, andpricing_optionsdropped fromrequired;anyOf [signal_ref|signal_id]keeps identity mandatory. Matches changeset (free/bundled signals can legitimately omit pricing).static/schemas/source/core/targeting.json:443-451— legacy flatsignal_targetingretained asdeprecated: true, schema-valid. Migration-window assertion attests/composed-schema-validation.test.cjs:866-877..changeset/signal-targeting-overlay.md—minoris the right bracket: additive surface plusanyOf-gated legacy fields. No required field tightened on a published variant.
Follow-ups (non-blocking — file as issues)
- PR body says "rejecting"; schemas, changeset, and docs say "deprecating". "Enforces groups-only buy-time targeting by rejecting legacy flat `targeting_overlay.signal_targeting`" in the description doesn't match
targeting.json:443-451, the test attests/composed-schema-validation.test.cjs:866, ordocs/media-buy/advanced-topics/targeting.mdx("remains schema-valid during the SignalRef migration window"). Reword the PR body so the merge commit and the 3.1 changelog don't diverge from the schema. docs/reference/whats-new-in-3-1.mdxis silent on the migration. Largest single docs gap. Adopters on that page won't learn about SignalRef,signal_targeting_groups,signal_targeting_allowed/options/rules/included_signals, or the deprecation window. Add a short "SignalRef migration and product-scoped signal targeting" block.docs/reference/glossary.mdx:26— Audience Selector backslides tosignal_id. Same PR updatesdocs/governance/campaign/specification.mdx:390to "asignal_refor a natural language description"; the glossary entry should match its sibling.- No removal version named for
signal_id/signal_ids/ flatsignal_targeting. Changeset andsignal-id.json:6use "deprecated" + open-ended "migration window" with nox-removed-inanchor. Pick a target (4.0?) so the carry cost doesn't drift indefinitely. - No direct positive/rejection tests for
package-signal-targeting-groups.jsonandpackage-signal-targeting-group.json. The echo vector atstatic/test-vectors/media-buy/package-status-targeting-overlay-echo.jsonexercises the wire shape, but composed-schema-validation has no cases for: reject top-leveloperator: \"any\", reject emptygroups/signals, reject child operator other thanany/none. Add when convenient. docs/reference/glossary.mdx:312-313— "Signal Source" overloads two concepts.signal_type: signal_source(the value-type enum) andsignal_ref.scope: \"signal_source\"(the discriminator) want either split entries or an explicit cross-reference.
Minor nits (non-blocking)
additionalProperties: trueon the package signal targeting group schemas (package-signal-targeting-groups.json:26,package-signal-targeting-group.json:29, all threesignal-ref.jsonbranches). Consistent with the codebase. Thenot.anyOfcarry-over rejections are doing the load-bearing branch-hygiene work — worth knowing the gate is weaker thanfalseif a future field appears that needs strict rejection.
Safe to merge. Worth fixing the PR-body wording before the changelog gets cut so 3.1 ships with one story about flat signal_targeting, not two.
There was a problem hiding this comment.
The automated review encountered an issue (possibly reached max turns, timed out, or failed to post the final gh pr review). A human reviewer should take this PR.
This is an automated message from the Argus AI review workflow.
9dbc80e to
92e49f4
Compare
There was a problem hiding this comment.
Approving. Coherent minor-version protocol move with clean schema architecture and an honest legacy/canonical migration story.
Things I checked
- SignalRef scope hygiene is load-bearing.
static/schemas/source/core/signal-ref.json:27-35,59-66,90-97— thenot.anyOfclause on every branch rejects foreign-namespace carry-over (data_provider_domain,signal_source_url,agent_url,source,id). Each clause is exercised by a negative test intests/composed-schema-validation.test.cjs:636-685. The product-scope value-collision case is the right invariant to lock in. - Product invariants are tested both directions.
static/schemas/source/core/product.json:543-561(signal_targeting_optionsorsignal_targeting_rules⇒signal_targeting_allowed: true) is matched by positive + two negative tests attests/composed-schema-validation.test.cjs:786-838. Resolution-model enum rejection coversbuyer_plannedcorrectly. - Binary
value: falsecannot leak through package-level entries.package-signal-targeting.json:7-11allOfssignal-targeting-expression.json:24-27(value: const true); the wrapper does not redeclarevalue. Wire shape matches the docs' "use parent groupnonefor exclusion" claim atdocs/media-buy/advanced-topics/targeting.mdx:536. - Audit-oneof gate is satisfied. New discriminated
oneOfschemas (signal-ref.jsononscope,signal-targeting-expression.jsononvalue_type) classify asdiscriminatedand need no baseline entry.audience-selector.jsonstaysdangerousin baseline — samekind, no regression. - adagents.json claim verified.
static/schemas/source/adagents.jsonsignals[].idandauthorized_agents[].signal_ids[]remain local catalog strings, not the deprecated SignalId object — the PR description is accurate. minordesignation is grounded.docs/reference/versioning.mdx:23,128explicitly sanctions required → optional flips within a minor, andCHANGELOG.md:1291is the canonical precedent (protocol-enveloperequired:[status,payload]relaxation). Theget_signals.signals[].data_provider/pricing_optionsrelaxation lives in the same risk class.- Index registrations are complete.
static/schemas/source/index.json:361-406exposes all seven new core schemas. - Compliance scenario error.field path is correct.
static/compliance/source/protocols/media-buy/scenarios/product_signal_targeting.yaml:371-372citespackages[0].targeting_overlay.signal_targeting_groups.groups[0].signals[0].signal_ref— matches the schema nesting exactly.
Follow-ups (non-blocking — file as issues)
audience-selector.jsonnumeric branch is missing the min/maxanyOf. Lines 99-119 requirevalue_typebut notmin_valueormax_value.signal-targeting-expression.json:80-83carries theanyOf:[{required:min_value},{required:max_value}]that the selector should mirror. Today,{type:"signal", value_type:"numeric", signal_ref:{...}}with neither bound schema-validates — a nonsensical "any value of this numeric signal" predicate. Tightening this in a follow-up patch is allowed underversioning.mdx:130and aligns with the inline comment ataudience-selector.json:106.- No AJV coverage on the headline package-level payload.
tests/composed-schema-validation.test.cjsadds 270 lines and still has zero references tosignal_targeting_groups,package-signal-targeting, orselection_group_rules. The compliance YAML exercises the shape end-to-end at runtime, but the AJV unit suite should also lock in: theoperator: const "all"top, the binaryvalue: const truerejection (proving the "use parentnone" claim), and at least one positive + negative forselection_group_rules(missing requiredselection_group, invalidtargeting_mode,minItems:1). - Same
signal_refin bothanyandnonegroups is currently unguarded.docs/media-buy/advanced-topics/targeting.mdx:435has a SHOULD-reject rule forgeo_countries/geo_countries_excludesame-value overlap; nothing analogous appears near thesignal_targeting_groupssection at:524-540. Decide whether to add a SHOULD-reject or leave implementation-defined, but pick one. min_selected_signals: 0+selection_mode: "required"is schema-valid but semantically contradictory.signal-targeting-rules.json:79-83(top-level and insideselection_group_rules[]). Prose says sellers MUST treat the minimum as 1; consider anif/thenraisingminimumto 1 whenselection_mode === "required", or surface as a documented runtime-enforced rule. Otherwise adopters will encode the contradiction.- Legacy
value: false→ newnonegroup migration row is missing from the docs migration table. The shape divergence is described in the deprecation note attargeting.mdx:529,536but not represented as a row in any migration table. Buyers reading only the legacysignal-targeting.json:28-31schema will not find the mapping. scope: "product"portability caveat lives only in the schema description.product-filters.json:205calls out "seller-local exact option matching only, not a portable semantic identifier." The docs analog atmedia-products.mdx:505says the weaker "meaningful only within the selected product/package context." Surface the portability framing intargeting.mdxnear thesignal_targeting_groupssection so a buyer building multi-product discovery filters lands on it before the bug.
Minor nits (non-blocking)
- Notable that 2051 added lines and a new compliance scenario ship without an AJV positive case for
signal_targeting_groupsitself. The compliance YAML covers the wire path; the unit suite should still pin the shape so a future refactor of theoneOfbranches doesn't silently widen it.
LGTM. Follow-ups noted below.
|
Q1. Agreed: "the buyer selects what audience; the seller resolves which avails at booking time" is the right framing, and it captures the AudioGraph case cleanly. The schema separation between buyer-selected signals and seller-resolved booking is the correct primitive. A structured resolution_model field would be useful as an additive future improvement — it would let agents reason about when not to attempt buyer-side optimization — but description carries the narrative for now. Happy to follow up with a separate issue if the working group thinks it's worth scoping. Q2. Got it — selection_mode: "required" plus selection_group_rules covers what we need. The mixed mandatory/optional pattern (required audience tier + optional exclusions) is exactly the shape AudioGraph products will use. Q3. Makes sense — scope: "data_provider" for the portable AudioGraph audiences, scope: "product" reserved for genuinely product-specific signals like broadcast daypart-availability. That's the right partition and it keeps the namespace clean across iHeartMedia's product portfolio.One extension worth flagging: the temporal-planning rationale for seller-side resolution isn't unique to broadcast radio. Live events show the same pattern — sports, political programming, tentpole live broadcasts. Inventory arrives in bursts, requires advance planning, and isn't typically traded in real-time. The buyer can't reason about which audiences will be available against which live windows; the seller has to resolve audience-to-avail at planning time, not impression time.This suggests the "buyer-composable, seller-resolved-to-scheduled-inventory" pattern is a broader category than broadcast radio alone — it's any inventory where supply is scheduled in advance and constrained by time-bound availability rather than continuously auctioned. Worth naming explicitly in the spec so live-event sellers know which primitives apply to them. Could be a docs addition rather than a schema change. |
|
Q1 and Q2 confirmations close those threads — the schema separation and The Q3 live-events observation is worth capturing explicitly. The "buyer-composable, seller-resolved-to-scheduled-inventory" rationale does generalize cleanly to any time-bound supply that arrives in bursts and requires advance planning rather than real-time auction: live sports, political programming, tentpole broadcasts. The pattern is the same primitive; broadcast radio is just the motivating example in scope for this PR. @bokelley — this feels like a one-to-two sentence addition to whatever narrative section explains when sellers resolve audiences rather than buyers. Worth folding into this PR while the context is warm, or would you prefer a follow-up issue so this PR can close without scope creep? Generated by Claude Code |
Adds one sentence clarifying that the seller_planned model applies to any time-bound supply scheduled in advance, not only linear broadcast audio — live sports, political programming, and tentpole broadcasts follow the same primitive. Prompted by iHeartMedia AudioGraph comment on PR thread. https://claude.ai/code/session_019VvFeX77WCZen9dZCmkgsq
|
Folded the live-events generalization in — ec29498. Added one sentence at the end of the
@geneticallymodifiedfoodforthought — the AudioGraph context surfaced a real gap in the docs. The pattern was already there in the schema; it just needed naming for live-event sellers. Generated by Claude Code |
|
Good generalization — Generated by Claude Code |
There was a problem hiding this comment.
LGTM. Follow-ups noted below. SignalRef migration is internally consistent — branch hygiene via not.anyOf blocks, allOf if/then gating signal_targeting_options on signal_targeting_allowed: true, and explicit discriminator on scope / value_type all land cleanly.
Things I checked
- SignalRef branch hygiene.
signal-ref.json:27-35,59-66,90-97— each scope branch rejects cross-namespace fields (data_provider_domain,signal_source_url,agent_url,source,id). Verified the composed-schema test asserts this attests/composed-schema-validation.test.cjs:631-685. - Product gating invariant.
product.json:543-560allOf if/thenrequiressignal_targeting_allowed: truewheneversignal_targeting_optionsorsignal_targeting_rulesis present. Composed-schema test confirms at 805-821. - Binary expression shape.
signal-targeting-expression.json:23-27pinsvalue: trueconstfor binary, so exclusion comes only from a parentnonegroup. Docs attargeting.mdxandcreate_media_buy.mdxagree. - Discriminator audit. Both new
oneOfschemas (signal-ref.json,signal-targeting-expression.json) declarediscriminator.propertyNamewith const+required values.scripts/audit-oneof.mjs --checkshould classify them asdiscriminated; no baseline ratchet needed. - Legacy fields all
deprecated: true.signal-id.json:5,get-signals-response.json:31,audience-selector.json:23,57,97,product.json:148. Migration window contract is honest. - Legacy
signal_idfallback on wholesale events.wholesale-feed-event.json:611-614$defs/signalcarriesanyOf: [signal_ref, signal_id]. The changeset's "signal_id remains accepted on wholesale signal events" claim is wired through the shared listing definition, not duplicated per-event. - Index registration.
static/schemas/source/index.json:361-407registers all 8 new core schemas; no duplicate$ids. - Changeset present and named.
.changeset/signal-targeting-overlay.mdexists with migration prose.
Follow-ups (non-blocking — file as issues)
get-signals-response.json:109-120and the mirrored$defs/signalatwholesale-feed-event.json:603-609dropdata_providerandpricing_optionsfromrequired. Consumer-side contract relaxation: a 3.0 buyer that destructuressignals[].data_providerorsignals[].pricing_options[0]will readundefinedfrom a 3.1 server that omits them. Changeset isminorand frames this intentionally ("buyers should not assume…"), and this fits AdCP 3.1's documented migration posture. Worth a separate sanity pass against external buyer-agent pins — if any are hard-coded to requireddata_provider/pricing_options, consider anif/thenkeeping them required forsignal_type: marketplaceand relaxing only the source-native cases.docs/reference/glossary.mdx:26(Audience Selector entry). Reads "either a signal reference (pointing to a specificsignal_id)" — re-introduces thesignal_idframing in the same file that already has a "Signal Ref" entry at line 310 and a "Signal ID — Deprecated" entry at line 304. Should readsignal_reffor internal consistency.tests/example-validation.test.cjsonly updates the existingget_signalsfixture; no new example coverssignal_targeting_groups,signal_targeting_options, orincluded_signals. Composed-schema is doing the load-bearing coverage, but example coverage catches MDX drift later.- Compliance scenario fixtures
product_signal_targeting.yaml:69-87omit severalproduct.json-required fields (description,delivery_measurement, pricingrate/currency,reporting_capabilities). Confirm the compliance controller hydrates these before thevalidations.response_schemacheck at line 126 fires — otherwise schema validation fails before field-value asserts run. - Glossary entries for
signal_targeting_groups,signal_targeting_options,signal_targeting_rules,included_signals,selection_group,resolution_model— five new public schema concepts repeating acrossmedia-products.mdx,targeting.mdx,create_media_buy.mdx, andwhats-new. Glossary anchors per concept close the loop.
Minor nits (non-blocking)
- Loose
additionalProperties: trueon SignalRef branches.signal-ref.json:36,67,98. Thenot.anyOfblock correctly rejects the named cross-namespace fields, but any typo or unlisted deprecated alias still passes the type check. Acceptable migration-window tradeoff; tighten in a future major bump. - Co-located
signal_idsinadagents.jsonauthorization selector.docs/signals/data-providers.mdx:194,391andwhats-new-in-3-1.mdx:139referencesignal_idsarrays unrelated to the deprecatedSignalIdobject — they're local catalog IDs in the authorization grammar. A casual reader skimming forsignal_idwill trip. One-line clarifying parenthetical helps. docs/media-buy/task-reference/create_media_buy.mdx:537-616duplicates the (A OR B) AND NOT (C) example already present attargeting.mdx:556-590. Keep one minimal example here, link out for the longer walk-through.
Safe to merge.
There was a problem hiding this comment.
Request changes. The product-scoped signal targeting design is the right shape — included_signals for descriptive metadata, signal_targeting_options for selectable controls, signal_targeting_groups as the portable Boolean baseline, signal_ref with scope discriminator, legacy signal_id kept as deprecated. One asymmetric break needs to be reconciled with the rest of the PR before merge.
MUST FIX
product-filters.json:203-225 silently rejects legacy signal_id on get_products.filters.signal_targeting[]. Before this PR, the filter item used $ref: /schemas/core/signal-targeting.json, which accepts signal_ref OR deprecated signal_id via anyOf (signal-targeting.json:34-37). The new shape allOfs /schemas/core/signal-targeting-expression.json, which requires signal_ref and has no signal_id branch (signal-targeting-expression.json:29,54,79). A buyer agent calling get_products with filters.signal_targeting[].signal_id — the only shape that existed before signal_ref — now fails validation.
This is the only site in the PR where signal_id is dropped. targeting.json:443-451, signal-targeting.json:34-37, audience-selector.json:36-39, signal-listing.json:64-67, wholesale-feed-event.json:610-613, and get-signals-request.json:65-72 all keep the anyOf [signal_ref, signal_id] deprecation bridge. The changeset (signal-targeting-overlay.md:13) explicitly promises "During this minor-version migration window, legacy signal_id remains accepted on signal listings, audience selectors, legacy flat signal targeting, and wholesale signal events" — the filter slipped the list.
Two acceptable fixes:
- Add the
anyOf [signal_ref, signal_id]bridge to the filter item to match every other surface in the PR. - Bump the changeset to
majorand add a migration note calling out this filter explicitly.
Per the AdCP semver rule (CHANGELOG), shipping a tightening that rejects previously-valid input under a minor changeset is the block. The asymmetry was flagged independently by code-reviewer and ad-tech-protocol-expert.
Things I checked
signal-ref.jsonis properly discriminated onscope, withnot.anyOffencing carry-over ofdata_provider_domain,signal_source_url,agent_url,source,idacross branches. The audit walker'sdiscriminatedclassification holds.product.jsonadds anallOf if/theninvariant: presence ofsignal_targeting_optionsorsignal_targeting_rulesforcessignal_targeting_allowed: true(product.json:543-560). Right shape.signal-listing.jsonrequires inlinename+value_typeonly whensignal_ref.scope === "product"— data-provider and signal-source refs defer to the authoritative catalog. Right shape.targeting.json:443-451correctly preserves flatsignal_targetingas deprecated; new selection usessignal_targeting_groups.get-signals-response.json:117-120keepsanyOf [signal_ref, signal_id]for the wholesale-feed response — backwards-compatible.- Wholesale-feed event payloads (
wholesale-feed-event.json:610-613) carry the sameanyOfbridge. - The compliance scenario at
static/compliance/source/protocols/media-buy/scenarios/product_signal_targeting.yaml:362-373correctly exercises seller rejection of an unadvertisedsignal_refwith error.field pointing at the offending path. - Docs are consistent with the schema vocabulary (
product/data_provider/signal_sourcescopes;all/any/noneoperators) acrossmedia-products.mdx,targeting.mdx,create_media_buy.mdx,get_products.mdx,get_signals.mdx,whats-new-in-3-1.mdx,glossary.mdx. - Changeset is
minorwith deprecated paths kept; the wire-impact justifiesminorif the filter is reconciled. Otherwisemajor.
Follow-ups (non-blocking — file as issues)
get_signals.mdx:481,914— two example response payloads still emitsignal_agent_segment_idwithoutsignal_ref. The field-descriptions block at line 121 says new responses SHOULD include it; the canonical example at line 82 does. Backfill the two legacy examples.media-products.mdx:409andcreate_media_buy.mdx:537— scope list mentions onlyproductanddata_provider; addsignal_sourcefor symmetry withtargeting.mdx:23andkey-concepts.mdx.signal-listing.json:64-67+if/thenat 68-89 — a legacysignal_id-only listing escapes thename+value_typerequirement that applies tosignal_ref.scope === "product". Acceptable as a migration-window concession but worth a comment.signal-ref.json:36,67,98— each branch'snot.anyOflist is hand-maintained per scope. Adding a future identity field requires updating two other branches' exclusion lists. ConsideradditionalProperties: falseplus an explicit allowlist when the migration window closes.
Minor nits (non-blocking)
package-signal-targeting-groups.json:9-12.operator: { const: "all" }boxes v1 in; future expansion toany/noneat the top level would be a non-breaking enum widening for read-validators but a codegen break for clients that ship theconstas a literal type. Considerenum: ["all"]now.product_signal_targeting.yaml:99-104.stateful: falseonget_products_wholesalewhile it feedsproduct_idinto downstream stateful steps is inconsistent with adjacent discover→create scenarios. Probably harmless given storyboard semantics; worth aligning.signal-catalog-type.json:14-15. Em-dashes replaced with ASCII hyphens in two enumDescriptions. Stylistic only.
Drop the filter break or bump to major and this ships clean.
There was a problem hiding this comment.
LGTM. Follow-ups noted below. The SignalRef discriminator with explicit not.anyOf branch hygiene is the right shape — legacy SignalId stays opt-in via anyOf on every consumer surface while new buy-time entrypoints (signal-targeting-expression.json, product-signal-targeting-option.json) require canonical signal_ref. Four-surface product model (included_signals / signal_targeting_allowed / signal_targeting_options / signal_targeting_rules) is non-overlapping, and the allOf invariant at static/schemas/source/core/product.json:543-560 (options-or-rules → signal_targeting_allowed: true) is enforced where it matters.
Things I checked
- Changeset
.changeset/signal-targeting-overlay.mdexists, classifiedminor, prose matches wire impact (legacySignalId/signal_ids/flatsignal_targetingretained as deprecated, no required-flips on request fields). - No new undiscriminated
oneOf.SignalRefis discriminated onscope;signal-targeting-expression.jsonis discriminated onvalue_type. No regression againstscripts/oneof-discriminators.baseline.json. - Cross-surface alignment:
signal_refthreads throughaudience-selector.json,signal-targeting.json,signal-listing.json,wholesale-feed-event.json,get-signals-request.json/get-signals-response.json. Every consumer that previously acceptedsignal_idkeeps it asanyOfwith the deprecation noted. - Buy-time fence holds:
package-signal-targeting.jsonextendssignal-targeting-expression.json, which requiressignal_refand does not accept legacysignal_id.product-signal-targeting-option.json:26requiressignal_ref. - Docs↔schema coherence on the headline surfaces —
media-products.mdx,get_products.mdx,create_media_buy.mdx,advanced-topics/targeting.mdx,whats-new-in-3-1.mdx,glossary.mdx. The five user-visible terms (included_signals,signal_targeting_allowed,signal_targeting_options,signal_targeting_rules,signal_targeting_groups) thread cleanly through all four pages. - Test coverage:
tests/composed-schema-validation.test.cjs(+287) exercises the inline-options-requires-allowed invariant, branch hygiene on eachSignalRefscope, and the product-localname+value_typerequirement.tests/example-validation-simple.test.cjs(+114) covers the package-group composition path. - Compliance scenario
static/compliance/source/protocols/media-buy/scenarios/product_signal_targeting.yaml(+373) exercises the wholesaleget_signals→ compose →create_media_buyflow end-to-end. get_signals.signals[]required-list relaxation (data_provider,pricing_options→ optional). Conservative direction for a response field; called out in the changeset.
Follow-ups (non-blocking — file as issues)
- PR description overstates two invariants vs what shipped. Body says "Enforces groups-only buy-time targeting by rejecting legacy flat `targeting_overlay.signal_targeting`" — schema at `static/schemas/source/core/targeting.json:443` and the composed test at `tests/composed-schema-validation.test.cjs:866-878` confirm the flat form is still schema-valid (deprecated, not rejected — which matches the changeset prose). Body also says "Updates `get_signals` response to require `signal_ref`"; `static/schemas/source/signals/get-signals-response.json:117-119` requires `anyOf: [signal_ref, signal_id]` during the migration window. Either tighten the schemas to match the description, or reword the description. The migration-window posture is the right call; the body is the thing that's wrong.
- `error-code.json` parity miss. `static/schemas/source/enums/error-code.json:323` `SIGNAL_NOT_FOUND.suggestion` still says "verify signal_id via get_signals" while the description at L127 was updated to `signal_ref`. One-line fix.
- oneOf baseline note is stale (not regressed). `scripts/oneof-discriminators.baseline.json:159-163` notes `core/audience-selector.json` as `dangerous` with the pre-PR required list. Walker keys diff by file/pointer so CI won't fail, but the next baseline regen should refresh the note to reflect the four `signal_ref`-or-`signal_id` `anyOf` variants.
- Compliance storyboard only exercises the happy wholesale path. `product_signal_targeting.yaml` doesn't cover (a) seller-side rejection of flat `signal_targeting` at buy time, (b) the `signal_targeting_options` ⇒ `signal_targeting_allowed: true` invariant, (c) `seller_planned` resolution, or (d) the `selection_group_rules` "exactly one selection_group per child group" rule from `signal-targeting-rules.json:54`. Schema tests cover the structural invariants; add storyboard phases or trim the body claim.
- `signal-listing.json` and `get-signals-response.json` allow `signal_ref` and `signal_id` to coexist with no consistency constraint. A listing carrying both pointed at different signals is schema-valid. Add a "MUST prefer `signal_ref` when both are present" callout in the docs, or an `oneOf`-style guard. Low risk during migration but worth fencing before the deprecation window closes.
- Field duplication between `wholesale-feed-event.json` and `signal-listing.json`. `wholesale-feed-event.json:516-616` re-declares most listing fields via `$defs` rather than `$ref`. First field added to `signal-listing.json` will drift. Either inline the `$ref` or add a drift guard.
Minor nits (non-blocking)
- Real brand domain reintroduced in get_signals examples. The follow-up commit `f293938a3` swapped most provider names, but `docs/signals/tasks/get_signals.mdx` L228, L373, L483, L895, L980 still carry `experian.com`. `tests/example-validation.test.cjs:296` already does the `pinnacle-data.example` substitution on the fixture side — mirror it in the .mdx.
- Long single bullets in docs. `docs/media-buy/product-discovery/media-products.mdx:415` collapses six independent `signal_targeting_rules` fields into one ~600-word bullet; `docs/media-buy/task-reference/get_products.mdx:180` is a ~200-word table cell. Both are accurate, but agent-readers parse better when each rule is its own sub-bullet.
- Pre-existing drift, surface while you're in the file. `docs/signals/tasks/get_signals.mdx:1025` references `"field": "filters.platforms"` in an error example, but `signal-filters.json` has no `platforms` property — `destinations` is the correct filter. Not yours, but worth a one-line fix.
- `signal-listing.json:71-87` conditional only triggers on `signal_ref.scope === 'product'`. A listing carrying only legacy `signal_id` slips past the name/value_type requirement. Safe in practice because legacy `signal_id` is data_provider/agent-scoped, but a comment noting why it's intentionally unconstrained would help the next reader.
Safe to merge once the description is reconciled with the deprecated-not-rejected reality.
Summary
Adds product-scoped signal metadata and signal targeting for media buys. Products now have two distinct surfaces:
included_signals: non-selectable signals already included in, bundled with, or planned into the product.signal_targeting_allowed, optional inlinesignal_targeting_options, andsignal_targeting_rules: package-level signal selection controls.Packages select or echo eligible targetable signals through
targeting_overlay.signal_targeting_groupswith top-leveloperator: "all"and childany/nonegroups.Wholesale signal discovery stays on
get_signalsinstead of adding another product field:get_products(wholesale)can returnsignal_targeting_allowed: truewithout inlinesignal_targeting_options; buyers then callget_signalsfor the selectable signal feed.get_products(brief)andget_products(refine)can inline relevant or product-specificsignal_targeting_optionswhen the seller wants to provide pricing, activation handles, defaults, grouping hints, or a curated subset.signal_targeting_rulesremains product-scoped because one seller can route products through different ad servers or planning layers.SignalRefis the canonical named-signal identity surface across discovery, targeting, audience selectors, and wholesale events:scope: "data_provider"resolves through a catalog-publishedadagents.jsonsignal usingdata_provider_domain + signal_id.scope: "signal_source"resolves a source-native signal that is not catalog-published, usingsignal_source_url + signal_id.scope: "product"resolves only in the selected product/package context.Product signal listings share one signal-ref-plus-definition shape. Product-local refs require inline
nameandvalue_type; data-provider and signal-source refs can be reference-only because the authoritative definition lives at the referenced catalog or source.Legacy
SignalId/signal_id.sourceandget_signals.signal_idsremain as deprecated compatibility fields.adagents.jsonsignals[].idand authorizationsignal_idsare still local catalog ids, not the deprecatedSignalIdobject.Product / Protocol Feedback Addressed
included_signalsas the non-selectable analogue to covered properties/placements: descriptive product metadata, not buyer-selected targeting.get_signals, then compose package signal groups without duplicating the full signal menu inline.get_adcp_capabilities, since one seller can route products through different platforms.targeting_overlay.signal_targeting.signal_targeting_allowed: true; wholesale products may declare rules without inline options and rely onget_signalsfor the selectable feed.nameorvalue_type; product-local refs still must define them inline.SignalRefbranch hygiene so refs reject incompatible namespace carry-over likedata_provider_domain,signal_source_url,agent_url,source, and legacyidwhere they do not apply.get_signalsresponse to requiresignal_ref; request lookup now usessignal_refs, with legacysignal_idsdeprecated.signal_refinstead of the deprecatedsignal_idobject.selection_group_rules: each child package group must contain signals from exactly oneselection_groupand one targeting mode; sellers reject mixed/collapsed groups.get_signals, not seller-planned audience products.signal_agent_segment_id, and agent/source naming.Validation
npm run test:schemasnpm run test:composednpm run test:json-schemanpm run test:docs-navnpm run test:examplesnpm run test:targeting-overlay-vectorsgit diff --checktest:unit,test:test-dynamic-imports,test:callapi-state-change,typecheck