actor_search RPC spec — authenticated case-insensitive prefix search
over actor.name, returning the same {id, username, display_name?}
wire shape as actor_lookup.
Powers person-target pickers — visiones' CellGrantsEditor.svelte
teacher-picks-student flow replaces the deferred actor_by_name arm of
cell_grant_create with a debounced search against this method. Sibling
to actor_lookup: that resolves a known batch of ids → labels; this
resolves a partial name → candidate actors.
Auth + rate-limit posture
{account: 'required', actor: 'none'} + rate_limit: 'account'. Same
shape as actor_lookup: only that the caller is signed in matters, not
which actor is calling. The auth gate, the per-account rate limit
(default 1200/15min), and the ACTOR_SEARCH_LIMIT_MAX per-call cap
bound the enumeration surface this method would otherwise present.
The handler additionally requires the caller to be admin when
scope_ids is empty (the unbounded global-search arm). Non-admin
callers must always pass at least one scope_id — the SQL filters
actors to those holding a role_grant on one of the supplied scopes, so
a non-admin caller is restricted to actors they share a scope with.
The admin check is account-grain (any actor on the caller's account
holds a global admin role_grant), matching the actor: 'none' posture.
Caller-passes-scope_ids design
scope_ids is trusted as a filter, not as an authority claim — the
SQL filters to actors with role_grants on those scopes regardless of
whether the caller has authority over them. Consumers are responsible
for pre-filtering scope_ids against their own authority before
calling. Visiones passes the set of classrooms the teacher teaches,
sourced client-side from the teacher's role_grant list; the teacher
predicate stays in the visiones layer rather than baked into fuz_app.
Crucially, this does not widen the scope-existence oracle: an
attacker passing a random scope_id cannot learn "this scope has
members matching X" because the join filters to actors holding a
role_grant on the scope, and the SQL surfaces neither "did the scope
exist" nor "did the scope have non-matching members" — only the
matching subset is returned.
Wire shape — info-leak audit
Output {actors: [{id, username, display_name?}]} is identical to
actor_lookup's — see auth/actor_lookup_action_specs.ts for the full
field-by-field audit. Same omissions (account_id, email,
timestamps, role / role_grants / session state), same display_name
omitted-not-null contract, same response-order-unspecified rule.
Additional actor_search-specific posture:
- Prefix match (LOWER(name) LIKE LOWER(query) || '%'), not full
%query%. Full-LIKE would let a single call enumerate one
alphabetical bucket spread across many starting letters, which
defeats the per-call cap as an enumeration bound.
- Hard-deleted actors silently drop (cascade through actor.account_id
FK) — no tombstone oracle, same posture as actor_lookup.
- Empty result set on no-match — fail-soft like cell_list. No
"no actor matches" error message that would leak an existence
boundary on the search-term axis.
Why not extend actor_lookup?
Splitting the methods keeps the wire contracts independent: actor_lookup's
input is {ids}, actor_search's is {query} + optional filters.
Both surface the same ActorLookupEntryJson row shape (re-used here),
so the labels arc on the consumer side stays uniform.