Member Junction
    Preparing search index...

    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:

    // Initialize once at server startup
    await SearchEngine.Instance.Config({}, contextUser);

    // Execute searches (unscoped — original behavior)
    const result = await SearchEngine.Instance.Search({
    Query: 'quarterly revenue',
    MaxResults: 20,
    MinScore: 0.1
    }, contextUser);

    // Scoped search against two scopes with multi-tenant context
    const scopedResult = await SearchEngine.Instance.Search({
    Query: 'refund policy',
    MaxResults: 20,
    ScopeIDs: ['hr-scope-id', 'legal-scope-id'],
    SearchContext: { PrimaryScopeRecordID: 'tenant-a' }
    }, contextUser);

    Hierarchy (View Summary)

    Index

    Constructors

    Accessors

    • get GlobalKey(): string

      Returns string

    Methods

    • Enforce 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.

      Parameters

      • declaration: string
      • laneID: string
      • rendered: unknown
      • scopeLabel: string
      • rowLabel: string
      • Optionalcollector: LaneProblemCollector

      Returns void

    • Fail 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.

      Parameters

      • source: string
      • rendered: unknown
      • fieldName: string
      • scopeLabel: string
      • rowLabel: string
      • laneID: string
      • Optionalcollector: LaneProblemCollector

      Returns void

    • Build 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.

      Parameters

      Returns string

    • 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).

      Parameters

      • config: SearchEngineConfig = {}

        Engine configuration options

      • contextUser: UserInfo

        The user context for initialization

      • forceRefresh: boolean = false

        If true, re-initializes even if already configured

      Returns Promise<void>

    • 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:

      • It takes the same untrusted 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.
      • It reports every broken lane in one pass rather than throwing on the first, so a misconfigured scope can be fixed in one sitting instead of one error per re-run.

      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.

      Parameters

      • input: ExplainScopeInput

        scopes to explain plus the hypothetical caller context and principals

      • contextUser: UserInfo

        the user to evaluate entitlement for

      Returns Promise<ScopeExplanation[]>

      one explanation per requested scope, in the order requested

    • Filter 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:

      1. Unknown entities are excluded (fail closed).
      2. If the user lacks entity-level CanRead, all results for that entity are dropped.
      3. If the user is exempt from RLS, all results pass through.
      4. If RLS applies, a RunView validates which record IDs the user can read.
      5. Content items and chunks are kept only when the user may read the record they were derived from (VerifyOriginRecords).

      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.

      Parameters

      • results: SearchResultItem[]
      • contextUser: UserInfo
      • Optionalstats: { OriginGateRemoved: number }

        optional per-call counters; OriginGateRemoved is incremented by step 5's removals

      Returns Promise<SearchResultItem[]>

    • Keep 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:

      • Storage hits are refused under an audience. Their permission model is the storage account's; 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.
      • A reader with no roles (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.

      Parameters

      Returns Promise<SearchResultItem[]>

    • 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.

      Returns GlobalObjectStore

    • 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.

      Parameters

      • input: {
            AIAgentID?: string;
            AISkillID?: string;
            ContextUser: UserInfo;
            FailureReason: string;
            PrimaryScopeRecordID?: string;
            Query: string;
            ScopeIDs?: string[];
            StartTime: number;
        }

      Returns Promise<void>

    • 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.

      Parameters

      • query: string

        The search query text

      • maxResults: number = 8

        Maximum number of preview results (default: 8)

      • contextUser: UserInfo

        The user performing the search

      Returns Promise<SearchResult>

      Search result in preview mode

    • Build 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.

      Parameters

      • source: { AIAgentID?: string; AISkillID?: string }

      Returns ScopePrincipals

    • The 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.

      Parameters

      Returns BaseSearchProvider

    • For 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.

      Parameters

      • idsByEntity: Map<string, string[]>

        origin entity name → record ids, each a bare key value or a key segment

      • contextUser: UserInfo

      Returns Promise<Map<string, Set<string>>>

      origin entity name → the ids (as given) of the rows the user may read

    • The 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.

      Parameters

      Returns number

    • Deterministically 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.

      Parameters

      • value: unknown

      Returns string

    • 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:

      1. One provider event per provider that ran, in the order they return (a provider that failed reports resultCount: 0)
      2. Exactly one fused event
      3. Exactly one final event (reranked is part of the type but not emitted today)
      4. On error: a single error event in place of fused and final.

      Parameters

      Returns AsyncIterable<SearchStreamEvent>

      for await (const ev of SearchEngine.Instance.streamSearch(params, user)) {
      switch (ev.phase) {
      case 'provider': scratchpad.append(`${ev.providerName}: ${ev.resultCount} hits`); break;
      case 'final': scratchpad.commit(ev.results); break;
      case 'error': scratchpad.fail(ev.error); break;
      }
      }
    • Second 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.

      Parameters

      Returns Promise<SearchResultItem[]>

    • Returns 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.

      Type Parameters

      Parameters

      • this: new () => T
      • OptionalclassName: string

      Returns T