A bot with no channel account data is silently deaf, and the allowlist is writable by any MCP client #129

Open
opened 2026-08-26 16:01:49 +00:00 by jlxq0 · 5 comments
Owner

Two findings from one read of src/channel.rs, both about what a newly created bot cannot do or cannot see. Same root as #128: what a bot cannot do on its first run is what nobody reports, because everyone already past it never sees it.

1. The likeliest failure is the only one with no telemetry

ChannelRegistry::config has three outcomes and logs two:

Ok(Some(raw)) => deserialize, or warn "channel config is malformed; refusing to push"
Err(e)        => warn "could not read channel config; refusing to push"
Ok(None)      => ChannelConfigEventContent::default()      <- nothing logged

The default has an empty allowed_senders, and an empty allowlist pushes nothing. That is correct and the doc comment defends it: failing closed beats letting a stranger put text in front of a model holding live credentials.

But Ok(None) means the account data key is simply absent, which is not an error, so it takes the quiet branch. It is also the state every newly created bot is in until somebody writes the key. So a bot with no app.matrix_mcp.channel account data is silently deaf to push, indefinitely, while whoami answers, list_joined_rooms shows the room and read_recent_messages returns every message. The fetch path never consults the allowlist, which is why the inside of the session looks perfectly healthy.

One info! on that branch, naming the mxid and saying the key is absent, converts a permanent silent failure into a log line at startup. It changes the security posture not at all, because the branch already refuses.

Confirmed live 2026-08-26: channel: skipped, sender not allowlisted ... allowed:0 for a new bot, at INFO, among 1,569 lines, and it took reading channel.rs to know the string existed. The one signal that says a channel is muted is invisible from inside the session and undocumented outside it. A tool to read one's own allowlist, or a count on whoami, would close the other half.

Ordering note: the log line is a one-liner. The rest of this issue is a decision and a project.

2. app.matrix_mcp.channel is not a reserved account-data type

account_data_event_type_is_reserved lists nine exact types plus the m.secret_storage.key. prefix. The channel allowlist is not among them, so set_account_data will write it.

The list's own justification for including m.audit_room is that a malicious overwrite disables future audit notices. The channel allowlist is the stronger case by that same argument: overwriting m.audit_room disables notices, while overwriting allowed_senders grants a standing inbound push channel into a model holding live credentials, which is what the type's own doc comment calls the injection boundary.

The layering that exists is real and worth keeping. set_account_data is not in CHANNEL_TOOLS, so a message arriving over the channel cannot by itself reach the writer.

It closes only if the two mounts are held by different sessions, and in this fleet they are not. Every director holds the channel mount and the full /mcp mount, so injected text arriving on the channel is read by a session that can write the allowlist. The escalation is from one-shot to durable: the injection has already happened by assumption, and what the write buys is persistence. A new MXID in allowed_senders is a push channel that survives restarts, that no operator has a reason to inspect, and that the bot itself has no tool to read.

Reserving the type is the fix and it has a real cost. README.md:93 documents the JSON as a manual setup step; reserving it means a new bot's allowlist can only be written from a Matrix client, never by automation on the creation path. Given that path already fails to write the key at all and fails to give a new bot invite tools (#128), it needs a design rather than a patch.

  • #128, the invite capability and the ambiguous empty room list.
  • #127, a mount that cannot describe its own state.
Two findings from one read of `src/channel.rs`, both about what a **newly created** bot cannot do or cannot see. Same root as #128: what a bot cannot do on its first run is what nobody reports, because everyone already past it never sees it. ## 1. The likeliest failure is the only one with no telemetry `ChannelRegistry::config` has three outcomes and logs two: Ok(Some(raw)) => deserialize, or warn "channel config is malformed; refusing to push" Err(e) => warn "could not read channel config; refusing to push" Ok(None) => ChannelConfigEventContent::default() <- nothing logged The default has an empty `allowed_senders`, and an empty allowlist pushes nothing. That is correct and the doc comment defends it: failing closed beats letting a stranger put text in front of a model holding live credentials. But `Ok(None)` means the account data key is simply **absent**, which is not an error, so it takes the quiet branch. It is also **the state every newly created bot is in until somebody writes the key.** So a bot with no `app.matrix_mcp.channel` account data is silently deaf to push, indefinitely, while `whoami` answers, `list_joined_rooms` shows the room and `read_recent_messages` returns every message. The fetch path never consults the allowlist, which is why the inside of the session looks perfectly healthy. **One `info!` on that branch, naming the mxid and saying the key is absent, converts a permanent silent failure into a log line at startup.** It changes the security posture not at all, because the branch already refuses. Confirmed live 2026-08-26: `channel: skipped, sender not allowlisted ... allowed:0` for a new bot, at INFO, among 1,569 lines, and it took reading `channel.rs` to know the string existed. **The one signal that says a channel is muted is invisible from inside the session and undocumented outside it.** A tool to read one's own allowlist, or a count on `whoami`, would close the other half. Ordering note: the log line is a one-liner. The rest of this issue is a decision and a project. ## 2. `app.matrix_mcp.channel` is not a reserved account-data type `account_data_event_type_is_reserved` lists nine exact types plus the `m.secret_storage.key.` prefix. The channel allowlist is not among them, so `set_account_data` will write it. The list's own justification for including `m.audit_room` is that a malicious overwrite disables future audit notices. **The channel allowlist is the stronger case by that same argument**: overwriting `m.audit_room` disables notices, while overwriting `allowed_senders` grants a standing inbound push channel into a model holding live credentials, which is what the type's own doc comment calls the injection boundary. **The layering that exists is real and worth keeping.** `set_account_data` is not in `CHANNEL_TOOLS`, so a message arriving over the channel cannot by itself reach the writer. **It closes only if the two mounts are held by different sessions, and in this fleet they are not.** Every director holds the channel mount and the full `/mcp` mount, so injected text arriving on the channel is read by a session that can write the allowlist. The escalation is from one-shot to durable: the injection has already happened by assumption, and what the write buys is persistence. A new MXID in `allowed_senders` is a push channel that survives restarts, that no operator has a reason to inspect, and that the bot itself has no tool to read. **Reserving the type is the fix and it has a real cost.** `README.md:93` documents the JSON as a manual setup step; reserving it means a new bot's allowlist can only be written from a Matrix client, never by automation on the creation path. Given that path already fails to write the key at all and fails to give a new bot invite tools (#128), it needs a design rather than a patch. ## Related - #128, the invite capability and the ambiguous empty room list. - #127, a mount that cannot describe its own state.
Author
Owner

3. get_account_data cannot say "I cannot see that"

Measured on a live account rather than reasoned about, with both controls:

m.direct                            content present, 66 bytes    positive control
app.matrix_mcp.channel              content=null
app.matrix_mcp.nonexistent_control  content=null                 negative control

An invented type and a real-but-unwritten one are the same answer. The positive control beside them is what makes the null mean anything; without it the reading is the absent-field fault again, an absence produced by nothing writing there read as an absence produced by nothing happening.

Two things in the code make it sharper than the observation.

A present event that fails to serialise becomes null. src/mcp.rs:6773 is .map(|raw| serde_json::to_value(&raw).unwrap_or(serde_json::Value::Null)). So "the key is there and we could not render it" and "the key is not there" are the same result. A transport failure does become an internal_error, so that half is fine; this is the half that is not.

account_data_raw reads the local store, not the server. A key written on the server and not yet synced into this client reads as absent, which is the same staleness family as the receipt in #127 and it fails in the direction that says "not provisioned" about an account that is.

Why this matters for the creation path. A provisioning check written the obvious way, "read app.matrix_mcp.channel, is it there", reports success for a correctly provisioned bot and for an unreadable or unsynced account identically. It needs a known-present type read in the same call, which is what the positive control above is.

Two operational facts for README.md:93

set_account_data replaces rather than merges. The bot measured above had no existing content so nothing could have been lost. The next one may, and whoever performs that documented step will run it against an account whose current state they have not read, so a partial app.matrix_mcp.channel would be overwritten wholesale. That belongs on the line beside the JSON.

Who belongs on a new bot's list is a decision rather than a template default. One name alone means a bot no peer can reach over Matrix. That may be exactly right and it should be chosen rather than inherited.

What the three findings have in common

The Ok(None) branch logs nothing and it is the state every new bot starts in. app.matrix_mcp.channel is not reserved while m.audit_room is, on reasoning that applies more strongly here. And get_account_data cannot say "I cannot see that". All three are the tool answering confidently about a narrower question than the one asked.

Incident scope, from six hours of logs and 7,670 lines: 57 pushed, 170 skipped for no live session, and 4 allowlist skips, all four the same new bot. One instance, mechanism confirmed, nothing else muted.

## 3. `get_account_data` cannot say "I cannot see that" Measured on a live account rather than reasoned about, with both controls: m.direct content present, 66 bytes positive control app.matrix_mcp.channel content=null app.matrix_mcp.nonexistent_control content=null negative control An invented type and a real-but-unwritten one are the same answer. The positive control beside them is what makes the null mean anything; without it the reading is the absent-field fault again, an absence produced by nothing writing there read as an absence produced by nothing happening. Two things in the code make it sharper than the observation. **A present event that fails to serialise becomes `null`.** `src/mcp.rs:6773` is `.map(|raw| serde_json::to_value(&raw).unwrap_or(serde_json::Value::Null))`. So "the key is there and we could not render it" and "the key is not there" are the same result. A transport failure does become an `internal_error`, so that half is fine; this is the half that is not. **`account_data_raw` reads the local store, not the server.** A key written on the server and not yet synced into this client reads as absent, which is the same staleness family as the receipt in #127 and it fails in the direction that says "not provisioned" about an account that is. **Why this matters for the creation path.** A provisioning check written the obvious way, "read `app.matrix_mcp.channel`, is it there", reports success for a correctly provisioned bot and for an unreadable or unsynced account identically. It needs a known-present type read in the same call, which is what the positive control above is. ## Two operational facts for `README.md:93` **`set_account_data` replaces rather than merges.** The bot measured above had no existing content so nothing could have been lost. The next one may, and whoever performs that documented step will run it against an account whose current state they have not read, so a partial `app.matrix_mcp.channel` would be overwritten wholesale. That belongs on the line beside the JSON. **Who belongs on a new bot's list is a decision rather than a template default.** One name alone means a bot no peer can reach over Matrix. That may be exactly right and it should be chosen rather than inherited. ## What the three findings have in common The `Ok(None)` branch logs nothing and it is the state every new bot starts in. `app.matrix_mcp.channel` is not reserved while `m.audit_room` is, on reasoning that applies more strongly here. And `get_account_data` cannot say "I cannot see that". **All three are the tool answering confidently about a narrower question than the one asked.** Incident scope, from six hours of logs and 7,670 lines: 57 pushed, 170 skipped for no live session, and **4 allowlist skips, all four the same new bot.** One instance, mechanism confirmed, nothing else muted.
Author
Owner

4. A bot cannot ask whether it can hear, and the answer belongs in whoami

Measured on a second bot, healthy this time, which is what makes it a capability gap rather than an instance of the first finding.

Asked to read its own app.matrix_mcp.channel, it could not. Its channel mount is the eight in CHANNEL_TOOLS and get_account_data is not among them. Its other mount has the tool and authenticates as a person, so it would have returned that person's account data and answered nothing about the bot. It declined to use the wrong instrument, which is right, and the result is that no means available to a bot answers "am I muted".

So there are now two distinct unreadable states, and both look exactly like a quiet room: content: null from a mount that has the tool, and no such tool from a mount that does not.

Put it in whoami

WhoamiResult is mxid and device_id today (src/mcp.rs:275-278). Returning the allowlist alongside them costs no new schema, which is the argument CHANNEL_TOOLS's own doc comment makes against growing the list: a channel session is a conversation rather than an administration console, and every tool's schema is loaded into the model's context before it has done any work.

Not get_account_data on the channel mount. It is generic, so it would let a channel session read any global account data on the bot, including the secret-storage and cross-signing container types that account_data_event_type_is_reserved protects on the write side. Granting a broad read to answer a narrow question is how a reserved list stops covering what it should, which is finding 2 in this issue arriving from the other direction.

Report the entries, not a count

allowed: 3 is satisfied by three MXIDs none of which is the principal's, which is exactly what a display name or a truncated MXID produces. Character-for-character is what distinguishes configured from configured-wrong, and ChannelConfigEventContent's own doc comment already warns that display names are mutable and anyone can set one to impersonate anyone else. A bot's own allowlist is not a secret from the bot.

Distinguish absent from unreadable in the response

Finding 1 applied here rather than restated: if config() took the Ok(None) branch, say so. That is the state every new bot starts in, and it is currently the only one of the three failure paths with no log line either, so a bot that could see "no account data written" would answer the question that took a fleet-wide log sweep tonight.

What it buys

A new bot's first act can be to ask whether it can hear. Today it cannot find out by any means available to it, and the fleet found out because a person noticed a director not answering him.

Same root as #128 from a third direction: what a new bot cannot do on its first run is what nobody reports, because everyone already past it never looks.

## 4. A bot cannot ask whether it can hear, and the answer belongs in `whoami` Measured on a second bot, healthy this time, which is what makes it a capability gap rather than an instance of the first finding. Asked to read its own `app.matrix_mcp.channel`, it could not. Its `channel` mount is the eight in `CHANNEL_TOOLS` and `get_account_data` is not among them. Its other mount has the tool and authenticates as a person, so it would have returned that person's account data and answered nothing about the bot. It declined to use the wrong instrument, which is right, and the result is that **no means available to a bot answers "am I muted".** So there are now two distinct unreadable states, and both look exactly like a quiet room: `content: null` from a mount that has the tool, and no such tool from a mount that does not. ### Put it in `whoami` `WhoamiResult` is `mxid` and `device_id` today (`src/mcp.rs:275-278`). Returning the allowlist alongside them **costs no new schema**, which is the argument `CHANNEL_TOOLS`'s own doc comment makes against growing the list: a channel session is a conversation rather than an administration console, and every tool's schema is loaded into the model's context before it has done any work. **Not `get_account_data` on the channel mount.** It is generic, so it would let a channel session read any global account data on the bot, including the secret-storage and cross-signing container types that `account_data_event_type_is_reserved` protects on the write side. Granting a broad read to answer a narrow question is how a reserved list stops covering what it should, which is finding 2 in this issue arriving from the other direction. ### Report the entries, not a count `allowed: 3` is satisfied by three MXIDs none of which is the principal's, which is exactly what a display name or a truncated MXID produces. **Character-for-character is what distinguishes configured from configured-wrong**, and `ChannelConfigEventContent`'s own doc comment already warns that display names are mutable and anyone can set one to impersonate anyone else. A bot's own allowlist is not a secret from the bot. ### Distinguish absent from unreadable in the response Finding 1 applied here rather than restated: if `config()` took the `Ok(None)` branch, say so. That is the state every new bot starts in, and it is currently the only one of the three failure paths with no log line either, so a bot that could see "no account data written" would answer the question that took a fleet-wide log sweep tonight. ### What it buys A new bot's first act can be to ask whether it can hear. Today it cannot find out by any means available to it, and the fleet found out because a person noticed a director not answering him. Same root as #128 from a third direction: **what a new bot cannot do on its first run is what nobody reports**, because everyone already past it never looks.
Author
Owner

Settling finding 1: the branch, not the errcode

Verified against v0.10.5, the version that produced the log, rather than against main. ChannelRegistry::config at that tag:

Ok(Some(raw)) => deserialize, or warn "channel config is malformed; refusing to push"
Ok(None)      => default()                        <- silent
Err(e)        => warn "could not read channel config; refusing to push"

Both WARN branches are compiled into the running binary. The log shows an ERROR 404 and then the INFO skip milliseconds later with no WARN between them, so neither the malformed nor the unreadable branch ran and Ok(None) did. matrix-sdk maps an absent account-data type to Ok(None), and the 404 is the SDK's own logging underneath that mapping rather than an error reaching this code.

So absent and unreadable are already different branches and the fix does not need to read M_NOT_FOUND off an error. That proposal is the right requirement with the wrong mechanism: inspecting the errcode would couple this to the SDK's error shape and to Matrix's errcode staying stable, in order to recover a distinction the type system is already making.

Ok(None) => info!(mxid, "channel config absent; this bot has never been provisioned")
Err(e)   => warn!(error = %e, "could not read channel config; refusing to push")

The comment on that branch has to name the coupling, because it is a fact about matrix-sdk sitting in this code with nothing asserting it: if the SDK ever maps 404 to Err instead, the info! goes silent and every unprovisioned bot reads as unreadable. What a reader would observe is that the absent line stops appearing entirely while new bots keep being deaf.

whoami reports which, rather than inheriting the ambiguity

With the branches separated, whoami says absent, unreadable, or the list, and allowed: 0 never stands for two different things.

Entries rather than a count, and that is settled. allowed: 3 is satisfied by three MXIDs none of which is the principal's, which is exactly what a display name or a truncated MXID produces, and ChannelConfigEventContent's own doc comment warns that display names are mutable and anyone can set one to impersonate anyone else. A bot's own allowlist is not a secret from that bot, and reporting it through whoami hands over nothing a reader of that account's data could not already get.

m.direct is not a valid positive control

Correcting the check sketched in finding 3. m.direct exists on the bot it was measured on only because someone opened a DM with it. A freshly provisioned bot has none, so a provisioning check copied from that transcript reads content: null twice and calls a working bot unreadable, which is the same absent-versus-unreadable fault the check exists to avoid, one level up.

m.push_rules is the control. The server generates it for every account, so it holds on a bot with no history, and it is already in account_data_event_type_is_reserved (src/mcp.rs:361), so it can be read and never written. That is the right shape for a control: present by construction, and not something a check could accidentally create.

## Settling finding 1: the branch, not the errcode Verified against `v0.10.5`, the version that produced the log, rather than against `main`. `ChannelRegistry::config` at that tag: Ok(Some(raw)) => deserialize, or warn "channel config is malformed; refusing to push" Ok(None) => default() <- silent Err(e) => warn "could not read channel config; refusing to push" Both WARN branches are compiled into the running binary. The log shows an ERROR 404 and then the INFO skip milliseconds later with **no WARN between them**, so neither the malformed nor the unreadable branch ran and `Ok(None)` did. matrix-sdk maps an absent account-data type to `Ok(None)`, and the 404 is the SDK's own logging underneath that mapping rather than an error reaching this code. **So absent and unreadable are already different branches and the fix does not need to read `M_NOT_FOUND` off an error.** That proposal is the right requirement with the wrong mechanism: inspecting the errcode would couple this to the SDK's error shape and to Matrix's errcode staying stable, in order to recover a distinction the type system is already making. Ok(None) => info!(mxid, "channel config absent; this bot has never been provisioned") Err(e) => warn!(error = %e, "could not read channel config; refusing to push") **The comment on that branch has to name the coupling**, because it is a fact about matrix-sdk sitting in this code with nothing asserting it: if the SDK ever maps 404 to `Err` instead, the `info!` goes silent and every unprovisioned bot reads as unreadable. What a reader would observe is that the absent line stops appearing entirely while new bots keep being deaf. ## `whoami` reports which, rather than inheriting the ambiguity With the branches separated, `whoami` says **absent**, **unreadable**, or the list, and `allowed: 0` never stands for two different things. **Entries rather than a count, and that is settled.** `allowed: 3` is satisfied by three MXIDs none of which is the principal's, which is exactly what a display name or a truncated MXID produces, and `ChannelConfigEventContent`'s own doc comment warns that display names are mutable and anyone can set one to impersonate anyone else. A bot's own allowlist is not a secret from that bot, and reporting it through `whoami` hands over nothing a reader of that account's data could not already get. ## `m.direct` is not a valid positive control Correcting the check sketched in finding 3. `m.direct` exists on the bot it was measured on only because someone opened a DM with it. **A freshly provisioned bot has none**, so a provisioning check copied from that transcript reads `content: null` twice and calls a working bot unreadable, which is the same absent-versus-unreadable fault the check exists to avoid, one level up. **`m.push_rules` is the control.** The server generates it for every account, so it holds on a bot with no history, and it is already in `account_data_event_type_is_reserved` (`src/mcp.rs:361`), so it can be read and never written. That is the right shape for a control: present by construction, and not something a check could accidentally create.
Author
Owner

Correcting the previous comment's evidence, and one thing it missed

The conclusion stands and the reason I gave for it does not. I wrote that the Ok(None) branch fired because no WARN appeared between the 404 and the INFO skip. That is an absence from an instrument nobody had shown could record a presence, which is the fault this issue is about. Neither WARN string has appeared for any bot in twelve hours across 8,278 lines, so the log cannot distinguish "that branch did not run" from "that string never appears". WARN itself is emitted freely in the same period, which shows the level works and says nothing about those two lines.

Settled from the library instead. matrix-sdk-0.17.0/src/account.rs:946:

match self.client.send(request).await {
    Ok(r) => Ok(Some(r.account_data)),
    Err(e) => {
        if let Some(kind) = e.client_api_error_kind() {
            if kind == &ErrorKind::NotFound { Ok(None) } else { Err(e.into()) }
        } else { Err(e.into()) }
    }
}

config() calls fetch_account_data_static, which delegates to fetch_account_data (:959-964), so M_NOT_FOUND becomes Ok(None) and every other failure becomes Err. The SDK performs the discrimination one layer below, which is why the one-line info! is the whole fix and why reading M_NOT_FOUND in our code would be not merely redundant but unreachable: a 404 never arrives as an Err.

That also explains the zero rather than leaving it loose. The Err branch fires only on non-404 account-data failures, which are rare, so twelve hours without one is expected. The string is reachable; nothing has reached it.

The coupling to name in the comment, since it is matrix-sdk's behaviour sitting in our control flow with nothing asserting it: if a future version stops special-casing NotFound, Ok(None) goes unreachable, every unprovisioned bot logs "could not read" instead, and the two cases merge again silently. A reader would notice that the absent line had stopped appearing entirely while new bots kept being deaf, and no test would catch it.

The thing this turned up: the tool and the channel read different sources

config() reads the server. get_account_data reads the local store.

config()            client.account().fetch_account_data_static()   -> server, 404 mapped to Ok(None)
get_account_data    client.account().account_data_raw()            -> state_store().get_account_data_event()

So a bot asking get_account_data("app.matrix_mcp.channel") about itself is not asking the question the channel asks. A key written on the server but not yet synced into this client reads as null from the tool while config() would find it, and the two disagree with nothing saying so. That sharpens finding 3: the tool's null means "not in my local store", which is a third state beside absent-on-server and unreadable.

It is also the argument for finding 4 from another direction. whoami reporting the allowlist would report what the channel actually used, resolved the way the channel resolves it, rather than a second reading of a different source that happens to look like the same question.

m.push_rules remains the positive control for any provisioning check, not m.direct.

## Correcting the previous comment's evidence, and one thing it missed **The conclusion stands and the reason I gave for it does not.** I wrote that the `Ok(None)` branch fired *because no WARN appeared between the 404 and the INFO skip*. That is an absence from an instrument nobody had shown could record a presence, which is the fault this issue is about. Neither WARN string has appeared for any bot in twelve hours across 8,278 lines, so the log cannot distinguish "that branch did not run" from "that string never appears". WARN itself is emitted freely in the same period, which shows the level works and says nothing about those two lines. **Settled from the library instead.** `matrix-sdk-0.17.0/src/account.rs:946`: match self.client.send(request).await { Ok(r) => Ok(Some(r.account_data)), Err(e) => { if let Some(kind) = e.client_api_error_kind() { if kind == &ErrorKind::NotFound { Ok(None) } else { Err(e.into()) } } else { Err(e.into()) } } } `config()` calls `fetch_account_data_static`, which delegates to `fetch_account_data` (`:959-964`), so **`M_NOT_FOUND` becomes `Ok(None)` and every other failure becomes `Err`.** The SDK performs the discrimination one layer below, which is why the one-line `info!` is the whole fix and why reading `M_NOT_FOUND` in our code would be not merely redundant but **unreachable**: a 404 never arrives as an `Err`. That also explains the zero rather than leaving it loose. The `Err` branch fires only on non-404 account-data failures, which are rare, so twelve hours without one is expected. The string is reachable; nothing has reached it. **The coupling to name in the comment**, since it is matrix-sdk's behaviour sitting in our control flow with nothing asserting it: if a future version stops special-casing `NotFound`, `Ok(None)` goes unreachable, every unprovisioned bot logs "could not read" instead, and the two cases merge again silently. A reader would notice that the absent line had stopped appearing entirely while new bots kept being deaf, and no test would catch it. ## The thing this turned up: the tool and the channel read different sources **`config()` reads the server. `get_account_data` reads the local store.** config() client.account().fetch_account_data_static() -> server, 404 mapped to Ok(None) get_account_data client.account().account_data_raw() -> state_store().get_account_data_event() So a bot asking `get_account_data("app.matrix_mcp.channel")` about itself is **not asking the question the channel asks**. A key written on the server but not yet synced into this client reads as `null` from the tool while `config()` would find it, and the two disagree with nothing saying so. That sharpens finding 3: the tool's `null` means "not in my local store", which is a third state beside absent-on-server and unreadable. It is also the argument for finding 4 from another direction. `whoami` reporting the allowlist would report **what the channel actually used**, resolved the way the channel resolves it, rather than a second reading of a different source that happens to look like the same question. `m.push_rules` remains the positive control for any provisioning check, not `m.direct`.
Author
Owner

Two things measured after this was filed, and the first changes how the fix is written.

matrix-sdk already separates the two cases, so no errcode inspection is needed. matrix-sdk-0.17.0/src/account.rs:946:

match self.client.send(request).await {
    Ok(r) => Ok(Some(r.account_data)),
    Err(e) => {
        if let Some(kind) = e.client_api_error_kind() {
            if kind == &ErrorKind::NotFound { Ok(None) } else { Err(e.into()) }
        } else { Err(e.into()) }
    }
}

M_NOT_FOUND becomes Ok(None); every other failure becomes Err. A 404 never arrives as an Err, so reading the errcode here would be unreachable code recovering a distinction the type system has already made. One info! on the silent branch is the whole thing.

Name the coupling in a comment. That mapping is matrix-sdk's behaviour sitting in this crate's control flow with nothing asserting it. If a future version stops special-casing NotFound, Ok(None) goes unreachable, every unprovisioned bot logs could not read instead, and the two cases silently merge again. A reader would notice reason=absent had stopped appearing entirely while new bots kept being deaf, and no test would catch it.

Second: a director cannot run this check on itself, which is why whoami carrying the state is the fix rather than a convenience. Measured by two directors on their own mounts:

  • The channel mount is eight tools and none reads account data, so a bot cannot see its own allowlist.
  • The matrix-julian mount does carry get_account_data and answers as @julian:kampong.social, so using it would return his account data and say nothing about a bot. Wrong instrument, not a workaround.
  • get_account_data reads the local state store (account_data_raw -> state_store()), while the push path's config() reads the homeserver. So even once a bot can call it, the two can disagree with nothing saying so.

So whoami must report what the channel actually used, resolved the way the channel resolves it. A self-check built on get_account_data would be a second reading of a different source that looks like the same question.

And the control for any provisioning check is m.push_rules, never m.direct. m.direct exists only if someone has opened a DM with the account, so a freshly provisioned bot has none and a check built on it reads a working bot as unreadable. m.push_rules is server-generated for every account, and it is already in the reserved list, so it can be read and never written.

**Two things measured after this was filed, and the first changes how the fix is written.** **matrix-sdk already separates the two cases, so no errcode inspection is needed.** `matrix-sdk-0.17.0/src/account.rs:946`: match self.client.send(request).await { Ok(r) => Ok(Some(r.account_data)), Err(e) => { if let Some(kind) = e.client_api_error_kind() { if kind == &ErrorKind::NotFound { Ok(None) } else { Err(e.into()) } } else { Err(e.into()) } } } `M_NOT_FOUND` becomes `Ok(None)`; every other failure becomes `Err`. **A 404 never arrives as an `Err`**, so reading the errcode here would be unreachable code recovering a distinction the type system has already made. One `info!` on the silent branch is the whole thing. **Name the coupling in a comment.** That mapping is matrix-sdk's behaviour sitting in this crate's control flow with nothing asserting it. If a future version stops special-casing `NotFound`, `Ok(None)` goes unreachable, every unprovisioned bot logs `could not read` instead, and the two cases silently merge again. A reader would notice `reason=absent` had stopped appearing entirely while new bots kept being deaf, and no test would catch it. **Second: a director cannot run this check on itself, which is why `whoami` carrying the state is the fix rather than a convenience.** Measured by two directors on their own mounts: - The `channel` mount is eight tools and none reads account data, so a bot cannot see its own allowlist. - The `matrix-julian` mount does carry `get_account_data` and answers as `@julian:kampong.social`, so using it would return **his** account data and say nothing about a bot. Wrong instrument, not a workaround. - `get_account_data` reads the **local state store** (`account_data_raw` -> `state_store()`), while the push path's `config()` reads the **homeserver**. So even once a bot can call it, the two can disagree with nothing saying so. **So `whoami` must report what the channel actually used, resolved the way the channel resolves it.** A self-check built on `get_account_data` would be a second reading of a different source that looks like the same question. **And the control for any provisioning check is `m.push_rules`, never `m.direct`.** `m.direct` exists only if someone has opened a DM with the account, so a freshly provisioned bot has none and a check built on it reads a working bot as unreadable. `m.push_rules` is server-generated for every account, and it is already in the reserved list, so it can be read and never written.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/matrix-mcp#129
No description provided.