# Search: how S16 is built

What S16 built (plan CORE §3.22; TuteCampus's palette and generated `tsvector`, TuteDesk's GIN index and marked match,
TuteLoop's "only people the searcher may see", 25 October 2026). The rules: **one search, kinds registered**; **a
record kind finds only its list's rows**; **one scorer for every kind**; **nothing is found that couldn't be opened**.

## The engine (CORE-D16)

Scout's interface over PostgreSQL. `laravel/scout` with Atrium's own driver, `pgsql` (`App\Support\Search\
PostgresEngine`, registered in `AppServiceProvider`; `config/scout.php`). A searchable model uses Scout's `Searchable`
and implements `Indexed`: `searchColumns()` names its generated `tsvector`, the text pg_trgm compares, the join that
reaches them, and any exact matches. The database generates and GIN-indexes those columns, so the engine's update and
delete do nothing. A search runs on the model's own query (its global scopes hold: a membership stays in its
workspace) plus Scout's `query()` callback.

| Table | `search_vector` (`simple`, weighted) | `search_text` (pg_trgm) |
|---|---|---|
| `membership` (moved from `person` by plan SP: the words this workspace entered) | first and family name A, other names B, the e-mail's words C | the name |
| `tenant` | name A, the slug's words B | the name |

`simple` so a name is never stemmed. Both columns are `$hidden` on their models (`SearchGuardTest`). Meilisearch later
is `SCOUT_DRIVER=meilisearch`, its keys and `scout:import` per model; `toSearchableArray()` already says what an index
would hold (a membership carries its `tenant_id` and its own words).

## The query (`Terms`, `Scorer`)

- **As typed**: each word a prefix, all needed (`'kof':* & 'men':*`), so it finds while typing. `websearch_to_tsquery`,
  in the plan, needs whole words, so it isn't used.
- **Loosely**: for a kind where nothing matched as typed, from four letters, also a word similar to the name
  (`? <% search_text`, pg_trgm word similarity ≥ `Scorer::SIMILAR` 0.4, set for that transaction alone). So
  *Mensha* finds Mensah; a letter swapped at the start (*Mnesah*) is beyond trigrams.
- **Exact**: a kind's own (people: `Membership::whereContact`, an ID as `Identifier` writes it, or from four digits a
  phone holding them, a leading 0 left off; the People list's search uses it too).
- **Ranked**: a match as typed first, `1 + ts_rank`, then the word similarity.
- **The short lists** (pages, settings, actions, roles: their words are translations, not columns) are scored by the
  same SQL over a JSON record set (`Scorer::rank`), never by a PHP copy of it.
- **The match is marked** by `Terms::mark()`: the typed beginning of each word, as parts (`[{text, mark}]`), drawn as
  text. `ts_headline`, in the plan, marks whole words and returns HTML; one marker for records and lists keeps them
  alike.

## The kinds (`SearchKinds::ALL`)

| Kind | Shape | Finds | Needs | Opens |
|---|---|---|---|---|
| `person` | `RecordKind` (`Membership`) | the People list's rows (never the Service, never someone removed) | `person.view` | their page |
| `workspace` | `RecordKind` (`Tenant`) | the Workspaces list's rows | `workspace.view` (the Service, outside a lens) | a lens on it |
| `role` | `ListedKind` | the Roles list's rows, by title in the reader's language | `role.view` | its page |
| `page` | `ListedKind` | `Nav::pages()`: the sidebar and the person's own (`Nav::MINE`) | each page's own | the page |
| `setting` | `ListedKind` | the catalogue, each on the highest page the person opens: platform, workspace, their own | each level's own | its page, with its value |
| `action` | `ListedKind` | `ActionKind::ACTIONS` (add a person, import, new role, open a lens), switching workspace, the theme | each its own | a form, a post, the page itself |

- **A record kind finds only its list's rows**: `ListDefinition::records()` (the list's base query: its scope and
  exclusions) as a `whereIn` in Scout's `query()` callback, with the list's permission. Two walls: the model's
  workspace scope, and the list's rows.
- **A workspace's permission needs a workspace** (`SearchKind::allows`): the Service outside a lens passes every
  permission but has none, so no workspace kind, page or action is offered to it; the fail-closed scope is the
  backstop. Through a lens (`SearchKind::inLens`) the account's own pages and settings are closed (ApplyLens), so
  search leaves them out.

## The endpoint and the palette

- `GET /search?q=&scope=` (`SearchController`, `throttle:search`, its figure `search.per_minute`; open to everyone
  signed in, each kind deciding; in the Service's no-lens routes). `@`, `/`, `>` at the start narrow to people, pages,
  actions (`Search::PREFIXES`); `scope` does the same (`Search::SCOPES`, code). It answers the scope it read, the
  scopes the person has, the groups, and `platform` (no workspace: the words for nothing found).
- At most 5 a kind, 15 within a scope. Empty: recent picks, then 5 pages to jump to, 4 things to do.
- `POST /search/recent {kind, id}` keeps a pick as a `search_pick` row of the person, for this workspace or the platform
  (6, newest first; a list in the person's preferences until plan SN D5) if its kind would show it to the person now; each is found again through its kind
  (`SearchKind::recall`) every time. Not a change (ApplyLens lets it through; RecordServiceAction skips it).
- `CommandPalette.vue` (reka-ui's dialog) and `SearchBox.vue` in the header (Ctrl K / ⌘K anywhere): asks 120 ms after
  the last key, the previous request dropped; Enter waits for the answer to what's in the box; the arrows move through
  every group; the full screen on a phone, with Esc as a button.

## Adding a kind

1. Records: a model using Scout's `Searchable` and `Indexed`, its generated `search_vector` and `search_text` (GIN,
   `$hidden`) in a migration, its list in `Lists::ALL`, and a `RecordKind` naming the list and the model. A short list:
   a `ListedKind` whose `items()` returns only what the person may open.
2. Register it in `SearchKinds::ALL`; its group's words in `lang/en/search.php` (`group.<key>`).
3. An action: a row in `ActionKind::ACTIONS` (its route, its permission) and its words (`action.<key>.title`, `.detail`).

## The guards

| Guard | Test |
|---|---|
| Each kind found by its name and by a misspelling | `Search/SearchTest` |
| Every hit opens for whoever found it (five people, fourteen searches) | `Search/SearchTest` |
| The Service without a lens finds workspaces and platform pages, never a workspace's record | `Search/SearchTest` |
| Search stays in the workspace, a recent pick included | `Tenancy/TenantSweepTest` (the sweep's search) |
| Every kind names a permission in the catalogue; a record kind's is its list's, on generated, indexed, hidden columns | `Guards/SearchGuardTest` |
| A named limiter | `Search/SearchTest`, `Guards/NamedLimiterTest` |
