fix(negotiations): an answer beats staleness — drift is logged, never fatal - #1474
Merged
Conversation
…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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 MCPanswer_pending_questionlane. 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
lostfor 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, sointentFingerprint/opportunityStatus/opportunityUpdatedAtno 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 — stillinput_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):input_required, metadata type/opportunity/network match, binding present and matching, and the already-settled race handling are unchanged.resolveNegotiationAdmissionis replaced by a resumability check on current reality only — the opportunity is in a status run-existing would accept (mirrorsNEGOTIATION_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.resolveNegotiationAdmissionitself is untouched; its other caller (expireInflightQuestion) keeps its semantics.The unresumable tail becomes an explicit proposal (terminal opportunity / archived signal):
kind: 'answer'and a newcontinuationStatus: 'unresumable'— durable, inline answer included, and no continuation can ever claim it (the claim path refuses non-requestedsettlements). The task retires underask_user_answered_unresumable, which resolves the park honestly — answer-triggered, i.e. an explicit human act, so law (1) is intact.The result surface tells the truth (
packages/protocol/src/negotiations/negotiation.answer-consumption.ts, additive):InflightAnswerSettlementResultandNegotiationAnswerResumeOutcomegainrecorded_unresumable; drift can no longer reachlost/not_parked.consumeQuestionBlockAnswerscounts the tail in its ownrecordedarray, and the consumption log line reportsresumed/recorded/skippeddistinctly — tonight's ambiguousskipped:1cannot recur.Protocol change is minimally additive: patch bump 23.6.0 → 23.6.1, changelog entry in the existing style,
bun scripts/sync-lockfile-versions.tsrun (bun.lock updated), dist rebuilt before api tests.Test evidence
packages/protocol:bun test src/negotiations/tests/negotiation.answer-consumption.spec.ts— 23 pass, 0 fail (new: unresumable outcome enqueues nothing;recordedcounted apart fromskipped).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 tostalledsince the park →settled, settlement records the binding's values, drift logged); terminal opportunity →recorded_unresumable, park retired, continuation claiminvalid, 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.ts— 47 pass, 0 fail (new: unresumable tail deliversQUESTION_ANSWER_UNRESUMABLE_MESSAGE, enqueues the retirement regeneration, resumes nothing, notifies nobody).services/apianswer-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/protocolwholesrc/negotiations/tests/— 718 pass, 2 fail; both innegotiator-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/apifull 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 --noEmitclean inservices/api; protocoltscclean via the dist build.Note: committing
questioner.adapter.tssurfaced a latent pre-commit lint failure (the adapter's one@indexnetwork/protocoltype import predates the adapter-layering eslint rule). Fixed the way the rule prescribes: the import is replaced by the local structural mirror ofNegotiationCounterpartyBinding.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-859a8758ddcfon the sandbox with the answer recorded earlier tonight. Opportunity78a6b283isnegotiating(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