From 2373f9a782c19db7e4d1e5ef5106623c2a072dc7 Mon Sep 17 00:00:00 2001 From: kperry Date: Thu, 23 Jul 2026 18:02:39 +0200 Subject: [PATCH 1/2] feat(finder): make the publisher host free-text searchable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free-text search previously covered displayName, description, capabilities, and tags — but not the one text every entry reliably carries, the publisher host. A quickstart user who registered translator.example.com and searched "translator" got zero results unless the display fields happened to repeat the word. Add publisher as a fifth FTS5 column. FTS5 has no ALTER TABLE, so migration 002 drops and recreates the table and repopulates it from the base and side tables — self-contained, no feed replay, ACTIVE rows only so tombstoned agents are not resurrected. insertFTS threads the same publisher value the base-row insert derives, so the two columns agree by construction. Open now records applied migrations and ans-finder logs them at startup, making the first in-place schema upgrade diagnosable from logs alone. Document the text-matching contract in the finder spec (exact-token AND, case/diacritic-insensitive, dotted terms match as phrases, FTS operators are literal, ranking informational) and align the Query.text description, its example, and the score description with it. register.sh now derives demo display text from the host — presentation, not a search requirement — truncated to the RA's field limits. Co-Authored-By: Claude Fable 5 Signed-off-by: kperry --- cmd/ans-finder/main.go | 6 + internal/adapter/docsui/openapi/finder.yaml | 40 ++++- internal/adapter/store/sqlitefinder/apply.go | 18 ++- .../sqlitefinder/migrate_internal_test.go | 142 ++++++++++++++++++ .../migrations/002_fts_publisher.sql | 56 +++++++ .../sqlitefinder/search_publisher_test.go | 82 ++++++++++ internal/adapter/store/sqlitefinder/sqlite.go | 14 ++ scripts/demo/register.sh | 18 ++- spec/api-spec-finder-v1.yaml | 40 ++++- 9 files changed, 392 insertions(+), 24 deletions(-) create mode 100644 internal/adapter/store/sqlitefinder/migrate_internal_test.go create mode 100644 internal/adapter/store/sqlitefinder/migrations/002_fts_publisher.sql create mode 100644 internal/adapter/store/sqlitefinder/search_publisher_test.go diff --git a/cmd/ans-finder/main.go b/cmd/ans-finder/main.go index ce47686..6afdbe6 100644 --- a/cmd/ans-finder/main.go +++ b/cmd/ans-finder/main.go @@ -116,6 +116,12 @@ func run(cfgPath string) error { logger.Warn().Err(err).Msg("index close") } }() + // Surface schema upgrades: a migration can rewrite the whole FTS + // index between "starting" and "listening", so an operator must be + // able to confirm from logs alone that it ran. + if applied := store.AppliedMigrations(); len(applied) > 0 { + logger.Info().Strs("migrations", applied).Msg("applied index migrations") + } // Feed client + poller. feedClient, err := poller.NewHTTPFeedClient(cfg.Feed.BaseURL, cfg.Feed.AllowHTTP, cfg.Feed.Timeout) diff --git a/internal/adapter/docsui/openapi/finder.yaml b/internal/adapter/docsui/openapi/finder.yaml index 79933a5..479f180 100644 --- a/internal/adapter/docsui/openapi/finder.yaml +++ b/internal/adapter/docsui/openapi/finder.yaml @@ -106,6 +106,26 @@ paths: set when it satisfies the relevance criteria for `text` AND every constraint in `filter`. + **Text matching contract (this registry).** ARDS leaves text + relevance criteria registry-defined; this Finder matches as + follows. `query.text` splits on whitespace into terms, and every + term must match the entry across its searchable fields — + `displayName`, `description`, `capabilities`, `tags`, and the + publisher host. Matching is case- and diacritic-insensitive but + exact-token: no stemming and no prefix expansion, so "translate" + does not match "translator". Prefer content keywords — filler + words must also match and usually exclude everything. A term + containing separators (dots, hyphens) matches as a **phrase**: + its sub-tokens adjacent and in order within one field. So + `translator.example.com` matches that publisher host, and + "translator" alone matches too, but `translator.com` does not — + separate the words with spaces (`translator example`) to match + them independently. FTS query operators in the input (AND, OR, + `*`, quotes, …) are treated as literal text, never as syntax. + Results are currently ordered by bm25 relevance; the ranking + algorithm is informational, not part of this contract, and may + improve without notice. + **Why POST.** The query carries a free-text string plus a structured `filter` whose keys are dot-separated field paths (ARDS §7.1); this does not fit a query string. The body shape is @@ -174,7 +194,8 @@ paths: returns an aggregation over the matched set rather than ranked entries (ARDS §7.3). Explore lets a client introspect the registry — for example, "which media types are available?" — - narrowed by the same `text` and `filter` as search. + narrowed by the same `text` and `filter` as search. `query.text` + follows the search operation's **text matching contract**. For explore, `query.text` and `query.filter` are **both optional**; when both are absent, the aggregation covers the @@ -267,10 +288,13 @@ components: text: type: string description: | - Natural-language description of the need (ARDS §7.1). Narrows - the result set by semantic relevance. Required for search, - optional for explore. - example: find me a flight booking agent + Free-text keyword terms describing the need (ARDS §7.1). + Narrows the result set by relevance under the **text matching + contract** documented on the search operation (exact-token, + all terms must match) — prefer content keywords over filler + words. Applies identically to search and explore. Required + for search, optional for explore. + example: flight booking filter: $ref: '#/components/schemas/Filter' @@ -397,8 +421,10 @@ components: minimum: 0 maximum: 100 description: | - Semantic relevance ranking (0–100) computed by the - registry (ARDS §7.2). It is **strictly an informational + Relevance score (0–100) computed by the registry + (ARDS §7.2), normalized **within this result set** — the + best match scores 100 — so scores are not comparable + across queries. It is **strictly an informational relevance metric** and MUST NOT be interpreted by orchestrators as a cryptographic trust, compliance, or safety rating. Trust evaluation is decoupled and handled diff --git a/internal/adapter/store/sqlitefinder/apply.go b/internal/adapter/store/sqlitefinder/apply.go index 8242cc5..46ea4c7 100644 --- a/internal/adapter/store/sqlitefinder/apply.go +++ b/internal/adapter/store/sqlitefinder/apply.go @@ -233,6 +233,7 @@ func insertActiveRow(ctx context.Context, tx *sqlx.Tx, pe project.ProjectedEntry return fmt.Errorf("sqlitefinder: marshal entry: %w", err) } + publisher := publisherFromURN(pe.Entry.Identifier) res, err := tx.ExecContext(ctx, ` INSERT INTO finder_entries ( ans_name, type, url, identifier, publisher, agent_id, log_id, @@ -240,7 +241,7 @@ func insertActiveRow(ctx context.Context, tx *sqlx.Tx, pe project.ProjectedEntry lifecycle, created_at, expires_at, entry_json ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, pe.AnsName, pe.Entry.Type, pe.Entry.URL, pe.Entry.Identifier, - publisherFromURN(pe.Entry.Identifier), pe.AgentID, pe.LogID, + publisher, pe.AgentID, pe.LogID, pe.Entry.DisplayName, pe.Entry.Description, pe.Entry.Version, pe.Entry.UpdatedAt, string(project.LifecycleActive), pe.CreatedAt, pe.ExpiresAt, string(entryJSON), ) @@ -261,7 +262,7 @@ func insertActiveRow(ctx context.Context, tx *sqlx.Tx, pe project.ProjectedEntry if err := insertSideValues(ctx, tx, "finder_entry_attestation_types", rowid, attestationTypes(pe.Entry)); err != nil { return err } - return insertFTS(ctx, tx, rowid, pe.Entry) + return insertFTS(ctx, tx, rowid, pe.Entry, publisher) } // applyTombstone suppresses every ACTIVE row for the tombstone's ansName @@ -395,13 +396,16 @@ func insertSideValues(ctx context.Context, tx *sqlx.Tx, table string, rowid int6 } // insertFTS writes the FTS row for an entry, flattening capabilities and -// tags into space-joined text columns the tokenizer can index. -func insertFTS(ctx context.Context, tx *sqlx.Tx, rowid int64, e project.Entry) error { +// tags into space-joined text columns the tokenizer can index. publisher +// is the URN segment (the agent's host); indexing it lets +// free-text queries match host words — unicode61 splits on ".", so +// translator.example.com matches the token "translator". +func insertFTS(ctx context.Context, tx *sqlx.Tx, rowid int64, e project.Entry, publisher string) error { if _, err := tx.ExecContext(ctx, ` - INSERT INTO finder_entries_fts (rowid, display_name, description, capabilities_text, tags_text) - VALUES (?,?,?,?,?)`, + INSERT INTO finder_entries_fts (rowid, display_name, description, capabilities_text, tags_text, publisher) + VALUES (?,?,?,?,?,?)`, rowid, e.DisplayName, e.Description, - strings.Join(e.Capabilities, " "), strings.Join(e.Tags, " ")); err != nil { + strings.Join(e.Capabilities, " "), strings.Join(e.Tags, " "), publisher); err != nil { return fmt.Errorf("sqlitefinder: insert fts: %w", err) } return nil diff --git a/internal/adapter/store/sqlitefinder/migrate_internal_test.go b/internal/adapter/store/sqlitefinder/migrate_internal_test.go new file mode 100644 index 0000000..46e0e31 --- /dev/null +++ b/internal/adapter/store/sqlitefinder/migrate_internal_test.go @@ -0,0 +1,142 @@ +package sqlitefinder + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/agentnameservice/ans/internal/finder/index" +) + +// TestMigration002_RepopulatesFTSFromExistingRows exercises the upgrade +// path a deployed Finder takes: a database created at schema 001 — +// with entries already indexed under the four-column FTS shape — is +// opened by a binary that ships 002. FTS5 has no ALTER TABLE, so 002 +// drops and recreates the FTS table; this test proves the repopulation +// preserves the existing searchable text (display, capabilities) AND +// makes the publisher host searchable, without replaying the feed. +// +// Setup applies 001 verbatim from the embedded FS (not a hand-copied +// schema, so the test cannot drift from what shipped), seeds one ACTIVE +// and one REVOKED row exactly as the 001-era store would have left them +// (the revoked row has no FTS row — tombstones clear it), then lets +// Open apply 002. +func TestMigration002_RepopulatesFTSFromExistingRows(t *testing.T) { + t.Parallel() + ctx := context.Background() + path := filepath.Join(t.TempDir(), "finder.db") + + seedSchema001(ctx, t, path) + + // Open applies pending migrations — only 002 here. + s, err := Open(ctx, path) + if err != nil { + t.Fatalf("open at schema 002: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + + // The upgrade must be observable: callers log AppliedMigrations at + // startup, so it has to report exactly the migration that ran. + if got := s.AppliedMigrations(); len(got) != 1 || got[0] != "002_fts_publisher.sql" { + t.Fatalf("AppliedMigrations = %v, want [002_fts_publisher.sql]", got) + } + + cases := []struct { + name string + text string + want int + }{ + {name: "publisher host token now matches", text: "translator", want: 1}, + {name: "pre-existing display text preserved", text: "converter", want: 1}, + {name: "pre-existing description text preserved", text: "conversion", want: 1}, + {name: "pre-existing capabilities text preserved", text: "translate_text", want: 1}, + {name: "pre-existing tags text preserved", text: "linguistics", want: 1}, + {name: "revoked row not resurrected into FTS", text: "revokedhost", want: 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, err := s.Search(ctx, index.SearchQuery{Text: tc.text, Limit: 10}, + time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("search %q: %v", tc.text, err) + } + if len(res.Results) != tc.want { + t.Fatalf("text %q: got %d results, want %d", tc.text, len(res.Results), tc.want) + } + }) + } +} + +// seedSchema001 builds a database exactly as a 001-era store would have +// left it: 001 applied and recorded, one ACTIVE row with side values and +// a four-column FTS row, one REVOKED row with blanked display fields and +// no FTS or side rows. +func seedSchema001(ctx context.Context, t *testing.T, path string) { + t.Helper() + + body, err := migrationsFS.ReadFile("migrations/001_initial.sql") + if err != nil { + t.Fatalf("read embedded 001: %v", err) + } + + db, err := sqlx.ConnectContext(ctx, "sqlite", path+"?_pragma=foreign_keys(1)") + if err != nil { + t.Fatalf("connect for seed: %v", err) + } + defer func() { _ = db.Close() }() + + stmts := []string{ + `CREATE TABLE schema_migrations ( + version TEXT PRIMARY KEY, + applied_at_ms INTEGER NOT NULL + )`, + string(body), + `INSERT INTO schema_migrations(version, applied_at_ms) + VALUES('001_initial.sql', 0)`, + + // ACTIVE row: display fields share no tokens with the host, so a + // post-migration host match can only come from the new column. + `INSERT INTO finder_entries ( + id, ans_name, type, url, identifier, publisher, agent_id, log_id, + display_name, description, lifecycle, created_at, entry_json + ) VALUES ( + 1, 'ans://v1.0.0.translator.example.com', + 'application/mcp-server-card+json', + 'https://translator.example.com/.well-known/mcp.json', + 'urn:air:translator.example.com:agents:converter', + 'translator.example.com', 'agent-1', 'log-1', + 'converter', 'language conversion service', + 'ACTIVE', '2025-01-01T00:00:00Z', + '{"identifier":"urn:air:translator.example.com:agents:converter","displayName":"converter"}' + )`, + `INSERT INTO finder_entry_capabilities (entry_rowid, value) + VALUES (1, 'translate_text')`, + `INSERT INTO finder_entry_tags (entry_rowid, value) + VALUES (1, 'linguistics')`, + `INSERT INTO finder_entries_fts ( + rowid, display_name, description, capabilities_text, tags_text + ) VALUES (1, 'converter', 'language conversion service', 'translate_text', 'linguistics')`, + + // REVOKED row, as a tombstone leaves it: blanked display fields, + // no FTS row, no side rows. 002 must not give it one. + `INSERT INTO finder_entries ( + id, ans_name, type, url, identifier, publisher, agent_id, log_id, + display_name, description, lifecycle, created_at, entry_json + ) VALUES ( + 2, 'ans://v1.0.0.revokedhost.example.com', + 'application/mcp-server-card+json', + 'https://revokedhost.example.com/.well-known/mcp.json', + 'urn:air:revokedhost.example.com:agents:gone', + 'revokedhost.example.com', 'agent-2', 'tomb-2', + '', '', 'REVOKED', '2025-02-01T00:00:00Z', '{}' + )`, + } + for _, stmt := range stmts { + if _, err := db.ExecContext(ctx, stmt); err != nil { + t.Fatalf("seed stmt failed: %v\n%s", err, stmt) + } + } +} diff --git a/internal/adapter/store/sqlitefinder/migrations/002_fts_publisher.sql b/internal/adapter/store/sqlitefinder/migrations/002_fts_publisher.sql new file mode 100644 index 0000000..19f7a79 --- /dev/null +++ b/internal/adapter/store/sqlitefinder/migrations/002_fts_publisher.sql @@ -0,0 +1,56 @@ +-- Add the publisher host to the free-text search surface. +-- +-- The publisher (the agent's verified host, the URN's +-- segment) is the one text every entry reliably carries, and the first +-- thing a user types when they know the agent's domain — but 001 indexed +-- it only as a structured-filter column on the base table, so free-text +-- "translator" found nothing for translator.example.com unless the +-- publisher happened to repeat the word in its display fields. Adding +-- the column to the FTS table makes host words match: unicode61 treats +-- "." as a token separator, so translator.example.com indexes as the +-- tokens translator / example / com. Shared TLD tokens ("example", +-- "com") appear in nearly every row and bm25 down-weights them +-- accordingly, so they add no meaningful noise. +-- +-- FTS5 virtual tables do not support ALTER TABLE ADD COLUMN, so the +-- table is dropped and recreated with the new shape, then repopulated +-- from the base + side tables. Repopulation is self-contained — no feed +-- replay — because every indexed value survives outside the FTS copy: +-- display_name/description/publisher on finder_entries, capabilities +-- and tags in their side tables (group_concat ORDER BY rowid mirrors +-- insertFTS's space-joining in insertion order). Only ACTIVE rows are +-- rebuilt: that is the runtime invariant (insertActiveRow adds the FTS +-- row, tombstone suppression deletes it), and rebuilding a +-- REVOKED/DEPRECATED row would resurrect it into search results. +-- +-- Who this migration protects: any deployment created from a released +-- 001-schema binary (v0.1.x and later ship the finder). Amending 001 in +-- place would silently skip those databases, whose 001 row is already +-- recorded in schema_migrations. +DROP TABLE finder_entries_fts; + +CREATE VIRTUAL TABLE finder_entries_fts USING fts5( + display_name, + description, + capabilities_text, + tags_text, + publisher, + tokenize='unicode61 remove_diacritics 2' +); + +INSERT INTO finder_entries_fts ( + rowid, display_name, description, capabilities_text, tags_text, publisher +) +SELECT + e.id, + e.display_name, + e.description, + COALESCE((SELECT group_concat(c.value, ' ' ORDER BY c.rowid) + FROM finder_entry_capabilities c + WHERE c.entry_rowid = e.id), ''), + COALESCE((SELECT group_concat(t.value, ' ' ORDER BY t.rowid) + FROM finder_entry_tags t + WHERE t.entry_rowid = e.id), ''), + e.publisher +FROM finder_entries e +WHERE e.lifecycle = 'ACTIVE'; diff --git a/internal/adapter/store/sqlitefinder/search_publisher_test.go b/internal/adapter/store/sqlitefinder/search_publisher_test.go new file mode 100644 index 0000000..6391d40 --- /dev/null +++ b/internal/adapter/store/sqlitefinder/search_publisher_test.go @@ -0,0 +1,82 @@ +package sqlitefinder_test + +import ( + "testing" + + "github.com/agentnameservice/ans/internal/finder/index" + "github.com/agentnameservice/ans/internal/finder/project" +) + +// TestSearch_PublisherHostTokensMatch proves the publisher host is part +// of the free-text surface: a user who only knows the agent's domain +// finds it even when the display fields share no tokens with the host. +// The seeded entry's displayName/description deliberately avoid every +// host word, so a match can only come from the FTS publisher column. +// unicode61 treats "." as a separator, so "translator.example.com" +// indexes as the tokens translator / example / com. +// +// Query-side, a dotted term is NOT split into independent AND tokens: +// buildMatchQuery quotes each whitespace term whole, and FTS5 treats a +// quoted multi-token string as a PHRASE — sub-tokens adjacent and in +// order within one field. The full host matches its publisher column +// because the tokens sit there in exactly that order; a reordered or +// partial dotted form does not (pinned below and documented in the +// spec's text-matching contract). +func TestSearch_PublisherHostTokensMatch(t *testing.T) { + t.Parallel() + s := newStore(t) + mustApply(t, s, + activeEntry("translator.example.com", "v2-demo-agent", + "application/mcp-server-card+json", + "https://translator.example.com/.well-known/mcp.json", + withDisplay("v2-demo-agent", "demo registration target")), + ) + + cases := []struct { + name string + text string + want int + }{ + {name: "host label alone", text: "translator", want: 1}, + {name: "full host matches as ordered phrase", text: "translator.example.com", want: 1}, + {name: "reordered dotted term is a non-matching phrase", text: "example.translator", want: 0}, + {name: "partial dotted term skipping a label is a non-matching phrase", text: "translator.com", want: 0}, + {name: "space-separated host words match as independent terms", text: "translator example", want: 1}, + {name: "case-insensitive host label", text: "TRANSLATOR", want: 1}, + {name: "host term AND display term compose across columns", text: "translator demo", want: 1}, + {name: "term absent from every column", text: "summarizer", want: 0}, + {name: "host term AND absent term still requires all terms", text: "translator summarizer", want: 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res := search(t, s, index.SearchQuery{Text: tc.text, Limit: 10}) + if len(res.Results) != tc.want { + t.Fatalf("text %q: got %d results, want %d", tc.text, len(res.Results), tc.want) + } + }) + } +} + +// TestSearch_TombstoneRemovesPublisherFromFTS guards the suppression +// invariant for the new column: after a revoke, the agent's host tokens +// must stop matching — a revoked agent surfacing via its domain would +// defeat the tombstone. +func TestSearch_TombstoneRemovesPublisherFromFTS(t *testing.T) { + t.Parallel() + s := newStore(t) + mustApply(t, s, + activeEntry("revocable.example.org", "victim", "application/mcp-server-card+json", + "https://revocable.example.org/.well-known/mcp.json", + withDisplay("victim", "will be revoked")), + ) + if res := search(t, s, index.SearchQuery{Text: "revocable", Limit: 10}); len(res.Results) != 1 { + t.Fatalf("pre-revoke: got %d results, want 1", len(res.Results)) + } + + mustApply(t, s, tombstone("revocable.example.org", "victim", + "ans://v1.0.0.revocable.example.org", "2025-06-01T00:00:00Z", project.LifecycleRevoked)) + + if res := search(t, s, index.SearchQuery{Text: "revocable", Limit: 10}); len(res.Results) != 0 { + t.Fatalf("post-revoke: got %d results, want 0 — revoked agent still discoverable by host", len(res.Results)) + } +} diff --git a/internal/adapter/store/sqlitefinder/sqlite.go b/internal/adapter/store/sqlitefinder/sqlite.go index 016ab34..9091b8c 100644 --- a/internal/adapter/store/sqlitefinder/sqlite.go +++ b/internal/adapter/store/sqlitefinder/sqlite.go @@ -41,6 +41,13 @@ var migrationsFS embed.FS // satisfies index.Catalog. type Store struct { db *sqlx.DB + + // appliedMigrations records the migration files Open applied to + // THIS database (empty when the schema was already current). The + // store has no logger by convention; the caller reads this via + // AppliedMigrations and logs it, so an in-place upgrade — which + // can rewrite the whole FTS index — is visible in the logs. + appliedMigrations []string } // compile-time assertion that Store implements the index port. @@ -133,10 +140,17 @@ func (s *Store) migrate(ctx context.Context) error { if err := tx.Commit(); err != nil { return fmt.Errorf("sqlitefinder: commit %s: %w", f, err) } + s.appliedMigrations = append(s.appliedMigrations, f) } return nil } +// AppliedMigrations lists the migration files Open applied to this +// database, in application order. Empty means the schema was already +// current. Callers log this at startup so schema upgrades are +// diagnosable from logs alone. +func (s *Store) AppliedMigrations() []string { return s.appliedMigrations } + // loadAppliedMigrations reads the schema_migrations table into a set. // Factored out so rows.Close lives on a defer and rows.Err is checked // after the loop. diff --git a/scripts/demo/register.sh b/scripts/demo/register.sh index 1dab43f..8e32ebf 100755 --- a/scripts/demo/register.sh +++ b/scripts/demo/register.sh @@ -119,14 +119,25 @@ if [ "$LANE" = "v1" ]; then REGISTER_PATH="/v1/agents/register" AGENT_BASE="/v1/agents" LAST_AGENT_FILE="$DATA/last-agent-id-v1" - DISPLAY_NAME="v1-demo-agent" else REGISTER_PATH="/v2/ans/agents" AGENT_BASE="/v2/ans/agents" LAST_AGENT_FILE="$DATA/last-agent-id" - DISPLAY_NAME="v2-demo-agent" fi +# Host-derived display text. Searchability does not require this — the +# Finder indexes the publisher host itself, so "translator" finds +# translator.example.com whatever the display name says. This is +# presentation: search results and lifecycle output should show a name +# recognizably tied to what the user registered, not a fixed +# "demo-agent" string. Truncated to stay inside the RA's field limits +# (displayName 64, description 150) for long-but-legal DNS labels. +HOST_LABEL="${AGENT_HOST%%.*}" +HOST_LABEL="${HOST_LABEL:0:40}" +DISPLAY_NAME="$HOST_LABEL $LANE demo agent" +DESCRIPTION="Demo agent for $AGENT_HOST, registered by scripts/demo/register.sh on the $LANE lane." +DESCRIPTION="${DESCRIPTION:0:150}" + header "POST $REGISTER_PATH" # metaDataUrl sits at /.well-known/ so the ANS_DNSAID profile's SVCB # rows carry the capability locator (key65400) and well-known suffix @@ -139,10 +150,11 @@ REG_REQ=$(jq -n \ --arg idCsr "$IDENTITY_CSR_PEM" \ --arg srvCsr "$SERVER_CSR_PEM" \ --arg display "$DISPLAY_NAME" \ + --arg desc "$DESCRIPTION" \ --arg profiles "${ANS_DISCOVERY_PROFILES:-}" ' { agentDisplayName: $display, - agentDescription: "register.sh demo target", + agentDescription: $desc, version: $version, agentHost: $host, endpoints: [{ diff --git a/spec/api-spec-finder-v1.yaml b/spec/api-spec-finder-v1.yaml index 79933a5..479f180 100644 --- a/spec/api-spec-finder-v1.yaml +++ b/spec/api-spec-finder-v1.yaml @@ -106,6 +106,26 @@ paths: set when it satisfies the relevance criteria for `text` AND every constraint in `filter`. + **Text matching contract (this registry).** ARDS leaves text + relevance criteria registry-defined; this Finder matches as + follows. `query.text` splits on whitespace into terms, and every + term must match the entry across its searchable fields — + `displayName`, `description`, `capabilities`, `tags`, and the + publisher host. Matching is case- and diacritic-insensitive but + exact-token: no stemming and no prefix expansion, so "translate" + does not match "translator". Prefer content keywords — filler + words must also match and usually exclude everything. A term + containing separators (dots, hyphens) matches as a **phrase**: + its sub-tokens adjacent and in order within one field. So + `translator.example.com` matches that publisher host, and + "translator" alone matches too, but `translator.com` does not — + separate the words with spaces (`translator example`) to match + them independently. FTS query operators in the input (AND, OR, + `*`, quotes, …) are treated as literal text, never as syntax. + Results are currently ordered by bm25 relevance; the ranking + algorithm is informational, not part of this contract, and may + improve without notice. + **Why POST.** The query carries a free-text string plus a structured `filter` whose keys are dot-separated field paths (ARDS §7.1); this does not fit a query string. The body shape is @@ -174,7 +194,8 @@ paths: returns an aggregation over the matched set rather than ranked entries (ARDS §7.3). Explore lets a client introspect the registry — for example, "which media types are available?" — - narrowed by the same `text` and `filter` as search. + narrowed by the same `text` and `filter` as search. `query.text` + follows the search operation's **text matching contract**. For explore, `query.text` and `query.filter` are **both optional**; when both are absent, the aggregation covers the @@ -267,10 +288,13 @@ components: text: type: string description: | - Natural-language description of the need (ARDS §7.1). Narrows - the result set by semantic relevance. Required for search, - optional for explore. - example: find me a flight booking agent + Free-text keyword terms describing the need (ARDS §7.1). + Narrows the result set by relevance under the **text matching + contract** documented on the search operation (exact-token, + all terms must match) — prefer content keywords over filler + words. Applies identically to search and explore. Required + for search, optional for explore. + example: flight booking filter: $ref: '#/components/schemas/Filter' @@ -397,8 +421,10 @@ components: minimum: 0 maximum: 100 description: | - Semantic relevance ranking (0–100) computed by the - registry (ARDS §7.2). It is **strictly an informational + Relevance score (0–100) computed by the registry + (ARDS §7.2), normalized **within this result set** — the + best match scores 100 — so scores are not comparable + across queries. It is **strictly an informational relevance metric** and MUST NOT be interpreted by orchestrators as a cryptographic trust, compliance, or safety rating. Trust evaluation is decoupled and handled From 3e7454ef6b9d9189d794cc583f384069df7296a4 Mon Sep 17 00:00:00 2001 From: kperry Date: Thu, 23 Jul 2026 20:38:21 +0200 Subject: [PATCH 2/2] fix(finder): return a defensive copy from AppliedMigrations Returning the internal slice let a caller's sort or in-place append reach the store's own record. slices.Clone removes the sharing; the getter runs once at startup, so the copy costs nothing. Addresses Copilot review feedback on #94. Co-Authored-By: Claude Fable 5 Signed-off-by: kperry --- internal/adapter/store/sqlitefinder/sqlite.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/adapter/store/sqlitefinder/sqlite.go b/internal/adapter/store/sqlitefinder/sqlite.go index 9091b8c..c5e0fb0 100644 --- a/internal/adapter/store/sqlitefinder/sqlite.go +++ b/internal/adapter/store/sqlitefinder/sqlite.go @@ -25,6 +25,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "sort" "strings" @@ -148,8 +149,9 @@ func (s *Store) migrate(ctx context.Context) error { // AppliedMigrations lists the migration files Open applied to this // database, in application order. Empty means the schema was already // current. Callers log this at startup so schema upgrades are -// diagnosable from logs alone. -func (s *Store) AppliedMigrations() []string { return s.appliedMigrations } +// diagnosable from logs alone. The returned slice is a copy — mutating +// it cannot corrupt the store's record. +func (s *Store) AppliedMigrations() []string { return slices.Clone(s.appliedMigrations) } // loadAppliedMigrations reads the schema_migrations table into a set. // Factored out so rows.Close lives on a defer and rows.Err is checked