Decision packet: streaming commit/cancel after a focus, caret or unit mismatch¶
Status: RESEARCH DRAFT — REVIEW PENDING. A proposal for a maintainer decision; not an accepted ADR, and it authorises no code, default, dependency or release. Issue: #796 (parent #787) Builds on: Proposed ADR — streaming delivery boundary (invariant 4, "no blind cleanup"), streaming endpointing and commit safety, and the replay protocol spec (SR-006, SR-011). Code baseline: main @ 8a72bbc6. Every file:line below is at that commit. Evidence class: code reading plus the synthetic fake-screen tests. Nothing here was run against a real editor, X11, Wayland, macOS or Windows; statements about real editors are labelled hypothesis.
1. The question for the maintainer¶
[streaming] enabled = true (off by default: enabled: bool = False at src/yazses/config.py:350, in StreamingConfig at :331) types live partials into the focused application and, at hold release, replaces them blind: StreamingInjector.commit() sends shift+Left × chars_injected then types the final text, and cancel() sends BackSpace × chars_injected (src/yazses/inject/streaming.py:68-91). chars_injected is len(text) in Python code points (streaming.py:66). Nothing checks that the field, the caret, or the unit of caret movement is still the one the count was made against.
tests/test_streaming_fake_screen.py pins this with strict xfails. The decision is which policy turns them green, and what YazSes does with the final text when the answer is "we cannot safely clean up".
2. The xfails this packet is about¶
| Id | Test (tests/test_streaming_fake_screen.py) | Hazard | Today on the fake screen |
|---|---|---|---|
| X2c, X2k | test_desired_cleanup_after_focus_change_leaves_foreign_text[commit] / [cancel] (:272-284) | focus moves A→B after a preview; cleanup acts in B | "foreignfinal" / "foreign" — " text" deleted from B, "draft" orphaned in A (:260-269) |
| X3 | test_desired_cancel_after_caret_move_keeps_user_text (:302-309) | user clicks inside their own text; cancel backspaces from the new caret | "abc draft" becomes " draft" (:295-299) |
| X4z, X4r | test_desired_commit_keeps_text_before_a_multi_unit_preview[zwj-emoji+combining] / [rtl-with-harakat] (:343-358) | code points ≠ caret units; shift+Left over-selects | "keep|" loses 3 / 1 characters (:331-340) |
Out of scope, named so it is not mistaken for covered: X1 test_desired_every_confirmed_delta_reaches_the_reader (:221-234) is the engine's one-slot pending partial. None of the options below touch it; it needs a queue or a "replace the preview with the full confirmed prefix" contract in stt/streaming.py, a separate decision.
One gap the current suite does not pin, and every option must close: after focus moves to B, the poll thread keeps typing new partials into B (daemon.py:6678-6685 calls inject_partial with no target check). SR-006's oracle (target_writes == ()) forbids it.
3. Options¶
Option A — focus token checked before every write and before cleanup¶
Capture an opaque token for "where the preview lives" at the first partial; re-read it before each later partial and before commit()/cancel(). On a mismatch or an unreadable token, do not select, do not delete, do not type into the current target; seal and report an outcome the daemon routes to recovery.
Granularity, by what the code base can already read:
| Platform | Window level | Field level | Caret | Existing code |
|---|---|---|---|---|
| X11 | xdotool getactivewindow | no (needs AT-SPI) | no | windowctl/focus.py:196-201 |
| Linux + AT-SPI | via focus event's app | yes — focus events already handled | possible: Text.caretOffset (not read today) | inject/target.py:85-102 |
| Windows | GetForegroundWindow | GUITHREADINFO.hwndFocus (control, not text field) | no offset | platform/windows/winfo.py:138-150 |
| macOS | frontmost app (FocusedAppProvider) | AX focused element — needs Accessibility permission, not read today | AX selected range (hypothesis, unverified) | platform/macos/focused_app.py:61-72 |
| Wayland without AT-SPI | none — no portal for another client's focus | none | none | windowctl/focus.py:18-20 says so |
A pure window-level token turns X2 green. It does not turn X3 green: the caret moved inside the same field, and the token is unchanged. X3 needs a caret check, and the caret check has a trap: our own typing moves the caret, so a "caret changed" counter fires on every partial. The check has to be expected offset vs observed offset — record caretOffset after each own write, compare before cleanup — which only AT-SPI (and possibly macOS AX) can do. Everywhere else the caret is unverifiable, and Option A then falls through to Option C's behaviour for X3.
Option B — count caret units as grapheme clusters, not code points¶
Replace len(text) with units(text), the number of extended grapheme clusters (UAX #29 — reference, not re-read 2026-11-06). Turns X4z and X4r green on the fake screen, because the fake's approximate cluster model (tests/test_streaming_fake_screen.py:55-72) agrees with UAX #29 on both fixtures ("👩💻 café" with a combining acute = 6 clusters; "سَلام" = 4).
Risks that make B insufficient on its own:
- One unit does not fit two keys. Hypothesis to verify per editor: many editors move
shift+Leftby grapheme cluster but deleteBackSpaceby code point inside some scripts (combining marks, Arabic harakat, Indic clusters), socommitandcancelmay need different counts. The fake screen deletes by cluster too (:108-113), so it cannot show this. - Editors disagree with UAX #29 and with each other (terminals moving by cell or code point, emoji sequences the editor's font does not join). A count right for one app deletes foreign text in another — the guard-rarity rule in ADR-021 cuts the other way here: a destructive action needs proof, not a better guess.
- Normalisation in flight. If the injector or the target normalises (NFC/NFD) what it receives, the count is of a string the target never held.
- No stdlib segmenter. Python's
unicodedatahas no UAX #29 segmentation. The third- partyregexmodule's\Xdoes it; at8a72bbc6regexis only inuv.lockas a dependency ofwhisper-normalizer(uv.lock:6462), not a base dependency. Adding it is rule 4 work (latest stable,>=floor) and a hand-written segmenter is the "hand-written set is the defect" pattern.
B reduces how much is deleted when the count is wrong; it never makes a blind deletion safe, so it does not touch X2 or X3.
Option C — abandon the preview on any doubt (no blind cleanup)¶
When the target cannot be shown unchanged — token mismatch, token unreadable, caret unverifiable, preview contains anything outside a "provably 1 code point = 1 caret unit" set — commit()/cancel() send no selection, no backspace, and do not type the final text into the current target. The preview stays where it was; the final text goes to recovery (clipboard plus a notice, reusing the lost-dictation notice path already on main, or the staged buffer when [staged] is on).
Turns X2, X3 and X4 green by construction, at a cost: the user is left with a stale preview to remove by hand, and on a target with no probe (Wayland without AT-SPI) every commit abandons — streaming becomes "preview, then paste-by-hand". That is the honest consequence of the proposed ADR's invariant 4, and the natural follow-on is to refuse to start live partial typing when no probe exists (fall back to batch, say why once), rather than to abandon at the end.
Option D — no external preview (for completeness)¶
Already listed as "defer" in the 2026-11-03 proposed ADR: show partials only in the overlay; type once at release. X2, X3 and X4 become green trivially (nothing to clean up). It is the only option that removes the hazard rather than detecting it. It is mentioned so the comparison is complete, not re-argued here.
4. Which xfails each option turns green (fake screen)¶
| X2c / X2k focus | X3 caret | X4z / X4r units | new: partial typed into B | |
|---|---|---|---|---|
| A (window/field token) | green | red | red | green if the partial path checks too |
| A + caret offset (AT-SPI only) | green | green where readable | red | green |
| B (grapheme units) | red | red | green (fake model only) | red |
| C (abandon on doubt) | green | green | green | green |
| A + C (recommended shape) | green | green (C covers the unverifiable caret) | green (C covers non-trivial text) | green |
| A + B + C | as A + C, plus fewer abandons for plain-Unicode text on verified targets | |||
| D | green | green | green | n/a |
Caveat that applies to every row: the xfail tests build StreamingInjector(screen) with no probe (:241-245, :288-292, :354-357), and FakeScreen has no notion of a token. None of them turns green without the fixture also modelling the probe — e.g. FakeScreen.focus_token() returning (focused, caret_version) — and the test passing it in (or the injector discovering it). That is a fixture change, legitimate because the fake must model what the real screen reports, but it must be reviewed as such: a fixture that hands the injector the answer can make any implementation look right. When an xfail turns green it XPASSes, which fails the suite under xfail_strict; the change must promote it to an ordinary test and delete the paired "current behaviour" test in the same commit.
5. Interface sketch (proposal; no code written)¶
# src/yazses/inject/focus_token.py — pure, no platform imports
@dataclass(frozen=True)
class FocusToken:
window: str # opaque: X11 window id, HWND, macOS pid:window number
element: str = "" # opaque per-session element handle; "" = not readable
caret: int | None = None # caret offset in the element, None = not readable
def same_target(before: FocusToken | None, now: FocusToken | None) -> bool | None:
"""True/False when provable; None when either side cannot tell (treated as False)."""
class Outcome(Enum):
REPLACED = "replaced" # preview selected and replaced (today's path)
TYPED = "typed" # no preview was typed; final typed normally
ABANDONED_TARGET = "abandoned_target" # focus/caret provably changed
ABANDONED_UNKNOWN = "abandoned_unknown"# could not verify; nothing sent
# src/yazses/platform/base.py — new Protocol, next to FocusedAppProvider (:468-487)
@runtime_checkable
class FocusTokenProvider(Protocol):
def token(self) -> FocusToken | None:
"""Cheap, never raises, never returns a title or on-screen text."""
# src/yazses/inject/streaming.py
class StreamingInjector:
def __init__(self, injector, focus_probe: Callable[[], FocusToken | None] | None = None,
select_units: Callable[[str], int] = len,
delete_units: Callable[[str], int] = len) -> None: ...
def inject_partial(self, text: str) -> None: ... # checks token; on mismatch stops previewing
def commit(self, final_text: str) -> Outcome: ... # caller routes ABANDONED_* to recovery
def cancel(self) -> Outcome: ...
Daemon touch points: construction at daemon.py:4034, the commit at daemon.py:5390 (the only caller that must act on the outcome — an ABANDONED_* result must not fall through to "injected"), and the poll loop at daemon.py:6678-6685. The 34 cancel() calls on the streaming injector (daemon.py:4262 through :6538) can keep ignoring the return value.
Privacy and the offline rule: tokens are opaque ids held in process memory, never titles or text, never logged above DEBUG, and add no outbound primitive (no change to the ADR-019 egress inventory).
6. Decisions the maintainer needs to make¶
- Default when no probe exists (
focus_probe=None): keep today's blind cleanup, or fail closed (Option C)? Failing closed changes behaviour for anyone who opted into streaming; keeping it leaves X2–X4 red wherever there is no probe. - Where an abandoned final goes: clipboard + notice, staged buffer, or the current target anyway (batch-mode semantics: the text lands wherever focus is at release, which is what non-streaming YazSes already does). SR-006's oracle says no write; batch mode says write where focus is. Pick one and record why.
- Refuse-to-preview on unprobeable targets (Wayland without AT-SPI) vs abandon at the end.
- Option B at all, and if so: separate select and delete units, and a
regexdependency (base, or an extra with anlenfallback that then abandons non-ASCII). - Whether this lands before or after the
[staged]+[streaming]safety fix the 2026-11-03 proposal puts first.
Proposal (not a decision): A + C, with C as the fail-closed default; refuse live preview where no probe exists; B deferred until per-editor evidence exists for which unit each key moves. Rationale: only C removes blind deletion everywhere, A keeps the common "nothing moved" case working where it can be verified, and B's correctness depends on editor facts nobody has measured.
7. Acceptance tests (all deterministic, fake-screen; to be written with the change)¶
- Promote X2c, X2k, X3, X4z, X4r; delete their paired current-behaviour tests (
:260-269,:295-299,:331-340) or flip them to assert the new outcome. - Partial after focus change: preview in A, focus B, one more partial → B unchanged, A unchanged,
commit()returnsABANDONED_TARGET. - Unchanged target regression: ASCII and plain-Arabic controls (
"hello","مرحبا") with a stable token → still"keep|X"andOutcome.REPLACED(the feature keeps working). - Probe returns None / raises → no
shift+Left, noBackSpace, no final typed,ABANDONED_UNKNOWN; the probe exception does not escape. - A→B→A: focus returns to A before release with the caret where we left it → REPLACED only if the caret offset matches; otherwise abandoned.
- Seal still holds: a late partial after an abandoned commit is dropped (extends
:364-371). - Daemon: an
ABANDONED_*outcome atdaemon.py:5390reaches recovery exactly once, records the dictation as not injected, and types nothing. - Option B only:
units()over the vendored UAX #29GraphemeBreakTest.txtcases (Unicode licence; vendored, since tests are offline), and a property test thatselect_units(s) <= len(s)for every string. - Latency: the probe runs under the injector lock on the hold-release path; X11's
xdotoolpath costs one subprocess per check (2 s timeout atinject/target.py:159). Measure before/after on hardware; this is a hardware-QA item, not a CI test.
8. Residual risks no option removes¶
- TOCTOU: focus can change between the probe and the keystroke. The lock serialises YazSes against itself, not against the user or the window manager.
- Window-level tokens miss in-window focus moves (another tab, another field in the same window) on X11 and Windows without element-level focus.
- Fake-screen green is not editor green. Promotion to a default, or any claim that streaming is safe on a named platform, needs the hardware/application verification the replay spec's stop/go section requires (
design/specs/streaming-commit-replay-protocol.md§8).