Connect things, see them, get out of trouble

Settled 18 Aug: one flat list. Not grouped by direction — that is an engineer's axis. A researcher wants to connect a thing, see what they have connected, and undo it when it is the wrong one. Miro sits in the same list as the meeting accounts because to the person using it, they are the same kind of thing: an account Bristlenose talks to on their behalf.

Design artefact · 18 Aug 2026 · supersedes the grouped variant · relates to docs/design-cloud-import.md §7 and §9, docs/design-miro-bridge.md

SHIPPED 18 Aug 2026 — and the pane is not the one drawn below. Read §1 as the reasoning that got there, not as the built thing.

What changed, and why. The flat list and the Add Account… sheet are both gone. There are exactly four possible services and, for now, one account per service — so the pane is a section per service, each carrying one row in one of four states. A flat list answers "what have I connected?"; a fixed catalogue of four is worth reading as "what can this thing talk to?" as well, and a permanent Not-connected row answers that without a sheet to open. Zoom is listed, saying it cannot be connected yet, rather than hidden — §1's argument against offering "a door onto nothing" survives as the reason its row carries no button, which is a better answer than absence.

The four states, and the one line that decides them: everything the pane shows is derived from what is already on disk, so it makes no network call. Not available · Not connected · Connected · Needs attention — the last being one row-slot with two causes (§2's Sign in again and Wrong kind of account), because they are mutually exclusive and the row draws identically for both. §2's fourth state, Can't reach it, was cut: it is a state of the network rather than of the account, rendering it means a Settings pane doing I/O on appear, and §2 had already argued it deserves no Reconnect button. It belongs in the import window, where a call is actually being made.

The consequence of no-network is the interesting one. Anything knowable only from a call has to be persisted when that call happens. Google's tier is free — GoogleAccountTier is string work on the address domain — so a consumer account is flagged before the researcher goes looking at an empty list. Microsoft's is not, so a personal account still reads as merely connected until the adapter writes its DriveTier verdict onto the grant. That gap is pinned by a test named for it.

Connecting stays at the point of intent. §3's rule holds: Connect… posts the same notification the File menu does and opens the import window. One door, one sign-in flow. Miro is the exception and says so rather than offering a button that fails — its connect needs a running serve and an open project.

Commits 8901845f (storage) · d3b66642 (the pane) · a27f85b4 (the tombstone).
The hard part is not the list. It is that today you can only have one of each. KeychainHelper stores one item per service under a fixed account string ("bristlenose"), so signing in to a second Microsoft account silently overwrites the first — no error, no warning, and the first account simply stops working. §7 already names this: *"a freelancer's second client tenant would silently overwrite the first."*

A UR consultant has a personal Microsoft account and one per client. They have a personal Miro and their agency's. Multi-account is the ordinary case for this audience, not an edge case — and every "silly situation" worth escaping from is downstream of getting the keying right.

1 — The pane

Ordinary — several accounts, some of them doubled

Accounts
General
Appearance
LLM
⁝⁝Transcription
Accounts
((•))MCP
T
Microsoft Teams
martin@clientco.com
Disconnect…
T
Microsoft TeamsPERSONAL
martin@outlook.com
Disconnect…
M
Google Meet
martin@finca342.org
Disconnect…
Miro
Martin Storey · 144a
Disconnect…
Add Account…

Bristlenose uses these to bring recordings in and to send quotes out.

One row per account, not per service. Two Teams rows is not a defect to design away — it is the answer to "which of my Microsoft accounts is this?", and it is the only place that question can be asked. The service name repeats; the identity line is what distinguishes them, so it does the work and gets the second line to itself.

Add Account — what it is possible to connect

Add an account
T
Microsoft Teams
Import recordings from OneDrive
Connect…
M
Google Meet
Import recordings from Drive
Connect…
Miro
Send quotes to a board
Connect…

This is where "what can I connect?" gets answered, rather than by permanent Not-connected rows in the main list. It is the Internet Accounts idiom, it stays honest when the catalogue grows, and — the reason that matters here — it is the only shape that lets you add a second account of a service you already have. A per-service row with a Connect button cannot express that.

Zoom is deliberately absent. It is behind a flag and cannot sign in, so listing it would offer a door that opens onto nothing. The catalogue shows what is connectable now, not what exists in the codebase.

2 — The states, and the escapes

The shipped pane has one state: connected. These are the rest, and each one exists because there is a situation a researcher currently cannot get out of.

Accounts
T
Microsoft TeamsSIGN IN AGAIN
martin@clientco.com — your organisation ended the session
Sign In…
T
Microsoft TeamsPERSONAL
martin@outlook.com — personal accounts have no recordings folder
Disconnect…
Miro
Martin Storey · 144a
Disconnect…
Miro
Can't reach Miro just now
Disconnect…
Add Account…
StateThe situation it rescuesWhat the row does
Connected shipped Name, identity, Disconnect. Says nothing else, deliberately.
Sign in again "It just stopped working and I don't know why." A refused refresh drops the grant silently today, so a revoked session makes the account vanish from this list — the researcher cannot tell it from having disconnected it themselves. Keeps the row, names the cause, offers Sign In…. The account survives its own credential.
Wrong kind of account "I signed in and there's nothing there." A personal Microsoft account has no /Recordings folder at all — the import window shows an empty list, which is indistinguishable from having no recordings. A quiet PERSONAL pill and one sentence. The adapter already detects this (DriveTier); today it is only surfaced inside the import window, and only while it is open.
Can't reach it cut "Is it broken or am I offline?" Not built, and deliberately. The argument in this row is what killed it: nothing is wrong with the account, so there is nothing for an account pane to say. It is also the one state that cannot be derived at rest — rendering it means Settings doing network I/O on appear. It belongs in the import window, where a call is actually being made.
Two of the same "Which one is Bristlenose actually using?" The situation this whole redesign exists for. Both rows, both identities, both removable — the storage supports it now. The pane does not yet show two, because nothing offers a way to connect a second; see the scope-cut note above §4 for why choosing between them is deferred rather than owed.

Every one of these is a state that exists in the system today and is invisible. None of them needs new detection — the tier check, the refresh failure and the reachability probe all exist. What is missing is a place that outlives the window they happen in.

3 — Implementation issues

In dependency order. Issue 1 is the one everything else waits on, and it is a credential-storage change, so it wants care rather than speed.

1 · The Keychain can only hold one account per service

KeychainHelper keys every item on (serviceNames[provider], "bristlenose") — a fixed account string. A second sign-in to the same service hits errSecDuplicateItem and takes the SecItemUpdate path, which overwrites the first account's token in place. No error is returned; set returns true. The first account stops working at its next refresh and there is nothing anywhere saying why.

Three shapes:

(a) One Keychain item per account, kSecAttrAccount = a stable per-account key. §7 prescribes this, following MCPTokenStore: hash the identifier, never store the raw address — the account attribute is unencrypted metadata, and a client's email sitting readable in Keychain Access is precisely the kind of leak this project cares about. Costs a new enumeration path (kSecMatchLimitAll + kSecReturnAttributes), which KeychainHelper has no equivalent of today.

(b) One item per service holding an array of grants. Enumeration is free, migration is a JSON shape change, and the store code barely moves. But every write rewrites every account for that service, so one bad write loses them all — and the blob grows without bound if disconnect ever fails to prune.

(c) Service name per account. Rejected: service names come from a hardcoded allowlist that exists precisely so an unregistered key fails closed. Making them dynamic removes the guard that caught a silent no-op twice this week.

Take (a). It is what §7 already specifies, it keeps failures per-account, and the enumeration cost is one function. (b) is genuinely tempting for a day's work and its failure

shipped (a), as prescribed. One item per account, kSecAttrAccount a SHA-256 of the lowercased address. The shape came out better than this issue proposed in one respect worth carrying: the fixed account string stays as the default parameter, so the LLM provider keys — which the Python side reads at that exact fixed account — are untouched, and only cloud sign-ins pass a per-account key. Enumeration is KeychainHelper.accounts(provider:), attributes only, so listing accounts does not decrypt every credential to do it. Verified by reverting derive() to the fixed key: 12 of the 17 new tests fail, the two-accounts-coexist repro among them.

2 · There is no stable account identifier, only an email address

Whatever keys the Keychain item has to survive the address changing and has to be the same across two sign-ins to the same account. Today we hold mail ?? userPrincipalName — a UPN can be renamed, and a researcher who re-signs-in after a rename would silently acquire a second row for the same account.

Microsoft's token carries oid (immutable object id) and tid (tenant), which is the correct key. Google's carries sub. Miro has neither — the token is pasted, and MiroAPI.Connection returns display strings only; the closest thing to an id is the team name, which is not one.

Parse the id claim where there is one, and fall back to the address where there is not. Do not invent a synthetic id: a random per-connection id makes "the same account connected twice" unrepresentable, which is exactly the mess to be escaped from. For Miro, treat a second connection carrying the same userName + orgName as a replacement rather than an

shipped, scope cut The address is the identifier; the id claims are not parsed. oid+tid and sub would survive a rename, but the researcher who renames and re-signs-in, acquiring a second row for the same account, was judged edge-case paranoia for v1. That second row is visible, labelled with its address, and removable — which is the escape hatch working rather than failing. The Miro-replacement heuristic is therefore moot: Miro is not per-account keyed at all.

3 · Miro is not a CloudPlatform and must not become one

The pane needs one list, but CloudGrantStore.connections() switches over CloudPlatform — an enum meaning "a meeting platform with an import adapter". Adding .miro to it puts Miro in the import window's platform picker, the fixture harness, and CloudPlatform.built.

A small ConnectedService value the pane renders — id, display name, identity line, capability, disconnect action — produced by both the meeting adapters and Miro. The

shipped As prescribedAccountService, an enum of .cloud(CloudPlatform) and .miro, carrying display name, symbol, purpose, and whether connecting is possible from the pane. CloudPlatform is untouched and still has three cases.

4 · Once there are two, something has to choose — and it is not this pane

The genuinely awkward one. openLive(platform:) loads the grant for a platform. With two Teams accounts it must pick, and Settings is the wrong place to decide it: the choice belongs at the moment of use, where the researcher knows which client they are working on. The same applies to the export sheet with two Miro accounts.

Three ways: a picker in the import window (honest, one more click every time); most-recently-used with a way to switch (quiet, and quietly wrong the first time it guesses); or a per-project association — this project imports from that account — which matches how a consultant thinks and costs a schema field.

Ship the picker first, and only when a second account exists. One account is the common case and should stay one click. The per-project association is the better long-term answer and can be added underneath a picker that already works, without changing the storage. Do not ship most-recently-used — the failure is importing a client's recordings under the wrong account's credentials, which is the one mistake in this feature that cannot be undone by noticing.

5 · Live-session invalidation currently keys on the platform

The disconnect notification I shipped carries a platform raw value, and the coordinator drops the open window if it matches. With two accounts per platform, disconnecting the personal Teams account would close an import window signed in to the work one.

Carry the account key alongside the platform and match on both. Small, but it must

shipped Landed in the same change as the keying, as this issue insisted. The notification carries account alongside platform, and the match is extracted as CloudDisconnectMatch.dropsSession — a pure function, because a NotificationCenter observer is an awkward place to test a decision. Unknown on either side drops the session: the costs are not symmetric, and only one of the two mistakes is recoverable by noticing.

6 · Existing grants have to migrate, once

Anything connected today sits under the fixed "bristlenose" account string. After the change nothing looks for it there.

Read the old key on first launch, write it under the new one, delete the old. It either works or the researcher signs in again — an outcome worth stating plainly rather than engineering a fallback chain that has to be maintained forever. Do the deletion: leaving the

shipped Read, rewrite, delete — the deletion included. It runs on every read rather than behind a once-flag: one Keychain miss in the ordinary case, and a flag is a thing to reset in tests and to get wrong across processes. One correction to the fear stated here — a leftover would not have been a ghost. Enumeration decodes what it finds, so any stray item would still show its address and its Disconnect button.

7 · The iCloud sync question comes with it, unresolved

All of this stores third-party credentials, and KeychainHelper.set writes kSecAttrSynchronizable: true unconditionally, against §7 and §10's non-synchronizable. Multiplying the number of stored accounts multiplies whatever the answer is.

Settle it in the same change, not after. Note the wrinkle: kSecAttrSynchronizable cannot be flipped on an existing item by SecItemUpdate, so the migration in issue 6 is the natural and possibly only cheap

reversed Settled 18 Aug: the sync STAYS. Not deferred — decided the other way, against §7 and §10, which both now want truing. The realistic loss event is the Mac being stolen, and iCloud Keychain is what saves you; the alternatives are paper, a USB stick you will lose, or paying for a password manager. iCloud Keychain is end-to-end encrypted regardless of Advanced Data Protection, so this is not plaintext-to-Apple, and the grant permits downloading a limited set of recordings from an account the researcher is signed into in Office and Teams all day on the same devices. The mechanical wrinkle named here was real, and is exactly why it had to be settled now rather than later: kSecAttrSynchronizable cannot be flipped by SecItemUpdate, so the migration was the one cheap moment it could ever have changed. That moment has passed, with the answer "stay synced".

Scope cut, 18 Aug — "multi-account madness later". Issue 4 is deferred by decision: nothing has to choose between two accounts yet. This pane's job is to let a researcher see which account is connected, notice it is the wrong one, disconnect, and sign in to the right one. That is the whole escape hatch, and for someone working one client at a time it is a complete answer rather than a workaround.

The storage half is not deferred and is already being builtKeychainHelper has grown get/set/delete(provider:account:) and an accounts(provider:) enumeration. So: hold two accounts safely now, decide between them later. Note the shape it took, which is better than issue 1 proposed — the fixed account string stays as the default parameter, so the LLM provider keys, which the Python side reads at that exact fixed account, are untouched and only cloud sign-ins pass a per-account key.

4 — What I would build, and in what order

  1. The storage change alone, with no UI — per-account keying, the id claim, the migration, the sync decision, and the notification carrying an account key. Behind the existing single-account behaviour, so nothing changes on screen. This is the risky part and it deserves to land by itself.
  2. The flat list rendering ConnectedService shipped, changed shape — a section per service, Miro included, four states, read-only plus Disconnect and a Connect that opens the import window. ConnectedService shipped as AccountService.
  3. Add Account… dropped — there is no Add Account sheet. With four fixed services and one account each, a permanent Not-connected row in each section is the same answer without a modal. It comes back only if a second account of one service becomes reachable.
  4. The picker at point of use deferred — nothing has to choose between two accounts yet.

Order matters more than usual here because steps 1 and 3 are the ones that can lose a credential, and putting the whole list between them means the storage change has been exercised by hand for a while before anything invites a second account.

That held, by a different route. Step 3 was dropped rather than sequenced, so the storage change now sits behind a pane that cannot invite a second account at all — the same protection, with one fewer thing built.

5 — Closed, and still open

Closed 18 Aug.

Do LLM providers or MCP Agents move in, or get a cross-reference?No, and no. Both need more room than a row, and both are their own thing: the LLM pane is a configuration surface where connecting is one field among model choice, status and validation; MCP Agents is an exposure control with its own install flow and per-project semantics. Neither is an account you connect and forget, which is what this list is for. No cross-reference either — a pointer from one pane to another is the shape you reach for when the boundary is wrong, and here it is right.

Sections, or a flat list?Flat, permanently, not just for now. Three services today and Zoom the only one on the horizon: four rows. A list of four does not need navigation, and grouping four things by an axis the reader does not think in costs more than it explains.