@indexnetwork/protocol 4.15.0-rc.357.1 → 4.16.1-rc.359.1

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 (105) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/contact/contact.inviter.d.ts.map +1 -1
  3. package/dist/contact/contact.inviter.js +12 -5
  4. package/dist/contact/contact.inviter.js.map +1 -1
  5. package/dist/index.d.ts +12 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +5 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/opportunity/delivery-card.cache.d.ts.map +1 -1
  10. package/dist/opportunity/delivery-card.cache.js +7 -3
  11. package/dist/opportunity/delivery-card.cache.js.map +1 -1
  12. package/dist/opportunity/discriminator/discriminator.adjustments.d.ts +22 -15
  13. package/dist/opportunity/discriminator/discriminator.adjustments.d.ts.map +1 -1
  14. package/dist/opportunity/discriminator/discriminator.adjustments.js +39 -20
  15. package/dist/opportunity/discriminator/discriminator.adjustments.js.map +1 -1
  16. package/dist/opportunity/feed/feed.categorizer.d.ts.map +1 -1
  17. package/dist/opportunity/feed/feed.categorizer.js +6 -0
  18. package/dist/opportunity/feed/feed.categorizer.js.map +1 -1
  19. package/dist/opportunity/feed/feed.graph.d.ts +2 -0
  20. package/dist/opportunity/feed/feed.graph.d.ts.map +1 -1
  21. package/dist/opportunity/feed/feed.graph.js +53 -28
  22. package/dist/opportunity/feed/feed.graph.js.map +1 -1
  23. package/dist/opportunity/feed/feed.state.d.ts +3 -1
  24. package/dist/opportunity/feed/feed.state.d.ts.map +1 -1
  25. package/dist/opportunity/feed/feed.state.js.map +1 -1
  26. package/dist/opportunity/opportunity.claim-safety.d.ts +25 -0
  27. package/dist/opportunity/opportunity.claim-safety.d.ts.map +1 -0
  28. package/dist/opportunity/opportunity.claim-safety.js +89 -0
  29. package/dist/opportunity/opportunity.claim-safety.js.map +1 -0
  30. package/dist/opportunity/opportunity.discover.d.ts.map +1 -1
  31. package/dist/opportunity/opportunity.discover.js +6 -1
  32. package/dist/opportunity/opportunity.discover.js.map +1 -1
  33. package/dist/opportunity/opportunity.evaluator.d.ts.map +1 -1
  34. package/dist/opportunity/opportunity.evaluator.js +13 -8
  35. package/dist/opportunity/opportunity.evaluator.js.map +1 -1
  36. package/dist/opportunity/opportunity.graph.d.ts +36 -0
  37. package/dist/opportunity/opportunity.graph.d.ts.map +1 -1
  38. package/dist/opportunity/opportunity.graph.js +177 -18
  39. package/dist/opportunity/opportunity.graph.js.map +1 -1
  40. package/dist/opportunity/opportunity.persist.d.ts +8 -1
  41. package/dist/opportunity/opportunity.persist.d.ts.map +1 -1
  42. package/dist/opportunity/opportunity.persist.js +23 -2
  43. package/dist/opportunity/opportunity.persist.js.map +1 -1
  44. package/dist/opportunity/opportunity.presentation-cache.d.ts +7 -0
  45. package/dist/opportunity/opportunity.presentation-cache.d.ts.map +1 -0
  46. package/dist/opportunity/opportunity.presentation-cache.js +15 -0
  47. package/dist/opportunity/opportunity.presentation-cache.js.map +1 -0
  48. package/dist/opportunity/opportunity.presentation.d.ts.map +1 -1
  49. package/dist/opportunity/opportunity.presentation.js +8 -5
  50. package/dist/opportunity/opportunity.presentation.js.map +1 -1
  51. package/dist/opportunity/opportunity.presenter.d.ts +4 -4
  52. package/dist/opportunity/opportunity.presenter.d.ts.map +1 -1
  53. package/dist/opportunity/opportunity.presenter.js +55 -14
  54. package/dist/opportunity/opportunity.presenter.js.map +1 -1
  55. package/dist/opportunity/opportunity.safe-presentation.d.ts +1 -1
  56. package/dist/opportunity/opportunity.safe-presentation.d.ts.map +1 -1
  57. package/dist/opportunity/opportunity.safe-presentation.js +22 -9
  58. package/dist/opportunity/opportunity.safe-presentation.js.map +1 -1
  59. package/dist/opportunity/opportunity.state.d.ts +3 -0
  60. package/dist/opportunity/opportunity.state.d.ts.map +1 -1
  61. package/dist/opportunity/opportunity.state.js.map +1 -1
  62. package/dist/opportunity/opportunity.tools.d.ts +1 -17
  63. package/dist/opportunity/opportunity.tools.d.ts.map +1 -1
  64. package/dist/opportunity/opportunity.tools.js +32 -2
  65. package/dist/opportunity/opportunity.tools.js.map +1 -1
  66. package/dist/shared/agent/model.config.d.ts +5 -0
  67. package/dist/shared/agent/model.config.d.ts.map +1 -1
  68. package/dist/shared/agent/model.config.js +1 -0
  69. package/dist/shared/agent/model.config.js.map +1 -1
  70. package/dist/shared/hyde/hyde.documents.d.ts +10 -0
  71. package/dist/shared/hyde/hyde.documents.d.ts.map +1 -0
  72. package/dist/shared/hyde/hyde.documents.js +50 -0
  73. package/dist/shared/hyde/hyde.documents.js.map +1 -0
  74. package/dist/shared/hyde/hyde.env.d.ts +9 -0
  75. package/dist/shared/hyde/hyde.env.d.ts.map +1 -0
  76. package/dist/shared/hyde/hyde.env.js +10 -0
  77. package/dist/shared/hyde/hyde.env.js.map +1 -0
  78. package/dist/shared/hyde/hyde.frame.d.ts +151 -0
  79. package/dist/shared/hyde/hyde.frame.d.ts.map +1 -0
  80. package/dist/shared/hyde/hyde.frame.js +108 -0
  81. package/dist/shared/hyde/hyde.frame.js.map +1 -0
  82. package/dist/shared/hyde/hyde.generator.d.ts +23 -11
  83. package/dist/shared/hyde/hyde.generator.d.ts.map +1 -1
  84. package/dist/shared/hyde/hyde.generator.js +50 -21
  85. package/dist/shared/hyde/hyde.generator.js.map +1 -1
  86. package/dist/shared/hyde/hyde.graph.d.ts +58 -17
  87. package/dist/shared/hyde/hyde.graph.d.ts.map +1 -1
  88. package/dist/shared/hyde/hyde.graph.js +333 -89
  89. package/dist/shared/hyde/hyde.graph.js.map +1 -1
  90. package/dist/shared/hyde/hyde.state.d.ts +19 -2
  91. package/dist/shared/hyde/hyde.state.d.ts.map +1 -1
  92. package/dist/shared/hyde/hyde.state.js +23 -3
  93. package/dist/shared/hyde/hyde.state.js.map +1 -1
  94. package/dist/shared/hyde/hyde.validator.d.ts +79 -0
  95. package/dist/shared/hyde/hyde.validator.d.ts.map +1 -0
  96. package/dist/shared/hyde/hyde.validator.js +49 -0
  97. package/dist/shared/hyde/hyde.validator.js.map +1 -0
  98. package/dist/shared/hyde/lens.inferrer.d.ts +190 -12
  99. package/dist/shared/hyde/lens.inferrer.d.ts.map +1 -1
  100. package/dist/shared/hyde/lens.inferrer.js +93 -38
  101. package/dist/shared/hyde/lens.inferrer.js.map +1 -1
  102. package/dist/shared/interfaces/database.interface.d.ts +46 -2
  103. package/dist/shared/interfaces/database.interface.d.ts.map +1 -1
  104. package/dist/shared/interfaces/database.interface.js.map +1 -1
  105. package/package.json +2 -1
@@ -17,8 +17,11 @@ import { resolveInitialStatus } from './opportunity.state.js';
17
17
  import { OpportunityEvaluator } from './opportunity.evaluator.js';
18
18
  import { IntentIndexer } from '../intent/intent.indexer.js';
19
19
  import { getModelName } from '../shared/agent/model.config.js';
20
+ import { selectHydeDocumentsForGeneration } from '../shared/hyde/hyde.documents.js';
21
+ import { getHydeGenerationMode } from '../shared/hyde/hyde.env.js';
20
22
  import { validateOpportunityActors } from './opportunity.utils.js';
21
- import { viewerCentricCardSummary } from './opportunity.presentation.js';
23
+ import { safeFallbackSummary } from './opportunity.safe-presentation.js';
24
+ import { hasUnsupportedOpportunityClaim } from './opportunity.claim-safety.js';
22
25
  import { persistOpportunities } from './opportunity.persist.js';
23
26
  import { INTRODUCER_DISCOVERY_SOURCE } from './opportunity.introducer.js';
24
27
  import { negotiateCandidates } from "../negotiation/negotiation.graph.js";
@@ -52,6 +55,9 @@ const routingLog = protocolLogger('OpportunityGraph:Routing');
52
55
  const DEDUP_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
53
56
  /** Default cap for source premises used by premise-to-premise discovery. Prevents BACKEND-5-style fan-out. */
54
57
  const DEFAULT_SOURCE_PREMISE_DISCOVERY_LIMIT = 40;
58
+ function networkMembershipPairKey(userId, networkId) {
59
+ return `${userId}\u0000${networkId}`;
60
+ }
55
61
  /** Per-source cap for candidate premise matches. */
56
62
  const PREMISE_MATCH_LIMIT_PER_SOURCE = 20;
57
63
  /** Resolve the source premise discovery cap from env, preserving 0 as an explicit disable switch. */
@@ -273,6 +279,7 @@ export class OpportunityGraphFactory {
273
279
  .map((c) => ({
274
280
  contextId: c.id,
275
281
  networkId: c.networkId,
282
+ text: c.text,
276
283
  embedding: c.embedding,
277
284
  }));
278
285
  return {
@@ -334,10 +341,11 @@ export class OpportunityGraphFactory {
334
341
  }
335
342
  targetIndexIds = [state.networkId];
336
343
  }
337
- else if (state.indexScope && state.indexScope.length > 0) {
344
+ else if (state.indexScope !== undefined) {
338
345
  // Bounded scope (e.g. a network-scoped agent's reachable networks):
339
346
  // intersect with the user's actual memberships so discovery never
340
- // reaches networks outside the agent's bound scope.
347
+ // reaches networks outside the agent's bound scope. An explicit
348
+ // empty scope is authoritative and must fail closed.
341
349
  const allowed = new Set(state.indexScope);
342
350
  targetIndexIds = state.userNetworks.filter((n) => allowed.has(n));
343
351
  scopeLog.verbose('Applied indexScope intersection', {
@@ -350,6 +358,19 @@ export class OpportunityGraphFactory {
350
358
  // Search all user's indexes
351
359
  targetIndexIds = state.userNetworks;
352
360
  }
361
+ if (state.triggerIntentId) {
362
+ // A trigger intent is an authoritative discovery boundary, not just
363
+ // ranking context. Recompute the intersection at the graph edge so
364
+ // direct chat/MCP callers cannot bypass FromIntentQueue admission.
365
+ const assignedNetworkIds = new Set(await this.database.getNetworkIdsForIntent(state.triggerIntentId));
366
+ const activeOwnerNetworkIds = new Set(state.userNetworks);
367
+ targetIndexIds = targetIndexIds.filter((networkId) => assignedNetworkIds.has(networkId) && activeOwnerNetworkIds.has(networkId));
368
+ scopeLog.verbose('Applied trigger-intent network intersection', {
369
+ triggerIntentId: state.triggerIntentId,
370
+ assignedCount: assignedNetworkIds.size,
371
+ targetCount: targetIndexIds.length,
372
+ });
373
+ }
353
374
  // Fetch index details
354
375
  const targetNetworks = await Promise.all(targetIndexIds.map(async (networkId) => {
355
376
  const index = await this.database.getNetwork(networkId);
@@ -483,6 +504,19 @@ export class OpportunityGraphFactory {
483
504
  try {
484
505
  let resolvedIntentId;
485
506
  if (state.triggerIntentId) {
507
+ const isOwnedActiveIntent = state.indexedIntents.some((intent) => intent.intentId === state.triggerIntentId);
508
+ if (!isOwnedActiveIntent) {
509
+ resolveLog.warn('Trigger intent is not an active intent owned by the discovery user', {
510
+ triggerIntentId: state.triggerIntentId,
511
+ userId: state.userId,
512
+ });
513
+ return {
514
+ resolvedTriggerIntentId: undefined,
515
+ resolvedIntentInIndex: false,
516
+ discoverySource: 'context',
517
+ error: 'Trigger intent is not available for discovery.',
518
+ };
519
+ }
486
520
  const inNetwork = await this.database.getNetworkIdsForIntent(state.triggerIntentId);
487
521
  const inTarget = inNetwork.some((id) => targetIndexIds.includes(id));
488
522
  resolvedIntentId = state.triggerIntentId;
@@ -961,7 +995,8 @@ export class OpportunityGraphFactory {
961
995
  const contextCandidates = [];
962
996
  for (const ctx of state.sourceContexts.filter(c => targetNetworkIds.includes(c.networkId))) {
963
997
  // Attempt HyDE-enhanced search first
964
- const hydeDocs = await self.database.getHydeDocumentsForSource('context', ctx.contextId);
998
+ const persistedHydeDocs = await self.database.getHydeDocumentsForSource('context', ctx.contextId);
999
+ const hydeDocs = selectHydeDocumentsForGeneration(persistedHydeDocs, getHydeGenerationMode(), ctx.text);
965
1000
  const lensEmbeddings = hydeDocs
966
1001
  .filter(d => d.hydeEmbedding?.length > 0)
967
1002
  .map(d => ({
@@ -1319,6 +1354,38 @@ export class OpportunityGraphFactory {
1319
1354
  const dedupedCandidates = Array.from(bestByUser.values());
1320
1355
  // Re-sort by similarity descending (Map iteration order doesn't guarantee sort)
1321
1356
  dedupedCandidates.sort((a, b) => b.similarity - a.similarity);
1357
+ const discoveryUserId = state.onBehalfOfUserId ?? state.userId;
1358
+ let eligibleCandidates;
1359
+ try {
1360
+ const requestedPairs = dedupedCandidates.flatMap((candidate) => [
1361
+ { userId: discoveryUserId, networkId: candidate.networkId },
1362
+ { userId: candidate.candidateUserId, networkId: candidate.networkId },
1363
+ ]);
1364
+ const activePairs = await this.database.getActiveNetworkMembershipPairs(requestedPairs);
1365
+ const activePairKeys = new Set(activePairs.map((pair) => networkMembershipPairKey(pair.userId, pair.networkId)));
1366
+ eligibleCandidates = dedupedCandidates.filter((candidate) => activePairKeys.has(networkMembershipPairKey(discoveryUserId, candidate.networkId))
1367
+ && activePairKeys.has(networkMembershipPairKey(candidate.candidateUserId, candidate.networkId)));
1368
+ }
1369
+ catch (error) {
1370
+ evaluationLog.error('Active network membership recheck failed; skipping evaluation', { error });
1371
+ return {
1372
+ candidates: [],
1373
+ evaluatedOpportunities: [],
1374
+ remainingCandidates: [],
1375
+ error: 'Failed to validate candidate network memberships.',
1376
+ agentTimings: [],
1377
+ };
1378
+ }
1379
+ if (eligibleCandidates.length < dedupedCandidates.length) {
1380
+ evaluationLog.info('Removed candidates without active network pairs before evaluation', {
1381
+ before: dedupedCandidates.length,
1382
+ after: eligibleCandidates.length,
1383
+ removed: dedupedCandidates.length - eligibleCandidates.length,
1384
+ });
1385
+ }
1386
+ if (eligibleCandidates.length === 0) {
1387
+ return { candidates: [], evaluatedOpportunities: [], remainingCandidates: [], agentTimings: [] };
1388
+ }
1322
1389
  if (dedupedCandidates.length < sortedCandidates.length) {
1323
1390
  evaluationLog.info("Deduped candidates by userId", {
1324
1391
  before: sortedCandidates.length,
@@ -1326,8 +1393,8 @@ export class OpportunityGraphFactory {
1326
1393
  removed: sortedCandidates.length - dedupedCandidates.length,
1327
1394
  });
1328
1395
  }
1329
- const batchToEvaluate = dedupedCandidates.slice(0, EVAL_BATCH_SIZE);
1330
- const remaining = dedupedCandidates.slice(EVAL_BATCH_SIZE);
1396
+ const batchToEvaluate = eligibleCandidates.slice(0, EVAL_BATCH_SIZE);
1397
+ const remaining = eligibleCandidates.slice(EVAL_BATCH_SIZE);
1331
1398
  // Early termination: if search was query-driven and no query-sourced candidates remain,
1332
1399
  // clear remaining to prevent pointless pagination through non-query leftovers
1333
1400
  const isQueryDriven = !!state.searchQuery?.trim();
@@ -1347,7 +1414,6 @@ export class OpportunityGraphFactory {
1347
1414
  }
1348
1415
  const agentTimingsAccum = [];
1349
1416
  try {
1350
- const discoveryUserId = state.onBehalfOfUserId ?? state.userId;
1351
1417
  const sourceProfile = await this.database.getProfile(discoveryUserId);
1352
1418
  const sourceEntity = {
1353
1419
  userId: discoveryUserId,
@@ -1686,6 +1752,7 @@ export class OpportunityGraphFactory {
1686
1752
  // Only pass opportunities that passed the threshold to downstream nodes
1687
1753
  const passedOpportunities = evaluatedOpportunities.filter((o) => o.score >= minScore);
1688
1754
  return {
1755
+ candidates: eligibleCandidates,
1689
1756
  evaluatedOpportunities: passedOpportunities,
1690
1757
  remainingCandidates: effectiveRemaining,
1691
1758
  trace: traceEntries,
@@ -1940,11 +2007,21 @@ export class OpportunityGraphFactory {
1940
2007
  const counterpartName = candidate.candidateUser.profile?.name ?? '';
1941
2008
  const viewerName = sourceUser.profile.name;
1942
2009
  const rawReasoning = updated.interpretation?.reasoning ?? '';
1943
- const personalizedSummary = viewerCentricCardSummary(rawReasoning, counterpartName, undefined, viewerName);
2010
+ const personalizedSummary = safeFallbackSummary(rawReasoning, {
2011
+ counterpartName,
2012
+ viewerName,
2013
+ emptyText: 'A suggested connection.',
2014
+ });
1944
2015
  traceEmitter?.({
1945
2016
  type: 'opportunity_draft_ready',
1946
2017
  opportunityId: candidate.opportunityId,
1947
- opportunity: updated,
2018
+ opportunity: {
2019
+ ...updated,
2020
+ interpretation: {
2021
+ ...updated.interpretation,
2022
+ reasoning: personalizedSummary,
2023
+ },
2024
+ },
1948
2025
  personalizedSummary,
1949
2026
  counterparty: {
1950
2027
  userId: candidate.candidateUser.id,
@@ -2347,6 +2424,77 @@ export class OpportunityGraphFactory {
2347
2424
  return { opportunities: [] };
2348
2425
  }
2349
2426
  try {
2427
+ // Recompute the authoritative owner-side scope at the final boundary.
2428
+ // The adapter receives this immutable request scope and locks current
2429
+ // memberships plus trigger-intent assignments through commit.
2430
+ const currentOwnerMemberships = await this.database.getNetworkMemberships(state.userId);
2431
+ let finalAllowedNetworkIds = currentOwnerMemberships.map((membership) => membership.networkId);
2432
+ // Only an explicit trigger intent is an authoritative network boundary.
2433
+ // Ad-hoc global discovery may heuristically resolve a matching intent
2434
+ // for ranking, but must retain its all-membership reach.
2435
+ const finalTriggerIntentId = state.triggerIntentId;
2436
+ if (finalTriggerIntentId) {
2437
+ const currentAssignments = new Set(await this.database.getNetworkIdsForIntent(finalTriggerIntentId));
2438
+ finalAllowedNetworkIds = finalAllowedNetworkIds.filter((networkId) => currentAssignments.has(networkId));
2439
+ }
2440
+ const explicitScope = state.networkId
2441
+ ? [state.networkId]
2442
+ : state.indexScope;
2443
+ if (explicitScope !== undefined) {
2444
+ const explicitlyAllowed = new Set(explicitScope);
2445
+ finalAllowedNetworkIds = finalAllowedNetworkIds.filter((networkId) => explicitlyAllowed.has(networkId));
2446
+ }
2447
+ finalAllowedNetworkIds = [...new Set(finalAllowedNetworkIds)];
2448
+ if (finalAllowedNetworkIds.length === 0) {
2449
+ persistLog.info('Skipped persistence because final discovery scope is empty', {
2450
+ userId: state.userId,
2451
+ triggerIntentId: finalTriggerIntentId,
2452
+ });
2453
+ return { opportunities: [] };
2454
+ }
2455
+ const finalAllowedNetworks = new Set(finalAllowedNetworkIds);
2456
+ const networkEligibility = {
2457
+ ownerUserId: state.userId,
2458
+ allowedNetworkIds: finalAllowedNetworkIds,
2459
+ ...(finalTriggerIntentId ? { triggerIntentId: finalTriggerIntentId } : {}),
2460
+ };
2461
+ // Recheck evaluator participants before any dedup/reactivation/write.
2462
+ // Persistence-only introducers are deliberately absent here: personal-
2463
+ // network contact discovery validates the evaluated owner/candidate
2464
+ // pairs without requiring the owner actor that is added below.
2465
+ const requestedPairs = state.evaluatedOpportunities.flatMap((evaluated) => evaluated.actors.flatMap((actor) => actor.networkId
2466
+ ? [{ userId: actor.userId, networkId: actor.networkId }]
2467
+ : []));
2468
+ const activePairs = await this.database.getActiveNetworkMembershipPairs(requestedPairs);
2469
+ const activePairKeys = new Set(activePairs.map((pair) => networkMembershipPairKey(pair.userId, pair.networkId)));
2470
+ const evaluatedToPersist = state.evaluatedOpportunities.filter((evaluated) => evaluated.actors.length > 0
2471
+ && evaluated.actors.every((actor) => actor.networkId != null
2472
+ && finalAllowedNetworks.has(actor.networkId)
2473
+ && activePairKeys.has(networkMembershipPairKey(actor.userId, actor.networkId))));
2474
+ if (evaluatedToPersist.length < state.evaluatedOpportunities.length) {
2475
+ persistLog.info('Skipped opportunities with inactive participant network pairs', {
2476
+ before: state.evaluatedOpportunities.length,
2477
+ after: evaluatedToPersist.length,
2478
+ removed: state.evaluatedOpportunities.length - evaluatedToPersist.length,
2479
+ });
2480
+ }
2481
+ if (evaluatedToPersist.length === 0)
2482
+ return { opportunities: [] };
2483
+ const updateStatusIfStillEligible = async (opportunityId, status, existingActors) => {
2484
+ // Reactivation preserves the existing opportunity row, so lock the
2485
+ // existing participant anchors rather than the evaluator's current
2486
+ // (often network-less) actor output. Introducers do not participate
2487
+ // in matching eligibility and must not suppress a valid pair.
2488
+ const anchors = existingActors.filter((actor) => actor.role !== 'introducer');
2489
+ if (anchors.length === 0 || anchors.some((actor) => !finalAllowedNetworks.has(actor.networkId))) {
2490
+ return null;
2491
+ }
2492
+ if (!this.database.updateOpportunityStatusIfNetworkEligible) {
2493
+ persistLog.error('Network-eligible status update adapter is unavailable; failing closed');
2494
+ return null;
2495
+ }
2496
+ return this.database.updateOpportunityStatusIfNetworkEligible(opportunityId, status, anchors, networkEligibility);
2497
+ };
2350
2498
  const itemsToPersist = [];
2351
2499
  const reactivatedOpportunities = [];
2352
2500
  const existingBetweenActors = [];
@@ -2374,7 +2522,7 @@ export class OpportunityGraphFactory {
2374
2522
  // counterparty, not between the introducer and the counterparty.
2375
2523
  const dedupUserId = (state.onBehalfOfUserId ?? state.userId);
2376
2524
  const uniqueCounterparts = new Set();
2377
- for (const evaluated of state.evaluatedOpportunities) {
2525
+ for (const evaluated of evaluatedToPersist) {
2378
2526
  const candidateUserId = evaluated.actors.find(a => a.userId !== dedupUserId)?.userId;
2379
2527
  if (candidateUserId)
2380
2528
  uniqueCounterparts.add(candidateUserId);
@@ -2394,7 +2542,7 @@ export class OpportunityGraphFactory {
2394
2542
  }));
2395
2543
  dedupAlreadyAccepted.push(...lookups.flat());
2396
2544
  }
2397
- for (const evaluated of state.evaluatedOpportunities) {
2545
+ for (const evaluated of evaluatedToPersist) {
2398
2546
  const indexIdForActors = state.networkId ?? evaluated.actors[0]?.networkId;
2399
2547
  let actors;
2400
2548
  let data;
@@ -2493,7 +2641,7 @@ export class OpportunityGraphFactory {
2493
2641
  if (existing.status === 'stalled' || sameIntroducer) {
2494
2642
  // Introduction path always targets 'draft' (chat-only surface) rather than using
2495
2643
  // initialStatus, because introductions are always chat-initiated, not background-discovered.
2496
- const reactivated = await this.database.updateOpportunityStatus(existing.id, 'draft');
2644
+ const reactivated = await updateStatusIfStillEligible(existing.id, 'draft', existing.actors);
2497
2645
  if (reactivated) {
2498
2646
  persistLog.verbose('Reactivated opportunity (introduction path)', {
2499
2647
  opportunityId: existing.id,
@@ -2526,7 +2674,7 @@ export class OpportunityGraphFactory {
2526
2674
  continue;
2527
2675
  }
2528
2676
  }
2529
- const reactivated = await this.database.updateOpportunityStatus(existing.id, 'draft');
2677
+ const reactivated = await updateStatusIfStillEligible(existing.id, 'draft', existing.actors);
2530
2678
  if (reactivated) {
2531
2679
  persistLog.info('Resuming orphaned negotiating opportunity (introduction path)', {
2532
2680
  opportunityId: existing.id,
@@ -2539,7 +2687,7 @@ export class OpportunityGraphFactory {
2539
2687
  }
2540
2688
  else if (existing.status === 'latent') {
2541
2689
  // Upgrade latent to draft for introduction path
2542
- const upgraded = await this.database.updateOpportunityStatus(existing.id, 'draft');
2690
+ const upgraded = await updateStatusIfStillEligible(existing.id, 'draft', existing.actors);
2543
2691
  if (upgraded) {
2544
2692
  persistLog.verbose('Upgraded latent opportunity to draft (introduction path)', {
2545
2693
  opportunityId: existing.id,
@@ -2655,7 +2803,7 @@ export class OpportunityGraphFactory {
2655
2803
  // Reactivate expired or stalled opportunities.
2656
2804
  // Stalled opportunities are reactivated regardless of age: a stalled negotiation
2657
2805
  // is still in-flight for this pair, so we resume it rather than create a parallel one.
2658
- const reactivated = await this.database.updateOpportunityStatus(existing.id, initialStatus);
2806
+ const reactivated = await updateStatusIfStillEligible(existing.id, initialStatus, existing.actors);
2659
2807
  if (reactivated) {
2660
2808
  persistLog.verbose('Reactivated opportunity', {
2661
2809
  opportunityId: existing.id,
@@ -2691,7 +2839,7 @@ export class OpportunityGraphFactory {
2691
2839
  }
2692
2840
  }
2693
2841
  // Task is stale or missing — reactivate the orphaned negotiating opportunity
2694
- const reactivated = await this.database.updateOpportunityStatus(existing.id, initialStatus);
2842
+ const reactivated = await updateStatusIfStillEligible(existing.id, initialStatus, existing.actors);
2695
2843
  if (reactivated) {
2696
2844
  persistLog.info('Resuming orphaned negotiating opportunity', {
2697
2845
  opportunityId: existing.id,
@@ -2704,7 +2852,7 @@ export class OpportunityGraphFactory {
2704
2852
  }
2705
2853
  else if (existing.status === 'latent' && initialStatus !== 'latent') {
2706
2854
  // Upgrade latent (background-discovered) to the higher-priority status (e.g. pending)
2707
- const upgraded = await this.database.updateOpportunityStatus(existing.id, initialStatus);
2855
+ const upgraded = await updateStatusIfStillEligible(existing.id, initialStatus, existing.actors);
2708
2856
  if (upgraded) {
2709
2857
  persistLog.verbose('Upgraded latent opportunity to higher-priority status', {
2710
2858
  opportunityId: existing.id,
@@ -2774,6 +2922,13 @@ export class OpportunityGraphFactory {
2774
2922
  },
2775
2923
  };
2776
2924
  }
2925
+ if (hasUnsupportedOpportunityClaim(data.interpretation.reasoning)) {
2926
+ persistLog.warn('Skipping opportunity with unsupported affiliation/presence claim at persistence boundary', {
2927
+ source: data.detection.source,
2928
+ triggerIntentId: data.detection.triggeredBy,
2929
+ });
2930
+ continue;
2931
+ }
2777
2932
  try {
2778
2933
  validateOpportunityActors(data.actors);
2779
2934
  }
@@ -2841,6 +2996,7 @@ export class OpportunityGraphFactory {
2841
2996
  database: this.database,
2842
2997
  embedder: this.embedder,
2843
2998
  items: itemsForPersistence,
2999
+ networkEligibility,
2844
3000
  });
2845
3001
  const allOpportunities = [...reactivatedOpportunities, ...createdList];
2846
3002
  persistLog.verbose('Persistence complete', {
@@ -2990,7 +3146,10 @@ export class OpportunityGraphFactory {
2990
3146
  indexName: indexRecord?.title ?? (actorIndexId ?? ''),
2991
3147
  connectedWith,
2992
3148
  suggestedBy,
2993
- reasoning: opp.interpretation?.reasoning ?? 'Connection opportunity',
3149
+ reasoning: safeFallbackSummary(opp.interpretation?.reasoning, {
3150
+ counterpartName: connectedWith.join(' and '),
3151
+ emptyText: 'Connection opportunity',
3152
+ }),
2994
3153
  status: opp.status,
2995
3154
  category,
2996
3155
  confidence: confidence != null ? confidence : null,