Skip to main content
Proposed (STA-787). It amends 0221 (decision 3 and its 2026-10-24 note) and applies 0333 and 0173 to @stackra/search.

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

  1. 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 and visibleKeysQuery subquery 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.
  2. One Arabic normaliser. searchWordsOf folds Arabic through normalizeArabic and adds only the Latin accents, the case and the split. The SQL mirror is search.fold(text) (migration search.0003_create_fold_function), which the trigram lane calls. search.unaccent_immutable is not replaced, so no existing expression index holds values its function no longer computes.
  3. Reindex plan. Per searchable model, in order: (a) deploy the package (migration 0003 creates search.fold); (b) the model’s next migration creates gin (search.fold(search_text) gin_trgm_ops) concurrently (expand); (c) run search.in(Model).reindex() as a job in its own context, which rewrites search_text and the vectors with the article stripped; (d) a later migration drops the search.unaccent_immutable index (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 names search.unaccent_immutable, a search migration drops it.
  4. Embedder token. search.embedder.tokenSecret names a runtime secret; HttpEmbedderDriver takes the SecretStore the 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.
  5. “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 tokenEnv to tokenSecret in 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.