Skip to content

feat(registry): preserve member-declared 'buying' type — closes #3549#3766

Merged
EmmaLouise2018 merged 1 commit into
mainfrom
EmmaLouise2018/buying-type-preservation
May 1, 2026
Merged

feat(registry): preserve member-declared 'buying' type — closes #3549#3766
EmmaLouise2018 merged 1 commit into
mainfrom
EmmaLouise2018/buying-type-preservation

Conversation

@EmmaLouise2018

Copy link
Copy Markdown
Contributor

Closes #3549.

Decision: keep buying in AgentType 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's 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''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: 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

File Change
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 rationale.
server/src/capabilities.ts inferAgentType docstring updated to reference the carve-out path. Inference itself unchanged — still never returns 'buying'.
server/tests/unit/resolve-agent-types-buying.test.ts New file. 7/7 pass.

Coverage matrix (pinned by the new test file)

# snapshot client result reason
1 inferred=sales buying sales probe wins
2 inferred=null buying buying carve-out
3 inferred=null sales unknown smuggle blocked
4 inferred=null creative unknown smuggle blocked
5 inferred=null signals unknown smuggle blocked
6 no snapshot buying buying case 4 (validated)
7 no snapshot buyer unknown legacy string drop

Test plan

  • New: server/tests/unit/resolve-agent-types-buying.test.ts — 7/7 pass.
  • npx tsc --noEmit -p server/tsconfig.json — clean.
  • Pre-commit hooks green.

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 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

@EmmaLouise2018 EmmaLouise2018 requested a review from bokelley as a code owner May 1, 2026 17:16
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.
@EmmaLouise2018 EmmaLouise2018 force-pushed the EmmaLouise2018/buying-type-preservation branch from 9ee4a80 to 9d19780 Compare May 1, 2026 21:00
@EmmaLouise2018 EmmaLouise2018 merged commit 435a537 into main May 1, 2026
13 checks passed
@EmmaLouise2018 EmmaLouise2018 deleted the EmmaLouise2018/buying-type-preservation branch May 1, 2026 21:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

decision(schemas): remove 'buying' from AgentType enum, or keep for future use?

1 participant