Every way it goes wrong

A pre-mortem over the whole import path, from sign-in to a window the researcher closed an hour ago. For each state: where it surfaces, the exact words, and what they can actually do about it. Enumerated from the shipped types — GoogleAPIOutcome, TeamsAPIOutcome, ArtifactAvailability, ImportRowState, ListOutcome, FetchOutcome, DownloadVerdict — not from imagination, so the gaps below are real gaps rather than unwritten cases.

Design artefact · 17 Aug 2026 · sibling of cloud-import-sidebar-progress.html · relates to docs/design-cloud-import.md §6

The finding, before the tables: the window can describe every state and can only detect the remote ones. Fourteen row states and six blanket titles exist, localised into 21 languages — and all three adapters hardcode localState: .notImported, with nothing downstream deriving it. So six of the fourteen have no producer at all: Imported, Damaged, On iCloud, On “Archive”, No longer available, View only. Everything the window might say about the file you already have is written, translated, and unreachable.

The second half of the same defect is lifetime: what the window does learn — outcomes, progress, a shortfall — lives in store.outcomes and dies with the window the design actively encourages people to close. Both halves are answered by one artefact: a durable, per-project record of what this project received. See §11.
window the import window row the project's sidebar row footer the window's arithmetic line nowhere not currently surfaced

1 — Before a single row exists

Sign-in and listing. These are blanket states: the window has nothing to show, so it says one thing well instead of forty rows of nothing.

StateWhereWhat it saysWhat they can do
signedOutwindow Not signed inSign in — the only control on screen.
needsReauthentication
Google + Teams
window Sign-in didn't finish Sign in again. RetryPolicy.onceAfterReauthentication — once, then stop, because a refreshed token that fails identically is a loop.
scopeNotGranted(scope:)window Bristlenose can't see your recordings Re-consent, naming the one missing permission — Google's consent screen lets you tick scopes individually, so this is routine, not exceptional. A wrong scope name here is worse than none, so scopeHint returns nil rather than guessing.
notAvailableOnThisPlan
personal Google
window This account can't record Meet calls Nothing, and say so. No amount of consent fixes it. Drag-drop from Finder is the whole answer and must be named here.
accountNotLicensed
Teams only
window Bristlenose can't see your recordings Their IT. Distinct from Google's plan case in remedy — a licence can be granted, a consumer tier cannot.
rateLimited(retryAfter:)window Couldn't load your meetings Wait, then retry. Never routed to a permission message — Google returns quota exhaustion as 403, and reading that as a permission problem sends a researcher to their IT department over something that clears itself.
transient(status:) / unexpected window Couldn't load your meetings Retry for transient; unexpected fails closed — an unclassifiable response never earns a free retry.
pageCapHit footer counts become a floor Narrow the window. The list is a prefix and looks exactly like a complete one — this is why ListOutcome exists. Verify the footer actually renders the distinction rather than printing a confident total.
harvest failure
Meet, new
window footer Unavailable, per row Retry the list. Forces every unmatched row off “Not recorded”, because “we couldn't look” must never render as “nobody recorded” — on a mono-reason list that becomes None of these were recorded over a month of interviews sitting in Drive.
Empty window, no errorwindow No recordings in the last 30 days Widen the window. The most dangerous response in the feature — a 200 with an empty list is not an error and must never be dressed as one, nor as certainty.

2 — Per row, at list time

A row exists. Two independent axes decide what it says: what the platform will serve (ArtifactAvailability) and what is already on this Mac (ImportRowState). The checkbox column carries a third distinction that is easy to collapse and expensive to lose: a dead checkbox means “there is a recording here and you cannot have it”; no checkbox means “there is nothing here”.

StateStatus columnCheckboxWhat they can do
.available(silent) liveTick it. The common case says nothing, deliberately.
.notRecordedNot recorded none Nothing to do — and no permissions link. An ordinary month of un-recorded standups must never raise a permissions question.
.notOrganiser(organiser:)Someone else dead Ask them — the row names the person, because the real-world fix is to ping them. A count would be dead weight where a name is a workflow. Never the raw address.
.needsScope(_)Needs access deadRe-consent. Being unable to look is the permission problem.
.notOnThisPlanNeeds a paid plan deadUpgrade, or drag-drop. Only a personal account earns this.
.unsupportedUnavailable deadRetry the list. Deliberately weaker than “Not recorded” — it claims only what we know.
.notResolvedCouldn't match none No producer since the listing was inverted. Strings live in 21 locales and the state is unreachable. Keep it or cut it — but decide, because a state nothing can enter is a promise the code no longer keeps.
.viewOnly
Teams tenant block · no producer
View onlynone Nothing, by any route. The API path is blocked, and so is downloading by hand — the usual “drag-drop still works” escape does not apply. An IT exemption is the only remedy and the copy must say so plainly.
.noLongerAvailableNo longer available none No producer. Needs a previous listing to diff against — which the per-project record in §11 would also supply.
.importedImported ticked + deadNo producer. Every adapter hardcodes .notImported, so a window opened after a successful import offers to fetch it all again.
.imported this session ✓ Importedempty and live Observed 17 Aug, real defect. After a successful fetch the row shows “Imported” while its checkbox draws empty and enabled — localState is never refreshed, so drawsTicked reads the tick set, which was cleared. The row invites a re-download of a file it just told you it has.
.damagedDamaged liveNo producer. Would want the expected size, which the §3 sweep now supplies.
.notDownloaded(provider:)On iCloud ticked + dead No producer. Would say: materialise it in Finder — not an error, a healthy file in the destination's cloud, a different cloud from the import's.
.driveNotConnected(volume:)On “Archive” ticked + deadNo producer. Would say: plug the drive in — naming the volume is what makes it actionable.

3 — At the moment of commit

StateWhereWhat it saysWhat they can do
Picker cancelledwindow Access wasn't granted. Press Import again. Reported once for the batch, not N times — a grant failure is the batch's failure.
⚠ Picker deselection nowhere nothing Silent shrink. The code honours what was granted, not what was asked — so a researcher who unticks two files inside Google's Picker gets a smaller batch and no acknowledgement. The rows stay ticked, the ring's denominator is wrong, and “3 of 4” counts a file nobody asked for.
⚠ Not enough disk space nowhere → should be window This needs 4.2 GB free… Free space, or pick a different project. CloudDownloadError.insufficientSpace exists and fires per file, mid-batch; the precheck that should fire before the first byte reads sizeBytes, nil on every Google row. The size sweep makes it possible.
Destination unreachablewindow The picker no longer offers projects with no path, after a real recording was published into the sandbox container root while the window said “✓ Imported”. A project whose lease is unavailable is still offered — a known gap, not yet a refusal state.

4 — During the transfer

Every one of these is verified rather than trusted, because a download that lands and is wrong is worse than one that fails. DownloadVerdict.isRetryable already decides which offer a Retry.

VerdictRetryableWhat went wrongWhat they can do
.shortRead(expected:received:) yes Connection dropped mid-file. The commonest real failure. Retry — the window keeps failed rows ticked so Retry has something to act on.
.badStatus(_) yes Server said no, transiently.Retry.
.notMedia(contentType:) no JSON where a video should be — alt=media missing would land a perfectly valid file description on disk named .mp4. Nothing; a bug on our side. Must be reported as such, not as a network fault.
.sizeMismatch / .hashMismatch no Arrived complete and wrong. Nothing automatic. The file is never published — a truncated MP4 still has ftyp in its first eight bytes, which is why magic-bytes alone was never enough.
.wrongFormat(detected:) no A real media file of the wrong kind.Nothing.
.cancelledn/a A decision, not a fault. URLError(.cancelled) used to fall through to the generic catch and be recorded as a failure — so the terminus counted the user's own choice as a fault and offered Retry for rows they had deliberately abandoned. Nothing is owed. Say nothing.
⚠ Destination vanishes mid-batch Volume unmounted, folder moved. Every remaining publish fails. Should stop the batch and report the project as unreachable — one cause, not N download failures. Untested.
⚠ Another platform chosen
while a batch runs
openLive replaces store unconditionally. The running fetch task holds its store strongly, so it keeps downloading — invisibly, into a project the new window knows nothing about, with no way to see it or stop it. Reachable today in three clicks. Nothing, because nothing tells them. §9 is the fix.
⚠ App quits mid-batch Ordinary URLSession, not a background one, so the transfers die. .part files remain and nothing anywhere remembers a batch was in flight. Nothing, and nothing is said. Same shape as the stranded-run problem the pipeline's event log was built to fix, and it wants the same answer: write the batch's intent somewhere durable before the first byte.

5 — After the batch, while the window is still open

StateWhereWhat it saysWhat they can do
All importedfooter 5 importedClose it. Analysis starts on its own.
Some failedfooter 3 imported · 2 failed Retry failed — the primary button changes verb, and the failed rows stayed ticked precisely so it has something to act on.
Stoppedwindow StoppedRe-tick and import again. No apology.

6 — After the window closes. This is the hole.

Everything above lives in the window. The researcher closes it — because it looks like it is working — and the entire vocabulary goes with it. What remains is a project row that knows only what is on disk.

What happenedWhere it lives nowWhat the row showsWhat is owed
⚠ Two of five failed store.outcomes, in memory nothing A shortfall that survives the window, and a route back. The Drive file ids are still held, so retrying needs no re-list.
⚠ The batch is still running store.progress, in memory nothing The ring, and “3 of 4”. Which is the whole of the sibling document.
⚠ It finished an hour ago, short nowhere — the store is per-window state nothing The researcher sees a finished report over three interviews and no signal that a fourth exists in the cloud and never came down. This feature's own failure mode — a shorter set that reads as complete — arriving after the window built to prevent it was dismissed.
⚠ They quit and relaunched nowhere nothing Even the memory of the shortfall is gone. Only durable state fixes this.

Eight of the nine ⚠ rows in this document are one defect. Not missing error handling — the handling is unusually complete — but error handling with the lifetime of a window that the design actively encourages people to close.

7 — What each platform can send that the others cannot

PlatformOnly thereConsequence
Meet Consumer accounts that can record (One Premium / AI Pro) but are invisible to the Meet API, which serves Workspace-hosted meetings only. Conference records expire after 30 days while the Drive file lives on. Sizes unknown before the per-file Picker grant. A paying researcher reachable by calendar + Picker and invisible to the artifact door. And a window wider than 30 days reports “Not recorded” for meetings that were — pre-existing, unchanged by the inversion, and now cheap to detect since the oldest record returned is one readable number.
Teams The tenant Block-download policy: browser-only access with, verbatim, “no ability to download or sync files or access them through apps”. Channel meetings can land in a View only folder where even the organiser is view-only. The only state in the feature with no remedy and no manual fallback. It needs a licence a regulated client buys — which means it is likeliest exactly where the recordings matter most. Say it plainly rather than let a cohort call discover it.
Zoom
parked behind a flag
Cloud recording switched off for the account — a string already exists for it. And renditions: one session served as speaker view, gallery view, shared screen, audio only. The rendition case is the dangerous one and it is the same shape as Meet's siblings with the opposite meaning: two Meet children are two interviews; four Zoom children are one interview four times. Ticking them all yields 45 minutes analysed as four participants. CloudImportOutline.Kind is an enum so a .rendition case would make the compiler ask at every switch.

9 — Reopening mid-batch: the window is a monitor, not a chooser

Reopening stays in File ▸ Import, which keeps the existing convention and reads the way the researcher thinks — “my Teams import”, not “the import window”. While a batch is in flight the platform that owns it is the only enabled item; the other two dim, which is how the menu explains the one-batch-at-a-time rule instead of letting a click destroy a running transfer.

Reopening already shows the live batch rather than a fresh sign-in — the store lives above the window and CloudImportWindow.task only lists if store.listing == nil. So there is no authentication dance to avoid; there is a store replacement to stop.

What the window is, in each mode

ControlChoosingWatchingWhy
Checkboxesliveinert, still drawn The batch was snapshotted at startFetch (let queue = fetchOrder). Ticking now changes nothing — and toggle is not guarded on isFetching today, so it is a live control that silently does nothing.
Primary buttonImport 5 RecordingsStop Already correct, and already not prominent — HIG does not give the primary role to a destructive action.
Destination popupliveinert The destination URL was captured at startFetch. Changing it mid-batch is a control that lies.
Window scope “Last 30 days”live inert Actively harmful today. Its onChange re-lists with no isFetching guard, replacing listing while fetchOrder still points at the old rows.
Filter fieldlivelive The one control that gets more useful while watching — it is how you find the one that is stuck, which is the whole reason to come back.
Per-row progressthe point Bars, bytes, per-file outcome. The diagnosis the sidebar ring deliberately cannot give.

The menu item swaps verb, it does not just dim its neighbours. “Teams…” starts an import; during a batch the same position should read “Show Teams Import”. Same idiom as Turn On/Off Agent Access and the Hide/Show panel rows — this codebase already prefers a changed verb to an ambiguous one, and it removes the last doubt about what the click will do.

11 — What the batch leaves behind, and where “already imported” actually lives

The end of a batch is not a fresh open — it should be better than one

When the last transfer finishes the window becomes choosable again: checkboxes live, Import back, destination live. But it must not become amnesiac. A genuinely fresh open today would offer to re-download every file it just fetched, because no adapter derives local state — the “✓ Imported” a researcher sees comes from store.outcomes, which is in-memory and dies with the window.

After the batchCheckboxStatusWhy
Landedticked + deadImported You have it. Re-fetching spends an expiry-limited remote read on nothing.
Failedstays ticked, livethe reason Deliberate — Retry failed needs something to act on, and the store already keeps failed rows ticked for exactly this.
Cancelledunticked, live(silent) A decision, not a fault. Nothing owed, nothing said.
Never tickedunticked, live(silent) Unchanged. This is the “what is still available” the window is being reopened for.

“Can they re-download into a different project?” — yes, and it breaks the model

localState is a stored property of the row, but “already imported” is a property of (row, destination). The same recording is imported into IKEA with uxfriends and not into Ward handover — and the destination popup is right there in the footer, changeable at any moment. A row that reads Imported must go back to a live checkbox the instant the destination changes. A field set once by the adapter cannot express that, and no amount of refreshing it after a fetch will fix the shape.

Filename matching is the tempting answer and the wrong one

CloudDownloadNaming is deterministic, so “does destination/<name> exist?” looks like a free answer. It is a guess: the researcher renames files, two studies collide on a title, and a file restored from a backup is indistinguishable from one we fetched. The house rule is the exact key over the clever heuristic — and here the exact key is cheap, because we know the row id we downloaded.

One artefact answers four of the flagged rows. A small durable record inside the destination project — which cloud rows landed, when, from which platform and account, and which were asked for and did not arrive — supplies:

It answersWhich flagged row
“Do I already have this, in this project?” .imported has no producer
“Four were promised, three came” — an hour later, in a closed window ⚠ the shortfall dies with the store (§6)
“A batch was in flight when you quit” ⚠ nothing remembers (§4)
“This one was in last week's list and is gone now” .noLongerAvailable has no producer

Do we need a hash?

Not for this. A hash identifies a file; the question is about a recording. “Did this project already receive cloud row X” is answered exactly by the record, with no file inspection at all — and byte-identical is only evidence for same-recording, never proof: two exports of one call can differ by a byte and be the same interview.

LayerCostCatchesMisses
Row id in the recordnothing — it is the record Exactly what we fetched, under any name. The only layer that can say asked for and never arrived. Files that arrived by another route. A file since deleted.
Size, scanned across the folder one directory listing, no reads A renamed file — the case in question. And the more likely one: a recording the researcher downloaded from Drive by hand last week and dragged in, which no record can know about. Two genuinely different recordings of identical byte length — possible, and a false positive here costs only an un-offered re-download.
Content hash a full read of every file, every time the window opens Nothing size does not already catch, at this level of stakes. Same recording re-exported — differs by a byte, reads as unrelated.

Record the hash anyway, because it is now free. §3's size sweep already calls files.get?fields=size; adding md5Checksum is the same round trip. The adapter's own comment says verification settled for size + magic bytes because “fetching metadata purely to hash would cost a round trip per row” — that objection expires the moment the sweep exists. So DownloadVerdict.hashMismatch, which is written and unreachable, becomes reachable at zero cost, and the record carries a hash for the day a question is worth a full read.

The rule: hash to verify what just arrived, size to recognise what is already there. One is a check on bytes we are receiving anyway; the other must not cost a gigabyte of reading every time someone opens a window.

And the harm being prevented is not wasted bandwidth. A duplicate import lands a second copy under the canonical name, the pipeline scans the folder, and one interview becomes two sessions with two participants — the same corruption as the Zoom rendition case, arriving by a different door.

The case that defeats all three layers: renamed and trimmed

The maintainer's own routine, stated 17 Aug 2026: “I often rename and trim manually downloaded videos before dragging and dropping into folders to process.” Download from Drive by hand → rename → trim → drag in. Then open cloud import and the same meeting is listed.

LayerVerdictWhy
Row id in the recordblind It never came through us. A key we mint cannot cover a file that arrived by another door.
Sizeblind Trimming changes it, by construction.
Content hashblind Trimming changes every byte after the cut, and most tools re-write the container besides.

So this is a boundary, not a cost question. For files arriving by a route we do not mint keys for, the only usable signals are intrinsic to the media. No amount of bookkeeping on our side reaches them.

Measured, 17 Aug 2026 — and it removes half the answer

ffprobe over the five recordings this feature has actually fetched, plus the Teams file already in the same project:

FileDurationcreation_timeOther tags
4 × Meet recordings20.75s · 14.0s · 28.25s · 2152.83s absentencoder=Google
1 × Teams recording1092.10s 2026-05-09T13:46:03Z

Google writes no creation timestamp into its MP4s. Microsoft does. So the duration + creation-time pair works on Teams and collapses to duration alone on Meet — which is the platform this was being designed for. One ffprobe run, ten seconds, and it retires a plan that read as obviously correct.

The 99.9% case, stated 17 Aug 2026

“Download manually, rename to make a nice clean name, drop in a folder, process. Two days later, with more interviews, you go the file-importer route and we are smart enough to say ‘you got those already in this project’.”

Note what that rules out: the per-project record cannot help here. Those files never came through the importer, so there is no row id to have recorded. The record answers a different question — what we fetched, and what we were asked for and never got. For the case the researcher actually lives in, the duration scan is the entire answer, not a fallback.

Route inAnswered byMeetTeams
By hand, renamed
the 99.9% case
duration scan of the destination folder works — sub-second, encoder=Google corroborates works — plus creation_time
Through the importerthe per-project record, by row id exact exact
By hand, then trimmed unrecognisable creation_time, if the trim kept it

What is actually being prevented: p2 and p3 are the same person

Not a wasted download. A false finding. Two copies of one interview become two sessions, two participants, and two sets of the same quotes — and the quote clustering then puts those identical quotes together, where agreement between two participants is exactly what a researcher reads as corroboration.

So the duplicate does not look like a mistake. It looks like a stronger result. Theme prevalence reads “4 of 6 participants” for something three of five said; the analysis page's signal-concentration metrics inherit the same inflation; and nothing anywhere is wrong-looking enough to prompt a check. This is the same class as every other hazard in this document — a defect whose output is indistinguishable from a good one — and it is the reason the guard is worth building before anything else on the media side.

Which settles how it presents: as .imported, not as a warning. When the durations match, the honest claim is simply this project already has this interview — the same status, the same ticked-and-dead checkbox, as a recording we fetched ourselves. No new state, no new words, no “are you sure?”. ImportRowState.imported gains its second producer and the researcher sees green on four of six.

Worth knowing, not solving here: the import window would be the only defence. The invariant is one interview, one participant, and it is a property of the study rather than of the importer — two same-content files landing by any route (two hand-drops, a restore, a colleague's copy) double a participant just as silently. Guarding at import is the cheap, early, well-placed catch; it is not the same as the pipeline holding the invariant. Separate conversation, but the assumption that this fix covers it would be wrong.

So the cheap half ships first and alone. A pure matcher — rows with durations against local media files with durations — plus one folder scan whenever the destination popup changes, feeding ImportRowState.imported, which is written, translated into 21 languages, and has never had a producer. No schema, no persistence, no privacy question. The record is a separate and larger thing for the jobs only it can do.

Trimming is out of scope, decided. “If the user bothers to trim the video — whatever.” Audio fingerprinting would solve it and is IP-protection technology: a dependency, a compute budget and a capability, for one cell of that table. A hand-trimmed Meet recording is unrecognisable, said once, and never reached for again.

One thing to check before wiring it: how close is the container's duration to the API's? Google's endTime − startTime is wall-clock between recording events; the container's is media length. They should agree closely and may not agree exactly, so the comparison wants a small tolerance — and the tolerance is free to widen, because a study's interviews differ by minutes, not seconds.

Whatever the signal, the response is a note — never a block

A researcher may legitimately want both — the trimmed working copy and the full original. So the finding is “this project may already contain this meeting”, naming the file it resembles, with the checkbox still live. A false positive that mentions costs a glance; a false positive that refuses costs them a recording and teaches them the feature is wrong about their own folder.

And the habit is itself a finding. Trimming before import is manual work done because there is nowhere else to do it. That is a different conversation from this document, but it is worth noticing that the duplicate hazard here is partly self-inflicted by a missing capability rather than by the import path.

And it belongs in the project, not in the app. The project folder is what the researcher owns, moves between machines and hands to a colleague — the same reasoning that puts re-identification keys under .bristlenose/ rather than in preferences. A record kept in app state would be right until the first time somebody opened the folder somewhere else.

Sizing it honestly: this is the largest single item in this document. It is a new persisted artefact with a schema, a migration story and a privacy question (it names meetings and platforms, so it is project-private and never rides along in an export). Everything else here is a guard, a disabled control or a word. Worth doing once, and worth not doing by accident as a side-effect of fixing the checkbox.

12 — The order I would fix them in

#FixWhy here
1A per-project record of what landed §11. Answers four flagged rows at once — the shortfall that outlives the window, “do I already have this”, the quit-mid-batch hole, and the vanished recording. Everything else is polish until a closed window can still tell the truth. Also the biggest item here: schema, migration, and project-private by construction.
2The ring and “3 of 4” Same seam, and it is what makes closing the window safe in the first place.
3Monitor mode + the platform lock Closes a reachable defect — three clicks to an unstoppable invisible batch — and makes four inert-but-live controls honest.
4Refresh localState after a fetch Observed, reproducible, and it invites a re-download of a file we just said we have.
5Free-space precheck before the first byte Rides in free on the size sweep, and turns a mid-batch failure into a decision.
6Acknowledge a Picker deselection Small, silent, and it makes two other numbers wrong.
7Decide .notResolved Cheap either way, and a state nothing can enter is a lie in 21 languages.
8Durable batch intent The quit-mid-batch hole. Real, rarer, and needs a design rather than a patch.