Skip to content

fix(negotiations): an answer beats staleness — drift is logged, never fatal - #1474

Merged
yanekyuk merged 3 commits into
devfrom
fix/answers-beat-staleness
Aug 21, 2026
Merged

fix(negotiations): an answer beats staleness — drift is logged, never fatal#1474
yanekyuk merged 3 commits into
devfrom
fix/answers-beat-staleness

Conversation

@yanekyuk

Copy link
Copy Markdown
Contributor

"Intent update should always be explicit. Stale negs should be solved by question answering. When everything else is tried, the agent can propose resignaling with the updated intent."

The broken flow

Tonight on the sandbox (task aefff73b-d61f-4864-bfd8-859a8758ddcf), a question parked at 20:20 ("Timing: This week") was answered by its own recipient at ~23:0x through the new MCP answer_pending_question lane. Every arrow up to the last one worked: the tool resolved the open question, routed the answer onto the right negotiation ref, consumption re-resolved the park, and the settle ran (question_answer_consumed … resumed:0, skipped:1).

The broken arrow was the settle's revalidation fence. One block returned lost for two very different reasons — "this answer does not belong to this park" (coherence) and "the world moved since the park" (drift). The signal had been edited twice since the park, so intentFingerprint/opportunityStatus/opportunityUpdatedAt no longer matched the stamped binding, and the fence threw the user's answer away. That is exactly what the design law forbids: the answer IS the freshest commitment there is, and the resumed turn rebuilds its context from current data anyway. The park became a zombie — still input_required, rendered as "waiting on YOUR answer" by every #1470/#1472/#1473 surface, structurally unanswerable until the 24h expiry. The same reply resumed the OTHER signal's park fine (negotiation_answer_resumed_inflight, resumed:1): the pipeline works; the fence was the defect.

How each arrow is fixed

Settle fence split (services/api/src/adapters/questioner.adapter.ts, settleInflightNegotiationAnswerFromDm):

  • Coherence stays hard, byte-for-byte: settlement-id derivation, task exists and is input_required, metadata type/opportunity/network match, binding present and matching, and the already-settled race handling are unchanged.
  • Drift stops losing: the binding comparison against resolveNegotiationAdmission is replaced by a resumability check on current reality only — the opportunity is in a status run-existing would accept (mirrors NEGOTIATION_START_STATUSES: latent/draft/pending/negotiating/stalled) and the recipient signal is not archived. Within that, the answer settles and the resume enqueues no matter what moved, and the drift is logged honestly (negotiation_answer_settled_despite_drift, naming what moved). The settlement keeps recording the binding's counterparty/provenance values — they describe the park that was answered. resolveNegotiationAdmission itself is untouched; its other caller (expireInflightQuestion) keeps its semantics.

The unresumable tail becomes an explicit proposal (terminal opportunity / archived signal):

  • The answer is still recorded as heard: the settlement is written with kind: 'answer' and a new continuationStatus: 'unresumable' — durable, inline answer included, and no continuation can ever claim it (the claim path refuses non-requested settlements). The task retires under ask_user_answered_unresumable, which resolves the park honestly — answer-triggered, i.e. an explicit human act, so law (1) is intact.
  • The DM receives fixed server-owned copy (same rules as the feat(api): question-message notifications, deep-linked, plus the empty-set close-out #1441 close-out prose): the truth about why nothing resumed, plus the proposal — re-running discovery under the updated signal — offered, never performed. The retired park's question then leaves the DM through the ordinary feat(api): question-message notifications, deep-linked, plus the empty-set close-out #1441 regeneration (parked set shrinks → message shrinks or closes out). No automatic re-signal, no automatic re-discovery, no cascade.

The result surface tells the truth (packages/protocol/src/negotiations/negotiation.answer-consumption.ts, additive):

  • InflightAnswerSettlementResult and NegotiationAnswerResumeOutcome gain recorded_unresumable; drift can no longer reach lost/not_parked.
  • consumeQuestionBlockAnswers counts the tail in its own recorded array, and the consumption log line reports resumed/recorded/skipped distinctly — tonight's ambiguous skipped:1 cannot recur.
  • The MCP/persona answer-tool lane is enqueue-async (the tool returns before consumption runs), so the proposal copy for the unresumable case is delivered as the DM message above rather than as a synchronous tool result.

Protocol change is minimally additive: patch bump 23.6.0 → 23.6.1, changelog entry in the existing style, bun scripts/sync-lockfile-versions.ts run (bun.lock updated), dist rebuilt before api tests.

Test evidence

  • packages/protocol: bun test src/negotiations/tests/negotiation.answer-consumption.spec.ts23 pass, 0 fail (new: unresumable outcome enqueues nothing; recorded counted apart from skipped).
  • services/api: bun test ./src/adapters/tests/negotiation-dm-answer.settlement.spec.ts (DB-backed) — 10 pass, 0 fail. New specs: the incident inverted (signal edited + opportunity moved to stalled since the park → settled, settlement records the binding's values, drift logged); terminal opportunity → recorded_unresumable, park retired, continuation claim invalid, idempotent redelivery; archived signal → recorded_unresumable; coherence pins (foreign settlement id, recipient the binding does not name → lost, task untouched; timeout-settled and non-parked tasks unchanged).
  • services/api: bun test ./src/queues/tests/question-message.queue.isolated.ts47 pass, 0 fail (new: unresumable tail delivers QUESTION_ANSWER_UNRESUMABLE_MESSAGE, enqueues the retirement regeneration, resumes nothing, notifies nobody).
  • services/api answer-path siblings (open-question-consumption.settlement, negotiator-answer.host, answer-precedence + wiring, open-question-message, negotiation-question-routing.static, negotiation-stalled-retry.database, questioner.evidence, negotiation-listing-park.host) — 71 pass, 0 fail across 9 files.
  • packages/protocol whole src/negotiations/tests/ — 718 pass, 2 fail; both in negotiator-discovery-query.spec.ts (live-LLM eval), byte-identical to origin/dev and green when re-run in isolation — the known whole-dir flake.
  • services/api full isolated suite — files=109 passed=1135 failed=4: opportunity.service.rediscovery.isolated, opportunity.service.maintenance.isolated, tool.controller.isolated, archive-legacy-negotiations.isolated. All four byte-identical to origin/dev (pre-existing, the known dev-red set); none touch this diff.
  • tsc --noEmit clean in services/api; protocol tsc clean via the dist build.

Note: committing questioner.adapter.ts surfaced a latent pre-commit lint failure (the adapter's one @indexnetwork/protocol type import predates the adapter-layering eslint rule). Fixed the way the rule prescribes: the import is replaced by the local structural mirror of NegotiationCounterpartyBinding.

Post-merge verification

Run on the root session after merge (deliberately not from this lane): re-run the settle against the real zombie park aefff73b-d61f-4864-bfd8-859a8758ddcf on the sandbox with the answer recorded earlier tonight. Opportunity 78a6b283 is negotiating (live), so after this fix the answer must settle and enqueue the resume. State the result on this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N1FQ3gV1oWWXQHMGGroqcV

…umable tail

An answer settled on a park whose negotiation genuinely cannot continue
(terminal opportunity / archived signal) is no longer indistinguishable from a
lost settlement: settleInflightAnswer may report 'recorded_unresumable',
resumeParkedNegotiation surfaces it as its own outcome (never an enqueue), and
consumeQuestionBlockAnswers counts it in a dedicated `recorded` array so a
`skipped` count can never again hide an answer that was heard. Additive only;
patch bump 23.6.1 with changelog and lockfile sync.

Claude-Session: https://claude.ai/code/session_01N1FQ3gV1oWWXQHMGGroqcV
…d, drift stops losing

Observed live (sandbox, 2026-08-20, task aefff73b): a client answered her own
parked question through the MCP answer lane, routing and consumption both ran,
and the settle returned 'lost' — the signal had been edited twice since the
park, so the revalidation fence (intentFingerprint / opportunityStatus /
opportunityUpdatedAt vs the stamped binding) refused her answer. The park
became a zombie: input_required, rendered as waiting on her, unanswerable.

The one fence is now two. Coherence keeps refusing hard: settlement-id
derivation, task still input_required, metadata and binding matches, and the
already-settled races are byte-for-byte what they were. Drift is re-read from
current reality instead: the opportunity must be in a status run-existing
would accept (mirrors NEGOTIATION_START_STATUSES) and the recipient signal
not archived — within that, the answer settles and resumes regardless of what
moved since the park, and the drift is logged
(negotiation_answer_settled_despite_drift). The settlement keeps recording
the binding's provenance values; they describe the park that was answered.

The unresumable tail is recorded, never dropped: kind 'answer' with
continuationStatus 'unresumable' (no continuation may ever claim it), the
task retires under reason ask_user_answered_unresumable, and the settle
reports 'recorded_unresumable' so the caller can tell the client the truth
and propose the next step.

Also replaces the adapter's one protocol import with the local structural
mirror the adapter layering rule (and this file's own header) prescribe —
the import predates the rule and blocked the pre-commit lint.

Claude-Session: https://claude.ai/code/session_01N1FQ3gV1oWWXQHMGGroqcV
…its question retires

When consumption reports recorded_unresumable, the DM receives server-owned
copy (same rules as the close-out prose): the honest reason nothing resumed,
and the PROPOSAL of the next step in the owner's vocabulary — re-running
discovery under the updated signal, offered, never performed. The retired
park's question then leaves the DM through the ordinary #1441 regeneration:
the settled park has dropped out of the parked set, so the message shrinks or
closes out. The consumption log counts the tail distinctly
(resumed/recorded/skipped) so a skipped count can never again hide an answer
that was heard.

Claude-Session: https://claude.ai/code/session_01N1FQ3gV1oWWXQHMGGroqcV
@yanekyuk
yanekyuk merged commit a2199e9 into dev Aug 21, 2026
7 checks passed
yanekyuk added a commit that referenced this pull request Aug 21, 2026
…ift (#1475)

* fix(negotiations): the continuation claim and expiry stop refusing drift

The park-time drift fence #1474 removed from the settle survived in two
more places, and tonight's live verification hit the first: the answer
settled, the resume enqueued, and the claim refused with
admission:"invalid" because the world had moved since the park.

validateMaterialBinding (claim, heartbeat, completion) now splits the
same way the settle did: the coherence joins, settlement match, and the
completion path's terminal-effect assertion stay hard; the park-time
triple (intentFingerprint / opportunityStatus / opportunityUpdatedAt)
becomes log-only behind a resumable-status gate on current reality.
expireInflightQuestion keeps every binding-vs-input equality check and
drops its resolveNegotiationAdmission equality gate — expiry is a
cleanup act and must land even when the opportunity went terminal or
the signal was edited; the admission read remains for the drift log.

RESUMABLE_OPPORTUNITY_STATUSES hoists to the atomic module so both the
settle (#1474) and the claim gate on the same set; the protocol type
import that hoist would have tripped in lint becomes the same local
structural mirror the questioner adapter already carries.

Claude-Session: https://claude.ai/code/session_01N1FQ3gV1oWWXQHMGGroqcV

* test(negotiations): pin the drift/coherence split at the claim and expiry fences

The incident inverted (drift then settle then claim mints the
successor), the settle-to-claim race (terminal current status still
invalid), the completion path's terminal-effect assertion, the
coherence pins (settlement mismatch, gone membership, dead counterparty
binding), and expiry under drift, on a terminal opportunity, after an
answer, and against a mismatched binding.

Claude-Session: https://claude.ai/code/session_01N1FQ3gV1oWWXQHMGGroqcV
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant