@memberjunction/search-engine 5.49.0 → 5.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generic/EntitySearchProvider.d.ts +17 -3
- package/dist/generic/EntitySearchProvider.d.ts.map +1 -1
- package/dist/generic/EntitySearchProvider.js +23 -6
- package/dist/generic/EntitySearchProvider.js.map +1 -1
- package/dist/generic/ExternalHitMapper.d.ts +48 -0
- package/dist/generic/ExternalHitMapper.d.ts.map +1 -0
- package/dist/generic/ExternalHitMapper.js +88 -0
- package/dist/generic/ExternalHitMapper.js.map +1 -0
- package/dist/generic/FullTextSearchProvider.d.ts +9 -1
- package/dist/generic/FullTextSearchProvider.d.ts.map +1 -1
- package/dist/generic/FullTextSearchProvider.js +11 -3
- package/dist/generic/FullTextSearchProvider.js.map +1 -1
- package/dist/generic/ScopeDimensionResolver.d.ts +146 -0
- package/dist/generic/ScopeDimensionResolver.d.ts.map +1 -0
- package/dist/generic/ScopeDimensionResolver.js +464 -0
- package/dist/generic/ScopeDimensionResolver.js.map +1 -0
- package/dist/generic/ScopeExplanation.d.ts +141 -0
- package/dist/generic/ScopeExplanation.d.ts.map +1 -0
- package/dist/generic/ScopeExplanation.js +72 -0
- package/dist/generic/ScopeExplanation.js.map +1 -0
- package/dist/generic/ScopeFilterGuard.d.ts +127 -0
- package/dist/generic/ScopeFilterGuard.d.ts.map +1 -0
- package/dist/generic/ScopeFilterGuard.js +290 -0
- package/dist/generic/ScopeFilterGuard.js.map +1 -0
- package/dist/generic/ScopeTemplateRenderer.d.ts +12 -2
- package/dist/generic/ScopeTemplateRenderer.d.ts.map +1 -1
- package/dist/generic/ScopeTemplateRenderer.js +25 -5
- package/dist/generic/ScopeTemplateRenderer.js.map +1 -1
- package/dist/generic/ScopeValueEscaper.d.ts +112 -0
- package/dist/generic/ScopeValueEscaper.d.ts.map +1 -0
- package/dist/generic/ScopeValueEscaper.js +152 -0
- package/dist/generic/ScopeValueEscaper.js.map +1 -0
- package/dist/generic/SearchEngine.d.ts +188 -8
- package/dist/generic/SearchEngine.d.ts.map +1 -1
- package/dist/generic/SearchEngine.js +600 -35
- package/dist/generic/SearchEngine.js.map +1 -1
- package/dist/generic/VectorSearchProvider.d.ts +2 -1
- package/dist/generic/VectorSearchProvider.d.ts.map +1 -1
- package/dist/generic/VectorSearchProvider.js +23 -26
- package/dist/generic/VectorSearchProvider.js.map +1 -1
- package/dist/generic/search.types.d.ts +160 -0
- package/dist/generic/search.types.d.ts.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/permissions/SearchScopePermissionResolver.d.ts +37 -2
- package/dist/permissions/SearchScopePermissionResolver.d.ts.map +1 -1
- package/dist/permissions/SearchScopePermissionResolver.js +74 -2
- package/dist/permissions/SearchScopePermissionResolver.js.map +1 -1
- package/dist/providers/AzureAISearchProvider.d.ts.map +1 -1
- package/dist/providers/AzureAISearchProvider.js +24 -6
- package/dist/providers/AzureAISearchProvider.js.map +1 -1
- package/dist/providers/ElasticsearchSearchProvider.d.ts.map +1 -1
- package/dist/providers/ElasticsearchSearchProvider.js +17 -5
- package/dist/providers/ElasticsearchSearchProvider.js.map +1 -1
- package/dist/providers/OpenSearchSearchProvider.d.ts.map +1 -1
- package/dist/providers/OpenSearchSearchProvider.js +16 -5
- package/dist/providers/OpenSearchSearchProvider.js.map +1 -1
- package/dist/providers/TypesenseSearchProvider.d.ts.map +1 -1
- package/dist/providers/TypesenseSearchProvider.js +17 -6
- package/dist/providers/TypesenseSearchProvider.js.map +1 -1
- package/package.json +8 -8
|
@@ -30,6 +30,17 @@ import { BaseReRanker } from './BaseReRanker.js';
|
|
|
30
30
|
import { NoopReRanker, LoadNoopReRanker } from './NoopReRanker.js';
|
|
31
31
|
import { RerankerBudgetGuard } from '../rerankers/RerankerBudgetGuard.js';
|
|
32
32
|
import { RenderScopeTemplate, RenderScopeJsonTemplate } from './ScopeTemplateRenderer.js';
|
|
33
|
+
import { LaneKindForIndexType } from './ScopeValueEscaper.js';
|
|
34
|
+
import { CheckRenderedTemplate, CheckRequiredMetadataKeys, ParseRequiredMetadataKeys } from './ScopeFilterGuard.js';
|
|
35
|
+
import { ScopeDimensionResolver } from './ScopeDimensionResolver.js';
|
|
36
|
+
import { DefaultSearchScopePermissionResolver } from '../permissions/SearchScopePermissionResolver.js';
|
|
37
|
+
/**
|
|
38
|
+
* Emitted whenever a scope configures no lanes at all — which in MJ means UNSCOPED, not narrow.
|
|
39
|
+
* Shared by the dry run and the search path so both produce identical wording.
|
|
40
|
+
*/
|
|
41
|
+
const UNBOUNDED_SCOPE_DIAGNOSTIC = 'this scope configures NO lanes (no external indexes, entities, or storage accounts). ' +
|
|
42
|
+
'That is not a narrow scope — providers read an empty configuration as UNSCOPED, so it ' +
|
|
43
|
+
'searches everything available to them with no filter.';
|
|
33
44
|
// Keep the default re-ranker registration alive under tree-shaking
|
|
34
45
|
LoadNoopReRanker();
|
|
35
46
|
/**
|
|
@@ -71,18 +82,21 @@ export class SearchEngine extends BaseSingleton {
|
|
|
71
82
|
this._defaultMaxResults = 20;
|
|
72
83
|
this._defaultOverfetchFactor = 2;
|
|
73
84
|
this._cache = new Map();
|
|
85
|
+
this._dimensionResolver = new ScopeDimensionResolver();
|
|
74
86
|
}
|
|
75
87
|
/** Static accessor for the global singleton instance */
|
|
76
88
|
static get Instance() {
|
|
77
89
|
return super.getInstance();
|
|
78
90
|
}
|
|
79
91
|
/**
|
|
80
|
-
* Minimum trimmed query length we accept.
|
|
81
|
-
*
|
|
92
|
+
* Minimum trimmed query length we accept. A single-character query against a
|
|
93
|
+
* `LIKE '%term%'` fan-out is essentially a full-database scan with negligible
|
|
82
94
|
* relevance — the providers also enforce this, but we short-circuit here to
|
|
83
|
-
* avoid the cache lookup and provider dispatch overhead too.
|
|
95
|
+
* avoid the cache lookup and provider dispatch overhead too. Set to 2 (was 3) so
|
|
96
|
+
* legitimate short queries aren't silently dropped (bug C3); must stay in lockstep
|
|
97
|
+
* with the providers' MIN_TERM_LENGTH.
|
|
84
98
|
*/
|
|
85
|
-
static { this.MIN_TERM_LENGTH =
|
|
99
|
+
static { this.MIN_TERM_LENGTH = 2; }
|
|
86
100
|
/**
|
|
87
101
|
* Result cache TTL. 30s balances "user resubmits the same prefix" wins against
|
|
88
102
|
* "results stay reasonably fresh after a write". Cache key includes the user's
|
|
@@ -91,6 +105,13 @@ export class SearchEngine extends BaseSingleton {
|
|
|
91
105
|
static { this.CACHE_TTL_MS = 30_000; }
|
|
92
106
|
/** Maximum cached entries across all users. LRU-evicted on overflow. */
|
|
93
107
|
static { this.CACHE_MAX_ENTRIES = 500; }
|
|
108
|
+
/**
|
|
109
|
+
* Resolver for a scope's declared Search Context dimensions. Overridable so a host can
|
|
110
|
+
* supply additional derivation sources (e.g. an external signal) without forking the engine.
|
|
111
|
+
*/
|
|
112
|
+
get dimensionResolver() {
|
|
113
|
+
return this._dimensionResolver;
|
|
114
|
+
}
|
|
94
115
|
/** Access the cached provider metadata from SearchEngineBase */
|
|
95
116
|
get Base() {
|
|
96
117
|
return SearchEngineBase.Instance;
|
|
@@ -178,6 +199,8 @@ export class SearchEngine extends BaseSingleton {
|
|
|
178
199
|
SourceCounts: undefined,
|
|
179
200
|
ContextUser: contextUser,
|
|
180
201
|
AIAgentID: params.AIAgentID ?? null,
|
|
202
|
+
AISkillID: params.AISkillID ?? null,
|
|
203
|
+
PrimaryScopeRecordID: params.SearchContext?.PrimaryScopeRecordID ?? null,
|
|
181
204
|
});
|
|
182
205
|
return this.buildErrorResult('Query cannot be empty', startTime);
|
|
183
206
|
}
|
|
@@ -232,6 +255,9 @@ export class SearchEngine extends BaseSingleton {
|
|
|
232
255
|
// ──────────────────────────────────────────────────────────
|
|
233
256
|
let sourceCounts;
|
|
234
257
|
let fusedResults;
|
|
258
|
+
// Per-scope decisions, captured for SearchExecutionLog.ScopeDecisionJSON. Empty on
|
|
259
|
+
// the unconstrained path, which resolves no scope and therefore decides nothing.
|
|
260
|
+
const scopeDecisions = [];
|
|
235
261
|
if (isUnconstrained) {
|
|
236
262
|
const labeledLists = await this.executeProviders(params.Query, providerTopK, params.Filters, contextUser, isPreview, undefined, onProviderResolved);
|
|
237
263
|
sourceCounts = this.countSources(labeledLists);
|
|
@@ -240,7 +266,7 @@ export class SearchEngine extends BaseSingleton {
|
|
|
240
266
|
}
|
|
241
267
|
else {
|
|
242
268
|
// Run each scope independently, then cross-scope RRF
|
|
243
|
-
const perScopeRunResults = await Promise.all(resolvedScopes.map(bundle => this.executeScopeBundle(params.Query, providerTopK, params.Filters, contextUser, isPreview, bundle, params.SearchContext, params.FusionWeightsOverride, onProviderResolved)));
|
|
269
|
+
const perScopeRunResults = await Promise.all(resolvedScopes.map(bundle => this.executeScopeBundle(params.Query, providerTopK, params.Filters, contextUser, isPreview, bundle, params.SearchContext, params.FusionWeightsOverride, this.principalsFrom(params), onProviderResolved)));
|
|
244
270
|
const sc = { Vector: 0, FullText: 0, Entity: 0, Storage: 0 };
|
|
245
271
|
const perScopeFused = new Map();
|
|
246
272
|
for (const r of perScopeRunResults) {
|
|
@@ -249,6 +275,7 @@ export class SearchEngine extends BaseSingleton {
|
|
|
249
275
|
sc.Entity += r.sourceCounts.Entity;
|
|
250
276
|
sc.Storage += r.sourceCounts.Storage;
|
|
251
277
|
perScopeFused.set(r.scopeID, r.fused);
|
|
278
|
+
scopeDecisions.push(r.decision);
|
|
252
279
|
}
|
|
253
280
|
sourceCounts = sc;
|
|
254
281
|
if (perScopeFused.size > 1) {
|
|
@@ -308,6 +335,9 @@ export class SearchEngine extends BaseSingleton {
|
|
|
308
335
|
SourceCounts: sourceCounts,
|
|
309
336
|
ContextUser: contextUser,
|
|
310
337
|
AIAgentID: params.AIAgentID ?? null,
|
|
338
|
+
AISkillID: params.AISkillID ?? null,
|
|
339
|
+
PrimaryScopeRecordID: params.SearchContext?.PrimaryScopeRecordID ?? null,
|
|
340
|
+
ScopeDecisions: scopeDecisions,
|
|
311
341
|
});
|
|
312
342
|
const finalResult = {
|
|
313
343
|
Success: true,
|
|
@@ -337,6 +367,8 @@ export class SearchEngine extends BaseSingleton {
|
|
|
337
367
|
SourceCounts: undefined,
|
|
338
368
|
ContextUser: contextUser,
|
|
339
369
|
AIAgentID: params.AIAgentID ?? null,
|
|
370
|
+
AISkillID: params.AISkillID ?? null,
|
|
371
|
+
PrimaryScopeRecordID: params.SearchContext?.PrimaryScopeRecordID ?? null,
|
|
340
372
|
});
|
|
341
373
|
return this.buildErrorResult(msg, startTime);
|
|
342
374
|
}
|
|
@@ -483,19 +515,90 @@ export class SearchEngine extends BaseSingleton {
|
|
|
483
515
|
}
|
|
484
516
|
}
|
|
485
517
|
/**
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
*
|
|
518
|
+
* Deterministically serialize a value with object keys sorted, so that two
|
|
519
|
+
* logically-identical inputs always produce the same string.
|
|
520
|
+
*
|
|
521
|
+
* `JSON.stringify` preserves *insertion* order, which means a caller that builds
|
|
522
|
+
* `SecondaryScopes` by spreading (a common pattern) can emit the same dimensions in
|
|
523
|
+
* different orders across calls. Left unsorted that causes avoidable cache misses;
|
|
524
|
+
* sorted, identity is stable. Array order is PRESERVED — see buildCacheKey for why
|
|
525
|
+
* `ScopeIDs` order is significant.
|
|
526
|
+
*/
|
|
527
|
+
stableStringify(value) {
|
|
528
|
+
if (value === null || typeof value !== 'object')
|
|
529
|
+
return JSON.stringify(value) ?? 'null';
|
|
530
|
+
if (Array.isArray(value))
|
|
531
|
+
return `[${value.map((v) => this.stableStringify(v)).join(',')}]`;
|
|
532
|
+
const entries = Object.entries(value)
|
|
533
|
+
.filter(([, v]) => v !== undefined)
|
|
534
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
535
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${this.stableStringify(v)}`).join(',')}}`;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Build a stable cache key for a search.
|
|
539
|
+
*
|
|
540
|
+
* The key must include EVERY input that can change the result set, or the cache
|
|
541
|
+
* will serve one caller's results to another. Two of those inputs were previously
|
|
542
|
+
* missing and both are tenancy/authorization-relevant:
|
|
543
|
+
*
|
|
544
|
+
* - `SearchContext` — carries `PrimaryScopeRecordID` (the TENANT) and the
|
|
545
|
+
* `SecondaryScopes` dimensions. Omitting it meant a user with access to two
|
|
546
|
+
* tenants could be served the other tenant's results for up to the cache TTL,
|
|
547
|
+
* and that two searches differing only by dimension (channel, skill, …)
|
|
548
|
+
* collided. This is the reason for the fix.
|
|
549
|
+
* - `ScopeIDs` — determines which corpora are searched at all.
|
|
550
|
+
*
|
|
551
|
+
* Also folded in: `Mode`, `FusionWeightsOverride` and `PermissionOverfetchFactor`
|
|
552
|
+
* (all change ranking or the candidate pool, so they change results) and
|
|
553
|
+
* `AIAgentID` (conservative: agent identity participates in scope resolution and
|
|
554
|
+
* per-agent overrides upstream; including it can only cost a miss, never leak).
|
|
555
|
+
*
|
|
556
|
+
* `ScopeIDs` order is deliberately NOT sorted: it is behaviourally significant,
|
|
557
|
+
* because cross-scope reranker config and budget are taken from the first scope in
|
|
558
|
+
* the array that supplies one. Two different orderings can therefore produce
|
|
559
|
+
* different results and must not share a key.
|
|
560
|
+
*
|
|
561
|
+
* The whole projection is emitted through `stableStringify` so key-order variation
|
|
562
|
+
* in `SecondaryScopes` doesn't fragment the cache. The user ID stays as a readable
|
|
563
|
+
* prefix for debuggability.
|
|
564
|
+
*
|
|
565
|
+
* Note this runs AFTER scope resolution in `searchInternal`, so nothing here is
|
|
566
|
+
* circular. Entitlement is resolved by the CALLERS (`__Scoped_Search`, the
|
|
567
|
+
* GraphQL resolvers), which deny before reaching the engine; the engine therefore
|
|
568
|
+
* never caches across an allow/deny boundary.
|
|
489
569
|
*/
|
|
490
570
|
buildCacheKey(trimmed, params, contextUser) {
|
|
491
571
|
const userKey = contextUser?.ID ?? 'anonymous';
|
|
492
572
|
const f = params.Filters ?? {};
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
573
|
+
const projection = {
|
|
574
|
+
// Order-insensitive: sorted so equivalent filter sets share an entry.
|
|
575
|
+
Filters: {
|
|
576
|
+
EntityNames: f.EntityNames ? [...f.EntityNames].sort() : undefined,
|
|
577
|
+
SourceTypes: f.SourceTypes ? [...f.SourceTypes].sort() : undefined,
|
|
578
|
+
Tags: f.Tags ? [...f.Tags].sort() : undefined,
|
|
579
|
+
},
|
|
580
|
+
MaxResults: params.MaxResults ?? this._defaultMaxResults,
|
|
581
|
+
MinScore: params.MinScore ?? 0,
|
|
582
|
+
Mode: params.Mode ?? undefined,
|
|
583
|
+
// Order-SENSITIVE — do not sort (see doc comment).
|
|
584
|
+
ScopeIDs: params.ScopeIDs ?? undefined,
|
|
585
|
+
SearchContext: params.SearchContext
|
|
586
|
+
? {
|
|
587
|
+
PrimaryScopeEntityID: params.SearchContext.PrimaryScopeEntityID ?? undefined,
|
|
588
|
+
PrimaryScopeRecordID: params.SearchContext.PrimaryScopeRecordID ?? undefined,
|
|
589
|
+
SecondaryScopes: params.SearchContext.SecondaryScopes ?? undefined,
|
|
590
|
+
}
|
|
591
|
+
: undefined,
|
|
592
|
+
FusionWeightsOverride: params.FusionWeightsOverride ?? undefined,
|
|
593
|
+
PermissionOverfetchFactor: params.PermissionOverfetchFactor ?? undefined,
|
|
594
|
+
AIAgentID: params.AIAgentID ?? undefined,
|
|
595
|
+
// Same reasoning as AIAgentID, and Phase D is what makes it load-bearing: a skill is
|
|
596
|
+
// a principal that can reach a scope the user's own roles do not grant, and it binds
|
|
597
|
+
// into expansion queries. Two searches identical but for the active skill are NOT
|
|
598
|
+
// interchangeable, so they must not share a cache entry.
|
|
599
|
+
AISkillID: params.AISkillID ?? undefined,
|
|
600
|
+
};
|
|
601
|
+
return `${userKey}|${trimmed}|${this.stableStringify(projection)}`;
|
|
499
602
|
}
|
|
500
603
|
/** Insert into the LRU cache, evicting oldest entries when over capacity. */
|
|
501
604
|
cachePut(key, result) {
|
|
@@ -529,6 +632,318 @@ export class SearchEngine extends BaseSingleton {
|
|
|
529
632
|
}, contextUser);
|
|
530
633
|
}
|
|
531
634
|
// ────────────────────────────────────────────────────────────────
|
|
635
|
+
// Dry run — explain the bound without executing a search
|
|
636
|
+
// ────────────────────────────────────────────────────────────────
|
|
637
|
+
/**
|
|
638
|
+
* Resolve the entire access chain for one or more scopes and report what a search WOULD be
|
|
639
|
+
* able to reach — **without querying any provider**.
|
|
640
|
+
*
|
|
641
|
+
* This is the answer to a question the platform previously could not answer at all: *"as
|
|
642
|
+
* this user, with this skill active, for this tenant — what is in bounds?"* Every input to
|
|
643
|
+
* that decision is transient. A grant applies because a time window is open right now; a
|
|
644
|
+
* dimension is discarded because it was caller-authored on a `ServerDerived` key; a lane is
|
|
645
|
+
* skipped because its filter lost an `{% if %}` clause. Afterwards, none of it is visible:
|
|
646
|
+
* a correctly-bounded result set and an accidentally-widened one look identical.
|
|
647
|
+
*
|
|
648
|
+
* Note the distinction from {@link PreviewSearch}, which is a real search capped at a few
|
|
649
|
+
* results. This runs **no** search — it reports the bound, not a sample of what is inside
|
|
650
|
+
* it. A sample cannot show you an over-broad bound, because the extra documents it would
|
|
651
|
+
* newly permit are exactly the ones you did not think to look for.
|
|
652
|
+
*
|
|
653
|
+
* Two properties make the output trustworthy:
|
|
654
|
+
*
|
|
655
|
+
* - It takes the **same untrusted `SearchContext` a real caller would send**, so the
|
|
656
|
+
* preview shows the anti-spoof discard actually happening. A dry run that only accepted
|
|
657
|
+
* pre-sanitized input would hide the one thing worth previewing.
|
|
658
|
+
* - It reports **every** broken lane in one pass rather than throwing on the first, so a
|
|
659
|
+
* misconfigured scope can be fixed in one sitting instead of one error per re-run.
|
|
660
|
+
*
|
|
661
|
+
* Unlike a real search this never throws for a scope-level problem; a scope that would fail
|
|
662
|
+
* closed comes back with `Reachable: false` and the reason, since "it would have failed"
|
|
663
|
+
* is precisely the finding the caller asked for.
|
|
664
|
+
*
|
|
665
|
+
* @param input scopes to explain plus the hypothetical caller context and principals
|
|
666
|
+
* @param contextUser the user to evaluate entitlement for
|
|
667
|
+
* @returns one explanation per requested scope, in the order requested
|
|
668
|
+
*/
|
|
669
|
+
async ExplainScope(input, contextUser) {
|
|
670
|
+
const explanations = [];
|
|
671
|
+
for (const scopeID of input.ScopeIDs) {
|
|
672
|
+
explanations.push(await this.explainOneScope(scopeID, input, contextUser));
|
|
673
|
+
}
|
|
674
|
+
return explanations;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Build the principal set a dimension's expansion query may bind.
|
|
678
|
+
*
|
|
679
|
+
* Exists so the real search path and the `ExplainScope` dry run cannot construct principals
|
|
680
|
+
* differently. They already did once: `ExplainScope` passed the agent and the search path
|
|
681
|
+
* passed nothing, so any scope deriving its bound from `AgentID` previewed one bound and
|
|
682
|
+
* searched with another. A single conversion site makes that class of drift unrepresentable
|
|
683
|
+
* rather than merely fixed.
|
|
684
|
+
*
|
|
685
|
+
* Accepts anything carrying the two principal IDs, which both `SearchParams` and
|
|
686
|
+
* `ExplainScopeInput` do.
|
|
687
|
+
*/
|
|
688
|
+
principalsFrom(source) {
|
|
689
|
+
return { AgentID: source.AIAgentID ?? null, SkillID: source.AISkillID ?? null };
|
|
690
|
+
}
|
|
691
|
+
/** Explain a single scope. Never throws — a failure to resolve IS the explanation. */
|
|
692
|
+
async explainOneScope(scopeID, input, contextUser) {
|
|
693
|
+
const bundle = this.Base.GetScopeBundle(scopeID);
|
|
694
|
+
const scope = bundle?.Scope ?? this.Base.GetActiveScopeByID(scopeID);
|
|
695
|
+
if (!bundle || !scope) {
|
|
696
|
+
return this.buildUnresolvableExplanation(scopeID, input, contextUser);
|
|
697
|
+
}
|
|
698
|
+
const entitlement = await this.explainEntitlement(scopeID, input, contextUser);
|
|
699
|
+
// Resolve dimensions against the caller's UNSANITIZED context, exactly as a real search
|
|
700
|
+
// would. A ScopeDimensionError means the search would have failed closed — that is a
|
|
701
|
+
// legitimate result here, so it is reported rather than propagated.
|
|
702
|
+
let dimensions = [];
|
|
703
|
+
const diagnostics = [];
|
|
704
|
+
let effectiveContext;
|
|
705
|
+
let dimensionFailure = null;
|
|
706
|
+
try {
|
|
707
|
+
const resolved = await this.dimensionResolver.Resolve({
|
|
708
|
+
Scope: scope,
|
|
709
|
+
CallerContext: input.SearchContext,
|
|
710
|
+
ContextUser: contextUser,
|
|
711
|
+
Principals: this.principalsFrom(input),
|
|
712
|
+
});
|
|
713
|
+
dimensions = resolved.Provenance;
|
|
714
|
+
diagnostics.push(...resolved.Diagnostics);
|
|
715
|
+
effectiveContext = resolved.Context;
|
|
716
|
+
}
|
|
717
|
+
catch (e) {
|
|
718
|
+
dimensionFailure = e instanceof Error ? e.message : String(e);
|
|
719
|
+
diagnostics.push(`dimension resolution FAILED — a real search would be refused: ${dimensionFailure}`);
|
|
720
|
+
}
|
|
721
|
+
// With dimensions unresolved there is no context to render lanes against, so every lane
|
|
722
|
+
// is reported as skipped for that reason rather than rendered against a partial bound.
|
|
723
|
+
const lanes = dimensionFailure
|
|
724
|
+
? this.buildAllLanesSkipped(bundle, `dimension resolution failed: ${dimensionFailure}`)
|
|
725
|
+
: this.explainLanes(bundle, effectiveContext);
|
|
726
|
+
// A scope with NO lanes at all is not a narrow scope — it is MJ's widest. Empty child
|
|
727
|
+
// collections are collapsed to `undefined` by buildScopeConstraints, and every provider
|
|
728
|
+
// reads that as "unscoped": all entities, all indexes, no filter. Treating zero lanes as
|
|
729
|
+
// unreachable inverted the one finding a reviewer most needs, so it is called out
|
|
730
|
+
// explicitly instead.
|
|
731
|
+
const hasLanes = lanes.length > 0;
|
|
732
|
+
if (!hasLanes) {
|
|
733
|
+
diagnostics.push(UNBOUNDED_SCOPE_DIAGNOSTIC);
|
|
734
|
+
}
|
|
735
|
+
const canRetrieve = hasLanes ? lanes.some((l) => l.Status === 'Active') : true;
|
|
736
|
+
return {
|
|
737
|
+
ScopeID: scope.ID,
|
|
738
|
+
ScopeName: scope.Name,
|
|
739
|
+
Entitlement: entitlement,
|
|
740
|
+
Dimensions: dimensions,
|
|
741
|
+
Lanes: lanes,
|
|
742
|
+
Diagnostics: diagnostics,
|
|
743
|
+
Reachable: entitlement.Allowed && canRetrieve && !dimensionFailure,
|
|
744
|
+
Unbounded: !hasLanes,
|
|
745
|
+
ResolvedContext: effectiveContext,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
/** Resolve entitlement for the dry run, including the skill and tenant principals. */
|
|
749
|
+
async explainEntitlement(scopeID, input, contextUser) {
|
|
750
|
+
const principals = {
|
|
751
|
+
UserID: contextUser.ID ?? null,
|
|
752
|
+
AgentID: input.AIAgentID ?? null,
|
|
753
|
+
SkillID: input.AISkillID ?? null,
|
|
754
|
+
PrimaryScopeRecordID: input.SearchContext?.PrimaryScopeRecordID ?? null,
|
|
755
|
+
};
|
|
756
|
+
try {
|
|
757
|
+
const [agent, skill] = await Promise.all([
|
|
758
|
+
this.loadPrincipal('MJ: AI Agents', input.AIAgentID, contextUser),
|
|
759
|
+
this.loadPrincipal('MJ: AI Skills', input.AISkillID, contextUser),
|
|
760
|
+
]);
|
|
761
|
+
const permission = await DefaultSearchScopePermissionResolver.ResolveEffectivePermission({
|
|
762
|
+
User: contextUser,
|
|
763
|
+
SearchScopeID: scopeID,
|
|
764
|
+
Agent: agent,
|
|
765
|
+
Skill: skill,
|
|
766
|
+
PrimaryScopeRecordID: principals.PrimaryScopeRecordID,
|
|
767
|
+
ContextUser: contextUser,
|
|
768
|
+
});
|
|
769
|
+
return {
|
|
770
|
+
Allowed: permission.Allowed,
|
|
771
|
+
Level: permission.Level,
|
|
772
|
+
Source: permission.Source,
|
|
773
|
+
Reason: permission.Reason,
|
|
774
|
+
Principals: principals,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
catch (e) {
|
|
778
|
+
// A resolver failure must read as "denied", never as "allowed" — an explanation that
|
|
779
|
+
// fails open would be worse than no explanation at all.
|
|
780
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
781
|
+
return {
|
|
782
|
+
Allowed: false,
|
|
783
|
+
Level: 'None',
|
|
784
|
+
Source: 'NoGrant',
|
|
785
|
+
Reason: `entitlement could not be resolved, reported as denied: ${msg}`,
|
|
786
|
+
Principals: principals,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/** Load an agent or skill principal by ID; null when no ID was supplied. */
|
|
791
|
+
async loadPrincipal(entityName, id, contextUser) {
|
|
792
|
+
if (!id)
|
|
793
|
+
return null;
|
|
794
|
+
const entity = await this.ProviderToUse.GetEntityObject(entityName, contextUser);
|
|
795
|
+
const loaded = await entity.Load(id);
|
|
796
|
+
return loaded ? entity : null;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Render every lane and report which would run.
|
|
800
|
+
*
|
|
801
|
+
* Reuses `buildScopeConstraints` with a collector rather than duplicating the render logic.
|
|
802
|
+
* That matters more than it looks: a separate "explain" renderer would be a second
|
|
803
|
+
* implementation of the guard rules, free to drift from the enforcing one, and a preview
|
|
804
|
+
* that disagrees with what actually runs is worse than having no preview.
|
|
805
|
+
*/
|
|
806
|
+
/**
|
|
807
|
+
* Turn already-built constraints plus a problem map into per-lane explanations.
|
|
808
|
+
*
|
|
809
|
+
* Takes the constraints rather than rebuilding them. The previous version called
|
|
810
|
+
* `buildScopeConstraints` itself, which meant the SEARCH path re-rendered every Nunjucks
|
|
811
|
+
* template a second time on every scope of every query purely to produce a log record —
|
|
812
|
+
* pure waste on the hottest path in the engine.
|
|
813
|
+
*
|
|
814
|
+
* Both callers still share one rendering pass, which is what keeps the dry run honest:
|
|
815
|
+
* a separate explain-only renderer would be a second implementation of the guard rules,
|
|
816
|
+
* free to drift from the enforcing one.
|
|
817
|
+
*/
|
|
818
|
+
buildLaneExplanations(bundle, constraints, problems) {
|
|
819
|
+
const lanes = [];
|
|
820
|
+
for (const row of bundle.ExternalIndexes) {
|
|
821
|
+
const rendered = constraints.ExternalIndexes?.find((c) => UUIDsEqual(c.SearchScopeExternalIndexID, row.ID));
|
|
822
|
+
lanes.push({
|
|
823
|
+
Kind: 'ExternalIndex',
|
|
824
|
+
Target: row.ExternalIndexName ?? row.ID,
|
|
825
|
+
LaneID: row.ID,
|
|
826
|
+
Status: problems.has(row.ID) ? 'Skipped' : 'Active',
|
|
827
|
+
RenderedFilter: this.stringifyFilter(rendered?.MetadataFilter),
|
|
828
|
+
RequiredMetadataKeys: this.safeRequiredKeys(row.RequiredMetadataKeys),
|
|
829
|
+
Reason: problems.get(row.ID),
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
for (const row of bundle.Entities) {
|
|
833
|
+
const rendered = constraints.Entities?.find((c) => UUIDsEqual(c.SearchScopeEntityID, row.ID));
|
|
834
|
+
lanes.push({
|
|
835
|
+
Kind: 'Entity',
|
|
836
|
+
Target: this.lookupEntityName(row.EntityID) || row.EntityID,
|
|
837
|
+
LaneID: row.ID,
|
|
838
|
+
Status: problems.has(row.ID) ? 'Skipped' : 'Active',
|
|
839
|
+
RenderedFilter: rendered?.ExtraFilter ?? null,
|
|
840
|
+
RequiredMetadataKeys: this.safeRequiredKeys(row.RequiredMetadataKeys),
|
|
841
|
+
Reason: problems.get(row.ID),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
for (const row of bundle.StorageAccounts) {
|
|
845
|
+
const rendered = constraints.StorageAccounts?.find((c) => UUIDsEqual(c.SearchScopeStorageAccountID, row.ID));
|
|
846
|
+
lanes.push({
|
|
847
|
+
Kind: 'StorageAccount',
|
|
848
|
+
Target: row.FileStorageAccountID,
|
|
849
|
+
LaneID: row.ID,
|
|
850
|
+
// FolderPath is a path prefix, not an access bound, so it is deliberately
|
|
851
|
+
// unguarded here — consistent with buildScopeConstraints.
|
|
852
|
+
Status: 'Active',
|
|
853
|
+
RenderedFilter: rendered?.FolderPath ?? null,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
return lanes;
|
|
857
|
+
}
|
|
858
|
+
/** Render every lane for a DRY RUN, collecting problems instead of throwing on the first. */
|
|
859
|
+
explainLanes(bundle, effectiveContext) {
|
|
860
|
+
const problems = new Map();
|
|
861
|
+
try {
|
|
862
|
+
const constraints = this.buildScopeConstraints(bundle, effectiveContext, problems);
|
|
863
|
+
return this.buildLaneExplanations(bundle, constraints, problems);
|
|
864
|
+
}
|
|
865
|
+
catch (e) {
|
|
866
|
+
// buildScopeConstraints should not throw with a collector present, but a template
|
|
867
|
+
// renderer can still fail for reasons the guards do not model.
|
|
868
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
869
|
+
return this.buildAllLanesSkipped(bundle, `constraint building threw: ${msg}`);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
/** Every lane, reported as skipped for one shared reason. */
|
|
873
|
+
buildAllLanesSkipped(bundle, reason) {
|
|
874
|
+
return [
|
|
875
|
+
...bundle.ExternalIndexes.map((row) => ({
|
|
876
|
+
Kind: 'ExternalIndex',
|
|
877
|
+
Target: row.ExternalIndexName ?? row.ID,
|
|
878
|
+
LaneID: row.ID,
|
|
879
|
+
Status: 'Skipped',
|
|
880
|
+
RenderedFilter: null,
|
|
881
|
+
RequiredMetadataKeys: this.safeRequiredKeys(row.RequiredMetadataKeys),
|
|
882
|
+
Reason: reason,
|
|
883
|
+
})),
|
|
884
|
+
...bundle.Entities.map((row) => ({
|
|
885
|
+
Kind: 'Entity',
|
|
886
|
+
Target: this.lookupEntityName(row.EntityID) || row.EntityID,
|
|
887
|
+
LaneID: row.ID,
|
|
888
|
+
Status: 'Skipped',
|
|
889
|
+
RenderedFilter: null,
|
|
890
|
+
RequiredMetadataKeys: this.safeRequiredKeys(row.RequiredMetadataKeys),
|
|
891
|
+
Reason: reason,
|
|
892
|
+
})),
|
|
893
|
+
...bundle.StorageAccounts.map((row) => ({
|
|
894
|
+
Kind: 'StorageAccount',
|
|
895
|
+
Target: row.FileStorageAccountID,
|
|
896
|
+
LaneID: row.ID,
|
|
897
|
+
Status: 'Skipped',
|
|
898
|
+
RenderedFilter: null,
|
|
899
|
+
Reason: reason,
|
|
900
|
+
})),
|
|
901
|
+
];
|
|
902
|
+
}
|
|
903
|
+
/** Explanation for a scope that is inactive, missing, or otherwise not loadable. */
|
|
904
|
+
buildUnresolvableExplanation(scopeID, input, contextUser) {
|
|
905
|
+
return {
|
|
906
|
+
ScopeID: scopeID,
|
|
907
|
+
ScopeName: '(not found)',
|
|
908
|
+
Entitlement: {
|
|
909
|
+
Allowed: false,
|
|
910
|
+
Level: 'None',
|
|
911
|
+
Source: 'NoGrant',
|
|
912
|
+
Reason: 'the scope is inactive, expired, or does not exist, so no search can use it',
|
|
913
|
+
Principals: {
|
|
914
|
+
UserID: contextUser.ID ?? null,
|
|
915
|
+
AgentID: input.AIAgentID ?? null,
|
|
916
|
+
SkillID: input.AISkillID ?? null,
|
|
917
|
+
PrimaryScopeRecordID: input.SearchContext?.PrimaryScopeRecordID ?? null,
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
Dimensions: [],
|
|
921
|
+
Lanes: [],
|
|
922
|
+
Diagnostics: [`scope "${scopeID}" is not an active scope`],
|
|
923
|
+
Reachable: false,
|
|
924
|
+
// Not "known to be bounded" — the scope could not be loaded, so nothing about its
|
|
925
|
+
// configuration was observed. It is unreachable either way.
|
|
926
|
+
Unbounded: false,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
/** Parse a lane's required-key contract for display; a malformed one reports as empty. */
|
|
930
|
+
safeRequiredKeys(raw) {
|
|
931
|
+
try {
|
|
932
|
+
const keys = ParseRequiredMetadataKeys(raw);
|
|
933
|
+
return keys.length ? keys : undefined;
|
|
934
|
+
}
|
|
935
|
+
catch {
|
|
936
|
+
// The malformed declaration is already reported as the lane's skip reason.
|
|
937
|
+
return undefined;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/** Render a filter of unknown shape as a display string. */
|
|
941
|
+
stringifyFilter(filter) {
|
|
942
|
+
if (filter === null || filter === undefined)
|
|
943
|
+
return null;
|
|
944
|
+
return typeof filter === 'string' ? filter : JSON.stringify(filter);
|
|
945
|
+
}
|
|
946
|
+
// ────────────────────────────────────────────────────────────────
|
|
532
947
|
// Scope resolution
|
|
533
948
|
// ────────────────────────────────────────────────────────────────
|
|
534
949
|
/**
|
|
@@ -554,11 +969,58 @@ export class SearchEngine extends BaseSingleton {
|
|
|
554
969
|
/**
|
|
555
970
|
* Execute all scoped providers for a single scope bundle and return per-scope fused results.
|
|
556
971
|
*/
|
|
557
|
-
async executeScopeBundle(query, topK, filters, contextUser, isPreview, bundle, searchContext, agentFusionWeights,
|
|
972
|
+
async executeScopeBundle(query, topK, filters, contextUser, isPreview, bundle, searchContext, agentFusionWeights,
|
|
973
|
+
/**
|
|
974
|
+
* Principals available to bind into a dimension's expansion query.
|
|
975
|
+
*
|
|
976
|
+
* These MUST match what `ExplainScope` passes. When they did not, a scope whose
|
|
977
|
+
* `expansionQueryID` binds `AgentID` derived one bound in the dry run and a different
|
|
978
|
+
* one at search time — making the preview quietly wrong in the only direction anybody
|
|
979
|
+
* cares about.
|
|
980
|
+
*/
|
|
981
|
+
principals, onProviderResolved) {
|
|
558
982
|
const scope = bundle.Scope;
|
|
559
983
|
const scopeConfig = this.parseJson(scope.ScopeConfig);
|
|
560
|
-
|
|
984
|
+
// Resolve this scope's DECLARED dimensions before building any constraint. For a scope
|
|
985
|
+
// with no declaration this returns the caller's context untouched (legacy behaviour);
|
|
986
|
+
// for a declared scope it discards caller-supplied values for ServerDerived keys,
|
|
987
|
+
// enforces value grammars, applies narrowingOf as a meet, and gives strictValidation
|
|
988
|
+
// teeth. A ScopeDimensionError propagates so the search fails CLOSED.
|
|
989
|
+
const dimensionResult = await this.dimensionResolver.Resolve({
|
|
990
|
+
Scope: bundle.Scope,
|
|
991
|
+
CallerContext: searchContext,
|
|
992
|
+
ContextUser: contextUser,
|
|
993
|
+
Principals: principals,
|
|
994
|
+
});
|
|
995
|
+
for (const note of dimensionResult.Diagnostics) {
|
|
996
|
+
LogStatus(`SearchEngine: scope "${bundle.Scope.Name}" — ${note}`);
|
|
997
|
+
}
|
|
998
|
+
const effectiveContext = dimensionResult.Context;
|
|
999
|
+
const constraints = this.buildScopeConstraints(bundle, effectiveContext);
|
|
561
1000
|
const perProviderQueryTransforms = constraints.QueryTransforms ?? {};
|
|
1001
|
+
// Capture the decision for the audit log, REUSING the constraints just built rather
|
|
1002
|
+
// than re-rendering every template a second time. Reaching this line means every lane
|
|
1003
|
+
// guard passed (buildScopeConstraints throws otherwise), so the problem map is empty
|
|
1004
|
+
// and every lane is Active.
|
|
1005
|
+
const unbounded = bundle.ExternalIndexes.length === 0
|
|
1006
|
+
&& bundle.Entities.length === 0
|
|
1007
|
+
&& bundle.StorageAccounts.length === 0;
|
|
1008
|
+
const decision = {
|
|
1009
|
+
ScopeID: scope.ID,
|
|
1010
|
+
ScopeName: scope.Name,
|
|
1011
|
+
Entitlement: null,
|
|
1012
|
+
Dimensions: dimensionResult.Provenance,
|
|
1013
|
+
Lanes: this.buildLaneExplanations(bundle, constraints, new Map()),
|
|
1014
|
+
// Same warning the dry run emits. The two paths produce one shape, so they should
|
|
1015
|
+
// produce the same prose too — an auditor reading a log row should not have to know
|
|
1016
|
+
// it was written by the search path rather than by a preview.
|
|
1017
|
+
Diagnostics: unbounded
|
|
1018
|
+
? [...dimensionResult.Diagnostics, UNBOUNDED_SCOPE_DIAGNOSTIC]
|
|
1019
|
+
: dimensionResult.Diagnostics,
|
|
1020
|
+
Reachable: true,
|
|
1021
|
+
Unbounded: unbounded,
|
|
1022
|
+
ResolvedContext: effectiveContext,
|
|
1023
|
+
};
|
|
562
1024
|
// Determine which providers this scope participates in (SearchScopeProvider rows)
|
|
563
1025
|
const scopeProviderIDs = new Set(bundle.Providers.map(p => NormalizeUUID(p.SearchProviderID)));
|
|
564
1026
|
const allowAllProviders = scopeProviderIDs.size === 0; // empty = scope is IsGlobal or all-inclusive
|
|
@@ -576,7 +1038,9 @@ export class SearchEngine extends BaseSingleton {
|
|
|
576
1038
|
return {
|
|
577
1039
|
scopeID: scope.ID,
|
|
578
1040
|
fused: [],
|
|
579
|
-
sourceCounts: { Vector: 0, FullText: 0, Entity: 0, Storage: 0 }
|
|
1041
|
+
sourceCounts: { Vector: 0, FullText: 0, Entity: 0, Storage: 0 },
|
|
1042
|
+
decision: { ...decision, Reachable: false,
|
|
1043
|
+
Diagnostics: [...decision.Diagnostics, 'no applicable providers for this scope'] },
|
|
580
1044
|
};
|
|
581
1045
|
}
|
|
582
1046
|
// Resolve per-provider `SearchScopeProvider.MaxResultsOverride` if present
|
|
@@ -643,32 +1107,122 @@ export class SearchEngine extends BaseSingleton {
|
|
|
643
1107
|
: undefined;
|
|
644
1108
|
const fusionWeights = agentFusionWeights ?? scopeWeights;
|
|
645
1109
|
const fused = this._fusion.Fuse(labeled, topK, fusionWeights);
|
|
646
|
-
return { scopeID: scope.ID, fused, sourceCounts };
|
|
1110
|
+
return { scopeID: scope.ID, fused, sourceCounts, decision };
|
|
647
1111
|
}
|
|
648
1112
|
/**
|
|
649
1113
|
* Assemble a `ScopeConstraints` for a single scope: Nunjucks-render each template
|
|
650
1114
|
* field against the `SearchContext`, then hand the rendered values to providers.
|
|
651
1115
|
*/
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
})
|
|
1116
|
+
/**
|
|
1117
|
+
* Fail a search CLOSED when a scope field that RESTRICTS was authored but did not render
|
|
1118
|
+
* usably (see `CheckRenderedTemplate`).
|
|
1119
|
+
*
|
|
1120
|
+
* Throwing rather than dropping the offending row is deliberate. Dropping it would empty
|
|
1121
|
+
* the row collection, and `buildScopeConstraints` collapses an empty collection to
|
|
1122
|
+
* `undefined` — which every provider reads as "unscoped", i.e. all entities / all indexes
|
|
1123
|
+
* with no filter. So the surgical-looking fix is the one that widens; failing the search
|
|
1124
|
+
* is the one that doesn't. A broken restricting template is a misconfiguration and should
|
|
1125
|
+
* be loud and actionable, never silently degraded into a wider search.
|
|
1126
|
+
*/
|
|
1127
|
+
assertRestrictingTemplateRendered(source, rendered, fieldName, scopeLabel, rowLabel, laneID, collector) {
|
|
1128
|
+
const check = CheckRenderedTemplate(source, rendered);
|
|
1129
|
+
if (check.Status !== 'unusable')
|
|
1130
|
+
return;
|
|
1131
|
+
this.reportLaneProblem(`SearchEngine: scope ${scopeLabel} — ${fieldName} for "${rowLabel}" could not be rendered safely, so the search was NOT run. ${check.Reason}`, laneID, collector);
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Enforce a lane's `RequiredMetadataKeys` contract (Phase E).
|
|
1135
|
+
*
|
|
1136
|
+
* The rendered filter must mention every key the author declared. This is the only guard
|
|
1137
|
+
* that catches a filter which rendered *partially* — where an optional `{% if %}` clause
|
|
1138
|
+
* disappeared because its dimension was absent or discarded, leaving a non-empty filter
|
|
1139
|
+
* that passes every other check while restricting on strictly less than intended.
|
|
1140
|
+
*/
|
|
1141
|
+
assertRequiredMetadataKeys(declaration, laneID, rendered, scopeLabel, rowLabel, collector) {
|
|
1142
|
+
let requiredKeys;
|
|
1143
|
+
try {
|
|
1144
|
+
requiredKeys = ParseRequiredMetadataKeys(declaration);
|
|
1145
|
+
}
|
|
1146
|
+
catch (e) {
|
|
1147
|
+
// A contract that cannot be parsed must not degrade to "no contract" — that would
|
|
1148
|
+
// turn a typo in the declaration into an unguarded lane.
|
|
1149
|
+
this.reportLaneProblem(`SearchEngine: scope ${scopeLabel} — lane "${rowLabel}" has an unreadable RequiredMetadataKeys declaration, so the lane cannot be trusted. ${e instanceof Error ? e.message : String(e)}`, laneID, collector);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
if (requiredKeys.length === 0)
|
|
1153
|
+
return;
|
|
1154
|
+
const check = CheckRequiredMetadataKeys(rendered, requiredKeys);
|
|
1155
|
+
if (check.Status !== 'unusable')
|
|
1156
|
+
return;
|
|
1157
|
+
this.reportLaneProblem(`SearchEngine: scope ${scopeLabel} — lane "${rowLabel}" failed its RequiredMetadataKeys contract, so the search was NOT run. ${check.Reason}`, laneID, collector);
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Route a lane problem to the right place: throw when enforcing a real search, record when
|
|
1161
|
+
* explaining a hypothetical one.
|
|
1162
|
+
*
|
|
1163
|
+
* A dry run must be able to report *every* broken lane in one pass. If it threw on the first
|
|
1164
|
+
* one, an administrator would fix a scope one error at a time, re-running after each — and
|
|
1165
|
+
* the whole point of the preview is to see the entire picture before anything runs.
|
|
1166
|
+
*/
|
|
1167
|
+
reportLaneProblem(message, laneID, collector) {
|
|
1168
|
+
if (collector) {
|
|
1169
|
+
collector.set(laneID, message);
|
|
1170
|
+
LogStatus(message);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
LogError(message);
|
|
1174
|
+
throw new Error(message);
|
|
1175
|
+
}
|
|
1176
|
+
buildScopeConstraints(bundle, searchContext, collector) {
|
|
1177
|
+
const scopeLabel = `${bundle.Scope.Name} (${bundle.Scope.ID})`;
|
|
1178
|
+
const externalIndexes = bundle.ExternalIndexes.map(row => {
|
|
1179
|
+
const rowLabel = row.ExternalIndexName ?? row.ID;
|
|
1180
|
+
// §5.4: values are escaped for THIS lane's dialect automatically, derived from IndexType.
|
|
1181
|
+
const laneKind = LaneKindForIndexType(row.IndexType);
|
|
1182
|
+
const metadataFilter = RenderScopeJsonTemplate(row.MetadataFilter, searchContext, undefined, laneKind);
|
|
1183
|
+
this.assertRestrictingTemplateRendered(row.MetadataFilter, metadataFilter, 'MetadataFilter', scopeLabel, rowLabel, row.ID, collector);
|
|
1184
|
+
this.assertRequiredMetadataKeys(row.RequiredMetadataKeys, row.ID, metadataFilter, scopeLabel, rowLabel, collector);
|
|
1185
|
+
// ExternalIndexConfig is JSON (namespace/routing), regardless of the filter dialect.
|
|
1186
|
+
const externalIndexConfig = RenderScopeTemplate(row.ExternalIndexConfig, searchContext, undefined, 'json');
|
|
1187
|
+
// ExternalIndexConfig can carry tenant routing (e.g. Pinecone `namespace`), so a
|
|
1188
|
+
// silent render failure here can widen retrieval just like a filter can.
|
|
1189
|
+
this.assertRestrictingTemplateRendered(row.ExternalIndexConfig, externalIndexConfig, 'ExternalIndexConfig', scopeLabel, rowLabel, row.ID, collector);
|
|
1190
|
+
return {
|
|
1191
|
+
SearchScopeExternalIndexID: row.ID,
|
|
1192
|
+
IndexType: row.IndexType,
|
|
1193
|
+
VectorIndexID: row.VectorIndexID ?? undefined,
|
|
1194
|
+
ExternalIndexName: row.ExternalIndexName ?? undefined,
|
|
1195
|
+
ExternalIndexConfig: this.parseJson(externalIndexConfig),
|
|
1196
|
+
MetadataFilter: metadataFilter
|
|
1197
|
+
};
|
|
1198
|
+
});
|
|
1199
|
+
const entities = bundle.Entities.map(row => {
|
|
1200
|
+
// The entity lane is T-SQL, so single quotes must be doubled.
|
|
1201
|
+
const extraFilter = row.ExtraFilter ? RenderScopeTemplate(row.ExtraFilter, searchContext, undefined, 'sql') : undefined;
|
|
1202
|
+
const entityLabel = this.lookupEntityName(row.EntityID) || row.EntityID;
|
|
1203
|
+
this.assertRestrictingTemplateRendered(row.ExtraFilter, extraFilter, 'ExtraFilter', scopeLabel, entityLabel, row.ID, collector);
|
|
1204
|
+
// The SQL lane loses a guarded clause exactly the way an index lane does — same
|
|
1205
|
+
// renderer, same SearchContext, same optional {% if %} blocks — and it is the lane
|
|
1206
|
+
// that reads the operational database, so it gets the same contract.
|
|
1207
|
+
this.assertRequiredMetadataKeys(row.RequiredMetadataKeys, row.ID, extraFilter, scopeLabel, entityLabel, collector);
|
|
1208
|
+
return {
|
|
1209
|
+
SearchScopeEntityID: row.ID,
|
|
1210
|
+
EntityID: row.EntityID,
|
|
1211
|
+
EntityName: this.lookupEntityName(row.EntityID),
|
|
1212
|
+
ExtraFilter: extraFilter,
|
|
1213
|
+
// UserSearchString and FolderPath are NOT restrictions — they shape the query
|
|
1214
|
+
// text / a path prefix — so a soft render there cannot widen an access bound
|
|
1215
|
+
// and is deliberately left unguarded.
|
|
1216
|
+
// 'none' deliberately: this becomes QUERY TEXT, not syntax. Escaping it would corrupt
|
|
1217
|
+
// the search rather than protect it, and it cannot express a bound.
|
|
1218
|
+
UserSearchString: row.UserSearchString ? RenderScopeTemplate(row.UserSearchString, searchContext, undefined, 'none') : undefined
|
|
1219
|
+
};
|
|
1220
|
+
});
|
|
668
1221
|
const storage = bundle.StorageAccounts.map(row => ({
|
|
669
1222
|
SearchScopeStorageAccountID: row.ID,
|
|
670
1223
|
FileStorageAccountID: row.FileStorageAccountID,
|
|
671
|
-
|
|
1224
|
+
// Path traversal, not quoting, is the risk on a storage lane.
|
|
1225
|
+
FolderPath: row.FolderPath ? RenderScopeTemplate(row.FolderPath, searchContext, undefined, 'path') : undefined
|
|
672
1226
|
}));
|
|
673
1227
|
// Per-provider query transforms: resolved from SearchScopeProvider.QueryTransformTemplateID
|
|
674
1228
|
// For stored template IDs we need the TemplateEngine — that resolution happens in
|
|
@@ -1062,6 +1616,8 @@ export class SearchEngine extends BaseSingleton {
|
|
|
1062
1616
|
SourceCounts: undefined,
|
|
1063
1617
|
ContextUser: input.ContextUser,
|
|
1064
1618
|
AIAgentID: input.AIAgentID ?? null,
|
|
1619
|
+
AISkillID: input.AISkillID ?? null,
|
|
1620
|
+
PrimaryScopeRecordID: input.PrimaryScopeRecordID ?? null,
|
|
1065
1621
|
});
|
|
1066
1622
|
}
|
|
1067
1623
|
/**
|
|
@@ -1080,6 +1636,15 @@ export class SearchEngine extends BaseSingleton {
|
|
|
1080
1636
|
log.SearchScopeID = input.ScopeIDs && input.ScopeIDs.length > 0 ? input.ScopeIDs[0] : null;
|
|
1081
1637
|
log.UserID = input.ContextUser.ID ?? null;
|
|
1082
1638
|
log.AIAgentID = input.AIAgentID ?? null;
|
|
1639
|
+
log.AISkillID = input.AISkillID ?? null;
|
|
1640
|
+
log.PrimaryScopeRecordID = input.PrimaryScopeRecordID ?? null;
|
|
1641
|
+
// ScopeDecisionJSON answers "why could this search reach what it reached" — the
|
|
1642
|
+
// dimension provenance and per-lane outcomes, which are otherwise gone the moment
|
|
1643
|
+
// the search returns. Same shape ExplainScope() produces, so a preview taken at
|
|
1644
|
+
// configuration time is directly comparable with what actually ran.
|
|
1645
|
+
log.ScopeDecisionJSON = input.ScopeDecisions?.length
|
|
1646
|
+
? JSON.stringify(input.ScopeDecisions)
|
|
1647
|
+
: null;
|
|
1083
1648
|
log.Query = input.Query;
|
|
1084
1649
|
log.TotalDurationMs = Date.now() - input.StartTime;
|
|
1085
1650
|
log.ResultCount = input.ResultCount;
|