@memberjunction/server 6.1.4 → 6.1.6

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 (227) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/SessionManager.d.ts +5 -2
  4. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  5. package/dist/agentSessions/SessionManager.js +17 -8
  6. package/dist/agentSessions/SessionManager.js.map +1 -1
  7. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  8. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  9. package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
  10. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  11. package/dist/agentSessions/index.d.ts +1 -0
  12. package/dist/agentSessions/index.d.ts.map +1 -1
  13. package/dist/agentSessions/index.js +1 -0
  14. package/dist/agentSessions/index.js.map +1 -1
  15. package/dist/auth/actionAuthorization.d.ts +40 -0
  16. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  17. package/dist/auth/actionAuthorization.js +94 -0
  18. package/dist/auth/actionAuthorization.js.map +1 -0
  19. package/dist/auth/exampleNewUserSubClass.js +2 -1
  20. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  21. package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
  22. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  23. package/dist/auth/magicLink/MagicLinkService.js +55 -38
  24. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  25. package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
  26. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
  27. package/dist/auth/magicLink/magicLinkCore.js +20 -52
  28. package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
  29. package/dist/auth/newUsers.d.ts.map +1 -1
  30. package/dist/auth/newUsers.js +15 -11
  31. package/dist/auth/newUsers.js.map +1 -1
  32. package/dist/context.d.ts +6 -0
  33. package/dist/context.d.ts.map +1 -1
  34. package/dist/context.js +24 -34
  35. package/dist/context.js.map +1 -1
  36. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  37. package/dist/generated/graphql-schemas/__mj.js +3417 -3428
  38. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  39. package/dist/generic/CacheInvalidationResolver.d.ts +44 -8
  40. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  41. package/dist/generic/CacheInvalidationResolver.js +59 -19
  42. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  43. package/dist/generic/ResolverBase.d.ts +95 -11
  44. package/dist/generic/ResolverBase.d.ts.map +1 -1
  45. package/dist/generic/ResolverBase.js +186 -76
  46. package/dist/generic/ResolverBase.js.map +1 -1
  47. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  48. package/dist/generic/RunViewResolver.js +12 -0
  49. package/dist/generic/RunViewResolver.js.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +44 -45
  53. package/dist/index.js.map +1 -1
  54. package/dist/isolatedProvider.d.ts +20 -0
  55. package/dist/isolatedProvider.d.ts.map +1 -0
  56. package/dist/isolatedProvider.js +22 -0
  57. package/dist/isolatedProvider.js.map +1 -0
  58. package/dist/multiTenancy/index.d.ts.map +1 -1
  59. package/dist/multiTenancy/index.js +22 -2
  60. package/dist/multiTenancy/index.js.map +1 -1
  61. package/dist/postgresqlCompat.d.ts +8 -0
  62. package/dist/postgresqlCompat.d.ts.map +1 -0
  63. package/dist/postgresqlCompat.js +19 -0
  64. package/dist/postgresqlCompat.js.map +1 -0
  65. package/dist/postgresqlPoolSettings.d.ts +108 -0
  66. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  67. package/dist/postgresqlPoolSettings.js +134 -0
  68. package/dist/postgresqlPoolSettings.js.map +1 -0
  69. package/dist/realtimeWidget/widgetGuestElevation.d.ts +4 -2
  70. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  71. package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
  72. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  73. package/dist/resolvers/ActionResolver.d.ts +31 -5
  74. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  75. package/dist/resolvers/ActionResolver.js +75 -14
  76. package/dist/resolvers/ActionResolver.js.map +1 -1
  77. package/dist/resolvers/AdhocQueryResolver.d.ts +35 -17
  78. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  79. package/dist/resolvers/AdhocQueryResolver.js +92 -166
  80. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  81. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  82. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  83. package/dist/resolvers/DatasetResolver.js +57 -8
  84. package/dist/resolvers/DatasetResolver.js.map +1 -1
  85. package/dist/resolvers/EntityRecordNameResolver.d.ts +8 -23
  86. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  87. package/dist/resolvers/EntityRecordNameResolver.js +11 -48
  88. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  89. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
  90. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  91. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  92. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  93. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  94. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  95. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  96. package/dist/resolvers/GetDataResolver.js +13 -1
  97. package/dist/resolvers/GetDataResolver.js.map +1 -1
  98. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  99. package/dist/resolvers/QuerySystemUserResolver.js +2 -1
  100. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  101. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +83 -22
  102. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  103. package/dist/resolvers/RealtimeClientSessionResolver.js +174 -72
  104. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  105. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  106. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  107. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  108. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  109. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  110. package/dist/resolvers/RunAIAgentResolver.js +2 -1
  111. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  112. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  113. package/dist/resolvers/RunTemplateResolver.js +12 -11
  114. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  115. package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
  116. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  117. package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
  118. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  119. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
  120. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
  121. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
  122. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  123. package/dist/resolvers/SyncDataResolver.js +6 -2
  124. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  125. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  126. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  127. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  128. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  129. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  130. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  131. package/dist/resolvers/adhocTimeout.js +12 -0
  132. package/dist/resolvers/adhocTimeout.js.map +1 -0
  133. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  134. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  135. package/dist/rest/ViewOperationsHandler.js +29 -17
  136. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  137. package/dist/services/TaskGraphActionRunner.d.ts +21 -8
  138. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  139. package/dist/services/TaskGraphActionRunner.js +43 -16
  140. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  141. package/package.json +92 -92
  142. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  143. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  144. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  145. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  146. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  147. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  148. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  149. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  150. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  151. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
  152. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  153. package/src/__tests__/RealtimeClientSessionResolver.test.ts +310 -31
  154. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  155. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
  156. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  157. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  158. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
  159. package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
  160. package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
  161. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  162. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
  163. package/src/__tests__/SessionManager.test.ts +94 -17
  164. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  165. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +131 -0
  166. package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
  167. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  168. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  169. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
  170. package/src/__tests__/actionAuthorization.test.ts +130 -0
  171. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  172. package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
  173. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  174. package/src/__tests__/magicLink.test.ts +15 -76
  175. package/src/__tests__/magicLinkService.consume.test.ts +230 -0
  176. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  177. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
  178. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  179. package/src/__tests__/multiTenancy.test.ts +1 -1
  180. package/src/__tests__/newUsers.test.ts +62 -5
  181. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  182. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  183. package/src/agentSessions/SessionManager.ts +23 -25
  184. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  185. package/src/agentSessions/index.ts +1 -0
  186. package/src/auth/actionAuthorization.ts +119 -0
  187. package/src/auth/exampleNewUserSubClass.ts +2 -2
  188. package/src/auth/magicLink/MagicLinkService.ts +61 -39
  189. package/src/auth/magicLink/magicLinkCore.ts +19 -56
  190. package/src/auth/newUsers.ts +15 -11
  191. package/src/context.ts +25 -34
  192. package/src/generated/graphql-schemas/__mj.ts +3417 -3428
  193. package/src/generic/CacheInvalidationResolver.ts +74 -19
  194. package/src/generic/ResolverBase.ts +207 -80
  195. package/src/generic/RunViewResolver.ts +17 -0
  196. package/src/index.ts +49 -46
  197. package/src/isolatedProvider.ts +22 -0
  198. package/src/multiTenancy/index.ts +24 -2
  199. package/src/postgresqlCompat.ts +21 -0
  200. package/src/postgresqlPoolSettings.ts +213 -0
  201. package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
  202. package/src/resolvers/ActionResolver.ts +84 -21
  203. package/src/resolvers/AdhocQueryResolver.ts +105 -187
  204. package/src/resolvers/DatasetResolver.ts +70 -10
  205. package/src/resolvers/EntityRecordNameResolver.ts +11 -52
  206. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
  207. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  208. package/src/resolvers/GetDataResolver.ts +14 -1
  209. package/src/resolvers/QuerySystemUserResolver.ts +2 -1
  210. package/src/resolvers/RealtimeClientSessionResolver.ts +196 -77
  211. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  212. package/src/resolvers/RunAIAgentResolver.ts +4 -1
  213. package/src/resolvers/RunTemplateResolver.ts +13 -12
  214. package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
  215. package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
  216. package/src/resolvers/SyncDataResolver.ts +6 -2
  217. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  218. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  219. package/src/resolvers/adhocTimeout.ts +12 -0
  220. package/src/rest/ViewOperationsHandler.ts +37 -18
  221. package/src/services/TaskGraphActionRunner.ts +45 -16
  222. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  223. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  224. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  225. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  226. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  227. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -11,9 +11,9 @@
11
11
  * and **executes** every tool call the browser relays back. The three mutations here cover the MVP:
12
12
  *
13
13
  * 1. {@link RealtimeClientSessionResolver.StartRealtimeClientSession} — authorize, create the
14
- * session (storing the target agent id server-side, authoritatively), mint the ephemeral config.
15
- * 2. {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} — execute a relayed tool call,
16
- * reading the target agent id from the session (NOT the client) so the browser can't swap targets.
14
+ * session (storing the target agent id on it), mint the ephemeral config.
15
+ * 2. {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} — execute a relayed tool call
16
+ * for the target agent stored on the session, re-authorized for the caller on every call.
17
17
  * 3. {@link RealtimeClientSessionResolver.RelayRealtimeTranscript} — persist a transcript turn as a
18
18
  * `Conversation Detail`.
19
19
  *
@@ -21,12 +21,19 @@
21
21
  * - **Start**: the caller must have `CanRun` on the *target* agent (the Realtime Co-Agent is an internal
22
22
  * orchestration agent, so the meaningful gate is the agent doing the real work).
23
23
  * - **Execute / Relay**: inbound ownership — the session's `UserID` must equal `contextUser.ID`.
24
+ * - **Session `Config`**: the owner can edit it through the generic entity API, so nothing in it is
25
+ * trusted. Execute re-checks `CanRun` on its target, direct actions never come from it, and its
26
+ * run ids are used only when {@link SessionRunIDVerifier} finds them to be records of the session.
27
+ * - **Scope-limited callers** (anonymous and resource-scoped magic-link sessions, including public
28
+ * web-widget guests): no direct actions. Their scope exists only as row-level filters, which an
29
+ * action can read around, and a scoped anonymous caller's relay runs as the system user. They
30
+ * reach the target agent only through `invoke-target-agent`.
24
31
  *
25
32
  * @module @memberjunction/server
26
33
  */
27
34
  import { Resolver, Mutation, Arg, Ctx, Int, Float, ObjectType, Field, PubSub, PubSubEngine } from 'type-graphql';
28
35
  import { AppContext, UserPayload } from '../types.js';
29
- import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView } from '@memberjunction/core';
36
+ import { AuthorizationEvaluator, UserInfo, IMetadataProvider, LogError, LogStatus, RunView, BaseEntity } from '@memberjunction/core';
30
37
  import { UUIDsEqual, IsValidUUID } from '@memberjunction/global';
31
38
  import {
32
39
  MJAIAgentEntity,
@@ -48,6 +55,7 @@ import {
48
55
  FindIgnoredRealtimeConfigKeys,
49
56
  ParseRealtimeTypeConfiguration,
50
57
  ResolveEffectiveRealtimeConfig,
58
+ INVOKE_TARGET_AGENT_TOOL_NAME,
51
59
  RealtimeAllowedAgent,
52
60
  RealtimeDirectActionsConfig,
53
61
  REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION,
@@ -62,6 +70,8 @@ import { ResolverBase } from '../generic/ResolverBase.js';
62
70
  import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
63
71
  import { GetReadWriteProvider } from '../util.js';
64
72
  import { SessionManager } from '../agentSessions/index.js';
73
+ import { SessionRunIDVerifier } from '../agentSessions/SessionRunIDVerifier.js';
74
+ import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
65
75
  import { resolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
66
76
 
67
77
  /**
@@ -137,9 +147,9 @@ const MAX_PRIOR_TRANSCRIPT_CHARS = 8_000;
137
147
  const MAX_PRIOR_TRANSCRIPT_LEGS = 5;
138
148
 
139
149
  /**
140
- * Authoritative shape persisted in `AIAgentSession.Config_` for a client-direct voice session.
141
- * The target agent id is stored here at start and read back on every relay — the browser never
142
- * re-supplies it, so it cannot swap targets mid-session.
150
+ * Shape persisted in `AIAgentSession.Config_` for a client-direct voice session. The server writes
151
+ * it, but the session owner can also edit it through the generic entity API, so relays re-check
152
+ * what they read from it (see the authorization model above).
143
153
  */
144
154
  interface RealtimeSessionConfig {
145
155
  /** The top-level target agent the co-agent voices on behalf of. */
@@ -375,6 +385,32 @@ export class RealtimeClientSessionResolver extends ResolverBase {
375
385
  // state on — otherwise that cleanup silently no-ops on a freshly-constructed instance.
376
386
  private readonly clientSessionService = new RealtimeClientSessionService();
377
387
  private readonly sessionManager = new SessionManager(this.clientSessionService);
388
+ private readonly runIDVerifier = new SessionRunIDVerifier();
389
+
390
+ /**
391
+ * The reason a `Save()`/`Delete()` returned false, for a log line. `CompleteMessage` renders
392
+ * `Message`, `Error` and every `Errors[]` entry (validation errors included, since `Save()`
393
+ * runs `Validate()` and records its errors there), so there is no separate `Validate()` call.
394
+ * When a subclass refused the write without recording anything, say so explicitly instead of
395
+ * "unknown error", which is what hid MJ#4791.
396
+ *
397
+ * Only a NULL `LatestResult` proves the refusal happened before the provider (nothing was
398
+ * registered at all). A SUCCESS `LatestResult` is an earlier save on the same instance — the
399
+ * refusal registered nothing — so its text must never be reported as this failure's reason.
400
+ */
401
+ private describeSaveFailure(entity: BaseEntity): string {
402
+ const latest = entity.LatestResult;
403
+ if (!latest) {
404
+ return `no failure detail recorded (LatestResult null; ResultHistory length ${entity.ResultHistory.length}) ` +
405
+ '— the write was refused before reaching the provider';
406
+ }
407
+ if (latest.Success) {
408
+ return 'no failure detail recorded (latest result entry is a prior success; ' +
409
+ `ResultHistory length ${entity.ResultHistory.length})`;
410
+ }
411
+ const detail = latest.CompleteMessage?.trim();
412
+ return detail || `no failure detail recorded (LatestResult empty; ResultHistory length ${entity.ResultHistory.length})`;
413
+ }
378
414
 
379
415
  /**
380
416
  * Start a client-direct realtime voice session targeting `targetAgentId`.
@@ -386,7 +422,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
386
422
  * agent's `DefaultCoAgentID` → type-level `AIAgentCoAgent` default row → global Realtime Co-Agent) —
387
423
  * see {@link RealtimeClientSessionResolver.resolveCoAgentID} for the full contract.
388
424
  * 3. Create the durable `AIAgentSession` (run by the co-agent), storing `targetAgentID` in its
389
- * config server-side — this is the authoritative target for all later relays.
425
+ * config — the target later relays use, re-authorized for the caller on each relay.
390
426
  * 4. Mint the {@link import('@memberjunction/ai').ClientRealtimeSessionConfig} via the service.
391
427
  * On failure the just-created session is closed so no half-open session leaks.
392
428
  *
@@ -501,8 +537,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
501
537
  * Execute a single tool call the browser relayed from its provider socket and return the
502
538
  * serialized result for the browser to relay back to the model.
503
539
  *
504
- * Ownership-gated; the target agent id is read from the **session config** (authoritative), never
505
- * from the client. Heartbeats the session on success.
540
+ * Ownership-gated. The target agent id is read from the session config and `CanRun` on it is
541
+ * checked for the caller on every call, because the owner can edit the config. A direct-action
542
+ * call from a scope-limited caller is refused before any elevation. Heartbeats the session on
543
+ * success.
506
544
  *
507
545
  * @returns The serialized tool result JSON.
508
546
  */
@@ -518,13 +556,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
518
556
  const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
519
557
  const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
520
558
  const config = this.readSessionConfig(session);
559
+ await this.assertCanRunTarget(config.targetAgentID, contextUser, provider);
560
+ if (toolName !== INVOKE_TARGET_AGENT_TOOL_NAME && IsScopeLimitedPrincipal(contextUser)) {
561
+ return this.refuseDirectAction(agentSessionId, toolName);
562
+ }
521
563
 
522
564
  // SCOPED-ANONYMOUS ELEVATION (issue #3371): once ownership is proven above, the delegated
523
565
  // run + its AI-run-entity writes execute as the system user for a scoped anonymous caller
524
566
  // (the caller's role deliberately holds no grants on the run entities). The lead
525
- // targetAgentID comes from the session config and was CanRun-gated at start; the colleague
526
- // union is gated just below, against the CALLER, so elevation never widens agent authority.
567
+ // targetAgentID is CanRun-gated against the CALLER just above, and the colleague union
568
+ // just below, so elevation never widens agent authority.
527
569
  const runUser = ResolveScopedAnonymousRunUser(contextUser);
570
+ const runIDs = await this.runIDVerifier.Verify(
571
+ session.ID,
572
+ { CoAgentRunID: config.coAgentRunID, PendingFeedbackRunID: config.pendingFeedbackRunID },
573
+ runUser,
574
+ provider,
575
+ );
528
576
  if (runUser !== contextUser) {
529
577
  LogStatus(
530
578
  `ExecuteRealtimeSessionTool: dispatching relayed tool '${toolName}' for session ${agentSessionId} ` +
@@ -542,16 +590,17 @@ export class RealtimeClientSessionResolver extends ResolverBase {
542
590
  // Multi-target (Move 4): the session's persisted allowed-agent union — a model-named
543
591
  // colleague in the call is validated against this; absent ⇒ single-target behavior.
544
592
  AllowedAgents: await this.filterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
545
- DirectActions: config.directActions,
593
+ // No DirectActions: the service takes them from the session's start-time projection
594
+ // or the target agent's metadata, never from the owner-editable config.
546
595
  // Attribution follows the VISITOR even when `runUser` is elevated: the delegated run
547
596
  // row and its context-memory scope must stay the person's, not the system user's.
548
597
  AttributionUserID: contextUser.ID,
549
598
  // Nest the delegated target-agent run under the co-agent observability run (when present).
550
- ParentRunID: config.coAgentRunID,
599
+ ParentRunID: runIDs.CoAgentRunID,
551
600
  Call: { CallID: callId, ToolName: toolName, Arguments: argsJson },
552
601
  OnProgress: this.buildDelegationProgressCallback(pubSub, userPayload, agentSessionId, callId),
553
602
  // Resume a previously-paused delegated run (if any) with the user's answer.
554
- ResumeRunID: config.pendingFeedbackRunID,
603
+ ResumeRunID: runIDs.PendingFeedbackRunID,
555
604
  },
556
605
  runUser,
557
606
  provider,
@@ -561,7 +610,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
561
610
  `ExecuteRealtimeSessionTool: completed relayed tool '${toolName}' (callId: ${callId}) in ${durationMs}ms`,
562
611
  );
563
612
 
564
- if (toolName !== 'invoke-target-agent') {
613
+ if (toolName !== INVOKE_TARGET_AGENT_TOOL_NAME) {
565
614
  await this.persistDirectActionTurn(
566
615
  session,
567
616
  callId,
@@ -581,7 +630,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
581
630
  // Junction-link any artifacts the delegated run produced into the session's conversation
582
631
  // history (best-effort) — so chat, session review, and resume carryover can all see them.
583
632
  // Runs as `runUser`: the junction entity is not among an anonymous caller's relay grants.
584
- await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, provider);
633
+ // The hidden anchor detail it may need to create, though, is written by the CALLER (#4791)
634
+ // — same reason `persistDirectActionTurn` is: the System user is not the conversation owner.
635
+ await this.linkDelegatedArtifactsToConversation(session, Artifacts, runUser, contextUser, provider);
585
636
 
586
637
  await this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
587
638
  return ResultJson;
@@ -616,6 +667,19 @@ export class RealtimeClientSessionResolver extends ResolverBase {
616
667
  };
617
668
  }
618
669
 
670
+ /**
671
+ * The tool result for a direct-action call from a scope-limited caller (see the authorization
672
+ * model above). It has the broker's result shape, so the model hears that the tool is not
673
+ * available and can ask the target agent through `invoke-target-agent` instead.
674
+ */
675
+ private refuseDirectAction(agentSessionId: string, toolName: string): string {
676
+ LogStatus(`ExecuteRealtimeSessionTool: refused direct action '${toolName}' for a scope-limited caller on session ${agentSessionId}.`);
677
+ return JSON.stringify({
678
+ success: false,
679
+ output: `The tool '${toolName}' is not available in this session. Ask the agent to do it with ${INVOKE_TARGET_AGENT_TOOL_NAME}.`,
680
+ });
681
+ }
682
+
619
683
  /**
620
684
  * Rolls the session's `pendingFeedbackRunID` forward after a relayed tool call. The id we just
621
685
  * consumed (resumed or none) is dropped; a new paused run id is stored only when the run paused
@@ -645,7 +709,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
645
709
  session.Config_ = JSON.stringify(next);
646
710
  if (!(await session.Save())) {
647
711
  LogError(
648
- `RealtimeClientSessionResolver.updatePendingFeedbackRunID save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
712
+ `RealtimeClientSessionResolver.updatePendingFeedbackRunID save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
649
713
  );
650
714
  }
651
715
  }
@@ -686,14 +750,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
686
750
  // conversation (run-viewer observability parity). Best-effort — never fails the transcript relay.
687
751
  // The prompt-run write runs as the scoped-anonymous elevated user (issue #3371) — the visible
688
752
  // Conversation Detail above deliberately stays on the caller.
689
- const promptRunID = this.readPromptRunID(session);
753
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
754
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
690
755
  if (promptRunID) {
691
756
  await this.clientSessionService.AppendPromptRunMessage(
692
757
  promptRunID,
693
758
  this.mapTranscriptRoleToChatRole(role),
694
759
  text,
695
760
  replacesPrevious ?? false,
696
- ResolveScopedAnonymousRunUser(contextUser),
761
+ runUser,
697
762
  provider,
698
763
  );
699
764
  }
@@ -891,7 +956,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
891
956
  provider,
892
957
  );
893
958
 
894
- const promptRunID = this.readPromptRunID(session);
959
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
960
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
895
961
  if (!promptRunID) {
896
962
  return false;
897
963
  }
@@ -900,7 +966,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
900
966
  'assistant',
901
967
  this.formatToolTurn(toolName, argsJson, resultJson),
902
968
  false,
903
- ResolveScopedAnonymousRunUser(contextUser),
969
+ runUser,
904
970
  provider,
905
971
  );
906
972
  }
@@ -992,16 +1058,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
992
1058
  return true; // nothing to add — a no-op flush is a success, not a failure
993
1059
  }
994
1060
 
995
- const promptRunID = this.readPromptRunID(session);
1061
+ // Runs as the scoped-anonymous elevated user (issue #3371) — the caller's role holds no prompt-run grants.
1062
+ const runUser = ResolveScopedAnonymousRunUser(contextUser);
1063
+ const promptRunID = await this.readPromptRunID(session, runUser, provider);
996
1064
  if (!promptRunID) {
997
1065
  return false; // no observability prompt run for this session — logged in the helper
998
1066
  }
999
1067
  // Delegate to the service so usage writes share the per-run serialization with transcript-message
1000
1068
  // appends — otherwise the frequent usage save clobbers freshly-appended Messages (and vice-versa).
1001
- // Runs as the scoped-anonymous elevated user (issue #3371) — the caller's role holds no prompt-run grants.
1002
- return this.clientSessionService.AccumulatePromptRunUsage(
1003
- promptRunID, inputDelta, outputDelta, ResolveScopedAnonymousRunUser(contextUser), provider,
1004
- );
1069
+ return this.clientSessionService.AccumulatePromptRunUsage(promptRunID, inputDelta, outputDelta, runUser, provider);
1005
1070
  }
1006
1071
 
1007
1072
  /** Clamps a relayed token delta: negative / non-finite values become 0. */
@@ -1028,24 +1093,40 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1028
1093
 
1029
1094
  /**
1030
1095
  * Reads the co-agent `AIPromptRun` id from the session config WITHOUT the relay path's
1031
- * throw-on-missing-target semantics — usage relay is best-effort, so a missing/malformed
1032
- * config or absent id just logs and returns `null`.
1096
+ * throw-on-missing-target semantics, and keeps it only when it is the prompt run of the
1097
+ * session's co-agent run (the owner can edit the config). Usage relay is best-effort, so a
1098
+ * missing/malformed config, an absent id, or an id of another session just logs and returns `null`.
1099
+ *
1100
+ * @param runUser The user the prompt-run write runs as; the check runs as the same user.
1033
1101
  */
1034
- private readPromptRunID(session: MJAIAgentSessionEntity): string | null {
1035
- try {
1036
- const config = this.readSessionConfig(session);
1037
- if (config.promptRunID) {
1038
- return config.promptRunID;
1039
- }
1102
+ private async readPromptRunID(
1103
+ session: MJAIAgentSessionEntity,
1104
+ runUser: UserInfo,
1105
+ provider: IMetadataProvider,
1106
+ ): Promise<string | null> {
1107
+ const config = this.tryReadSessionConfig(session);
1108
+ if (!config) {
1109
+ LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
1110
+ return null;
1111
+ }
1112
+ if (!config.promptRunID) {
1040
1113
  LogStatus(
1041
1114
  `RelayRealtimeUsage: session ${session.ID} has no co-agent promptRunID (observability run ` +
1042
1115
  'creation was skipped or failed) — usage delta dropped.',
1043
1116
  );
1044
1117
  return null;
1045
- } catch {
1046
- LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
1118
+ }
1119
+ const verified = await this.runIDVerifier.Verify(
1120
+ session.ID,
1121
+ { CoAgentRunID: config.coAgentRunID, PromptRunID: config.promptRunID },
1122
+ runUser,
1123
+ provider,
1124
+ );
1125
+ if (!verified.PromptRunID) {
1126
+ LogError(`RelayRealtimeUsage: session ${session.ID} names a promptRunID that is not its co-agent prompt run — ignored.`);
1047
1127
  return null;
1048
1128
  }
1129
+ return verified.PromptRunID;
1049
1130
  }
1050
1131
 
1051
1132
  /**
@@ -1195,8 +1276,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1195
1276
  }
1196
1277
 
1197
1278
  /**
1198
- * Authorization gate for {@link RealtimeClientSessionResolver.StartRealtimeClientSession}: the
1199
- * caller must be able to run the **target** agent. Denial throws (no session is created).
1279
+ * Authorization gate for the **target** agent: the caller must be able to run it. Denial throws.
1280
+ * {@link RealtimeClientSessionResolver.StartRealtimeClientSession} runs it before any session is
1281
+ * created, and {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} runs it on every
1282
+ * relayed call, because the owner can edit the target in the session config.
1200
1283
  */
1201
1284
  private async assertCanRunTarget(
1202
1285
  targetAgentId: string,
@@ -1725,6 +1808,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1725
1808
  // (allowed-agent union); the snapshot is injected into the system prompt at mint.
1726
1809
  ApplicationID: applicationId,
1727
1810
  AppContext: appContext,
1811
+ // Scope-limited callers get no direct actions (see the authorization model above), so
1812
+ // the model is not given tools that the relay refuses.
1813
+ ExcludeDirectActions: IsScopeLimitedPrincipal(contextUser),
1728
1814
  },
1729
1815
  // SCOPED-ANONYMOUS ELEVATION (issue #3371): the prepare creates the co-agent
1730
1816
  // observability AIAgentRun/AIPromptRun/run-step, which a scoped anonymous caller's role
@@ -1766,9 +1852,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1766
1852
 
1767
1853
  /**
1768
1854
  * Writes the co-agent observability run ids into the session's `Config_` alongside the
1769
- * authoritative `targetAgentID`, then saves the session. These ids are read back on close to
1770
- * finalize the runs (and on relay to nest delegated runs). Best-effort: a save failure is logged,
1771
- * not thrown — the voice session still proceeds, it just won't carry the run ids.
1855
+ * `targetAgentID`, then saves the session. These ids are read back on close to
1856
+ * finalize the runs (and on relay to nest delegated runs). Relays never read the direct actions
1857
+ * from it. Best-effort: a save failure is logged, not thrown — the voice session still proceeds,
1858
+ * it just won't carry the run ids.
1772
1859
  */
1773
1860
  private async persistObservabilityRunIDs(
1774
1861
  session: MJAIAgentSessionEntity,
@@ -1797,7 +1884,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1797
1884
  const saved = await session.Save();
1798
1885
  if (!saved) {
1799
1886
  LogError(
1800
- `RealtimeClientSessionResolver.persistObservabilityRunIDs save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
1887
+ `RealtimeClientSessionResolver.persistObservabilityRunIDs save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
1801
1888
  );
1802
1889
  }
1803
1890
  }
@@ -1915,7 +2002,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
1915
2002
  if (!saved) {
1916
2003
  LogError(
1917
2004
  `SaveSessionChannelState: save failed for session ${agentSessionID} / channel ${channelID}: ` +
1918
- `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2005
+ `${this.describeSaveFailure(row)}`,
1919
2006
  );
1920
2007
  }
1921
2008
  return saved;
@@ -2200,7 +2287,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2200
2287
  artifact.UserID = contextUser.ID;
2201
2288
  artifact.Visibility = 'Always';
2202
2289
  if (!(await artifact.Save())) {
2203
- const message = `SaveSessionChannelArtifact: artifact save failed: ${artifact.LatestResult?.CompleteMessage ?? 'unknown error'}`;
2290
+ const message = `SaveSessionChannelArtifact: artifact save failed for session ${session.ID}: ${this.describeSaveFailure(artifact)}`;
2204
2291
  LogError(message);
2205
2292
  return { Success: false, ErrorMessage: message, ConversationDetailLinked: false };
2206
2293
  }
@@ -2212,7 +2299,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2212
2299
  version.Content = contentJson;
2213
2300
  version.UserID = contextUser.ID;
2214
2301
  if (!(await version.Save())) {
2215
- const message = `SaveSessionChannelArtifact: artifact version save failed: ${version.LatestResult?.CompleteMessage ?? 'unknown error'}`;
2302
+ const message = `SaveSessionChannelArtifact: artifact version save failed for session ${session.ID} (artifact ${artifact.ID}): ${this.describeSaveFailure(version)}`;
2216
2303
  LogError(message);
2217
2304
  return { Success: false, ErrorMessage: message, ArtifactID: artifact.ID, ConversationDetailLinked: false };
2218
2305
  }
@@ -2270,13 +2357,22 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2270
2357
  * queries (which scan junctions across ALL details) still surface the artifact. This is the
2271
2358
  * least-invasive correct anchor — no fake visible message, no orphaned artifact.
2272
2359
  *
2360
+ * TWO PRINCIPALS, deliberately: the lookup (`findLatestSessionDetailID`) and the junction row
2361
+ * itself (`saveDetailArtifactJunction`, `MJ: Conversation Detail Artifacts`) run as `runUser`
2362
+ * (elevated for a scoped-anonymous caller) — that junction entity is outside an anonymous
2363
+ * caller's relay grants. The hidden ANCHOR `Conversation Detail` this may need to create runs as
2364
+ * `callerUser` instead: like every other `MJ: Conversation Details` write, the System user is
2365
+ * refused because it is not the conversation's owner (see {@link persistDirectActionTurn},
2366
+ * issue #4791) — only the actual caller can create it.
2367
+ *
2273
2368
  * Strictly best-effort: every failure path logs and returns; a relayed tool call NEVER fails
2274
2369
  * because history linking did.
2275
2370
  */
2276
2371
  private async linkDelegatedArtifactsToConversation(
2277
2372
  session: MJAIAgentSessionEntity,
2278
2373
  artifacts: DelegatedRunArtifact[] | undefined,
2279
- contextUser: UserInfo,
2374
+ runUser: UserInfo,
2375
+ callerUser: UserInfo,
2280
2376
  provider: IMetadataProvider,
2281
2377
  ): Promise<void> {
2282
2378
  if (!artifacts || artifacts.length === 0 || !session.ConversationID) {
@@ -2284,13 +2380,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2284
2380
  }
2285
2381
  try {
2286
2382
  const detailID =
2287
- (await this.findLatestSessionDetailID(session, contextUser, provider)) ??
2288
- (await this.createHiddenSessionAnchorDetail(session, contextUser, provider));
2383
+ (await this.findLatestSessionDetailID(session, runUser, provider)) ??
2384
+ (await this.createHiddenSessionAnchorDetail(session, callerUser, provider));
2289
2385
  if (!detailID) {
2290
2386
  return; // anchor unavailable — logged in the helpers
2291
2387
  }
2292
2388
  for (const artifact of artifacts) {
2293
- await this.saveDetailArtifactJunction(detailID, artifact.ArtifactVersionID, contextUser, provider);
2389
+ await this.saveDetailArtifactJunction(detailID, artifact.ArtifactVersionID, runUser, provider);
2294
2390
  }
2295
2391
  } catch (error) {
2296
2392
  LogError(`ExecuteRealtimeSessionTool: delegated-artifact history link failed: ${(error as Error).message}`);
@@ -2331,25 +2427,25 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2331
2427
  */
2332
2428
  private async createHiddenSessionAnchorDetail(
2333
2429
  session: MJAIAgentSessionEntity,
2334
- contextUser: UserInfo,
2430
+ callerUser: UserInfo,
2335
2431
  provider: IMetadataProvider,
2336
2432
  ): Promise<string | null> {
2337
- const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY, contextUser);
2433
+ const detail = await provider.GetEntityObject<MJConversationDetailEntity>(CONVERSATION_DETAIL_ENTITY, callerUser);
2338
2434
  detail.NewRecord();
2339
2435
  detail.ConversationID = session.ConversationID;
2340
2436
  detail.Role = 'AI';
2341
2437
  detail.HiddenToUser = true;
2342
2438
  detail.Message = 'Artifacts produced during a realtime session (system anchor).';
2343
2439
  detail.AgentSessionID = session.ID;
2344
- // Attribute the anchor to the SESSION owner, not the (possibly elevated) writer — identical
2345
- // for every non-elevated caller, whose ownership of the session is already proven.
2440
+ // Attribute to the SESSION owner — the caller writing this row, whose ownership
2441
+ // `loadOwnedActiveSession` already proved.
2346
2442
  detail.UserID = session.UserID;
2347
2443
  if (await detail.Save()) {
2348
2444
  return detail.ID;
2349
2445
  }
2350
2446
  LogError(
2351
2447
  `ExecuteRealtimeSessionTool: hidden anchor detail save failed for session ${session.ID}: ` +
2352
- `${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2448
+ `${this.describeSaveFailure(detail)}`,
2353
2449
  );
2354
2450
  return null;
2355
2451
  }
@@ -2376,7 +2472,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2376
2472
  if (!saved) {
2377
2473
  LogError(
2378
2474
  `RealtimeClientSessionResolver: artifact junction save failed for detail ${conversationDetailID} / version ${artifactVersionID}: ` +
2379
- `${junction.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2475
+ `${this.describeSaveFailure(junction)}`,
2380
2476
  );
2381
2477
  }
2382
2478
  return saved;
@@ -2416,7 +2512,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2416
2512
  if (!(await row.Save())) {
2417
2513
  LogError(
2418
2514
  `SaveSessionChannelArtifact: LastActiveAt stamp failed for session ${agentSessionID} / channel ${channelID}: ` +
2419
- `${row.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2515
+ `${this.describeSaveFailure(row)}`,
2420
2516
  );
2421
2517
  }
2422
2518
  } catch (error) {
@@ -2501,9 +2597,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2501
2597
  }
2502
2598
 
2503
2599
  /**
2504
- * Parses the session's persisted config, returning the authoritative `targetAgentID` plus any
2505
- * observability run ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed
2506
- * or carries no target — a relay cannot proceed without a target.
2600
+ * Parses the session's persisted config, returning its `targetAgentID` plus any observability run
2601
+ * ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed or carries no
2602
+ * target — a relay cannot proceed without a target. The owner can edit the config, so callers
2603
+ * re-check what they act on.
2507
2604
  */
2508
2605
  private readSessionConfig(session: MJAIAgentSessionEntity): RealtimeSessionConfig {
2509
2606
  const raw = session.Config_;
@@ -2560,12 +2657,6 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2560
2657
  return minutes && minutes > 0 ? minutes * 60 : undefined;
2561
2658
  }
2562
2659
 
2563
- /**
2564
- * Persists a single transcript turn as a `Conversation Detail` stamped with the session's
2565
- * conversation, the mapped role, the turn text, the session id, and the owning user.
2566
- *
2567
- * @returns The boolean save result (logs `CompleteMessage` on failure).
2568
- */
2569
2660
  /**
2570
2661
  * Stamps `RecordingStartedAt` (the recording `t0` alignment origin) + `RecordingMedia` on a
2571
2662
  * just-started session when the browser captured WITH consent. Best-effort: a parse/save failure
@@ -2592,11 +2683,18 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2592
2683
  session.RecordingMedia = 'Audio';
2593
2684
  if (!(await session.Save())) {
2594
2685
  LogError(
2595
- `RealtimeClientSessionResolver.stampRecordingStart save failed: ${session.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2686
+ `RealtimeClientSessionResolver.stampRecordingStart save failed for session ${session.ID}: ${this.describeSaveFailure(session)}`,
2596
2687
  );
2597
2688
  }
2598
2689
  }
2599
2690
 
2691
+ /**
2692
+ * Persists a single transcript turn as a `Conversation Detail` stamped with the session's
2693
+ * conversation, the mapped role, the turn text, the session id, and the owning user.
2694
+ *
2695
+ * @returns The boolean save result. On failure it logs the session, the write user and the
2696
+ * reason from {@link describeSaveFailure}.
2697
+ */
2600
2698
  private async persistTranscriptTurn(
2601
2699
  session: MJAIAgentSessionEntity,
2602
2700
  role: string,
@@ -2626,7 +2724,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2626
2724
  const saved = await detail.Save();
2627
2725
  if (!saved) {
2628
2726
  LogError(
2629
- `RealtimeClientSessionResolver.persistTranscriptTurn save failed: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2727
+ `RealtimeClientSessionResolver.persistTranscriptTurn save failed for session ${session.ID} (write user ${contextUser.ID}): ${this.describeSaveFailure(detail)}`,
2630
2728
  );
2631
2729
  }
2632
2730
  return saved;
@@ -2667,8 +2765,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2667
2765
  // ⚠️ ELEVATING INSTEAD WAS TRIED AND IS A TRAP. `ResolveScopedAnonymousRunUser` returns
2668
2766
  // `UserCache.GetSystemUser()`, which on this deployment resolves to the unconfigured
2669
2767
  // placeholder `not.set@nowhere.com` ("Configured provisioning user not found; falling back
2670
- // to an Owner"). Saving as a user that does not exist returns false with a NULL
2671
- // `LatestResult` — indistinguishable from a permission denial, and just as silent.
2768
+ // to an Owner"). That user EXISTS — it is not a missing/invalid principal — and even carries
2769
+ // full CRUD on `MJ: Conversation Details`. It is refused anyway:
2770
+ // `MJConversationDetailEntityExtended`'s owner gate accepts only the conversation's OWNER or
2771
+ // an Edit/Owner Resource Permission grantee, and the System user is neither. Measured on
2772
+ // MJ#4791 (before the core-entities fix that now records the denial): `Save()` → `false`,
2773
+ // `LatestResult` null — indistinguishable from a permission denial, and just as silent. The
2774
+ // denial itself still happens; that fix only made it stop being silent.
2672
2775
  const writeUser = contextUser;
2673
2776
  const rv = RunView.FromMetadataProvider(provider);
2674
2777
  const result = await rv.RunView<MJConversationDetailEntity>(
@@ -2689,8 +2792,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2689
2792
  //
2690
2793
  // `ResultType: 'entity_object'` hands back hydrated entities, but they do not carry the
2691
2794
  // context user the way `GetEntityObject(entity, user)` does — so `Save()` ran with no
2692
- // principal and failed with an EMPTY message ("unknown error"), which is what the elevation
2693
- // fix above looked like when it was still broken. The insert path beside this one has
2795
+ // principal and failed with an EMPTY message ("unknown error"), which is what an empty-message
2796
+ // failure here looked like before the re-load. The insert path beside this one has
2694
2797
  // always used `GetEntityObject`; this now matches it, which is also why they now succeed
2695
2798
  // and fail for the same reasons.
2696
2799
  const previous = await provider.GetEntityObject<MJConversationDetailEntity>(
@@ -2722,7 +2825,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2722
2825
  `RealtimeClientSessionResolver.replacePreviousTranscriptTurn save failed for session `
2723
2826
  + `${session.ID} — the ${mappedRole} turn keeps its PREVIOUS, shorter text and the `
2724
2827
  + `transcript now understates what was said: `
2725
- + `${previous.LatestResult?.CompleteMessage || JSON.stringify(previous.LatestResult ?? null)}`,
2828
+ + `${this.describeSaveFailure(previous)}`,
2726
2829
  );
2727
2830
  }
2728
2831
  return saved;
@@ -2742,6 +2845,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2742
2845
  * Stored as stable facts (Role: 'AI', HiddenToUser: true, ExternalID: callId, CompletionTime: durationMs,
2743
2846
  * Message: JSON-serialized tool facts) so that session review can reconstruct the action card dynamically
2744
2847
  * on load (Option 1) without polluting user-facing speech chat bubbles.
2848
+ *
2849
+ * Writes as the CALLER, never the elevated run user (issue #4791). Owning the session
2850
+ * (`loadOwnedActiveSession`'s gate has already run on every path here) does NOT imply owning
2851
+ * the conversation: the conversation id was client-supplied at session start. What authorizes
2852
+ * this write is the save itself, evaluated for the caller — the conversation owner gate in
2853
+ * `MJConversationDetailEntityExtended`, plus the caller's own entity permission and Create RLS
2854
+ * on `MJ: Conversation Details` (for the magic-link role that RLS scopes by the invite's
2855
+ * `ScopeResourceID`, and the role belongs to a downstream app, not MJ core). That is why
2856
+ * writing as the caller is safe even though the conversation id originated client-side.
2857
+ * Elevating to the System user would bypass RLS entirely, and the owner gate refuses it anyway
2858
+ * because the System user is not the conversation owner. Measured on MJ#4791 (before the
2859
+ * core-entities fix that now records the denial): `Save()` returned `false` with a NULL `LatestResult` and an empty
2860
+ * `ResultHistory` — indistinguishable from any other silent failure. Elevating this write is
2861
+ * what silently dropped both hidden tool-execution turns of a real magic-link session; the
2862
+ * denial itself still happens today, only its silence was fixed separately.
2863
+ * `ResolveScopedAnonymousRunUser` elevates AI-**run**-entity writes only (prompt runs, agent
2864
+ * runs, the relayed dispatch itself) — never this one.
2745
2865
  */
2746
2866
  private async persistDirectActionTurn(
2747
2867
  session: MJAIAgentSessionEntity,
@@ -2757,10 +2877,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2757
2877
  return false;
2758
2878
  }
2759
2879
  try {
2760
- const writeUser = ResolveScopedAnonymousRunUser(contextUser);
2761
2880
  const detail = await provider.GetEntityObject<MJConversationDetailEntity>(
2762
2881
  CONVERSATION_DETAIL_ENTITY,
2763
- writeUser,
2882
+ contextUser,
2764
2883
  );
2765
2884
  detail.NewRecord();
2766
2885
  detail.ConversationID = session.ConversationID;
@@ -2802,13 +2921,13 @@ export class RealtimeClientSessionResolver extends ResolverBase {
2802
2921
  const saved = await detail.Save();
2803
2922
  if (!saved) {
2804
2923
  LogError(
2805
- `RealtimeClientSessionResolver.persistDirectActionTurn save failed: ${detail.LatestResult?.CompleteMessage ?? 'unknown error'}`,
2924
+ `RealtimeClientSessionResolver.persistDirectActionTurn save failed for session ${session.ID} (tool '${toolName}', call ${callId ?? 'n/a'}, write user ${contextUser.ID}): ${this.describeSaveFailure(detail)}`,
2806
2925
  );
2807
2926
  }
2808
2927
  return saved;
2809
2928
  } catch (error) {
2810
2929
  const message = error instanceof Error ? error.message : String(error);
2811
- LogError(`RealtimeClientSessionResolver.persistDirectActionTurn unexpected error: ${message}`);
2930
+ LogError(`RealtimeClientSessionResolver.persistDirectActionTurn unexpected error for session ${session.ID}: ${message}`);
2812
2931
  return false;
2813
2932
  }
2814
2933
  }