# Global search registration (extension entities)

The platform ships an **Azure-portal-style global search** (`GET /api/search`). It is a registry of
`ISearchProvider` resolved as `IEnumerable<ISearchProvider>`: the Core providers (Users, Tenants,
Tickets…) are registered by the package, and **client extension entities are invisible to it until they
are registered**. There is no auto-scan of the `extensions` schema — by design (a blind scan would leak
cross-tenant rows and bypass per-section RBAC).

You do **not** write an `ISearchProvider` per entity. A generic engine does the work; you declare each
searchable entity once via `AddExtensionSearch<ExtensionsDbContext>(…)` in
`{Project}.Infrastructure/DependencyInjection.cs` (inside the `<<< EXTENSION-SEARCH-DI >>>` markers).

**You do not hand-write this either** — the deterministic CLI `cli/scaffold-extension-search` generates and
idempotently patches the block (it even inserts the wrapper + the required `using` when a project predates
the seam, so `ss upgrade` back-fills existing apps). The C# below is exactly what it emits; it is documented
here so reviewers can read the intent. Spec assembly lives in `build-spec.ts`.

## When to register an entity

Register the entity behind every **list screen** (`SmartListView`) the module exposes — that is exactly
the set a user expects to find by searching. Reference/lookup entities surfaced in their own section
(categories, priorities…) count too.

## What you declare (and what is automatic)

| Declared (per entity) | Automatic (engine) |
|---|---|
| category key, label, lucide icon | text-column discovery + dynamic `LIKE` predicate |
| `RequirePermission(...)` — the section's `.read` | pagination + total count |
| `RouteTo(e => ...)` — the detail route | result mapping (id/title/route) |
| `TenantScoped()` when `ITenantEntity` | title default = `Name`/`Title`/`Code`/first string |
| `RestrictTo(...)` only if a row rule exists | composition with Core providers; no UI change |

## Conventions (derive from the entity's owning section)

For an entity bound to section `app.module.section`:

- **Permission** = `app.module.section.read` (the section's read permission — already seeded by core-seed).
- **Route** = `/app/module/section/{id}` (the detail route; matches the nav route + `:id`).
- **Category key** = the plural, lower-case entity/section code (e.g. `tasks`, `categories`).
- **`TenantScoped()`** = add it for every `ITenantEntity`. The engine adds the `TenantId == current`
  predicate explicitly — **defense in depth** over the named "Tenant" context filter scaffold-entity
  mounts in ExtensionsDbContext (TENANT-FILTERS markers; DEV-API-032): the named filter can be lifted
  (`IgnoreTenantScope()`) or missing on an app generated before the seam existed, and the search engine
  must stay fail-closed either way. Do NOT rely on the context filter alone.
- **`RestrictTo(...)`** = only when the list handler applies a row-level rule (e.g. "a collaborator sees
  only their own rows"). Mirror that rule exactly so search never reveals a row the list would hide:
  ```csharp
  .RestrictTo(scope => scope.Has("app.module.section.assign")
      ? null                                            // manager → no extra restriction
      : e => e.AssignedToUserId == scope.UserId)        // collaborator → own rows only
  ```

## Worked example

```csharp
services.AddExtensionSearch<ExtensionsDbContext>(search =>
{
    // <<< EXTENSION-SEARCH-DI BEGIN >>>
    search.Entity<Task>("tasks", "Tâches", "ListTodo")
          .RequirePermission("todo.taches.liste.read")
          .RouteTo(t => $"/todo/taches/liste/{t.Id}")
          .TenantScoped()
          .RestrictTo(scope => scope.Has("todo.taches.liste.assign")
              ? null
              : t => t.AssignedToUserId == scope.UserId);

    search.Entity<Category>("categories", "Catégories", "Tag")
          .RequirePermission("todo.parametres.categories.read")
          .RouteTo(c => $"/todo/parametres/categories/{c.Id}")
          .TenantScoped();
    // <<< EXTENSION-SEARCH-DI END >>>
});
```

## Rules

1. **Never auto-scan** — register explicitly. No declaration ⇒ not searchable (fail-closed, no leak).
2. **`TenantScoped()` for every `ITenantEntity` AND `IOptionalTenantEntity`** — the engine adds the
   explicit `TenantId` filter, independently of the named "Tenant" context filter (defense in depth —
   the context filter can be lifted or missing on a pre-seam app). Generated client entities are
   typically `IOptionalTenantEntity` (`Guid?` TenantId) — omitting the call on
   them is a cross-tenant search leak (`discover.ts` now recognises both, fail-closed default).
3. **Mirror the list handler's row scope** with `RestrictTo` — same predicate, same bypass permission.
4. **Permission = the section's `.read`** — the same path core-seed already seeds; never invent a new one.
5. **No frontend work** — the search panel renders categories dynamically (server label + icon).
6. **Every `view: list` entity registers** — gated by `audit-dev-api DEV-API-029` (err): the count of
   ACTIVE `search.Entity<…>` lines between the markers must cover the module's list pagespecs.

## Known limit — the identifier the user types lives on a dated CHILD

The engine only searches text columns of the REGISTERED entity. When the
user-typed identifier lives on a dated satellite (a licence plate on
`VehicleRegistration`, not on `Vehicle` — which only carries `Vin`/`Brand`/
`Model`), registering the parent alone still finds nothing for a plate.

Workable TODAY: register the satellite too, and `RouteTo` it to the PARENT's
fiche (`.RouteTo(r => $"/{app}/{module}/{section}/{r.VehicleId}")`) — the hit
opens the vehicle. `build-spec.ts` will not propose this (it reasons per menu
section); author it by hand between the markers when the BA declares such an
identifier. The general derivation ("the searched identifier lives on a dated
child") is the same class as the DERIVED COLUMNS chantier (round 3) and is
deliberately deferred to it.
