Skip to content

fix(negotiations): the continuation claim and expiry stop refusing drift - #1475

Merged
yanekyuk merged 2 commits into
devfrom
fix/continuation-claim-drift
Aug 21, 2026
Merged

fix(negotiations): the continuation claim and expiry stop refusing drift#1475
yanekyuk merged 2 commits into
devfrom
fix/continuation-claim-drift

Conversation

@yanekyuk

Copy link
Copy Markdown
Contributor

The design law

"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 flow that was still broken

Tonight's live verification of #1474 (sandbox): the recipient's answer to zombie park aefff73b-d61f-4864-bfd8-859a8758ddcf settled exactly as designed — negotiation_answer_settled_despite_drift logged, task canceled ask_user_answered, settlement continuationStatus:'requested', consumption resumed:1, recorded:0, skipped:0, resume job enqueued. Then the flow broke ONE HOP downstream: RunExistingJob: Exact negotiation continuation skipped {admission:"invalid"}. No successor turn ran; opportunity 78a6b283 sits at negotiating, untouched; the answer is durable but the negotiation never resumed.

The park-time drift fence #1474 removed from the settle was standing in two more places, and both still refused drift. This lane removes the last two copies:

  1. validateMaterialBinding (shared by claim, heartbeat, and completion) — after the coherence joins it returned null when the current intent fingerprint, opportunity status, or opportunityUpdatedAt differed from the PARK-TIME values the settlement carries. Now split the same way fix(negotiations): an answer beats staleness — drift is logged, never fatal #1474 split the settle: every coherence join, the settlement match, and the completion path's terminalOpportunityStatus effect assertion stay hard; when no terminal status is asserted, the gate is current reality only — the current status must be in the resumable set (latent/draft/pending/negotiating/stalled, the same RESUMABLE_OPPORTUNITY_STATUSES the settle uses, now hoisted to the atomic module) — and within it, drift is logged (negotiation_continuation_claimed_despite_drift) and the claim proceeds. A side effect stated in the code: heartbeats re-run this validation mid-execution while the turn itself may legitimately touch the opportunity — with drift log-only, a heartbeat can no longer kill its own execution.
  2. expireInflightQuestion — the resolveNegotiationAdmission equality triple meant a drifted park could never even EXPIRE; the 24h sweep was fenced out by the same staleness the law says must not matter (the second zombie class). Every binding-vs-input equality check (coherence — the caller must pass what the binding says) stays; the admission read remains only to feed the drift log (negotiation_expiry_settled_despite_drift), and expiry lands even when the opportunity went terminal or the signal was edited. The timeout settlement it writes is also continuationStatus:'requested' and resumes through the same claim, so the fence split serves both the answer path and the timeout path.

The claim keeps its existing claimed/busy/completed/invalid vocabulary — invalid simply stops being reachable via park-time drift. No protocol files changed.

Test evidence

New DB spec services/api/src/adapters/tests/negotiation-continuation.atomic.spec.ts (10 tests): the incident inverted (signal edited + opportunity moved → settle settled → claim claimed, successor minted, consultation carries the answer), terminal current status still invalid (settle→claim race), completion still asserts its exact terminal effect (wrong current status throws, correct one completes), coherence pins (settlement mismatch / gone membership / dead counterparty binding → invalid), and expiry: drifted park expires and its timeout settlement claims through, terminal opportunity still expires but the resume refuses, an answered consult is not rewritten, a mismatched binding still returns null.

  • NODE_ENV=test bun test src/adapters/tests/negotiation-continuation.atomic.spec.ts src/adapters/tests/negotiation-dm-answer.settlement.spec.ts src/adapters/tests/negotiation-question-routing.static.spec.ts src/adapters/tests/question-retirement.static.spec.ts src/queues/tests/timeout.queue.spec.ts src/adapters/tests/negotiation-stalled-retry.database.spec.ts80 pass, 0 fail
  • NODE_ENV=test API_TEST_ISOLATED_TARGET=src/queues/tests/run-existing.queue.isolated.ts bun test src/lib/testing/isolated-test-import-harness.spec.ts16 pass, 0 fail
  • bun run typecheck clean; eslint clean on all touched files (fixing en route the atomic module's pre-existing restricted protocol import, which the pre-commit hook now enforces — it becomes the same local structural mirror the questioner adapter already carries).

Pre-existing failures (baselined per the #1462 method — git diff origin/dev over these specs and their scanned sources is empty; not chased):

  • The 4 known dev-red isolated files: opportunity.service.rediscovery, opportunity.service.maintenance, tool.controller, archive-legacy-negotiations.
  • Whole-dir src/adapters/tests run: 2 failures in conversation.database.adapter.spec.ts, 1 in negotiation-scope.static.spec.ts, 4 in clicredential.adapter.spec.ts — all reproduce in isolation with zero diff vs origin/dev.
  • The protocol whole-dir live-LLM flake (untouched; no protocol changes in this PR).

Post-merge verification

The root session re-drives the real park aefff73b-d61f-4864-bfd8-859a8758ddcf after merge: its settlement is already requested, so redelivery reports already_settled → re-enqueue → the claim must now go through and run the successor turn on opportunity 78a6b283-78e3-4b53-b3f1-aed142db1966.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N1FQ3gV1oWWXQHMGGroqcV

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
…piry 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
@yanekyuk
yanekyuk merged commit 6a62be5 into dev Aug 21, 2026
7 checks passed
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