Skip to content

Fix four defects in the model-badge integration - #167

Merged
TroyHernandez merged 5 commits into
mainfrom
fix/badge-review
Aug 5, 2026
Merged

TroyHernandez merged 5 commits into
mainfrom
fix/badge-review

Conversation

@TroyHernandez

Copy link
Copy Markdown
Contributor

Review findings against the #155 rescue. The chat.api transport rewire itself was clean; all four are in the badge work.

1. HIGH — a display-name relogin left the caller on the rejected token

mx.client::mx_set_displayname() wraps mx_with_relogin(), so a stale token is refreshed and persisted — but it returns invisible(TRUE) and discards the refreshed client. matrix_update_displayname() kept its own cfg, so the /model acknowledgement then sent with the token the homeserver had just rejected.

chat_send() does no relogin of its own, so the failure was swallowed by the caller's tryCatch and the acknowledgement vanished with nothing logged. Narrow window, but token rotation is the entire reason this path exists.

Now returns a config and re-reads after a successful rename; all three call sites adopt it.

Follow-up for mx.client: the real fix is for mx_set_displayname() to return the client the way mx_with_relogin() does. That is an mx.client change and 0.2.0 is mid-submission, so corteza reloads from disk for now.

2. Startup and /clear advertised the wrong model under runtime overrides

Both call matrix_update_displayname() with no session, so matrix_badge_displayname() fell back to cfg$model rather than the model the next session is actually built with:

startup/reset:   bot ⚡ configured-model
actual session:  bot ⚡ runtime-override

Per-message badges were always right; only the account display name was wrong. Both sites now pass the run's model/provider.

3. The mx.client floor was declared but not enforced

matrix_require_mx() version-gated chat.api only — with a comment explaining precisely why a Suggests floor is a resolution hint and not a runtime guarantee, and then not applying that to mx.client. A host on 0.1.1 passed the guard, and the missing mx_set_displayname() was lost to a best-effort tryCatch, so the sender-line badge silently never worked. Now gated on .MX_CLIENT_MIN.

4. The tests did not reach these paths

The 15 badge assertions covered pure rendering and default helpers. None drove matrix_update_displayname() or /model and /clear through the rewired transport, which is exactly why 1 and 2 escaped. I had claimed "2663 tests pass" settled the semantic merge; it settled the helpers, not the integration.

Added guards that drive matrix_update_displayname() with a stubbed relogin and assert the reloaded config is adopted, assert the runtime override reaches the display name, and check the runtime floor.

Verification

2668 tests, 0 failures. R CMD check: 0 errors, 0 warnings, 1 NOTE (Suggests or Enhances not in mainstream repositories: chat.api, the known drat item).

1. HIGH: a display-name relogin left the caller on the rejected token.
   mx.client::mx_set_displayname() wraps mx_with_relogin(), so a stale
   token is refreshed and persisted, but it returns invisible(TRUE) and
   discards the refreshed client. matrix_update_displayname() kept its
   own cfg, and the /model acknowledgement then sent with the token the
   homeserver had just rejected. chat_send() does no relogin of its own,
   so the failure was swallowed by the caller's tryCatch and the ack
   vanished with nothing logged. It now returns a config and re-reads
   after a successful rename; all three call sites adopt it.

2. Startup and /clear advertised the wrong model under runtime
   overrides. Both call matrix_update_displayname() with no session, so
   matrix_badge_displayname() fell back to cfg$model rather than the
   model the next session would actually be built with. Both now pass
   the run's model/provider.

3. The mx.client >= 0.2.0 floor was declared in DESCRIPTION but not
   enforced at runtime. matrix_require_mx() version-gated chat.api only,
   with a comment explaining exactly why a Suggests floor is not a
   runtime guarantee -- and did not apply it to mx.client. A host on
   0.1.1 passed the guard and then lost the rename to a best-effort
   tryCatch. Now gated on .MX_CLIENT_MIN.

4. The badge tests only covered pure rendering helpers, so 1 and 2 both
   escaped them. Added guards that drive matrix_update_displayname()
   with a stubbed relogin, assert the override reaches the display name,
   and check the runtime floor.
1. HIGH: the refreshed token never reached encrypted sends.
   matrix_send_maybe_encrypted() adopted the fresh cfg for the member
   lookup but still handed mx_send_encrypted() crypto$client, a copy
   taken once at init. After a rename relogin that object holds the
   rejected token, so an encrypted /model acknowledgement was dropped
   exactly as before. The cached client is gone; sends take the caller's
   live cfg. Nothing else read the field.

2. Startup returned a stale mx_sess: it was built before the rename and
   returned after, so archive-flush room-name lookups spent the run on a
   rejected token. Rebuilt after the refresh.

3. The regression tests did not reach the regression. They called
   matrix_update_displayname() directly, so deleting any of the three
   'cfg <-' assignments left them green. There is now a matrix_poll()
   test that drives a /model switch through a relogin-on-rename and
   asserts the send receives the rotated token; reverting the assignment
   turns it red, which I verified rather than assumed.

   Writing it surfaced why the first attempt was so easy to get wrong:
   matrix_poll() ignores chat_poll()$messages and re-extracts from
   res$raw, so a message seeded through the .extract seam never reaches
   the reply path at all.

4. The floor test only proved CI had a new enough mx.client. The version
   lookups in matrix_require_mx() are now injectable, so the gate is
   tested by passing an old version and expecting the error.
Three review rounds have each found the same defect in a different
holder, so this fixes the class rather than the instances. The token
rotates -- chat_poll() relogins, and so does a badge rename -- and
anything built once from cfg keeps the rejected one.

matrix_mx_session() is pure field validation over cfg, no network, so
caching it buys nothing and costs correctness. The poll loop now derives
through sess_now()/chat_now() at each use: read receipts, member
lookups, archive, typing. matrix_run_step() derives its own session for
the archive flush rather than using the startup one, which every
rotation since had left stale.

matrix_approval_cb() closed over the cfg its session was built with, and
a session outlives many rotations. A prompt sent on a rejected token
fails into FALSE, which the model reads as the user declining. It now
re-reads at invocation; approvals are rare, interactive and already
blocking, so the read is free.

Swept the rest: every remaining mx_sess is either call-scoped or the
deliberate pre-sync chat client, which must exist before the sync.

Tests: the E2EE guard drives matrix_send_maybe_encrypted() and asserts
mx_send_encrypted() gets the live cfg -- reintroducing crypto$client
turns it red, which I checked. /clear now has the same relogin guard as
/model. Updated the typing source-pin, whose intent (typing rides the
contract, not mx_typing) is unchanged.
mx_with_relogin() persists the refreshed client BEFORE it retries, so a
relogin whose retry then fails -- rate limit, transient 5xx -- still
leaves the live token on disk. Gating the reload on success threw it
away and sent the following /model or /clear acknowledgement on the
rejected token: the same silent drop the reload was added to prevent.

Disk is authoritative either way, so the success check went away rather
than growing another branch. When nothing rotated the reload returns
what we already had.

The earlier guards only modelled 'persist, then succeed', which is why
this survived two rounds. The new one models 'persist, then throw';
restoring the success gate turns it red, verified.

Also drops the now-unused mx_sess assignment left by the de-caching.
@TroyHernandez
TroyHernandez merged commit 8592467 into main Aug 5, 2026
2 checks passed
@TroyHernandez
TroyHernandez deleted the fix/badge-review branch August 5, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant