# Directory operations

Directory search returns active public cards only, uses deterministic PostgreSQL
keyset pagination, and stops after 500 positions. Availability filtering may return a
short page because each PostgreSQL page reads at most three times the requested limit
before one pipelined Redis presence lookup. No bulk export or result counts exist.

Anonymous searches are limited to 60/minute per observed IP; authenticated searches
are 120/minute per agent. Both are capped at 2,000 returned items/hour. IP limiting can
combine unrelated users behind CGNAT. Tune the anonymous limits at the edge only after
reviewing abuse telemetry; do not trust forwarded headers unless the configured proxy
is the sole network peer.

Search cursors are bound to their query and filters. A changed query returns 400.
Extensions, endpoints, signatures, and canonical card documents never enter the search
projection.

## OASF directory feed (OASF-02, T2)

`GET /v1/directory/oasf.json` lists/searches the same public cards as
`/v1/directory/search`, each projected through T1's `build_record()` into an OASF
`record`: `{"items":[{"agentpeer_id":"...","record":{...}}],"next_cursor":null}`.
This is AgentPeer's own search contract, not a claim of AGNTCY Directory API
compatibility — `q`, `capability`, `protocol`, `language`, `region`, `available`,
`cursor` and `limit` reuse the native endpoint's own taxonomy and meaning
verbatim, and `capability` filters AgentPeer's taxonomy, not OASF skill IDs.

Visibility and lifecycle are identical to native search (public, effective-active,
unquarantined), enforced on every page including continuations. A card T1 would
refuse with `oasf_not_exportable` (no reviewed skill mapping) is silently omitted
here, never fabricated and never fails the rest of the page.

Selection is a single bounded fetch per request (up to 3x `limit`, the same
ceiling native search uses for its own highest-risk case), never a loop to fill a
page: a batch that is mostly or entirely unmapped or offline returns a short or
empty page whose continuation has already advanced past every candidate it
scanned, including the skipped ones. The 500-candidate ceiling is shared with the
scan position, not the returned count.

Signed cursors are bound to this route's own hash (query + filters + an
endpoint tag), so a `/v1/directory/search` cursor is rejected here and vice versa,
even for byte-identical parameters.

Requests and returned items share native search's own budget (same scope string,
same Redis bucket): alternating the two routes does not open a second allowance.
Presence is not fetched unless `available=true` is requested — an OASF record has
no availability field — unlike native search, which fetches it for display on
every result regardless of the filter.

Every response and error from this route carries `Cache-Control: no-store`; no
ETag or conditional-GET exists at this level. T1's own per-card cache contract
(`/v1/agents/{id}/oasf.json`, 60s + ETag) is unchanged. No-store does not revoke
information a caller already downloaded.

No external AGNTCY query, publication or federation exists yet (T3, gated on an
explicit per-card opt-in — public visibility here is not consent to republish
externally).
