Skip to content

fix(host): gate the settlement ack on an observed version label - #403

Merged
mtskf merged 23 commits into
mainfrom
fix/settlement-ack-label-observed
Sep 9, 2026
Merged

fix(host): gate the settlement ack on an observed version label#403
mtskf merged 23 commits into
mainfrom
fix/settlement-ack-label-observed

Conversation

@mtskf

@mtskf mtskf commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Summary

Gate the settlement ack Document on an OBSERVED post-apply version label. A settlement where neither the settle-time/dispatch-retry readVersion nor a lock-held resync observed document.version used to post live V+1 bytes labelled docVersion: V; the webview would base its next Edit on that stale label, ending in a stale verdict → lock-free forward advance → externalEpoch bump → the replay buffer dropped (buffered-keystroke loss). Now the ack posts only when the label is backed by a real observation; otherwise it is withheld with a per-incident latched toast, and the content-unobserved epoch verdict falls back to positive version-delta evidence instead of "not foreign".

Changes

  • host-session-core.ts: one version representation — ApplyEditOutcome.ok.documentVersion deleted; the applyEditSettled event carries a required settledVersion: number | null, applied to lastAppliedDocVersion via Math.max for every outcome kind. New ackLabelObserved gate threaded through settlementEffects (every arm) and the drain's readonly/stale/no-op repost; withheld acks emit logWarn + a new showResyncFailure effect. Content-unobserved foreign verdict: lastAppliedDocVersion > heldBase + (ok ? 1 : 0) (positive evidence only). editRejectedDeliveryFailed.documentVersion is nullable — an unobserved recovery read clears the rejection but withholds the reseed.
  • effect-executor.ts: readVersionGuarded() — the single guarded version reader (number | null, never fabricated) serving the settlement-dispatch retry (restores liveness on transient settle-read failures) and sendEditRejected's three recovery dispatches (previously unguarded throw sources). showResyncFailure shares the per-incident resync-failure latch with the reseed-build guard (one latch, two triggers).
  • execute-write.ts: caller-contract comments narrowed to CONTENT (the guarded version retry is the single documented exception). No code change.
  • Tests: fixture re-point across the settlement suites; new pins for the gate (withhold / lock-held observation / byte-equality-never-licenses / non-ok arms / no-op delta-0), the unified Math.max advance, the version-delta epoch verdict, the null recovery arm (+ id-guard + convergence), the retry (transient/persistent, both arms), the three guarded recovery sites, latch sharing, and four end-to-end pins over the real pipeline (with a fault-injection dropLockHeldDocumentChanged harness arm and an armVersionFailure seam). Gate and latch non-vacuity measured via revert-checks.

Related

  • TODO: "settlement の ack Document は「reducer 保持の version」でラベルされるのに buildSeedDocument は live bytes を読む" (2026-09-07, split from the settle-total PR's review). Residuals (quiet-document liveness backstop; unobserved-label parse-failed drain corner) are recorded in a follow-up TODO entry.

Test Plan

  • pnpm compile (all 5 tsconfigs) green
  • pnpm test:unit green (279 files / 5354 tests)
  • Revert-checks measured: neutering ackLabelObserved to true reddens the withhold pins; splitting the shared latch reddens the latch-sharing pin
  • pnpm biome check clean on changed files; pnpm build green; pnpm check:todo-hygiene / check:todo-markers OK

mtskf added 23 commits September 8, 2026 21:10
…djudicate unobserved-content settlements from version-delta evidence
…atch so a transient failure cannot withhold the ack
…etry liveness, lock-held observation, shared latch)
…served

The drain re-bases the stash onto the settled label. Without an observation
behind that label the re-acquired base is a known-stale lower bound, so the
NEXT settlement's version-delta fallback counts our own unobserved increment
as foreign, bumps the epoch, and the resulting reseed drops the webview replay
buffer holding the keystrokes the failure toast asks the user to retry. Require
the same observation the ack requires.

Post-dispose is exempt and stays as it was: the drain does not re-acquire the
lock there, so no later settlement ever reads the re-based label, while the
stash is the dropped keystroke's only carrier. Gating it there would trade a
structurally unreachable epoch bump for real data loss. The newly blocked ALIVE
drop gets its own triage log; the webview replay buffer still carries the edit,
so no toast.

Side effect: the parse-failed drain arm can no longer redeliver a draft
Document at an unobserved label, which closes that accepted residual by
construction rather than by a local gate.

Also here: extract ackEffects so both ack sites share one owner for the gate,
pin the two evidence paths the ack label can come from (a lock-held raise
licensing the ack, and that raise supplying the version-delta verdict when the
settlement itself observed nothing), and correct the settlement comments that
described superseded behaviour - the toast-before-reseed rationale, the
withhold pair's surface, the count of version-raising paths, the ownContribution
invariant, and one inverted cross-reference.
readVersionGuarded serves three call families with different consequences - the
resolved settlement's retry, the pipeline-rejection arm's only version read, and
the three edit-rejected recovery dispatches - but its warn named none of them, so
a withheld ack and a withheld recovery reseed logged the same line. Take a
literal site and put it on the warn.

The doc counted two families and called the rejection arm's read a retry; it
reaches no settle-time read at all. Correct that, and state at the rejection arm
that the reseed it promises is conditional on the label being observed.
execute-write's null-contract claimed a numeric version sentinel would be
assigned verbatim and rewind the label. The settlement clamps with Math.max, so
it could not - the real hazard is worse and now stated: any fabricated number
satisfies settledVersion !== null and fabricates the observation the ack gate
tests. The consequences are also per missing observation, not one flat list.

The barrier's ok arm attributed an unverified landing to a null settledVersion;
it is a null currentContent that downgrades the tag, and a version-only failure
leaves the write verified.

The settle-rejection suite's header banned asserting h.errors is empty under any
armVersionFailure, which contradicts the two tests where an ack IS licensed - a
transient failure the retry recovers, and a persistent one a lock-held
documentChanged labels. Limit the rule to the arrangement that actually
withholds.
… the keystroke

Reverts the ALIVE half of the `canDrain` ack-label gate added earlier in this
branch, restoring the reviewed drain shape: the drain gates on CONTENT evidence
only, because it is a new WRITE rather than an ack.

The gate refused the drain whenever no post-apply version was observed, which
dropped the keystroke and handed it to the webview's replay buffer. That buffer
does not survive the ordinary continuation: the apply DID move the document, so
its later onDidChangeTextDocument arrives with the lock already released, reads
as a lock-free forward advance, bumps the epoch, and edit-sync drops the buffer
on recordedEpoch > buf.epoch. No second fault is needed, so the refusal was a
deterministic loss of the keystroke the failure toast asks the user to retry.

Draining self-heals on that same continuation instead: the accept arm
re-acquires the lock at the settled base, so the echo lands lock-held, bumps
nothing, and its raise licenses the next ack. What the drain accepts is the
stale re-base residual - the re-acquired base is a known-stale lower bound, so a
later settlement can score our own increment as foreign and spend one spurious
epoch bump. Reaching it needs a second, independent read failure (that
settlement must also miss its content read while observing a version beyond
heldBase + ownContribution), and by then the drained keystroke is already on the
document. Strictly shallower harm at a strictly deeper fault; the residual is
documented at canDrain and in the liveness-backstop follow-up entry.

The diagnostic arm the gate needed ("unlabelled settle") is removed with it -
that configuration now drains, so the arm was unreachable. Tests are rewritten
to the new contract and pin both halves: the write happens, and the reachable
residual (a parse-failing stash redelivering its draft at the stored label) is
pinned literally rather than asserted away.

Also here, three comment corrections from the same review:
- settlementEffects cited a verbatim quotation that exists in no test and
  inverted the sibling's instruction; it now names apply-edit-settle-rejection's
  CONTAINMENT measurement and points the order pin at expectToastBeforeReseed.
- the ack gate's ACCEPTED RESIDUAL paragraph claimed the two readings are
  indistinguishable outright; that holds only when the content is also
  unobserved - with an observed content the byte mismatch already bumps.
- withholdAckEffects named only one of the two post-dispose suppression routes.
readVersionGuarded took `site: string`, so the roster of triage tokens was
enforced only by a comment asking readers to pass a literal, and just one of the
five labels was observed anywhere in the tests.

Type it as a closed union instead: the compiler now rejects an off-roster token
and any computed non-literal expression, which matters because this runs on a
failure path and must not evaluate anything that can throw. The union cannot
catch a copy-paste that stamps one valid token onto the wrong arm - the three
adjacent edit-rejected-recovery sites are exactly that shape - so the four
unpinned tests each assert the site their name claims.

Also here:
- reportResyncFailure's GUARDED rationale named only the reseed caller while the
  doc-comment above it says both triggers report through the helper; it now
  names both, which is why one guard suffices.
- the settle-read-failure warn listed only "no version advance" as the
  consequence of an unread version. This branch added a second one: the ack
  Document is withheld unless some source observed a post-apply version.
…lds them

Comment-only. Seven claims that a reader could act on but that do not survive
checking:

- the guarded-version-read roster does NOT make the literal rule a compiler
  guarantee — a helper returning the union, or a ternary over two valid tokens,
  type-checks fine, so the literal rule is stated as the convention it is;
- a VERSION-only read failure puts the self-advance AT RISK rather than
  suppressing it, since the guarded dispatch retry rescues the transient case
  (two source sites plus the test-side rationale);
- the withhold pair is unreachable post-dispose by THREE routes, not two — a
  stash that drains post-dispose never calls ackEffects at all;
- a persistent version-read failure withholds UNLESS a lock-held resync already
  raised the label (the second disjunct of ackLabelObserved);
- "saved your change" holds on the no-op route by precondition, but the
  landed-but-unverified route only has the apply's ok, which is why the toast
  keeps its "could not verify it" hedge;
- the canDrain revert warning now names the test that enforces it;
- the stale-re-base residual is tracked in the follow-up TODO entry but is NOT
  closed by that entry's ack-timeout / reseed-retry half: the mis-scoring
  happens inside the next settlement's version-delta fallback, which reads
  heldBase as exact and has no input a timeout or retry can reach.

Also replaces a rot-prone `edit-sync.ts:288` line reference with the symbolic
`recordedEpoch > buf.epoch` form the same describe block already uses.
…ricating ack evidence

The stash drain is the one degraded path in this file that carried no triage
record. It is also the only one that WRITES at a base it knows to be a lower
bound, so a later spurious epoch bump could not be attributed to the drain that
caused it. Emit a `logWarn` naming the stale re-base and both of its
consequences, on the accept and parse-failed arms.

Scoped three ways, each pinned:
- only when `ackLabelObserved` is false — there is no residual to report when
  the base rests on an observation;
- never on the readonly/stale/no-op arm, which already logs the same incident
  through `withholdAckEffects`;
- never post-dispose, where the accept arm deliberately does not re-acquire the
  lock, so neither named consequence can occur (`heldBase` is null there, and
  the record would cite a base that was never held).

Also pins the accepted residual itself: a second content-unobserved settlement
takes the version-delta fallback, which reads the re-acquired base as exact and
scores our own increment as foreign — exactly one epoch bump, with the drained
bytes already on the document.

Separately, the disposed-no-stash arm asked `settlementEffects` for the full
effect set and filtered it down to `showError`, which meant handing the ack gate
a literal `true` for an observation it did not have and depending on the filter
for its correctness. It now builds its toasts directly through `failureToasts`,
which `settlementEffects` also uses so the toast text keeps one owner.
… and pin the ack clause on both arrangements

The warn's parentheticals named a mix of result fields and event fields, so a
reader binding both to the pipeline result read "no version advance unless the
VERSION was read" as a verdict on this settlement — false whenever the guarded
dispatch retry rescued a transient failure. Name the EVENT fields, and say that
either source can supply the observation.

The ack clause was pinned only on the arrangement where it is true (a
version-only failure, where the ack really is withheld). The mirror — content
unread, version observed, ack posted — asserted nothing about the warn at all,
so an unconditional reword of that clause would have stayed green while the warn
claimed the opposite of what happened. That test now spies on console.warn and
pins the clause's conditional form.

Both existing substring pins are preserved verbatim.
The doc comment claimed the union is 'one per call family', contradicting
the very next sentence and the roster itself: family 3 (sendEditRejected's
three recovery dispatches) alone owns three of the five tokens. Corrected
to 'one per call site (not one per family ...)'.
…drain warn per arm

failureToasts switched over ApplyEditOutcome with a bare default reading
outcome.message, unlike every other outcome-kind switch in this file. Safe
only because all three folded-in variants happen to share a message field;
a future 6th variant would compile silently through the direct call site
(applyEditSettled's disposed-no-stash arm has no exhaustive switch upstream
to catch it). Switched to explicit cases plus a never-typed default, same
idiom as settlementEffects.

staleReBaseWarn's message asserted consequences that are false on one of
its two arms: the accept arm's 'the bytes land' clause is true but its
'a parse-failed draft goes out' clause is not (only an applyEdit is
emitted there), while the parse-failed arm is the reverse (no applyEdit,
only the rejected draft). Split the message per verdict.kind so each arm
states only what actually happens, keeping the shared 'unlabelled drain'
prefix the existing stringContaining pins key off.

Strengthened both existing pins to assert the arm-specific clause in
addition to the shared prefix, so a regression that collapses the two
messages back into one inaccurate string is caught.
… exclusion

The drain's readonly/stale/no-op arm deliberately does not spread
staleReBaseWarn into its effects (that residual is already logged through
withholdAckEffects). No test pinned that exclusion exhaustively, and the
readonly sub-case at an UNOBSERVED ack label had no test at all — the only
existing canWrite:false drain test uses an OBSERVED label. Strengthened
the existing no-op test to a full toEqual and added the missing readonly
case, both with a negative pin against a stray 'unlabelled drain' log.
…-label gate

The divergedAfterApply field doc described the ok-but-mismatch convergence
shape as epoch++ plus an authoritative resync plus a diverged log, which
reads as if all three are unconditional. Only two are: execute-write's
settle() guards the content read and the version read separately, so the
diverged tag proves the content was observed and says nothing about the
version. With an unobserved label the settlement swaps the ack Document for
the withhold pair while the epoch bump and the diverged log run regardless.

The POST-DISPOSE withhold-pair test still described the disposed no-stash
arm as filtering settlement effects down to showError. That arm now calls
failureToasts directly and never reaches settlementEffects, so neither the
ack nor the withhold pair is constructed at all -- which is what the test
actually pins.
@mtskf
mtskf merged commit 797fa9c into main Sep 9, 2026
2 checks passed
@mtskf
mtskf deleted the fix/settlement-ack-label-observed branch September 9, 2026 03:48
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