Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
# Search completion
**Package**: `@suaveplan/search`
**Status**: PROPOSED
**Type**: spec-conformance + feature
## Why
The 2026-05-02 cross-monorepo audit confirmed that `@suaveplan/search` shipped on genesis with only the in-memory side of the original surface — `MemorySearchEngine`, BM25 + trigram scorers, the tokenizer, and Zod schemas. The legacy `packages/universal/search/src` package on `../suaveplan/` carries the full search-product surface that downstream services (catalogue, knowledge, analytics) rely on: a `SearchClient<T>` orchestrator, a `QueryBuilder` fluent API, a four-analyzer `SearchPipeline` (`LowercaseAnalyzer`, `StemmingAnalyzer`, `StopWordAnalyzer`, `SynonymAnalyzer`), a `searchHooks` event bus, the `FacetDistribution` / `FacetRequest` / `FieldBoost` / `ScoringFunction` shapes, and three production adapters — `ElasticsearchAdapter`, `OpenSearchAdapter` (with optional AWS sigv4 signing), and `MeiliSearchAdapter`. None of these were archived as out-of-scope; they were silently dropped during the original genesis migration.
Genesis policy (CLAUDE.md §Workflow rule 5) is "preserve the full public surface during rewrites — trimming is OK ONLY when code is duplicated elsewhere in-repo or can be replaced by an existing `@suaveplan/*` library." None of the dropped capabilities are duplicated elsewhere on genesis, so each one must be ported. This change closes that gap so any downstream service can compose an in-memory engine for tests with a hosted engine for production behind one `SearchEngine<TDoc>` contract.
## What changes
- ADDED: `SearchClient<TDoc extends SearchDocument>` orchestrator class — wraps any `SearchEngine<TDoc>` with `searchHooks` lifecycle wiring, `SearchPipeline` analyzer chain, `defaultLimit`, optional `fieldBoosts`, and an optional `ScoringFunction`. Methods: `index`, `indexMany`, `remove`, `removeMany`, `search`, `find`, `suggest`, `clear`, `size`. Every async method threads `AbortSignal` through to the underlying engine.
- ADDED: `QueryBuilder` fluent typed query DSL — `.term(value)`, `.must(filter)`, `.should(filter)`, `.mustNot(filter)`, `.range(field, op, value)`, `.term_(field, value)`, `.prefix(field, value)`, `.fuzzy(field, value, distance?)`, `.geo(field, lat, lng, radiusKm?)`, `.filter(field, op, value)` (legacy alias), `.facet(field, maxValues?)`, `.sort(field, direction)`, `.paginate(offset, limit)`, `.boost(field, weight)`, `.scoringFunction(fn)`, `.build()`, `.reset()`. The `.build()` output is a `SearchQuery` matching the engine's shape.
- ADDED: `SearchPipeline` class with composable `Analyzer` interface — `process(query)` walks every registered analyzer in order. Built-in analyzers: `LowercaseAnalyzer`, `StemmingAnalyzer` (Porter-like suffix stripping; 18 rules), `StopWordAnalyzer` (configurable stop-word set; 51-word default), `SynonymAnalyzer` (one-way and bidirectional synonym map). Custom analyzers conform to `interface Analyzer { name: string; analyze(terms): string[] }` and can be registered without touching the package.
- ADDED: `searchHooks` typed event bus — `'search:querying'` (intercept and rewrite query before the engine sees it), `'search:indexing'` (intercept and rewrite document before index), `'search:result'` (post-process result set before return). Hooks are wired through `@suaveplan/events` and respect the existing handler-priority / order semantics.
- ADDED: `FacetDistribution`, `FacetValue`, `FacetRequest`, `FieldBoost`, `ScoringFunction<T>`, `SearchSuggestion`, `SuggestOptions` shape types — co-located with their implementation modules.
- ADDED: `BaseElasticAdapter<TDoc>` shared base class — handles auth (basic / apiKey / bearer), request-timeout, query body construction (`bool` / `multi_match` / `range`), filter translation, response transformation (hits + aggregations → `SearchResult`), bulk indexing, index management (`ensureIndex`, `deleteIndex`, `refresh`), and structured error mapping (`HttpError` / `RateLimitError` / `TimeoutError` / `ConfigError`).
- ADDED: `ElasticsearchAdapter<TDoc>` class — extends `BaseElasticAdapter`; ES 8.x by default, ES 7.x compatibility-mode header opt-in.
- ADDED: `OpenSearchAdapter<TDoc>` class — extends `BaseElasticAdapter`; optional `AwsSigningConfig` injects an `(url, init) => Promise<RequestInit>` hook so sigv4 signing is pluggable.
- ADDED: `MeiliSearchAdapter<TDoc>` class — implements `SearchEngine<TDoc>` against MeiliSearch HTTP API; supports `searchableAttributes`, `filterableAttributes`, `sortableAttributes`, faceted search via `facetDistribution`, suggestions via `_formatted` extraction.
- ADDED: `ElasticBaseConfig`, `ElasticAuth`, `ElasticsearchConfig`, `OpenSearchConfig`, `AwsSigningConfig`, `MeiliSearchConfig`, `SearchClientConfig` shape types.
- MODIFIED: `SearchEngine<TDoc>` interface — adds optional `bulkIndex(documents)` and `ensureSchema()` lifecycle hooks; the new methods default-throw with `SEARCH.NOT_IMPLEMENTED` so existing engines (e.g. `MemorySearchEngine`) compile unchanged.
- MODIFIED: `SEARCH_CODES` registry — adds `PROVIDER_UNREACHABLE`, `PROVIDER_TIMEOUT`, `PROVIDER_RATE_LIMITED`, `PROVIDER_CONFIG_INVALID`, `HOOK_HANDLER_FAILED`, `ANALYZER_INVALID`, `NOT_IMPLEMENTED`, `AWS_SIGNING_FAILED`, `BULK_INDEX_PARTIAL_FAILURE` codes; the existing `INDEX_FAILED`, `QUERY_INVALID`, `ENGINE_NOT_READY`, `FIELD_UNKNOWN`, `CURSOR_INVALID` codes are preserved.
- MODIFIED: `index.ts` barrel — re-exports every new symbol; no `export *` (one explicit subpath per module).
- MODIFIED: `package.json` `exports` map — adds subpaths `./client/search-client`, `./query-builder/query-builder`, `./pipeline/search-pipeline`, `./pipeline/analyzers/lowercase`, `./pipeline/analyzers/stemming`, `./pipeline/analyzers/stopword`, `./pipeline/analyzers/synonym`, `./hooks/search-hooks`, `./adapters/base-elastic`, `./adapters/elasticsearch`, `./adapters/opensearch`, `./adapters/meilisearch`.
- MODIFIED: `package.json` `dependencies` — adds `@suaveplan/events` (`workspace:^`).
## Out of scope
- **Algolia adapter** — different query DSL and pricing model; lands as its own package (`@suaveplan/search-algolia`) once a tenant requests it. Avoiding speculative adapter sprawl in this change.
- **Vector search / dense retrieval** — embedding pipelines, ANN indexes (HNSW / IVF), and hybrid lexical+vector ranking are a separate concern with their own ML dependency surface; they land as `@suaveplan/search-vector`.
- **Bundling `@elastic/elasticsearch` or `meilisearch-js` SDKs** — both adapters use direct `fetch()` with Zod-validated responses to keep the package tree-shake-friendly and free of native-binding peer deps. This matches the upstream design and the `@suaveplan/sitemap` / `@suaveplan/dmarc` precedent (HTTP-only, Zod-validated wire formats).
- **Typesense adapter** — the genesis testing harness already ships `@suaveplan/testing-integration-typesense` for service spin-up; the production adapter is a separate package because Typesense's query shape (filter strings, multi-search) does not map onto the Elastic family base class without re-architecture.
- **`registerHook` legacy free-function deprecation** — kept and delegated to the new `searchHooks.hook` API for one release; removing it is a breaking change that requires its own deprecation window.
- **Re-folding of `openspec/specs/search/spec.md`** if a canonical spec file does not yet exist, this change creates it for the cap; full spec folding lands once all search changes archive.
## Source mapping
This change derives from `../suaveplan/packages/universal/search/src/`. Genesis `enterprise/search.md` is a generic enterprise template (no per-feature requirements), so source mapping cites the upstream package source directly.
| Requirement | Source file | Source lines | Notes |
|---|---|---|---|
| `SearchClient<T>` orchestrator | `packages/universal/search/src/search-client/search-client.ts` | L21-L93 | Verbatim; `AbortSignal` threaded through every async method on genesis |
| `SearchClientConfig` | `packages/universal/search/src/types/search.types.ts` | L139-L148 | Verbatim |
| `searchHooks` event bus + `'search:querying'` hook | `packages/universal/search/src/hooks.ts` | L1-L62 | Verbatim; rewired to use `@suaveplan/events` `Hooks` class |
| `'search:indexing'` and `'search:result'` hooks | (no upstream line — added in this change) | — | Symmetry with `'search:querying'`; justified in "no source line" table below |
| `QueryBuilder` fluent DSL — `term`, `filter`, `facet`, `sort`, `paginate`, `offset`, `limit`, `build`, `reset` | `packages/universal/search/src/query-builder/query-builder.ts` | L27-L107 | Verbatim |
| `QueryBuilder.must`, `should`, `mustNot`, `range`, `prefix`, `fuzzy`, `geo`, `boost`, `scoringFunction` extensions | (no upstream line — added in this change) | — | Justified in "no source line" table below |
| `SearchPipeline` composable analyzer chain | `packages/universal/search/src/pipeline/search-pipeline.ts` | L204-L224 | Verbatim |
| `Analyzer` interface (`name`, `analyze(terms)`) | `packages/universal/search/src/pipeline/search-pipeline.ts` | L9-L12 | Verbatim |
| `LowercaseAnalyzer` | `packages/universal/search/src/pipeline/search-pipeline.ts` | L106-L114 | Verbatim |
| `StemmingAnalyzer` (Porter-like 18-rule suffix table) | `packages/universal/search/src/pipeline/search-pipeline.ts` | L57-L103 | Verbatim |
| `StopWordAnalyzer` (51-word default + configurable set) | `packages/universal/search/src/pipeline/search-pipeline.ts` | L117-L186 | Verbatim |
| `SynonymAnalyzer` (one-way and bidirectional) | `packages/universal/search/src/pipeline/search-pipeline.ts` | L21-L51 | Verbatim |
| `FacetRequest`, `FacetValue`, `FacetDistribution`, `FieldBoost`, `ScoringFunction`, `SearchSuggestion`, `SuggestOptions` | `packages/universal/search/src/types/search.types.ts` | L26-L77 | Verbatim |
| `BaseElasticAdapter<T>` shared base class | `packages/universal/search/src/adapters/base-elastic-adapter.ts` | L75-L442 | Verbatim |
| `ElasticBaseConfig`, `ElasticAuth` | `packages/universal/search/src/adapters/base-elastic-adapter.ts` | L26-L47 | Verbatim |
| `ElasticsearchAdapter<T>` | `packages/universal/search/src/adapters/elasticsearch-adapter.ts` | L45-L64 | Verbatim |
| `ElasticsearchConfig` | `packages/universal/search/src/adapters/elasticsearch-adapter.ts` | L12-L15 | Verbatim |
| `OpenSearchAdapter<T>` | `packages/universal/search/src/adapters/opensearch-adapter.ts` | L38-L60 | Verbatim |
| `OpenSearchConfig`, `AwsSigningConfig` | `packages/universal/search/src/adapters/opensearch-adapter.ts` | L12-L23 | Verbatim |
| `MeiliSearchAdapter<T>` | `packages/universal/search/src/adapters/meilisearch-adapter.ts` | L35-L305 | Verbatim |
| `MeiliSearchConfig` | `packages/universal/search/src/types/search.types.ts` | L150-L164 | Verbatim |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `'search:indexing'` and `'search:result'` lifecycle hooks (in addition to upstream `'search:querying'`) | Genesis-wide CLAUDE.md rule 3 (enterprise-grade modularity) requires a complete intercept surface. Upstream only exposes `'search:querying'`; downstream consumers (knowledge-graph, content-pipeline) need to rewrite indexed documents (e.g. inject embeddings) and post-process result sets (e.g. attach permission metadata). Symmetric naming with the existing hook avoids API drift. |
| `QueryBuilder.must` / `should` / `mustNot` / `range` / `prefix` / `fuzzy` / `geo` / `boost` / `scoringFunction` methods | Upstream `QueryBuilder` only ships `filter()` and `term()`. The proposal task description explicitly requires these clauses (must / should / must_not / range / term / prefix / fuzzy / geo). Adding them on the builder side keeps a typed DSL while preserving every adapter's freedom to translate filters into its native query language. |
| `bulkIndex` and `ensureSchema` optional methods on `SearchEngine<TDoc>` | Bulk indexing is a documented `BaseElasticAdapter` capability (upstream L149-L166) but is not part of the universal interface. Genesis policy requires the universal contract to expose every supported optimisation so callers can migrate engines without code changes; both methods default-throw with `SEARCH.NOT_IMPLEMENTED` so existing engines (e.g. `MemorySearchEngine`) compile unchanged. |
| `AbortSignal` threaded through every `SearchClient` and `SearchEngine` async method | enterprise/search.md L43: "Backpressure & cancellation — `AbortSignal` everywhere async". Upstream lacks signal support on the client-orchestration surface. |
| Zod schemas for `SearchClientConfig`, `ElasticBaseConfig`, `ElasticAuth`, `MeiliSearchConfig`, `AwsSigningConfig`, `FacetRequest`, `FieldBoost`, every `Analyzer` config | enterprise/search.md L37: "Zod schemas for all public types". Upstream validates through TypeScript only. |
| New `SEARCH.PROVIDER_*` / `SEARCH.HOOK_HANDLER_FAILED` / `SEARCH.ANALYZER_INVALID` / `SEARCH.NOT_IMPLEMENTED` / `SEARCH.AWS_SIGNING_FAILED` / `SEARCH.BULK_INDEX_PARTIAL_FAILURE` codes | Genesis-wide policy (CLAUDE.md §Workflow rule 2) that every thrown error route through `@suaveplan/error`. Upstream throws `HttpError` / `TimeoutError` / `RateLimitError` from `@suaveplan/error` directly; genesis wraps them with stable `SEARCH.*` codes so consumers can pattern-match without depending on the foundation error types. |
| `SearchPipeline.process` accepts `AbortSignal` | Long synonym graphs / stemming chains can dominate request latency on large queries; allowing abort midway through analysis matches the rest of the package. |
## Verification approach
Each spec delta requirement carries a `**Verified by:**` line pointing at concrete `it(...)` titles. The test files that will exist after this change ships:
- `packages/platform/search/src/client/search-client.test.ts` — `index` / `search` / `find` / `suggest` round-trip against an in-memory engine; `defaultLimit` applied; `fieldBoosts` and `scoringFunction` re-rank results; `removeMany` and bulk index path
- `packages/platform/search/src/client/search-client.spec-conformance.test.ts` — `searchHooks.hook('search:querying')` rewrites the query before the engine sees it; `'search:indexing'` rewrites documents; `'search:result'` post-processes; `AbortSignal` aborts mid-flight; injected `MemorySearchEngine` produces deterministic order
- `packages/platform/search/src/query-builder/query-builder.test.ts` — `term` / `must` / `should` / `mustNot` / `range` / `prefix` / `fuzzy` / `geo` / `filter` / `facet` / `sort` / `paginate` build correctly; `boost` and `scoringFunction` round-trip; `reset()` clears state
- `packages/platform/search/src/pipeline/search-pipeline.test.ts` — `LowercaseAnalyzer` / `StemmingAnalyzer` / `StopWordAnalyzer` / `SynonymAnalyzer` each tested in isolation; `SearchPipeline` walks analyzers in order; custom analyzer registers
- `packages/platform/search/src/pipeline/search-pipeline.spec-conformance.test.ts` — `AbortSignal` mid-pipeline rejects with `ANALYZER_INVALID` when an analyzer throws; bidirectional synonyms expand correctly; stop-word fallback returns original tokens when filtering would empty the list
- `packages/platform/search/src/hooks/search-hooks.test.ts` — `'search:querying'` / `'search:indexing'` / `'search:result'` lifecycle bus tested with multiple handlers; handler-priority order respected; `unsubscribe` removes record
- `packages/platform/search/src/hooks/search-hooks.spec-conformance.test.ts` — handler exception raises `HOOK_HANDLER_FAILED` with original `cause`; per-hook payload type pinned via `expectTypeOf`
- `packages/platform/search/src/adapters/base-elastic.test.ts` — `index` / `remove` / `search` / `clear` / `size` / `suggest` round-trip against a `fetch` mock; `bulkIndex` builds NDJSON; `ensureIndex` / `deleteIndex` / `refresh`; auth basic / apiKey / bearer; `searchFields` honoured; facets translate to aggregations
- `packages/platform/search/src/adapters/base-elastic.spec-conformance.test.ts` — 429 rate-limit response raises `PROVIDER_RATE_LIMITED` with `retryAfterMs`; 408 / 504 raises `PROVIDER_TIMEOUT`; missing host raises `PROVIDER_CONFIG_INVALID`; non-OK responses throw `HttpError`-wrapped `SearchError`
- `packages/platform/search/src/adapters/elasticsearch.test.ts` — adapter sends ES 8 headers by default; `compatibilityMode: true` switches to ES 7 headers
- `packages/platform/search/src/adapters/opensearch.test.ts` — adapter without `awsSigning` behaves like `BaseElasticAdapter`; with `awsSigning` invokes the supplied signer once per request and uses the returned `RequestInit`; signer rejection raises `AWS_SIGNING_FAILED`
- `packages/platform/search/src/adapters/meilisearch.test.ts` — `index` / `remove` / `search` / `clear` / `size` / `suggest` round-trip; filters translate to MeiliSearch filter strings; `ensureIndex` patches `searchableAttributes` / `filterableAttributes` / `sortableAttributes`; faceted search returns `facetDistribution`
- `packages/platform/search/src/adapters/meilisearch.spec-conformance.test.ts` — 429 raises `PROVIDER_RATE_LIMITED`; 408 / 504 raises `PROVIDER_TIMEOUT`; 400 raises `QUERY_INVALID`; missing API key path
- `tests/dep-graph.test.ts` (cross-package) — `@suaveplan/search` declares `@suaveplan/events` as a runtime dep; the package still has zero browser-only deps; subpath count matches feature module count
## Migration impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/search` to enumerate. As of `grep -rl "@suaveplan/search" packages/ --include="package.json"` (2026-05-02), the only match is the package's own `package.json` — there are no in-repo dependents on genesis yet.
- (no in-repo dependents) — additive only; existing barrel exports (`MemorySearchEngine`, `createMemorySearchEngine`, `SEARCH_CODES`, `SearchError`, `bm25Score`, `tfIdfScore`, `tokenize`, `simpleStem`, `normalizeToken`, `buildTrigrams`, `trigramSimilarity`, every Zod schema, every type alias) are preserved unchanged. New exports (`SearchClient`, `QueryBuilder`, `SearchPipeline`, four analyzers, `searchHooks`, `BaseElasticAdapter`, `ElasticsearchAdapter`, `OpenSearchAdapter`, `MeiliSearchAdapter`, every new type alias) are additive.
The `SearchEngine<TDoc>` interface gains two optional methods (`bulkIndex` and `ensureSchema`) with default-throw stubs in the abstract class so `MemorySearchEngine` compiles unchanged. Consumers that depended on the old `SearchEngine` shape continue to compile because the new methods are optional.
For foundation-package safety, a `bunx turbo build typecheck test --filter=...@suaveplan/search` sweep runs in §6 of `tasks.md`.
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `MemorySearchEngine` (BM25 + trigram + tokenizer) | yes | yes | Already on genesis |
| `SearchEngine<TDoc>` interface | yes | yes | Already on genesis; gains optional `bulkIndex` / `ensureSchema` in this change |
| `SearchClient<T>` orchestrator | yes | yes (this change) | `AbortSignal` threaded through all async methods |
| `QueryBuilder` fluent DSL | yes | yes (this change) | Adds `must` / `should` / `mustNot` / `range` / `prefix` / `fuzzy` / `geo` / `boost` / `scoringFunction` |
| `SearchPipeline` analyzer chain | yes | yes (this change) | Verbatim |
| `LowercaseAnalyzer` | yes | yes (this change) | Verbatim |
| `StemmingAnalyzer` (Porter-like) | yes | yes (this change) | Verbatim |
| `StopWordAnalyzer` (configurable) | yes | yes (this change) | Verbatim |
| `SynonymAnalyzer` (one-way / bidirectional) | yes | yes (this change) | Verbatim |
| `searchHooks` typed event bus | yes | yes (this change) | Adds `'search:indexing'` and `'search:result'` |
| `FacetDistribution` / `FacetValue` / `FacetRequest` types | yes | yes (this change) | Verbatim |
| `FieldBoost` / `ScoringFunction` types | yes | yes (this change) | Verbatim |
| `SearchSuggestion` / `SuggestOptions` | yes | yes (this change) | Verbatim |
| `BaseElasticAdapter<T>` | yes | yes (this change) | Verbatim |
| `ElasticsearchAdapter<T>` | yes | yes (this change) | Verbatim |
| `OpenSearchAdapter<T>` (with optional AWS sigv4) | yes | yes (this change) | `AwsSigningConfig` is pluggable; no AWS-SDK runtime dep |
| `MeiliSearchAdapter<T>` | yes | yes (this change) | Verbatim |
| Algolia adapter | no | 🚫 (out of scope) | Not in upstream; will land as `@suaveplan/search-algolia` once a tenant requests it |
| Typesense adapter | no | 🚫 (out of scope) | Not in upstream; query shape doesn't map onto Elastic family base class |
| Vector / dense retrieval | no | 🚫 (out of scope) | Not in upstream; lands as `@suaveplan/search-vector` |
No upstream capabilities are dropped.

View File

@@ -0,0 +1,217 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: search (search-completion)
**Package**: `@suaveplan/search`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: SearchClient orchestrator wraps any SearchEngine
The package SHALL expose a `SearchClient<TDoc extends SearchDocument>` class that wraps any `SearchEngine<TDoc>` implementation and applies the `searchHooks` lifecycle bus, an optional `SearchPipeline` analyzer chain, `defaultLimit`, optional `fieldBoosts`, and an optional `ScoringFunction`. The client SHALL expose `index`, `indexMany`, `remove`, `removeMany`, `search`, `find`, `suggest`, `clear`, `size`. Every async method SHALL accept and forward an `AbortSignal` to the underlying engine.
#### Scenario: search applies defaultLimit when query.limit is unset
- **WHEN** `client.search({ text: "foo" })` is called and `defaultLimit` is `25`
- **THEN** the engine receives a query with `pagination.limit: 25` AND the response carries `total` reflecting the engine's count
#### Scenario: fieldBoosts re-rank results
- **WHEN** the client is configured with `fieldBoosts: [{ field: "title", weight: 3 }]` and a search returns hits with overlapping `body` and `title` matches
- **THEN** the result order surfaces title-matching documents above body-only matches with the same baseline score
#### Scenario: AbortSignal propagates to engine
- **WHEN** `client.search(query, { signal })` is called and `controller.abort()` fires
- **THEN** the engine's `search` invocation also receives the same signal AND the call rejects with `AbortError`
**Verified by:** `packages/platform/search/src/client/search-client.test.ts::"applies defaultLimit when query limit is unset"` AND `packages/platform/search/src/client/search-client.test.ts::"re-ranks hits using configured fieldBoosts"` AND `packages/platform/search/src/client/search-client.spec-conformance.test.ts::"propagates AbortSignal to the engine search call"`
---
### Requirement: searchHooks lifecycle event bus
The package SHALL expose a `searchHooks` typed event bus implementing three lifecycle hooks: `'search:querying'` (intercept and rewrite query before the engine sees it), `'search:indexing'` (intercept and rewrite document before index), `'search:result'` (post-process result set before return). The bus SHALL be implemented over `@suaveplan/events` `Hooks` and respect handler-priority / order semantics. Handler exceptions SHALL raise `SearchError(SEARCH.HOOK_HANDLER_FAILED)` with the original `cause` preserved.
#### Scenario: search:querying handler rewrites the query
- **WHEN** a handler subscribes to `'search:querying'` and adds a default filter `{ field: "tenantId", op: "eq", value: "t-1" }`
- **THEN** the engine receives a query that includes the injected filter AND the original caller's query is unchanged (the handler operates on a copy)
#### Scenario: handler-priority order respected
- **WHEN** two handlers subscribe to the same hook with priorities `10` and `5`
- **THEN** the priority-`10` handler runs first AND its mutation is visible to the priority-`5` handler
#### Scenario: handler exception raises HOOK_HANDLER_FAILED
- **WHEN** a subscribed handler throws synchronously
- **THEN** the dispatch rejects with `SearchError(SEARCH.HOOK_HANDLER_FAILED)` AND `cause` is the original exception AND `context.hook` is the hook name
**Verified by:** `packages/platform/search/src/hooks/search-hooks.test.ts::"applies search:querying handler to rewrite the engine query"` AND `packages/platform/search/src/hooks/search-hooks.test.ts::"runs handlers in priority order"` AND `packages/platform/search/src/hooks/search-hooks.spec-conformance.test.ts::"raises HOOK_HANDLER_FAILED when a handler throws"`
---
### Requirement: QueryBuilder fluent typed query DSL
The package SHALL expose a `QueryBuilder` class with a fluent typed DSL: `term(value)`, `must(filter)`, `should(filter)`, `mustNot(filter)`, `range(field, op, value)`, `prefix(field, value)`, `fuzzy(field, value, distance?)`, `geo(field, lat, lng, radiusKm?)`, `filter(field, op, value)` (legacy alias), `facet(field, maxValues?)`, `sort(field, direction)`, `paginate(offset, limit)`, `boost(field, weight)`, `scoringFunction(fn)`, `build()`, `reset()`. `build()` SHALL return a `SearchQuery` matching the engine's shape.
#### Scenario: build composes filters, facets, sort, pagination
- **WHEN** `new QueryBuilder().term("typescript").must({ field: "category", op: "eq", value: "docs" }).facet("category").sort("title", "asc").paginate(0, 20).build()`
- **THEN** the returned `SearchQuery` has `text: "typescript"`, includes the must-filter, declares the facet, sorts by title ascending, and paginates from offset 0 with limit 20
#### Scenario: range and fuzzy clauses build correctly
- **WHEN** `.range("price", "lt", 100).fuzzy("title", "widgit", 1)` is added to a query
- **THEN** the built query carries a range filter `{ field: "price", op: "lt", value: 100 }` AND a fuzzy clause with edit-distance 1
#### Scenario: reset clears state
- **WHEN** `reset()` is called after building
- **THEN** a subsequent `build()` returns a query with empty filters, no sort, default pagination (`offset: 0, limit: 20`), and no facets
**Verified by:** `packages/platform/search/src/query-builder/query-builder.test.ts::"builds a SearchQuery composing filters, facets, sort, and pagination"` AND `packages/platform/search/src/query-builder/query-builder.test.ts::"adds range and fuzzy clauses to the built query"` AND `packages/platform/search/src/query-builder/query-builder.test.ts::"reset returns the builder to default state"`
---
### Requirement: SearchPipeline composable analyzer chain
The package SHALL expose a `SearchPipeline` class that walks a registered list of `Analyzer` implementations in order. The `Analyzer` interface SHALL be `{ name: string; analyze(terms: readonly string[]): string[] }`. The package SHALL ship four built-in analyzers — `LowercaseAnalyzer`, `StemmingAnalyzer`, `StopWordAnalyzer`, `SynonymAnalyzer` — plus the `SearchPipeline.process(query, signal?)` method honouring `AbortSignal`.
#### Scenario: pipeline walks analyzers in order
- **WHEN** a pipeline `new SearchPipeline([new LowercaseAnalyzer(), new StopWordAnalyzer(), new StemmingAnalyzer()])` processes `"Running The Tests"`
- **THEN** the output reflects lowercase → stop-word removal → stem (e.g. includes `"running"` and `"run"`)
#### Scenario: SynonymAnalyzer expands one-way and bidirectional synonyms
- **WHEN** `new SynonymAnalyzer({ laptop: ["notebook", "computer"] })` analyzes `["laptop"]`
- **THEN** the result is `["laptop", "notebook", "computer"]`
#### Scenario: StopWordAnalyzer falls back to original tokens when filter would empty the list
- **WHEN** the input is `["the", "and"]` and both are stop-words
- **THEN** the analyzer returns `["the", "and"]` unchanged (so search isn't completely blank)
#### Scenario: AbortSignal mid-pipeline rejects with ANALYZER_INVALID
- **WHEN** an analyzer throws during `process(query, signal)` and `signal.aborted` is true
- **THEN** the call rejects with `SearchError(SEARCH.ANALYZER_INVALID)` AND `context.analyzer` is the failing analyzer's `name`
**Verified by:** `packages/platform/search/src/pipeline/search-pipeline.test.ts::"walks analyzers in registration order"` AND `packages/platform/search/src/pipeline/search-pipeline.test.ts::"SynonymAnalyzer expands one-way and bidirectional synonyms"` AND `packages/platform/search/src/pipeline/search-pipeline.spec-conformance.test.ts::"StopWordAnalyzer falls back when filtering would empty the list"` AND `packages/platform/search/src/pipeline/search-pipeline.spec-conformance.test.ts::"raises ANALYZER_INVALID when an analyzer throws under aborted signal"`
---
### Requirement: Facet, boost, and scoring shape types
The package SHALL expose `FacetDistribution`, `FacetValue`, `FacetRequest`, `FieldBoost`, `ScoringFunction<T>`, `SearchSuggestion`, `SuggestOptions` shape types co-located with the modules that consume them. Each SHALL be a typed interface (`FieldBoost`, `FacetRequest`, etc.) or a typed function alias (`ScoringFunction<T>`).
#### Scenario: FieldBoost weight is numeric
- **WHEN** a `FieldBoost` literal `{ field: "title", weight: 3 }` is constructed
- **THEN** `expectTypeOf(boost.weight).toEqualTypeOf<number>()` passes AND `boost.field` is typed as `string`
#### Scenario: ScoringFunction signature
- **WHEN** a `ScoringFunction<{ id: string }>` is declared
- **THEN** `expectTypeOf<ScoringFunction<{ id: string }>>().toEqualTypeOf<(doc: { id: string }, baseScore: number) => number>()` passes
**Verified by:** `packages/platform/search/src/query-builder/query-builder.test.ts::"FieldBoost weight is typed as number"` AND `packages/platform/search/src/client/search-client.spec-conformance.test.ts::"ScoringFunction signature is enforced via expectTypeOf"`
---
### Requirement: ElasticsearchAdapter against Elasticsearch 8.x REST API
The package SHALL expose an `ElasticsearchAdapter<TDoc>` class extending `BaseElasticAdapter` that implements `SearchEngine<TDoc>` against the Elasticsearch 8.x REST API via native `fetch()`. The adapter SHALL support full-text search via `multi_match`, faceted search via aggregations, sort/filter/pagination, suggestions via `phrase_prefix`, bulk indexing (NDJSON), index management (`ensureIndex`, `deleteIndex`, `refresh`), and authentication (basic, apiKey, bearer). Optional `compatibilityMode: true` SHALL switch request headers to ES 7 compatibility format.
#### Scenario: index + search round-trip
- **WHEN** a document is indexed via `adapter.index("1", { title: "Widget" })`, the index is refreshed, and `search({ text: "widget" })` is called
- **THEN** the response contains a hit whose `document.title === "Widget"` AND `document.id === "1"`
#### Scenario: faceted search returns aggregations
- **WHEN** a search query specifies `facets: [{ field: "category" }]`
- **THEN** the request body includes an `aggs.category.terms` clause AND the response's `facetDistribution` is keyed by `category` with bucket counts
#### Scenario: compatibilityMode switches headers
- **WHEN** the adapter is constructed with `compatibilityMode: true`
- **THEN** every request carries `Content-Type: application/json; compatible-with=7` and `Accept: application/json; compatible-with=7`
#### Scenario: 429 raises PROVIDER_RATE_LIMITED
- **WHEN** the cluster responds 429 with `Retry-After: 5`
- **THEN** the call rejects with `SearchError(SEARCH.PROVIDER_RATE_LIMITED)` AND `context.retryAfterMs === 5000`
**Verified by:** `packages/platform/search/src/adapters/elasticsearch/elasticsearch.test.ts::"index and search round-trip via fetch"` AND `packages/platform/search/src/adapters/base-elastic/base-elastic.test.ts::"faceted search translates to aggregations"` AND `packages/platform/search/src/adapters/elasticsearch/elasticsearch.test.ts::"compatibilityMode switches headers to ES 7 format"` AND `packages/platform/search/src/adapters/base-elastic/base-elastic.spec-conformance.test.ts::"raises PROVIDER_RATE_LIMITED with retryAfterMs on 429"`
---
### Requirement: OpenSearchAdapter with optional AWS sigv4 signing
The package SHALL expose an `OpenSearchAdapter<TDoc>` class extending `BaseElasticAdapter` that targets OpenSearch 1.x/2.x (compatible with Amazon OpenSearch Service). The adapter SHALL accept an optional `AwsSigningConfig` whose `sign(url, init) => Promise<RequestInit>` hook is invoked once per request to add sigv4 headers. The package SHALL NOT bundle any AWS SDK; the signer is supplied by the caller. Signer rejection SHALL raise `SearchError(SEARCH.AWS_SIGNING_FAILED)`.
#### Scenario: without awsSigning behaves like the base adapter
- **WHEN** an `OpenSearchAdapter` is constructed without `awsSigning`
- **THEN** every request goes through `BaseElasticAdapter.request` unchanged AND no `sign()` callback is invoked
#### Scenario: with awsSigning calls the signer once per request
- **WHEN** an adapter is constructed with `awsSigning: { region: "us-east-1", sign }` and a search is dispatched
- **THEN** `sign(url, init)` is invoked exactly once before the underlying `fetch` AND the returned `RequestInit` is the one that hits the wire
#### Scenario: signer rejection raises AWS_SIGNING_FAILED
- **WHEN** the supplied `sign()` rejects with `new Error("invalid creds")`
- **THEN** the adapter call rejects with `SearchError(SEARCH.AWS_SIGNING_FAILED)` AND `cause` is the original error
**Verified by:** `packages/platform/search/src/adapters/opensearch/opensearch.test.ts::"behaves like base adapter without awsSigning"` AND `packages/platform/search/src/adapters/opensearch/opensearch.test.ts::"invokes the supplied signer once per request"` AND `packages/platform/search/src/adapters/opensearch/opensearch.test.ts::"raises AWS_SIGNING_FAILED when the signer rejects"`
---
### Requirement: MeiliSearchAdapter against MeiliSearch HTTP API
The package SHALL expose a `MeiliSearchAdapter<TDoc>` class implementing `SearchEngine<TDoc>` against the MeiliSearch HTTP API via native `fetch()`. The adapter SHALL support indexing, removal, search with filter strings (translated from `SearchFilter`), faceted search via `facetDistribution`, suggestions via `_formatted` extraction, sort, pagination, and index management (`ensureIndex` patches `searchableAttributes` / `filterableAttributes` / `sortableAttributes`; `deleteIndex` removes the index entirely).
#### Scenario: filters translate to MeiliSearch filter strings
- **WHEN** a search query carries filters `[{ field: "category", op: "eq", value: "docs" }, { field: "price", op: "gte", value: 10 }]`
- **THEN** the outgoing request body has `filter: ['category = "docs"', "price >= 10"]`
#### Scenario: ensureIndex patches index settings
- **WHEN** the adapter is configured with `searchableAttributes: ["title"]` and `ensureIndex()` is called
- **THEN** a `POST /indexes` is dispatched followed by `PATCH /indexes/<name>/settings` with the supplied attributes in the body
#### Scenario: 400 raises QUERY_INVALID
- **WHEN** MeiliSearch responds 400 with a malformed-query body
- **THEN** the call rejects with `SearchError(SEARCH.QUERY_INVALID)` AND `cause.message` includes the response text
**Verified by:** `packages/platform/search/src/adapters/meilisearch/meilisearch.test.ts::"translates filters to MeiliSearch filter strings"` AND `packages/platform/search/src/adapters/meilisearch/meilisearch.test.ts::"ensureIndex patches searchableAttributes"` AND `packages/platform/search/src/adapters/meilisearch/meilisearch.spec-conformance.test.ts::"raises QUERY_INVALID on 400 response"`
---
### Requirement: SearchEngine interface gains optional bulkIndex and ensureSchema
The `SearchEngine<TDoc>` interface SHALL gain two optional methods: `bulkIndex(documents, options?): Promise<void>` and `ensureSchema(): Promise<void>`. Both SHALL be optional so existing implementations (e.g. `MemorySearchEngine`) compile unchanged. When invoked on an engine that does not implement them, the default behaviour SHALL throw `SearchError(SEARCH.NOT_IMPLEMENTED)` carrying `context.engine` (the engine's `name`) and `context.method`.
#### Scenario: MemorySearchEngine compiles with the extended interface
- **WHEN** `MemorySearchEngine` is instantiated and `bulkIndex` / `ensureSchema` are not implemented
- **THEN** the type still satisfies `SearchEngine<TDoc>` AND calling `engine.bulkIndex?.(docs)` is `undefined` (the optional method is absent)
#### Scenario: NOT_IMPLEMENTED carries engine and method context
- **WHEN** `BaseElasticAdapter.bulkIndex` is replaced with the default-throw stub and invoked
- **THEN** the call rejects with `SearchError(SEARCH.NOT_IMPLEMENTED)` AND `context.engine === "elastic"` AND `context.method === "bulkIndex"`
**Verified by:** `packages/platform/search/src/adapters/base-elastic/base-elastic.spec-conformance.test.ts::"NOT_IMPLEMENTED carries engine and method context"` AND `tests/dep-graph.test.ts::"MemorySearchEngine still satisfies SearchEngine without bulkIndex"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,117 @@
# Tasks — Search completion
> Every checkbox MUST reflect work actually done in this change. The eight capabilities closed here were silently dropped during the original genesis migration (audit dated 2026-05-02). Do not retroactively tick boxes for surface that already shipped.
## 0. Pre-flight (non-negotiable)
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [ ] 1.1 Read `../suaveplan/packages/universal/search/src/{search-client,query-builder,pipeline,hooks,adapters,types}/*.ts` end-to-end
- [ ] 1.2 Read `openspec/specs/search/spec.md` (if it exists; create the cap dir if not)
- [ ] 1.3 Read `openspec/AGENTS.md` Sections 0a (parity), 13 (spec-conformance), 14 (source mapping), 14a (dependents)
- [ ] 1.4 Draft `proposal.md` covering all eight gaps (`SearchClient`, `QueryBuilder`, `SearchPipeline` + 4 analyzers, `searchHooks`, facet/boost/scoring types, `ElasticsearchAdapter`, `OpenSearchAdapter` with AWS sigv4, `MeiliSearchAdapter`)
- [ ] 1.5 Fill `## Source mapping` in `proposal.md` with upstream line ranges for every requirement; every "no source line" requirement justified
- [ ] 1.6 Draft spec delta at `specs/search/spec.md`
- [ ] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
- [ ] 1.8 `openspec change validate search-completion` exits with no errors
## 2. Implementation
- [ ] 2.1 `SearchClient<TDoc>` orchestrator — port from upstream `search-client/search-client.ts` into `packages/platform/search/src/client/search-client.ts`; thread `AbortSignal` through every async method
- [ ] 2.2 `QueryBuilder` fluent DSL — port from upstream `query-builder/query-builder.ts` into `packages/platform/search/src/query-builder/query-builder.ts`; add `must` / `should` / `mustNot` / `range` / `prefix` / `fuzzy` / `geo` / `boost` / `scoringFunction` clauses on top of upstream surface
- [ ] 2.3 `SearchPipeline` composable analyzer chain — port from upstream `pipeline/search-pipeline.ts` into `packages/platform/search/src/pipeline/search-pipeline.ts`; thread `AbortSignal` through `process(query, signal?)`
- [ ] 2.4 `Analyzer` interface + co-located built-ins:
- `pipeline/analyzers/lowercase/lowercase.ts` (verbatim from upstream)
- `pipeline/analyzers/stemming/stemming.ts` (verbatim 18-rule Porter-like table)
- `pipeline/analyzers/stopword/stopword.ts` (verbatim 51-word default + configurable set)
- `pipeline/analyzers/synonym/synonym.ts` (verbatim one-way / bidirectional)
- [ ] 2.5 `searchHooks` typed event bus — port from upstream `hooks.ts` into `packages/platform/search/src/hooks/search-hooks.ts`; rewire to `@suaveplan/events` `Hooks` class; add `'search:indexing'` and `'search:result'` symmetric hooks alongside upstream `'search:querying'`
- [ ] 2.6 Co-locate `FacetDistribution`, `FacetValue`, `FacetRequest`, `FieldBoost`, `ScoringFunction<T>`, `SearchSuggestion`, `SuggestOptions` shape types next to their consumers (no shared `types/` dump)
- [ ] 2.7 `BaseElasticAdapter<TDoc>` shared base class — port from upstream `adapters/base-elastic-adapter.ts` into `packages/platform/search/src/adapters/base-elastic/base-elastic.ts`; map upstream `HttpError` / `RateLimitError` / `TimeoutError` / `ConfigError` to genesis `SearchError` codes (`PROVIDER_*`)
- [ ] 2.8 `ElasticsearchAdapter<TDoc>` — port from upstream `adapters/elasticsearch-adapter.ts` into `packages/platform/search/src/adapters/elasticsearch/elasticsearch.ts`; ES 8 default + optional ES 7 compatibility mode
- [ ] 2.9 `OpenSearchAdapter<TDoc>` — port from upstream `adapters/opensearch-adapter.ts` into `packages/platform/search/src/adapters/opensearch/opensearch.ts`; pluggable `AwsSigningConfig` (no AWS-SDK runtime dep)
- [ ] 2.10 `MeiliSearchAdapter<TDoc>` — port from upstream `adapters/meilisearch-adapter.ts` into `packages/platform/search/src/adapters/meilisearch/meilisearch.ts`
- [ ] 2.11 Extend `SearchEngine<TDoc>` interface — add optional `bulkIndex(documents, options?)` and `ensureSchema()` methods; default-throw with `SEARCH.NOT_IMPLEMENTED` so `MemorySearchEngine` compiles unchanged
- [ ] 2.12 Add `SEARCH_*` codes (`PROVIDER_UNREACHABLE`, `PROVIDER_TIMEOUT`, `PROVIDER_RATE_LIMITED`, `PROVIDER_CONFIG_INVALID`, `HOOK_HANDLER_FAILED`, `ANALYZER_INVALID`, `NOT_IMPLEMENTED`, `AWS_SIGNING_FAILED`, `BULK_INDEX_PARTIAL_FAILURE`) to `errors/search-error.ts`
- [ ] 2.13 Wire OTel counters / spans on every public entrypoint: `search.client.queries`, `search.client.queries_duration_ms`, `search.client.indexed`, `search.pipeline.process_duration_ms`, `search.hooks.fired`, `search.adapter.requests`, `search.adapter.errors`, `search.adapter.bulk_indexed`
- [ ] 2.14 Wire Zod schemas for `SearchClientConfig`, `ElasticBaseConfig`, `ElasticAuth`, `ElasticsearchConfig`, `OpenSearchConfig`, `AwsSigningConfig`, `MeiliSearchConfig`, `FacetRequest`, `FieldBoost`, every `Analyzer` config to `schemas/search.schemas.ts`
- [ ] 2.15 Update `index.ts` barrel — re-export every new symbol; no `export *` (one explicit subpath per module)
- [ ] 2.16 Update `package.json` `exports` map — add subpaths `./client/search-client`, `./query-builder/query-builder`, `./pipeline/search-pipeline`, `./pipeline/analyzers/lowercase/lowercase`, `./pipeline/analyzers/stemming/stemming`, `./pipeline/analyzers/stopword/stopword`, `./pipeline/analyzers/synonym/synonym`, `./hooks/search-hooks`, `./adapters/base-elastic/base-elastic`, `./adapters/elasticsearch/elasticsearch`, `./adapters/opensearch/opensearch`, `./adapters/meilisearch/meilisearch`
- [ ] 2.17 Update `package.json` `dependencies` — add `@suaveplan/events` (`workspace:^`)
## 3. Tests
- [ ] 3.1 `packages/platform/search/src/client/search-client.test.ts` — `index` / `search` / `find` / `suggest` round-trip against an in-memory engine; `defaultLimit` applied; `fieldBoosts` and `scoringFunction` re-rank results; `removeMany` and bulk-index path
- [ ] 3.2 `packages/platform/search/src/client/search-client.spec-conformance.test.ts` — `searchHooks.hook('search:querying')` rewrites the query before the engine sees it; `'search:indexing'` rewrites documents; `'search:result'` post-processes; `AbortSignal` aborts mid-flight; injected `MemorySearchEngine` produces deterministic order
- [ ] 3.3 `packages/platform/search/src/query-builder/query-builder.test.ts` — `term` / `must` / `should` / `mustNot` / `range` / `prefix` / `fuzzy` / `geo` / `filter` / `facet` / `sort` / `paginate` build correctly; `boost` and `scoringFunction` round-trip; `reset()` clears state
- [ ] 3.4 `packages/platform/search/src/pipeline/search-pipeline.test.ts` — `LowercaseAnalyzer` / `StemmingAnalyzer` / `StopWordAnalyzer` / `SynonymAnalyzer` each tested in isolation; `SearchPipeline` walks analyzers in order; custom analyzer registers; analyzer name returned via `analyzerNames`
- [ ] 3.5 `packages/platform/search/src/pipeline/search-pipeline.spec-conformance.test.ts` — `AbortSignal` mid-pipeline rejects with `ANALYZER_INVALID` when an analyzer throws; bidirectional synonyms expand correctly; stop-word fallback returns original tokens when filtering would empty the list; stem rule guards against under-3 char results
- [ ] 3.6 `packages/platform/search/src/hooks/search-hooks.test.ts` — `'search:querying'` / `'search:indexing'` / `'search:result'` lifecycle bus tested with multiple handlers; handler-priority order respected; `unsubscribe` removes record
- [ ] 3.7 `packages/platform/search/src/hooks/search-hooks.spec-conformance.test.ts` — handler exception raises `HOOK_HANDLER_FAILED` with original `cause`; per-hook payload type pinned via `expectTypeOf`
- [ ] 3.8 `packages/platform/search/src/adapters/base-elastic/base-elastic.test.ts` — `index` / `remove` / `search` / `clear` / `size` / `suggest` round-trip against a `fetch` mock; `bulkIndex` builds NDJSON; `ensureIndex` / `deleteIndex` / `refresh`; auth basic / apiKey / bearer; `searchFields` honoured; facets translate to aggregations
- [ ] 3.9 `packages/platform/search/src/adapters/base-elastic/base-elastic.spec-conformance.test.ts` — 429 rate-limit response raises `PROVIDER_RATE_LIMITED` with `retryAfterMs`; 408 / 504 raises `PROVIDER_TIMEOUT`; missing host raises `PROVIDER_CONFIG_INVALID`; non-OK responses throw `SearchError` wrapping `HttpError`
- [ ] 3.10 `packages/platform/search/src/adapters/elasticsearch/elasticsearch.test.ts` — adapter sends ES 8 headers by default; `compatibilityMode: true` switches to ES 7 headers
- [ ] 3.11 `packages/platform/search/src/adapters/opensearch/opensearch.test.ts` — adapter without `awsSigning` behaves like `BaseElasticAdapter`; with `awsSigning` invokes the supplied signer once per request and uses the returned `RequestInit`; signer rejection raises `AWS_SIGNING_FAILED`
- [ ] 3.12 `packages/platform/search/src/adapters/meilisearch/meilisearch.test.ts` — `index` / `remove` / `search` / `clear` / `size` / `suggest` round-trip; filters translate to MeiliSearch filter strings; `ensureIndex` patches `searchableAttributes` / `filterableAttributes` / `sortableAttributes`; faceted search returns `facetDistribution`
- [ ] 3.13 `packages/platform/search/src/adapters/meilisearch/meilisearch.spec-conformance.test.ts` — 429 raises `PROVIDER_RATE_LIMITED`; 408 / 504 raises `PROVIDER_TIMEOUT`; 400 raises `QUERY_INVALID`; missing API key path
- [ ] 3.14 Cross-package `tests/dep-graph.test.ts` — `@suaveplan/search` declares `@suaveplan/events` as a runtime dep; the package still has zero browser-only deps; subpath count matches feature module count
- [ ] 3.15 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely-async adapter integration sweeps)
## 4. Documentation
- [ ] 4.1 Sibling `.md` for every new implementation `.ts` (≥200 words, basic + advanced examples, full API reference) — `client/search-client.md`, `query-builder/query-builder.md`, `pipeline/search-pipeline.md`, four analyzer `.md` files, `hooks/search-hooks.md`, `adapters/base-elastic/base-elastic.md`, `adapters/elasticsearch/elasticsearch.md`, `adapters/opensearch/opensearch.md`, `adapters/meilisearch/meilisearch.md`
- [ ] 4.2 README links every co-located `.md` under `## Modules`
- [ ] 4.3 Update `CLAUDE.md` `search` package listing — remove "MemorySearchEngine + BM25 + trigram + tokenizer only" wording; describe the full restored surface (orchestrator, builder, pipeline, hooks, three production adapters)
- [ ] 4.4 Refresh `STATUS.md` "Completed on genesis" entry for `search` to reflect parity
## 5. Pre-archive verification (the 5-gate triple)
- [ ] 5.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the monorepo
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/search` — green
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/search` — 100/100/100/100
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/search` — exit 0, no `error` findings
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/search` — green
- [ ] 5.6 Banned-pattern scan zero output: `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only" packages/platform/search/src/ --include="*.ts"`
- [ ] 5.7 Missing `.md` scan zero output (per AGENTS.md §13 step 5)
- [ ] 5.8 README check `test -f packages/platform/search/README.md`
- [ ] 5.9 Bare-version check zero output (per AGENTS.md §13 step 7)
- [ ] 5.10 Subpath count matches feature module count (per AGENTS.md §13 step 8)
## 6. Pre-archive 7-row checklist (AGENTS.md Section 13)
- [ ] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim (especially `SearchClient<TDoc>`, `BaseElasticAdapter<TDoc>`, `ElasticsearchAdapter<TDoc>`, `OpenSearchAdapter<TDoc>`, `MeiliSearchAdapter<TDoc>`)
- [ ] 6.2 Zod schemas wired at every declared boundary (`SearchClientConfig`, `ElasticBaseConfig`, `ElasticAuth`, `ElasticsearchConfig`, `OpenSearchConfig`, `AwsSigningConfig`, `MeiliSearchConfig`, `FacetRequest`, `FieldBoost`, every `Analyzer` config)
- [ ] 6.3 Every error code added in §2.12 thrown by at least one production code path; no orphans
- [ ] 6.4 Strict-mode behaviors tested both on and off (e.g. `compatibilityMode: true` vs `false`; `awsSigning` present vs absent; bulk-index partial failure vs success)
- [ ] 6.5 OTel counters / histograms emitted from production paths and asserted with a real in-memory `MeterProvider`
- [ ] 6.6 Lifecycle dedup tested where claimed (`SearchPipeline` analyzer registry idempotent on identical analyzer; `searchHooks.hook` returns a one-shot unsubscriber)
- [ ] 6.7 No retroactive tick — every box above reflects work done in this change
## 7. Archive
- [ ] 7.1 Fold spec delta into `openspec/specs/search/spec.md` (creating the cap file if missing)
- [ ] 7.2 Move `openspec/changes/search-completion/` → `openspec/archive/<yyyy-mm-dd>-search-completion/`
- [ ] 7.3 Refresh `MANIFEST.md` (dep set changed: `@suaveplan/search` now depends on `@suaveplan/events`)
- [ ] 7.4 Update `STATUS.md` to mark the eight dropped capabilities as restored
## Audit Note (2026-05-02)
Audit verdict: **DEFERRED — 0/65 tasks evidenced in code**.
Evidence reviewed:
- `packages/platform/search/src/` contains only `engine/memory-engine/`, `text/{tokenizer,scorer,trigram}/`, `errors/`, `schemas/`, `types/`, `constants/`. No `client/`, no `query-builder/`, no `pipeline/`, no `hooks/`, no `adapters/`.
- `package.json` does not depend on `@suaveplan/events`.
- The eight upstream capabilities (SearchClient, QueryBuilder, SearchPipeline + 4 analyzers, searchHooks, facet/boost/scoring types, ElasticsearchAdapter, OpenSearchAdapter with sigv4, MeiliSearchAdapter, SearchEngine.bulkIndex/ensureSchema) are absent.
Missing pieces:
1. `client/search-client.ts` orchestrator + `query-builder/query-builder.ts` fluent DSL with the upstream clauses.
2. `pipeline/search-pipeline.ts` analyzer chain with `lowercase`, `stemming`, `stopword`, `synonym` analyzers ported verbatim from upstream.
3. Three production adapters (`adapters/elasticsearch`, `adapters/opensearch` with pluggable `AwsSigningConfig`, `adapters/meilisearch`) on top of a shared `BaseElasticAdapter`, plus the 9 new `SEARCH_*` error codes.
Decision: leave change open. This is the largest of the deferred set (8 dropped capabilities, full dep graph rewire to `@suaveplan/events`, nine new error codes, parity audit).