@memberjunction/search-engine 5.49.0 → 5.51.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.
Files changed (63) hide show
  1. package/dist/generic/EntitySearchProvider.d.ts +17 -3
  2. package/dist/generic/EntitySearchProvider.d.ts.map +1 -1
  3. package/dist/generic/EntitySearchProvider.js +23 -6
  4. package/dist/generic/EntitySearchProvider.js.map +1 -1
  5. package/dist/generic/ExternalHitMapper.d.ts +48 -0
  6. package/dist/generic/ExternalHitMapper.d.ts.map +1 -0
  7. package/dist/generic/ExternalHitMapper.js +88 -0
  8. package/dist/generic/ExternalHitMapper.js.map +1 -0
  9. package/dist/generic/FullTextSearchProvider.d.ts +9 -1
  10. package/dist/generic/FullTextSearchProvider.d.ts.map +1 -1
  11. package/dist/generic/FullTextSearchProvider.js +11 -3
  12. package/dist/generic/FullTextSearchProvider.js.map +1 -1
  13. package/dist/generic/ScopeDimensionResolver.d.ts +146 -0
  14. package/dist/generic/ScopeDimensionResolver.d.ts.map +1 -0
  15. package/dist/generic/ScopeDimensionResolver.js +464 -0
  16. package/dist/generic/ScopeDimensionResolver.js.map +1 -0
  17. package/dist/generic/ScopeExplanation.d.ts +141 -0
  18. package/dist/generic/ScopeExplanation.d.ts.map +1 -0
  19. package/dist/generic/ScopeExplanation.js +72 -0
  20. package/dist/generic/ScopeExplanation.js.map +1 -0
  21. package/dist/generic/ScopeFilterGuard.d.ts +127 -0
  22. package/dist/generic/ScopeFilterGuard.d.ts.map +1 -0
  23. package/dist/generic/ScopeFilterGuard.js +290 -0
  24. package/dist/generic/ScopeFilterGuard.js.map +1 -0
  25. package/dist/generic/ScopeTemplateRenderer.d.ts +12 -2
  26. package/dist/generic/ScopeTemplateRenderer.d.ts.map +1 -1
  27. package/dist/generic/ScopeTemplateRenderer.js +25 -5
  28. package/dist/generic/ScopeTemplateRenderer.js.map +1 -1
  29. package/dist/generic/ScopeValueEscaper.d.ts +112 -0
  30. package/dist/generic/ScopeValueEscaper.d.ts.map +1 -0
  31. package/dist/generic/ScopeValueEscaper.js +152 -0
  32. package/dist/generic/ScopeValueEscaper.js.map +1 -0
  33. package/dist/generic/SearchEngine.d.ts +188 -8
  34. package/dist/generic/SearchEngine.d.ts.map +1 -1
  35. package/dist/generic/SearchEngine.js +600 -35
  36. package/dist/generic/SearchEngine.js.map +1 -1
  37. package/dist/generic/VectorSearchProvider.d.ts +2 -1
  38. package/dist/generic/VectorSearchProvider.d.ts.map +1 -1
  39. package/dist/generic/VectorSearchProvider.js +23 -26
  40. package/dist/generic/VectorSearchProvider.js.map +1 -1
  41. package/dist/generic/search.types.d.ts +160 -0
  42. package/dist/generic/search.types.d.ts.map +1 -1
  43. package/dist/index.d.ts +5 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +5 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/permissions/SearchScopePermissionResolver.d.ts +37 -2
  48. package/dist/permissions/SearchScopePermissionResolver.d.ts.map +1 -1
  49. package/dist/permissions/SearchScopePermissionResolver.js +74 -2
  50. package/dist/permissions/SearchScopePermissionResolver.js.map +1 -1
  51. package/dist/providers/AzureAISearchProvider.d.ts.map +1 -1
  52. package/dist/providers/AzureAISearchProvider.js +24 -6
  53. package/dist/providers/AzureAISearchProvider.js.map +1 -1
  54. package/dist/providers/ElasticsearchSearchProvider.d.ts.map +1 -1
  55. package/dist/providers/ElasticsearchSearchProvider.js +17 -5
  56. package/dist/providers/ElasticsearchSearchProvider.js.map +1 -1
  57. package/dist/providers/OpenSearchSearchProvider.d.ts.map +1 -1
  58. package/dist/providers/OpenSearchSearchProvider.js +16 -5
  59. package/dist/providers/OpenSearchSearchProvider.js.map +1 -1
  60. package/dist/providers/TypesenseSearchProvider.d.ts.map +1 -1
  61. package/dist/providers/TypesenseSearchProvider.js +17 -6
  62. package/dist/providers/TypesenseSearchProvider.js.map +1 -1
  63. 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. One- and two-character queries against
81
- * a `LIKE '%term%'` fan-out are essentially full-database scans with negligible
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 = 3; }
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
- * Build a stable cache key for a search. Includes the user identity so RLS
487
- * scopes never bleed across users, plus the trimmed query, MaxResults,
488
- * MinScore, and a deterministic projection of Filters.
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 filterKey = JSON.stringify({
494
- EntityNames: f.EntityNames ? [...f.EntityNames].sort() : undefined,
495
- SourceTypes: f.SourceTypes ? [...f.SourceTypes].sort() : undefined,
496
- Tags: f.Tags ? [...f.Tags].sort() : undefined,
497
- });
498
- return `${userKey}|${trimmed}|${params.MaxResults ?? this._defaultMaxResults}|${params.MinScore ?? 0}|${filterKey}`;
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, onProviderResolved) {
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
- const constraints = this.buildScopeConstraints(bundle, searchContext);
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
- buildScopeConstraints(bundle, searchContext) {
653
- const externalIndexes = bundle.ExternalIndexes.map(row => ({
654
- SearchScopeExternalIndexID: row.ID,
655
- IndexType: row.IndexType,
656
- VectorIndexID: row.VectorIndexID ?? undefined,
657
- ExternalIndexName: row.ExternalIndexName ?? undefined,
658
- ExternalIndexConfig: this.parseJson(RenderScopeTemplate(row.ExternalIndexConfig, searchContext)),
659
- MetadataFilter: RenderScopeJsonTemplate(row.MetadataFilter, searchContext)
660
- }));
661
- const entities = bundle.Entities.map(row => ({
662
- SearchScopeEntityID: row.ID,
663
- EntityID: row.EntityID,
664
- EntityName: this.lookupEntityName(row.EntityID),
665
- ExtraFilter: row.ExtraFilter ? RenderScopeTemplate(row.ExtraFilter, searchContext) : undefined,
666
- UserSearchString: row.UserSearchString ? RenderScopeTemplate(row.UserSearchString, searchContext) : undefined
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
- FolderPath: row.FolderPath ? RenderScopeTemplate(row.FolderPath, searchContext) : undefined
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;