@indexnetwork/protocol 6.8.0-rc.395.1 → 6.10.0-rc.397.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 (61) hide show
  1. package/CHANGELOG.md +1 -0
  2. package/dist/index.d.ts +8 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/negotiation/negotiation.agent.d.ts +3 -1
  7. package/dist/negotiation/negotiation.agent.d.ts.map +1 -1
  8. package/dist/negotiation/negotiation.agent.js +4 -1
  9. package/dist/negotiation/negotiation.agent.js.map +1 -1
  10. package/dist/negotiation/negotiation.consultation-policy.d.ts +40 -0
  11. package/dist/negotiation/negotiation.consultation-policy.d.ts.map +1 -0
  12. package/dist/negotiation/negotiation.consultation-policy.js +69 -0
  13. package/dist/negotiation/negotiation.consultation-policy.js.map +1 -0
  14. package/dist/negotiation/negotiation.graph.d.ts +86 -28
  15. package/dist/negotiation/negotiation.graph.d.ts.map +1 -1
  16. package/dist/negotiation/negotiation.graph.js +311 -81
  17. package/dist/negotiation/negotiation.graph.js.map +1 -1
  18. package/dist/negotiation/negotiation.question-safety.d.ts +26 -0
  19. package/dist/negotiation/negotiation.question-safety.d.ts.map +1 -0
  20. package/dist/negotiation/negotiation.question-safety.js +56 -0
  21. package/dist/negotiation/negotiation.question-safety.js.map +1 -0
  22. package/dist/negotiation/negotiation.state.d.ts +25 -2
  23. package/dist/negotiation/negotiation.state.d.ts.map +1 -1
  24. package/dist/negotiation/negotiation.state.js +35 -0
  25. package/dist/negotiation/negotiation.state.js.map +1 -1
  26. package/dist/opportunity/opportunity.graph.d.ts +12 -1
  27. package/dist/opportunity/opportunity.graph.d.ts.map +1 -1
  28. package/dist/opportunity/opportunity.graph.js +43 -5
  29. package/dist/opportunity/opportunity.graph.js.map +1 -1
  30. package/dist/opportunity/opportunity.state.d.ts +5 -2
  31. package/dist/opportunity/opportunity.state.d.ts.map +1 -1
  32. package/dist/opportunity/opportunity.state.js +5 -0
  33. package/dist/opportunity/opportunity.state.js.map +1 -1
  34. package/dist/questioner/questioner.agent.d.ts +1 -1
  35. package/dist/questioner/questioner.agent.d.ts.map +1 -1
  36. package/dist/questioner/questioner.agent.js +5 -0
  37. package/dist/questioner/questioner.agent.js.map +1 -1
  38. package/dist/questioner/questioner.presets.d.ts.map +1 -1
  39. package/dist/questioner/questioner.presets.js +9 -13
  40. package/dist/questioner/questioner.presets.js.map +1 -1
  41. package/dist/questioner/questioner.types.d.ts +47 -10
  42. package/dist/questioner/questioner.types.d.ts.map +1 -1
  43. package/dist/questioner/questioner.types.js +61 -1
  44. package/dist/questioner/questioner.types.js.map +1 -1
  45. package/dist/shared/agent/tool.factory.d.ts.map +1 -1
  46. package/dist/shared/agent/tool.factory.js +1 -0
  47. package/dist/shared/agent/tool.factory.js.map +1 -1
  48. package/dist/shared/interfaces/agent-dispatcher.interface.d.ts +3 -0
  49. package/dist/shared/interfaces/agent-dispatcher.interface.d.ts.map +1 -1
  50. package/dist/shared/interfaces/agent-dispatcher.interface.js.map +1 -1
  51. package/dist/shared/interfaces/database.interface.d.ts +81 -6
  52. package/dist/shared/interfaces/database.interface.d.ts.map +1 -1
  53. package/dist/shared/interfaces/database.interface.js.map +1 -1
  54. package/dist/shared/interfaces/negotiation-events.interface.d.ts +14 -2
  55. package/dist/shared/interfaces/negotiation-events.interface.d.ts.map +1 -1
  56. package/dist/shared/interfaces/negotiation-events.interface.js.map +1 -1
  57. package/dist/shared/schemas/question.schema.d.ts +347 -28
  58. package/dist/shared/schemas/question.schema.d.ts.map +1 -1
  59. package/dist/shared/schemas/question.schema.js +132 -2
  60. package/dist/shared/schemas/question.schema.js.map +1 -1
  61. package/package.json +1 -1
@@ -4,9 +4,11 @@ import { requestContext } from "../shared/observability/request-context.js";
4
4
  import { NegotiationGraphState } from "./negotiation.state.js";
5
5
  import { IndexNegotiator } from "./negotiation.agent.js";
6
6
  import { ASK_USER_LOCK_SLACK_MS, allowedActionsFor, askUserAnswerWindowMs, configuredAskUserEnabled, configuredProtocolVersion, fallbackActionFor, isRejectLikeAction, isTerminalAction, readProtocolVersion, rejectActionFor } from "./negotiation.protocol.js";
7
+ import { assessConsultationEligibility, consultationPromptFor, negotiationConsultationPolicyMode } from "./negotiation.consultation-policy.js";
7
8
  import { NegotiationScreener, configuredScreenMode } from "./negotiation.screen.js";
8
9
  import { assessDeadlock, configuredDeadlockShiftEnabled, configuredDeadlockThreshold } from "./negotiation.deadlock.js";
9
10
  import { protocolLogger } from "../shared/observability/protocol.logger.js";
11
+ import { NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY, NEGOTIATION_QUESTION_GENERIC_NETWORK, negotiationQuestionSettlementId, validateInflightAskUserFields } from './negotiation.question-safety.js';
10
12
  const logger = protocolLogger("NegotiationGraph");
11
13
  const initLog = protocolLogger("NegotiationGraph:Init");
12
14
  const screenNodeLog = protocolLogger("NegotiationGraph:Screen");
@@ -108,10 +110,14 @@ export class NegotiationGraphFactory {
108
110
  ].filter(Boolean).join("\n");
109
111
  const initNode = async (state) => {
110
112
  try {
111
- // Find-or-create the DM conversation for this agent pair (same as user DMs)
113
+ // Exact continuations reuse the prior conversation and preclaimed
114
+ // successor; they must not create any mutable state before the fence.
112
115
  const agentIdA = `agent:${state.sourceUser.id}`;
113
116
  const agentIdB = `agent:${state.candidateUser.id}`;
114
- const conversation = await database.getOrCreateDM(agentIdA, agentIdB, 'agent');
117
+ const execution = state.continuationExecution;
118
+ const conversation = execution
119
+ ? { id: execution.conversationId }
120
+ : await database.getOrCreateDM(agentIdA, agentIdB, 'agent');
115
121
  // --- Lock gate: check for an active task on this conversation ---
116
122
  const priorMessages = await database.getMessagesForConversation(conversation.id);
117
123
  const activeStates = ['submitted', 'working', 'input_required', 'waiting_for_agent', 'claimed'];
@@ -128,10 +134,37 @@ export class NegotiationGraphFactory {
128
134
  : 5 * 60 * 1000;
129
135
  return (Date.now() - new Date(t.updatedAt).getTime()) < freshnessMs;
130
136
  };
131
- const priorTask = state.opportunityId
132
- ? await database.getNegotiationTaskForOpportunity(state.opportunityId)
137
+ if (Boolean(state.resumeFromTaskId) !== Boolean(state.continuationSettlementId)
138
+ || Boolean(state.resumeFromTaskId) !== Boolean(execution))
139
+ return { error: 'invalid continuation correlation' };
140
+ const exactContinuation = state.resumeFromTaskId && state.continuationSettlementId && execution
141
+ ? { taskId: state.resumeFromTaskId, settlementId: state.continuationSettlementId, execution }
133
142
  : null;
134
- const isLocked = !!priorTask && isActiveAndFresh(priorTask);
143
+ const priorTask = exactContinuation
144
+ ? await database.getTask(exactContinuation.taskId)
145
+ : state.opportunityId
146
+ ? await database.getNegotiationTaskForOpportunity(state.opportunityId)
147
+ : null;
148
+ const claimedSuccessor = exactContinuation
149
+ ? await database.getTask(exactContinuation.execution.successorTaskId)
150
+ : null;
151
+ if (exactContinuation) {
152
+ const settlement = priorTask?.metadata?.questionSettlement;
153
+ const storedExecution = claimedSuccessor?.metadata?.continuationExecution;
154
+ if (!priorTask
155
+ || priorTask.conversationId !== conversation.id
156
+ || priorTask.state !== 'canceled'
157
+ || priorTask.metadata?.opportunityId !== state.opportunityId
158
+ || settlement?.settlementId !== exactContinuation.settlementId
159
+ || settlement?.taskId !== exactContinuation.taskId
160
+ || !claimedSuccessor
161
+ || claimedSuccessor.conversationId !== conversation.id
162
+ || storedExecution?.token !== exactContinuation.execution.token
163
+ || storedExecution?.fence !== exactContinuation.execution.fence
164
+ || storedExecution?.status !== 'claimed')
165
+ return { error: 'invalid exact continuation task' };
166
+ }
167
+ const isLocked = !exactContinuation && !!priorTask && isActiveAndFresh(priorTask);
135
168
  if (isLocked) {
136
169
  initLog.info('Conversation locked by active task, returning busy', {
137
170
  conversationId: conversation.id,
@@ -186,7 +219,7 @@ export class NegotiationGraphFactory {
186
219
  // Conversation-scoped prior task: reused for both the initiator tie-break
187
220
  // (only when active+fresh) and protocol-version inheritance (any prior
188
221
  // task on the conversation pins the version).
189
- const convTask = (!readInitiator(priorTask?.metadata) || !readProtocolVersion(priorTask?.metadata))
222
+ const convTask = !exactContinuation && (!readInitiator(priorTask?.metadata) || !readProtocolVersion(priorTask?.metadata))
190
223
  ? await database.getLatestNegotiationTaskForConversation?.(conversation.id).catch(() => null)
191
224
  : null;
192
225
  if (!readInitiator(priorTask?.metadata)) {
@@ -227,24 +260,36 @@ export class NegotiationGraphFactory {
227
260
  protocolVersion,
228
261
  candidateUserId: state.candidateUser.id,
229
262
  networkId: state.indexContext.networkId,
263
+ sourceIntentId: state.sourceIntentId,
264
+ candidateIntentId: state.candidateIntentId,
265
+ participantBindings: [
266
+ ...(state.sourceIntentId ? [{ userId: state.sourceUser.id, intentId: state.sourceIntentId, networkId: state.indexContext.networkId }] : []),
267
+ ...(state.candidateIntentId ? [{ userId: state.candidateUser.id, intentId: state.candidateIntentId, networkId: state.indexContext.networkId }] : []),
268
+ ],
230
269
  intentSnapshots: buildIntentSnapshots(state.sourceUser, state.candidateUser),
231
270
  ...(state.opportunityId && { opportunityId: state.opportunityId }),
232
271
  maxTurns,
233
272
  isContinuation,
234
273
  priorTurnCount: priorTurns.length,
274
+ ...(exactContinuation ? {
275
+ resumeFromTaskId: exactContinuation.taskId,
276
+ continuationSettlementId: exactContinuation.settlementId,
277
+ } : {}),
235
278
  };
236
279
  if (state.opportunityId && Boolean(state.opportunityStatus) !== Boolean(state.opportunityUpdatedAt)) {
237
280
  throw new Error('Negotiation attempt requires both opportunity status and updatedAt');
238
281
  }
239
- const task = state.opportunityId && state.opportunityStatus && state.opportunityUpdatedAt
240
- ? await database.createNegotiationTaskForAttempt({
241
- conversationId: conversation.id,
242
- opportunityId: state.opportunityId,
243
- expectedStatus: state.opportunityStatus,
244
- expectedUpdatedAt: state.opportunityUpdatedAt,
245
- metadata: taskMetadata,
246
- })
247
- : await database.createTask(conversation.id, taskMetadata);
282
+ const task = exactContinuation
283
+ ? claimedSuccessor
284
+ : state.opportunityId && state.opportunityStatus && state.opportunityUpdatedAt
285
+ ? await database.createNegotiationTaskForAttempt({
286
+ conversationId: conversation.id,
287
+ opportunityId: state.opportunityId,
288
+ expectedStatus: state.opportunityStatus,
289
+ expectedUpdatedAt: state.opportunityUpdatedAt,
290
+ metadata: taskMetadata,
291
+ })
292
+ : await database.createTask(conversation.id, taskMetadata);
248
293
  if (!task) {
249
294
  throw new Error('Negotiation attempt is stale or already claimed');
250
295
  }
@@ -282,6 +327,14 @@ export class NegotiationGraphFactory {
282
327
  protocolVersion,
283
328
  priorTurnCount: priorTurns.length,
284
329
  ...(userAnswers.length > 0 && { userAnswers }),
330
+ ...(exactContinuation?.execution.consultation
331
+ ? { privateConsultation: exactContinuation.execution.consultation }
332
+ : {}),
333
+ ...(exactContinuation && (() => {
334
+ const turnContext = priorTask?.metadata?.turnContext;
335
+ const reason = turnContext?.consultationPolicyReason;
336
+ return typeof reason === 'string' ? { consultationPolicyReason: reason } : {};
337
+ })()),
285
338
  ...(seedMessages.length > 0 && { messages: seedMessages }),
286
339
  };
287
340
  }
@@ -360,7 +413,7 @@ export class NegotiationGraphFactory {
360
413
  screenedAt: new Date().toISOString(),
361
414
  durationMs,
362
415
  };
363
- await database.setTaskScreenDecision?.(state.taskId, record).catch((err) => {
416
+ await database.setTaskScreenDecision?.(state.taskId, record, state.continuationExecution).catch((err) => {
364
417
  screenNodeLog.error("Failed to persist screen decision", { taskId: state.taskId, error: err });
365
418
  });
366
419
  screenNodeLog.info("negotiation_screen", {
@@ -410,6 +463,7 @@ export class NegotiationGraphFactory {
410
463
  const isSource = state.currentSpeaker === "source";
411
464
  const ownUser = isSource ? state.sourceUser : state.candidateUser;
412
465
  const otherUser = isSource ? state.candidateUser : state.sourceUser;
466
+ const ownIntentId = isSource ? state.sourceIntentId : state.candidateIntentId;
413
467
  // Determine if this is the system agent's final allowed turn
414
468
  const maxTurns = state.maxTurns ?? 0;
415
469
  const isFinalTurn = maxTurns > 0 && (state.turnCount + 1) >= maxTurns;
@@ -420,19 +474,20 @@ export class NegotiationGraphFactory {
420
474
  const seat = ownUser.id === (state.initiatorUserId ?? state.sourceUser.id)
421
475
  ? 'initiator'
422
476
  : 'counterparty';
423
- // ask_user availability (P3.2): flag on, full pause loop wired
477
+ // Legacy ask_user availability (P3.2): flag on, full pause loop wired
424
478
  // (questioner + answer-window timer + an opportunity to resume
425
479
  // against), v2 non-final non-opening turn, and this side's one client
426
- // consultation not yet spent (rationing). Chat-triggered runs get no
427
- // special casing the pause exits the graph at the turn boundary, so
428
- // the stream never blocks on a question; the resume is always an async
429
- // continuation.
480
+ // consultation not yet spent (rationing). Shadow is observational and
481
+ // must preserve this legacy path byte-for-byte except for telemetry.
482
+ const policyMode = negotiationConsultationPolicyMode();
430
483
  const askUserAvailable = version === 'v2'
431
484
  && !isFinalTurn
432
485
  && configuredAskUserEnabled()
433
486
  && !!questionerEnqueue
434
487
  && !!timeoutQueue?.enqueueAskUserExpiry
435
488
  && !!state.opportunityId
489
+ && !!ownIntentId
490
+ && !!state.indexContext.networkId
436
491
  && !(state.turnCount === 0 && !state.isContinuation)
437
492
  && !hasPriorAskUser(state.messages, ownUser.id);
438
493
  // ─── Deadlock detection → persuasion→bargaining stance (IND-428) ──────
@@ -479,6 +534,9 @@ export class NegotiationGraphFactory {
479
534
  allowedActions: [...allowedActionsFor(version, seat, isFinalTurn, { askUser: askUserAvailable })],
480
535
  ...(state.discoveryQuery && isSource && { discoveryQuery: state.discoveryQuery }),
481
536
  ...(ownMemory.length > 0 && { negotiatorMemory: ownMemory }),
537
+ ...(state.privateConsultation?.recipientUserId === ownUser.id
538
+ ? { privateConsultation: state.privateConsultation }
539
+ : {}),
482
540
  };
483
541
  const scope = { action: 'manage:negotiations', scopeType: 'network', scopeId: state.indexContext.networkId };
484
542
  const dispatchResult = await dispatcher.dispatch(ownUser.id, scope, payload, { timeoutMs: state.timeoutMs });
@@ -512,8 +570,11 @@ export class NegotiationGraphFactory {
512
570
  // candidate-side turns would make the pickup prompt frame the
513
571
  // search as "your user searched for X" for the wrong user.
514
572
  ...(isSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
515
- });
516
- await database.updateTaskState(state.taskId, "waiting_for_agent");
573
+ ...(state.privateConsultation && state.privateConsultation.recipientUserId === ownUser.id
574
+ ? { privateConsultation: state.privateConsultation }
575
+ : {}),
576
+ }, state.continuationExecution);
577
+ await database.updateTaskState(state.taskId, "waiting_for_agent", undefined, state.continuationExecution);
517
578
  return { status: 'waiting_for_agent' };
518
579
  }
519
580
  else {
@@ -534,6 +595,9 @@ export class NegotiationGraphFactory {
534
595
  ...(askUserAvailable && { canAskUser: true }),
535
596
  ...(bargainingMode && { bargaining: { consecutiveNonConvergent: deadlock.consecutiveNonConvergent } }),
536
597
  ...(ownMemory.length > 0 && { memory: ownMemory }),
598
+ ...(state.privateConsultation?.recipientUserId === ownUser.id
599
+ ? { privateConsultation: state.privateConsultation }
600
+ : {}),
537
601
  });
538
602
  }
539
603
  traceEmitter?.({ type: "agent_end", name: agentName, durationMs: Date.now() - agentStart, summary: `${turn.action}` });
@@ -548,11 +612,55 @@ export class NegotiationGraphFactory {
548
612
  turn.action = openingAction;
549
613
  }
550
614
  }
551
- // Safety net: an ask_user that slipped past availability gating (e.g. a
552
- // locally-dispatched agent ignoring allowedActions, or rationing already
553
- // spent) is coerced to the conservative fallback BEFORE persisting — a
554
- // pause we cannot resume must never enter the turn history.
555
- if (turn.action === 'ask_user' && !askUserAvailable) {
615
+ // IND-508 deterministic admission is evaluated only after the opening
616
+ // guard but before any turn is persisted. It consumes action/role enums
617
+ // only; free-form reasoning, messages, profiles, and evaluator inputs
618
+ // are intentionally unavailable to the policy.
619
+ let consultationPolicyReason;
620
+ const policyEligibility = policyMode === 'off' ? { eligible: false } : assessConsultationEligibility({
621
+ protocolVersion: version,
622
+ seat,
623
+ isOpeningTurn: state.turnCount === 0 && !state.isContinuation,
624
+ isFinalTurn,
625
+ screenedOut: isScreenBlocked(state),
626
+ action: turn.action,
627
+ ownSuggestedRole: turn.assessment?.suggestedRoles?.ownUser,
628
+ priorActions: history.map((prior) => prior.action),
629
+ previouslyConsulted: hasPriorAskUser(state.messages, ownUser.id),
630
+ hasExactResumeCoordinate: Boolean(configuredAskUserEnabled()
631
+ && questionerEnqueue
632
+ && timeoutQueue?.enqueueAskUserExpiry
633
+ && state.taskId
634
+ && state.opportunityId
635
+ && ownIntentId
636
+ && state.indexContext.networkId),
637
+ lifecycleValid: Boolean(state.taskId && state.opportunityId && ownIntentId && state.indexContext.networkId),
638
+ });
639
+ const emitConsultationTelemetry = (stage, reason) => {
640
+ turnLog.info('negotiation_consultation_policy', { stage, mode: policyMode, reason });
641
+ emitWide({ type: 'negotiation_consultation_policy', stage, mode: policyMode, reason });
642
+ };
643
+ if (policyEligibility.eligible && policyEligibility.reason) {
644
+ emitConsultationTelemetry('eligible', policyEligibility.reason);
645
+ if (policyMode === 'on') {
646
+ consultationPolicyReason = policyEligibility.reason;
647
+ turn = {
648
+ ...turn,
649
+ action: 'ask_user',
650
+ message: null,
651
+ assessment: {
652
+ reasoning: 'Client consultation required.',
653
+ suggestedRoles: turn.assessment.suggestedRoles,
654
+ },
655
+ askUser: consultationPromptFor(consultationPolicyReason),
656
+ };
657
+ emitConsultationTelemetry('asked', consultationPolicyReason);
658
+ }
659
+ }
660
+ // Safety net: off/shadow retain legacy behavior. In on, a spontaneous
661
+ // ask_user is admissible only when the deterministic policy just
662
+ // authorized it, so no unbounded pause can enter shared history.
663
+ if (turn.action === 'ask_user' && (!askUserAvailable || (policyMode === 'on' && !consultationPolicyReason))) {
556
664
  turnLog.warn('ask_user emitted while unavailable, coercing to conservative fallback', {
557
665
  seat, isFinalTurn, taskId: state.taskId,
558
666
  });
@@ -576,7 +684,7 @@ export class NegotiationGraphFactory {
576
684
  seat,
577
685
  detectedAt: new Date().toISOString(),
578
686
  };
579
- await database.setTaskDeadlockShift?.(state.taskId, deadlockShiftRecord).catch((err) => {
687
+ await database.setTaskDeadlockShift?.(state.taskId, deadlockShiftRecord, state.continuationExecution).catch((err) => {
580
688
  turnLog.error('Failed to persist deadlock shift record', { taskId: state.taskId, error: err });
581
689
  });
582
690
  turnLog.info('negotiation_deadlock_shift', {
@@ -606,6 +714,7 @@ export class NegotiationGraphFactory {
606
714
  role: "agent",
607
715
  parts,
608
716
  taskId: state.taskId,
717
+ ...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
609
718
  });
610
719
  // ─── ask_user pause (P3.2) ────────────────────────────────────────────
611
720
  // The negotiator consults its OWN client: persist the turn (done above),
@@ -615,50 +724,103 @@ export class NegotiationGraphFactory {
615
724
  // like the waiting_for_agent suspend; the answer (or window expiry)
616
725
  // resumes via the run-existing continuation path.
617
726
  if (turn.action === 'ask_user') {
618
- const disclosureSubject = turn.askUser?.disclosureSubject?.trim()
619
- || turn.message
620
- || turn.assessment.reasoning;
621
- const draftQuestion = turn.askUser?.draftQuestion ?? turn.message ?? undefined;
622
- await database.setTaskTurnContext(state.taskId, {
623
- sourceUser: state.sourceUser,
624
- candidateUser: state.candidateUser,
625
- indexContext: state.indexContext,
626
- seedAssessment: state.seedAssessment,
627
- ...(isSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
727
+ const counterparty = isSource ? state.candidateUser : state.sourceUser;
728
+ const safeAskUser = validateInflightAskUserFields({
729
+ disclosureSubject: turn.askUser?.disclosureSubject,
730
+ draftQuestion: turn.askUser?.draftQuestion,
731
+ forbiddenIdentifiers: [
732
+ counterparty.id,
733
+ counterparty.profile.name ?? '',
734
+ state.opportunityId,
735
+ state.taskId,
736
+ state.indexContext.networkId,
737
+ isSource ? state.candidateIntentId ?? '' : state.sourceIntentId ?? '',
738
+ ],
739
+ forbiddenSourceText: [
740
+ counterparty.profile.bio ?? '',
741
+ counterparty.profile.location ?? '',
742
+ ...counterparty.intents.flatMap((intent) => [intent.title, intent.description]),
743
+ state.seedAssessment.reasoning,
744
+ state.indexContext.prompt,
745
+ state.discoveryQuery ?? '',
746
+ ],
747
+ });
748
+ const settlementId = negotiationQuestionSettlementId(state.taskId);
749
+ const askUserBinding = await database.captureNegotiationAskUserBinding({
750
+ taskId: state.taskId,
751
+ settlementId,
752
+ recipientUserId: ownUser.id,
753
+ recipientIntentId: ownIntentId,
754
+ opportunityId: state.opportunityId,
755
+ networkId: state.indexContext.networkId,
756
+ turnContext: {
757
+ sourceUser: state.sourceUser,
758
+ candidateUser: state.candidateUser,
759
+ indexContext: state.indexContext,
760
+ seedAssessment: state.seedAssessment,
761
+ ...(isSource && state.discoveryQuery && { discoveryQuery: state.discoveryQuery }),
762
+ ...(consultationPolicyReason && { consultationPolicyReason }),
763
+ },
764
+ ...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
628
765
  });
629
- // Arm the timer BEFORE flipping state: a timer against a task that
630
- // never reaches input_required no-ops harmlessly at fire time, while
631
- // an input_required task without a timer would strand until the lock
632
- // slack expires.
766
+ // Arm the timer BEFORE flipping state: it is the durable recovery
767
+ // trigger even when generation enqueues no job or persists no row.
633
768
  const windowMs = askUserAnswerWindowMs();
634
769
  await timeoutQueue.enqueueAskUserExpiry(state.taskId, {
770
+ settlementId,
635
771
  opportunityId: state.opportunityId,
636
772
  userId: ownUser.id,
637
- disclosureSubject,
773
+ recipientIntentId: ownIntentId,
774
+ networkId: state.indexContext.networkId,
775
+ intentFingerprint: askUserBinding.intentFingerprint,
776
+ opportunityStatus: askUserBinding.opportunityStatus,
777
+ opportunityUpdatedAt: askUserBinding.opportunityUpdatedAt,
778
+ counterpartyUserId: askUserBinding.counterpartyUserId,
779
+ counterpartyIntentId: askUserBinding.counterpartyIntentId,
638
780
  }, windowMs);
639
- // Counterparty referenced by attributes, never identity the
640
- // negotiation_inflight preset's referential-closure contract.
641
- const counterpartyHint = [
642
- otherUser.profile.bio,
643
- otherUser.profile.location,
644
- otherUser.profile.skills?.length ? `skills: ${otherUser.profile.skills.join(', ')}` : undefined,
645
- ].filter(Boolean).join('; ') || 'a potential match on the network';
646
- const userContext = (await database.getUserContext(ownUser.id, null).catch(() => null))?.text ?? '';
647
- await questionerEnqueue({
648
- mode: 'negotiation_inflight',
649
- userId: ownUser.id,
650
- sourceType: 'opportunity',
651
- sourceId: state.opportunityId,
652
- context: {
653
- negotiationId: state.taskId,
654
- counterpartyHint,
655
- disclosureSubject,
656
- ...(draftQuestion && { draftQuestion }),
657
- indexContext: state.indexContext.prompt,
658
- ...(userContext && { userContext }),
659
- },
660
- });
661
- await database.updateTaskState(state.taskId, 'input_required');
781
+ // Persistence admission requires the exact task to be input_required.
782
+ // Flip before enqueue; if the structured ask_user fields fail the
783
+ // deterministic privacy gate, the timer alone closes the exact task.
784
+ await database.updateTaskState(state.taskId, 'input_required', undefined, state.continuationExecution);
785
+ if (safeAskUser) {
786
+ const userContext = (await database.getUserContext(ownUser.id, null).catch(() => null))?.text ?? '';
787
+ await questionerEnqueue({
788
+ mode: 'negotiation_inflight',
789
+ purpose: 'inflight_consultation',
790
+ userId: ownUser.id,
791
+ sourceType: 'opportunity',
792
+ sourceId: state.opportunityId,
793
+ negotiation: {
794
+ purpose: 'inflight_consultation',
795
+ recipientUserId: ownUser.id,
796
+ recipientIntentId: ownIntentId,
797
+ opportunityId: state.opportunityId,
798
+ taskId: state.taskId,
799
+ networkId: state.indexContext.networkId,
800
+ },
801
+ context: {
802
+ negotiationId: state.taskId,
803
+ counterpartyHint: NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY,
804
+ disclosureSubject: safeAskUser.disclosureSubject,
805
+ ...(safeAskUser.draftQuestion && { draftQuestion: safeAskUser.draftQuestion }),
806
+ indexContext: NEGOTIATION_QUESTION_GENERIC_NETWORK,
807
+ ...(consultationPolicyReason && { consultationPolicyReason }),
808
+ ...(userContext && { userContext }),
809
+ },
810
+ }).catch((error) => {
811
+ turnLog.error('Failed to enqueue safe ask_user question; timeout recovery remains armed', {
812
+ taskId: state.taskId,
813
+ opportunityId: state.opportunityId,
814
+ error,
815
+ });
816
+ });
817
+ }
818
+ else {
819
+ turnLog.warn('Skipping unsafe or incomplete ask_user question generation', {
820
+ taskId: state.taskId,
821
+ opportunityId: state.opportunityId,
822
+ });
823
+ }
662
824
  turnLog.info('negotiation_ask_user_pause', {
663
825
  taskId: state.taskId,
664
826
  opportunityId: state.opportunityId,
@@ -673,7 +835,7 @@ export class NegotiationGraphFactory {
673
835
  negotiationConversationId: state.conversationId,
674
836
  turnIndex: state.turnCount,
675
837
  actor: isSource ? 'source' : 'candidate',
676
- disclosureSubject,
838
+ questionGenerationSafe: Boolean(safeAskUser),
677
839
  windowMs,
678
840
  });
679
841
  return {
@@ -690,7 +852,7 @@ export class NegotiationGraphFactory {
690
852
  ...(deadlockShiftRecord && { deadlockShift: deadlockShiftRecord }),
691
853
  };
692
854
  }
693
- await database.updateTaskState(state.taskId, "working");
855
+ await database.updateTaskState(state.taskId, "working", undefined, state.continuationExecution);
694
856
  if (state.opportunityId) {
695
857
  emitWide({
696
858
  type: "negotiation_turn",
@@ -757,6 +919,17 @@ export class NegotiationGraphFactory {
757
919
  const finalizeNode = async (state) => {
758
920
  const traceEmitter = requestContext.getStore()?.traceEmitter;
759
921
  const emitWide = (event) => traceEmitter?.(event);
922
+ const pauseReceipt = (outcome) => state.continuationExecution
923
+ ? {
924
+ continuationReceipt: {
925
+ priorTaskId: state.continuationExecution.taskId,
926
+ settlementId: state.continuationExecution.settlementId,
927
+ successorTaskId: state.continuationExecution.successorTaskId,
928
+ fence: state.continuationExecution.fence,
929
+ outcome,
930
+ },
931
+ }
932
+ : undefined;
760
933
  if (state.status === 'waiting_for_agent') {
761
934
  if (state.opportunityId) {
762
935
  emitWide({
@@ -767,7 +940,7 @@ export class NegotiationGraphFactory {
767
940
  isContinuation: state.isContinuation,
768
941
  });
769
942
  }
770
- return {};
943
+ return pauseReceipt('waiting_for_agent') ?? {};
771
944
  }
772
945
  // ask_user pause: no outcome, no completed state — the task stays
773
946
  // input_required until the client answers or the window expires.
@@ -781,7 +954,7 @@ export class NegotiationGraphFactory {
781
954
  isContinuation: state.isContinuation,
782
955
  });
783
956
  }
784
- return {};
957
+ return pauseReceipt('input_required') ?? {};
785
958
  }
786
959
  // Init can fail closed before an attempt owns a task. Such a rejection is
787
960
  // not a completed negotiation and must not write through an empty task ID,
@@ -827,12 +1000,21 @@ export class NegotiationGraphFactory {
827
1000
  : {}),
828
1001
  };
829
1002
  try {
830
- await database.updateTaskState(state.taskId, "completed");
1003
+ await database.updateTaskState(state.taskId, "completed", undefined, state.continuationExecution);
831
1004
  await database.createArtifact({
832
1005
  taskId: state.taskId,
833
1006
  name: "negotiation-outcome",
834
1007
  parts: [{ kind: "data", data: outcome }],
835
- metadata: { hasOpportunity, turnCount: state.turnCount },
1008
+ metadata: {
1009
+ hasOpportunity,
1010
+ turnCount: state.turnCount,
1011
+ ...(state.continuationExecution ? {
1012
+ continuationOutcome: hasOpportunity
1013
+ ? 'accepted'
1014
+ : (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
1015
+ } : {}),
1016
+ },
1017
+ ...(state.continuationExecution ? { continuationExecution: state.continuationExecution } : {}),
836
1018
  });
837
1019
  finalizeLog.info('Session complete', {
838
1020
  conversationId: state.conversationId,
@@ -852,7 +1034,7 @@ export class NegotiationGraphFactory {
852
1034
  : (screenedOut || isRejectLikeAction(lastTurn?.action))
853
1035
  ? 'rejected'
854
1036
  : 'stalled';
855
- await database.updateOpportunityStatus(state.opportunityId, nextStatus).catch((err) => {
1037
+ await database.updateOpportunityStatus(state.opportunityId, nextStatus, undefined, state.continuationExecution).catch((err) => {
856
1038
  finalizeLog.error("Failed to update opportunity status", { opportunityId: state.opportunityId, nextStatus, error: err });
857
1039
  });
858
1040
  }
@@ -887,11 +1069,26 @@ export class NegotiationGraphFactory {
887
1069
  }),
888
1070
  });
889
1071
  }
1072
+ if (state.consultationPolicyReason) {
1073
+ // Stable, content-free terminal funnel telemetry. This executes only
1074
+ // after a fenced continuation has reached its terminal outcome.
1075
+ finalizeLog.info('negotiation_consultation_policy', {
1076
+ stage: 'terminal_outcome',
1077
+ reason: state.consultationPolicyReason,
1078
+ outcome: hasOpportunity ? 'accepted' : (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
1079
+ });
1080
+ emitWide({
1081
+ type: 'negotiation_consultation_policy',
1082
+ stage: 'terminal_outcome',
1083
+ reason: state.consultationPolicyReason,
1084
+ outcome: hasOpportunity ? 'accepted' : (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
1085
+ });
1086
+ }
890
1087
  // Enqueue post-negotiation reflection (P5.2 memory write path) — fire
891
1088
  // and forget: a reflection failure must never affect the outcome. Only
892
1089
  // sessions that actually exchanged turns teach anything; init/turn
893
1090
  // errors with turnCount 0 are skipped.
894
- if (reflectEnqueue && state.turnCount > 0) {
1091
+ if (reflectEnqueue && state.turnCount > 0 && !state.continuationExecution) {
895
1092
  reflectEnqueue({
896
1093
  negotiationId: state.taskId,
897
1094
  conversationId: state.conversationId,
@@ -915,24 +1112,36 @@ export class NegotiationGraphFactory {
915
1112
  }
916
1113
  // Enqueue question generation for stalled/capped negotiations (not accepted or explicitly rejected).
917
1114
  // Require turnCount > 0 so early init/turn errors don't enqueue with empty context.
918
- if (!hasOpportunity && !isRejectLikeAction(lastTurn?.action) && state.turnCount > 0 && state.opportunityId && questionerEnqueue) {
1115
+ if (!hasOpportunity && !isRejectLikeAction(lastTurn?.action) && state.turnCount > 0 && state.opportunityId && state.sourceIntentId && state.indexContext.networkId && questionerEnqueue && !state.continuationExecution) {
919
1116
  const stallReason = atCap
920
1117
  ? 'turn_cap'
921
1118
  : (state.error && /timeout/i.test(state.error))
922
1119
  ? 'timeout'
923
1120
  : 'stalled';
924
1121
  const userContext = (await database.getUserContext(state.sourceUser.id, null))?.text ?? '';
1122
+ const sourceIntent = state.sourceUser.intents.find((intent) => intent.id === state.sourceIntentId);
925
1123
  questionerEnqueue({
926
1124
  mode: 'negotiation',
1125
+ purpose: 'stalled_followup',
927
1126
  userId: state.sourceUser.id,
928
1127
  sourceType: 'opportunity',
929
1128
  sourceId: state.opportunityId,
1129
+ negotiation: {
1130
+ purpose: 'stalled_followup',
1131
+ recipientUserId: state.sourceUser.id,
1132
+ recipientIntentId: state.sourceIntentId,
1133
+ opportunityId: state.opportunityId,
1134
+ taskId: state.taskId,
1135
+ networkId: state.indexContext.networkId,
1136
+ },
930
1137
  context: {
931
1138
  negotiationId: state.taskId,
932
- counterpartyHint: `${state.candidateUser.profile.name ?? 'Unknown'}${state.candidateUser.profile.bio ? ', ' + state.candidateUser.profile.bio : ''}`,
933
- indexContext: state.indexContext.prompt,
1139
+ counterpartyHint: NEGOTIATION_QUESTION_GENERIC_COUNTERPARTY,
1140
+ indexContext: NEGOTIATION_QUESTION_GENERIC_NETWORK,
934
1141
  outcomeReason: stallReason,
935
- keyTake: outcome.reasoning,
1142
+ recipientIntent: sourceIntent
1143
+ ? `${sourceIntent.title}: ${sourceIntent.description}`
1144
+ : 'the signal attached to this match',
936
1145
  userContext,
937
1146
  },
938
1147
  }).catch((err) => finalizeLog.error('Failed to enqueue negotiation question generation', {
@@ -940,7 +1149,20 @@ export class NegotiationGraphFactory {
940
1149
  error: err,
941
1150
  }));
942
1151
  }
943
- return { outcome, status: 'completed' };
1152
+ const terminalReceipt = state.continuationExecution
1153
+ ? {
1154
+ continuationReceipt: {
1155
+ priorTaskId: state.continuationExecution.taskId,
1156
+ settlementId: state.continuationExecution.settlementId,
1157
+ successorTaskId: state.continuationExecution.successorTaskId,
1158
+ fence: state.continuationExecution.fence,
1159
+ outcome: hasOpportunity
1160
+ ? 'accepted'
1161
+ : (screenedOut || isRejectLikeAction(lastTurn?.action)) ? 'rejected' : 'stalled',
1162
+ },
1163
+ }
1164
+ : undefined;
1165
+ return { outcome, status: 'completed', ...terminalReceipt };
944
1166
  };
945
1167
  const workflow = new StateGraph(NegotiationGraphState)
946
1168
  .addNode("init", initNode)
@@ -972,7 +1194,7 @@ export class NegotiationGraphFactory {
972
1194
  * @returns Only candidates that produced an opportunity
973
1195
  */
974
1196
  export async function negotiateCandidates(negotiationGraph, sourceUser, candidates, indexContext, opts) {
975
- const { maxTurns, traceEmitter, indexContextOverrides, timeoutMs, onCandidateResolved, trigger, initiatorUserId } = opts ?? {};
1197
+ const { maxTurns, traceEmitter, indexContextOverrides, timeoutMs, onCandidateResolved, trigger, initiatorUserId, resumeFromTaskId, continuationSettlementId, continuationExecution, } = opts ?? {};
976
1198
  // Local helper to emit events whose shape is wider than the declared
977
1199
  // `TraceEmitter` union (mirrors the cast used in chat.agent at the relay sink
978
1200
  // and inside turn/finalize nodes above).
@@ -1001,6 +1223,8 @@ export async function negotiateCandidates(negotiationGraph, sourceUser, candidat
1001
1223
  const result = await invokeWithAbortSignal(negotiationGraph, {
1002
1224
  sourceUser,
1003
1225
  candidateUser: candidate.candidateUser,
1226
+ ...(candidate.sourceIntentId && { sourceIntentId: candidate.sourceIntentId }),
1227
+ ...(candidate.candidateIntentId && { candidateIntentId: candidate.candidateIntentId }),
1004
1228
  indexContext: candidateIndexContext,
1005
1229
  seedAssessment: {
1006
1230
  reasoning: candidate.reasoning,
@@ -1011,6 +1235,11 @@ export async function negotiateCandidates(negotiationGraph, sourceUser, candidat
1011
1235
  ...(candidate.opportunityStatus && { opportunityStatus: candidate.opportunityStatus }),
1012
1236
  ...(candidate.opportunityUpdatedAt && { opportunityUpdatedAt: candidate.opportunityUpdatedAt }),
1013
1237
  ...(initiatorUserId && { initiatorUserId }),
1238
+ ...(resumeFromTaskId && continuationSettlementId && continuationExecution ? {
1239
+ resumeFromTaskId,
1240
+ continuationSettlementId,
1241
+ continuationExecution,
1242
+ } : {}),
1014
1243
  ...(maxTurns !== undefined && { maxTurns }),
1015
1244
  ...(timeoutMs !== undefined && { timeoutMs }),
1016
1245
  });
@@ -1064,6 +1293,7 @@ export async function negotiateCandidates(negotiationGraph, sourceUser, candidat
1064
1293
  accepted,
1065
1294
  turns: turnHistory,
1066
1295
  outcome: resolvedOutcome,
1296
+ ...(result.continuationReceipt ? { continuationReceipt: result.continuationReceipt } : {}),
1067
1297
  });
1068
1298
  }
1069
1299
  catch (hookErr) {