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.
Calendars.Read has required admin consent since late
November 2025, and we verified otherwise on a tenant where we are the admin.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.Identical OneDrive contents. Identical researcher. The only variable is what their organisation let them consent to.
| Meeting | Scheduled | Recorded | Size | Status | |
|---|---|---|---|---|---|
| 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 | ||
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.
Files.Read only| Meeting | Recorded | Size | Status | |
|---|---|---|---|---|
| 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 | ||
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 ListOutcome —
exhausted / 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.
Sign-in finished, but ClientCo requires an administrator to approve apps before they can read your meeting recordings.
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
You may have closed the window — or your organisation may need to approve Bristlenose first. We can't tell which from here.
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.
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 finding | What the data loses | What the researcher sees |
|---|---|---|
Calendars.Read is admin-gateddocumented |
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. |
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.
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.
Microsoft won't let you send the request yourself. Send this link to whoever manages your Microsoft 365 account.
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.
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.
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.
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 — today | Two calls — split | No 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.
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.
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.Calendars.Read → Calendars.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.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.AADSTS53003 distinctly.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./Recordings as a personal account./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.MicrosoftGrant 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.
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.
Files.Read alone and one carrying
both. Read both screens. Needs someone else's tenant — ours always says yes.Files.Read either, and scenario B does not work today — not because of the
calendar finding, but because of us. Two independent gates that fail at the same screen: this
one is ours to clear (Partner Center / MPN account, DNS-verified domain); the
Calendars.Read one belongs to the tenant and we can never clear it. Clearing ours
restores window B, never window A. It is the single highest-priority item on this page and
it is not a research question — it is paperwork someone has to do./Recordings folder name localise?ListOutcome is
established as the surviving rail. “4 recordings in the last 30 days” is true and reads like
certainty; is there wording that conveys this is your Recordings folder, which is not the same
as everything you recorded without becoming a permanent apology? A copy question, not a
structural one.