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 it would empty the row collection, and buildScopeConstraints collapses an empty collection to undefined — which every provider reads as "unscoped", i.e. all entities / all indexes with no filter. So the surgical-looking fix is the one that widens; failing the search is the one that doesn't. A broken restricting template is a misconfiguration and should be loud and actionable, never silently degraded into a wider search.

      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.

      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.

      This is a safety net. Providers are expected to do per-provider permission push-down (Section 3.6 of plans/search-scopes-rag-plus.md). If this filter is removing more than a handful of results in practice, the responsible provider's push-down is incomplete and should be fixed.

      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.

      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

    • 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. Yields events as each pipeline stage produces output so the caller can emit partials to the UI / agent before fusion + reranking complete.

      Phase 2C v1 semantics: runs the same internal pipeline as Search and emits synthetic events at each transition. This preserves all existing fusion / permission / dedup / enrich behavior — important because those steps have subtle correctness rules that we don't want to re-implement in a parallel code path. Per-provider partials are reconstructed from the final SourceCounts; a future refactor (Phase 2C v2) can split provider emission to true real-time concurrent emission once we measure that the synthetic phase is the actual bottleneck.

      Cancellation: the consumer can stop iterating at any point — the underlying Search() will run to completion but its result is discarded. AbortSignal-based mid-pipeline cancellation is a Phase 2C v2 concern.

      Event ordering:

      1. Zero or more provider events (one per non-empty source)
      2. Exactly one fused event
      3. Optional one reranked event (when a reranker is configured)
      4. Exactly one final event
      5. On error: a single error event in place of 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.results.length} hits`); break;
      case 'final': scratchpad.commit(ev.results); break;
      case 'error': scratchpad.fail(ev.error); break;
      }
      }
    • 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