Skip to content

fix: survive third-party DOM mutation from page translation - #3065

Closed
basnijholt wants to merge 344 commits into
cinnyapp:devfrom
mindroom-ai:fix/translate-dom-mutation-crash
Closed

fix: survive third-party DOM mutation from page translation#3065
basnijholt wants to merge 344 commits into
cinnyapp:devfrom
mindroom-ai:fix/translate-dom-mutation-crash

Conversation

@basnijholt

Copy link
Copy Markdown

Problem

A user on a Dutch-locale Chrome hit this on a thread at chat.mindroom.chat:

NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
    at pte (https://chat.mindroom.chat/assets/index-B042P1B6.js:9:26173)
    at U1 (...)

The whole app was replaced by react-router's default error page ("Unexpected Application Error!").

Root cause

Chrome's page translator moves React-owned text nodes into injected <font> wrappers. React still holds the original node and calls parent.removeChild(node) on the next commit, which throws out of the commit phase and escapes to the router error boundary.

Evidence that translation was live on the document: the reported stack trace was itself translated — V8 never localizes the at prefix in error.stack, yet the screenshot shows op pte (...) and bij U1 (...). "Onverwachte toepassingsfout!" also does not appear in src/app/locales/nl.json; it is react-router's hardcoded English heading, translated in place.

Streaming MindRoom replies send frequent m.replace edits, so a translated thread re-commits constantly and crashes quickly. That matches the ?threadId= URL in the report.

App-side causes were ruled out: the only removeChild in src is CallEmbed.ts:299, already wrapped in a guarded attempt(), and dom.ts:116 appends its temporary clipboard input outside the React root.

Fix

Layer one — stop the translator. index.html now sets translate="no" on <html> and adds <meta name="google" content="notranslate">. This costs nothing in reach: the in-app language picker already covers en/de/nl, and i18next-browser-languagedetector already resolves navigator ahead of htmlTag, so a Dutch browser gets a Dutch UI without the browser translator.

Layer two — survive anything else. installDomMutationGuard() patches Node.prototype.removeChild / insertBefore so a wrong-parent call no-ops (or appends) instead of throwing. This covers extensions that ignore notranslate. It runs before the first React commit, is idempotent, returns an uninstall function for tests, and logs one console.warn per session so genuine app-side DOM bugs stay visible.

Changes

  • index.htmltranslate="no" + notranslate meta
  • src/app/utils/domMutationGuard.ts — new guard
  • src/index.tsx — install before the first commit
  • src/app/utils/domMutationGuard.test.tsx — regression coverage
  • FORK_CHANGES.md — Runbook entry

Validation

  • domMutationGuard.test.tsx (7 tests): the unguarded crash reproduces deterministically, the guarded path recovers and keeps rendering, warn-once, reparented-anchor append, well-formed calls untouched, idempotence, uninstall restoration
  • npm run typecheck clean
  • npm run build clean; both attributes verified present in dist/index.html
  • npm run lint 0 errors, 17 warnings (existing baseline)
  • npx prettier --check on touched files, and git diff --check, both clean
  • npm run test: 3497 passed, 1 failed — src/app/styles/scrollbarTheme.test.ts, which reproduces on clean dev in a throwaway worktree. It asserts on hashed selectors in node_modules/folds/dist/style.css and reads no file this PR touches.

Deploy note

Cached clients keep crashing until they pick up the new index.html, so this needs the Cinny deploy to /var/www/cinny/dist to take effect.

basnijholt and others added 30 commits July 3, 2026 22:19
…207 P3 gate)

Docker gate on the P3.3 tip caught the stop-emoji spec: after the
engine strip removed the viewer's "open thread" context, a redacted
reaction on a thread reply landed in ROOM cache scope only. Thread
hydration reads THREAD-scope records, so the P1.2 I2 protection
(re-applying the cached redaction against stale un-pruned /relations
copies and gappy-sync reloads) was silently lost for exactly this
case.

Root cause: planRedactionCacheCleanup is called with
`fallbackThreadId: undefined` from the engine (no UI state). For a
pruned reaction, its m.relates_to is redacted away before
RoomEvent.Redaction fires, `threadRootId` is often gone, and the
redaction event itself is not a thread event. Every hint in the
plan's chain (getThreadCacheTargetId → target.threadRootId →
redaction.threadRootId) returns undefined, so threadCacheTargetId
is undefined and the engine persists to room scope only.

Fix at the plan level (chosen over engine-side helper — the plan
already receives `room` and it centralizes the entire attribution
hint chain in one pure function; the handler stays declarative):
when every event-side hint is gone, iterate room.getThreads() and
ask each thread's getUnfilteredTimelineSet().findEventById() for
the redacted event id. Exactly one hit → use that thread as the
attribution with threadTargetFromFallback: false (SDK membership IS
the event's attribution, not a viewer-side guess). Ambiguous (>1)
or zero hits → keep the previous behavior (by-event-id scan +
room-scope persist).

Red-first evidence: new tests fail against the unfixed plan
(threadCacheTargetId undefined instead of $thread-root; engine
persistThreadEventCacheSnapshot called 0 times instead of 1) and
pass with the fix. Ambiguous / zero-hit tests already pass, proving
existing fallback behavior is preserved.

Tests:
- redactionCacheLifecycle.test.ts +3 pure-plan cases
  (single-thread hit / ambiguous multi-thread / zero-thread).
- engineWriteThrough.redaction.test.ts (new file, +3 tests):
  engine-boundary integration — dispatch → plan → deletes +
  persists, asserting the derived thread id flows through both
  delete and persist and that ambiguous / zero-hit cases still hit
  the room-scope fallback.

Validation:
- npx vitest run src/app/mindroom/engine/ → 60/60 green.
- npx vitest run src/app/mindroom/ → 226 files / 1950 tests green.
- npm run typecheck clean.
- npm run lint → 18 warnings, 0 errors, zero delta from baseline.

Co-authored-by: p33-impl (inherited-draft) <noreply@mindroom.chat>
… P3 gate round 2)

The round-1 attempt (9a6b15b) modelled the wrong reality. matrix-js-sdk's
applyEventAsRedaction calls moveAllRelatedToMainTimeline for non-root thread
events, which removes the redacted target from its thread's timelineSet
BEFORE RoomEvent.Redaction fires. Round-1's plan-level scan of
room.getThreads() therefore found zero hits at fire time and never derived
attribution for the stop-emoji case (docker gate re-run confirmed the same
failure at spec line 116 on a clean network).

Two layers replace it, both stronger than the round-1 scan:

Layer 1 (PRIMARY, cache-derived, cannot be defeated by SDK timeline movement):
deleteThreadEventFromCacheByEventId now returns Promise<string[]> — the
thread scopes it deleted from (deduped, empty on no-match). The engine's
redaction handler, when the plan yields no threadCacheTargetId, awaits the
walker and persists the redaction tombstone to each returned scope. The
tombstone lands precisely where the reaction record physically lived.

Layer 2 (SECONDARY hint, pre-prune from the SDK): mindroomSyncEngine now
plumbs RoomEvent.Redaction's third arg (threadId, captured pre-prune at
room.js:2255 before makeRedacted runs) into EngineLiveEventMeta.sdkThreadId,
which the plan honors as sdkThreadIdHint at the top of its hint chain
(authoritative, not threadTargetFromFallback). This covers thread MESSAGE
redactions (which don't hit layer 1 because there's no reaction record to
delete) and any reaction whose redaction reaches the SDK-emit path.

The round-1 plan-level SDK-thread-set scan is kept as a harmless leftover
for the case where the redacted event IS still threaded at fire time (thread
messages never hit moveAllRelatedToMainTimeline; only reactions do).

Contract test updated: the walker returns the scope(s) it deleted from
(single scope, multiple scopes, and empty on no-match). Engine boundary
tests rewritten to model reality — the reaction is NOT in the thread
timelineSet at fire time. Plan-level test added for the sdkThreadIdHint
priority in the hint chain.

Validation: engine + cacheStore vitest 153/153, full mindroom vitest 226
files / 1955 tests, typecheck clean, lint 18 warnings 0 errors (baseline
delta zero).
…ken (CINNY-207 P3.3 review)

Two defects in the explicit persist point for network back-pagination:

- The collection loop walked from the newest end and stopped at the
  pre-fetch earliest event, so when the SDK extends the timeline in
  place (the common case) it collected the already-known NEWER events
  and missed the newly fetched OLDER slice entirely. Now walks from the
  oldest end and stops at the pre-fetch earliest.
- The persist call passed no beforeTokenForEarliest, losing the
  continuity proof the deleted P1.1 sweep used to write; a reload could
  not trust cached back-pagination past the new earliest event. The
  timeline's backward token now flows with the batch (null is
  meaningful: room-start proof).

Red-first: the new persist-point test fails against the prior loop
(wrong slice, no token).
…207 P4.1)

Introduce a client-scoped scheduler that will serialize every backfill-
shaped network fetch the app makes: gap fills for Phase 3's tail
discontinuities, thread-open backfills, thread-seed warm-ups, and the
room's own deep-history sweep. Phase 4 lands the queue + dedup + cap
first (this commit) and then wires actual fetch executors on top in
P4.2/P4.3/P4.4.

Invariants:

  AC8 dedup: one Map covers queued AND in-flight jobs. Enqueuing a
    second (roomId, threadId, kind) while the first is still around
    returns the same promise identity — the caller gets the outcome
    for free, and the redundant executor is never invoked. Bumps
    schedulerDeduped on the rejected duplicate.

  Priority + activity: five bands (0=current-room/open-triggered;
    1=my-server gap-fills on other rooms; 2=recently-active my-server
    tails; 3=thread inventory prewarm; 4=current-room deep history).
    Within a band, order by `room.getLastActiveTimestamp()`
    descending. At most MAX_CONCURRENT_BACKFILL_JOBS (=2) run at
    once — enough to overlap I/O without swamping the server.

  Cooperative abort v1: the SDK's `mx.fetchRelations` /
    `mx.createMessagesRequest` do NOT accept an AbortSignal today, so
    executors receive one and MUST check `signal.aborted` between
    batches. Cancellation lands between requests, not mid-request.
    Follow-up (recorded in Deviations for the FINAL docs commit):
    migrate the raw fetch onto `mx.http.authedRequest({abortSignal})`
    once we're comfortable bypassing the SDK helpers.

Engine wiring: `createMindroomSyncEngine` instantiates the scheduler
alongside the write-through + gap tracker, exposes it on the returned
instance, and calls `abortAll()` from `stop()` so account switch /
logout tears down every in-flight fetch.

Observability: four new probe counters (`schedulerEnqueued`,
`schedulerDeduped`, `schedulerAborted`, `schedulerCompleted`) on
`window.__MINDROOM_CACHE_PROBE__` — together the AC8 evidence handle.

Tests: 14 new unit tests in `backfillScheduler.test.ts` covering the
three invariants plus abort semantics (in-flight, queued, unknown key,
`abortAll`), a `pendingJobs()` snapshot for future integration, and
executor error propagation without slot leakage. Full engine test
folder green (77/77); RoomTimeline harness updated (harness engine now
carries an empty scheduler so consumer components that hold a
reference typecheck).

Validation: `npx tsc --noEmit` clean; engine + RoomTimeline vitest
folders 261/261 green.
…Y-219) (#62)

* feat(mindroom): timeline minimap with per-human-message stripes (CINNY-219)

Port the t3code conversation minimap to the MindRoom timeline: left-edge
stripes for every rendered message not sent by a MindRoom agent, with a
hover preview card (human message + final agent reply), click-to-jump via
handleOpenEvent, keyboard navigation, and scroll-frame in-view tracking.

Agent detection combines the platform's mindroom_ localpart prefix with
io.mindroom.* content metadata. Fine-pointer devices only; hidden in the
compact room overview. See the CINNY-219 Runbook entry in FORK_CHANGES.md.

* fix(minimap): address PR #62 review findings

- omit aria-valuenow when no stripe is active instead of announcing 0
- make e2e fixture passwords env-overridable (E2E_HUMAN_PASSWORD/E2E_AGENT_PASSWORD)
- range-based in-view tracking: a stripe stays lit while scrolling through
  its (possibly viewport-tall) agent reply, not only while the question
  itself intersects the viewport
- re-run the in-view pass on container/content resize (window resize,
  collapsible expand, media load) via ResizeObserver
- stop pairing an agent reply across a redacted human question; redacted
  non-agent messages terminate the pairing run (regression tests added)

* refactor(minimap): apply review nits

- track (pointer: fine) via the media query's change event (same pattern
  as useSystemThemeKind) so plugging in a mouse enables the minimap
  without a room/thread switch
- build fixture login JSON with json.dumps so overridden passwords with
  quotes or backslashes can't break the request body
Land the second half of the F13 tier plan: the executor that turns
the Phase 3.2 gap-fill queue from an enqueue-only detector into an
actual "message a room missed while offline reaches the cache after
restart" path, and the D3 prefetch policy that keeps every future
speculative fetch friendly to remote homeservers.

New modules:

  engine/prefetchPolicy.ts — D3 policy. `resolveRoomPrefetchTier`
    compares the room's `m.room.create` sender domain to
    `mx.getDomain()`; matches → `own`, differs → `federated`,
    missing → `background`. Never parses room ids (room v12 / MSC4291
    room ids are opaque — this is deliberate). `isRoomEligibleForRawFetch`
    is the raw-fetch gate: rejects federated + encrypted + background
    rooms. Constants (`ROOM_TAIL_PREFETCH_DEPTH`,
    `THREAD_INVENTORY_PREFETCH_LIMIT`,
    `CURRENT_ROOM_DEEP_HISTORY_TARGET`) live here so the scheduler
    is the single arbiter of depth.

  engine/gapFillExecutor.ts — over the P4.1 `BackfillScheduler`.
    Subscribes to the gap tracker's queue via the new `onEnqueue`
    hook, so a fresh `RoomEvent.TimelineReset` -> `limited-sync` enqueue
    dispatches immediately. Executor uses `mx.createMessagesRequest`
    (Direction.Backward, 200/batch, up to 20 iterations) — the first
    caller of that SDK method in this fork (Deviations §8). Persists
    each chunk via `saveRoomEventsToCache`, then clears the durable
    `tailDiscontinuity` marker on success (persisted anything OR
    server confirmed no more history). On abort or transport error
    the marker stays put so the next boot retries. Federated rooms
    are short-circuited BEFORE the scheduler sees them; encrypted
    rooms pass the tier gate but the raw-fetch gate rejects and
    clears the marker (they're never usable via /messages without
    decryption context).

New cacheStore surface: `noteRoomFederated(sessionId, roomId, flag)`
in `cacheStoreLedger.ts` — patch-only setter for the ledger's
`federated` field. Existing rows: single-field replace, other
counters untouched (so LRU-inside-priority ordering the eviction job
depends on is preserved). Missing rows: minimal bootstrap
(`approxBytes=0`, `eventCount=0`, `lastActivityTs=0`, `federated`)
so the flag survives until the events store's ledger tracker fills
in real deltas. Fills the P2.2-deferred writer gap.

Engine consolidation: `mindroomSyncEngine` grows a `noteRoomFocused`
method exposed on the returned instance. Bookkeeping executed per
call: resolve tier (skip stamp for `background`), `noteRoomFederated`,
`setEvictionProtectedRoomIds([roomId])` (single-element v1 —
Deviations §8: LRU inside priority covers everything else),
`noteRoomOpened` + `noteThreadOpened` for lastOpenedTs. MindroomRoomTimeline
calls it from a useEffect keyed [syncEngine, room.roomId, threadId]
sitting under the enginePersistForRoom useMemo.

E2E spec tightened (AC13, team-lead-approved divergence-3): the P3.2
red version sent ONE offline message and slept — Tuwunel rarely
declares `limited=true` for that single event, so the fill path only
sometimes ran. The green version sends ~25 REST messages while the
page is still mounted, reloads, resets the probe on the fresh page,
waits 12s, and asserts (a) `schedulerCompleted >= 1`, (b)
`gapFillsEnqueued >= 1`, (c) the last REST event id is present in
the room event cache. `test.fail()` removed.

Engine gap tracker: `GapFillScheduler` now exposes
`onEnqueue(listener)` returning an unsubscribe. The in-memory
scheduler fires listeners in a try/catch so a misbehaving subscriber
can't break the tracker.

Architecture guard updated: `mindroomSyncEngine.ts` and
`gapFillExecutor.ts` join `engineGapTracker.ts` on the cacheStore-
consumer allowlist. Both are engine-native cache orchestrators
(D2) — routing through eventRepository would be a gratuitous
pass-through.

Test harness: `MindroomSyncEngine` mock in
`RoomTimeline.test.shared.ts` and `RoomTimelineCollapsible.test.ts`
now includes `noteRoomFocused: () => undefined` and
`scheduler: createBackfillScheduler()` so consumers using either
don't crash.

Tests: 4 new suites, 24 tests total.
  - prefetchPolicy.test.ts (10): tier resolution across own /
    federated / port-suffixed / missing-create / malformed-sender /
    unknown-our-domain; eligibility across encrypted / federated /
    background.
  - gapFillExecutor.test.ts (5): drains a queued limited-sync job
    and clears the marker; skips federated rooms (marker preserved);
    skips encrypted own-server rooms (marker cleared); subscribes to
    future enqueues; leaves the marker on network failure.
  - noteRoomFocused.test.ts (4): federated=false for own-server;
    federated=true for other-server; no stamp for background;
    single-element protection replacement.
  - E2E cinny207-gap-fill-restart tightened (still docker-gated).

Validation: engine + threads folders 373 tests passing (added 24 net
new); full mindroom suite 230 files / 1988 tests green; typecheck
clean.
…b (CINNY-207 P4.3)

Retire `useRoomEagerPreload` and its whole render-facing progressive
recalibration dance. Deep-history sweep is now a band-4 job on the
engine's `BackfillScheduler` that fetches `/messages` via
`mx.createMessagesRequest` and persists straight through
`saveRoomEventsToCache` — the SDK live timeline is never mutated by
the sweep, so React stops paying the recalibrate cost per batch and
the user gets bottomless scrollback on next cache hydration.

Motivations (from the plan):
  - F13: the old loop was unbounded eager preload, which is why very
    active rooms would OOM the tab.
  - D14: prefer the "persist raw events to IDB, let hydration read on
    demand" pattern over "grow the SDK's in-memory timeline forever".
  - AC8: the scheduler's dedup key
    (roomId, undefined, 'room-deep-history') means view-mode flips,
    thread open/close, and the countless RoomTimeline re-mounts
    triggered by focus/filter changes NEVER fire redundant deep-history
    sweeps.

New module: `engine/deepHistoryJob.ts`.
  `enqueueRoomDeepHistoryJob({mx, sessionId, scheduler, roomId,
   targetEventCount?})` submits a band-4 job. The executor:
    1. Skips encrypted / federated / background rooms via
       `isRoomEligibleForRawFetch` (same policy as gap-fill).
    2. Reads the room's live-timeline backward token as the starting
       from-token (matches the old preload's saved-token restoration).
    3. Loops `mx.createMessagesRequest(Direction.Backward, 200/batch)`
       until reaching CURRENT_ROOM_DEEP_HISTORY_TARGET (10000 events),
       the server signals no more history (end===undefined), the token
       is stuck (same token twice), or the AbortSignal fires.
    4. Persists each chunk via `saveRoomEventsToCache`.
    5. Yields to a macrotask between batches so a long sweep can't
       starve UI callbacks.

Deletions:
  - `src/app/mindroom/threads/preloadController.ts` — the whole hook
    plus its recalibrate + stall-guard state machine (317 lines).
  - `[eagerPreloading, setEagerPreloading]` React state in
    `MindroomRoomTimeline`; the reset useLayoutEffect; the
    `eagerPreloadDoneForRoomRef` and its handoff to
    `useRoomCacheHydrationController`; the `!eagerPreloading` term in
    the skeleton gate at the top of the room timeline (skeleton now
    relies on cache/live counts alone).
  - `eagerPreloading` prop on `useTimelineDebugRangeController` and
    the debug log entry that carried it (mirror test updated).
  - `roomCacheHydrationController` no longer takes the preload-done
    ref or the eagerPreloading setter; the `.finally()` block that
    used to clear the loading flag on re-entry is gone.

Wiring:
  - `MindroomRoomTimeline` calls `enqueueRoomDeepHistoryJob` from a
    useEffect keyed [eventId, mx, room.roomId, roomEagerPreloadEnabled,
    sessionId, syncEngine, threadId]. Fire-and-forget; the scheduler
    dedupes and `syncEngine.stop()` aborts on account switch.
  - Engine barrel exports `enqueueRoomDeepHistoryJob` and
    `EnqueueDeepHistoryArgs`.

Architecture guards updated:
  - Rewrote the "delegates eager room preload orchestration outside
    RoomTimeline" guard in `RoomTimeline.architecture.test.ts`. It now
    asserts (a) `useRoomEagerPreload` is NOT present in RoomTimeline,
    (b) `enqueueRoomDeepHistoryJob` IS present.
  - New guard "keeps backfill network fetchers inside the engine":
    RoomTimeline must not call `mx.createMessagesRequest` directly.
  - `deepHistoryJob.ts` joins the cacheStore-consumer allowlist next
    to `gapFillExecutor.ts` (engine-native cache orchestrator).

Deviations (recorded in the FINAL docs commit):
  - Progressive-render recalibration NOT replicated. Old behavior:
    scrollbar height grew smoothly as each batch landed. New behavior:
    events land in IDB; the next mount / cache-hydrate pass surfaces
    them at once. Cost: users don't see the "loading more" scroll
    smear anymore. Benefit: no per-batch React re-render storm.
  - Cooperative abort v1 (same as backfillScheduler.ts): the executor
    checks `signal.aborted` between batches, not mid-request.

Tests:
  - deepHistoryJob.test.ts (4): drains the request loop until target
    reached; skips encrypted rooms; deduplicates concurrent enqueues
    (AC8); starts from the room's live-timeline backward token.
  - Deleted "keeps eager-preloading past fifty batches" from
    RoomTimeline.cache.test.ts — it asserted the deleted preload
    loop's iteration count on `mx.paginateEventTimeline`; the
    engine-side behavior is covered by the new deepHistoryJob suite.
  - timelineDebugController.test.ts updated to match the trimmed
    prop shape.

Validation: full vitest 337 files / 2553 tests green (up from 2549
after P4.2); typecheck clean.
…eduler (CINNY-207 P4.4)

Route the two remaining backfill-shaped fetches through the engine's
BackfillScheduler so AC8 dedup applies to them the same way it
already applies to gap-fill and deep-history:

  - ensureThreadSeedPrewarm now wraps its cache-first seed load in
    `syncEngine.scheduler.enqueue({kind: 'thread-seed', priority: 3})`.
    The per-controller `prewarmingThreadSeedPromisesRef` map used to
    be the F9 dedup point, but it only deduped WITHIN a single
    MindroomRoomTimeline mount — concurrent controllers or a remount
    would re-fire the same seed load. Client-scoped scheduler dedup
    guarantees at most one in-flight `loadThreadCachedSnapshot` +
    `mergeThreadBackfillEvents` for a given (room, thread). The
    controller-local refs are still populated for downstream
    consumers (e.g. `startUntargetedSeedPrewarmWait` in
    threadOpenSeedController reads the map to decide whether to
    await); they now mirror scheduler state instead of owning it.

  - refreshOverviewThreadCacheFromRelations now wraps its
    `fetchAllThreadRelations` + persist call in
    `syncEngine.scheduler.enqueue({kind: 'thread-backfill', priority: 2})`.
    Two overview-resume signals (visibility + focus firing in quick
    succession, or focus + online during a hop), or two independent
    resume callbacks for the same thread from different producers,
    now share the fetch. `overviewResumeRefreshInFlightRef` and
    `pendingOverviewResumeRefreshRef` are deleted; the 1s
    rate-limit on the outer trigger stays (it's a burst-fire guard
    on the OUTER controller, not per-fetch dedup).

Priority selection:
  - band 2 for thread-backfill: overview resume is user-triggered
    (page focus / online / visibility) so it beats prewarm (band 3)
    but yields to the current room's own gap-fill (band 0-1).
  - band 3 for thread-seed: inventory prewarm; yields to interactive
    work and to the current-room gap-fill, but runs ahead of the
    band-4 deep-history sweep so a room open followed by a thread
    open stays snappy even mid-sweep.

The two controller files keep their outer React shape (state,
generation guards, priority-target drain loop) — this commit does
not rip out orchestration, only relocates fetch dedup onto the
scheduler. That preserves every downstream consumer's API
(prewarmedThreadSeedIdsRef, prewarmingThreadSeedIdsRef, etc.) so
threadOpenSeedController and threadOpenLifecycleController don't
need to change in this commit. The remaining
threadSeedPrewarmController scaffolding is a candidate for further
simplification in a follow-up phase (P5+), where seed jobs could be
enqueued directly from `engine.noteRoomFocused` — recorded as a
Deviation for the FINAL docs commit.

Behavior:
  - No functional change users can see. Idle refresh count is
    strictly lower (dedup) rather than higher. Every existing test
    covering these controllers still passes because the callbacks'
    outer shape and effect ordering are preserved.

Validation: full mindroom vitest 231 files / 1992 tests green;
typecheck clean.
Wraps `cache-overhaul/11-p4-scheduler` with the runbook, plan
updates, scorecard rows for AC8 + AC13, and the seven Deviations
entries for the design choices team-lead approved in the GO-P4
briefing.

Content:

- FORK_CHANGES.md: full "Phase 4 - BackfillScheduler + prefetch
  policy" runbook entry ahead of the P3 gate entries. Lists the
  four feature commits with the file-level shape, the AC8 + AC13
  scorecard status, and the validation numbers.

- docs/mindroom-cache-overhaul-plan.md:
  - Header status: Phase 4 landed locally; Phase 5 next.
  - Section 5: Phase 4 phase bullets tagged "landed 2026-07-04"
    with the four commit SHAs.
  - Section 6.3 Scorecard: AC8 marked `☐ impl` with the exhaustive
    evidence trail (backfillScheduler unit tests + deepHistoryJob
    + gapFillExecutor dedup, plus the P4.4 controller-level
    integration). AC13 marked `☐ impl` with the executor design,
    the tightened AC13 spec shape, and the docker-gate handoff
    note.
  - Section 6.4 Regression guards: extended the cacheStore-
    consumer allowlist with the four engine-native cache
    orchestrators; added the new "backfill network fetchers live
    inside the engine" guard from P4.3.
  - Section 8 Deviations: seven new entries covering cooperative
    abort v1, `createMessagesRequest` as the raw-fetch primitive,
    `noteRoomFederated` as a patch-only setter, AC13 spec
    tightening, single-element eviction protection v1, no
    progressive-render recalibration for the deep-history job,
    and the "P4.4 keeps controller scaffolding" note.
  - Section 9 Status log: Phase 4 landing entry with the full
    commit list, validation numbers (2553/2553 vitest, 18-warning
    lint zero delta), and the docker-gate handoff.

- Lint zero-delta cleanup:
  - `engine/deepHistoryJob.ts`, `engine/__tests__/gapFillExecutor.test.ts`,
    `engine/__tests__/noteRoomFocused.test.ts` — wrap the
    `new Promise((r) => setTimeout(r, N))` pattern in a block-body
    executor so the `no-promise-executor-return` rule stops
    counting it as returning the setTimeout id. Behavior identical.
  - `threads/roomCacheHydrationController.ts` — restore `setAtBottom`
    to the effect dep list I trimmed in P4.3 (the effect calls it
    unconditionally, so the exhaustive-deps rule wanted it back).

Validation (final gate):
- `npx tsc --noEmit` clean.
- `npx vitest run` full 337 files / 2553 tests green.
- `npm run build` OK.
- `npm run lint` 18 warnings / 0 errors — matches the P3 baseline
  exactly (zero delta).

Docker gate: NOT run by the implementing agent. Team-lead runs the
AC13 flip (green-shape) + the regression trio (stop-emoji,
streamed-edit, background-freshness) after PR open.
First docker gate on the Phase 4 branch reported AC13
(cinny207-gap-fill-restart) failing at spec:130 with
`schedulerCompleted >= 1` receiving 0, while the earlier
`gapFillsEnqueued >= 1` assertion passed. That snapshot is
ambiguous between "silent job failure" and "policy skipped before
scheduler saw it" — exactly what made debugging from a probe
snapshot hard. Three converging fixes land here.

1. `schedulerFailed` probe counter, bumped from the scheduler's
   non-abort reject branch. Previously a non-abort executor throw
   counted as neither `schedulerCompleted` nor `schedulerAborted`;
   the counter shape couldn't distinguish "job silently rejected"
   from "job never ran". The AC13 spec now asserts
   `schedulerFailed === 0` with a diagnostic message pointing at
   `createMessagesRequest` / `saveRoomEventsToCache` so the next
   gate iteration doesn't need log spelunking.

2. `gapFillExecutor.enqueue` no longer short-circuits on tier.
   Every tracker enqueue now enters the backfill scheduler so
   `gapFillsEnqueued` and `schedulerEnqueued` stay in lockstep.
   The `isRoomEligibleForRawFetch` gate still fires inside
   `runOnce` with the same net behavior (no /messages call for
   federated / encrypted rooms) but the completion counter bumps.
   Marker semantics preserved by tier inside `runOnce`:
   encrypted-own clears the marker (unusable ciphertext, no retry
   point); federated preserves the marker (Deviations §8 policy —
   handled by user attention, not background sweeps).

3. AC13 spec's probe reset moves from
   `page.evaluate(() => probe.reset())` after
   `expectLoggedInShellStable` to `page.addInitScript` BEFORE the
   reload. The old order zeroed the probe AFTER the post-reload
   engine had already primed, enqueued startup jobs on
   Sync→PREPARED, and quite possibly completed some — so any
   `schedulerCompleted` bumps during that window were wiped and
   the assertion relied on a NEW enqueue in the following 12s
   (racy on a well-caught-up sync). Init-script runs on the fresh
   document before app JS mounts; the probe module installs
   itself on import and the script resets it via microtask + rAF
   fallback so every counter delta is attributable to the
   post-reload engine.

Tests: +2 scheduler (`schedulerFailed` counted on non-abort
reject; abort-caused reject still counts as `schedulerAborted`,
not `schedulerFailed`). +1 gapFillExecutor red-first AC13
mechanism (startup job for own-server room with
`prevBatch=undefined` still enters the scheduler and completes:
`schedulerEnqueued=1, schedulerCompleted=1, schedulerFailed=0`,
with the SDK call landing on `fromToken=null`). Federated-room
test updated for the new lockstep behavior (marker preserved, no
/messages call, but `schedulerEnqueued=1, schedulerCompleted=1`).

Validation: `npx tsc --noEmit` clean; `npx vitest run` 337 files
/ 2557 tests green (+4 vs the Phase 4 baseline 2553);
`npm run build` OK; `npm run lint` 18 warnings / 0 errors (exact
baseline, zero delta).
… P5.1)

Introduces `engine/reconciler.ts` with `scheduleReconcile()` — the P5
enforcement point for D7: coverage decides paint, never revalidation.
Every thread open now schedules exactly one reconcile pass through the
P4.1 BackfillScheduler with a new `'reconcile'` kind at band 0. When
cache matches server truth the pass is a cheap no-op (fetch, diff
empty, no writes, no tick — the D7 "cached was right" path). When they
diverge the P1.2 machinery (hydrateCachedEvents →
applyCachedRedactions, applyCachedReplaceRelations,
reconcileRelationEventsWithAggregation) applies missed edits, missed
redactions, and aggregation cleanup in place, and fires a single
batched onRepaired tick so the render layer picks up the change
without per-repair flicker.

Removes finding F7's 200-event ceiling: the pre-P5
`refreshLatestThreadRelationsTail` capped at a single limit-200 batch,
so a divergence deeper than 200 events never converged after open.
The reconciler pages further until either the fetched chunk overlaps
the cached window by event id, the SDK signals no-more, or the abort
signal fires (bounded by MAX_RECONCILE_ITERATIONS=25 for parity with
`fetchAllThreadRelations`).

D7 rewire in `threadOpenCacheFirst.ts`: the complete-coverage path
schedules the reconcile via a new `scheduleReconcile` prop instead of
firing the now-deleted `refreshLatestThreadRelationsTail`. The
partial-coverage path in `threadOpenLifecycleController.ts` also
schedules one after `runThreadOpenSdkBootstrap` — every open, without
exception (AC9). The scheduler dedups so both paths firing on the
same open produce one round-trip.

Deletes `refreshLatestThreadRelationsTail` from
`threadOpenCacheController.ts` and its type; the arch guard at
`RoomTimeline.architecture.test.ts:754` stays as a tripwire against
reintroducing it as a component-local useCallback. The controller
also drops the `roomTimelineSet` param that was plumbed through only
for the deleted method.

Tuwunel stale-copy handling: the reconciler funnels every fetched raw
event through `createPreferLiveEventMapper` (P1.2 F6-B). If a raw
event carries `unsigned.redacted_because` and the SDK live instance
doesn't yet know it's redacted, `makeRedacted` cascades into the
SDK's `Relations.BeforeRedaction` listener and removes the stale
reaction chip — the same invariant I2 machinery that P3-gate landed.

AC2 spec added RED (`test.fail`) at
`e2e/live/cinny207-stale-cache-divergence.spec.ts`: seed a thread
with M (edit target v1), R (redact target), and a 👍 reaction; close
the client; edit M to v2, redact both the reaction and R plus 25
filler thread messages via REST; reopen the thread URL (no reload);
assert `v2 converged` visible, no 👍 chip, R tombstoned, cache
converged, and the mid-viewport anchor stays within 8px through the
repair. The filler is what forces the reconciler (not Tier-1
write-through) to be the one that converges the cache. Commit 4
hardens the applier and flips this green.

Validation: `npx tsc --noEmit` clean; `npx vitest run` 338 files /
2563 tests green (+6 vs the Phase 4 gate-fix baseline 2557); `npm run
build` OK; `npm run lint` 18 warnings / 0 errors (exact baseline,
zero delta).
…esh (CINNY-207 P5.1)

Moves the `/relations` fetch boundary fully into the engine and
deletes `threadOpenPostBootstrapRefresh.ts` — its limit-200 fetch is
now the reconciler (Commit 1), its forward-gap check + log string
move inline into `threadOpenLifecycleController.ts`, and its
shouldScrollToLatestOnOpen → refreshLatestThreadSlice branch also
moves into the lifecycle controller. The `'thread-open-forward-gap-check'`
log string is preserved from the new home so existing capture
consumers keep working.

New `engine/threadRelationsFetcher.ts` owns the `fetchAllThreadRelations`
paging fetcher (migrated from `threadBootstrap.ts`) plus the
`MAX_THREAD_FETCH_EVENTS` / `MAX_THREAD_FETCH_ITERATIONS` caps.
`threadBootstrap.ts` re-exports the engine symbols for the existing
unit-test surface (`threadBootstrap.test.ts` still covers the fetcher
end-to-end via the facade).

New `engine/threadBackfillJob.ts` is a thin scheduler-job producer:
`enqueueThreadBackfillJob({mx, scheduler, room, threadId, priority,
shouldContinue})` routes the fetch through the P4.1 BackfillScheduler
under the existing `'thread-backfill'` kind (P4.4's overview-resume
dedup domain — a user-triggered open and a background resume for the
same thread coalesce). The React-side render-state work
(setSupplementalThreadEvents, saveThreadOpenSeedSnapshot,
persistThreadEventCache, setThreadTailLoaded, forceTimelineUpdate,
tick) stays in `threadOpenCacheController`; only the network side
lands in the engine.

`threadOpenCacheController.backfillThreadRelationsIntoCache` now
awaits `enqueueThreadBackfillJob(...)` instead of calling
`fetchAllThreadRelations` directly. The controller gains a
`scheduler: BackfillScheduler` prop plumbed from
`MindroomRoomTimeline.tsx` (via `syncEngine.scheduler`).

`threadOverviewResumeController.ts` retargets its
`fetchAllThreadRelations` import at `../engine` (was
`./threadBootstrap`) — it stays a legitimate non-engine consumer of
the fetcher for now (a full engine-side rewrite of overview-resume is
a future refactor); the arch guard's non-engine importer allowlist
lists it explicitly.

Two new architecture guards land in
`engine/__tests__/engine.architecture.test.ts`:

  1. `fetchAllThreadRelations is defined in engine/, and imported
     only within engine/**` — accepts `threadBootstrap.ts` as the
     re-export facade and `threadOverviewResumeController.ts` as the
     lone non-engine consumer. A third caller trips the guard.
     Explicitly out of scope: `notifications/readReceipts.ts` uses
     `mx.fetchRelations` with a `RelationType.Thread` limit-1 receipt
     probe — receipts-domain, not thread-history backfill.

  2. `mx.fetchRelations in threads/ is limited to threadOpenSdkBootstrap.ts
     with exactly 2 occurrences` — the two limit-50 fallback SDK
     bootstraps stay; a third `mx.fetchRelations(` call in that file
     or any new caller elsewhere in `threads/` trips the guard.

Existing `RoomTimeline.architecture.test.ts` post-bootstrap-refresh
guard is reshaped: the file's runner is deleted, so the guard now
asserts the forward-gap check + log string live in the lifecycle
controller (rather than in a separate refresh module).

Validation: `npx tsc --noEmit` clean; `npx vitest run` 337 files /
2563 tests green (-1 file from the deleted `threadOpenPostBootstrapRefresh.test.ts`,
which the reconciler + backfill-job units + arch guards cover
equivalently); `npm run build` OK; `npm run lint` 18 warnings / 0
errors (exact baseline, zero delta).
Wires a room-scope reconcile pass onto the engine's `noteRoomFocused`
so every room focus enters the P4.1 scheduler under
`kind: 'reconcile'` with `threadId: undefined`. Completes the D7
"every open schedules exactly one reconcile" invariant at both
scopes: thread opens land as thread-scope reconciles (Commit 1), and
room opens land as room-scope reconciles.

The room-scope executor is intentionally a fast no-op. Room-open tail
catchup is already end-to-end owned by two engine-owned producers
wired in earlier phases:

  - `RoomEvent.TimelineReset` (P3.2) writes the durable
    `tailDiscontinuity` marker and enqueues a `'limited-sync'`
    gap-fill job.
  - `Sync -> PREPARED` (P3.2) enqueues a per-room `'startup'`
    gap-fill job for each joined room with the marker set.

The P4.2 gap-fill executor consumes both queues, drives a
`mx.createMessagesRequest` catchup, persists via
`saveRoomEventsToCache`, and clears the marker on completion. What
Commit 3 adds at the room scope is the SCHEDULE tripwire: probe
counters bump (`schedulerEnqueued` +1 per room open), and a capture
can prove no room-open silently short-circuits away from the engine.
Running a second /messages catchup here would duplicate the gap-fill
executor's work, so the room-scope reconcile executor is deliberately
inert — it logs `reconcile-complete` with `note: 'room-scope
reconcile — tail catchup owned by gap-fill executor'` and returns
`repaired: false, fetchedCount: 0, iterations: 0`. Because it never
repaired anything, `onRepaired` is intentionally NOT called
(preserves the invariant "onRepaired fires only when a repair was
actually applied").

Dedup keys: AC8's `(roomId, threadId, kind)` key means a room-scope
reconcile (threadId=undefined) and a thread-scope reconcile
(threadId=$thread) on the same room map to different keys and coexist
by design. Repeated `noteRoomFocused` calls (rerender storms, quick
tab switches) collapse to one job via the standard AC8 dedup path —
the second call returns the in-flight promise identity, bumps
`schedulerDeduped`.

Unit tests added to `reconciler.test.ts`:

  - Room-scope pass enters the scheduler with kind `'reconcile'` +
    threadId `undefined` + band 0, does not fetch, does not fire
    onRepaired.
  - Room-scope pass dedups against another room-scope schedule for
    the same room.
  - Room-scope and thread-scope reconciles on the same room coexist
    (different dedup domains).

Validation: `npx tsc --noEmit` clean; `npx vitest run` 337 files /
2566 tests green (+3 vs Commit 2's 2563 for the new room-scope
units); `npm run build` OK; `npm run lint` 18 warnings / 0 errors
(exact baseline, zero delta).
… unit (CINNY-207 P5.2)

Two new reconciler unit tests plus the AC2 spec flip. The applier's
in-place invariant (AC10) and the Tuwunel stale-copy re-apply path
(P3-gate work) are now covered end-to-end.

Applier hardening unit:
  `applier hardens against prepends: repairs only swap or delete
   existing ids + append at the tail (AC10)`

  Fixture: a fetched page carrying a bundled edit on a cached target
  AND a redaction event targeting a different cached target. Asserts
  the reconciler treats both as divergence, fires onRepaired exactly
  once, and mutates via `hydrateCachedEvents` (which uses SDK
  `makeRedacted` / `makeReplaced` — instance mutation, not array
  splice). The reconciler deliberately does NOT push to
  `setSupplementalThreadEvents`; the render layer picks up the
  mutation on the batched tick. That's the AC10 guarantee: no
  prepends, no length changes, no anchor drift.

Tuwunel stale-copy re-apply unit:
  `Tuwunel stale-copy re-apply: a fetched page carrying
   unsigned.redacted_because for a cached target reapplies the
   redaction via prefer-live mapper`

  Fixture: `mx.getRoom(...).findEventById($reaction)` returns a live
  SDK-managed instance with a tracked `makeRedacted` spy. The fetch
  returns the reaction event with `unsigned.redacted_because` — the
  exact Tuwunel behavior we discovered empirically in the P3 gate
  work (docker Tuwunel serves un-pruned redacted events on /relations
  for ~10s after redaction). Asserts the reconciler funnels the raw
  event through `createPreferLiveEventMapper` before diffing, so the
  live instance gets the redaction re-applied and the SDK's
  `Relations.BeforeRedaction` listener cascades reaction chip
  cleanup. Invariant I2: our own record of server truth drives
  convergence; a stale server copy cannot un-repair a fresh
  redaction the client already knew about.

AC2 spec flipped GREEN: `e2e/live/cinny207-stale-cache-divergence.spec.ts`
loses its `test.fail` guard. The docker gate against real Tuwunel is
the team-lead's to run; the applier + prefer-live mapper wiring is
covered by the new unit tests in the meantime, so the spec correctly
describes the expected converged state.

Validation: `npx tsc --noEmit` clean; `npx vitest run` 337 files /
2568 tests green (+2 vs Commit 3's 2566 for the new applier +
Tuwunel stale-copy units); `npm run build` OK; `npm run lint` 18
warnings / 0 errors (exact baseline, zero delta).
FORK_CHANGES.md Runbook: prepends a Phase 5 entry covering P5.1
Commits 1-3 and P5.2 Commit 4 with per-commit file lists (new /
modified / deleted), the D7 rewire diff, the arch guard changes, the
AC2 spec design, the P5.1 Commit 2 deviations (leaner producer split
+ overview-resume non-engine allowlist entry + readReceipts scope
exclusion), the followups (move overview-resume fully into engine;
thread AbortSignal through fetchRelations when the SDK grows one),
and the full validation numbers.

docs/mindroom-cache-overhaul-plan.md:
  - Phase 5 section header flipped to `landed 2026-07-04` with the
    four feature commit hashes and a per-commit summary.
  - §6.3 scorecard AC2 (☐ impl — reconciler + spec design covered,
    docker gate pending); AC9 (✓ — 11 reconciler unit tests + the
    D7 wiring at threadOpenCacheFirst.ts:114 and lifecycle
    controller partial-coverage schedule); AC10 (✓ — existing anchor
    suites stay green, new correction-path applier hardening unit,
    AC2 spec 8px assertion). AC2 references the docker gate as the
    team-lead's responsibility per plan §7.
  - §6.4 regression guards: adds the two new engine-owned
    `/relations` boundary guards (fetchAllThreadRelations
    engine-only, mx.fetchRelations in threads/ limited to
    threadOpenSdkBootstrap.ts with exactly 2 occurrences), notes on
    the retained `refreshLatestThreadRelationsTail` tripwire, and
    the reshaped post-bootstrap guard pointing at the lifecycle
    controller as the new home of the `'thread-open-forward-gap-check'`
    log string.
  - §9 status log: full Phase 5 entry with all four feature commits
    described end-to-end and validation numbers.

Validation: `npx tsc --noEmit` clean; `npx vitest run` 337 files /
2568 tests green (identical to the P5.2 Commit 4 tip — this is a
docs-only commit); `npm run build` OK; `npm run lint` 18 warnings /
0 errors (exact baseline, zero delta).
…/repaired probes (CINNY-207 P5-GATE-FIX)

AC2 gate root cause: mx.fetchRelations is a pure HTTP call and does
NOT push events into the SDK thread model. The reconciler mapped and
hydrated events but never called `thread.addEvents`, so
useThreadRenderState (which reads `thread.events`) never saw the
fetched m.replace edit — the reopen render kept painting v1 even
after the reconcile pass completed. The pre-P5
runThreadOpenPostBootstrapRefresh (deleted in 05594b5) called
`currentThread.addEvents(latestEvents, false)`; P5 dropped it.

Fix: on divergence, before hydration and the onRepaired tick, inject
allMapped into `room.getThread(threadId)?.addEvents(events, false)`.
The `false` argument matches the reconciler's Backward-from-HEAD
pagination direction (tail end of thread). SDK dedupes on event_id
so re-injecting known events is a no-op. Skipped entirely on the D7
no-op path to preserve the "cached was right = zero cost" guarantee.

Also verified empirically that docker Tuwunel DOES honor MSC3981
recurse=true (a threaded m.replace on M appears in /relations of the
thread root when recurse is set) — so the earlier ranked hypothesis
about missing recurse support is disproved.

Observability: adds reconcilesScheduled and reconcilesRepaired probe
counters. Bumped in scheduleReconcile (thread-scope + room-scope)
and at the repair-applied point respectively — same lesson as
schedulerFailed from the P4 gate fix (without them, trace analysis
can't distinguish "never scheduled" from "scheduled, empty diff").

Tests: 3 new red-first units in reconciler.test.ts assert the
counters bump correctly, that the SDK injection happens with the
right shape on divergence, and that it does NOT happen on the D7
no-op path. Verified red without the fix (stash + rerun: 3 failures),
green with the fix (15/15).

Validation: typecheck clean, 2572/2572 tests pass, build OK, lint
holds exact 18-warning baseline (0 errors).
… clones (CINNY-207 P5-GATE-FIX v2)

The v1 fix (4aa3c19) added thread.addEvents injection but AC2 still
failed on tip 4aa3c19 (docker Tuwunel gate). Team-lead disproved the
RECURSE hypothesis empirically via curl (server does return the
m.replace with recurse=true) and identified the true root cause:
instance race on the complete-coverage cache-first path.

Root cause:
- useThreadRenderState in 'live' mode reads fallbackEvents populated
  by setSupplementalThreadEvents, which holds the exact MatrixEvent
  clones hydrateThreadFromCache built from the cache.
- The reconciler was re-mapping cachedPage.events (raw JSON) via
  mapCachedThreadPageEvents, producing a SECOND set of clones.
- applyCachedReplaceRelations mutated the second clones via
  makeReplaced; the render kept holding the first set unchanged.
- The onRepaired tick re-ran the render, saw unchanged instances,
  painted v1 forever.

Fix:
- Extend HydratedThreadCachePage with hydratedEvents / hydratedRootEvent
  (optional MatrixEvent arrays). hydrateThreadFromCache populates them
  with the same instances passed to setSupplementalThreadEvents.
- Reconciler resolveCachedSnapshotEventsForRepair helper prefers those
  instances; consults room.findEventById directly for the P1.2
  both-ways-heal case (SDK has a newer live copy). Guarded so the
  preferLive mapEvent fresh-clone fallback never wins over the
  render's instance.
- Retain thread.addEvents(allMapped, false) from v1 (SDK Thread model
  still needs the new edit event for its own downstream listeners).

Red-first: new reconciler.test.ts unit models the detached-render
shape (renderHeldEditTarget with own vi.fn() makeReplaced spy passed
via cachedPage.hydratedEvents; fetched chunk is a standalone m.replace
targeting $edit-target). Assertion fails when the reconciler re-maps
to fresh clones (count 0), passes only when it operates on the
render's instance.

Validation:
- typecheck: clean.
- vitest: 337/337 files, 2573/2573 tests. One intermittent
  noteRoomFocused flake on the first full run; passed in isolation
  and on the second full-suite run. Documented as pre-existing
  fake-indexeddb cross-test flake, unrelated to this diff.
- build: OK.
- lint: 0 errors, 18 warnings — exact baseline, zero delta.

Observability retained: reconcilesScheduled/reconcilesRepaired probes
from v1 still bump identically.

Not pushed. Docker AC2 re-run is team-lead's.
…pplementalThreadEvents (CINNY-207 P5-GATE-FIX v3)

Team-lead refinement of v2: v2 was necessary but not sufficient. The
instance-race patch made the P1.2 hydration pipeline mutate render-held
clones (correct on the cache path), but on complete-coverage cache-first
the SDK bootstrap is skipped by design, and the SDK's own timeline set
was never told about the fetched m.replace event. Any downstream SDK
listener re-reading thread.replacingEvent via the SDK path would still
see v1.

Do BOTH injections:
  1. Engine keeps the SDK-side leg: liveThread.addEvents(allMapped, false)
     (already in v1) plus the v2 cache-instance identity fix.
  2. Engine widens onRepaired to
     (repairedEvents: readonly MatrixEvent[]) => void and calls
     onRepaired(allMapped). The component-side callback (at each
     scheduleReconcile call site) routes the batch through
     setSupplementalThreadEvents(threadId, [...repairedEvents]) + tick.
     mergeThreadRenderEvents dedups by event id, so double-injection
     with the SDK path is a no-op there.

That preserves the P3.3 render-only boundary invariant (engine has no
import of setSupplementalThreadEvents) while making BOTH render paths
converge on the same tick: SDK-populated thread.events AND
component-owned fallbackThreadEventsState.events.

Callers updated:
  - threadOpenCacheFirst.ts: complete-coverage path
  - threadOpenLifecycleController.ts: partial-coverage path (and
    passes setSupplementalThreadEvents through to cache-first)
  - mindroomSyncEngine.ts room-open: unchanged (no onRepaired)

Red-first evidence:
  - reconciler.test.ts new unit asserting onRepaired receives the
    fetched batch as array-of-MatrixEvent. Pre-fix: batchArg is
    undefined; post-fix: length 2 with both fetched ids.
  - threadOpenCacheFirst.test.ts new unit modeling the reconciler
    firing onRepaired with a repaired batch, asserting
    setSupplementalThreadEvents was called with (threadId, batch).
    Pre-fix: 0 calls (callback was a bare tick); post-fix: 1.
  - Defensive companion asserting empty batch is a no-op on the
    component side.

Validation: typecheck clean; full vitest 337/337 files 2576/2576 tests
(+3 from v3); build OK; lint at exact 18-warning baseline (0 errors).
…NY-207 P5-GATE-FIX v4)

Team-lead diagnosis of AC2 re-run against v3 tip 52e3749 (clean network,
0 resets): still failing at line 236. The complete-coverage cache-first
reopen path has room.getThread(threadId) === null by design at injection
time (SDK bootstrap skipped), so the v3 SDK-side leg
(liveThread.addEvents) no-ops silently. Convergence has to come through
the widened onRepaired -> setSupplementalThreadEvents leg. The AC2
spec's probe log was too muted to distinguish that shape from other
failure modes.

Changes (observability-first, no behavior change to the fix chain):
- cacheProbe.ts: add reconcilesThreadNull counter, exposed on
  window.__MINDROOM_CACHE_PROBE__.snapshot().
- reconciler.ts: SDK-injection branch now has an
  `else if (!liveThread && allMapped.length > 0)` arm bumping the
  counter and emitting a `reconcile-thread-null` timeline debug log
  with mappedCount. The repair itself still runs against
  cachedPage.hydratedEvents and fires onRepaired(allMapped) so the
  render-fallback leg carries the change through
  setSupplementalThreadEvents (as wired in v3).
- e2e AC2 spec: probe polling now fires a t=0 baseline immediately
  after reopen navigation (not just t=2s), and the comment above
  documents a diagnosis fork for the four possible signatures so the
  next docker trace is self-interpreting.
- reconciler.test.ts: new red-first unit
  'still delivers repairedEvents through onRepaired when
  room.getThread returns null — P5-GATE-FIX v4 AC2 complete-coverage
  reopen'. Stashing reconciler.ts and running: `expected +0 to be 1`
  on reconcilesThreadNull. Restore: 18/18 tests pass, incl. assertion
  that onRepaired fires with the full mapped batch even when
  getThread === undefined and reconcilesRepaired bumps (proof the
  hydrate path survives the SDK-thread-absent branch).

Validation:
- npm run typecheck: clean
- npx vitest run src/app/mindroom/engine/__tests__/reconciler.test.ts:
  18/18 pass
- npx vitest run src/app/mindroom/threads/cacheProbe.test.ts:
  5/5 pass (shape-agnostic)
- Full vitest: 337/337 files, 2577/2577 tests (+1 from v3 baseline)
- npm run build: OK (sw + main bundles built)
- npm run lint: exact 18-warning baseline, 0 errors, zero delta

Docker AC2 gate remains team-lead's to run. The next failing trace
should distinguish itself between the four signatures documented in
the FORK_CHANGES.md Runbook entry for v4.
…P6.1)

Introduce the D4-native prefetch settings shape in `engine/prefetchPolicy.ts`:
`PrefetchScope` literal type ('my-server' | 'all-rooms' | 'current-room-only'),
`sanitizePrefetchScope`, `sanitizePrefetchDepth` (clamp to
[ROOM_TAIL_PREFETCH_DEPTH, CURRENT_ROOM_DEEP_HISTORY_TARGET], integer, silent
fallback), and pure `resolvePrefetchConfig` that consumes a settings snapshot
and returns {scope, currentRoomDepth, roomTailDepth, threadInventoryLimit}.
Non-user-visible bounds (ROOM_TAIL_PREFETCH_DEPTH, THREAD_INVENTORY_PREFETCH_LIMIT)
stay constants.

Transitional shape: `mindroomSettings` grows `prefetchScope` and `prefetchDepth`
alongside the legacy `paginationLimit` so the surrounding tree stays green
while Commits 2-4 migrate consumers and remove the legacy field.

Tests: 17 new sanitizer + resolver assertions in prefetchPolicy.test.ts
(20 total in the file). `mindroomSettings.test.ts` unchanged: legacy field
still lives so the pre-existing hydration/sanitize/persist assertions hold.
New `MindroomPrefetchSettings` component wired through the existing
`MindroomGeneralMessageSettings` extension slot. Two SequenceCards, one
per tile:

  1. "Prefetch scope" — folds `PopOut + FocusTrap + Menu` selector
     (cloned from `SelectMessageLayout` in features/settings/general/
     General.tsx ~762-820) offering My homeserver / All rooms / Current
     room only. Writes the literal to `prefetchScope`.
  2. "Current room history depth" — number `Input` matching the shape
     of the deleted `MindroomMessagePreloadLimitInput` (Escape resets,
     Enter/blur commits via `sanitizePrefetchDepth`, Success variant
     while dirty). Bounds [ROOM_TAIL_PREFETCH_DEPTH=200,
     CURRENT_ROOM_DEEP_HISTORY_TARGET=10000] enforced by the sanitizer.

Architecture guard `RoomTimeline.architecture.test.ts` flipped: the
settingsExtensionsSource must contain `MindroomPrefetchSettings` (not
`MindroomMessagePreloadLimitSetting`); settingsMenuExtensionsSource
still must NOT contain either symbol.

Tests: 6 new (renders both tiles; clamps below MIN to
ROOM_TAIL_PREFETCH_DEPTH; clamps above MAX to
CURRENT_ROOM_DEEP_HISTORY_TARGET; Escape resets; scope selection writes
the literal; Enter commits). Consumer wiring lives in Commit 3; the
legacy tile is deleted in Commit 4 (D4).
Team-lead ran instrumented docker AC2 against v4 tip c136700 and
reported decisive probe data: reconcilesScheduled=3, reconcilesRepaired=2,
schedulerCompleted=6, schedulerFailed=0, engineLiveWrites=0. The
reconciler scheduled AND repaired twice, yet v1 kept painting — which
excludes scheduling regression, fetch failure, and instance-race as
the load-bearing failure.

This is a documentation-only entry. No code change: the load-bearing
fix (v3's widened onRepaired(repairedEvents) routed by both call sites
through setSupplementalThreadEvents(threadId, [...repairedEvents]))
is already in place since 52e3749.

Adds to FORK_CHANGES.md a "v5 honest root-cause" entry at the top of
the Runbook that:

- Names v3 as the load-bearing fix (spread + Array.from in
  mergeThreadRenderEvents delivers a new array reference; the
  fallbackThreadEventsState.events state setter fires; both
  fallbackEvents and threadEvents memos re-derive; buildThreadEvents
  re-invokes hydrateCachedEvents on the merged set — that's the
  invalidation chain the pre-P5 tail refresh got for free).

- Explains why v1 (SDK addEvents) and v2 (repair the render's
  cachedPage.hydratedEvents instances, not fresh clones) were real
  but not load-bearing on the AC2 complete-coverage cache-first shape.
  v1: SDK bootstrap is skipped by design on that shape, so
  liveThread is null and addEvents no-ops (v4's reconcilesThreadNull
  counter is the diagnostic). v2: in-place mutation of instances
  within an array whose reference hasn't changed cannot re-derive
  React memos that key on the array reference — the state setter
  never fires without v3's spread-array delivery.

- Documents the memo-key audit per team-lead request:
  useThreadRenderState.ts:211 fallbackEvents memo deps on
  [fallbackThreadEventsState.events, .threadId, threadId], and
  useThreadRenderState.ts:218 threadEvents memo deps on
  [fallbackEvents, room, thread, threadEventRefreshTick, threadId,
  threadInitialCacheHydrated]. Reference-identity comparison on both.
  forceTimelineUpdate + setThreadTimelineTick alone do NOT invalidate
  threadEvents (they touch timeline state and a component-owned tick
  that this memo does not depend on).

- Explains why v1 and v2 are still worth keeping despite not being
  load-bearing: v1 remains correct for the partial-coverage path where
  the SDK thread exists (SDK listeners then see the fetched event);
  v2's object-identity contract is a correctness requirement so the
  merged set that flows through buildThreadEvents → hydrateCachedEvents
  at re-render acts on the render-held instances rather than dead clones.

- Enumerates expected outcomes and next-diagnosis forks for team-lead's
  next AC2 re-run against c136700: best case AC2 converges; if it
  still fails with the v4 signature (reconcilesRepaired ≥ 1,
  reconcilesThreadNull = 1) the residual issue is downstream of the
  fallback-array delivery (virtualized cell cache holding stale render,
  or mergeThreadRenderEvents picking the wrong preferred event during
  dedup).

Team-lead's instrumented spec-polling commit (22b20ed) is preserved
in the branch — it IS the observability that made this diagnosis
possible.

Validation:
- npm run typecheck: clean
- npm run lint: exact 18-warning baseline, 0 errors, zero delta
- No test files touched — no vitest run required for docs-only
- Full vitest suite last ran green on c136700 (2577/2577)
Migrate MindroomRoomTimeline + its controller graph off the legacy
`paginationLimit` setting and onto `prefetchDepth` (Commit 1's D4
replacement).

  - MindroomRoomTimeline reads `prefetchDepth` via useSetting +
    sanitizePrefetchDepth (~22 refs); safePaginationLimit/Ref renamed
    to prefetchDepth/Ref through the controller graph
    (roomCacheHydrationController, roomEventOpenController,
    roomPaginationCommandController, roomTimelineNavigationController,
    roomTimelineWindowController, threadOpenCacheController,
    threadSeedPrewarmController).
  - timelinePagination.ts's `paginationLimit` parameter renamed to
    `windowLimit` (mechanical — the parameter is a slice length, not a
    user setting; this frees the allowlist-free 6.4 guard the next
    commit installs).
  - Deep-history job wiring: `MindroomRoomTimeline` snapshots
    `prefetchDepth` and passes it as `targetEventCount` when calling
    `enqueueRoomDeepHistoryJob`. Snapshot at effect fire (not via ref)
    because the scheduler dedup key does not include the depth; a
    mid-focus depth change picks up on the next mount. Chosen because
    the deep-history job already accepts `targetEventCount` (P4.3);
    routing through `noteRoomFocused` would require plumbing a new
    setter through the engine construction path without adding
    behavior.
  - Test shims: `RoomTimeline.test.shared.ts` and
    `RoomTimelineCollapsible.test.ts` return prefetchDepth (plus
    prefetchScope='my-server' so mindroomSettings hydration is stable)
    from the `useSetting` mock. `RoomTimeline.navigation.test.ts` and
    `RoomTimeline.cache.test.ts` read `settingsState.prefetchDepth`.
    The cache test's "keeps the first visible classic room message
    anchored" case updated: pre-D4 `paginationLimit: 100` is not
    representable with the new sanitizer (clamps to 200); the test now
    uses 200 with the resulting `{start:100,end:300}` range assertion.
  - `src/app/pages/client/inbox/Notifications.tsx` intentionally NOT
    touched — its `paginationLimit` is an unrelated upstream local
    parameter (notification-fetch batch size), not the MindRoom setting.

Validation: `npx tsc --noEmit` clean; focused vitest
(RoomTimeline.cache 76/76, RoomTimeline.navigation 22/22,
RoomTimelineCollapsible 12/12, roomPaginationCommandController 4/4);
full mindroom suite 232 files / 2031 tests green.
…NNY-207 P5 gate closure)

Five fix iterations produced a precise diagnosis but no green live run:
Tuwunel recurse verified working (curl); probe signatures nondeterministic
(repaired 2->0 with identical code); the final failing run's network log
shows the reconciler's limit=200 fetch NEVER fired — the executor exits
before its first fetch through one of three silent paths (fetch-failure
swallow, zero-divergence, shouldContinue guard abort), with the evidence
pointing at the guard abort during reopen mount churn.

Spec re-annotated test.fail() with the full diagnosis; Deviations entry
records the pending design decision (guard-abort reschedule + the
token-resume thread-scope seam); scorecard AC2 row honest. Per plan rule
6.1: red with evidence over green by wallpaper.
…Y-207 P6.1/D4)

Retires the P1.6 `MindroomMessagePreloadLimitSetting` and the
`paginationLimit` field it wrote. D4 semantics: the stored value is
DROPPED, never mapped to `prefetchDepth` — the two settings have
incompatible semantics (unbounded eager-preload target vs. clamped
[200, 10000] scrollback depth with a different default), so mapping
would give users a silent behavior change on upgrade.

Deletions:
  - `MindroomMessagePreloadLimitSetting.tsx` + test
  - `preloadSettings.test.ts`
  - From `preloadSettings.ts`: DEFAULT_PAGINATION_LIMIT,
    MIN_PAGINATION_LIMIT, MAX_PAGINATION_LIMIT,
    sanitizePaginationLimit, ROOM_CACHE_PERSIST_DEBOUNCE_MS (the P1.1
    sweep debounce — its subject was deleted in P3.3, the constant had
    no live consumers). Survivors: THREAD_BATCH_SIZE (fetch batch
    size for thread /relations), ROOM_TIMELINE_INTERACTIVE_BATCH_SIZE
    (interactive pagination cap), THREAD_EDIT_COMPACTION_DEBOUNCE_MS
    (P1.4 edit compaction trailing debounce). File is not empty so
    the survivors stay where they are (they predate D4 and are
    engine-adjacent, not settings).

`mindroomSettings.ts`:
  - `paginationLimit` gone from the type.
  - `withMindroomSettings` destructures-omits `paginationLimit` on
    every read: even if an in-memory Settings snapshot still carries
    it (a plugin, a stale fixture), we never propagate forward.

`mindroomSettingsBootstrap.ts` (new leaf module — NO transitive
import of `state/settings.ts`):
  - `dropLegacyMindroomSettings()` reads localStorage, strips the
    `paginationLimit` key if present, writes the cleaned blob back.
    Idempotent, defensive (no-op on missing/malformed/no-localStorage).
    Called from `src/index.tsx` BEFORE any transitive settings
    import — `mindroomSettings.ts` would defeat the "before init"
    guarantee because that module IS the settings atom.

`mindroomSettings.test.ts` — rewritten (7 tests):
  - drop test (stored paginationLimit → prefetchDepth default, no key on snapshot);
  - garbage-scope coerce test;
  - no-paginationLimit-key-after-write test;
  - scrub test (legacy key removed, everything else preserved);
  - scrub is a no-op when key absent;
  - scrub is a no-op on missing/malformed/array blob;
  - previously 3 → now 7.

6.4 guard (new arch test `prefetchSettings.architecture.test.ts`):
  - written RED FIRST before removals — three tests failed against the
    pre-Commit-4 tree (file existence, offender scan, D4 test titles);
  - (a) both legacy files absent;
  - (b) recursive scan of src/app/mindroom/ for
    /paginationLimit|PreloadLimit|PAGINATION_LIMIT/, excluding an
    exemption list of non-consumers (this arch test itself, the D4
    drop machinery, the D4 tests, legacy-negation guards, the
    preloadSettings.ts historical header). Divergence recorded: the
    brief called for "zero allowlist" but the D4 drop code MUST name
    the key it strips (a `paginationLimit` in the destructure-omit is
    load-bearing); the exemption list is the honest reading of "no
    live consumer" and every entry is justified in the guard's doc
    header;
  - (c) settingsExtensions has `MindroomPrefetchSettings`;
    mindroomSettings has `prefetchScope` + `prefetchDepth` + import
    from `../engine/prefetchPolicy`;
  - (d) mindroomSettings.test.ts contains the four D4 case titles.

The `state/settings.test.ts` ownership guard (paginationLimit not in
generic settings source) stays green — the field was never in that
file.

Validation: `npx tsc --noEmit` clean; focused vitest
(prefetchSettings.architecture 5/5, mindroomSettings 7/7,
state/settings 4/4, MindroomPrefetchSettings 6/6,
RoomTimeline.architecture 96/96); full mindroom + state/settings
232 files / 2036 tests green.
… (CINNY-207 P5-GATE-FIX v4 final)

Team-lead time-boxed final iteration: make the reconciler the deterministic
owner it was designed to be. On divergence, persist the fully-mapped
prefer-live fetched batch through persistThreadEventCacheSnapshot in
addition to the v3 dual injection (SDK addEvents + widened onRepaired
supplemental sink). This closes the design seam where the pre-v4 chain
converged in memory but never taught the CACHE about the fetched events,
so the next reopen from IDB re-hit the stale window because the gap-fill
executor only writes room scope and the live-mode gates skip catch-up
sync by design.

Divergence detection continues to compare fetched events against CACHE
records (cachedIds from cachedPage.rootEvent + events raw JSON), not
SDK state — the change is that we now WRITE BACK on divergence.

Two new probe counters for docker trace analysis:
  - reconcilerPersists: bumps once per repair pass that persisted via
    the engine snapshot writer.
  - reconcilesOnRepairedFired: bumps AFTER onRepaired returns; the gap
    to reconcilesRepaired is diagnostic (guard-skipped/throwing
    callback shows as N,0 pair).

Red-first unit coverage (+3 tests, 22 total):
  - persists via engine path on divergence (reconcilerPersists = 1)
  - SDK-vs-cache timing race: SDK holds v2 but cache holds v1, both
    persist + inject fire (the timing-nondeterminism case)
  - callback-fired counter bumps strictly AFTER callback returns
  - D7 no-op path does NOT persist (cost guarantee)

Validation: typecheck clean, 337/337 vitest files (2581/2581 tests),
build OK, lint 18 warnings baseline zero delta. Architecture guard
still holds (persist is called from engine/reconciler.ts, not render).

AC2 spec left annotated test.fail() from the honest-red gate closure
commit d5b2d34 — team-lead's docker gate decides whether this fix
green-flips the spec or the honest-red package remains the shipping
state.
…7.1)

Docs-only commit that closes Phase 6 + Phase 7:

- `docs/mindroom-cache-strategy.md`: full rewrite. Core Model diagram
  now shows the `MindroomSyncEngine` and its four components
  (WriteThrough, BackfillScheduler, CacheStore, Reconciler) with the
  read APIs feeding the current-room `ThreadRecord` index. The three
  legacy IDB rows in the Cache Layers table collapse into one
  `cacheStore/` row (unified `mindroom-cache::<sessionId>` DB schema
  v3 with `events`, `meta`, `room_ledger`, `thread_summaries`; D8
  wipes the six legacy DBs on first v3 open). Write Owners names the
  four engine writers and adds the explicit inverse rule that render
  components and per-room controllers own ZERO writes. Read Owners
  rewritten around thread-open -> reconciler and hydration helpers.
  Coverage Semantics gains D7 verbatim ("coverage decides what to
  paint, never whether to revalidate; complete coverage = reconcile
  expected to be a no-op, not skipped"). Main Flows deletes the
  Eager Preload section entirely and adds a Tiered Prefetch section
  (prefetchScope setting + D3 detection, depth targets per tier,
  scheduler properties). Forbidden Patterns keeps the cross-room
  eager preload rule with the updated justification (cross-room work
  now exists but lives exclusively in the engine's scheduler under
  the user's scope policy) and adds three new prohibitions: cache
  writes bypassing `cacheStore`, history fetches outside the
  scheduler/reconciler, and reintroducing a preload-limit-style
  setting. Review Checklist gains the scheduler-dedup and D7 "still
  schedules reconcile on complete coverage" questions.

- `FORK_CHANGES.md`: Phase 6 + Phase 7 runbook entry with the full
  P6.1 (four commits) and P7.1 (dead-code audit + this docs commit)
  detail. Records the divergence from the six-commit brief: Commit 5
  and Commit 6 are combined into this single docs commit because
  the dead-code audit produced zero verified-dead orphans and
  forcing an empty commit would have been dishonest.

- `docs/mindroom-cache-overhaul-plan.md`: header status marker
  updated (Phase 6+7 fully landed locally; P7.2 is the orchestrator's);
  §5 Phase 6 / Phase 7 markers updated with commit shas; §6.4
  regression guards gains the P6.1 arch guard row with the exemption
  list justification; §8 Deviations gains four new entries (arch
  guard exemption list vs. zero allowlist, deep-history depth wiring
  via targetEventCount snapshot, D4 drop-vs-map semantics, dead-code
  sweep folded into docs commit); §9 status log gains the Phase 6+7
  landing entry with per-commit detail and validation results.

Validation on the branch tip: `npx tsc --noEmit` clean;
`npx vitest run` 337 files / 2593 tests green (+25 vs the Phase 5
baseline of 2568 — 17 new sanitizer/resolver tests, 6 new UI
component tests, 5 new arch guard tests, 4 new rewritten
mindroomSettings tests; the deleted `preloadSettings.test.ts` +
`MindroomMessagePreloadLimitSetting.test.ts` remove 8 tests, net
+17 new + reshuffled elsewhere lands at +25); `npm run build`
clean; `npm run lint` 18 warnings / 0 errors — matches the pre-P6
baseline exactly (zero delta).
… into cache-overhaul/13-p6p7-settings-cleanup

# Conflicts:
#	FORK_CHANGES.md
#	docs/mindroom-cache-overhaul-plan.md
)

* fix(web): recover expired config sessions

* fix(web): require cached config for offline mode

* docs: align config recovery runbook

* test(web): cover uncached config failure
* fix(simple-mode): keep spaces visible

* docs(runbook): record simple-mode space PR review
* fix: guard cyclic Matrix timeline links

* docs: update timeline fix validation count

* docs: record timeline fix PR review
* feat(diagnostics): add opt-in deep tracing

* feat(diagnostics): trace thread resume work

* fix(diagnostics): harden deep tracing races

* fix(diagnostics): distinguish unknown response sizes

* fix(diagnostics): normalize trace durations

* fix(diagnostics): close failed thread load spans

* fix(diagnostics): close pagination trace gaps

* fix(diagnostics): parse iOS stack frames

* refactor(diagnostics): remove feature-specific trace spans

* refactor(diagnostics): narrow deep trace to durable signals

* fix(diagnostics): preserve clean stop evidence

* fix(diagnostics): retain legacy action evidence

* docs: record deep trace trim validation

* docs: correct trimmed diff size

* fix(diagnostics): serialize trace setting changes

* fix(diagnostics): retain trace control ownership
…(CINNY-130)

Newly provisioned agent users like MindRoomExpert never appeared in the invite dialog's auto-suggest even though their typed Matrix ID resolved fine.
The suggestion cache bootstrapped the user directory with a single-space search term, and Tuwunel matches directory queries by case-insensitive substring over MXID and display name.
A space therefore only matched users whose display name contains a literal space, so space-less display names were systematically excluded from the local suggestion cache.
A natural spaced query like "mindroom expert" also returned nothing server-side because no MXID or display name contains that exact substring.

Bootstrap the directory cache with "@" instead, which every MXID contains, so all directory-visible users enter the cache.
This is a Tuwunel-compatible visible-user bootstrap rather than a Matrix-standard match-all guarantee, and the existing limited-response fallback stays load-bearing because production already exceeds Tuwunel's 500-result clamp.
For per-keystroke server searches, queries with internal whitespace now also issue a whitespace-compacted variant, with each variant's results published independently as it settles so one hung request cannot starve the other's results.
Ranking is whitespace-insensitive at the shared user-directory search boundary so compact-only hits survive the final relevance pass instead of being filtered back out by the spaced input.

Verified against production Tuwunel: the space bootstrap returned 27 of 500+ users while "@" returns the full visible set, confirming the hole this fixes.
Stale-request and owner guards are pinned by adversarial tests that fail if the request-generation guards are removed.
…Y-128)

Sending a voice message while attachments were staged in the room composer left the attachments behind: the voice went out standalone through its own pipeline and the staged files stayed on the upload board, so the natural "attach a file and talk about it" gesture produced a thread without the attachment.

The voice send path now enrolls eligible staged companions into the same send session before handing off.
Companions send first in board order, the voice message sends last, and everything lands under a single thread root, matching the grouping semantics the MindRoom backend uses for text-plus-attachment sends.
Inside an existing thread the batch joins that thread instead of creating a new root.

Eligibility is decided at gesture time against live board state: companions that are oversized, prep-failed, upload-failed, paste-marker-only, or prepared for a different encryption state than the live room stay staged and never block the voice.
A companion failure mid-batch stays local to that companion; survivors and the voice still send.
Ownership is explicit: the recorder-owned voice item is not cancelable through the board's bulk Remove All, terminal voice failures release the global send claim promptly even when a companion never settles, and parked voice retries stay on the standalone pipeline rather than combining with attachments staged later.

Recovery after partial failure was deliberately simplified during review: failed items remain staged on the board with no thread-binding metadata, so a later typed message can never be silently redirected into a stale recovery thread.
The pre-existing repo-wide plaintext-after-encryption transition gap discovered during review is tracked separately as CINNY-131.
* fix(invite): clear stale failed search results

* docs: record invite review follow-up PR

* fix(invite): preserve empty search state identity

* docs: record invite review completion

* docs: record Fable invite review
* docs(CINNY-128): plan voice send + staged attachment same-thread grouping (PLAN-B)

* plan: CINNY-128 voice send flushes staged attachments into same thread

* feat(room-input): expose active send session state

* fix(room-input): send staged files with voice

* docs: report CINNY-128 implementation

* fix(room-input): enforce combined send enrollment

* fix(room-input): complete combined voice send lifecycle

* fix(room-input): simplify combined voice send ownership

* fix(room-input): keep companion upload failures local

* fix(room-input): fail fast on terminal voice errors

* fix(room-input): preserve combined voice ownership

* fix(room-input): remove dead companion guard

* feat(composer): unify message bundle send

* fix(voice): satisfy forwarded component lint

* refactor(voice): keep forwardRef render focused

* fix(voice): require unified retry path

* fix(composer): use send-time relation context

* fix(composer): close unified send retry gaps
Read the backend-owned cron_description field for recurring scheduled tasks and share display policy across compact cards and thread headers. Preserve count fallbacks for multiple or incomplete task sets and existing execute_at behavior.
Prevent End from spinning forever when the embedded Element Call iframe stops responding.

- Share one end request across both call controls and enforce a four-second host fallback.
- Preserve healthy Hangup and Close behavior while cleaning up pre-join endings.
- Make embed disposal idempotent, fault-tolerant, and exact about Matrix listener removal.
- Preserve started-call room ownership and reset joined state when the embed changes.
- Keep every independently useful lifecycle fix from #189 without importing its coupled RTC membership and room-retirement subsystem.

Validated with 21 focused call tests, the full 3,396-test suite, typecheck, production/PWA build, ESLint, formatting, independent exact-head review, and green PR checks.
* fix(messages): keep Matrix IDs literal in code

* docs: record PR review status

* fix(messages): preserve custom code link labels

* docs: close code mention review
* feat: default to simple mode with expanded messages

* test: prove malformed settings use defaults
* Fix expired tool approval cards

* Update approval validation count

* Avoid duplicate approval expiry parsing

* Test live approval expiry timestamp format

* Parse approval expiry timestamps deterministically

* Pin approval expiry edges and drop unreachable guard
* feat: restore recent threads panel

* fix: harden recent thread panel resizing

* refactor: simplify recently opened threads

* fix: anchor recently opened at sidebar bottom

* docs: record persistent recently opened validation

* docs: record current dev validation

* fix: make recently opened panel resizable

* test: cover touch resizing

* fix: collapse recently opened by default
* feat: unify color tokens across all five themes

Stage 1a of the visual refresh. All five palettes are now generated from
one OKLCH ramp on a single brand hue (288deg), replacing the split where
light/silver used a blue Primary and the dark family a lavender one.

- Add a fork-owned lightTheme in colors.css.ts and stop importing folds'
  built-in one. No other module imported it.
- Tint neutrals with a trace of the brand hue so greys look chosen rather
  than dead; midnight raises the tint, butter tints warm instead.
- Move ContainerLine to ~0.03 lightness from its own Container instead of
  ~0.13, so borders read as edges rather than hard rules.
- Make Other.Shadow translucent everywhere. The dark family used opaque
  black, which defeated the diffuse softShadow tokens. Butter gains its
  own Other block instead of inheriting the shared dark one.
- Share one darkAccents object across dark, midnight and butter; only the
  neutral ladders and Secondary differ.
- Give silver its own darkened accents, since its darker background can't
  hold 4.5:1 against the shared light ones.
- Point --tc-link at Primary.Main per theme kind; it was a standalone blue
  that the new violet Primary would have clashed with.

Ramps were generated and contrast-checked with chroma-js at authoring
time; only static hex is committed and no color math runs at runtime. All
155 audited pairs pass WCAG AA, with Success.Main and Warning.Main on
light-kind backgrounds held to the 3:1 non-text threshold as icon colors.

Every Background.Container hex is unchanged, so the duplicates in
index.css, themeBootstrap.ts and the pre-paint bootstrap in index.html
needed no edit. No vertical metric changed, so the height-calibrated
virtualizer estimator is untouched.

* docs: record stage 1a screenshot verification

* feat: add optical tracking and a real mono font stack

* feat(design): unify motion with shared duration and easing tokens

Every transition in the app picked its own duration and curve by hand.
Hovering across the sidebar, the thread list, and a message ran 100ms
linear, 120ms ease, 0.15s with no curve at all, and 200ms
cubic-bezier(0, 0.8, 0.67, 0.97) - three speeds at three accelerations
for what is the same gesture.

Motion.css.ts defines five durations and four easings on :root. The
steps sit close to the values already in use, so adopting them is a
snap-to-grid rather than a re-timing; Fast and Slow are exactly the two
most common existing values. Fifteen call sites across eight files now
use them.

The transition() shorthand builder lives in a separate plain module
rather than in Motion.css.ts, because a .css.ts file may only export
values vanilla-extract can serialize. Exporting a function from one
builds fine until a regular .tsx imports it, and ThreadTagPill.tsx does.

index.css gains a global prefers-reduced-motion rule, but it clamps
transition-duration and scroll-behavior only. Collapsing a transition is
always safe: the end state arrives immediately and nothing is lost but
the travel. The usual blanket snippet also clamps animation-duration and
iteration-count, which would freeze the spinner, the typing dots, and
the streaming indicator on one frame - those are the only signal that
something is in progress, so clamping them would remove information
rather than motion. Animations opt out one at a time instead, which here
means dropping the wobble from the call avatar while keeping its glow.

* feat(composer): acknowledge focus with a Primary ring

Typing into the composer was the one interaction with no visual
acknowledgement at all: the caret appeared and the box stayed exactly as
it was.

:focus-within thickens the existing inset ring from B300 in
SurfaceVariant.ContainerLine to B400 in Primary.Main, on the shared
transition timing. Inset rather than outset so it cannot be clipped by an
ancestor and cannot move anything, which matters because the composer
shares a column with a virtualized timeline whose row heights come from a
content-based estimator. Confirmed byte-identical getBoundingClientRect()
focused and unfocused.

:focus-within rather than :focus is deliberate: reaching for the emoji or
attachment button keeps the composer group lit, which is the accurate
description of what has focus.

As a non-text indicator the ring needs 3:1 against SurfaceVariant.Container
and gets 5.30:1 (silver) through 5.76:1 (dark). It also reads at least
4.49:1 against the resting ContainerLine it replaces, so the state change
is unmistakable rather than a thickness difference.

* feat(theme): put avatar and name colors on one OKLCH ramp

The two colors that identify a person were generated by two unrelated
rules and clashed. Avatar fallbacks came from eight hand-written HSL
values; sender names came from power-level tag colors passed through
accessibleColor, which only clamped LAB lightness. The default moderator
green #1fd81f therefore stayed fully saturated in both themes - a neon
name beside a muted avatar, in an app whose every other surface had just
moved onto one ramp.

--mx-uc-1..8 become eight evenly spaced OKLCH hues, 45 degrees apart,
rotated 32 degrees off zero so none lands on the 288 degree brand hue.
Preserving the original hues was tried first and rejected: 208 and 242
collapse into two near-identical blues once forced to share a lightness,
and telling people apart is the entire job of these colors. Each value is
checked in both directions, since colorMXID uses it as name text and as an
avatar background with Surface.Container as the letter; worst case 4.56:1
light, 7.69:1 dark.

accessibleColor now normalizes any tag color onto the same targets. Hue
stays the caller's, since that is what distinguishes one tag from another;
lightness and chroma become the theme's. Chroma is pulled in until the
color is inside sRGB rather than letting .hex() clip, because clipping
drags hue and lightness with it, which is the opposite of what a
fixed-lightness ramp is for. All seven default tag colors pass 4.5:1 in
both groups.

* fix(sidebar): stop unread counts out-shouting mentions

UnreadBadge rendered both states solid, so an ordinary unread count came
out near-black in the light themes and near-white in the dark ones, at
roughly 15:1 against the page, while a mention came out green at 4-10:1.
A near-black slab outweighs a green one no matter what the green means,
so the sidebar shouted loudest about the thing that mattered least - and
in a busy account nearly every room has a count, which is what turned the
room list into a wall of chips.

Counted non-highlight badges drop to Soft with an outline; mentions keep
Solid. The number still reads at 10.2:1 (dark) to 13.4:1 (light) inside
the soft pill - only the slab behind it recedes. The outline is what keeps
it legible as a pill on the light themes, where Secondary.Container is
barely off the sidebar background; folds draws it with outline rather than
border, so height stays 16px in both states.

The countless form stays Solid: it is an 8px dot with no text inside it to
carry the meaning, so softening it would leave nothing to see.

The solid mention chip now reads 3x to 7x louder against the page than the
softened count in every theme, where it was quieter in all five before.

* feat(code): put syntax colors on the app's OKLCH ramp

ReactPrism.css carried two unrelated palettes: an ad-hoc light one and
Monokai for dark. Monokai's hot pink and acid green are a whole design
language of their own, and agent output is mostly code, so the largest
colored surface in the app was the one speaking a different dialect from
everything around it. The light palette had its own inversion - #0f4777
made comments a saturated blue, louder than the code they annotate.

Both are regenerated on the rule the --mx-uc-* colors and accessibleColor
already use: hue carries the token's identity, lightness and chroma belong
to the theme. Light sits at L 0.48 / C<=0.135, two steps darker than the
user colors because code sits on SurfaceVariant.Container and silver's is
darker than any message background; worst case 4.72:1. Dark is the usual
L 0.82 / C<=0.115, worst case 6.56:1 against butter's container.

operator, punctuation and comment sit off the semantic ramp because they
are scaffolding rather than content, descending in weight in that order
and all still clearing 4.5:1. "Quieter" flips direction between the
groups, so the dark comment comes down off L 0.82 rather than sitting on
it - keeping it on the ramp made the quietest token the brightest thing in
the block.

* fix(sidebar): give Recently Opened the app's scrollbar

The Recently Opened list was a plain overflow: auto div, so it was the
one scroller in the sidebar showing the platform's own scrollbar - a
grey slab sitting directly below a room list that scrolls through
folds' Scroll.

It now uses Scroll with the same settings PageNavContent gives that room
list. Scroll sets height: 100%, which only resolves inside a box the
flex layout has already sized, so the sizing moves to a wrapper and the
scrolling stays on the child - the same split PageNavContent uses.
Overriding folds' height from our own class would have depended on
stylesheet order.

paddingRight goes away because Scroll reserves its own 8px gutter and
the room list pads to 0 on that side for the same reason; keeping it
would have stepped the two lists out of alignment.

* fix(thread): stop the header label out-shouting the thread title

The thread header put its emphasis on the wrong line. "Thread View"
rendered at B400 - 14px, W500, full opacity - while the thread title
below it rendered at T200 with priority 300: 12px, W400, 0.75 opacity.
The label says the same thing on every thread the user opens; the title
is the only part of that bar that identifies the conversation.

Swap the two. "Thread View" becomes an eyebrow (L400, priority 300,
uppercase, 0.04em tracking) matching the treatment the sidebar category
headers and the SHOW MORE pill already use for chrome labels, and the
title takes T300 at full opacity with W500.

Measured live: eyebrow 6.53:1 dark / 6.88:1 light against the banner
container, title 10.22:1 and 15.05:1. The banner grows 75px to 77px
because only the subtitle line changes size - the title row is governed
by the back button - and it sits outside the virtualizer, so the row
estimates in threadRenderUtils.ts are untouched.

The +N tag overflow chip also drops its hardcoded rgba(128,128,128,0.2)
for the SurfaceVariant container tokens, so it tracks all five themes
and the tag pills beside it instead of being one grey slab everywhere.

* feat(theme): put the last shadows on theme tokens

Stage 1a moved elevation onto config.shadow, whose Other.Shadow token is
rgba(31, 30, 38, 0.13) in light, 0.16 in silver, and rgba(0, 0, 0, 0.55) in
dark and midnight. Five sites still painted their own black, so they were
tuned for exactly one theme: too heavy on light and butter, invisible on
midnight.

The two tag dropdowns and the collapsible pill take E100, the info popover
takes E200. The code-block truncation fade was also pointing the wrong way,
darkening downward over a surface it did not match; it now fades into
SurfaceVariant.Container, which is what BaseCode paints the block with, the
same shape CollapsibleGradientOverlay uses for message bodies.

src/app/**/*.css.ts now has no hardcoded colors left.

* fix(theme): keep soft badges visible where the palette collides

The new palette makes Secondary.Container the same hex as
SurfaceVariant.Container in dark, midnight and butter, and the same hex as
Background.ContainerActive everywhere but silver. Any Soft Secondary badge
landing on one of those surfaces loses its pill and leaves the digits
floating. On dev the dark pair was #333333 against #404040, so this is new.

The thread card's message count now draws its outline, like the two sibling
badges that already do. The unread count stops softening on the selected row,
which is already resolved by the selection highlight and so gives up nothing
by staying solid.

This patches the two call sites, not the collision. Moving Secondary.Container
off its neighbours repaints every Soft Secondary surface in the app, and this
branch has no live coverage of butter, midnight or silver to catch what that
would break. FORK_CHANGES.md records the collision and the unaudited surfaces.

* docs: compress the design-refresh runbook entries

Eleven stage entries at six to eight dense bullets each had turned the top of
the Runbook into 150 lines to scroll past before reaching anything else. The
per-stage prose was mostly reasoning that the commit messages and the code
comments already carry.

One table row per stage, then the things that actually cost someone time if
they do not know them: the Secondary.Container collision, the pinned T400/T300
tracking the virtualizer estimator depends on, why transition.ts cannot be a
.css.ts, why reduced motion skips keyframes, and the duplicated
Background.Container hexes.
* fix: restore scrollbar thumb contrast

* docs: record scrollbar PR status

* refactor: simplify scrollbar contrast fix

* fix: cover standards scrollbar fallback

* test: read scrollbar tracks from palette

* fix: cover textarea scrollbars
* fix: preserve browser number shortcuts in composer

* test: cover command number shortcuts

* test: strengthen composer shortcut coverage
…203)

* fix(ios): pin the app shell and portal host to the visual viewport (CINNY-132)

In the iOS Add-to-Home-Screen PWA, tapping the message composer left a large gap between the composer and the keyboard, and the whole app could scroll further than it should.

iOS Safari never shrinks the layout viewport for the software keyboard. Instead it pans the visual viewport down over the layout viewport to reveal the focused editor, and reports that displacement as visualViewport.offsetTop. Nothing in the codebase read offsetTop.

The shell was sized to visualViewport.height but still anchored at layout y=0, so it ended exactly offsetTop pixels above the visible bottom. That interval sat below the room page but inside #root, so it painted the app background: the reported gap. The remaining pan range is what allowed the app to scroll past its own bounds. The gap height was identically visualViewport.offsetTop, and offsetTop == 0 was already the correct geometry, which is why scrolling up worked around it.

The native Capacitor wrapper was unaffected because Keyboard.resize: 'native' shrinks the real WKWebView layout viewport, so visualViewport.height equals innerHeight and offsetTop is always 0.

The hook now publishes --app-viewport-offset-top alongside --app-height and subscribes to visualViewport 'scroll' in addition to 'resize', since a pan changes only the offset and fires no resize event. Both #root and its sibling #portalContainer become visual-viewport followers pinned to the visible window.

Positioning the portal host alone was not sufficient: the command palette and FilterBarMobileSheet sized themselves in svh/dvh, which track the layout viewport regardless of the parent box, so both now measure --app-height. The portal host is pointer-events: none with a zero-specificity :where() restore on its children, which keeps folds' own click-through tooltips intact. PopOut remains layout-viewport anchored; it is a small surface placed from a measured getBoundingClientRect(), so it degrades gracefully. Documented as a known limitation.

top is used rather than transform: translateY(). A transform would make #root the containing block for every position:fixed descendant. top on a fixed element creates no such containing block.

Changing #root from static to fixed also re-resolves the containing block for position:absolute descendants that have no positioned ancestor. All 50 absolute-positioned sites across 33 files were audited by tracing real render ancestry. All 50 are inert under the change: the visually hidden live regions carry all-auto insets with explicit 1px geometry, and every other site already has a positioned owner.

Also removes two dead compensation paths: the window.scrollTo(0, 0) call, which cannot work because html and body are both overflow:hidden and the displacement is a visual-viewport pan, and the duplicate per-view --app-height lock on the RoomView page. The hook is now mounted once at App rather than per room. orientationchange and pageshow no longer force layout-viewport geometry, since neither event proves the keyboard is gone.

Coverage locks the behavior rather than the source text. mobileKeyboardViewportGeometry.test.ts mounts the real App with the shipped stylesheet in CSSOM and drives a scroll-only pan; e2e/cinny132-keyboard-viewport.spec.ts measures the same invariant in Chromium with real getBoundingClientRect(). Each of the five load-bearing pieces (the App mount, #root position, its top, its height, and the portal host rule) was reverted in isolation and confirmed to turn both suites red.

Desktop, Android web and the Capacitor wrapper are identity cases; the resulting geometry is unchanged there.

Not verified on a physical iOS device: no desktop browser performs a real visual-viewport pan, so that WebKit reports the offset assumed here still needs on-device confirmation.

* fix(mobile): detect keyboard viewport geometry

* fix(mobile): ignore stale Safari viewport geometry
* fix: restore bottom-sheet corner radii with the folds radii token

The command-palette and thread-filter mobile sheets set their top
corner radius through var(--radii-400), but folds hashes its CSS
variable names, so the variable never resolved and both sheets
rendered with square corners. Use config.radii.R400 instead, which
also keeps the sheets on the fork's modernized radius scale. The folds
mocks in the three affected test files gain config.radii, and the
command-palette expectation pins the resolved value.

* docs: link bottom-sheet radii runbook entry to PR #200

* test: cover thread filter sheet radius token

* docs: record bottom-sheet validation
* fix: separate Recently Opened rows (CINNY-133)

Each entry in the Recently Opened panel rendered the room name and timestamp on the first line and the thread title on the second, with adjacent entries separated only by whitespace.

The spacing was inverted relative to the grouping it needed to express.
folds line boxes leave 3px of half-leading per side, so the outer column's 4px gap put 10px between the two lines inside a row while adjacent rows sat only 6px apart.
Under the Gestalt proximity principle the closer pair reads as the group, so each title appeared to belong to the next row's room name rather than its own.
Reordering the two lines alone does not correct this, because it leaves both distances unchanged.

Three changes restore the intended grouping without altering the 42px row pitch.

The title moves above the metadata line, so the brightest and largest text starts every row and the visual order matches the accessible label, which already read title-first.

The outer column's gap="100" is removed and respent as a margin-top on every entry after the first, inverting the optical distances to 6px within a row and 10px between rows.
The 4px is moved rather than added: the row box goes from 42px to 38px and the reclaimed space becomes the inter-row band.
The inner room/timestamp Box keeps its horizontal gap="100".

A single 1px ContainerLine hairline is drawn inside that band via `& + &::before`, inset to match NavItemContent's text paddings.
It is absolutely positioned, so it adds no layout height, and the adjacency selector means there is no rule above the first row, none below the last, and neither margin nor rule for a single-entry list.
The -2px offset is deliberately integral rather than a centered -2.5px, which keeps the rule crisp at DPR 1.

Row height math, truncation behavior, metadata opacities and the SpaceBetween flex pair are all unchanged, so the timestamp keeps its own width and the room name truncates first.
recentlyOpenedPanelHeight.ts is untouched.

* test: cover Recently Opened row grouping
* fix: render long-text preview markdown immediately

* perf: streamline preview marker placeholders

* fix: normalize long-text reply preview fallback

* fix: group long-text preview tool calls

* fix: harden long-text preview markdown

* fix: bound long-text preview edge cases

* fix: preserve container fence boundaries

* fix: scope preview container parsing

* docs: record final preview review

* refactor: simplify long-text preview rendering

* fix: keep preview fallback conservative

* docs: record preview closure review

* fix: preserve preview math and list semantics

* fix: align preview list and reply handling

* fix: match preview container boundaries

* fix: align preview parsing with parser

* fix: honor escaped preview syntax

* fix: preserve nested marker prefixes

* fix: keep wrench list items literal

* fix: keep wrapped wrench list markers literal

* fix: preserve ordinary wrench list formatting

* fix: validate nested preview tool markers

* fix: match nested marker DOM semantics
* fix: persist deep trace intent across relaunches

* test: cover deep trace activation failure intent

* fix: keep deep trace runtime status truthful

* docs: finalize deep trace runbook

* refactor: clarify deep trace status copy

* fix: report saved deep trace intent in exports

* docs: finalize deep trace PR runbook

* docs: condense deep trace runbook
A Dutch-locale Chrome on chat.mindroom.chat replaced a thread with
react-router's default error page and "NotFoundError: Failed to execute
'removeChild' on 'Node'". Chrome's translator moves React-owned text
nodes into injected <font> wrappers, so the next commit removes a node
from the wrong parent and the throw escapes to the router boundary. The
reported stack was itself translated ("at" rendered as "op"/"bij"), which
confirms translation was live on the document. Streaming MindRoom replies
commit constantly, so a translated thread crashes quickly.

Mark the document notranslate so Chrome's translator stays out of the
React tree; the in-app language picker already covers en/de/nl and the
language detector already resolves the browser locale ahead of the html
tag, so a Dutch browser still gets a Dutch UI.

Add installDomMutationGuard() as a second layer for extensions that
ignore notranslate: removeChild and insertBefore no-op (or append) on a
parent mismatch instead of throwing. It runs before the first React
commit, is idempotent, and warns once per session so genuine app-side DOM
bugs stay visible.
@basnijholt

Copy link
Copy Markdown
Author

Opened against the wrong remote — this targets our downstream fork, not upstream Cinny. Apologies for the noise.

@basnijholt basnijholt closed this Aug 7, 2026
@basnijholt basnijholt reopened this Aug 7, 2026
@basnijholt basnijholt closed this Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

@github-actions github-actions Bot locked and limited conversation to collaborators Aug 7, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant