@indexnetwork/protocol 4.16.0-rc.358.1 → 4.17.0-rc.360.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 (66) hide show
  1. package/CHANGELOG.md +8 -1
  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 +3 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -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 +23 -0
  37. package/dist/opportunity/opportunity.graph.d.ts.map +1 -1
  38. package/dist/opportunity/opportunity.graph.js +172 -17
  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.tools.d.ts +1 -17
  60. package/dist/opportunity/opportunity.tools.d.ts.map +1 -1
  61. package/dist/opportunity/opportunity.tools.js +32 -2
  62. package/dist/opportunity/opportunity.tools.js.map +1 -1
  63. package/dist/shared/interfaces/database.interface.d.ts +46 -2
  64. package/dist/shared/interfaces/database.interface.d.ts.map +1 -1
  65. package/dist/shared/interfaces/database.interface.js.map +1 -1
  66. package/package.json +1 -1
@@ -20,7 +20,8 @@ import { getModelName } from '../shared/agent/model.config.js';
20
20
  import { selectHydeDocumentsForGeneration } from '../shared/hyde/hyde.documents.js';
21
21
  import { getHydeGenerationMode } from '../shared/hyde/hyde.env.js';
22
22
  import { validateOpportunityActors } from './opportunity.utils.js';
23
- import { viewerCentricCardSummary } from './opportunity.presentation.js';
23
+ import { safeFallbackSummary } from './opportunity.safe-presentation.js';
24
+ import { hasUnsupportedOpportunityClaim } from './opportunity.claim-safety.js';
24
25
  import { persistOpportunities } from './opportunity.persist.js';
25
26
  import { INTRODUCER_DISCOVERY_SOURCE } from './opportunity.introducer.js';
26
27
  import { negotiateCandidates } from "../negotiation/negotiation.graph.js";
@@ -54,6 +55,9 @@ const routingLog = protocolLogger('OpportunityGraph:Routing');
54
55
  const DEDUP_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
55
56
  /** Default cap for source premises used by premise-to-premise discovery. Prevents BACKEND-5-style fan-out. */
56
57
  const DEFAULT_SOURCE_PREMISE_DISCOVERY_LIMIT = 40;
58
+ function networkMembershipPairKey(userId, networkId) {
59
+ return `${userId}\u0000${networkId}`;
60
+ }
57
61
  /** Per-source cap for candidate premise matches. */
58
62
  const PREMISE_MATCH_LIMIT_PER_SOURCE = 20;
59
63
  /** Resolve the source premise discovery cap from env, preserving 0 as an explicit disable switch. */
@@ -337,10 +341,11 @@ export class OpportunityGraphFactory {
337
341
  }
338
342
  targetIndexIds = [state.networkId];
339
343
  }
340
- else if (state.indexScope && state.indexScope.length > 0) {
344
+ else if (state.indexScope !== undefined) {
341
345
  // Bounded scope (e.g. a network-scoped agent's reachable networks):
342
346
  // intersect with the user's actual memberships so discovery never
343
- // 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.
344
349
  const allowed = new Set(state.indexScope);
345
350
  targetIndexIds = state.userNetworks.filter((n) => allowed.has(n));
346
351
  scopeLog.verbose('Applied indexScope intersection', {
@@ -353,6 +358,19 @@ export class OpportunityGraphFactory {
353
358
  // Search all user's indexes
354
359
  targetIndexIds = state.userNetworks;
355
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
+ }
356
374
  // Fetch index details
357
375
  const targetNetworks = await Promise.all(targetIndexIds.map(async (networkId) => {
358
376
  const index = await this.database.getNetwork(networkId);
@@ -486,6 +504,19 @@ export class OpportunityGraphFactory {
486
504
  try {
487
505
  let resolvedIntentId;
488
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
+ }
489
520
  const inNetwork = await this.database.getNetworkIdsForIntent(state.triggerIntentId);
490
521
  const inTarget = inNetwork.some((id) => targetIndexIds.includes(id));
491
522
  resolvedIntentId = state.triggerIntentId;
@@ -1323,6 +1354,38 @@ export class OpportunityGraphFactory {
1323
1354
  const dedupedCandidates = Array.from(bestByUser.values());
1324
1355
  // Re-sort by similarity descending (Map iteration order doesn't guarantee sort)
1325
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
+ }
1326
1389
  if (dedupedCandidates.length < sortedCandidates.length) {
1327
1390
  evaluationLog.info("Deduped candidates by userId", {
1328
1391
  before: sortedCandidates.length,
@@ -1330,8 +1393,8 @@ export class OpportunityGraphFactory {
1330
1393
  removed: sortedCandidates.length - dedupedCandidates.length,
1331
1394
  });
1332
1395
  }
1333
- const batchToEvaluate = dedupedCandidates.slice(0, EVAL_BATCH_SIZE);
1334
- const remaining = dedupedCandidates.slice(EVAL_BATCH_SIZE);
1396
+ const batchToEvaluate = eligibleCandidates.slice(0, EVAL_BATCH_SIZE);
1397
+ const remaining = eligibleCandidates.slice(EVAL_BATCH_SIZE);
1335
1398
  // Early termination: if search was query-driven and no query-sourced candidates remain,
1336
1399
  // clear remaining to prevent pointless pagination through non-query leftovers
1337
1400
  const isQueryDriven = !!state.searchQuery?.trim();
@@ -1351,7 +1414,6 @@ export class OpportunityGraphFactory {
1351
1414
  }
1352
1415
  const agentTimingsAccum = [];
1353
1416
  try {
1354
- const discoveryUserId = state.onBehalfOfUserId ?? state.userId;
1355
1417
  const sourceProfile = await this.database.getProfile(discoveryUserId);
1356
1418
  const sourceEntity = {
1357
1419
  userId: discoveryUserId,
@@ -1690,6 +1752,7 @@ export class OpportunityGraphFactory {
1690
1752
  // Only pass opportunities that passed the threshold to downstream nodes
1691
1753
  const passedOpportunities = evaluatedOpportunities.filter((o) => o.score >= minScore);
1692
1754
  return {
1755
+ candidates: eligibleCandidates,
1693
1756
  evaluatedOpportunities: passedOpportunities,
1694
1757
  remainingCandidates: effectiveRemaining,
1695
1758
  trace: traceEntries,
@@ -1944,11 +2007,21 @@ export class OpportunityGraphFactory {
1944
2007
  const counterpartName = candidate.candidateUser.profile?.name ?? '';
1945
2008
  const viewerName = sourceUser.profile.name;
1946
2009
  const rawReasoning = updated.interpretation?.reasoning ?? '';
1947
- const personalizedSummary = viewerCentricCardSummary(rawReasoning, counterpartName, undefined, viewerName);
2010
+ const personalizedSummary = safeFallbackSummary(rawReasoning, {
2011
+ counterpartName,
2012
+ viewerName,
2013
+ emptyText: 'A suggested connection.',
2014
+ });
1948
2015
  traceEmitter?.({
1949
2016
  type: 'opportunity_draft_ready',
1950
2017
  opportunityId: candidate.opportunityId,
1951
- opportunity: updated,
2018
+ opportunity: {
2019
+ ...updated,
2020
+ interpretation: {
2021
+ ...updated.interpretation,
2022
+ reasoning: personalizedSummary,
2023
+ },
2024
+ },
1952
2025
  personalizedSummary,
1953
2026
  counterparty: {
1954
2027
  userId: candidate.candidateUser.id,
@@ -2351,6 +2424,77 @@ export class OpportunityGraphFactory {
2351
2424
  return { opportunities: [] };
2352
2425
  }
2353
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
+ };
2354
2498
  const itemsToPersist = [];
2355
2499
  const reactivatedOpportunities = [];
2356
2500
  const existingBetweenActors = [];
@@ -2378,7 +2522,7 @@ export class OpportunityGraphFactory {
2378
2522
  // counterparty, not between the introducer and the counterparty.
2379
2523
  const dedupUserId = (state.onBehalfOfUserId ?? state.userId);
2380
2524
  const uniqueCounterparts = new Set();
2381
- for (const evaluated of state.evaluatedOpportunities) {
2525
+ for (const evaluated of evaluatedToPersist) {
2382
2526
  const candidateUserId = evaluated.actors.find(a => a.userId !== dedupUserId)?.userId;
2383
2527
  if (candidateUserId)
2384
2528
  uniqueCounterparts.add(candidateUserId);
@@ -2398,7 +2542,7 @@ export class OpportunityGraphFactory {
2398
2542
  }));
2399
2543
  dedupAlreadyAccepted.push(...lookups.flat());
2400
2544
  }
2401
- for (const evaluated of state.evaluatedOpportunities) {
2545
+ for (const evaluated of evaluatedToPersist) {
2402
2546
  const indexIdForActors = state.networkId ?? evaluated.actors[0]?.networkId;
2403
2547
  let actors;
2404
2548
  let data;
@@ -2497,7 +2641,7 @@ export class OpportunityGraphFactory {
2497
2641
  if (existing.status === 'stalled' || sameIntroducer) {
2498
2642
  // Introduction path always targets 'draft' (chat-only surface) rather than using
2499
2643
  // initialStatus, because introductions are always chat-initiated, not background-discovered.
2500
- const reactivated = await this.database.updateOpportunityStatus(existing.id, 'draft');
2644
+ const reactivated = await updateStatusIfStillEligible(existing.id, 'draft', existing.actors);
2501
2645
  if (reactivated) {
2502
2646
  persistLog.verbose('Reactivated opportunity (introduction path)', {
2503
2647
  opportunityId: existing.id,
@@ -2530,7 +2674,7 @@ export class OpportunityGraphFactory {
2530
2674
  continue;
2531
2675
  }
2532
2676
  }
2533
- const reactivated = await this.database.updateOpportunityStatus(existing.id, 'draft');
2677
+ const reactivated = await updateStatusIfStillEligible(existing.id, 'draft', existing.actors);
2534
2678
  if (reactivated) {
2535
2679
  persistLog.info('Resuming orphaned negotiating opportunity (introduction path)', {
2536
2680
  opportunityId: existing.id,
@@ -2543,7 +2687,7 @@ export class OpportunityGraphFactory {
2543
2687
  }
2544
2688
  else if (existing.status === 'latent') {
2545
2689
  // Upgrade latent to draft for introduction path
2546
- const upgraded = await this.database.updateOpportunityStatus(existing.id, 'draft');
2690
+ const upgraded = await updateStatusIfStillEligible(existing.id, 'draft', existing.actors);
2547
2691
  if (upgraded) {
2548
2692
  persistLog.verbose('Upgraded latent opportunity to draft (introduction path)', {
2549
2693
  opportunityId: existing.id,
@@ -2659,7 +2803,7 @@ export class OpportunityGraphFactory {
2659
2803
  // Reactivate expired or stalled opportunities.
2660
2804
  // Stalled opportunities are reactivated regardless of age: a stalled negotiation
2661
2805
  // is still in-flight for this pair, so we resume it rather than create a parallel one.
2662
- const reactivated = await this.database.updateOpportunityStatus(existing.id, initialStatus);
2806
+ const reactivated = await updateStatusIfStillEligible(existing.id, initialStatus, existing.actors);
2663
2807
  if (reactivated) {
2664
2808
  persistLog.verbose('Reactivated opportunity', {
2665
2809
  opportunityId: existing.id,
@@ -2695,7 +2839,7 @@ export class OpportunityGraphFactory {
2695
2839
  }
2696
2840
  }
2697
2841
  // Task is stale or missing — reactivate the orphaned negotiating opportunity
2698
- const reactivated = await this.database.updateOpportunityStatus(existing.id, initialStatus);
2842
+ const reactivated = await updateStatusIfStillEligible(existing.id, initialStatus, existing.actors);
2699
2843
  if (reactivated) {
2700
2844
  persistLog.info('Resuming orphaned negotiating opportunity', {
2701
2845
  opportunityId: existing.id,
@@ -2708,7 +2852,7 @@ export class OpportunityGraphFactory {
2708
2852
  }
2709
2853
  else if (existing.status === 'latent' && initialStatus !== 'latent') {
2710
2854
  // Upgrade latent (background-discovered) to the higher-priority status (e.g. pending)
2711
- const upgraded = await this.database.updateOpportunityStatus(existing.id, initialStatus);
2855
+ const upgraded = await updateStatusIfStillEligible(existing.id, initialStatus, existing.actors);
2712
2856
  if (upgraded) {
2713
2857
  persistLog.verbose('Upgraded latent opportunity to higher-priority status', {
2714
2858
  opportunityId: existing.id,
@@ -2778,6 +2922,13 @@ export class OpportunityGraphFactory {
2778
2922
  },
2779
2923
  };
2780
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
+ }
2781
2932
  try {
2782
2933
  validateOpportunityActors(data.actors);
2783
2934
  }
@@ -2845,6 +2996,7 @@ export class OpportunityGraphFactory {
2845
2996
  database: this.database,
2846
2997
  embedder: this.embedder,
2847
2998
  items: itemsForPersistence,
2999
+ networkEligibility,
2848
3000
  });
2849
3001
  const allOpportunities = [...reactivatedOpportunities, ...createdList];
2850
3002
  persistLog.verbose('Persistence complete', {
@@ -2994,7 +3146,10 @@ export class OpportunityGraphFactory {
2994
3146
  indexName: indexRecord?.title ?? (actorIndexId ?? ''),
2995
3147
  connectedWith,
2996
3148
  suggestedBy,
2997
- reasoning: opp.interpretation?.reasoning ?? 'Connection opportunity',
3149
+ reasoning: safeFallbackSummary(opp.interpretation?.reasoning, {
3150
+ counterpartName: connectedWith.join(' and '),
3151
+ emptyText: 'Connection opportunity',
3152
+ }),
2998
3153
  status: opp.status,
2999
3154
  category,
3000
3155
  confidence: confidence != null ? confidence : null,