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.
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.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.8901845f (storage) · d3b66642 (the pane) ·
a27f85b4 (the tombstone).
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."*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.
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.
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.
| State | The situation it rescues | What 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. |
| "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.
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.
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.
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.
CloudPlatform and must not become oneThe 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 prescribed —
AccountService, 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.
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.
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.
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.
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".
KeychainHelper 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.
ConnectedServiceConnectedService shipped as
AccountService.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.