No session can ask whether its own bot is cross-signed, and four of eight are not #139

Open
opened 2026-08-28 00:56:58 +00:00 by jlxq0 · 11 comments
Owner

Every director is currently in a cross-signing state it cannot observe about itself. Four of eight bot devices are unsigned, measured against Synapse's signature table, and no session can find that out about its own bot.

Why no session can see it

channel mount   whoami, send_text_message, send_reaction, read_recent_messages,
                read_thread, mark_read, list_joined_rooms, download_attachment
verify_status   src/mcp.rs:3209, on the /mcp mount only

The mount that is the bot has no cross-signing tools, and the mount that has them is not the bot. A channel whoami returns @alan_ai_bot:kampong.social; the /mcp mounts authenticate as the operator, so verify_status there answers about the operator instead.

verify_status reads client.user_id() and asks about its own identity. Nothing in the tree calls get_user_devices for another user, and the only admin_* tools are power levels, so there is no route for one identity to ask about another's devices either. Neither question is answerable today, from anywhere.

Third instance of this shape: #127 is a mount that cannot say whether it is still receiving, #129 is a bot that cannot read its own allowlist, and this is a bot that cannot say whether it is verified. Each is a real state with no instrument on the side that holds it.

Two tools

verify_status on CHANNEL_TOOLS. The structural fix and worth more than the incident that surfaced it: a bot asking whether it is signed turns a fleet-wide invisible state into one line anybody can run. It already returns cross_signed, user_has_master_key and a hint, which is exactly the question, and adding it to the list costs no new schema.

A read-only tool answering "is this user's device cross-signed, as this homeserver sees it". Needs client.encryption().get_user_devices(user_id), which is in the SDK and used by the unmerged #88 but by nothing on main. No admin access required, which is the thing that decides whether this lives here or with operations.

Acceptance: a real reading on both sides of the split

Not a green suite. One bot asking about itself and returning the answer that matches the Synapse signature table, and the split gives a positive and a negative without constructing either:

@alan_ai_bot        unsigned in the table   expect cross_signed = false
@honokahonoka_ai_bot  signed in the table   expect cross_signed = true

That second mxid is as it appears in the measurement; it has also been written @honoka_ai_bot in passing, and the one from the table is the one to trust until someone re-reads it.

Two ways to get a meaningless green here

The SDK's own success line is not evidence. #130: Device::verify discards the failures map returned inside a 200 by /keys/signatures/upload, and secret_store.rs discards the follow-up /keys/query whose comment says it exists to check the signature attached. @mantis_ai_bot logged Successfully signed our own device, the device is now verified and is unsigned in the table.

And the shield in a client is not evidence either, in both directions. A client that cached an unsigned device keeps showing the shield after a signature lands, and a client told to dismiss it shows nothing while the state is still wrong. The goal is that it stops being wrong, not that it stops being shown.

So the acceptance is the Synapse signature row, and these tools are worth building precisely because that row is currently the only honest answer and it lives somewhere no session can reach.

  • #130, the SDK reporting a device verified after discarding both results that could contradict it.
  • #127 and #129, the same shape at the stream and the allowlist.
  • #88, unmerged, which is about device keys never publishing rather than about signatures not attaching.
Every director is currently in a cross-signing state it cannot observe about itself. Four of eight bot devices are unsigned, measured against Synapse's signature table, and **no session can find that out about its own bot.** ## Why no session can see it channel mount whoami, send_text_message, send_reaction, read_recent_messages, read_thread, mark_read, list_joined_rooms, download_attachment verify_status src/mcp.rs:3209, on the /mcp mount only **The mount that *is* the bot has no cross-signing tools, and the mount that has them is not the bot.** A `channel` `whoami` returns `@alan_ai_bot:kampong.social`; the `/mcp` mounts authenticate as the operator, so `verify_status` there answers about the operator instead. `verify_status` reads `client.user_id()` and asks about **its own** identity. Nothing in the tree calls `get_user_devices` for another user, and the only `admin_*` tools are power levels, so there is no route for one identity to ask about another's devices either. **Neither question is answerable today, from anywhere.** Third instance of this shape: #127 is a mount that cannot say whether it is still receiving, #129 is a bot that cannot read its own allowlist, and this is a bot that cannot say whether it is verified. Each is a real state with no instrument on the side that holds it. ## Two tools **`verify_status` on `CHANNEL_TOOLS`.** The structural fix and worth more than the incident that surfaced it: a bot asking whether it is signed turns a fleet-wide invisible state into one line anybody can run. It already returns `cross_signed`, `user_has_master_key` and a hint, which is exactly the question, and adding it to the list costs no new schema. **A read-only tool answering "is this user's device cross-signed, as this homeserver sees it".** Needs `client.encryption().get_user_devices(user_id)`, which is in the SDK and used by the unmerged #88 but by nothing on `main`. **No admin access required**, which is the thing that decides whether this lives here or with operations. ## Acceptance: a real reading on both sides of the split **Not a green suite.** One bot asking about itself and returning the answer that matches the Synapse signature table, and the split gives a positive and a negative without constructing either: @alan_ai_bot unsigned in the table expect cross_signed = false @honokahonoka_ai_bot signed in the table expect cross_signed = true That second mxid is as it appears in the measurement; it has also been written `@honoka_ai_bot` in passing, and the one from the table is the one to trust until someone re-reads it. ## Two ways to get a meaningless green here **The SDK's own success line is not evidence.** #130: `Device::verify` discards the `failures` map returned inside a 200 by `/keys/signatures/upload`, and `secret_store.rs` discards the follow-up `/keys/query` whose comment says it exists to check the signature attached. `@mantis_ai_bot` logged `Successfully signed our own device, the device is now verified` and is unsigned in the table. **And the shield in a client is not evidence either, in both directions.** A client that cached an unsigned device keeps showing the shield after a signature lands, and a client told to dismiss it shows nothing while the state is still wrong. **The goal is that it stops being wrong, not that it stops being shown.** So the acceptance is the Synapse signature row, and these tools are worth building precisely because that row is currently the only honest answer and it lives somewhere no session can reach. ## Related - #130, the SDK reporting a device verified after discarding both results that could contradict it. - #127 and #129, the same shape at the stream and the allowlist. - #88, unmerged, which is about device keys never publishing rather than about signatures not attaching.
Author
Owner

The identifier is resolved, and my spelling was the fault

mcp__channel__whoami, from honoka's own session
  mxid       @honokahonoka_ai_bot:kampong.social
  device_id  KZNWDXGAEP

Clark's Synapse table is right. @honoka_ai_bot was mine, derived from the
pattern of my own @alan_ai_bot:kampong.social, and it does not exist.

So the acceptance keys on the doubled form. Keeping the table's spelling and
flagging the disagreement rather than normalising it is what stopped this
becoming a test that returns a clean answer about nobody, inside the issue whose
subject is exactly that.

A bot's mxid is not derivable from its agent name

Two known shapes among eight, @alan_ai_bot and @honokahonoka_ai_bot, and no
rule that produces both. Anything keyed on a constructed id is wrong for at
least one director and returns a well-formed answer about a user that does not
exist.

Read them, from whoami per session or from the homeserver, and never build
them from the agent name. The remaining six are unmeasured here; Clark's
signature query has all eight and is the cheaper source than eight messages.

## The identifier is resolved, and my spelling was the fault mcp__channel__whoami, from honoka's own session mxid @honokahonoka_ai_bot:kampong.social device_id KZNWDXGAEP Clark's Synapse table is right. `@honoka_ai_bot` was mine, derived from the pattern of my own `@alan_ai_bot:kampong.social`, and it does not exist. **So the acceptance keys on the doubled form.** Keeping the table's spelling and flagging the disagreement rather than normalising it is what stopped this becoming a test that returns a clean answer about nobody, inside the issue whose subject is exactly that. ## A bot's mxid is not derivable from its agent name Two known shapes among eight, `@alan_ai_bot` and `@honokahonoka_ai_bot`, and no rule that produces both. **Anything keyed on a constructed id is wrong for at least one director and returns a well-formed answer about a user that does not exist.** Read them, from `whoami` per session or from the homeserver, and never build them from the agent name. The remaining six are unmeasured here; Clark's signature query has all eight and is the cheaper source than eight messages.
Author
Owner

Design requirement that falls out of the mxid finding

The read-only tool must refuse an mxid the homeserver does not know, rather than reporting it unsigned. Today those are the same output: get_user_devices for a user that does not exist returns no devices, and "no signed device" and "no such user" both render as cross_signed: false.

That is the fault this issue is named after, one layer up. A caller that constructed @honoka_ai_bot from an agent name gets a well-formed answer about nobody, and the answer is the alarming one, so it reads as a finding rather than as a typo.

Concretely: resolve the mxid first and return an error when the homeserver has no such user, so a caller can distinguish not signed, no such user, and could not tell. Three outcomes, the same shape read_reactions needed for its empty-versus-unreadable contract.

The self-directed half is safe by construction and that is a reason to prefer it: verify_status reads client.user_id(), so there is no identifier for anyone to get wrong.

The acceptance identifiers, resolved from the identities

@honokahonoka_ai_bot:kampong.social   device KZNWDXGAEP   signed in the table
@alan_ai_bot:kampong.social           device HWXIWTGHIB   unsigned in the table

Read from each bot's own whoami rather than constructed. @honoka_ai_bot does not exist; it was derived from the pattern of @alan_ai_bot and no rule produces both shapes.

The remaining six are unmeasured, and Clark's signature query holds all eight, which is cheaper than eight messages.

## Design requirement that falls out of the mxid finding **The read-only tool must refuse an mxid the homeserver does not know, rather than reporting it unsigned.** Today those are the same output: `get_user_devices` for a user that does not exist returns no devices, and "no signed device" and "no such user" both render as `cross_signed: false`. That is the fault this issue is named after, one layer up. A caller that constructed `@honoka_ai_bot` from an agent name gets a well-formed answer about nobody, and the answer is the alarming one, so it reads as a finding rather than as a typo. Concretely: resolve the mxid first and return an error when the homeserver has no such user, so a caller can distinguish **not signed**, **no such user**, and **could not tell**. Three outcomes, the same shape `read_reactions` needed for its empty-versus-unreadable contract. **The self-directed half is safe by construction** and that is a reason to prefer it: `verify_status` reads `client.user_id()`, so there is no identifier for anyone to get wrong. ## The acceptance identifiers, resolved from the identities @honokahonoka_ai_bot:kampong.social device KZNWDXGAEP signed in the table @alan_ai_bot:kampong.social device HWXIWTGHIB unsigned in the table Read from each bot's own `whoami` rather than constructed. `@honoka_ai_bot` does not exist; it was derived from the pattern of `@alan_ai_bot` and no rule produces both shapes. **The remaining six are unmeasured**, and Clark's signature query holds all eight, which is cheaper than eight messages.
Author
Owner

Adding bootstrap_cross_signing to the mount: the safety argument holds, and it fixes one of the four rather than four

Read the tool before agreeing to put it on a mount that receives untrusted content. Two properties make it safe there, and both are already in the code rather than needing to be added:

It refuses to replace an existing identity, and it asks the homeserver rather than the cache. request_user_identity first, get_user_identity as the fallback, and on a transient /keys/query failure it refuses rather than guessing, with the comment saying a failed query must not be read as "no identity exists". So the worst a prompt-injected channel session can do is create an identity for an account that has none, which is the operation we want performed.

The recovery passphrase comes from server config, not from the caller: self.clients.recovery_passphrase(me). Nothing secret enters or leaves through a tool result, and there is no argument for a caller to control.

So the mount widening is defensible, which is not obvious from outside and is the thing I would want checked before agreeing.

But it is the wrong tool for three of the four

bootstrap_cross_signing refuses when an identity already exists, and three of the four unsigned accounts appear to have one. @mantis_ai_bot's own logs on the current pod:

CrossSigningStatus { has_master: true, has_self_signing: true, has_user_signing: true }
"Successfully imported the self-signing key, attempting to sign our own device"
"Successfully signed our own device, the device is now verified"

and it is unsigned in the signature table. That is an account with a complete identity whose device signature did not attach, which is #130 rather than a missing identity. Pointed at it, this tool returns "this account already has a cross-signing identity; refusing to replace it", correctly, because replacing one invalidates every verification anyone has done.

@lucybot is the case it does fix: no cross-signing keys at all, so there is nothing to refuse and nothing to invalidate.

So the split to establish before building is which of the four have an identity and which have none, and Clark's table can answer it in the same query that produced the signature rows. If it is one and three, then this tool unblocks Lucy and the other three need #130 resolved instead, and the job is two jobs.

UIA: the code already says what failure looks like

bootstrap_cross_signing(None) passes no auth data, and the error path spells out the case: "A homeserver that requires interactive auth for the first cross-signing upload cannot be bootstrapped this way." So the measurement is one call from a channel mount on an account with no identity, and the failure is legible rather than mysterious. @lucybot is both the account that needs it and the only account that can be used to measure it, since the tool refuses everywhere else.

The fifth device

Botfather reporting its own MATRIXMCP-DLX8SKHK as cross_signed=false against an existing master key is the same shape as @mantis_ai_bot: identity present, signature not attached. Whether Clark's table counts devices or accounts should be settled before anyone reports a number, and on this reading it is devices that matter, because an account can hold a complete identity and an unsigned device at once.

## Adding `bootstrap_cross_signing` to the mount: the safety argument holds, and it fixes one of the four rather than four Read the tool before agreeing to put it on a mount that receives untrusted content. **Two properties make it safe there, and both are already in the code rather than needing to be added:** **It refuses to replace an existing identity, and it asks the homeserver rather than the cache.** `request_user_identity` first, `get_user_identity` as the fallback, and on a transient `/keys/query` failure it **refuses rather than guessing**, with the comment saying a failed query must not be read as "no identity exists". So the worst a prompt-injected channel session can do is create an identity for an account that has none, which is the operation we want performed. **The recovery passphrase comes from server config, not from the caller**: `self.clients.recovery_passphrase(me)`. Nothing secret enters or leaves through a tool result, and there is no argument for a caller to control. So the mount widening is defensible, which is not obvious from outside and is the thing I would want checked before agreeing. ## But it is the wrong tool for three of the four **`bootstrap_cross_signing` refuses when an identity already exists**, and three of the four unsigned accounts appear to have one. `@mantis_ai_bot`'s own logs on the current pod: CrossSigningStatus { has_master: true, has_self_signing: true, has_user_signing: true } "Successfully imported the self-signing key, attempting to sign our own device" "Successfully signed our own device, the device is now verified" and it is unsigned in the signature table. **That is an account with a complete identity whose device signature did not attach**, which is #130 rather than a missing identity. Pointed at it, this tool returns *"this account already has a cross-signing identity; refusing to replace it"*, correctly, because replacing one invalidates every verification anyone has done. **`@lucybot` is the case it does fix**: no cross-signing keys at all, so there is nothing to refuse and nothing to invalidate. So the split to establish before building is **which of the four have an identity and which have none**, and Clark's table can answer it in the same query that produced the signature rows. If it is one and three, then this tool unblocks Lucy and the other three need #130 resolved instead, and the job is two jobs. ## UIA: the code already says what failure looks like `bootstrap_cross_signing(None)` passes **no auth data**, and the error path spells out the case: *"A homeserver that requires interactive auth for the first cross-signing upload cannot be bootstrapped this way."* So the measurement is one call from a `channel` mount on an account with no identity, and the failure is legible rather than mysterious. **`@lucybot` is both the account that needs it and the only account that can be used to measure it**, since the tool refuses everywhere else. ## The fifth device Botfather reporting its own `MATRIXMCP-DLX8SKHK` as `cross_signed=false` against an existing master key is the same shape as `@mantis_ai_bot`: identity present, signature not attached. **Whether Clark's table counts devices or accounts should be settled before anyone reports a number**, and on this reading it is devices that matter, because an account can hold a complete identity and an unsigned device at once.
Author
Owner

The credential is already in the right session; only the tool is missing

matrix-botfather established that there is no route that moves a bot token:
/keys/device_signing/upload is per-user behind UIA, so a Synapse admin cannot
sign another user's device, and bootstrap_cross_signing acts on the
authenticated account, which from a /mcp mount is @julian. It also declined
to read Julian's DMs to obtain one, which is what dc58ea3 exists to prevent.

Every director's channel mount is already authenticated as its own bot.
Mine returns @alan_ai_bot:kampong.social, device HWXIWTGHIB.

So the token that can sign a bot's device is sitting in that bot's own session.
No token has to move anywhere. Eight self-service calls, each with a
credential nobody else holds, replaces a cross-fleet job that has no legal route.

Which mount each director has, read from the repository

bin/tool-scope.sh's source, ~/Smithy/*_agent/wt/main/src/mcp.json, in one
pass rather than eight messages:

agent    mount           path
honoka   channel         /channel
honoka   matrix-julian   /mcp
lucy     channel         /channel
lucy     matrix-julian   /mcp
mantis   channel         /channel
mantis   matrix-julian   /mcp
penny    channel         /channel
penny    matrix-penny    /mcp
vryan    channel         /channel
vryan    matrix-vryan    /mcp

alan and clark have no home mcp.json and fall back to the repository's
shared .mcp.json, which carries channel alone.

Two rows are unresolved and they matter. matrix-penny and matrix-vryan
are named for their agent where the other three are named matrix-julian.
Whether that name means the mount authenticates as that agent's bot, or is
merely a label on a Julian-authenticated mount, is unmeasured. If it is the
former, those two already have room_create and the cross-signing tools as
their own identity today
, and part of this issue is only about the five
/channel-only sessions.

Asked directly rather than inferred from the name. Answer pending.

matrix-botfather's own device belongs in the count

MATRIXMCP-DLX8SKHK, cross_signed=false against an existing master key. Its
unable_to_decrypt on every message in the botfather DM is this same defect one
layer over: it cannot read replies because its device is unsigned, so it is
blocked by the thing it was asked to unblock.

Establish whether Clark's table counts devices or accounts before anyone
reports a number
, because that decides whether the answer is four or five.

## The credential is already in the right session; only the tool is missing `matrix-botfather` established that there is no route that *moves* a bot token: `/keys/device_signing/upload` is per-user behind UIA, so a Synapse admin cannot sign another user's device, and `bootstrap_cross_signing` acts on the authenticated account, which from a `/mcp` mount is `@julian`. It also declined to read Julian's DMs to obtain one, which is what `dc58ea3` exists to prevent. **Every director's `channel` mount is already authenticated as its own bot.** Mine returns `@alan_ai_bot:kampong.social`, device `HWXIWTGHIB`. So the token that can sign a bot's device is sitting in that bot's own session. **No token has to move anywhere.** Eight self-service calls, each with a credential nobody else holds, replaces a cross-fleet job that has no legal route. ## Which mount each director has, read from the repository `bin/tool-scope.sh`'s source, `~/Smithy/*_agent/wt/main/src/mcp.json`, in one pass rather than eight messages: agent mount path honoka channel /channel honoka matrix-julian /mcp lucy channel /channel lucy matrix-julian /mcp mantis channel /channel mantis matrix-julian /mcp penny channel /channel penny matrix-penny /mcp vryan channel /channel vryan matrix-vryan /mcp `alan` and `clark` have no home `mcp.json` and fall back to the repository's shared `.mcp.json`, which carries `channel` alone. **Two rows are unresolved and they matter.** `matrix-penny` and `matrix-vryan` are named for their agent where the other three are named `matrix-julian`. Whether that name means the mount authenticates as that agent's bot, or is merely a label on a Julian-authenticated mount, is unmeasured. **If it is the former, those two already have `room_create` and the cross-signing tools as their own identity today**, and part of this issue is only about the five `/channel`-only sessions. Asked directly rather than inferred from the name. Answer pending. ## `matrix-botfather`'s own device belongs in the count `MATRIXMCP-DLX8SKHK`, `cross_signed=false` against an existing master key. Its `unable_to_decrypt` on every message in the botfather DM is this same defect one layer over: it cannot read replies because its device is unsigned, so it is blocked by the thing it was asked to unblock. **Establish whether Clark's table counts devices or accounts before anyone reports a number**, because that decides whether the answer is four or five.
Author
Owner

Correction: MATRIXMCP-DLX8SKHK is Julian's device, not a fifth bot

I wrote above that matrix-botfather reports its own device as
cross_signed=false. Clark's query settles it:

SELECT user_id, device_id, hidden FROM devices WHERE device_id='MATRIXMCP-DLX8SKHK';
@julian:kampong.social | MATRIXMCP-DLX8SKHK | f

So verify_status from that mount answered about @julian, which is one of
his three unsigned devices in the 8-of-11 split, and botfather read it as its
own. Not a fifth of anything.

It corroborates the structural finding from the other side. A /mcp mount
answers about @julian whoever holds it, which is exactly why
bootstrap_cross_signing from that mount cannot help a bot, and why the tool has
to be on CHANNEL_TOOLS to be any use.

Clark's split, per device, with a control

bucket                        bot_devices  bot_accounts  human_devices
1 identity, device signed              14            10             48
2 identity, device UNSIGNED            15             6             16
3 no identity                          11            11              1

signed        honokahonoka, nexonexo, pennypenny, vryanvryan
#130          alan_ai_bot, clark_ai_bot, mantis_ai_bot
no identity   lucybot

One and three. bootstrap_cross_signing fixes @lucybot; the other three
are #130 and the tool correctly refuses them.

Devices is the unit and this is the proof rather than the argument:
@orchybot has OKZINJHZKW signed and WINZYQJMYI unsigned, @whatsappbot has
one signed against seven unsigned. The same account appears in both buckets,
so counting accounts would have called three of them signed and hidden eleven
unsigned devices.

The sixteen human devices in bucket 2 are ordinary unverified sessions rather
than a defect.

## Correction: `MATRIXMCP-DLX8SKHK` is Julian's device, not a fifth bot I wrote above that `matrix-botfather` reports its own device as `cross_signed=false`. Clark's query settles it: SELECT user_id, device_id, hidden FROM devices WHERE device_id='MATRIXMCP-DLX8SKHK'; @julian:kampong.social | MATRIXMCP-DLX8SKHK | f **So `verify_status` from that mount answered about `@julian`**, which is one of his three unsigned devices in the 8-of-11 split, and botfather read it as its own. Not a fifth of anything. **It corroborates the structural finding from the other side.** A `/mcp` mount answers about `@julian` whoever holds it, which is exactly why `bootstrap_cross_signing` from that mount cannot help a bot, and why the tool has to be on `CHANNEL_TOOLS` to be any use. ## Clark's split, per device, with a control bucket bot_devices bot_accounts human_devices 1 identity, device signed 14 10 48 2 identity, device UNSIGNED 15 6 16 3 no identity 11 11 1 signed honokahonoka, nexonexo, pennypenny, vryanvryan #130 alan_ai_bot, clark_ai_bot, mantis_ai_bot no identity lucybot **One and three.** `bootstrap_cross_signing` fixes `@lucybot`; the other three are `#130` and the tool correctly refuses them. **Devices is the unit and this is the proof rather than the argument**: `@orchybot` has `OKZINJHZKW` signed and `WINZYQJMYI` unsigned, `@whatsappbot` has one signed against seven unsigned. **The same account appears in both buckets**, so counting accounts would have called three of them signed and hidden eleven unsigned devices. The sixteen human devices in bucket 2 are ordinary unverified sessions rather than a defect.
Author
Owner

Withdrawing my own correction: the tool did report on its own device

I wrote above that verify_status from a /mcp mount "answered about an
identity that is not the session's". That is wrong and it should not become
the lesson.

whoami from that mount:

{"device_id":"MATRIXMCP-DLX8SKHK","mxid":"@julian:kampong.social"}

That mount is @julian, and MATRIXMCP-DLX8SKHK is the device it
authenticates with.
So verify_status reported on its own session's own
device, correctly, and Clark's row agrees with whoami rather than contradicting
it.

What was actually wrong is narrower: a human's device was counted in a list
of bots. It belongs in @julian's 8-of-11 split, which is where Clark's table
already had it.

The structural point stands on its own evidence and does not need this
one.
bootstrap_cross_signing from a /mcp mount targets @julian because
that is who the mount is, which is exactly why the tool has to be on
CHANNEL_TOOLS to reach a bot. That was established from the code and from
whoami, not from this device.

## Withdrawing my own correction: the tool did report on its own device I wrote above that `verify_status` from a `/mcp` mount "answered about an identity that is not the session's". **That is wrong and it should not become the lesson.** `whoami` from that mount: {"device_id":"MATRIXMCP-DLX8SKHK","mxid":"@julian:kampong.social"} **That mount *is* `@julian`, and `MATRIXMCP-DLX8SKHK` is the device it authenticates with.** So `verify_status` reported on its own session's own device, correctly, and Clark's row agrees with `whoami` rather than contradicting it. **What was actually wrong is narrower**: a human's device was counted in a list of bots. It belongs in `@julian`'s 8-of-11 split, which is where Clark's table already had it. **The structural point stands on its own evidence and does not need this one.** `bootstrap_cross_signing` from a `/mcp` mount targets `@julian` because that is who the mount is, which is exactly why the tool has to be on `CHANNEL_TOOLS` to reach a bot. That was established from the code and from `whoami`, not from this device.
Author
Owner

The four unsigned devices are downstream of #143

Measured by Clark, 2026-08-29:

seven live MAS sessions on MATRIXMCP-DLX8SKHK, finished_at IS NULL, back to 29 July
Synapse for that device:                      has_keys = 0   sigs = 0
control MATRIXMCP-KZ9M8ZY8, one session:      1 key          1 signature

Keyed and signed where one session held the device, keyless and unsigned where seven contend. A device whose keys Synapse never received cannot carry a signature, so the four unsigned devices are a consequence rather than four separate omissions.

That does not retire this issue. The two tools are still the right ones and the argument for them is unchanged: no session can ask whether its own bot is signed, and the fleet found out only because a person looked at a client. Once #143 is fixed, the same question has to be answerable from inside, or the next occurrence is invisible for the same reason this one was.

It does change the acceptance. The Synapse signature row remains the measure, and it will read the same for a bot whose device is uncontended and signed as for one that was never contending. So whoever builds this should record which devices had contending sessions before the fix, or the tool's first green will not distinguish repaired from never-broken.

Treat #143, #130 and #139 as one cause until the code contradicts it.

## The four unsigned devices are downstream of #143 Measured by Clark, 2026-08-29: seven live MAS sessions on MATRIXMCP-DLX8SKHK, finished_at IS NULL, back to 29 July Synapse for that device: has_keys = 0 sigs = 0 control MATRIXMCP-KZ9M8ZY8, one session: 1 key 1 signature **Keyed and signed where one session held the device, keyless and unsigned where seven contend.** A device whose keys Synapse never received cannot carry a signature, so the four unsigned devices are a consequence rather than four separate omissions. **That does not retire this issue.** The two tools are still the right ones and the argument for them is unchanged: **no session can ask whether its own bot is signed**, and the fleet found out only because a person looked at a client. Once #143 is fixed, the same question has to be answerable from inside, or the next occurrence is invisible for the same reason this one was. **It does change the acceptance.** The Synapse signature row remains the measure, and it will read the same for a bot whose device is uncontended and signed as for one that was never contending. So whoever builds this should record which devices had contending sessions before the fix, or the tool's first green will not distinguish repaired from never-broken. **Treat #143, #130 and #139 as one cause** until the code contradicts it.
Author
Owner

The one-cause reading is under a question: the control may have dissolved

Do not build on the single-mechanism conclusion in the comment above until this resolves.

That conclusion rests on a contrast, not on a single number:

DLX8SKHK   seven live sessions    has_keys = 0   sigs = 0
KZ9M8ZY8   one settled session    1 key          1 signature

The control device has since been reported as 8 live. If it holds eight live sessions and a key and a signature, then contention does not prevent keys from sticking, and the argument loses the only thing supporting it.

It may be two predicates rather than two answers, since settled and live need not count the same thing, and which query produced each is being established rather than guessed at.

What stands regardless: has_keys = 0, sigs = 0 on DLX8SKHK is a direct measurement of the device this issue is about. What is in doubt is the inference from it — that seven contending sessions are the reason — which is the part the one-cause statement, and its echo on the sibling issues, were built on.

Recording the doubt rather than retracting, because a retraction should carry the resolving measurement rather than the uncertainty. This line exists so that nobody reads the paragraph above it as settled in the meantime.

One thing that is settled and does not depend on any of it: the bearer-to-session mapping exists in MAS's database as oauth2_access_tokens.oauth2_session_id, so telling the seven apart needs no change to this server and no jti field. The runtime jti would be a convenience and nothing currently turns on it.

## The one-cause reading is under a question: the control may have dissolved **Do not build on the single-mechanism conclusion in the comment above until this resolves.** That conclusion rests on a contrast, not on a single number: DLX8SKHK seven live sessions has_keys = 0 sigs = 0 KZ9M8ZY8 one settled session 1 key 1 signature **The control device has since been reported as `8 live`.** If it holds eight live sessions *and* a key *and* a signature, then contention does not prevent keys from sticking, and the argument loses the only thing supporting it. It may be two predicates rather than two answers, since *settled* and *live* need not count the same thing, and which query produced each is being established rather than guessed at. **What stands regardless**: `has_keys = 0, sigs = 0` on `DLX8SKHK` is a direct measurement of the device this issue is about. **What is in doubt is the inference from it** — that seven contending sessions are the reason — which is the part the one-cause statement, and its echo on the sibling issues, were built on. **Recording the doubt rather than retracting**, because a retraction should carry the resolving measurement rather than the uncertainty. This line exists so that nobody reads the paragraph above it as settled in the meantime. **One thing that is settled and does not depend on any of it**: the bearer-to-session mapping exists in MAS's database as `oauth2_access_tokens.oauth2_session_id`, so telling the seven apart needs no change to this server and no `jti` field. The runtime `jti` would be a convenience and nothing currently turns on it.
Author
Owner

Baseline captured before any revocation — 2026-08-29T06:05:30Z

Recorded here so the revoke-all experiment cannot erase the state that distinguishes repaired from never broken. MAS is matrix-authentication-service 1.23.0; Synapse and MAS read from postgres-www.

There are exactly two MATRIXMCP-* devices on @julian, not the larger set assumed. The many other device ids on the account are Element and non-matrix-mcp clients.

device              display_name              live MAS sessions   has_keys   sigs   session window
MATRIXMCP-DLX8SKHK   Claude on Unknown device        7                0        0     2026-07-29 → 2026-08-28
MATRIXMCP-KZ9M8ZY8   Lenno on Unknown device         8                1        1     2026-05-21 → 2026-07-04

Instruments: MAS oauth2_sessions filtered on the device scope with finished_at IS NULL; Synapse e2e_device_keys_json and e2e_cross_signing_signatures. Structured counts, not log matches.

The count is not the discriminator, and this corrects the earlier contrast

An earlier note (mine) called KZ9M8ZY8 "one settled session" against DLX8SKHK's seven. That was wrong: KZ9M8ZY8 has eight live sessions, one more than DLX8SKHK, and it is the signed one. So "fewer contending sessions" cannot be the mechanism — the device with more sessions is keyed and signed.

What separates them is churn recency, not count. KZ9M8ZY8's sessions all landed by 2026-07-04 and it has been quiescent for eight weeks; DLX8SKHK's are still arriving, the last on 2026-08-28. A device that stopped accumulating sessions settled to a key and a signature; one still accumulating never did.

So the revoke-all experiment's prediction stands but its reasoning shifts: has_keys = 1 after revoke-and-add-one would show that ending the churn lets a key stick, not that a lower session count does. The control is quiesced-8 versus churning-7, not one versus seven.

jti join, DB side confirmed

oauth2_access_tokens carries both oauth2_access_token_id and oauth2_session_id, so a jti keyed on the token id joins to a session in the database. Whether MAS 1.23's introspection response emits jti for an oauth2 access token is read from source on main and unconfirmed against the running version, because confirming it needs a raw oauth2 bearer, which is stored hashed and cannot be recovered.

Read only. Nothing revoked, nothing uploaded.

## Baseline captured before any revocation — 2026-08-29T06:05:30Z Recorded here so the revoke-all experiment cannot erase the state that distinguishes *repaired* from *never broken*. MAS is `matrix-authentication-service 1.23.0`; Synapse and MAS read from `postgres-www`. **There are exactly two `MATRIXMCP-*` devices on `@julian`, not the larger set assumed.** The many other device ids on the account are Element and non-matrix-mcp clients. device display_name live MAS sessions has_keys sigs session window MATRIXMCP-DLX8SKHK Claude on Unknown device 7 0 0 2026-07-29 → 2026-08-28 MATRIXMCP-KZ9M8ZY8 Lenno on Unknown device 8 1 1 2026-05-21 → 2026-07-04 Instruments: MAS `oauth2_sessions` filtered on the device scope with `finished_at IS NULL`; Synapse `e2e_device_keys_json` and `e2e_cross_signing_signatures`. Structured counts, not log matches. ## The count is not the discriminator, and this corrects the earlier contrast An earlier note (mine) called `KZ9M8ZY8` "one settled session" against `DLX8SKHK`'s seven. **That was wrong: `KZ9M8ZY8` has eight live sessions, one more than `DLX8SKHK`, and it is the signed one.** So "fewer contending sessions" cannot be the mechanism — the device with *more* sessions is keyed and signed. **What separates them is churn recency, not count.** `KZ9M8ZY8`'s sessions all landed by 2026-07-04 and it has been quiescent for eight weeks; `DLX8SKHK`'s are still arriving, the last on 2026-08-28. A device that stopped accumulating sessions settled to a key and a signature; one still accumulating never did. So the revoke-all experiment's prediction stands but its reasoning shifts: **`has_keys = 1` after revoke-and-add-one would show that ending the churn lets a key stick, not that a lower session count does.** The control is quiesced-8 versus churning-7, not one versus seven. ## jti join, DB side confirmed `oauth2_access_tokens` carries both `oauth2_access_token_id` and `oauth2_session_id`, so a `jti` keyed on the token id joins to a session in the database. Whether MAS 1.23's introspection **response** emits `jti` for an oauth2 access token is read from source on `main` and unconfirmed against the running version, because confirming it needs a raw oauth2 bearer, which is stored hashed and cannot be recovered. Read only. Nothing revoked, nothing uploaded.
Author
Owner

Retracted: contention by session count is not the mechanism

The resolving measurement, both rows from the same query with the same predicate:

MATRIXMCP-KZ9M8ZY8   8 live sessions   has_keys 1   sigs 1
MATRIXMCP-DLX8SKHK   7 live sessions   has_keys 0   sigs 0

The device with more sessions is the signed one. The earlier "one settled session" was a mislabel: Synapse's sigs = 1 was read as a session count and written as one. The counts never moved; the noun was wrong.

So every inference from that contrast is withdrawn, including the one-cause statement placed on #143, #130 and #139, and the claim that seven contending sessions are why the record is empty. This replaces it rather than sitting beside it.

What stands, and always did: has_keys = 0, sigs = 0 on MATRIXMCP-DLX8SKHK is a direct measurement of that device. The why has gone, not the what.

Nothing here should be folded into a replacement hypothesis yet. One is on the table, that KZ9M8ZY8 has been quiescent for eight weeks while DLX8SKHK is still accumulating sessions, but that is a single variable chosen after seeing the outcome on two devices, which is the shape that fits perfectly and explains the wrong thing. The cheaper discriminator is whether DLX8SKHK was ever quiescent and still keyless, which would kill it without spending anybody's time.

How the retracted claim got here is on #143 under Provenance of the one-cause statement, so a later reader can see it arrived as an instruction on an unmeasured contrast rather than as a measurement.

## Retracted: contention by session count is not the mechanism The resolving measurement, both rows from the same query with the same predicate: MATRIXMCP-KZ9M8ZY8 8 live sessions has_keys 1 sigs 1 MATRIXMCP-DLX8SKHK 7 live sessions has_keys 0 sigs 0 **The device with *more* sessions is the signed one.** The earlier "one settled session" was a mislabel: Synapse's `sigs = 1` was read as a session count and written as *one*. The counts never moved; the noun was wrong. **So every inference from that contrast is withdrawn**, including the one-cause statement placed on #143, #130 and #139, and the claim that seven contending sessions are why the record is empty. **This replaces it rather than sitting beside it.** **What stands, and always did**: `has_keys = 0, sigs = 0` on `MATRIXMCP-DLX8SKHK` is a direct measurement of that device. **The *why* has gone, not the *what*.** **Nothing here should be folded into a replacement hypothesis yet.** One is on the table, that `KZ9M8ZY8` has been quiescent for eight weeks while `DLX8SKHK` is still accumulating sessions, but that is a single variable chosen after seeing the outcome on two devices, which is the shape that fits perfectly and explains the wrong thing. The cheaper discriminator is whether `DLX8SKHK` was ever quiescent and still keyless, which would kill it without spending anybody's time. **How the retracted claim got here is on #143** under *Provenance of the one-cause statement*, so a later reader can see it arrived as an instruction on an unmeasured contrast rather than as a measurement.
Author
Owner

Churn-recency is dead too, and the state to leave here is a measurement with no mechanism

2026-08-01 → 08-06   DLX8SKHK had ZERO live sessions for five days
2026-08-06 → 08-15   exactly ONE live session for nine days
now                  has_keys = 0, sigs = 0

A lone session for nine days did not leave a key. So neither session count nor recency explains it: eight-session KZ9M8ZY8 is signed, and DLX8SKHK is keyless through two quiescent windows. Withdrawn, like contention-by-count before it.

The current-state reading is load-bearing rather than a gap: device keys live in e2e_device_keys_json keyed on (user, device), persist across sessions, and go when the device is deleted. The same device id spans all eight sessions including one that finished on 08-01 and is still in devices, so a key uploaded during the lone-session window would still be there. One device_lists_stream row and zero device_keys_json rows corroborate: one announcement, no key lifecycle.

has_keys = 0, sigs = 0 has now survived every explanation offered for it. That is the state to leave on this issue: a measurement with no mechanism, rather than a mechanism.

So do not build a contention fix, and the PVC constraint recorded above is a constraint on a design nobody should now be starting.

Where the upload is actually gated, and a candidate that is already filed

matrix-sdk-crypto-0.17.0/src/olm/account.rs:666:

let device_keys = self.shared().not().then(|| self.device_keys());

Device keys are offered for upload only while shared is false. Once that flag latches true, keys_for_upload returns None for device keys forever, and every later /keys/upload carries one-time keys only.

That is the exact mechanism #88 describes, open since 2026-05-21 and mergeable: the per-account shared flag latches on the first apparently-successful upload and is never re-verified against the homeserver, so a client operates against a device that does not exist server-side. Its repro was a device with 50 one-time keys and zero rows in e2e_device_keys_json.

The discriminator is one query and it is cheap: does @julian's account hold one-time keys for DLX8SKHK while holding no device keys? If yes, the state matches #88's signature exactly and #88 becomes a candidate fix rather than an adjacent issue. If no, it is something else again.

Offered as a candidate with a test, not as the answer. Two hypotheses have already died here and this one has the same shape as both: it fits, and fitting is what the last two did.

And one bound on my own contribution. #88's canonical SDK line, "Our own device might have been deleted", appears zero times in this pod's entire log. That is not evidence. There is no matrix-sdk client for @julian on this pod at all: sixteen introspects, zero tool spans, no client-lifecycle line, measured earlier in this thread. A line that only a running client can emit, absent where no client runs, says nothing. The control is that the same grep returns 39 for a string that is there.

Earlier in this thread I said #88 was not the fix. That was about the cross-signing warning and it remains correct for it: publication and signing are different layers. This is a publication measurement, which is #88's own subject.

## Churn-recency is dead too, and the state to leave here is a measurement with no mechanism 2026-08-01 → 08-06 DLX8SKHK had ZERO live sessions for five days 2026-08-06 → 08-15 exactly ONE live session for nine days now has_keys = 0, sigs = 0 **A lone session for nine days did not leave a key.** So neither session count nor recency explains it: eight-session `KZ9M8ZY8` is signed, and `DLX8SKHK` is keyless through two quiescent windows. **Withdrawn, like contention-by-count before it.** The current-state reading is load-bearing rather than a gap: device keys live in `e2e_device_keys_json` keyed on `(user, device)`, persist across sessions, and go when the **device** is deleted. The same device id spans all eight sessions including one that finished on 08-01 and is still in `devices`, **so a key uploaded during the lone-session window would still be there.** One `device_lists_stream` row and zero `device_keys_json` rows corroborate: one announcement, no key lifecycle. **`has_keys = 0, sigs = 0` has now survived every explanation offered for it.** That is the state to leave on this issue: a measurement with no mechanism, rather than a mechanism. **So do not build a contention fix**, and the PVC constraint recorded above is a constraint on a design nobody should now be starting. ## Where the upload is actually gated, and a candidate that is already filed `matrix-sdk-crypto-0.17.0/src/olm/account.rs:666`: let device_keys = self.shared().not().then(|| self.device_keys()); **Device keys are offered for upload only while `shared` is false.** Once that flag latches true, `keys_for_upload` returns `None` for device keys **forever**, and every later `/keys/upload` carries one-time keys only. **That is the exact mechanism #88 describes**, open since 2026-05-21 and mergeable: the per-account `shared` flag latches on the first apparently-successful upload and is never re-verified against the homeserver, so a client operates against a device that does not exist server-side. Its repro was a device with **50 one-time keys and zero rows in `e2e_device_keys_json`**. **The discriminator is one query and it is cheap**: does `@julian`'s account hold one-time keys for `DLX8SKHK` while holding no device keys? **If yes, the state matches #88's signature exactly** and #88 becomes a candidate fix rather than an adjacent issue. If no, it is something else again. **Offered as a candidate with a test, not as the answer.** Two hypotheses have already died here and this one has the same shape as both: it fits, and fitting is what the last two did. **And one bound on my own contribution.** #88's canonical SDK line, *"Our own device might have been deleted"*, appears **zero** times in this pod's entire log. **That is not evidence.** There is no matrix-sdk client for `@julian` on this pod at all: sixteen introspects, zero tool spans, no client-lifecycle line, measured earlier in this thread. **A line that only a running client can emit, absent where no client runs, says nothing.** The control is that the same grep returns 39 for a string that is there. Earlier in this thread I said #88 was not the fix. **That was about the cross-signing warning and it remains correct for it**: publication and signing are different layers. This is a publication measurement, which is #88's own subject.
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#139
No description provided.