What the researcher actually sees

The import window was designed against a tenant that grants everything. Research on 17 Aug 2026 says the common corporate tenant does not — and the thing it withholds is the calendar, which is where the roster, the schedule, the titles and the arithmetic footer come from. This remodels the shipped surfaces against that tenant, so the consequence is visible rather than argued.

Drawing it settled it. The window without a calendar turned out to be a good product rather than a degraded one — date, time and duration identify a session, and all three survive. So the conclusion is not how do we cope when the calendar is refused. It is aim for A, land safely on B, and let the columns follow the data: ask for the video first and the calendar second, so a refused calendar costs the calendar and nothing else, and drop any column the grant returned no data for rather than ruling a stripe of em-dashes. §5 carries the changes; §6 carries the one measurement they are all gated on.

Design artefact · 17 Aug 2026 · siblings: cloud-import-recordings-grid.html, cloud-import-failure-states.html, cloud-import-scope-choice.html · relates to docs/design-cloud-import.md §3, §6

The finding, in one line: Calendars.Read has required admin consent since late November 2025, and we verified otherwise on a tenant where we are the admin.

Microsoft's message centre MC1163922 added 20 Exchange and Teams permissions to the Microsoft-managed default app consent policy, rolling out late Oct → late Nov 2025. Calendars.Read is on that list, and so is Calendars.ReadBasic. Files.Read is not — the change is scoped to Exchange and Teams, not files. Our §3 table marks Calendars.Read “no admin consent ✓verified” as of 15 Aug 2026; that check ran against our own tenant, whose owner is Global Administrator, which the probe brief already names as the trap that always says yes.

So the video survives and the calendar does not. Everything below follows from that one sentence. documented — the enumerated permission list comes from trade write-ups of the message centre post, not from a live non-admin tenant. The measurement that settles it is Q-B, and it is now the cheapest high-value probe we have.

1 — The same four recordings, three tenants

Identical OneDrive contents. Identical researcher. The only variable is what their organisation let them consent to.

A · The tenant we designed for — calendar granted

Import from Microsoft Teams
martin@clientco.com
Last 30 days ⌄
Filter
MeetingScheduledRecorded SizeStatus
Wed 13 Aug
P04 Interview
Priya Raman · Simon Ellery
09:30
1h
09:34
52m 11s
1.1 GB
P05 Interview
Dana Okonkwo · Simon Ellery
11:00
1h
11:02
48m 03s
1.0 GB
Weekly sync
Dana Okonkwo · Priya Raman · Simon Ellery
10:00
30m
Not recorded
Fri 8 Aug
P03 Interview
Aoife Brennan
15:00
1h
15:06
47m 52s
1.0 GB
Catch-up with Tomas
Tomas Lind
16:30
30m
16:31
24m 40s
402 MB
9 meetings in window · 4 you can fetch · 4 organised by someone else

Everything in this window except the tick, the size and the clock comes from the calendar. Titles, attendee lines, the Scheduled column, the un-recorded row, and all three numbers in the footer. That is not a decoration layer — it is most of the screen.

B · The common corporate tenant — Files.Read only

Import from Microsoft Teams
martin@clientco.com
Last 30 days ⌄
Filter
MeetingRecorded SizeStatus
Wed 13 Aug
P04 Interview
09:34
52m 11s
1.1 GB
P05 Interview
11:02
48m 03s
1.0 GB
Fri 8 Aug
P03 Interview
15:06
47m 52s
1.0 GB
Meeting with Tomas Lind
16:31
24m 40s
402 MB
4 recordings in the last 30 days

Four things vanished and one changed identity — and reviewed 17 Aug, only one of them matters. Gone: every attendee line, the whole Scheduled column, the Weekly sync — Not recorded row, and two of the three footer numbers. Changed: Catch-up with Tomas was the calendar subject, and the filename says Meeting with Tomas Lind.

The un-recorded row and the title change are both accepted, not problems. A meeting nobody recorded cannot be analysed whether we list it or not, so the row was never doing work for the researcher. And they do not find a session by its canonical title — they know they spoke to a person on a day, and there are usually a handful. The rename-before-drop pass in the researcher's own workflow already absorbs it, and session names stay editable in the report.

And the good titles are nearly always there anyway — because scheduling discipline pays off in the filename, not in the calendar API. A researcher schedules their sessions; it is the job. Teams writes the meeting subject into the recording's filename, so P04 Interview survives with no calendar scope at all. The degenerate Meeting with Tomas Lind shape only appears on an unscheduled call — and an ad-hoc hour-long research interview is rare by definition. Note the specimen above: the ad-hoc row is the 24-minute catch-up, not one of the interviews. We were about to buy with a scope something the filename gives away free.

And the footer's safety rail mostly survives — an earlier draft of this page had this wrong. §6's first honest-batch requirement is “show the join's arithmetic”, because the feature's designed output and its failure output are both a shorter list. But the part that answers did the listing finish? is ListOutcomeexhausted / pageCapHit / failed — and that comes off the recordings walk, which needs no calendar. What is genuinely lost is the cross-check against the diary, and at a handful of interviews the researcher's own memory is a better one than a footer: they know they ran six sessions, so four rows is a question they ask unprompted.

What is left, and it is a short list: the roster and the schedule. The attendee line feeds the drop-yourself rule, the externality marker, and §4's organiser framing. The Scheduled column exists to make Teams' recording-vs-booking gap legible. Both are real losses — A is the better window and worth reaching for — but neither stops the researcher identifying and importing the right sessions, which is what the window is for.

Note the column count: five, not six. When there is no scheduled data at all, the column goes — it does not render a stripe of em-dashes. A column of forty dashes is chrome advertising an absence, which is the opposite of the house rule that absence is itself information. The same applies to the attendee sub-line: no roster, no line, no explanatory placeholder under every title. The window's columns are derived from what the grant actually returned, so B is a smaller window rather than a damaged one — and the em-dash stays available for the row-level case, where this recording has no match but others do.

C · The consent that never lands — everything refused

Import from Microsoft Teams

Your organisation needs to approve Bristlenose

Sign-in finished, but ClientCo requires an administrator to approve apps before they can read your meeting recordings.

Try again

This is the state we would land in today if the consent request is all-or-nothing — and we currently ask for Files.Read, Calendars.Read, User.Read and offline_access in a single authorize call. If Entra evaluates that set as a unit, one admin-gated member takes the whole request down and the researcher does not get the degraded video-only window in B. They get nothing. Scenario B is only reachable if we ask twice. unmeasured

Import from Microsoft Teams

Sign-in didn't finish

You may have closed the window — or your organisation may need to approve Bristlenose first. We can't tell which from here.

Try again

Shipped today, and correct — but it is the weaker of the two. Phase.signInIncomplete exists precisely because ASWebAuthenticationSession reports plain cancellation whether the researcher closed the tab or Entra refused before any redirect. It names both possibilities honestly. The screen on the left is better whenever we can tell — and AADSTS90094 in the callback is us being told. We already parse it; we just don't act differently on it.

2 — Finding → data → what the user sees

Each row is one technical fact, the field it takes away, and the pixels that change. This is the table to argue with; the windows above are just it, rendered.

Technical findingWhat the data losesWhat the researcher sees
Calendars.Read is admin-gated
documented
calendarView returns 403. events is empty, so matched is nil for every row. No attendee line. No Scheduled column. Titles fall back to the filename.
A meeting with no recording has no driveItem Un-recorded meetings existed only as calendar events. Nothing in /Recordings represents them. Accepted. “Not recorded” rows disappear — and a meeting nobody recorded cannot be analysed whether we list it or not. The row was diagnostics, and the researcher's own memory of how many sessions they ran is the better diagnostic at this scale.
JoinArithmetic is computed from events eventsInWindow falls back to rows.count; organisedByOthers counts zero by design when the scope is declined. outcome is unaffected — it comes off the recordings walk. Footer drops from three numbers to one. The completeness rail survives: a truncated or failed listing still declares itself. Only the diary cross-check is lost.
Title is matched?.subject ?? parsed.title Always parsed.title — the filename segment before the timestamp. Accepted. Recognition is by person and day over a handful of sessions, not by title. “Meeting with Tomas Lind” names the human; “Weekly sync” names nothing. Renaming already happens by hand before the drop, and session names stay editable in the report.
Attendee/domain filtering needs the roster Nothing to filter on. Filter narrows to title text only. Minor at a handful of rows; it is the 200-meeting month that wanted attendee search, and that is not this user.
Entra may evaluate a scope set as a unit unmeasured One authorize call carrying an admin-gated scope may return no code at all. Not a degraded window — no window. Decides whether we ship one consent step or two.
A hand-rolled flow sends no Primary Refresh Token documented Device-compliance and hybrid-join state can't be evaluated, so a compliant-device Conditional Access policy refuses the token after a successful sign-in (AADSTS53003). Sign-in appears to work, then fails. Indistinguishable from a refused consent unless we read the error code — and unfixable by the researcher, their admin, or us.
/Recordings may be a localised folder name unmeasured The hardcoded path 404s on a non-English tenant. Currently indistinguishable from a personal account: we set tier = .personal and report an empty list. A wrong, confident explanation.

3 — The two walls — which we cannot tell apart, and that decides the design

Real products treat this as a support problem, not an engineering one. Morgen's and GReminders' answer to the same wall is a walkthrough plus a pre-built admin-consent URL carrying their client id. Nobody engineers around it.

An earlier draft of this page proposed detecting which wall the researcher hit and showing a different screen for each. That is not buildable — verified 17 Aug: both walls return the same AADSTS90094. The difference exists on Microsoft's own page, not in the callback we receive. The two panels below are therefore what the researcher sees on Microsoft's side, not a branch we can implement.

Their tenant enables the request workflow

Ask ClientCo to approve Bristlenose

Microsoft will show you a box to explain why you need it. Your IT team gets the request by email — most approve within a day.

The researcher has a door — but note where they are standing when they use it. If they click Request approval they stay on Microsoft's page and never return to us at all. So the callback we actually receive is overwhelmingly the other case, or someone who backed out. That is the second reason not to build a branch on it: the happy half of the branch mostly never reaches our code.

Their tenant does not

Only ClientCo's IT team can approve this

Microsoft won't let you send the request yourself. Send this link to whoever manages your Microsoft 365 account.

Import from Finder instead

No door. “Try again” must not appear — it sends someone at a wall only a third party can remove. The escape hatch is the honest one: the recording is two clicks away in Teams, and drag-drop already works. Naming that is not an admission of defeat; it is the appliance coping.

So we ship one screen, correct under both

Your organisation needs to approve Bristlenose

ClientCo requires an administrator to approve apps before they can read your meeting recordings. Send your IT team the link — this isn't one you can grant yourself.

Import from Finder instead

The limit produced a better screen than the branch would have. One sentence that is true whichever wall they hit, a link that works in both cases, no “Try again” to send them in a circle, and the manual route named rather than hidden. Shipped 17 Aug as MicrosoftSignInRefusal — a pure classifier over the Entra code, with isWorthRetrying false for both walls and true only for a genuine decline, so the distinction that does exist is the one the UI acts on.

Third refusal, and it is neither of these: AADSTS53003, Conditional Access. The sign-in succeeds and the token is refused anyway — typically a policy demanding a managed device, which a hand-rolled flow can never satisfy because it transmits no Primary Refresh Token for the device to be judged by. It presents as “sign-in worked, then nothing did”, and until 17 Aug it fell into unexpected and failed closed with no explanation, which reads as a bug in Bristlenose. Nobody in the conversation can lift it — not the researcher, not their admin, not us — so it now says exactly that.

Both screens need a scope line the researcher can hand over. An admin asked to approve an unknown app will ask what it reads. Today our consent screen says “unverified” and “the publisher has not provided links to their terms” — so the first thing their IT sees about us is an absence. That is docs/design-cloud-import.md §10's already-owed consent debt showing through Microsoft's own UI, and it is ours to fix, not Microsoft's.

4 — Three shapes, and the third one only became visible once B was drawn

Once the un-recorded row and the title change are accepted as fine, the calendar is buying two things: the attendee line and the Scheduled column. That is a much smaller prize than the design assumed, and it makes a third option live.

One call — todayTwo calls — splitNo calendar at all
Open tenant Scenario A. One prompt. Scenario A. Two prompts — a cost paid by the best-case user. Scenario B. One prompt.
Default-policy tenant Scenario C. Nothing works. Scenario B. Video imports; roster offered, declined gracefully. Scenario B. Identical to every other tenant.
Admin approves later Re-run the whole sign-in. Add the calendar grant alone. Nothing to approve.
What the researcher loses Everything, on a common tenant. Nothing they can't recover by asking. Attendee line + Scheduled column, on every tenant.
What we stop building The consent split, the ReadBasic switch, the roster-absence messaging, the two consent walls, and the whole calendarView path — pagination, timezone pinning, the ±30-minute join, and its tests.

Decided 17 Aug: two calls. A is the target, B is the fallback, and the split is what makes “both” possible. A is the better window and worth reaching for — the roster and the schedule are real. But reaching for it must not risk the video, and on a single call it does exactly that: one admin-gated member takes the whole request down and the researcher gets scenario C. Splitting is the only shape where a refused calendar costs the calendar and nothing else.

The third column stays on the table, unbuilt. Dropping the calendar entirely was a real option and it deletes the most code — but it also gives up A permanently for every tenant in order to simplify the tenants that were going to end at B anyway. Held in reserve: if the measurement below shows most tenants refuse, the split degenerates into it at no extra cost, since B is already the fallback we built. (Teams-specific either way — Google's adapter lists through calendar.events.readonly, so there the same move would remove the listing itself.)

What the split costs, stated so it is a decision and not a drift. The best-case researcher on a permissive tenant sees two consent prompts instead of one. That is a real regression for the luckiest user, paid so the common user gets a working window instead of a wall. The second prompt should be asked at a moment where its value is obvious — after the list is on screen, offering to name the people, not as a second gate in front of an empty window.

5 — Changes needed to deliver this

Decided 17 Aug: aim for A, fall back to B, and let the columns follow the data. The identifying tuple a researcher recognises a session by — date, time, duration — is calendar-independent (duration comes off the driveItem's own video/audio media facet), which is why B is a good product rather than a broken one. A is still better and worth reaching for; the work is making the reach safe.

  1. Split the consent into two authorize calls. Files.Read + User.Read + offline_access at sign-in; the calendar as a separate, declinable grant asked after the list is on screen, where its value is visible. MicrosoftScopes.requested becomes two sets and TeamsSource.signIn stops treating the calendar as part of getting in. This is the change everything else depends on — and it is gated on the measurement in §6.
  2. Switch Calendars.ReadCalendars.ReadBasic. Both are on the blocked list, so the 15 Aug rationale (“ReadBasic needs admin consent and Read does not”) is void. Same gate, strictly less data — it restores the data-minimisation position we gave up, and the $select compensating control stays as defence in depth.
  3. Derive the columns from the grant, don't render empty ones. No scheduled data at all → the Scheduled column does not exist, not a stripe of em-dashes. No roster → no attendee sub-line and no placeholder under every title. The em-dash survives for the row-level case, where one recording lacks a match and its neighbours have one. This is the concrete difference between window A and window B in §1, and it is a rendering rule rather than a message.
  4. Offer the calendar once, where it reads as an offer. Not a banner over an empty window and not an error — a quiet line near the roster's absence that explains what the second grant would add and lets them ask. It must survive being declined without nagging.
  5. Branch the consent refusal into the two walls, and only show “Try again” where the request workflow exists. Done 17 Aug, and the shape changed on contact. The branch is not buildable — both walls return the same AADSTS90094 — so it shipped as one screen correct under both, plus a MicrosoftSignInRefusal classifier whose isWorthRetrying is false for both walls and true only for a genuine decline. Still needed for sign-in itself, not just the calendar: Files.Read is not on Microsoft's blocked list, but §3's warning stands — under a verified-publishers consent policy the self-consentable set is the OIDC scopes plus User.Read, and Files.Read is not in it. The remaining half is UI: `isWorthRetrying` needs to reach the button, which lives in a file another session holds.
  6. Classify AADSTS53003 distinctly. Done 17 Aug. Conditional Access is neither a consent nor a licence problem; it fell into unexpected and failed closed with no explanation, which reads as a bug in Bristlenose. It now says that nobody in the conversation can lift it — and it is classified at the token leg as well as the authorize leg, because it refuses after a successful sign-in.
  7. Stop diagnosing a 404 on /Recordings as a personal account. Done 17 Aug — the listing now addresses /me/drive/special/recordings/children, the alias Graph documents as existing to avoid a path lookup “which would require localization”. No new scope; Files.Read is its least-privileged permission, and it survives the user renaming the folder too. The tier moved with it: Graph answers 403 or 404 when a read-only app asks for a special folder that doesn't exist, so the drive is now the discriminator — if /me/drive answers we hold Files.Read, the refusal is about the folder, and driveType names the tier as a field instead of a guess.
  8. Teams token persistence + refresh. Done 17 AugMicrosoftGrant in CloudGrantStore, registered as cloud-microsoft-teams in KeychainHelper.serviceNames. One grant, not Google's two: no Picker step here, so there is no second authorization to hold. The identity rides with the tokens because CloudImportStore picks its opening phase from accountEmail. Both the listing and the fetch renew before reading — the fetch for the sharper reason that a multi-gigabyte batch outlives an hour, so an unrenewed grant fails at the tail, after the researcher has stopped watching. And a renewal carries the refresh token forward when the response omits it, which Microsoft does routinely: storing the response verbatim renews once and then strands the account an hour later.

One change died on its own merits — marking filename-derived titles as filename-derived. Recognition runs on person and day over a handful of sessions, researchers nearly always schedule so the filename carries a real subject anyway, renaming happens by hand before the drop, and session names stay editable in the report. Four mechanisms already cover it.

6 — Open questions

Three of the six closed on 17 Aug. The one I had marked dissolved is live again and is now the gate on the whole plan.

Closed 17 Aug 2026.

Is scenario B a good enough product?Yes, and better than expected. Date, time and duration are enough to be fairly sure which session is which, and all three are calendar-independent (duration comes off the driveItem's media facet).

Is the roster worth a second prompt?Yes, as a second prompt and never as a gate. A is the better window and worth aiming for; B is a fine place to land. That is precisely the two-call shape, and it is why the question above matters.

Should we mark filename-derived titles?No. Researchers nearly always schedule, so the filename carries a real meeting subject; the ad-hoc case is rare by definition and names the human anyway.

What does the window do with a column it has no data for?Drops it. Not em-dashes. Columns derive from what the grant returned; the em-dash is for the row-level miss.