Context
After wave 10,@stackra/search ranked under the IAM VisibilityFilter but had no facet counts, so a screen that
counted by status would have counted outside the viewer’s records. It kept its own Arabic folding, which differed from
@stackra/i18n’s normalizeArabic (the article and the Quranic marks). The embedder read its bearer token from an
environment variable named in config. “Global indexes” in the owner questionnaire (item 23) had two possible readings.
Decision
- Facets.
SearchDriver.facets(table, request)returns, per requested filter path, how many matching rows hold each value, most frequent first.SearchIndex.facets(phrase, { paths, filters, limit })hands the driver the same words, filters andvisibleKeysQuerysubquery a ranked read uses, so record visibility bounds the rows before they are counted and a count never includes a row the viewer cannot see. The Postgres driver counts over the rows either lane matches (every indexed row for an empty phrase), each row once per value; the memory driver refuses a request with visibility, as it does for a search. - One Arabic normaliser.
searchWordsOffolds Arabic throughnormalizeArabicand adds only the Latin accents, the case and the split. The SQL mirror issearch.fold(text)(migrationsearch.0003_create_fold_function), which the trigram lane calls.search.unaccent_immutableis not replaced, so no existing expression index holds values its function no longer computes. - Reindex plan. Per searchable model, in order: (a) deploy the package (migration 0003 creates
search.fold); (b) the model’s next migration createsgin (search.fold(search_text) gin_trgm_ops)concurrently (expand); (c) runsearch.in(Model).reindex()as a job in its own context, which rewritessearch_textand the vectors with the article stripped; (d) a later migration drops thesearch.unaccent_immutableindex (contract). Between (a) and (b) the trigram lane scans without an index; between (a) and (c) an article-prefixed word in an old document is found by its prefix only. When no index namessearch.unaccent_immutable, a search migration drops it. - Embedder token.
search.embedder.tokenSecretnames a runtime secret;HttpEmbedderDrivertakes theSecretStorethe runtime binds and reads the token at call time. The environment is never read; the value is never logged, kept or put in an error. Which provider and region is region data in runway. - “Global indexes” means
Search.across. There is no cross-model index table. Each model keeps its own index in its own table and schema (so isolation and visibility stay per model), and a global search (the Cmd+K palette) reads the listed models’ indexes in turn and fuses them by reciprocal rank.
Consequences
- Product screens can show filter counts that match what the viewer may open.
- Arabic matching is the same in search, conflict-name checks and anywhere else that compares names.
- Deployments rename
tokenEnvtotokenSecretin runway config and bind the secret. - A global search costs one query per model; a shared index stays a later engine’s choice behind the same port.
Enforcement
planned — the package tests fix facets, folding, the migration and the secret read (packages/data/search/src/**/__tests__/); the SQL runs in the integration tier.
Supersedes and sources
Supersedes: none. Amends 0221 decision 3. Sources: owner questionnaire item 23;notes/handover/stackra-package-needs.md; notes/handover/build-wave10-2026-10-25.md; STA-595; STA-787.