@memberjunction/server 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 (38) hide show
  1. package/README.md +1 -1
  2. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  3. package/dist/agentSessions/SessionManager.js +6 -1
  4. package/dist/agentSessions/SessionManager.js.map +1 -1
  5. package/dist/auth/newUsers.d.ts.map +1 -1
  6. package/dist/auth/newUsers.js +6 -4
  7. package/dist/auth/newUsers.js.map +1 -1
  8. package/dist/generated/generated.d.ts +259 -14
  9. package/dist/generated/generated.d.ts.map +1 -1
  10. package/dist/generated/generated.js +1380 -73
  11. package/dist/generated/generated.js.map +1 -1
  12. package/dist/realtimeWidget/WidgetSessionService.d.ts +12 -1
  13. package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
  14. package/dist/realtimeWidget/WidgetSessionService.js +23 -8
  15. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
  16. package/dist/realtimeWidget/widgetGuestElevation.d.ts +22 -0
  17. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  18. package/dist/realtimeWidget/widgetGuestElevation.js +36 -0
  19. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  20. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  21. package/dist/resolvers/IntegrationDiscoveryResolver.js +9 -4
  22. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  23. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +21 -0
  24. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  25. package/dist/resolvers/RealtimeClientSessionResolver.js +75 -16
  26. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  27. package/package.json +91 -89
  28. package/src/__tests__/RealtimeClientSessionResolver.test.ts +420 -0
  29. package/src/__tests__/SessionManager.test.ts +62 -0
  30. package/src/__tests__/WidgetSessionService.test.ts +96 -0
  31. package/src/__tests__/widgetGuestElevation.test.ts +70 -2
  32. package/src/agentSessions/SessionManager.ts +6 -1
  33. package/src/auth/newUsers.ts +6 -5
  34. package/src/generated/generated.ts +967 -55
  35. package/src/realtimeWidget/WidgetSessionService.ts +24 -8
  36. package/src/realtimeWidget/widgetGuestElevation.ts +41 -0
  37. package/src/resolvers/IntegrationDiscoveryResolver.ts +9 -4
  38. package/src/resolvers/RealtimeClientSessionResolver.ts +84 -16
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { Metadata, RunView, UserInfo, LogError, LogStatus } from '@memberjunction/core';
18
- import { UUIDsEqual } from '@memberjunction/global';
18
+ import { MJLruCache, UUIDsEqual } from '@memberjunction/global';
19
19
  import type { MJConversationWidgetInstanceEntity, MJConversationEntity } from '@memberjunction/core-entities';
20
20
  import { UserCache } from '@memberjunction/sqlserver-dataprovider';
21
21
  import { MagicLinkKeyManager } from '../auth/magicLink/MagicLinkKeys.js';
@@ -283,9 +283,26 @@ export class WidgetSessionService {
283
283
  return this.MintGuestSession(input);
284
284
  }
285
285
 
286
- /** Short-TTL cache of resolved per-instance rate limits, so the limiter doesn't hit the DB per request. */
287
- private readonly rateLimitCache = new Map<string, { limit: number; expiresAtMs: number }>();
286
+ /**
287
+ * Short-TTL, size-bounded cache of resolved per-instance rate limits, so the limiter doesn't hit
288
+ * the DB per request. The cache key (`widgetKey`) is presented by an UNAUTHENTICATED caller on the
289
+ * public `/widget/session` endpoint, so an unbounded `Map` here lets anyone grow server memory
290
+ * forever by sending an endless stream of distinct garbage keys (Memory Leak Audit Round 8,
291
+ * Critical finding). `MJLruCache` evicts the least-recently-used entry once `RATE_LIMIT_CACHE_MAX_SIZE`
292
+ * is reached, in addition to the existing TTL — `RATE_LIMIT_CACHE_MAX_SIZE` is sized well above any
293
+ * realistic deployment's widget-instance count so legitimate lookups are never evicted prematurely.
294
+ */
295
+ private readonly rateLimitCache = new MJLruCache<string, number>({
296
+ maxSize: WidgetSessionService.RATE_LIMIT_CACHE_MAX_SIZE,
297
+ ttlMs: WidgetSessionService.RATE_LIMIT_CACHE_TTL_MS,
298
+ });
288
299
  private static readonly RATE_LIMIT_CACHE_TTL_MS = 60_000;
300
+ private static readonly RATE_LIMIT_CACHE_MAX_SIZE = 10_000;
301
+
302
+ /** Current entry count in the per-instance rate-limit cache (diagnostic / test hook). */
303
+ public get RateLimitCacheSize(): number {
304
+ return this.rateLimitCache.Size;
305
+ }
289
306
 
290
307
  /**
291
308
  * Resolves the per-instance `RateLimitPerMinute` for a widget key (W6 hardening), falling back to the
@@ -299,10 +316,9 @@ export class WidgetSessionService {
299
316
  if (!key) {
300
317
  return fallback;
301
318
  }
302
- const cached = this.rateLimitCache.get(key);
303
- const now = Date.now();
304
- if (cached && cached.expiresAtMs > now) {
305
- return cached.limit;
319
+ const cached = this.rateLimitCache.Get(key);
320
+ if (cached !== undefined) {
321
+ return cached;
306
322
  }
307
323
  let limit = fallback;
308
324
  try {
@@ -316,7 +332,7 @@ export class WidgetSessionService {
316
332
  } catch (e) {
317
333
  LogError(e);
318
334
  }
319
- this.rateLimitCache.set(key, { limit, expiresAtMs: now + WidgetSessionService.RATE_LIMIT_CACHE_TTL_MS });
335
+ this.rateLimitCache.Set(key, limit);
320
336
  return limit;
321
337
  }
322
338
 
@@ -77,6 +77,47 @@ export async function resolveWidgetGuestRunContext(
77
77
  };
78
78
  }
79
79
 
80
+ /**
81
+ * Returns the identity a realtime session's AI-RUN-ENTITY work (delegated tool dispatch +
82
+ * observability run creation/append/finalize) should execute as: the trusted SYSTEM principal when
83
+ * the caller is a SCOPED anonymous magic-link session, else the caller unchanged (issue #3371).
84
+ *
85
+ * A scoped anonymous session (`IsMagicLinkAnonymous` + `MagicLinkScope.ResourceID`) holds only the
86
+ * narrow relay grants its invite's role carries — deliberately NOT the AI run entities, whose rows
87
+ * leak the rendered system prompt. Unlike {@link resolveWidgetGuestRunContext} no widget instance is
88
+ * required: on the realtime path the agent authority is already server-side (the session config's
89
+ * `targetAgentID`, `CanRun`-gated at session start), so there is no client-supplied agent id to pin.
90
+ *
91
+ * PUBLIC WEB-WIDGET guests are deliberately EXCLUDED (returned unchanged): their seeded role writes
92
+ * run rows under the guest principal, which the `Widget Guest: Own Agent Runs` RLS read filter
93
+ * depends on — elevating them here would silently break that read-side control.
94
+ *
95
+ * Fails CLOSED: when no system user is available the caller is returned unchanged, so the request
96
+ * fails exactly as it would today rather than proceeding unelevated-but-assumed-elevated.
97
+ *
98
+ * Ownership/RLS gates must NEVER use this — they stay on the caller; this only changes who the
99
+ * work RUNS AS after ownership is proven.
100
+ */
101
+ export function ResolveScopedAnonymousRunUser(contextUser: UserInfo): UserInfo {
102
+ const scopeId = contextUser?.MagicLinkScope?.ResourceID;
103
+ if (!contextUser?.IsMagicLinkAnonymous || !scopeId || contextUser.WidgetGuestContext?.WidgetID) {
104
+ return contextUser;
105
+ }
106
+
107
+ const systemUser = UserCache.Instance.GetSystemUser();
108
+ if (!systemUser) {
109
+ LogError(
110
+ '[Realtime] Cannot elevate scoped-anonymous run work: no system user available; ' +
111
+ `falling back to the anonymous caller for scope ${scopeId}.`,
112
+ );
113
+ return contextUser;
114
+ }
115
+
116
+ // Deliberately silent on success — callers include per-utterance/per-usage-delta relays, so a
117
+ // per-call log line would flood a live session's log. The dispatch path logs the elevation once.
118
+ return systemUser;
119
+ }
120
+
80
121
  /**
81
122
  * Builds an elevated {@link UserPayload} that runs subsequent agent work as `elevatedUser` while
82
123
  * preserving the guest's `sessionId` — so progress/streaming PubSub still routes to the guest's
@@ -3433,9 +3433,11 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3433
3433
  { Path: pendingFilePath, Content: JSON.stringify(pendingPayload, null, 2) }
3434
3434
  ];
3435
3435
 
3436
- // Step 5: Run pipeline (restart kills process at the end)
3436
+ // Step 5: Run pipeline (restart kills process at the end).
3437
+ // Retrying variant — an install should not die because the migration hit a dropped
3438
+ // connection or a transient lock; it only replays while nothing has been applied.
3437
3439
  const rsm = RuntimeSchemaManager.Instance;
3438
- const batchResult = await rsm.RunPipelineBatch([rsuInput]);
3440
+ const batchResult = await rsm.RunPipelineBatchWithRetry([rsuInput]);
3439
3441
 
3440
3442
  const migrationSucceeded = batchResult.SuccessCount > 0;
3441
3443
  const pipelineSteps = batchResult.Results[0]?.Steps.map((s: RSUPipelineStep) => ({
@@ -5306,10 +5308,13 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
5306
5308
  };
5307
5309
  }
5308
5310
 
5309
- // Phase 2: Run all successful RSU inputs through one pipeline batch
5311
+ // Phase 2: Run all successful RSU inputs through one pipeline batch.
5312
+ // Retrying variant — a multi-connector install is the longest-running thing a user
5313
+ // does here, and it should not be lost to one transient step. Replays only while
5314
+ // nothing has been applied, so a partially-committed batch is never re-executed.
5310
5315
  const pipelineInputs = successfulBuilds.map(b => b.rsuInput);
5311
5316
  const rsm = RuntimeSchemaManager.Instance;
5312
- const batchResult = await rsm.RunPipelineBatch(pipelineInputs);
5317
+ const batchResult = await rsm.RunPipelineBatchWithRetry(pipelineInputs);
5313
5318
 
5314
5319
  // Phase 3: Post-pipeline — create entity maps, field maps, schedules for each success
5315
5320
  for (let i = 0; i < successfulBuilds.length; i++) {
@@ -60,7 +60,7 @@ import { ResolverBase } from '../generic/ResolverBase.js';
60
60
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
61
61
  import { GetReadWriteProvider } from '../util.js';
62
62
  import { SessionManager } from '../agentSessions/index.js';
63
- import { resolveWidgetGuestRunContext } from '../realtimeWidget/widgetGuestElevation.js';
63
+ import { resolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
64
64
 
65
65
  /**
66
66
  * Progress steps worth narrating to the realtime model — mirrors the normal agent-run path's filter
@@ -495,13 +495,28 @@ export class RealtimeClientSessionResolver extends ResolverBase {
495
495
  const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
496
496
  const config = this.readSessionConfig(session);
497
497
 
498
+ // SCOPED-ANONYMOUS ELEVATION (issue #3371): once ownership is proven above, the delegated
499
+ // run + its AI-run-entity writes execute as the system user for a scoped anonymous caller
500
+ // (the caller's role deliberately holds no grants on the run entities). The lead
501
+ // targetAgentID comes from the session config and was CanRun-gated at start; the colleague
502
+ // union is gated just below, against the CALLER, so elevation never widens agent authority.
503
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
504
+ if (runUser !== contextUser) {
505
+ LogStatus(
506
+ `ExecuteRealtimeSessionTool: dispatching relayed tool '${toolName}' for session ${agentSessionId} ` +
507
+ 'under the system user (scoped-anonymous caller).',
508
+ );
509
+ }
498
510
  const { ResultJson, PausedRunID, Artifacts } = await this.clientSessionService.ExecuteRelayedTool(
499
511
  {
500
512
  AgentSessionID: agentSessionId,
501
513
  TargetAgentID: config.targetAgentID,
502
514
  // Multi-target (Move 4): the session's persisted allowed-agent union — a model-named
503
515
  // colleague in the call is validated against this; absent ⇒ single-target behavior.
504
- AllowedAgents: config.allowedAgents,
516
+ AllowedAgents: await this.filterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
517
+ // Attribution follows the VISITOR even when `runUser` is elevated: the delegated run
518
+ // row and its context-memory scope must stay the person's, not the system user's.
519
+ AttributionUserID: contextUser.ID,
505
520
  // Nest the delegated target-agent run under the co-agent observability run (when present).
506
521
  ParentRunID: config.coAgentRunID,
507
522
  Call: { CallID: callId, ToolName: toolName, Arguments: argsJson },
@@ -509,7 +524,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
509
524
  // Resume a previously-paused delegated run (if any) with the user's answer.
510
525
  ResumeRunID: config.pendingFeedbackRunID,
511
526
  },
512
- contextUser,
527
+ runUser,
513
528
  provider,
514
529
  );
515
530
 
@@ -519,7 +534,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
519
534
 
520
535
  // Junction-link any artifacts the delegated run produced into the session's conversation
521
536
  // history (best-effort) — so chat, session review, and resume carryover can all see them.
522
- await this.linkDelegatedArtifactsToConversation(session, Artifacts, contextUser, provider);
537
+ // Runs as `runUser`: the junction entity is not among an anonymous caller's relay grants.
538
+ await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, provider);
523
539
 
524
540
  await this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
525
541
  return ResultJson;
@@ -625,6 +641,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
625
641
  }
626
642
  // Mirror the turn onto the co-agent's long-lived prompt run so its Messages capture the full
627
643
  // conversation (run-viewer observability parity). Best-effort — never fails the transcript relay.
644
+ // The prompt-run write runs as the scoped-anonymous elevated user (issue #3371) — the visible
645
+ // Conversation Detail above deliberately stays on the caller.
628
646
  const promptRunID = this.readPromptRunID(session);
629
647
  if (promptRunID) {
630
648
  await this.clientSessionService.AppendPromptRunMessage(
@@ -632,7 +650,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
632
650
  this.mapTranscriptRoleToChatRole(role),
633
651
  text,
634
652
  replacesPrevious ?? false,
635
- contextUser,
653
+ ResolveScopedAnonymousRunUser(contextUser),
636
654
  provider,
637
655
  );
638
656
  }
@@ -691,13 +709,18 @@ export class RealtimeClientSessionResolver extends ResolverBase {
691
709
  return { Success: false, ErrorMessage: 'Recording consent was not granted.' };
692
710
  }
693
711
 
712
+ // SCOPED-ANONYMOUS ELEVATION (issue #3371): past the ownership + consent gates, the store is
713
+ // server-side plumbing over entities (MJ: AI Agents read, MJ: Files, the file-session link)
714
+ // the caller's narrow relay role deliberately does not hold. Attribution flows through the
715
+ // session link, so nothing here depends on the caller's identity.
716
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
694
717
  try {
695
- const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', contextUser);
718
+ const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', runUser);
696
719
  if (!(await agent.Load(session.AgentID))) {
697
720
  return { Success: false, ErrorMessage: `Co-agent ${session.AgentID} for the session could not be loaded.` };
698
721
  }
699
722
 
700
- const accountID = await resolveRecordingStorageAccountID(agent, contextUser, provider);
723
+ const accountID = await resolveRecordingStorageAccountID(agent, runUser, provider);
701
724
  if (!accountID) {
702
725
  return { Success: false, ErrorMessage: 'No recording storage account is configured for this agent.' };
703
726
  }
@@ -714,7 +737,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
714
737
  StartedAt: session.RecordingStartedAt ?? new Date(),
715
738
  StorageAccountID: accountID,
716
739
  SessionID: agentSessionId,
717
- ContextUser: contextUser,
740
+ ContextUser: runUser,
718
741
  Provider: provider,
719
742
  // Sanitized capture-time waveform peaks → persisted as a peaks.json sidecar.
720
743
  Peaks: this.sanitizePeaks(peaks),
@@ -722,7 +745,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
722
745
 
723
746
  // Canonical consolidated file written — drop the crash-recovery shards (best-effort).
724
747
  if (fileID) {
725
- await deleteRealtimeRecordingSegments(agentSessionId, accountID, contextUser);
748
+ await deleteRealtimeRecordingSegments(agentSessionId, accountID, runUser);
726
749
  }
727
750
 
728
751
  return {
@@ -762,11 +785,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
762
785
  try {
763
786
  const { contextUser, provider } = this.requireUserAndProvider(ctx.userPayload, ctx.providers);
764
787
  const session = await this.loadOwnedSession(agentSessionId, contextUser, provider);
765
- const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', contextUser);
788
+ // Scoped-anonymous elevation (issue #3371) same rationale as UploadRealtimeRecording.
789
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
790
+ const agent = await provider.GetEntityObject<MJAIAgentEntity>('MJ: AI Agents', runUser);
766
791
  if (!(await agent.Load(session.AgentID))) {
767
792
  return false;
768
793
  }
769
- const accountID = await resolveRecordingStorageAccountID(agent, contextUser, provider);
794
+ const accountID = await resolveRecordingStorageAccountID(agent, runUser, provider);
770
795
  if (!accountID) {
771
796
  return false;
772
797
  }
@@ -780,7 +805,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
780
805
  Audio: buffer,
781
806
  MimeType: mimeType,
782
807
  StorageAccountID: accountID,
783
- ContextUser: contextUser,
808
+ ContextUser: runUser,
784
809
  });
785
810
  } catch (error) {
786
811
  LogError(`RealtimeClientSessionResolver.UploadRealtimeRecordingSegment failed for session ${agentSessionId}: ${error instanceof Error ? error.message : String(error)}`);
@@ -815,7 +840,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
815
840
  'assistant',
816
841
  this.formatToolTurn(toolName, argsJson, resultJson),
817
842
  false,
818
- contextUser,
843
+ ResolveScopedAnonymousRunUser(contextUser),
819
844
  provider,
820
845
  );
821
846
  }
@@ -913,7 +938,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
913
938
  }
914
939
  // Delegate to the service so usage writes share the per-run serialization with transcript-message
915
940
  // appends — otherwise the frequent usage save clobbers freshly-appended Messages (and vice-versa).
916
- return this.clientSessionService.AccumulatePromptRunUsage(promptRunID, inputDelta, outputDelta, contextUser, provider);
941
+ // Runs as the scoped-anonymous elevated user (issue #3371) the caller's role holds no prompt-run grants.
942
+ return this.clientSessionService.AccumulatePromptRunUsage(
943
+ promptRunID, inputDelta, outputDelta, ResolveScopedAnonymousRunUser(contextUser), provider,
944
+ );
917
945
  }
918
946
 
919
947
  /** Clamps a relayed token delta: negative / non-finite values become 0. */
@@ -1123,6 +1151,39 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1123
1151
  }
1124
1152
  }
1125
1153
 
1154
+ /**
1155
+ * Narrows a session's colleague union (`allowedAgents`) to the agents the CALLER may run.
1156
+ *
1157
+ * {@link assertCanRunTarget} gates the LEAD target at session start, but the union it travels
1158
+ * with was never gated at all — a model-named colleague resolves straight to a delegated run.
1159
+ * That was survivable while the run carried the caller's own identity, because base-agent
1160
+ * re-checks `CanRun` against `contextUser`. Once the run user is elevated for a scoped anonymous
1161
+ * caller (issue #3371) that check sees the SYSTEM user, so this is the only remaining place the
1162
+ * caller's own authority is applied to a colleague. It therefore runs for EVERY caller, elevated
1163
+ * or not — the authorization identity must never depend on the elevation decision.
1164
+ *
1165
+ * `HasPermission` reads AIEngineBase's in-memory caches (no DB round trip) and already fails
1166
+ * closed on error, so an unresolvable agent drops OUT of the union rather than becoming runnable.
1167
+ * A filtered-out colleague is not an error: the delegation layer reports it as "not available in
1168
+ * this session" and lists what remains, which is the same answer the model gets for a typo.
1169
+ *
1170
+ * @param allowedAgents The session's persisted colleague union (absent/empty ⇒ single-target).
1171
+ * @param contextUser The ORIGINAL caller — never the elevated run user.
1172
+ * @returns The subset the caller may run, preserving order.
1173
+ */
1174
+ private async filterAllowedAgentsByCanRun(
1175
+ allowedAgents: RealtimeAllowedAgent[] | undefined,
1176
+ contextUser: UserInfo,
1177
+ ): Promise<RealtimeAllowedAgent[] | undefined> {
1178
+ if (!allowedAgents || allowedAgents.length === 0) {
1179
+ return allowedAgents;
1180
+ }
1181
+ const verdicts = await Promise.all(
1182
+ allowedAgents.map((a) => AIAgentPermissionHelper.HasPermission(a.agentId, contextUser, 'run')),
1183
+ );
1184
+ return allowedAgents.filter((_, i) => verdicts[i]);
1185
+ }
1186
+
1126
1187
  /**
1127
1188
  * Resolves the AUTHORITATIVE target agent id under the co-agent's PAIRING CONSTRAINTS
1128
1189
  * (`MJ: AI Agent Co Agents`, ordered by `Sequence`):
@@ -1584,7 +1645,12 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1584
1645
  ApplicationID: applicationId,
1585
1646
  AppContext: appContext,
1586
1647
  },
1587
- contextUser,
1648
+ // SCOPED-ANONYMOUS ELEVATION (issue #3371): the prepare creates the co-agent
1649
+ // observability AIAgentRun/AIPromptRun/run-step, which a scoped anonymous caller's role
1650
+ // deliberately cannot write. `UserID` above stays the CALLER's id, so run attribution
1651
+ // and memory scope remain the visitor's. Authorization (CanRun, runtime overrides)
1652
+ // already ran on the caller in StartRealtimeClientSession.
1653
+ ResolveScopedAnonymousRunUser(contextUser),
1588
1654
  provider,
1589
1655
  );
1590
1656
 
@@ -2190,7 +2256,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2190
2256
  detail.HiddenToUser = true;
2191
2257
  detail.Message = 'Artifacts produced during a realtime session (system anchor).';
2192
2258
  detail.AgentSessionID = session.ID;
2193
- detail.UserID = contextUser.ID;
2259
+ // Attribute the anchor to the SESSION owner, not the (possibly elevated) writer — identical
2260
+ // for every non-elevated caller, whose ownership of the session is already proven.
2261
+ detail.UserID = session.UserID;
2194
2262
  if (await detail.Save()) {
2195
2263
  return detail.ID;
2196
2264
  }