feat(registry): preserve member-declared 'buying' type — closes #3549#3766
Merged
Merged
Conversation
This was referenced May 1, 2026
EmmaLouise2018
added a commit
that referenced
this pull request
May 1, 2026
#3776) Closes #3774. Surfaced by Brian-style red-team of #3766. Same inversion class as #3540 swept across filter sites, but 5 sites slipped through because they had a different shape — function-call arguments and local- inference re-implementations rather than equality comparisons against stored type. ## listAgents("buying") -> listAgents("sales") at 3 call sites - server/src/http.ts:2007 — POST /api/crawler/run - server/src/http.ts:8729 — periodic property crawler boot (variable also renamed buyingAgents -> salesAgents and the comment fixed) - server/src/mcp-tools.ts:831 — find_agents_for_property MCP tool These pass the agent set into property-crawl logic that semantically operates on sales agents (the ones with publisher authorizations and list_authorized_properties responses). Pre-fix the filter was a no-op because the crawler internally re-filters on agent.type !== "sales" at crawler.ts:420 — but the call sites were misleading and load-bearing on the defensive re-filter. ## Diagnostic-endpoint local inference consolidated + polarity flipped server/src/http.ts:8442 and server/src/routes/registry-api.ts:5802 had duplicated inline inference that returned 'buying' for agents exposing get_products / create_media_buy / create_media — exactly inverted vs the canonical CapabilityDiscovery.inferAgentType. Visible bug for any consumer of the public discovery diagnostic endpoints: every sales agent reported as type='buying'. Extracted into a single helper: server/src/lib/diagnostic-agent-type-inference.ts Both endpoints now call inferDiagnosticAgentType. Loose substring matching preserved (separate concern from polarity); canonical strict-match path in CapabilityDiscovery is unchanged. ## Missing 'sales' in resource handler server/src/mcp-tools.ts:2058 hard-coded ["creative", "signals", "buying"] in its agent-resource type filter — pre-#3540 'buying' was the (incorrectly) only sell-side type, so 'sales' was never added. Post-#3540 this means agents://sales returns "Unknown resource type". Added 'sales' to the list. 'buying' kept because per #3766 the type is now meaningful. ## Tests New: server/tests/unit/diagnostic-agent-type-inference.test.ts pins the 12-case polarity matrix, including an exhaustive "NEVER returns 'buying'" invariant assertion. If anyone reverts polarity back to 'buying', the cases marked `// pinning #3774` fail loudly. 12/12 pass. Typecheck clean. ## Out of scope - Tightening the diagnostic helper's loose substring matching to align with CapabilityDiscovery.inferAgentType's strict matching. Loose matching is preserved here intentionally — diagnostic endpoints serve pre-onboarding probes where tool-name conformance is incomplete. Worth a follow-up if the diagnostic endpoints' false-positive rate becomes a concern. - Replacing the diagnostic helper with the canonical CapabilityDiscovery inference helper (different match semantics — strict vs loose). Same follow-up.
…nferred snapshot Closes #3549. Decision: keep `buying` in the `AgentType` enum AND make member self- declaration of `buying` actually stick. ## The bug Buy-side agents are CLIENTS of AdCP, not servers. They call sales / creative / signals tools, they don't expose them. The probe-based inference in CapabilityDiscovery.inferAgentType therefore always returns `'unknown'` for them — there is no passive signal that distinguishes a buying agent from a broken/empty MCP server. resolveAgentTypes in routes/member-profiles.ts was overriding member-set `type: 'buying'` to `'unknown'` on first probe, because case 2 of the priority chain ("snapshot exists but inferred_type is null") squashed EVERY non-inferred type to `'unknown'` for smuggle protection. Right intent, wrong polarity for `buying`. ## The fix Carve out `'buying'` specifically from the squash-to-unknown path. Member-declared `'buying'` is preserved when the snapshot can't classify; all other claimed types (sales / creative / signals) are still squashed to `'unknown'` on a null-inferred snapshot. Why this is safe: - Sales / creative / signals agents DO expose AdCP tools — when reachable, the probe will classify them. A snapshot row with null inferred_type means probe failed OR all tools unrecognised; in either case, the client claiming `'sales'` against a non-classifying snapshot is the smuggle window we're protecting against. - `'buying'` is structurally exempt: there is no probe signature that distinguishes buying from non-AdCP. A malicious actor claiming `'buying'` against a real sales agent will still get squashed when the probe DOES classify it (case 1 wins over case 2). ## What changed - `server/src/routes/member-profiles.ts` — `resolveAgentTypes` carves out `'buying'` in the snapshot-but-null-inferred branch. Docstring expanded from 3 cases to 4 explaining the rationale. - `server/src/capabilities.ts` — `inferAgentType` docstring updated to reference the carve-out path; inference itself unchanged. - `server/tests/unit/resolve-agent-types-buying.test.ts` — new file pinning the 7-case matrix (probe wins over client, buying preserved, smuggle blocked for sales/creative/signals, no-snapshot fallback). ## Test plan - New: `resolve-agent-types-buying.test.ts` — 7/7 pass. - `npx tsc --noEmit -p server/tsconfig.json` — clean. ## Backwards compatibility Non-breaking. No on-the-wire schema change. The only behavior change is that member-declared `type: 'buying'` now sticks across probes — previously it was silently squashed to `'unknown'`, which was the bug. Any consumer reading agent.type that expected only `sales | creative | signals | unknown` was already wrong: `'buying'` has been a valid AgentType all along (see types.ts:1, registry.ts:512). ## Out of scope - Inferring `'buying'` from a probe signal (intentionally not done — no reliable signal exists; would misclassify broken agents). - UI treatment for `'buying'` agents in the registry — pairs with the existing 4-type display surface. - `?type=buying` query param on /api/registry/agents — separate issue if/when buying agents register and we want filtered views.
9ee4a80 to
9d19780
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3549.
Decision: keep
buyinginAgentTypeAND make member self-declaration ofbuyingactually stick.The bug
Buy-side agents are CLIENTS of AdCP, not servers — they call sales / creative / signals tools, they don't expose them. The probe-based inference in
CapabilityDiscovery.inferAgentTypetherefore always returns'unknown'for them. There's no passive signal that distinguishes a buying agent from a broken/empty MCP server.resolveAgentTypesinroutes/member-profiles.tswas overriding member-settype: 'buying'→'unknown'on first probe, because case 2 of the priority chain ("snapshot exists but inferred_type is null") squashed EVERY non-inferred type to'unknown'for smuggle protection. Right intent, wrong polarity forbuying.The fix
Carve out
'buying'specifically from the squash-to-unknown path. Member-declared'buying'is preserved when the snapshot can't classify; all other claimed types (sales / creative / signals) are still squashed to'unknown'on a null-inferred snapshot.Why this is safe:
inferred_typemeans probe failed OR all tools unrecognised; in either case, the client claiming'sales'against a non-classifying snapshot is the smuggle window we're protecting against.'buying'is structurally exempt: no probe signature distinguishes buying from non-AdCP. A malicious actor claiming'buying'against a real sales agent still gets squashed when the probe DOES classify it (case 1 wins over case 2).What changed
server/src/routes/member-profiles.tsresolveAgentTypescarves out'buying'in the snapshot-but-null-inferred branch. Docstring expanded from 3 cases to 4 explaining rationale.server/src/capabilities.tsinferAgentTypedocstring updated to reference the carve-out path. Inference itself unchanged — still never returns'buying'.server/tests/unit/resolve-agent-types-buying.test.tsCoverage matrix (pinned by the new test file)
salesbuyingsalesbuyingbuyingsalesunknowncreativeunknownsignalsunknownbuyingbuyingbuyerunknownTest plan
server/tests/unit/resolve-agent-types-buying.test.ts— 7/7 pass.npx tsc --noEmit -p server/tsconfig.json— clean.Backwards compatibility
Non-breaking. No on-the-wire schema change. The only behavior change: member-declared
type: 'buying'now sticks across probes — previously silently squashed to'unknown', which was the bug. Any consumer readingagent.typethat expected onlysales | creative | signals | unknownwas already wrong:'buying'has been a validAgentTypeall along (seetypes.ts:1,registry.ts:512).Out of scope
'buying'from a probe signal (intentionally not done — no reliable signal exists; would misclassify broken agents).'buying'agents in the registry.?type=buyingquery param on/api/registry/agents— separate issue if/when buying agents register and we want filtered views.?source=) and feat(registry): endorsed_by_publisher_member on discovered agents — option C from #3547 #3565 (endorsed_by_publisher_member) but no merge-order dependency.