ProtectedBaseAccess the cached provider metadata from SearchEngineBase
ProtecteddimensionResolver for a scope's declared Search Context dimensions. Overridable so a host can supply additional derivation sources (e.g. an external signal) without forking the engine.
ProtectedProviderResolve the metadata provider via SearchEngineBase (which extends BaseEngine and tracks ProviderToUse).
StaticInstanceStatic accessor for the global singleton instance
ProtectedassertEnforce a lane's RequiredMetadataKeys contract (Phase E).
The rendered filter must mention every key the author declared. This is the only guard
that catches a filter which rendered partially — where an optional {% if %} clause
disappeared because its dimension was absent or discarded, leaving a non-empty filter
that passes every other check while restricting on strictly less than intended.
Optionalcollector: LaneProblemCollectorProtectedassertFail a search CLOSED when a scope field that RESTRICTS was authored but did not render
usably (see CheckRenderedTemplate).
Throwing rather than dropping the offending row is deliberate. Dropping one lane of
several would quietly change what the scope covers, and a scope's ONLY lane dropped used to
collapse to undefined — "unscoped" to every provider. (A non-global scope now hands providers
an empty list, which means "nothing", but a silently narrowed scope is still a wrong answer.)
A broken restricting template is a misconfiguration and should be loud and actionable, never
silently degraded.
Optionalcollector: LaneProblemCollectorProtectedbuildBuild a stable cache key for a search.
The key must include EVERY input that can change the result set, or the cache will serve one caller's results to another. Two of those inputs were previously missing and both are tenancy/authorization-relevant:
SearchContext — carries PrimaryScopeRecordID (the TENANT) and the
SecondaryScopes dimensions. Omitting it meant a user with access to two
tenants could be served the other tenant's results for up to the cache TTL,
and that two searches differing only by dimension (channel, skill, …)
collided. This is the reason for the fix.ScopeIDs — determines which corpora are searched at all.Also folded in: Mode, FusionWeightsOverride and PermissionOverfetchFactor
(all change ranking or the candidate pool, so they change results) and
AIAgentID (conservative: agent identity participates in scope resolution and
per-agent overrides upstream; including it can only cost a miss, never leak).
ScopeIDs order is deliberately NOT sorted: it is behaviourally significant,
because cross-scope reranker config and budget are taken from the first scope in
the array that supplies one. Two different orderings can therefore produce
different results and must not share a key.
The whole projection is emitted through stableStringify so key-order variation
in SecondaryScopes doesn't fragment the cache. The user ID stays as a readable
prefix for debuggability.
Note this runs AFTER scope resolution in searchInternal, so nothing here is
circular. Entitlement is resolved by the CALLERS (__Scoped_Search, the
GraphQL resolvers), which deny before reaching the engine; the engine therefore
never caches across an allow/deny boundary.
Test / admin hook: clear the result cache.
Initialize the search engine by reading active SearchProvider records from SearchEngineBase (which caches them via BaseEngine) and instantiating each via ClassFactory.
Safe to call multiple times (no-ops if already configured unless forceRefresh=true).
Engine configuration options
The user context for initialization
If true, re-initializes even if already configured
ProtectedexplainResolve entitlement for the dry run, including the skill and tenant principals.
protected, like principalsFrom, so a test can drive it without weak-typed casts.
Resolve the entire access chain for one or more scopes and report what a search WOULD be able to reach — without querying any provider.
This is the answer to a question the platform previously could not answer at all: "as
this user, with this skill active, for this tenant — what is in bounds?" Every input to
that decision is transient. A grant applies because a time window is open right now; a
dimension is discarded because it was caller-authored on a ServerDerived key; a lane is
skipped because its filter lost an {% if %} clause. Afterwards, none of it is visible:
a correctly-bounded result set and an accidentally-widened one look identical.
Note the distinction from PreviewSearch, which is a real search capped at a few results. This runs no search — it reports the bound, not a sample of what is inside it. A sample cannot show you an over-broad bound, because the extra documents it would newly permit are exactly the ones you did not think to look for.
Two properties make the output trustworthy:
SearchContext a real caller would send, so the
preview shows the anti-spoof discard actually happening. A dry run that only accepted
pre-sanitized input would hide the one thing worth previewing.Unlike a real search this never throws for a scope-level problem; a scope that would fail
closed comes back with Reachable: false and the reason, since "it would have failed"
is precisely the finding the caller asked for. A scope that cannot be resolved (inactive,
expired, or missing) refuses the whole search, so when one is named every scope comes back
unreachable, each saying why.
scopes to explain plus the hypothetical caller context and principals
the user to evaluate entitlement for
one explanation per requested scope, in the order requested
ProtectedfilterFilter search results by entity-level and row-level security permissions, then by whether the user may read the record each content result was derived from.
Steps 1–4 are a safety net. Providers are expected to do per-provider permission
push-down (Section 3.6 of plans/search-scopes-rag-plus.md). If those steps remove more
than a handful of results in practice, the responsible provider's push-down is incomplete
and should be fixed. Step 5 is not: it drops by design, because no provider can push
an origin record's own row filters down into a content index. Its removals are counted
into stats.OriginGateRemoved, so the late-filter count keeps its meaning.
Groups results by entity for efficient permission checking:
storage-file results take their own path (filterStorageResults): they are re-checked against
the storage-account permission model for this user, never passed through on the strength of their type.
Optionalstats: { OriginGateRemoved: number }optional per-call counters; OriginGateRemoved is incremented by step 5's removals
ProtectedFilterKeep only the results every reader in params.Audience may read, on top of the caller's own filter.
Each reader gets the same safety net the caller did (filterByPermissions: entity read, row filters,
ownership), so a shared conversation's results are the intersection of what every participant may see —
the asker's reach is the ceiling, each other reader's reach lowers it. Readers are checked concurrently;
a reader with the caller's own ID, or listed twice, is checked once. Expects a validated audience
(searchInternal validates it before doing any work).
Two things the pass cannot do, both failing closed:
filterByPermissions re-checks a storage-file hit's account for the user it is given, but this pass
does not yet combine those per-reader answers for storage, so a storage hit is not shown to the room.UserRoles: []) reads nothing. Permissions and row filters are
evaluated from UserInfo.UserRoles, so such a reader empties the result. A reader with no
UserRoles array at all never gets here: validation refuses it.Identity contract. A result survives when every reader's filterByPermissions returns it. Survivors
are matched to the input by EntityName|RecordID (the record the result names), falling back to object
identity for a result that names no record. An override of filterByPermissions may therefore return
copies, but must not rewrite EntityName or RecordID.
Protected so a host can change how an audience combines (a host that materializes a shared reach in one
query can replace the per-reader pass). It receives the whole params, so an override can see the anchor
(SearchContext) and the scopes. The default is the intersection.
The Global Object Store is a place to store global objects that need to be shared across the application. Depending on the execution environment, this could be the window object in a browser, or the global object in a node environment, or something else in other contexts. The key here is that in some cases static variables are not truly shared because it is possible that a given class might have copies of its code in multiple paths in a deployed application. This approach ensures that no matter how many code copies might exist, there is only one instance of the object in question by using the Global Object Store.
ProtectedloadLoad an agent or skill principal by ID; null when no ID was supplied.
protected so a test can substitute principal loading; see explainEntitlement.
Public hook for callers (e.g. the GraphQL resolver) to emit a Status='Forbidden' SearchExecutionLog row when they reject a request before delegating to Search. Without this, forbidden invocations never reach the analytics dashboard — exactly the signal admins need to spot users / agents trying to access scopes they shouldn't.
Quick preview search optimized for autocomplete / typeahead. Uses preview mode (no enrichment), limited to 8 results by default. Only runs providers that have SupportsPreview=true.
The search query text
Maximum number of preview results (default: 8)
The user performing the search
Search result in preview mode
ProtectedprincipalsBuild the principal set a dimension's expansion query may bind.
Exists so the real search path and the ExplainScope dry run cannot construct principals
differently. They already did once: ExplainScope passed the agent and the search path
passed nothing, so any scope deriving its bound from AgentID previewed one bound and
searched with another. A single conversion site makes that class of drift unrepresentable
rather than merely fixed.
Accepts anything carrying the two principal IDs, which both SearchParams and
ExplainScopeInput do.
ProtectedProviderThe configured provider whose engine-stamped ProviderId item carries, or undefined when it carries
none or names no configured provider. The engine overwrites ProviderId on every result a provider
returns, before fusion.
Protected so a probe can map a hand-built hit to a provider instance without configuring the engine. It
decides which results skip ownership verification and which skip the lane ExtraFilter re-check, so an
override must only ever return the provider that actually produced the result.
ProtectedReadableFor each origin entity, those of its record ids the user may read under that entity's own permissions and
row filters — the check verifyOwnershipAndRowFilters gives a result's own entity. All entities are
asked in one RunViews batch. An entity the provider does not know, the user may not read at all, or
whose view fails gets no entry, so only the rows derived from it are dropped.
Protected so a host extending VerifyOriginRecords to another derived-content family can reuse it.
origin entity name → record ids, each a bare key value or a key segment
origin entity name → the ids (as given) of the rows the user may read
ProtectedResolveThe per-provider over-fetch multiplier for this search, in priority order: the caller's
SearchParams.PermissionOverfetchFactor; else the largest of each resolved scope's declared
ScopeConfig.permissionOverfetchFactor (a scope that declares none counts as the engine default);
else the engine default. Clamped to [1, MAX_OVERFETCH_FACTOR].
The largest wins across scopes, not the first: over-fetch exists to compensate for late
permission filtering, and a lane trimmed heavily by it needs the extra candidates whichever
scope it belongs to. A larger factor never changes which results the caller gets (the final
list is still trimmed to MaxResults), but it does cost more: every provider returns more
candidates, dedup, the content exclusion and the permission passes do more work, a re-ranker is
fed up to its inputTopN from a bigger pool, and streamSearch's per-provider partial events
(capped to the caller's MaxResults) draw from it. This is why a scope may declare it at all:
the scope's author knows how sparse its lanes are after permissions, and every caller shouldn't
have to.
Execute a multi-source search with RRF fusion and optional enrichment.
When params.ScopeIDs is provided, each scope runs independently and the results
are combined via cross-scope RRF before deduplication, re-ranking, and enrichment.
Search parameters
The user performing the search
Aggregated search result
ProtectedstableDeterministically serialize a value with object keys sorted, so that two logically-identical inputs always produce the same string.
JSON.stringify preserves insertion order, which means a caller that builds
SecondaryScopes by spreading (a common pattern) can emit the same dimensions in
different orders across calls. Left unsorted that causes avoidable cache misses;
sorted, identity is stable. Array order is PRESERVED — see buildCacheKey for why
ScopeIDs order is significant.
Streaming variant of Search: the same pipeline, with a progress event as each provider returns, so a UI or agent can show progress before fusion, the permission pass and enrichment finish.
Progress, not partial results. A provider event carries the provider's name, its duration and
resultCount — how many hits it returned, capped at MaxResults — and never the hits themselves
(results is always empty). They arrive before the permission pass, so they include rows the caller's
row filters would drop, external-index hits not yet verified against the entity they name, and content
whose origin record the caller may not read. Results arrive only in fused and final, which carry
exactly what Search returns. resultCount is counted before the permission and audience passes,
like SearchResult.SourceCounts, so don't show it to a room.
Cancellation: the consumer can stop iterating at any point — the underlying search runs to completion and its result is discarded. Mid-pipeline AbortSignal cancellation is not supported.
Event ordering:
provider event per provider that ran, in the order they return (a provider that failed
reports resultCount: 0)fused eventfinal event (reranked is part of the type but not emitted today)error event in place of fused and final.ProtectedVerifySecond gate, after a result is known to be a readable row of its own entity: may the user also read the record the row was DERIVED from?
Row-level security on a content entity settles who may read the content table. It cannot say whether the person may read the file, task or conversation that content was extracted from — that permission lives on the origin entity, under its own row filters. Without this gate, a chunk of a document the user may not open passes the first gate and is quoted anyway.
For MJ: Content Items and MJ: Content Item Chunks (and any IS-A subtype of either) the
default follows chunk → item (→ root item, for a split child) → MJ: Entity Record Documents
→ the origin record, and keeps a result only when the origin is a row the user may read,
verified the way verifyOwnershipAndRowFilters verifies the result's own entity. A
content row with no Entity Record Document (crawled, uploaded, external) has no origin and
passes unchanged, as does every result of a non-content entity.
Every lookup runs as the user, and that is a NEW requirement on who may see content hits: read on
the base MJ: Content Items for anyone who sees chunk hits (the chunk → item hop reads it), and read
on MJ: Entity Record Documents for anyone who sees hits derived from a record (the item → document
hop reads it). An app that indexes documents behind its own permissions grants those, under its
own row filters. Without them the affected rows are dropped: every row of the group for the chunk
and item hops, only the document-bearing rows for the document hop.
The origin's own permissions are checked once; an origin that is itself a content row is not walked again. Protected so a host can extend the rule to another derived-content family. Fails closed: a lookup that reports failure, or returns a row without a column it asked for (field-level security), drops the rows that depended on it; a lookup that throws drops the whole group. It never widens.
Protected StaticgetReturns the singleton instance of the class. If the instance does not exist, it is created and stored in the Global Object Store. If className is provided it will be used as part of the key in the Global Object Store, otherwise the actual class name will be used. NOTE: the class name used by default is the lowest level of the object hierarchy, so if you have a class that extends another class, the lowest level class name will be used.
OptionalclassName: string
Singleton search engine that orchestrates multi-source search with RRF fusion.
Providers are discovered from the MJ: Search Providers entity. Each active provider's DriverClass is resolved via ClassFactory to create an instance, which is then initialized with the provider's config from the DB record.
Usage: