# Recly **Whichever device records, a fixed process (original upload · transcription · result upload) runs when the recording ends.** This one document is Recly as it is now. It is neither a history nor a lane log; it states, as they are, the contracts and rules the current code keeps. The machine-readable source of truth is `spec/*.json` (the recording processing settings, meta and transcript schemas); this document defines what those schemas mean and the rules the schemas do not hold. **Section numbers are a contract.** A `docs/NN "subsection"` citation in a code comment points to §NN of this document and that subsection heading. Section numbers and subsection headings do not change without a reason. | Section | Contents | |---|---| | [0](#0-product-definition--rules-formerly-docs00) | Product definition · principles · rules (formerly ADRs) | | [1](#1-architecture-formerly-docs01) | Architecture | | [2](#2-workflow-contract-formerly-docs02) | Workflow contract — the internal step model only (the user document was retired, 2026-09-24) | | [3](#3-recording--storage-formerly-docs03) | Recording · storage · retention · deletion | | [4](#4-webhooks-formerly-docs04) | Webhooks — retired (2026-09-24) | | [5](#5-workflow-storage--secrets-formerly-docs05) | Recording processing settings · secrets (workflow storage and export/import were retired, 2026-09-24) | | [6](#6-authentication-formerly-docs06) | Google authentication | | [7](#7-i18n-formerly-docs07) | Localization | | [8](#8-transcription-formerly-docs08) | `transcribe` | | [9](#9-design-system-blueprint-formerly-docs09) | Design system "Blueprint" | | [10](#10-core-kmp-formerly-docs10) | Shared core (KMP) | | [11](#11-android--wear-formerly-docs11) | Android phone · Galaxy Watch | | [12](#12-macos-formerly-docs12) | macOS | | [13](#13-ios--watchos-formerly-docs13) | iPhone · Apple Watch | | [14](#14-windows-formerly-docs14) | Windows | | [15](#15-privacy--data-flows-formerly-docs15) | Privacy · data flows | | [16](#16-personas--pricing-formerly-docs16--proposal-undecided) | Personas · pricing (proposal, undecided) | | [20](#20-verification-status-formerly-docs20) | Verification status | | [21](#21-conventions-formerly-docs21) | Conventions — what was left on purpose | | [Open decisions](#open-decisions) | Remaining user decisions | --- ## 0. Product definition · rules (formerly docs/00) **Applied 2026-09-24:** A recording runs only the fixed processing plan (§5 "Fixed processing settings") — `drive.upload` → `transcribe` or `local.transcribe` → `transcript.publish` (→ `notes.organize` since 2026-10-27, §8 "Organizing"). User-editable workflows (the document, the editor, the picker, the per-device default pointer, export/import, schema 1/2 migration, the watch workflow list) and webhooks are **retired**, and there is no compatibility layer that reads earlier settings. In the rules table below, entries that decision invalidated are marked "retired". The phone screen has three tabs: Record, List, Settings. ### One-line definition A recorder that leaves the original recordings and the transcription results in **the user's own Google Drive**. iPhone and Mac can choose the user's own iCloud instead of Drive, and iPhone, Mac, Windows and the Android phone a local folder the user picks (ADR-024). Processing runs in the order recording, original upload, transcription, result upload. The watch handles recording and transfer to the phone; the phone and desktop handle post-processing. ### Principles 1. **The device that recorded runs it.** The watch hands off to the phone. **There is no Recly server.** 2. **Settings belong to the device.** The recording processing settings, transcription keys and preserved earlier definitions are all device-local and are not synchronized. Moving them between devices is the settings export/import (§5). Only recordings and result files go up to Drive — and those are the **recording list** the devices share (ADR-023, §3 "Recordings from other devices"). Runtime state such as retries and upload sessions lives only locally on each device. 3. **The original is not deleted before the ack.** 4. **The files in Drive are the interface.** Transcription is a choice of local, external API or OFF; from summarization on, the work belongs to the user's agent (§16, `skills/recly-notes/`) — except a recording's notes, which the user's own ChatGPT plan organizes for a user who signed in and agreed, and organizes again when the user asks (§8 "Organizing", 2026-10-27; the summary on request of 2026-10-09 was folded into them on 2026-10-28). The default is "my Drive + my automation". Drive is the source archive the app writes, so agents only read it. The repository's two skills are **examples** — one way to write minutes and keep those notes and later edits in the user's Notion (`skills/recly-notion/`) — and they assume the user adapts them to their own format and note app or builds their own (2026-10-01). 5. **There is no stealth mode.** Recording is always indicated while it runs, and on desktop the order is detect → confirm → record. ### Rules (formerly ADRs) These are the decisions code comments cite as `ADR-0NN`. The numbers are a stable contract and stay; the content states only the current rule. | Number | Rule | |---|---| | ADR-001 | The device records and the original goes to the user's Drive. Transcription and delivery are **optional steps the user puts into their own workflow**, not fixed post-processing. Summarization is not in the pipeline — the pipeline takes on only what a subscription agent cannot do (STT), and what an agent can do (text summarization) is done with an agent skill (`skills/recly-notes/`). (Revised 2026-09-24: user workflows and webhooks retired — transcription is the local, external API or OFF choice in the recording processing settings, §5) (Revised 2026-10-09: one exception — a summary of one recording, asked for in its detail, through the user's own ChatGPT plan with Sign in with ChatGPT. The subscription comes into the app rather than the app holding a key, so the rule "use what the user already pays for, no server" holds. It is never a step of the processing plan and never automatic, and there is no summarization with an API key; §8 "AI requests", §15 §10) (Revised 2026-10-15: a second exception — a recording's title and its speakers' names from the same plan, asked for one recording at a time: ticked in the title sheet after Stop, the publication of its transcript first asks for them, so they go up with it, and the recording's More menu asks for them later (§8 "Titles and speaker names"). Never for a recording nobody asked for: the ticked box is the user's express consent to that background use) (Revised 2026-10-27: a third exception — a recording's notes, its decisions, to-dos and open questions, organized in the background through the same plan for a user who signed in and agreed once: each part while it is recorded, and its transcript after Stop as the processing plan's last step, `notes.organize`, a no-op for anyone else. The consent the sign-in shows — or the question at the first recording of a user signed in before — is the user's express consent to that background use, which OpenAI's terms require; there is no switch beyond it but signing out (§8 "Organizing", §15 §10)) (Revised 2026-10-28: the first exception folded into the third — there is no summary on request any more; a recording's notes take its place, in one shape the user adjusts (notes instructions and up to three sections of their own) and edits, and `Summarize again` runs them once more on request (§8 "AI requests", §8 "Organizing")) (Revised 2026-10-29: the second exception moved under the third's consent — every transcript a signed-in, agreed user's device publishes first asks for its missing title and its speakers' names, with no box in the title sheet, whose empty title field then reads `Filled in automatically`; the More menu still asks on request (§8 "Titles and speaker names")) | | ADR-002 | The Android phone, iPhone, macOS and Windows run workflows. Galaxy Watch hands files to the phone over the Data Layer, Apple Watch over WatchConnectivity. **The watches have no authentication or network code.** Standalone upload from a cellular watch is out of scope | | ADR-003 | When the phone and the watch record at the same time, there are two files and they are not linked. `recordingId` is an independent per-device ULID, and there is no session linking (session id) | | ADR-004 | The workflow engine, Drive client, webhooks, sync and job queue live in a single Kotlin Multiplatform `core/`. Workflow logic is not written twice. (Webhooks retired 2026-09-24) | | ADR-005 | Shells: Android/Wear are Kotlin + Compose, iOS · watchOS · macOS are SwiftUI + KMP XCFramework, Windows is Compose Desktop (JVM) + a Rust capture helper | | ADR-006 | Audio is AAC-LC `.m4a`, 16 kHz mono 32 kbps, nominal 300-second segments — the parts a recording is transcribed in while it records (2026-10-29, §8 "Organizing"). Mobile and watch have one track, `mono`; desktop has three tracks, `mic` · `sys` · `mix`. A file the user imports is transcoded on the device into the same format, one `mono` track (2026-10-07, §3 "Naming rules") | | ADR-007 | Workflow definitions are a single **device-local document**, with no backend and no sync. Moving them between devices is the settings export/import (§5) — the file format is the document serialization as is (retired 2026-09-24) | | ADR-008 | Workflow JSON carries only the secret's **name** (`secretRef`); the value is in each device's secure storage. On a device without the value, that step fails with `MISSING_SECRET`. Values are not synchronized and never go into an export file — keys are entered on each device. (2026-09-24: workflow JSON retired — the same rule applies to `secretRef` in the recording processing settings) | | ADR-009 | The only OAuth scope is `drive.file`, and the consent screen is in Production. The full `drive` scope is never requested | | ADR-010 | Webhook signatures follow Standard Webhooks as is (retired 2026-09-24) | | ADR-011 | **No bot joins the meeting.** Detection uses microphone use and meeting apps, and the user records with one tap. There is no automatic recording. There are no per-participant streams; two tracks, "me vs. the other side", are the limit | | ADR-012 | There are three step types: `drive.upload` · `webhook` · `transcribe` (`schema: 3`). There is no `deliver.*` (Notion · Telegram). (2026-09-24: `webhook` retired. The user picks no steps; the fixed plan builds `drive.upload` · `transcribe` or `local.transcribe` · `transcript.publish`, and since 2026-10-27 `notes.organize` after them, §8 "Organizing") | | ADR-013 | There are no per-step conditions (`if`). There is only the one workflow-level `minDurationSec` | | ADR-014 | The Drive layout is one folder per recording — the part files and `meta.json` under `{folder}/{base}/` | | ADR-015 | Resumable upload **separates the protocol (pure functions in the core) from the transport (the platform)**. Apple replaces the transport with a background `URLSession` | | ADR-016 | Each device picks **one workflow in use** — a pointer that exists only locally and is not synchronized, and every recording on this device (manual, meeting detection, watch) runs it. Picking in the picker is what changes this pointer; there is no temporary per-recording choice (2026-09-02: the earlier "Default (name)" entry and the "default" wording were retired — the same workflow showed up as two entries). The shared document has no `enabled` · `isDefault` · `trigger.sources`, and there is neither a source filter nor a latest-`updatedAt` fallback. If there is no selection, or the workflow it points to is gone, nothing runs and the shell asks "Choose a workflow". The workflow in use can be deleted only after another workflow is selected first (retired 2026-09-24) | | ADR-017 | The local original is deleted **only when the upload succeeded**. A webhook-only workflow, or a failed upload passed over with `continue`, keeps the original. **Revised 2026-09-03**: even after a successful upload it stays for **7 days** (fixed value, no settings UI) — local parts are a cache with an expiry. The retention sweep on every job pass deletes the parts of a recording that meets "all jobs DONE + every upload succeeded + 7 days past both the file mtime and the last DONE time". When the detail screen plays a part that is not local, it downloads the part again from Drive by the upload output's `fileId` and stores it under the same name (sha256 verified), and that part gets another 7 days. A part that was never uploaded is kept forever | | ADR-018 | The product name is **Recly** (identifier `recly`). Android `app.recly` · Kotlin package `recly.core`, Apple bundles `app.recly`/`app.recly.watch`/`app.recly.mac`, XCFramework `ReclyCore`. Contracts the user does not see (file name `{base}`, webhook `user-agent: rec/…`, log events `rec.*`, device storage paths `files/rec` · `rec.db`, Data Layer paths `/rec/…`) stay `rec` | | ADR-019 | Windows encoding uses the **bundled ffmpeg** (LGPL dynamic linking, unmodified, separate process). The Media Foundation AAC MFT accepts only 44.1/48 kHz input and 96 kbps or higher output, so it cannot produce ADR-006's 16 kHz · 32 kbps. The MF path remains as `--encoder mf` and is not the default | | ADR-020 | The top of the default Drive folder is **`recly/`** — the `drive.upload` `folder` default `recly/{{yyyy}}/{{yyyy}}-{{MM}}`, and `recly/memo/{{yyyy}}-{{MM}}` for the "Memo" default workflow | | ADR-021 | In `transcribe` (STT + speaker diarization), **the device running the job calls the provider API directly with the user's key**. There is no intermediate server, relay or callback URL | | ADR-022 | **There is no telemetry.** No analytics, usage statistics, crash reporting, remote log collection, remote config, A/B testing or advertising identifiers. No shell has a Firebase/Crashlytics/Sentry/AppCenter-family dependency. Logs stay only in the device-local platform log and leave the device only when the user exports them | | ADR-023 | **Drive is the source of truth for the recording list** (2026-09-04). Recordings uploaded by another device on the same account also appear in this device's list — with no separate index file or server, the app lists in Drive the `{base}/` folders (ADR-014) it stamped with a `recordingId` and reads their `meta.json` (§3 "Recordings from other devices"). Such a row has no Job and no original on this device; playback downloads from Drive and caches. A folder that has no `meta.json` yet also appears in the list — as a provisional "Uploading on another device" row. When it disappears from Drive, the row disappears too. Rows this device created itself are left alone whatever Drive says. A device that chose iCloud lists the iCloud folder by the same rules (ADR-024). A local folder is never listed | | ADR-024 | **Each device picks its storage** (2026-10-02). The default is Google Drive; iPhone and Mac can switch to iCloud (the app's iCloud Drive folder) in Settings, and iPhone, Mac, Windows and the Android phone to a local folder the user picks (2026-10-03). The watches hand their recordings to their phone, which uploads them to its own storage. A recording uploads to the storage fixed when it started, and an uploaded recording is not moved. iCloud adds one folder-properties file to the same layout as Drive, and an upload ends when iCloud says it has received it; a local folder gets the same layout plus a Markdown transcript, is this device's alone (never listed), and an upload ends with the copy (§3 "Storage location") | Reversing these rules does not end with editing this document. Reversing ADR-022 in particular means also changing `docs/policy/privacy-policy.md`, the Play "Data safety" form and the App Store privacy labels. --- ## 1. Architecture (formerly docs/01) ### System picture ``` ┌─ Capture ─────────────────────────────────────────────────────────────────┐ │ Galaxy Watch Android phone Apple Watch iPhone macOS Windows │ │ MediaRecorder MediaRecorder AVAudioEngine AVAudioEngine CoreAudio tap WASAPI │ └──────┬─────────────┬───────────┬────────────┬──────────┬────────────┬─────┘ │ Data Layer │ │ WCSession │ │ │ │ ChannelClient │ transferFile │ │ ▼ │ ▼ │ │ │ (phone receives·ack) │ (phone receives·ack) │ │ │ ▼ ▼ ▼ ▼ ┌─ Executor (device-local) ──────────────────────────────────────────────────┐ │ Job(recording × workflow) → step 1 → step 2 → … ; state in local SQLite │ │ KMP core: workflow · job · drive · transcribe · sync · storage │ └───────────────┬───────────────────────────────────────────┬────────────────┘ │ │ ▼ ▼ Google Drive {folder}/{base}/ parts + meta.json STT provider (for external API transcription) or iCloud (iPhone·Mac) or a local folder (iPhone·Mac·Windows·Android, ADR-024) — same layout ``` ### Components | Component | Location | Responsibility | |---|---|---| | Recorder | Per platform | Microphone capture (system audio only in desktop meeting mode), writing segment files, detecting silenced input, writing `meta.json` | | Transfer | Wear/watchOS ↔ phone | Transferring parts and meta, sha256 verification, ack, deletion on the watch side after the ack | | Core | `core/` KMP | Recording processing settings and compiling the fixed plan, job queue, step execution, Drive resumable, transcription provider adapters, settings export/import | | Scheduler adapter | Per platform | Calls the core's `runDueJobs()` from WorkManager / background URLSession + BGTask / a tray or menu bar timer | | Auth adapter | Phone · desktop | Google OAuth, supplying the access token (`TokenProvider`) | | UI | Per platform | Recording start/stop, recording processing settings (phone · desktop), recording list and status | ### Recording lifecycle ``` start ──► Recording{id, source, workflowId, startedAt} (local DB, status=recording) │ parts written: p001, p002 … (sha256 and bytes recorded per segment) │ meta.json updated (at every segment boundary) │ each part filed → transcribed on its own and its new notes added, for a user signed in to │ ChatGPT who agreed (provisional notes, §8 "Organizing"; a watch's on the phone, as its parts arrive) stop ───► finalize: endedAt, durationSec, status=finalized │ ├─ phone·desktop ─► Job created {recordingId, workflowId, steps[] state=PENDING} │ Scheduler adapter calls runDueJobs() │ steps run in order · each step's state persisted · on failure, retry after backoff │ (the fixed plan: upload → transcribe → publish → notes.organize — the final notes, after │ the transcript is out, a no-op unless organizing is on; §5, §8 "Organizing") │ all steps finished → Job DONE │ → retention rule (ADR-017): once all of this recording's Jobs are DONE and one of them │ has SUCCEEDED every drive.upload, **after 7 days** the retention sweep on each pass │ deletes the local parts (otherwise kept + "Not uploaded" in the list; meta.json and │ the DB row are always kept). Playback in the detail screen downloads a deleted part │ again from Drive and caches it for 7 days │ └─ watch ──────► Transfer queue {recordingId, parts[]} — each part queued as soon as it is filed, while recording when the phone connects, transfer per part (the in-progress meta ahead of it) → the phone verifies sha256, then acks, and organizes the part while the watch still records after stop: the last part and meta.json → the phone creates the Job (same as above) → ack-meta → the watch deletes ``` Invariants: - Part files are not deleted until every Job of the same recording is DONE and one of them has succeeded in all of its `drive.upload` steps (ADR-017, §3 Retention · deletion). - Watch parts are not deleted before the phone's ack. - A `Job` is unique by `(recordingId, workflowId)`. Running a workflow again on the same recording does not create a new Job; it resumes the existing Job from its failed step. - A start needs 200 MB free on the device and a running recording 50 MB. Under the first nothing is created; under the second the recording is stopped the normal way and handed over like any other (§3 "Free storage on the device"). ### Long recordings — watchdog · reminders · battery stop · waiting to resume 2026-10-26 (GitHub issue #20). A recording that runs for hours must not stop silently. There is no server to notice, so every notice is an alarm or a local notification the recording device raises for itself. v1 constants, not settings. 2026-10-27: the same rules on all six platforms; the two that are pure logic — the silence rule and the battery rule — are the core's (`SilenceReminder`, `BatteryStop`, §10 "Shared rules for the shells"), and every shell calls them. | Rule | What happens | Galaxy Watch · Android phone | Apple Watch · iPhone | Mac | Windows | |---|---|---|---|---|---| | **Watchdog** | If the recording process dies, `Recording may have stopped` / `Open Recly to check.` Tap → the app (Windows: the click only dismisses it — the helper does not start the app). On the phones, watches and the Mac a notice is scheduled `2 × segmentSec` ahead (10 min at 300 s) when the recording starts and pushed back at every part boundary; the stop, a fatal failure and the wait below withdraw it, and a dead process cannot, so it fires. Windows records in a separate process, the capture helper, which outlives the app: when the app dies under a recording — no `stop`, and the app's end of the helper's stdout gone with its process (a write fails) — the helper closes the open segment (the next launch's recovery files it), raises the same notice at once and then exits (§14 "Long recordings") | ✓ alarm | ✓ | ✓ | ✓ the helper | | **Reminder** | 2026-10-27: when nobody has spoken for 10 minutes while the recording runs — a recording forgotten or walked away from — `Still recording` / `No one has spoken for 10 minutes.`, posted at once, with one `Stop` that stops like the ongoing notification's or Live Activity's stop — no title is asked, the recording is handed over as it is. Once per silence: after speech, the next 10 silent minutes remind again; never during a conversation. Heard by the recording process itself from the live strip's 0.1 s peak windows — on Windows the capture helper's `level` lines, the strip's windows there (the core's `SilenceReminder`): the floor is the quietest window of the last minute; a window sounds at ≥ floor + 10 dB and ≥ about −55 dBFS, so no microphone gain is assumed; speech is ≥ 2 s of sounding windows within the last 10 s, and only speech resets the 10 minutes (a cough, a door or a chime is shorter). A steady noise — a fan, an air conditioner — becomes the floor within a minute and counts as silence. A window of digital zero (a microphone taken away, e.g. by a call) neither counts nor resets. A reminder's Stop only stops the recording it was posted for. On the desktops the windows are the `mix` track's, so the other side of a call speaking is speech | ✓ | ✓ | ✓ | ✓ a tray balloon **without** `Stop` — an AWT balloon has no buttons; clicking it opens the tray popup, where the stop is | | **Battery stop** | Battery ≤ 5 % and not charging → the recording is stopped the same way (the open part is filed, the recording handed over, no title asked), then `Recording stopped: battery low`. Not for a recording that **started** at ≤ 5 % unplugged: that was the user's choice, and stopping it at once would leave next to nothing. An unreadable level stops nothing (the core's `BatteryStop`, fed a whole percent). Android reads the sticky `ACTION_BATTERY_CHANGED` at every change; the others every 60 s — `UIDevice` / `WKInterfaceDevice`, the Mac's IOKit power sources (`IOPSCopyPowerSourcesInfo`), Windows' `GetSystemPowerStatus` | ✓ | ✓ | ✓ laptops | ✓ laptops | | **Waiting to resume** | When a restart after an interruption runs out of its 10 attempts, the recording does not end: the open part is filed where it stands (on the Apple Watch it goes to the phone like any part filed while recording, §3 "Watch → phone transfer contract"), the watchdog and a reminder still shown are withdrawn, `Recording paused` / `Open Recly to keep recording.` is posted. When the app next becomes active it reattaches once; success writes one `gaps` range over the whole wait (reason unchanged) and re-arms the watchdog, failure keeps waiting. No audio arrives while it waits, so the silence reminder's 10 minutes do not run. A stop while waiting finalizes normally. No timeout — a suspended iOS app runs no timers | — | ✓ | — | — | - **No sound**: a chime would be recorded into the recording it is about. Android posts on its own channel (vibration, no sound); iPhone and Mac post silently. The Apple Watch has no haptic-only notification, so the three notices that fire only when nothing is recorded (watchdog, `Recording paused`, battery) use the default sound — in silent mode a wrist tap; the reminder stays silent. Windows cannot be silent: an AWT balloon has no such option, so the reminder plays the system's notification sound unless the user turned it off, and that sound may be in the `sys` and `mix` tracks; the battery balloon and the helper's watchdog come when nothing is being recorded. - **Notification permission** is asked at the first recording start on iPhone, Apple Watch and Mac (after the consent reminder, §12 "Consent reminder · jurisdiction notices"); without it the notices are not shown and the recording is unaffected. Android uses the app's notification permission; Windows asks for none (a tray balloon needs none, though the system's notification settings can hide it). - **Not covered**: Android alarms do not survive a reboot or a force stop (some vendors' swipe-away is one). On Windows a helper that dies with the app — ended together from Task Manager, a power loss — says nothing, and an app that hangs without dying is not noticed. A notice scheduled before a language change keeps the old language (on Windows: the language at the recording's start). - Log events: `rec.watchdog.fired` (Android; an Apple notice fires in a process that is gone; the Windows helper writes it to its stderr, which nobody reads once the app is gone), `rec.recorder.batteryStop` (`recordingId`, `level` in whole percent — every platform), `rec.recorder.nobodySpeaking` (`recordingId`; Windows, when the reminder is due), `rec.notice.noTray` · `rec.notice.failed` (`notice`; Windows, a balloon that could not go up), `rec.recorder.awaitingResume` (`recordingId`, `reason`, `error`); a resume logs the existing `rec.recorder.restarted`. - Not verified on a device yet: delivery after a real process death, the silence reminder against real rooms and microphones, the battery stop on Apple (the Mac's IOKit reading included), and the iOS background-to-foreground resume. Emulators confirmed the Android watchdog alarm (armed, pushed back, withdrawn) and the battery stop on phone and Wear (2026-10-26). Windows (2026-10-27): only the development host has run any of it — the rules, the helper's decision (`cargo test`, against the real binary with its fake source) and the shell's wiring over the fake helper; the helper's balloon is only type-checked against the Windows target, and `GetSystemPowerStatus`, the AWT balloons and whether Windows keeps the helper's notice after it exits need a Windows PC. Where a platform does not have a rule, the reason: - **Battery stop on a desktop** (a Mac with no internal battery, a Windows PC whose `BatteryFlag` says "no system battery"): nothing runs down. The first reading says so and the battery is not read again for that recording. - **Waiting to resume on Android**: Android keeps its recorder through a call — the system silences the input (`isClientSilenced`, recorded as `silenced`, §11 `SilenceMonitor`) and it resumes by itself — so there is no restart to run out of attempts. - **Waiting to resume on the Mac and Windows**: the desktops are never suspended in the background, so a restart that fails there is a real failure (the input is gone), and the recording ends as before (§12 "Capture pipeline", §14 "Capture"). ### Identifiers · time - `recordingId` · `workflowId` · `jobId` · `stepRunId`: ULID (26 characters, Crockford base32). Generated in the core. - `deviceId`: a UUID v4 generated at install, kept in secure storage (`{dataDir}/device.id` on macOS only). A reinstall gets a new value. - All times are UTC ISO-8601 (`2026-08-26T01:00:00.000Z`). The time zone for display is the meta's `timezone`. - Times in file names are compact UTC (`20260826T010000Z`). ### Repository layout ``` rec/ core/ KMP module (:core) — §10 android/ app/ phone app wear/ Galaxy Watch app recording/ MediaRecorder-based SegmentedRecorder + RecorderService (shared by phone and watch) datalayer/ phone↔watch Data Layer paths and JSON contract (used by both sides) apple/ Rec.xcworkspace RecKit/ Swift package: Recorder, MacCapture, Detect, Transfer, Auth, Transport, Workflow, CoreBridge RecMac/ RecPhone/ RecWatch/ windows/ app/ Compose Desktop capture-helper/ Rust (wasapi) — capture and detection, reports status as JSON lines spec/ JSON Schema + examples (the contract) scripts/ icon rendering docs/ this document + privacy policy + icon master ``` Build tools: the Gradle wrapper (`core` · `android:*` · `windows/app`), Xcode (`apple`), Cargo (`windows/capture-helper`), Node (`spec` validation). The root `settings.gradle.kts` includes `:core`, `:android:*` and `:windows:app`, and `apple` references the `:core:assembleXCFramework` output as a SwiftPM binary target. ### The core ↔ shell boundary **What the shells give the core** (`CoreDeps`): - `SecureStore` — reading/writing tokens and secrets (per namespace) - `TokenProvider` — returns a valid access token (refreshing on expiry is the shell's job) - `FileSystem` — okio `FileSystem` + the app data directory - `Transport` — Ktor by default. Apple can replace it with a background URLSession implementation (ADR-015) - `UbiquityContainer` — the app's iCloud container (optional). Only iPhone and Mac builds signed with the iCloud entitlement provide it (§3 "Storage location") - `LocalFolder` — the local folder the user picked (optional). The Mac and Windows provide it through the core's `PathFolder`, the iPhone through a bookmark of the folder picked in Files (`PickedFolder`), the Android phone through the Storage Access Framework (§3 "Storage location") - `AudioTools` — `concat` (lossless remux of parts, §8) - `Clock`, `Logger`, `DeviceInfo{deviceId, platform, name}`, the `io` dispatcher **What the core gives the shells** (`ReclyCore(deps, driverFactory)` — the shell opens the SQLDelight driver and passes it in): - `recordings` — registering a recording, adding parts, finalize, list, `delete` - `processingSettings` — reading, saving and importing the recording processing settings (§5 "Fixed processing settings"). The old `workflows` (document · selection rules) was retired (2026-09-24) - `jobs` — `enqueue(recordingId, chosenWorkflowId?)`, `runDueJobs(now)`, `retry(jobId)`, observing status - `secrets` — `SecretsRepository.put/delete/get/names` (the **only** entry point for writing values, §5) - `secretSync` — `setup`/`disable`/`status` - `transfer` — helpers for watch→phone receiver-side verification and ack (sending is a platform API, so it is in the shell) - `disconnect(alsoDeleteRecordings)` — the local-cleanup half of disconnecting - (2026-10-07) `importAudio` · `dropAbandonedImport` (§3 "Naming rules"), `retranscribe` (§10 "Re-transcription"), `editTranscript` (§8 "Editing"), `exportFile` (§8 "Exports"), `search` (§10 "Search"), and the pure rules every shell applies the same way — `SpeakerTurns`, `SilenceRanges`, and (2026-10-27) `SilenceReminder`, `BatteryStop` (§10 "Shared rules for the shells") - (2026-11-04) `summaries.undoAsk` (§8 "Ask"), `transcriptVersions` · `restoreTranscript` (§8 "Versions") and `TranscriptDocument.segmentRanges` (§9 "Transcript reader") - (2026-10-27) `notes` — a recording's organized notes: `observe`, `get`, `deleteItem`, and the consent (`needsConsent`, `observeNeedsConsent`, `consent`) (§8 "Organizing"); `StepReport.notesOnly` for a job with only its notes left The shells have only audio capture, transfer APIs, the scheduler and the UI. **Workflow semantics never live in a shell.** Every `suspend` entry point exposed to the shells carries `@Throws(Throwable::class)` — Kotlin/Native kills the process when an undeclared exception escapes an exported suspend function, even under a Swift `try?`. That includes a member of a core interface that Swift calls: `Transport.execute`, which the phone's `BackgroundTransport` calls back into, so a request that fails reaches the core instead of ending the app. A flow the shells observe never fails, for the same reason — a Swift `for await` over a flow that throws ends the app, and so does a throw in an Android `viewModelScope`. A read that fails emits nothing, or what the flow says a failed read is (`UNAVAILABLE` for `observeResults`, no grants for `transferConsents.observe`), and the next change reads again. --- ## 2. Workflow contract (formerly docs/02) > **Retired (2026-09-24)**: The user-editable workflow document (the local document, the export file `recly-workflows.json`, > schema 1..3) and its schema and example (`spec/workflow.schema.json`, `spec/examples/workflows.json`) were removed. A > recording runs only the fixed processing plan (§5 "Fixed processing settings"). The `Workflow`/`Step` model remains only as > the **internal representation** of that plan and of the job snapshot (`job.workflow_json`); it is neither a user document > nor a public schema. The common step fields, the meaning of `drive.upload` and `transcribe`, and the template variables > apply to that internal plan unchanged. The subsections on the document, selection and webhooks are a record. ### Document structure > **Retired (2026-09-24)**: There is no user document (§2 preamble). What follows is a record. ```json { "schema": 2, "revision": 12, "updatedAt": "2026-08-26T01:00:00.000Z", "updatedBy": "3f1c…-deviceId", "workflows": [ …workflow ] } ``` | Field | Meaning | |---|---| | `schema` | Currently **3**. When the parser meets a document **higher** than the app supports, it returns `Invalid(UnsupportedSchema)` and the import writes nothing. The UI shows "update the app". Old documents at `MIN_SCHEMA` (=1)..2 (stored locally or imported files) are read under the current rules and saved at the current schema (§5 Schema) | | `revision` | +1 on every write. Merge decisions use each workflow's `updatedAt`; `revision` is for diagnostics | | `updatedBy` | The `deviceId` of the device that wrote last | ### Workflows > **2026-09-24**: There are no user-made workflows. The fixed plan's `Workflow` is an internal value with a fixed id > (`ProcessingPlan.ID`), `name`, `minDurationSec` (`storage.minDurationSec` in the recording processing settings) and > `steps`. The description of the device pointer (ADR-016) below is retired. ```json { "id": "01J9ABCDEF0123456789ABCDEF", "name": "Meeting", "updatedAt": "2026-08-26T01:00:00.000Z", "minDurationSec": 30, "steps": [ …step ] } ``` | Field | Meaning | |---|---| | `name` | 1–40 characters. Used in the UI as the profile name and shown as is on the watch button | | `minDurationSec` | A recording shorter than this creates no Job and ends as `SKIPPED_SHORT` (the files are kept locally. 2026-09-02: the list's "Upload now" is gone, so there is no manual upload path — if a length should be uploaded, lower this value). Default 0 | | `steps` | 1–10, run in order | The document holds **only definitions**. Which workflow this device uses by default is a device-local pointer and is not synchronized (ADR-016, principle 2 "one definition, per-device state"). `enabled` · `isDefault` · `trigger` (=`sources`) disappeared in schema 3 — those fields in old documents are discarded on read, and only `trigger.minDurationSec` moves up (§5 Schema). ### Steps Common fields: | Field | Default | Meaning | |---|---|---| | `id` | required | `^[a-z][a-z0-9_]{0,31}$`, unique within the workflow | | `type` | required | `drive.upload` \| `transcribe` \| `local.transcribe` \| `transcript.publish` \| `notes.organize` (2026-10-27, §8 "Organizing"; `webhook` retired 2026-09-24) | | `onError` | `abort` | `abort`: later steps do not run, Job FAILED. `continue`: only this step is left FAILED and the next step proceeds | | `retry.maxAttempts` | 8 | 1–20 | | `retry.initialDelaySec` | 30 | Starting value of the exponential backoff | | `retry.maxDelaySec` | 3600 | Backoff cap. Jitter ±20% | #### `drive.upload` ```json { "id": "up", "type": "drive.upload", "folder": "recly/{{yyyy}}/{{yyyy}}-{{MM}}", "includeMeta": true } ``` - `folder` — a path template relative to the My Drive root. Missing folders are created. Default `recly/{{yyyy}}/{{yyyy}}-{{MM}}` (ADR-020). - The actual layout is under `{folder}/{base}/` (ADR-014, §3 Drive layout). - Every track in the recording is uploaded. There is no option to choose. - `includeMeta` — whether to upload `meta.json`. Default true. - Success condition: every part + the meta is in Drive and `md5Checksum` matches the local copy. - Idempotent: if the same `{base}` folder already exists it is reused, and a file with the same name and the same md5 is skipped. #### `webhook` > **Retired (2026-09-24)**: The webhook step was removed (§4). A `webhook` step in a job an earlier build queued does not run either. What follows is a record. ```json { "id": "hook", "type": "webhook", "url": "https://example.com/rec", "secretRef": "hook_main" } ``` - `url` — `https://` required. As an exception, `http://127.0.0.1` and `http://localhost` are allowed (local n8n and the like). - `secretRef` — the signing key name. Without it, the request is sent without signature headers. - Payload, signature and retry rules are in §4. - If a `drive.upload` precedes this step, the payload's `files[].drive` is filled in; otherwise `drive` is null. If an earlier `transcribe` succeeded, its result files also go into `files[]`. #### `transcribe` ```json { "id": "stt", "type": "transcribe", "provider": "rtzr", "secretRef": "rtzr_key", "language": "ko", "diarize": true, "speakers": { "min": 2, "max": 6 } } ``` Fields, providers, result files and polling rules are in §8. Ordering constraint: `transcribe` needs a `drive.upload` before it (validation error `TranscribeNeedsUpload`). ### Template variables > **2026-09-24**: The folder in the recording processing settings (`storage.folder`) cannot use `title` or `workflowName` (§5 "Fixed processing settings"). > **2026-10-09**: `storage.folder` applies only to a local folder; Google Drive and iCloud always use `recly/memo/{{yyyy}}-{{MM}}` (§9 principle 4). Only the names below are allowed inside `{{ }}`. An unknown variable is a validation error. | Variable | Value | |---|---| | `yyyy` `MM` `dd` `HH` `mm` | The recording's `startedAt` converted to the meta's `timezone` | | `title` | The meta `title`, or the workflow `name` if there is none | | `source` | `watch` / `phone` / `desktop` | | `recordingId` | The full ULID | | `workflowName` | The workflow `name` | | `device` | The meta `deviceName` | When used in a path, `/`, `\` and control characters are replaced with `_`, and leading and trailing whitespace is trimmed. ### Selection rules (ADR-016) > **Retired (2026-09-24)**: There is no workflow to select. Every recording runs the fixed plan, and the device pointer, the > seeding guess and the workflow list UI rules are gone. What follows is a record. 1. The `workflowId` the caller passes at recording start (or stop), if it **resolves** in the document. The shells **send this slot empty** (2026-09-02: the temporary per-recording choice is gone from the UI) — it stays as a core rule so tests and harnesses can run a specific workflow. 2. If it is absent or does not resolve, **the workflow in use on this device** (the device pointer), if it resolves. 3. If neither, no Job is created and the recording stays in the list in the `NO_WORKFLOW` state. There is neither a source filter nor a latest-`updatedAt` fallback. The device pointer and the definitions are both local. **The delete button of the workflow in use is disabled**, and no separate explanatory text is attached to the row. Deletion processing also checks the latest device pointer. If there is only one workflow, a new one has to be created and selected before the existing one can be deleted. When an import replaces the document and the selected definition disappears, the pointer is cleared and the list and recording screens show "Choose a workflow". The top of the workflow list shows no count. The create button is `+ New workflow`, offering the symbol and the label as one touch target. The shared secrets list offers only viewing names and deleting; adding is offered only in the transcription and webhook steps inside a workflow. On mobile, a row's delete button and form actions are placed on the right. Desktop applies the same creation path and deletion restriction. The seeding device's initial pointer is a **revocable guess**: when the shell calls `seed(preferred starter)` it takes effect immediately (the first device has to work offline too) but is recorded as a guess, and the first decisive sync settles it — **adopting** the remote document withdraws the guess (a pointer the user already changed is left alone), and this device's push/publish confirms it. Adoption also clears `seededHere`, so a `seed()` after adoption makes no guess at all. A recording queued **before** the default is settled (right after recovery or adoption, for example) is parked as `NO_WORKFLOW` and follows the existing manual-run path — there is no re-queuing. ### Validation rules > **2026-09-24**: With no user document, the document-level rules (local cache protection, unknown fields, migration · > `MigrationBlocked`) and the `webhook.url` scheme rule are retired. Validation of the recording processing settings is > defined by §5 "Fixed processing settings" and `spec/recording-settings.schema.json`. - Passes the schema + workflow `id` is a ULID + `name` 1–40 characters + `minDurationSec >= 0` + step `id`s unique + template variables valid + the `webhook.url` scheme rule + the `transcribe` ordering constraint + `transcribe.provider` is a known value (`UnknownProvider`) + `invokeUrl` only on `clova`. - A document that fails validation **does not overwrite the local cache**; the UI shows an error (this keeps sync from spreading a broken definition). - Unknown fields are ignored but not preserved on rewrite (forward compatibility comes only through the `schema` version). So if an old document has fields the typed model would drop, it is rejected instead of migrated (`MigrationBlocked` in §5 Schema). The exception is fields **known to be retired** — schema 1..2's `enabled` · `isDefault` · `trigger` are dropped by a name the code knows, so they are not a reason to reject (§5 Schema). ### Example > **Retired (2026-09-24)**: `spec/examples/workflows.json` was deleted. The settings examples are `spec/examples/recording-settings*.json`. `spec/examples/workflows.json` — three workflows: "Meeting" (Drive + webhook), "Memo" (Drive only), "Minutes" (Drive + `transcribe`). --- ## 3. Recording · storage (formerly docs/03) Schema: [`spec/recording.meta.schema.json`](../spec/recording.meta.schema.json). ### Audio settings | Item | Value | |---|---| | Codec / container | AAC-LC / `.m4a` (MPEG-4) | | Sample rate | 16,000 Hz (if the device does not support it, fall back to 44,100 Hz and record that in the meta) | | Channels | Mono | | Bitrate | 32 kbps | | Segment | Nominally 300 seconds. Boundaries are lossless — Android uses `setMaxFileSize(300 s × bitrate × 1.07)` + `setNextOutputFile` (`setMaxDuration` is not used because it **stops** the recording instead of rolling over to the next file; the actual length is therefore ± a few % by bytes), Apple swaps the `AVAudioFile`, and on Windows the helper cuts the PCM and hands it to ffmpeg. Each part's `durationSec` is canonical; `audio.segmentSec` is the nominal value. Unavoidable gaps are recorded in the meta's `gaps` | | **Tracks** | Mobile · watch: `mono`. Desktop: `mic`, `sys`, `mix` — each with the settings above, and they share **the same start time and segment boundaries** | Size: 14.4 MB per track per hour, 3.6 MB per segment. Desktop with 3 tracks: 43 MB per hour. Because one segment is 3.6 MB, it also passes the 25 MB upload limit of the OpenAI family. ### Naming rules ``` base = {yyyyMMdd}T{HHmmss}Z_{source}_{first 8 chars of recordingId} part = {base}_p{NNN}_{track}.m4a meta = {base}.meta.json ``` Examples: ``` 20260826T010000Z_watch_01J9ABCD_p001_mono.m4a 20260826T010000Z_desktop_01J9ZZ12_p003_sys.m4a 20260826T010000Z_desktop_01J9ZZ12.meta.json ``` - The time is `startedAt` in UTC. `NNN` starts at 1 and is not numbered independently per track — **the same time span gets the same number**. - File names contain no user strings such as the title or the device name (for path safety and machine parsing). The title goes into the meta and into the Drive folder's `description`. - Watch recordings use `_watch_`, matching `"source": "watch"` in the meta. - **Imports use `_import_`**, matching `"source": "import"` (2026-10-07): an audio or video file the user picked, which the shell transcodes into ADR-006 parts on this device (`platform` is this device's, one `mono` track). `ReclyCore.importAudio(sourcePath, displayName, startedAt, importer)` names it after the file without its extension, starts it at the file's own date when the shell passes one (else now — the `recordingId` carries the same instant, §1 "Identifiers · time"; every shell passes the file's creation date where the platform has one, else its modification date, 2026-10-15), and queues the fixed plan once it is complete; afterwards it is a recording like any other. While the shell's `AudioImporter` transcodes, the row is in the list with `status: recording` and no parts (`RecordingRecord.importing`; the shells show `IMPORTING`), and the parts are written to `{dataDir}/import-tmp/{recordingId}/` — beside the recordings, so nothing that scans a recording directory meets half of them. Only when every part is there are they moved in, registered and finalized in one locked pass. **An import is whole or not there**: a failure or a cancellation deletes the row and every file and answers `ImportResult.Failed` with `IMPORT_UNSUPPORTED` (no audio the platform can decode) or `IMPORT_UNREADABLE` (the file could not be read), the platform's words as the detail; nothing is kept for a failed import. A row a killed process left behind is never finalized by a recorder's crash recovery: recovery hands a `source: import` row that still says `recording` to `ReclyCore.dropAbandonedImport`, which drops it unless this process is importing it right now. ### Metadata One `meta.json` per recording. During recording it is rewritten at every segment boundary — **after a crash, everything up to the last boundary is recoverable**, and the last segment, if the process died before stop, cannot be read because its container was never closed, so recovery quarantines it as `{file}.corrupt` and does not register it. A recording with no readable part at all (only quarantined files left, or nothing at all) leaves nothing the app can do, so its row and its directory are deleted, quarantined files included (2026-09-04 user decision; before that it was kept as room for manual recovery, but all that remained was a `recording` row the user could not touch). After `stop`, `status: finalized`. ```json { "schema": 1, "recordingId": "01J9ABCDEF0123456789ABCDEF", "source": "desktop", "platform": "macos", "deviceId": "7c1e4b2a-0d3f-4a7e-9b1c-2f5e8d6a4c10", "deviceName": "MacBook Pro", "workflowId": "01J9ABCDEF0123456789ABCDEF", "title": "Weekly meeting", "startedAt": "2026-08-26T01:00:00.000Z", "endedAt": "2026-08-26T02:00:12.400Z", "durationSec": 3612.4, "timezone": "Asia/Seoul", "audio": { "codec": "aac-lc", "container": "m4a", "sampleRateHz": 16000, "channels": 1, "bitrateKbps": 32, "segmentSec": 300 }, "tracks": ["mic", "sys", "mix"], "parts": [ { "part": 1, "track": "mic", "file": "20260826T010000Z_desktop_01J9ABCD_p001_mic.m4a", "bytes": 1201234, "sha256": "…", "startOffsetSec": 0, "durationSec": 300 } ], "gaps": [ { "startSec": 1800.0, "endSec": 1800.3, "reason": "segment_restart" } ], "silenced": [ { "startSec": 120.0, "endSec": 125.5, "reason": "mic_taken" } ], "context": { "app": "us.zoom.xos", "participants": 3 }, "drive": { "folderId": "1AbCdEfGhIjKlMnOpQrStUvWxYz0123456", "folderUrl": "https://drive.google.com/drive/folders/1AbCdEfGhIjKlMnOpQrStUvWxYz0123456" }, "status": "finalized" } ``` | Field | Notes | |---|---| | `source` | `watch` · `phone` · `desktop` · `import` (§3 "Naming rules") | | `platform` | `wearos` · `android` · `watchos` · `ios` · `macos` · `windows` | | `title` | Optional. Entered by the user after stop in the phone and desktop shells, or absent (on the watch it is always absent because there is no input UI — it can be added later on the phone). It can be changed at any time on the detail screen and spreads to every device through the Drive folder's `description` (§3 "Recordings from other devices" — Titles) | | `parts` | The part list. `parts[].sha256` is computed right after a segment is finalized and is used to verify transfer and upload. `startOffsetSec` is the reference for the recording's timeline | | `gaps` | Spans where audio is missing because of a segment restart, an interruption, tap re-creation and so on | | `silenced` | Spans where the microphone was taken away, such as Android `isClientSilenced` or an Apple interruption. **Known limitation**: on Android, if stop is delayed (when unregistered parts are left over and handed to recovery), this span stays only in the log and does not go into the meta | | `context` | Optional. `app` is the bundle id of the detected meeting app, desktop only. `participants` (integer, head count including the user) is filled from the selection in the dialog after stop — the speaker-count hint for `transcribe` (§8). **There is no `context.calendar`** — calendar reading was removed from the whole product | | `drive` | Optional. This recording's Drive folder `folderId` · `folderUrl` (`webViewLink`). Right after `drive.upload` creates or finds the folder, and **before** it uploads the meta, it writes them to the row and to the local `meta.json` (`RecordingRepository.setDriveFolder`), so the copy in Drive and the devices that adopted it have the same values. Agent skills use it as the "Recording" link on the Notion page (2026-09-05). Absent before upload, or for a folder whose link was not received. Absent for iCloud recordings — there is no web link (§3 "Storage location") | | `status` | `recording` → `finalized` → (watch) `transferred` | The participant options are `2 · 3 · 4 · 5 · 6+ · Unknown`, and the default is "Unknown" (unknown) — if the user picks nothing, the field is omitted. No value (nil) is what "unknown" means, and the workflow's `speakers` default applies. `6+` pairs with §8 capping the speaker hint at 10. ### Local storage | Platform | Path | |---|---| | Android / Wear | `context.filesDir/recordings/{base}/` | | iOS / watchOS / macOS | `Application Support/app.recly.mac/recordings/{base}/` (app id directory; on iOS `isExcludedFromBackup = true`) | | Windows | `%LOCALAPPDATA%\Recly\recordings\{base}\` | For a recording **received** from the watch, the meta arrives last, so `{base}` cannot be known in advance, and the phone puts it in `recordings/{recordingId}/` (the directory name is stored in the DB row; the Drive layout is still `{base}`). On receipt, `upsertRecording` replaces the whole row but keeps `drive_folder_id` — even if the watch resends after the phone has already uploaded, the folder is not forgotten. The recording directory is stored in the DB as `recordings/{directory name}`, relative to the current app data root. Even when an iOS · watchOS update changes the app container UUID, lookup · transcription · playback · renaming · deletion are handled from the current root. An absolute path left by a previous version is resolved to the current container only when it lies under the same app's `Containers/Data/Application/{UUID}/Library/Application Support/{app id}/recordings/`. Existing files are not moved or deleted, and external paths are left as they are. ### Free storage on the device 2026-11-05. A recording must neither fill the device nor break off when its disk is full. All six platforms follow one rule, the core's `StorageFloor` (§10 "Shared rules for the shells"). Each shell reads its own free-space figure, in bytes, and hands it to the rule. These are v1 constants, not settings. Megabytes are decimal, as the platforms' storage screens count them. | When | Floor | What happens | |---|---|---| | **Start** | under **200 MB** (`START_BYTES`) free on the volume that holds the app's data directory, the one `recordings/` is under | The start is refused before the microphone is opened or a row, directory or file is created, so no recording exists. The line under the record control says `Not enough storage to record`, and the iPhone's Siri and Shortcuts intents say the same (§9 "What a start or a stop says"). It is not a failure, and it is not `The recording failed` | | **While recording** | under **50 MB** (`RECORDING_BYTES`) free on the volume that holds the recording's directory | Read at every part boundary (nominally 300 s, §3 "Audio settings"), once that boundary's part is filed. A write that failed is read the same way. Under the floor the recording stops the way the battery stop does (§1 "Long recordings — watchdog · reminders · battery stop · waiting to resume"): the open part is filed, no title is asked, and the recording is handed over as it stands. It is queued and uploaded on a phone or desktop, and a watch's parts go to the phone as usual. Then `Recording stopped: storage full` is posted as a notice. This happens once per recording and is not a failure | - **Why these numbers**: 200 MB is still about 14 hours of one 32 kbps track, or about 4.6 hours of a desktop's three, so the margin is the system's, not the recording's. 50 MB leaves room for the last part to be closed and for `meta.json`. A desktop part boundary writes 3 × 3.6 MB, and the floors are the same for its three tracks as for one. - **An unreadable figure** (negative) refuses nothing and stops nothing, the same as an unreadable battery. - **A write that failed**: a write refused for lack of space is the storage stop, not `The recording failed`. Android cannot tell why a `MediaRecorder` failed, and the Windows helper only dies (exit 1 after a fatal `error` event). So both decide by the reading taken right after the failure: under 50 MB it is the storage stop, otherwise the failure it always was. Apple also takes the error itself: `ENOSPC`, `NSFileWriteOutOfSpaceError` or Core Audio's `dskFulErr` anywhere in the chain of underlying errors means out of space, whatever the reading says. - **Out of scope**: `NEEDS_SPACE` (the Drive or iCloud account out of space, §10 "Drive out of space — `NEEDS_SPACE`") is about the destination and is unchanged. So is a full disk under a local folder destination, which stays an ordinary retryable failure (§3 "Storage location"). That folder may be on another volume, and nothing in it is being recorded. - **Log events**: `rec.recorder.storageRefused` (WARN, `freeBytes`) and `rec.recorder.storageStop` (WARN, `recordingId`, `freeBytes`, `cause` `boundary` | `write`, with the write's error on Android and Apple). The recorder writes both, on every platform. A write failure that became the storage stop is not also reported as a failure: the user never sees `The recording failed`, and Windows logs no `rec.helper.died`. The helper's own `rec.helper.error` is still logged. | Platform | The figure | Where it is read | What says it | |---|---|---|---| | Galaxy Watch · Android phone | `StatFs.availableBytes` (the blocks an app may use) on the nearest existing directory | `:android:recording`'s `SegmentedRecorder`: in `start` before anything is created; after each boundary's part is filed; when `MediaRecorder` reports an error, reaches its size limit unarmed, or `setNextOutputFile` fails | The start refusal goes to the screen as `RecorderEvent.NotEnoughStorage`, and the phone's tile and widget start through the activity, as every Wear start does. The stop is the service's notification stop, then a notice on the recording alerts channel (vibration, no sound) | | iPhone · Mac | `volumeAvailableCapacityForImportantUsage`, which counts what the system would purge on the user's behalf, read from a fresh `URL` each time because a `URL` caches its resource values | RecKit's `SegmentedRecorder`: in `start` before the microphone is authorized; after each boundary's parts are filed; when a segment write or the next segment's open fails | `StopLine.notEnoughStorage` on the status line. The stop is `RecordingNotices`' stop that asks nothing, then the `recording.storage` notice, which is silent | | Apple Watch | `volumeAvailableCapacity`: watchOS does not have the important-usage key (`API_UNAVAILABLE(watchos)`) | as the iPhone | the status line, then the same notice with the default sound, since nothing is recording any more (§1 "Long recordings — …" "No sound") | | Windows | `FileStore.usableSpace` for the nearest existing directory. `File.usableSpace` is not used because it answers 0 for a path it cannot read, and 0 would refuse every start | `WindowsRecorder` in the app, not the helper: in `start` before the helper is spawned; at each `part_done`; when the helper's stdout ends without a stop | `Not enough storage to record` on the tray popup's status line. The stop is the tray's stop that asks nothing, then a balloon | Where a platform cannot follow the rule exactly: - **Apple Watch** reads the stricter figure, space free right now. It can refuse a start that the iPhone, which counts purgeable space, would allow at the same fill. - **iPhone · Mac**: the important-usage figure can be higher than what a write gets at that moment, while the system has not purged yet. So a write can fail for lack of space above 50 MB, and the error code is what makes it the storage stop. - **Android · Windows** have no error that says "out of space" (see "A write that failed" above). A disk that fills between the failure and the reading reads as a failure, and the recording ends as a failed one. - **Windows**: the helper is unchanged. It still dies on a write that failed, and the parts it closed before dying are what the stop files. Not verified on a device: none of this has run against a really full disk. That includes what `MediaRecorder` and the helper's ffmpeg do when it fills, what `volumeAvailableCapacityForImportantUsage` and `volumeAvailableCapacity` report on a device, and `FileStore` on an NTFS volume (only APFS on the development host). Unit tests cover the rule (`StorageFloorTest`), a refused start with an injected figure (Android `NotEnoughStorageTest`, RecKit `SegmentedRecorderTests`, Windows `WindowsRecorderTest`), a boundary stop (RecKit, Windows; not Android, whose boundary and `onError` paths have no test), the helper dying on a full disk (Windows), the Apple error codes, and the notices (`RecordingNoticesTests`). ### Retention · deletion #### Automatic retention (ADR-017) Part files are deleted only when **all** of the following are true — ① the Job is DONE, ② the workflow has at least one `drive.upload` step and all of them SUCCEEDED (a failed upload skipped with `continue` is retained), ③ **every** Job of the same recording is DONE (FAILED · SKIPPED_SHORT · NEEDS_AUTH · NEEDS_SPACE also block deletion — `retry()` needs the parts), ④ **7 days** have passed since both the file's mtime and the last DONE time (`Retention.sweep` in every job pass re-evaluates this). Otherwise the files are retained and the list shows "Not uploaded". **The watch deletes as soon as the phone acks**. **What is not deleted**: `meta.json`, the DB rows (`recording` · `part` (marked `deleted=1`) · `job` · `step_run`), and the local copies made by `transcribe` (`{base}.transcript.json/.txt` — they are the detail screen's input, so they stay regardless of the part retention rule, §8). So even after the parts are deleted, everything the list and the detail need is still local. This automatic deletion has a window of **7 days after a successful upload** (2026-09-03, fixed value, no setting): there is still no rule that deletes just because time has passed; the retention sweep in every job pass deletes only when "the upload is done and 7 days have passed". In between, playback in the detail uses the local parts as they are; after they are deleted, it downloads them from Drive and keeps them another 7 days. Deletion by the user is always immediate. #### Deleting in the app — "Delete recording" The delete action on a list row — on the desktops, the last item of the detail's More menu (2026-10-15). **One recording at a time**, and every time it asks what to do about Drive. | Target | Action | |---|---| | Local parts · `meta.json` · result files (transcript) · the recording directory | Always deleted | | DB rows (`recording`, `part`, `job`, `step_run`) | Always deleted | | The `{base}/` folder in Drive and the files in it | **The user chooses. The default is "Keep in Drive"** | - The confirmation dialog shows both branches on one screen: `Delete local only` (the default) / `Also delete the Drive folder`. The default is "keep" because the files in Drive already **belong to the user**, and downstream automation may already have consumed that folder. **The irreversible option is never the default.** A recording whose row has no Drive link (it never reached Drive) has nothing to choose, so the two branches are not shown (Android · Apple 2026-09-29, Windows 2026-10-15). - If there is audio that has not been uploaded yet (the retention rule is keeping it), the dialog says so first — `Audio not yet in Drive is deleted with it.` It does not say how many parts (2026-09-29: the part count is data the user does not see). - A running (`RUNNING`) Job — an upload in progress, or one stuck in it — does not hold the deletion back (2026-10-08): the core stops that run first (core rule below), and the Job is deleted along with the recording, like `WAITING`/`NEEDS_AUTH`/`NEEDS_SPACE`/`FAILED`. The dialog is the same one; while the upload has not finished, the audio exists only here, and the line above leads it. Only a recording still being written — recording, importing — cannot be deleted: its page's `Delete` is disabled with the reason (§9 principle 2, "Detail header and More menu"). A recording still receiving from the watch can be (2026-10-30, both phones): a transfer the watch gave up on never ends, and only this phone's copy goes (§3 "Watch → phone transfer contract"). - **"Also delete from Drive"** is `files.delete`. **The folder id is read only from what the recording's `drive.upload` step left behind** — `output_json.folderId` (when the step finished or is parked), otherwise the resume state `state_json.folderId`. **`drive_folder_cache` is not used**: that cache is keyed by the rendered *path* (`recly/memo/2026-08`), so the folder is a parent shared by every other recording of that month, and deleting one recording must not delete it. If the call fails, the local deletion still goes ahead and leaves "Could not delete from Drive" — once the local copy is gone there is nothing left to retry from, so the user is also shown the Drive link. - For an **iCloud recording** (§3 "Storage location"), the same dialog speaks of iCloud instead of Drive (`Also delete the iCloud folder`), and the folder is deleted on every device through a coordinated delete. The failure message is likewise `Deleted here, but iCloud refused`. - For a **local folder recording** (§3 "Storage location"), the dialog speaks of the local folder (`Also delete from the local folder`), and the recording's folder in it is deleted. The failure message is `Deleted here, but not from the local folder`. - **Drive is the only path by which a deletion reaches other devices.** A recording deleted with "Also delete the Drive folder" disappears, at the next fetch, from the list of every other device that **adopted** that folder ("Recordings from other devices" below). The row on the device that **made** the recording stays — it is that device's original and Job record, and Drive has no authority to delete it. "Delete local only" reaches no device. - **Deleting an adopted row** is a Drive deletion only. This device holds nothing but a cache, so "Delete local only" would be undone by the next fetch; the dialog does not offer that option, says "Recorded on another device. Deleting removes it from Drive and from every device.", and then calls `delete(id, deleteDrive = true)`. The folder id is `recording.drive_folder_id`. The provisional row of a folder another device is still uploading into is deleted the same way (2026-10-08): an abandoned upload's folder is simply gone, and a device that is still uploading finds its folder gone at its next step (`drive.upload` re-resolves it) and uploads into a new one, so the recording comes back once that upload is in. **Core rule** — `RecordingRepository.delete(recordingId, deleteDrive): DeleteResult`: - There are only three results. `Deleted(driveDeleted, driveError)` / `Busy` (**nothing is touched**: an on-device transcription of the recording did not stop within 5 seconds, or — in a repository no executor is wired to, such as the watch's — a Job is `RUNNING`) / `NotFound`. - **A running Job is stopped first** (`Executor.stopping`, 2026-10-08). While the deletion runs, no Job of the recording is started; the run in flight is cancelled and given 2 seconds to wind down (`job.stopped` with `settled`), and is not waited for beyond that — the iPhone's background upload session answers a chunk only once it is sent (§13), and cancelling the coroutine does not reach it. Each Job runs as a coroutine of its own, so the pass goes on with the next recording's Job rather than waiting behind one that no longer exists. A run let go of writes nothing when it wakes: every store write goes through `withContext(io)`, which does not start for a cancelled coroutine, and those writes are `UPDATE … WHERE id` on rows that are gone. A pass that is itself cancelled (WorkManager, a background task's expiry) still waits for its run, as before. - **Finding the row · reading the folder id · deleting from the four tables (`step_run` · `job` · `part` · `recording`) are one transaction.** The schema has neither FKs nor CASCADE, so the tables are deleted one at a time by name (`step_run` goes first because its query reaches in through the `job` rows that still exist). The directory is deleted right after the commit, inside the same lock section — this keeps a cancellation from slipping in between the commit and the file deletion and leaving a directory that no row points to. - SQLite's single writer separates this transaction from `JobStore.claimRunning`: a claim that commits second finds **nothing to claim because the row is already gone**, and a deletion that commits second has already stopped the run and kept a new one from starting — never a run over files that are gone. - Drive `files.delete` is called after the commit. A failure is only reported as `Deleted.driveError`; it does not undo the local deletion. #### Detaching from the account — Sign out vs Disconnect This is about Google Drive — iCloud and the local folder have no in-app disconnect (§3 "Storage location"). The Google Drive settings offer **a single “Disconnect” button**. The connected account and the button are shown in the same row and text size as the surrounding settings. Pressing the button opens one confirmation, and confirming performs **the Google grant revocation and the local cleanup on this device** together. There is no “Disconnect this device only” option and no scope-selection menu. The confirmation says, in one paragraph, that Recly's Drive access is revoked on every device connected with the same Google account, that pending work continues when the user reconnects to the same account, and that recordings and settings stay. Recording deletion and the count of items not yet uploaded are not shown in this confirmation. Recordings are deleted only from the list. The not-connected row carries “Record locally. Connect Drive to upload.” as small secondary text. The Google permissions management link is normally hidden. It is shown, with an error notice, only when there is a record of a failed Google revocation (`revokeDebt`); when only the local cleanup is unfinished, the existing “Disconnect” button retries. Even if the disconnect fails or is interrupted because the app quit, the button keeps the name **“Disconnect”**. While processing, only a waiting notice is shown; on failure, only an error telling the user to try again in settings. A retry continues the existing processing steps, and the user is never asked to pick a separate “finish” action or a revocation · local-cleanup step. As soon as confirm is pressed, the confirmation closes and the button changes to **“Disconnecting…”** and is disabled. This progress state lasts from the Google revocation through the local cleanup. Even if the credentials or the saved processing step change midway, it does not turn into the “Connect Drive” button before completion. On completion it switches to the not-connected screen; on failure, to the existing button, which can retry. The internal `signOut` remains as an on-device credential cleanup used for things like auth recovery, but it is not exposed as a separate user action. The recording list does not show with separate text whether a recording is stored locally. Recordings that download only the tracks needed for playback and recordings that keep the original tracks are shown by the same list criteria. The display text for `NEEDS_AUTH` is a gray “Upload waiting”, which is separate from local storage · playback. Individual recording rows do not repeat the Drive connection explanation · button. The states, logs and error codes themselves are not changed. | | Sign out (this device) | Disconnect | |---|---|---| | Purpose | Stop using it on this device | Keep Recly from accessing my Drive | | Tokens | Delete this device's access/refresh token | Same + cancel (revoke) the Google grant | | Other devices | No effect | **Recly's connection on the same Google account is cut as well** — revoke withdraws the account's approval **per Cloud project**, so even when pressed on the phone it removes the grants received by the Mac · PC clients of the same project (§6) | | Recording files in Drive | Unchanged | **Unchanged** — they are the user's files and the app has no reason to delete them | | Recording processing settings | Unchanged | **Unchanged.** They are this device's settings, not something derived from the account (§5), and once deleted they cannot be recovered from anywhere | | Local secrets (STT keys) | Kept | **Kept** — for the same reason. The decision to detach the account is no reason to delete keys the user entered | | Local Job · step_run | Kept (continue running after signing in again) | **Unfinished work is kept and paused**, and resumes when the user reconnects to the same Drive account. Finished work records, `drive_folder_cache`, and the "Delete local only" records `remote/ignored/*` are cleared. | | Local recording files · `meta.json` | Kept | **Kept.** This action does not delete originals that have not been uploaded yet (principle 3). The confirmation briefly says that recordings and settings stay. Recordings are deleted separately, from the list | - **The disconnect dialog must show that Recly's Drive access is revoked on every device connected with the same Google account.** Google can take time to apply a revocation, so the dialog does not tell the user that other devices' screens switch to not connected immediately. A revocation failure is reported separately through the existing error · recovery path. **Only when there is a record of a failed Google revocation does Drive settings show a link for removing the access directly in the Google account settings ().** Even when the local cleanup has finished and the account shows as not connected, the link stays as long as the failure record remains. - The internal sign-out path does **not** call revoke — the reason and the citations are in §6 (iOS · macOS `signOut` vs `disconnect`, the Windows revoke subsection). **Disconnect has two halves**: the grant revoke (the job of the shell's platform SDK) and the local cleanup (`ReclyCore.disconnect(alsoDeleteRecordings): DisconnectResult`, the core's job). The core has no means of calling revoke, so the shell calls both **together**. - **It runs while quiesced.** All of the local cleanup is inside `Executor.quiesced` — a Job that is already running finishes its current step and stops, and only after that is anything deleted. The cached access token is invalidated first (`tokenProvider.invalidate()`), and then the `tokens` namespace is cleared — in the reverse order, a token the shell held in memory would carry over into the next run. - **Only four things are deleted**: the `tokens` namespace, finished work records (`job` · `step_run`), the Drive folder cache, and the "Delete local only" records (`kv` `remote/ignored/*`, §3 "Recordings from other devices"). The recording processing settings and the `secrets` namespace are left as they are (table above). - **Resuming unfinished work (2026-09-21)**: applies to work that includes a Drive step. Step outputs · upload sessions · transcription request IDs · retry counts · wait times are kept. `job.drive_account_id` stores the identifier confirmed through Drive `about.get(fields=user(permissionId))`, and the state before the disconnect is kept in `disconnected_status`. With the same account, the previous state is restored and steps that succeeded are not run again. With a different account, the previous work keeps waiting as `NEEDS_AUTH` and sends no upload · transcription. If the account cannot be confirmed, nothing resumes. Work from a previous version whose account was never confirmed is restored only when the identifier of the existing Drive folder's owner matches; if there is no data to confirm the owner, it keeps waiting. Step records that a previous build already deleted cannot be restored. - **`DisconnectResult(deletedRecordings, busyRecordings)`.** The delete option is kept for the core API's compatibility, but the revocation UI of all four shells always calls with `alsoDeleteRecordings=false`. When an internal API uses the delete option, the ids of recordings the deletion answered `Busy` for go into `busyRecordings` (nothing of the queue runs inside the disconnect, so a Job left `RUNNING` goes with its recording). Those recordings and **their Job rows are kept**, and the screen says so — pressing again deletes them then. - **`DisconnectPhase` — `NONE` → `REVOKE_PENDING` → `REVOKED_CLEANUP_OWED` → `NONE`.** It is kept in the shell's settings store (the retry may happen in the next launch, and by then the token is already gone). The order is the point: `REVOKE_PENDING` is written **before calling** revoke (revoke deletes this device's credentials, so if it were written afterwards, it would be lost along with those credentials). `REVOKED_CLEANUP_OWED` is written after revoke returns and **before calling the local cleanup**, and it is cleared to `NONE` only when the cleanup finishes and `busyRecordings` is empty. If an exception is thrown, the phase stays as it is — that is the honest state. If the phase · debt is not written to the store, the credentials are not touched. - **A retry does not delete someone else's grant.** A retry from `REVOKED_CLEANUP_OWED` **skips** revoke and only does the overdue local cleanup (that grant is already gone). A retry from `REVOKE_PENDING` asks the token store — if a refresh token is still there, revoke did not happen, so it calls revoke again; if not, it moves on to the cleanup. While a phase is pending, **sign-in and sign-out are held back** (if another account came into the empty slot, that account's grant would be deleted). - **revoke debt.** When revoke fails, that fact is recorded in the store **before the tokens are deleted** (the phase alone cannot catch it: the failure branch also deletes the refresh token, so if the process dies in between, the phase is `REVOKE_PENDING` but there is no token, and that is misread as "revoke happened"). While the debt stands, the screen says "Google still lists Recly" plus a link to the permissions page, and **only the user's confirmation clears the debt** — not even a later successful revoke clears it. The app does not hold the account's identity, so it cannot tell whether the grant it just deleted belongs to the account the debt points to. - **`DisconnectGate` — starting a recording is refused while a disconnect is in progress.** The "recording right now" value seen when the dialog opened goes stale while waiting for revoke (a network round trip). Meanwhile a tile · widget · shortcut · tray menu · meeting detection can start a new recording, and that recording has no Job yet, so it is not caught by the core's `Busy` check — the cleanup would end up deleting the files the recorder is writing. So there is an app-wide gate, and the disconnect holds it from before revoke until after the cleanup. A start is **refused, not queued** ("Disconnecting"), and it says why (§12: it does not stop anything on the user's behalf). The confirm button is also disabled while recording, and the value is read once more at the moment of confirmation. - The gate decision and the actual capture start must be **the same critical section** — if execution suspends after the decision but before the capture opens (for example for a workflow list lookup), the disconnect can take the gate in between. Windows achieves this with `DisconnectGate.ifOpen { start() }`, Android with a structure in which each side writes its flag before reading the other's and there is no suspension point in between, and Apple with a MainActor `tryLock`. Apple has two more rules — if the restoration result is uncertain (`GoogleRestoration.failed`), the phase is kept and the user is asked to try again, and if the `UserDefaults` flush fails, the credentials are not touched. ### Drive layout (ADR-014) ``` My Drive/ {folder template result}/ e.g. recly/memo/2026-08/ {base}/ e.g. 20260826T010000Z_desktop_01J9ABCD/ {base}_p001_mic.m4a {base}_p001_sys.m4a {base}_p001_mix.m4a … {base}.meta.json {base}.transcript.json / .txt (when a transcribe step was added) {base}.notes.json (its notes, once organized, §8 "Organizing") {base}.memo.json (the user's memo, once one is written, §8 "Memo") ``` - The folders are created by the app under the `drive.file` scope, so the app can find them again. Folder IDs are cached in the local DB. - The `{base}` folder records `title` in its `description` and `recordingId` · `workflowId` in its `appProperties`. `description` is **the canonical title**, which later renames update ("Recordings from other devices" — Titles, below). - A recording's final notes are `{base}.notes.json`, the same JSON as the copy kept on the device, and the folder's `appProperties.notesAt` is the version it holds (its `updatedAt`), which the list's pull compares before reading the file (2026-10-28). A `{base}.summary.json` written before then (a summary the user asked for, removed on 2026-10-28) stays in its folder, unread. - A recording's memo is `{base}.memo.json` (`spec/memo.schema.json`), the same JSON as the copy kept on the device, and the folder's `appProperties.memoAt` is when it last changed (its `updatedAt`), compared the same way (2026-10-30). - Upload completion is decided by comparing the Drive file's `md5Checksum` with the local md5 (sha256 is for transfer verification; md5 is the value Drive provides). - Part upload order: per track, starting from part 1. `meta.json` is uploaded last, so that "the meta exists → complete" can serve as a downstream trigger condition. ### Recordings from other devices (ADR-023) **Showing finished recordings that have no work record (2026-09-21).** When a recording has finished and there is no Job on this device, the list and the detail show its state as `DONE` · “Done”. The same goes for recordings whose work records were deleted by a disconnect. `NO_JOB` is not exposed and not counted among pending items. This display does not newly decide that a Drive upload succeeded and does not create a Job. If a real Job exists, its waiting · in-progress · failed state stays as it is, and the receiving · remote-upload · transcription progress markers also follow the existing rules. Phones and desktops signed in to the same account see **the same recording list**. There is no server and no index file — Drive already is that index: the layout above leaves one `{base}/` folder per recording, stamped with `recordingId` in its `appProperties`, so a single folder query (`mimeType = folder and trashed = false` — under `drive.file` only folders the app created come back, and among them the ones that have `appProperties.recordingId` are recording folders; Drive rejects `appProperties has { key=… }` without a value with a 400, confirmed on a real account 2026-09-04) returns every recording of this account. It does not matter that the folder template differs per workflow, because no path is walked. Core `RemoteRecordings.pull()`: 1. List the folders and group them by `recordingId`. Finished rows already adopted are skipped, and the provisional rows of step 3 below are completed. **If a finished recording made on this device has no Job, the Drive copy is verified and restored.** Only when every part's number · track · file name · size · SHA-256 matches the local meta and every Drive file ID is present are `drive_synced = 1` and each part's `drive_file_id` stored. The local meta · files · directory · `remote = 0` are kept. No new work record is created, and upload · transcription are not run again. An existing Job that is in progress or has failed is not overwritten, because its state is canonical. A restored recording shows as `DONE`, and if a valid `transcribe` progress marker remains, it shows as transcribing. If there is no local audio, playback downloads it from Drive with the restored file IDs. A copy that is incomplete or has different content is not treated as complete. If a restored folder is missing from this account's full list, only the Drive verification state · file IDs are cleared and the original is kept. Right after a reconnect, the fetch runs without the rate limit. A disconnect is serialized with any fetch in progress and resets the verification state · file IDs · fetch-throttle time, so verification results from the previous account are not used for a connection to another account. The startup recovery scan does not re-queue Job-less recordings whose Drive folder is known; it leaves them to this fetch. 2. For an unknown id, list the folder's children once and download `{base}.meta.json` (if there are two folders with the same id — a re-run to a different path — the newest one that is complete), and **adopt** the `recording` row (`RecordingRepository.adopt`): `remote = 1`, the folder in `drive_folder_id`, and `part` rows that are `deleted = 1` from the start, with `drive_file_id`. The directory is `recordings/{recordingId}/`, as for watch receipt, and `meta.json` is written there. **The meta builds paths** (the directory name · part file names), **so it is accepted only if it follows the schema**: only when `recordingId` equals the folder's, is in ULID format, and every `parts[].file` is exactly the name the naming rules give — otherwise it is rejected with `remote.meta.mismatch`. 3. **A folder without `meta.json` means "another device is uploading"** (2026-09-04). The meta is uploaded last (Drive layout above), so that folder is a recording being uploaded right now, and if it were put off to the next fetch, the user would see an empty list until a 20-minute upload finished. So a **provisional row** is opened from what the listing gave alone: `remote = 1`, that folder in `drive_folder_id`, and a `meta` with `source` read from the folder name `{yyyyMMddTHHmmssZ}_{source}_{ulid8}` (`phone` if unknown) and `startedAt` read from the ULID timestamp of `recordingId`, the title from the folder's `description`, empty `tracks` · `parts`, and `status = recording`. `meta.json` is written in the same place as for adoption. What the shell sees is `RecordingRecord.remoteUploading`. If the folder's `recordingId` is not a ULID, no row is opened — that value is the directory name. **Abandoned uploads**: a folder without a meta whose `createdTime` is more than 24 hours old is not adopted, and a provisional row already opened is deleted, once it reaches that age, the same way as when its folder disappears (`drop`). When a later fetch finds `meta.json` in that folder, it **swaps the provisional row for the real meta** (`finalized` · parts · `drive_file_id`) — this is the exception in step 1. 4. **The folder's `pending` marker**: a device that still has work left after the upload writes that onto the folder — `appProperties.pending` is the `type`s of the steps after the upload, joined with commas (`transcribe,transcript.publish`, `local.transcribe,transcript.publish`, or an empty string if there are none; `notes.organize` is never in it — no other device waits for the notes, which reach the folder on their own once made, §3 "Drive layout", §8 "Organizing"), and `appProperties.pendingAt` is the time it was written. It is written right after `drive.upload` creates or finds the folder (before the first byte), every time the runner finishes a step (what remains), and when the job becomes `DONE` · terminal `FAILED` (an empty value). The `appProperties` of `files.update` is a merge, so the folder's `recordingId` stays. The marker is **advisory**, so a failure is logged as `drive.marker.failed` and skipped. The reader is this fetch: it fills `recording.remote_pending` of every **remote** row with its folder's marker (`RecordingRecord.remotePending`), and the value is NULL if the marker is missing or empty or `pendingAt` is older than **8 hours** (the longest provider result timeout in §8). A row with `remote = 0` also receives it if its Drive copy was verified without a Job. For a row with a Job, its own job is canonical. 5. If the **folder** of a previously adopted row is no longer listed (another device deleted it, the user deleted it in Drive, or a re-run replaced it with another folder), the row and its cache are deleted (`drop` — inside the transaction it checks that `drive_folder_id` is that folder before deleting, so if a watch transfer has made the same id this device's own in the meantime, the row is not touched). Deletion comes before adoption, so if the same id remains in another folder, it is adopted again from that folder. If a **newer folder completes later** than the adopted row's folder (a re-run finished), the row moves there — the new folder's children are looked at once more only when there are two or more folders for that id. All of this happens only when the listing succeeded to the end — a failed fetch deletes nothing. 6. **"Delete local only" is remembered.** If a recording this device uploaded is deleted while its Drive folder is kept, the folder keeps being listed, so otherwise the next fetch would revive the recording just deleted as one from "another device". So `delete(deleteDrive = false)` leaves, inside the delete transaction, `remote/ignored/{recordingId}` → folder id in `kv`, and the fetch does not adopt that id while that folder is listed (`adopt` itself also checks this record inside its transaction, so a deletion that slips in during a fetch is not revived either). When the folder disappears from Drive, the record is deleted too. "Disconnect" clears all of these records — when the account is attached again, the device sees everything in Drive, like a new device. The folder id of a recording this device uploaded is also written **to the row's `drive_folder_id`** the moment `drive.upload` creates or finds the folder — because "Delete local only" must know which folder it kept even after a disconnect has cleared the queue (the step outputs) (for recordings uploaded before that, it is read from the step output; if there is none, the recording is deleted without a record — and shows up again at the next fetch). Properties of an adopted row: - **It has no Job, and none is ever created.** `enqueue` returns `PartsPurged` (Drive already has it, and this device has no original to send). Even if the recovery scan sees it as a "finalized row without a Job" and calls enqueue, the answer is the same. Its state in the list is **`DONE`**, not `NO_JOB` — it looks exactly like a finished recording (2026-09-04 user decision: a permanent "another device" state is meaningless), and there are no actions other than details · delete · open in Drive (no retry · upload). Only the delete dialog knows that it belongs to another device (the `remote` flag). - **It has a Drive link** (2026-09-05 user decision). It is a row read from that folder, so the folder is known by definition — the shell's `link` is the upload step output's `folderWebViewLink`, otherwise `RecordingRecord.driveFolderUrl` (the meta's `drive.folderUrl`, and if that is also missing, the canonical URL built from the folder id, `https://drive.google.com/drive/folders/{id}`). Recordings this device uploaded take the same fallback, so they can be opened from the moment the folder exists. The exception is a placeholder row that another device is still uploading (§9 principle 2: it has no link or retry until the upload is in; it can be deleted, "Retention · deletion" above). - **`uploaded()` is true.** It came from Drive, so by definition it is uploaded. The "parts not uploaded" in the delete warning is 0. - **Playback and transcripts come from Drive.** `AudioParts` downloads by `part.drive_file_id` instead of the upload output, and `RecordingResults` finds `{base}.transcript.json` in the folder by name (if another device transcribes later, it shows up the next time the recording is opened). Downloaded parts are deleted by the retention sweep after 7 days like any cache — there is no Job, so they age by the file mtime alone (the adoption branch of `claimPurge`). - **The title's canonical source is the Drive folder's `description`** ("Titles" below). - **A provisional row has the same properties.** It is `remote = 1`, so it has no Job, `uploaded()` is true, and delete · `enqueue` behave as for an adopted row. The only difference is that it has no parts yet (there is nothing to play), and renaming is refused because of `status = recording` — it works from the moment the meta arrives. What the shell uses to draw "what is happening right now" is three values of `RecordingRecord`. None of the three is a Job on this device, so they cannot be read from the queue. | Value | Meaning | |---|---| | `receiving` | A watch transfer is coming in (`remote = 0` · `source = watch` · `status = recording`) — the phone never records with `source = watch`, so this shape can only be a transfer. The row's `startedAt` is **the ULID timestamp of the `recordingId` the watch created** (§1 "Identifiers · time"): even when a 20-minute recording is handed over after it ends, it sits in its proper place in the list | | `remoteUploading` | Another device is uploading (`remote = 1` · `status = recording`) — the provisional row of step 3 above | | `remotePending` | What another device still has to do after the upload (`transcribe` and so on) — the marker of step 4 above | #### Titles Right after stop, the title popup in all four shells has a wide `Save` button (minimum width 120pt/dp) at the bottom right and a red `Discard recording` button just to its left (2026-10-08, user decision: it read `Cancel`, which did not say that it deletes). `Discard recording` is **the action that discards the recording that just ended**; the popup closes only through its two buttons — no swipe, outside tap, Back or Escape (2026-10-10: a swipe on the iPhone sheet used to discard the recording without a word). It is not an action that only skips the title. It deletes the local files · meta · DB rows of the one pending recording ID, and does not create a Job or wake the workflow. Only one of save · cancel can take that recording, so a save action that arrives late after a cancel does not start an upload. Other recordings and Drive are not touched, and a refused · failed deletion is shown as an error. The placeholder of the title field is the existing translation `Untitled`. The input starts empty, and the placeholder is never saved as the actual title. No separate hint is shown for an empty input. Pressing `Save` with an empty title keeps the date · time name, and the selected head count is applied as well. Separately from the title entry right after stop (`updateTitle`, only until the Job runs), **the name can be changed at any time on the detail screen** (`ReclyCore.rename(recordingId, title)`; for this device's recordings and adopted ones alike; an empty string means "Untitled" = the timestamp name). The rules that keep a title, once uploaded, from differing between devices: - **Local is immediate.** The row · `meta.json` are written right away, and `title/pending/{recordingId}` → title is left in `kv`. The list updates shortly through `recordings.observe()`. - **Pushing to Drive** (`RemoteRecordings.pushTitles`): the folder `description` is changed with `files.update`, and `{base}.meta.json` in the folder is overwritten with the local meta (`updateMedia`). The pending entry is deleted only when **both** succeeded — if the title was changed again in the meantime, the value differs and the entry stays (`kvDeleteIfValue`). Only one push runs at a time (a mutex, so that two renames in a row do not reach Drive in reversed order). It is tried once right after `rename`, and again at the end of every fetch (job pass). A recording whose folder is not known yet (before upload) waits, and so does a recording whose folder is known but that has no `meta.json` yet (upload in progress) — the upload in progress may upload a meta with the old title, and a later pass has to fix that. If the folder returns 404, another device deleted it, so the pending entry is dropped. When a recording is deleted (by any deletion), its pending entry is deleted too — so that a stale title is not pushed if the same folder is adopted again later. - **Pulling from Drive.** The `description` in the folder list the fetch receives is the title (no extra request; Drive returns only what is listed in `fields`, so `RECORDING_FOLDER_FIELDS` includes `description`). If it differs from the row's title, the row is changed (`applyTitle`; for adopted rows and this device's own alike). But if that recording has a pending push, **this device's change wins**. If the row knows its own folder, it reads that folder's value; if not (uploaded before this version), it reads the newest folder's and **remembers that folder on the row** (`rememberFolder`; a folder it already knew is not overwritten) — that gives the recording's renames somewhere to go. If `description` is empty, nothing happens — clearing a title does not propagate between devices (only renames propagate). - If two devices change it at the same time, the one that wrote to Drive later remains. Nothing beyond that. When to fetch: **every job pass** (the end of `ReclyCore.runDueJobs`, 2-minute throttle) and **when the list comes on screen** (the shell calls `pullRemoteRecordings(force = true)`, in the background without blocking the screen; on phones, every time the list tab is opened). Phones (Android · iPhone) also run the same fetch when the list is **pulled down**, and then wait until the fetch finishes — both phones use the platform's pull-to-refresh indicator as it is. This is the only exception to the ban on system spinners (§9 "Screen principles" 1) (2026-09-29 user decision): when the fetch finishes quickly, the square loader and `Loading…` flash on and off, whereas the platform indicator follows the finger down and slips back up silently. The throttle is **30 seconds while another device is doing something** — if at least one row is `remoteUploading` or has `remotePending` (asked of the DB at fetch time; no new state is added), a list that said something is in progress must be able to change its answer soon. Besides `jobs.observe()`, the shell's list also subscribes to `recordings.observe()` (changes to the recording table), so adoption · provisional rows · completion · marker changes · deletion show up immediately. With no sign-in, the fetch is silently skipped (`skipped = "auth"`). **Premise**: under the `drive.file` scope, files created by different client IDs (the phone's Android client, the Mac's iOS client…) must be visible to each other within the same Cloud project. The code assumed this even before — it finds the month folder `recly/memo/2026-09` with `findChild` and reuses it, so otherwise a folder with the same name would have been created again on every device. The official documentation does not state the unit, so **this is confirmed on a real account**: it holds if Drive has only one folder for the same month. If the files are not visible, this fetch just returns an empty list and breaks nothing, and the only alternative is the full `drive` scope, which conflicts with ADR-009. ### Storage location (ADR-024) **2026-10-02 user decision**: by default, recordings and results go to Google Drive, and iPhone · Mac can switch to **iCloud** in settings. Android phones · Galaxy Watch · Windows cannot use iCloud — iCloud has no official API for Android · Windows, and this difference is accepted. Apple Watch still hands its recordings to the iPhone (ADR-002), and the iPhone uploads them to its own storage. First run is the same as before. **2026-10-03 user decision**: Mac, Windows and the Android phone can also choose a **local folder** — a folder the user picks on the device, such as an Obsidian vault or a folder another tool syncs — as a third storage, with no Google account needed; the iPhone was added the same day, so an Apple Watch recording reaches an Obsidian vault through its iPhone. Its rules are under "Local folder" below; the watches do not offer it. - **Where to choose**: in builds that can offer iCloud or a local folder, the top section of settings becomes `Storage` and has the chips `Google Drive` · `iCloud` (product names, so they are not translated; `iCloud` only where it is offered) · `Local folder` (only where it is offered, "Local folder" below). A tap saves immediately (`ProcessingSettingsRepository.setStorage`, §5). Below the chips, only the row for the chosen storage appears, in the same shape — for Google Drive, `Drive connected` · `Disconnect` or `Drive not connected` · `Connect Drive`; for iCloud, `iCloud connected` or `iCloud not connected` plus where to turn it on (the menu names of system settings as they are — iPhone: `Sync this iPhone` in `Settings → [your name] → iCloud → Drive` and Recly under `Saved to iCloud → See All`; Mac: `Sync this Mac` in `System Settings → … → Drive` and Recly under `Apps Syncing to iCloud Drive`). The app has no way to sign the user in to iCloud or to turn on iCloud Drive, so the iCloud row has no connect button. On the Mac, when not connected, `Open iCloud Settings` (System Settings' Apple Account → iCloud) is added; on the iPhone there is no public link that opens the iCloud screen, so only the path is written (a private `App-Prefs:` link violates review guideline 2.5.1). The status is checked again when the settings screen appears, when the app becomes active again, and when `NSUbiquityIdentityDidChange` is received — turning it on in settings and coming back changes it right away. Once it becomes usable, the first level of the storage folder (`recly`) is created right away, so that even before the first recording a Recly folder shows in the Files app · Finder and Recly shows in the system's list of iCloud apps. The list · notifications keep using the short sentence of `ICLOUD_UNAVAILABLE`. Switching to iCloud does not disconnect Drive — Drive is still needed to download and delete earlier recordings that are in Drive. That disconnect is in the Drive row under the Google Drive chip. In builds that offer neither — an iPhone build without the iCloud entitlement — the section is `Google Drive` as before. - **Fixed per recording**: the storage is `storage.provider` of the recording processing settings, and it is fixed along with the settings when the recording starts (§5). Recordings already started and recordings being uploaded go to their original storage. Uploaded recordings are not moved (no migration). A retry the user presses does the remaining work with the current settings (§5), so a failed upload retried after switching storage goes to the new storage. - **The place on the iCloud side**: under `Documents` of the container `iCloud.app.recly` (the "Recly" folder in the Files app · Finder), the same layout as Drive — parts, `{base}.meta.json` and result files inside `{folder template}/{base}/`. What Drive keeps on the folder itself (the title in `description`; `recordingId` · `workflowId` · `pending` · `pendingAt` in `appProperties`) is kept in `{base}.folder.json` inside the folder, and because the list reads that file, `createdTime` is written there too. If the folder already exists, that file is not overwritten (just as Drive writes these only when it creates the folder). File · folder ids are `icloud:` + the path under `Documents` (`StorageKind.ofId`), so ids left in rows and step outputs find their own storage even when the setting changes. - **Upload and completion**: the upload step (`drive.upload`, `store: icloud`) **copies** the files into the container (coordinated writes), and the system uploads them. The step ends when iCloud says it has received every file (`ubiquitousItemIsUploaded`). Until then it is `WAITING`, rechecked every 30 seconds, and it does not use up attempts (`ICLOUD_UPLOADING`). In the list it looks the same as a Drive upload — badge `UPLOADING`, no reason line under the row, top status `UPLOADING`, not counted among pending items. The reason line of `Waiting for iCloud` (`ICLOUD_UNAVAILABLE`) is in the warning color of waiting, not the red of failure. The originals' 7-day window (ADR-017) starts after that (principle 3) — the reason for copying instead of moving is that if the user signs out of iCloud before the upload without choosing "Keep a Copy", the files in the container are removed from the device. Transcription results are written to the same folder and do not wait for the upload. - **When it cannot be used**: if the user is not signed in to iCloud, Recly's iCloud Drive is turned off, or the build has no entitlement, upload and result publishing are `WAITING`, rechecked every 5 minutes (`ICLOUD_UNAVAILABLE`, list `Waiting for iCloud`, `Retry` available). Why not `NEEDS_AUTH`: there is nothing the user can do inside the app, and when iCloud comes back the work continues on its own. - **Space**: if the system cannot upload because the account is out of space (`NSCocoaErrorDomain` 4354), it is `NEEDS_SPACE`, the same as Drive (`ICLOUD_STORAGE_FULL`, list `No space in iCloud`). The banner button is `Retry` — there is no public link that opens the iCloud storage screen in settings. - **List (ADR-023)**: the fetch lists Drive (if signed in) and iCloud (if the currently chosen storage is iCloud) together and merges them. A device that has not chosen iCloud does not touch the container — otherwise a "Recly" folder would appear in the iCloud Drive of someone who never asked for it. Rows of a storage that could not be listed this time are not deleted or changed (adopted-row drop, "Delete local only" records, restored copies, progress markers — all of them). iCloud syncs files one at a time in no particular order, so even with `meta.json` present, a recording is complete only when **every part the meta names is listed at its size**. Otherwise it is a provisional "another device is uploading" row, and the 24-hour rule is measured by the `createdTime` in `{base}.folder.json`. Lists are merged only between devices that use the same storage — if iPhone and Mac both use iCloud, they see the same list; if one of them uses Drive, neither sees the other's new recordings. - **Titles**: the canonical source is `description` in `{base}.folder.json`, and a rename fixes that file and `meta.json` together (the same rule as §3 "Titles"). If two devices change the same file at the same time, iCloud picks one version as current, and the shell deletes the other versions when it reads (`NSFileVersion` — the one written later remains). - **Progress marker**: `appProperties.pending`/`pendingAt` in `{base}.folder.json`. A step that runs again every 30 seconds while waiting for upload confirmation does not rewrite the marker — every write makes every device read that file again. - **Playback · deletion**: parts deleted by the retention sweep are fetched from the container (a download is requested and waited for up to 2 minutes) and their sha256 is checked. "Also delete the iCloud folder" deletes the folder on every device through a coordinated delete. What the user deletes in the Files app stays in iCloud Drive's "Recently Deleted" for 30 days (Apple), but whether the same holds for what the app deletes has not been checked — the dialog's default is, as with Drive, the option that does not delete. - **No web link**: an iCloud folder has no fixed web address, so there is no `meta.drive` and no `Open in Drive`. The Mac has `Show in Finder`; the iPhone has no action in that place. - **Not tied to an account (Known limitation)**: Drive work is tied to the Drive account and stops under a different account (§3 "Detaching from the account"), but iCloud work is not tied. If the device signs in with a different Apple ID, the remaining uploads go to the new account. There is no in-app disconnect either — whether to use iCloud is decided by the user in system settings (iPhone: Settings › [your name] › iCloud, Mac: System Settings › [your name] › iCloud). - **Build**: iCloud is off in a fresh checkout. It needs the container registration and the App ID capability (`app.recly`, `app.recly.mac`) and a Developer ID provisioning profile for Mac Release (§12), and `apple/Config/Local.xcconfig` turns it on (`RECLY_ICLOUD_CONTAINER`, `RECLY_PHONE_ENTITLEMENTS`, `RECLY_MAC_ENTITLEMENTS`). In a build that does not turn it on, `ReclyICloudContainer` in Info.plist is empty and iCloud is not offered. - **Agents**: none of Claude · ChatGPT · Gemini has an iCloud connector (checked 2026-10-02). iCloud recordings are read through the Mac's local folder (`~/Library/Mobile Documents/iCloud~app~recly/Documents/recly/`) (`skills/recly-notes`). - **Verification**: it goes as far as the core JVM tests (`ICloudFilesTest` · `ICloudUploadTest` · `ICloudJobTest` · `ICloudRemoteRecordingsTest` · `StorageSettingTest`) and RecKit `ICloudStorageTests`. Actual sync between two real devices on the same Apple ID has not been checked (§20). In particular, when two devices each create the same month folder (`recly/memo/2026-10`) before syncing, it is unknown whether iCloud merges them into one or splits them like `2026-10 2` — the list does not walk paths but looks for the folder property files, so even if they split, the list is correct; the Files app just shows two folders. #### Local folder - **Where**: iPhone, Mac, Windows and the Android phone (2026-10-03). The `Local folder` chip in `Storage`, and under it one row in the shape of the Drive row — `No folder chosen` · `Choose folder`, or the folder's own name (2026-10-11, every shell: the last part of the path as Finder, Files or Explorer shows it — the full path only in the tooltip and the accessibility value; a drive root shows itself) · `Change folder`; a folder that cannot be reached adds `This folder cannot be reached. Choose it again.` and offers `Choose folder`. Picking a folder releases the uploads waiting for one at once (`ReclyCore.resumeFolderWaits`) and runs the queue. The status is checked again when settings appear and when the app becomes active again. - **How the shell reaches it**: `CoreDeps.localFolder` (`LocalFolder`). The Mac and Windows hand the core's `PathFolder` a function that returns the picked path — the Mac app is not sandboxed (§12), so a path is enough. The Android phone uses the Storage Access Framework (`ACTION_OPEN_DOCUMENT_TREE` with a persisted read · write grant; an existing file is written over in place, since `createDocument` never replaces and would make `name (1)`, and every file is made as `application/octet-stream` so the provider keeps its name exactly). The iPhone is sandboxed: the folder picked in Files (`On My iPhone/Obsidian`, a folder in iCloud Drive, another app's provider) is kept as a `.minimalBookmark`, resolved into a security-scoped URL whose access is held while the app runs, and every read and write goes through `NSFileCoordinator` (RecKit `PickedFolder`; Apple, "Providing access to directories", checked 2026-10-03) — a coordinated read also brings down a file that is only in iCloud. The user can take that access back in Settings › Privacy & Security › Files and Folders, which reads as a folder that cannot be reached. Which folder was picked is kept on the device — Mac `UserDefaults` `storage.localFolder`, iPhone `UserDefaults` `storage.localFolderBookmark` (with its name in `storage.localFolderName`), Windows the shell's preferences, Android the app settings — not in the processing settings, and not exported. - **Layout**: the same as Drive — inside `{folder template}/{base}/`: the parts, `{base}.meta.json`, and with transcription on `{base}.transcript.json/.txt` plus `{base}.transcript.md` (§8 "Result files"). Nothing else: there is no folder-properties file, because the folder is never listed and the title is already in `meta.json`, which a rename rewrites together with the `.md`. File · folder ids are `folder:` + the path under the picked folder (`StorageKind.ofId`). - **Upload and completion**: the upload step (`drive.upload`, `store: folder`) **copies** the files, reads each one back for its md5, and is done right away — the copy is the upload. It makes no network request, so the copy and the transcript publishing run in the offline pass as well (§5 "Fixed processing settings") — on Android they wait for neither a connection nor Wi-Fi only. The originals' 7-day window (ADR-017) then starts as it does after a Drive upload, and playback after the sweep reads the parts back from the folder. The app deletes nothing in the folder except through "Also delete from the local folder". - **When it cannot be used**: no folder picked, the folder gone (a removed drive, a deleted or renamed folder), or the Android grant taken back — upload and result publishing are `WAITING`, rechecked every 5 minutes (`FOLDER_UNAVAILABLE`, list `Waiting for the local folder`, in the warning color of waiting, not the red of failure). Picking the folder again resumes them at once. - **Changing the folder**: later writes go to the new folder under the same relative paths — including the rest of a recording whose upload was cut short. Files already copied stay where they are. Because ids keep only the relative path, a folder the user moved and picked again is still found for playback and deletion. - **This device's alone**: a local folder is never listed (§3 "Recordings from other devices") — pointing two devices at one folder that another tool syncs does not merge their lists, and a row of this device is never dropped for not being listed. Drive's "Disconnect" leaves local folder jobs as they are, as it does iCloud's. - **No web link**: no `meta.drive` and no `Open in Drive`. The Mac has `Show in Finder` and Windows `Open the folder` for the recording's folder (in the folder picked now); the iPhone and the Android phone have no action there. - **Known limitations**: a full disk is an ordinary retryable failure, not `NEEDS_SPACE`. On Windows, Controlled Folder Access can refuse writes to protected folders such as Documents. - **Verification**: core JVM tests (`FolderUploadTest` · `FolderStorageTest` · `StorageSettingTest`), RecKit `LocalFolderStorageTests` and, on the iOS simulator, `PickedFolderTests` (pick, copy, read back, a relaunch through the bookmark, a folder gone), Windows `ShellStorageTest` · `PreferenceSettingsTest`, Android `FolderLabelTest`, and on the `rec36` emulator the real external-storage provider (2026-10-03): picking `Documents/…`, a recording landing under its exact names, a rename rewriting `meta.json` in place, a deleted folder making the upload wait with `attempts=0`, a new pick releasing it within a second, playback read back from the folder, and both delete options (`SafFolderTest`, opt-in, needs a folder picked first). Not checked: a real Windows PC (paths, the `JFileChooser`, Controlled Folder Access, a removed USB drive), a grant revoked by the system on Android, the transcript through a real engine on Android, and on a real iPhone: writing while the phone is locked (a background pass), a folder in iCloud Drive or another app's provider, and Obsidian picking up what was written (§20). ### Watch → phone transfer contract - Unit: one part file at a time + `meta.json` last. Android Data Layer paths: `/rec/part/{recordingId}/{part}/{track}/{sha256}/{file}`, `/rec/progress/{recordingId}`, `/rec/meta/{recordingId}` — the file name is carried in the path because the meta (= `{base}`) arrives last. Apple carries the same values in the `metadata` dictionary of `WCSession.transferFile` (`{recordingId, part, track, sha256, file}`, `{recordingId, progress}`, `{recordingId}`). - **Parts go while the watch is still recording** (2026-10-27, both watches alike). Each part is queued **as soon as its boundary files it**, and the pass that sends it puts an **in-progress meta** ahead of it: the recording's `meta.json` as it stands — `status: recording`, start time, time zone, segment length, tracks and every part filed so far with its offset and length. It is refreshed with each part, never acked, and finalizes nothing; a pass with no new part sends none. Galaxy Watch: `/rec/progress/{recordingId}`, one `RecorderHost.onPartFiled` → one WorkManager pass per part. Apple Watch: `{recordingId, progress: n}` with `n` the newest part it lists — the key that tells the one for the next part from a resend of this one while `WCSession` still carries it — and the pass's usual wake behind it. **The final meta stays last**: the last part and the final meta go once the recording is finalized, exactly as before, and registering the recording and creating its Job still happen only on the final meta. A phone out of reach changes nothing: the parts wait in the queue and go when it is back, the in-progress meta with them. - **The phone files what arrives while recording** into its receiving row: the in-progress meta opens the row (or updates it) with the watch's fields, keeps it `recording` and the receiver's (`transfer.pending.*`), and its part list gives each part that is here its offset and length — a part listed but not here is not added. Each part that is both here and listed — whichever arrived second — goes to the provisional pass of §8 "Organizing" (`TransferReceiver.partReceived` → `Notes.partFiled(received = true)`), so the phone transcribes that part with its own processing settings, pinned when the first in-progress meta arrived (§5), and adds its items, as for its own recordings. Consent, the quote check and placeholders are the core's, unchanged. An in-progress meta for a recording the final meta has already filed — or one this device made, or whose body names another recording or is not a watch's — changes nothing. The last part, which only the final meta lists, is left to the final pass, as a phone's own last part is. - **The Apple Watch sends the meta right behind the parts and then wakes the phone** (2026-10-26, GitHub issue #21: recordings stayed `Waiting to send` until the iPhone app was opened). A `WCSession` transfer waits until the receiving app runs, and the only thing that launches the iPhone app in the background is a live message from the watch (`sendMessage`), which works only while the watch app is in front and the phone is in range. A meta that waited for the part acks would need two such launches, with the watch app awake in between to hand it over. So the meta goes in the same pass as the parts, with an empty `sendMessage` behind them. `WCSession` delivers in the order things were sent, the phone accepts deliveries one at a time in arrival order, and `acceptMeta` files the meta only once every part it lists is on disk with its hash — a part that is not comes back as `Incomplete(missing)` and is resent. A part ack is only recorded and sends nothing. The Galaxy Watch still sends the meta once every part is acked: the Data Layer starts the phone's listener service for every delivery. - **The channel is closed after `onOutputClosed`** (2026-09-04, found on a real Watch7). The Task of `ChannelClient.sendFile` ends when the **request is accepted**, not when the transfer completes, and the official documentation says not to close the channel right after that and to learn of completion through `onOutputClosed`. Closing right away put the CLOSE in line behind the data, and the phone received `onInputClosed(CLOSE_REASON_REMOTE_CLOSE)`, discarded the file and did not ack (a 3.8 MB part was sent 6 times over an afternoon, 0 acks). The watch registers the callback before the first byte and waits for `CLOSE_REASON_NORMAL` before closing (any other reason is a link failure = `STALLED`). The phone accepts both `NORMAL` · `REMOTE_CLOSE` as "everything arrived", and sha256 is the final judge — a disconnect · timeout · local close means a truncated file, which is discarded. - **The phone's listener service is a new instance for every event** (2026-09-04, found on a real Z Fold7). Play Services delivers `onChannelOpened` · `onInputClosed` · `onChannelClosed` each to a newly created `WearableListenerService` instance and destroys it 1.5 seconds later. So the list of "channels being received" written to an instance field in `onChannelOpened` was empty at `onInputClosed`, and files fully received into the cache were ignored without any log (the second cause of the 0 phone acks). The staging file path is a function of the channel path alone (`cache/rec-transfer/{recordingId}/{file}`), and `onInputClosed` computes it again — no in-memory state is kept between the two callbacks. - For each part, the phone verifies the sha256 and sends an ack `{recordingId, part, track, ok}`. - The watch only **records** part acks and keeps the files; only after receiving `ack-meta ok:true` does it delete the parts · meta · directory · local row (if a later part or the meta gets a fatal nack, the parts acked earlier must still be there for resending · recovery to be possible). A `SHA256_MISMATCH` nack resends that part once; the second time it is fatal. - The phone files the meta the watch sent as it is — every field — and registers the recording in `recordings` and creates the Job when it receives the meta. The `recordingId` in the meta body must match the one in the path (mismatch → `RECORDING_ID_MISMATCH` nack, no core call), and **`ack-meta ok:true` is sent only after enqueue and waking the runner have finished** — if something fails before that, it does not ack and leaves it to the watch's resend (`acceptMeta` · `enqueue` are idempotent). If 24 hours pass after the **last** part arrived with no final meta, the orphan parts are deleted (2026-10-27: counted from the last part, not the first — a three-hour recording sends its first part three hours before its meta). The phones look at every launch — the iPhone's since 2026-10-30, a watch's wake included; before that it never looked, and a transfer the watch gave up on stayed receiving for weeks — and the Android phone also when the watch appears. - The user may delete the temporary row before the meta (2026-10-30, both phones, §3 "Deleting in the app"): the row, its parts and the receiver's `transfer.pending.*` · `transfer.progress.*` marks go together. Nothing is said to the watch. A watch that still has the recording keeps it until `ack-meta ok:true`, so its next part or meta opens a new row, the meta answers `Incomplete(missing)`, and the resend brings the recording back whole; a watch that gave up or was reset sends nothing more. - The temporary row before the meta **belongs to the receiver**: the phone's `RecordingRecovery` skips `recording` rows for which `TransferReceiver.receiving(recordingId)` is true (rows with the `transfer.pending.*` mark the receiver left on the first part or in-progress meta, refreshed by every part), and such a row is closed only by the meta arriving, by the 24-hour orphan cleanup or by the user's delete (above). A temporary row has empty `tracks` and zero-length parts, so finalizing it the way crash recovery does would freeze those empty values into the meta, and the Job would fail with `NO_INPUT_TRACK` (2026-10-01, found on a real iPhone that restarted between the parts and the meta). `RecordingRecord.receiving` (judging by the row's shape) is not used instead because the watch also revives its **own** `source = watch` recordings through the same recovery. - **Known limitation**: if `ack-meta ok:true` is lost, the watch resends the meta, and even if the phone has already uploaded and cleaned up and returns `Incomplete(all parts)`, the watch still has the parts (no deletion before ok), so it resends the parts · meta and the two converge (the phone's `acceptPart` overwrites, `enqueue` returns `AlreadyDone`). There is no case in which the watch treats `ack-meta ok:false` as completion; if a requested part is missing locally (deleted externally), it is marked failed with `PART_MISSING_LOCALLY` and the rest is kept. - During the transfer, the recording start time · `recordingId` are the values the watch created, used as they are (the phone does not regenerate them). The temporary row before any meta arrives (`TransferReceiver.placeholder`, parts without an in-progress meta) likewise reads its start time from **the ULID timestamp of `recordingId`** — using the arrival time would make just that recording jump to the top of the list on a phone that received a 20-minute recording after it ended. What the shell uses to recognize this row is `RecordingRecord.receiving` (the table at the end of §3 "Recordings from other devices"). - **The phone shows that the watch is recording** (2026-10-28, both watches alike). The watch publishes its recorder's state apart from the parts — `{recordingId, startedAt}` while it records, nothing once it stops — because the first part only comes a segment (5 minutes) in. It is written when the recorder starts recording, when it stops, and at app start when nothing is recording, so a process that died mid-recording leaves nothing standing once the app runs again. Galaxy Watch: a Data Layer item at `/rec/recording` (urgent), its payload hand-written JSON `{"recordingId":"…","startedAt":"…"}` (ISO-8601, UTC), deleted the moment the recorder leaves recording (before the finalize) — the watch's own item only, by its node id, so a phone paired with two watches keeps the other's. Apple Watch: the watch's application context `{recording: {recordingId, startedAt}}` (`startedAt` in seconds since 1970; the empty context `{}` is "not recording"), and the same dictionary as a live message when the phone is reachable, because a context does not wake the iPhone app — a launch is activated before it is in reach, so a message not yet sent goes when the session next reports the phone reachable. The phone keeps only the newest state and ignores one not shaped like it — an id that fails the transfer's id check, or a start before 2020 or more than a day past its own clock — and draws it (§11, §13 "iPhone"); none of it reaches the core or Drive. --- ## 4. Webhooks (formerly docs/04) > **Retired (2026-09-24)**: webhooks were removed from the product — the `webhook` step, the completion webhook setting, the payload and signature (Standard Webhooks), > `spec/webhook.payload.schema.json` and the local receiver script are all gone. Webhooks of jobs queued by earlier builds are not sent > either. This section is a record. **Exception**: the backoff formula in "Response handling" and the "a 429's Retry-After … capped at maxDelaySec" rule > remain valid as the retry rules of every step (`Executor` cites them). The shape of a result file entry (`files[]`, > `track: "transcript"`) also remains as step output (§8 "Result files"). ### Request > **Retired (2026-09-24)** — §4 preamble. ``` POST {url} content-type: application/json user-agent: rec/{version} ({platform}) webhook-id: {stepRunId ULID} webhook-timestamp: {unix seconds} webhook-signature: v1,{base64(HMAC-SHA256(secret, "{webhook-id}.{webhook-timestamp}.{body}"))} ``` - The signature is [Standard Webhooks](https://www.standardwebhooks.com/) as is (ADR-010). Without a `secretRef`, the `webhook-signature` header is omitted. - The secret is the raw bytes in secure storage. In the UI, a generate button creates 32 random bytes and shows them as `whsec_` + base64 (the Standard Webhooks convention). Signing uses the bytes left after stripping the `whsec_` prefix and base64-decoding. A generated value is **shown only once, at that moment**. - On a retry, `webhook-id` stays the same and `webhook-timestamp` and the signature are made anew. The receiver dedupes by `webhook-id`. - Timeout 30 seconds. Redirects are not followed. ### Payload > **Retired (2026-09-24)** — §4 preamble. ```json { "type": "recording.completed", "id": "01J9STEPR0N0123456789ABCDE", "timestamp": "2026-08-26T02:05:00.000Z", "data": { "recording": { "recordingId": "01J9ABCDEF0123456789ABCDEF", "source": "desktop", "platform": "macos", "title": "Weekly meeting", "startedAt": "2026-08-26T01:00:00.000Z", "endedAt": "2026-08-26T02:00:12.400Z", "durationSec": 3612.4, "timezone": "Asia/Seoul", "tracks": ["mic", "sys", "mix"], "context": { "app": "us.zoom.xos", "participants": 3 } }, "files": [ { "part": 1, "track": "mix", "name": "20260826T010000Z_desktop_01J9ABCD_p001_mix.m4a", "bytes": 3601234, "sha256": "…", "drive": { "fileId": "1AbC…", "webViewLink": "https://drive.google.com/file/d/1AbC…/view" } }, { "part": 1, "track": "meta", "name": "20260826T010000Z_desktop_01J9ABCD.meta.json", "bytes": 2210, "sha256": "…", "drive": { "fileId": "…", "webViewLink": "…" } } ], "folder": { "path": "recly/memo/2026-08/20260826T010000Z_desktop_01J9ABCD", "drive": { "folderId": "1XyZ…", "webViewLink": "https://drive.google.com/drive/folders/1XyZ…" } }, "workflow": { "id": "01J9ABCDEF0123456789ABCDEF", "name": "Meeting" }, "device": { "id": "7c1e4b2a-…", "platform": "macos", "name": "MacBook Pro" } } } ``` - `files[].drive` and `folder.drive` are filled only when a successful `drive.upload` exists **before** this step. **Otherwise null.** `files` holds only the files that upload step uploaded. - The meta file is included in `files[]` as `track: "meta"`. - If a preceding `transcribe` step succeeded, its result files are added as `track: "transcript"` (two entries, `{base}.transcript.json` and `{base}.transcript.txt`) (§8). If it failed or there is no such step, there are no entries. There is no separate object such as `data.transcript` — the receiver only needs to look at `files[]` and Drive. - `type` is only `recording.completed`. It was there from the start so that receivers branch on `type` when `recording.failed` and others are added later. ### Response handling > **Retired (2026-09-24)**: the table below is webhook-only, so it is a record. The backoff formula below the table and the table's > "a 429's Retry-After … capped at maxDelaySec" remain valid for every step (§4 preamble). | Response | Handling | |---|---| | 2xx | Success. Body ignored | | 408 · 425 · 429 · 5xx · network error · timeout | Retry (the `retry` rules). **If a 429 has `Retry-After`, that value takes precedence, capped at `maxDelaySec`** | | Other 4xx | The step is FAILED immediately (terminal), `onError` applies. The core message is `WEBHOOK_HTTP:{status}` | Backoff: `min(initialDelaySec × 2^(attempt-1), maxDelaySec)` ± 20% jitter. With the defaults: 30s, 60s, 120s, … capped at 3600s, 8 attempts. ### Receiver-side verification > **Retired (2026-09-24)** — §4 preamble. ```js import { Webhook } from "standardwebhooks"; const wh = new Webhook(process.env.REC_SECRET); // "whsec_…" const payload = wh.verify(rawBody, { "webhook-id": req.headers["webhook-id"], "webhook-timestamp": req.headers["webhook-timestamp"], "webhook-signature": req.headers["webhook-signature"], }); ``` The n8n Webhook node has no signature verification, so either verify with the library above in a Code node or, for a local n8n, use the `http://127.0.0.1` exception and leave it unsigned. The repository's local receiver `scripts/webhook-receiver.mjs` was deleted on 2026-09-24 (§20 "Local webhook receiver"). --- ## 5. Workflow storage · secrets (formerly docs/05) > **2026-09-24**: workflow storage (the local document · device default pointer · schema migration · export/import · save/edit · first-run > seed) was **retired**. What remains valid in this section is "Not synchronized", "Fixed processing settings", "Secrets" > and "Tokens". ### Not synchronized **Both the recording processing settings and secret values are per device.** Even when two devices use the same account, they do not see each other's settings, and a change made on one does not appear on the other by itself. Nothing is kept in Drive `appDataFolder` — no settings file and no `secrets.enc`, and so no pull/push, merge, freeze or `dirty` marker (replaces ADR-007). The only thing Recly writes to Drive is recording files, and those go to the user folder under the `drive.file` scope (§3). The one way to move settings between devices is **export/import** of the recording processing settings (see "Fixed processing settings" below). Secret values never leave in any file — a new device has no value for the `secretRef`, and the UI shows "No key on this device". What is not synchronized likewise includes: Job state, tokens, app settings (language · Wi-Fi only) (Wi-Fi only exists only in the phone shells: Android WorkManager UNMETERED, iOS allowsCellularAccess; 2026-09-03) (ADR-008). **The recording list is the exception** — not because it is synchronized, but because the recording folders in Drive are the list (ADR-023, §3 "Recordings from other devices"). ### Local state (`sync_state`) > **Retired (2026-09-24)**: the two rows below (`localDoc` · `deviceDefaultWorkflowId`) are not used. The key currently used in `sync_state` is > the recording processing settings `processing/settings` (see "Fixed processing settings" below). What follows is a record. One key/value table with just two rows, both belonging to this device. | Key | Value | |---|---| | `localDoc` | This device's workflow document. Running, editing and display all read this one document (`WorkflowStore`) | | `deviceDefaultWorkflowId` | This device's default workflow id (ADR-016). It is not inside the document, so an exported file does not carry over another device's choice | Even when the document cannot be read (a `schema` written by a later version, a step type this build does not know), **the row is left untouched**: being unable to read something is not the same as it not existing, and seeding the default workflows over it would erase the user's bytes. Such a device runs with the 2 default workflows and the row stays as is. ### Schema > **Retired (2026-09-24)**: the schema verdicts for the workflow document and the 1·2 migrations were removed. The format of the recording processing settings is > `spec/recording-settings.schema.json` v1 (see "Fixed processing settings" below). What follows is a record. The supported schema is **3** (§2). The verdict the parser reaches applies equally to the local document and to an imported file. | `schema` | Name | Behavior | |---|---|---| | `> 3` | **Newer** | `ParseResult.UnsupportedSchema` → not read. For an import this is the error corresponding to "Update the app"; for the local document, it runs on the defaults and leaves the row, as above | | `1..2` | **Outdated** | Read with the current rules and raised to the current schema (`ParseResult.Ok.migratedFrom`). The next save writes it back in the current schema | | `1..2` + retired fields | **Field-retirement migration** | `workflows[i].enabled` · `.isDefault` · `.trigger`, which ADR-016 removed, are **dropped knowingly, by name**. `trigger.minDurationSec` moves up to the top level of the workflow and `trigger.sources` is dropped | | `1..2` + unknown fields | **MigrationBlocked** | If there are fields the typed model would drop (`ParseResult.MigrationBlocked.fields`), it does not migrate and rejects the document — the moment it is written back, someone else's data would disappear | | `< 1` | — | `Invalid` | The lowest readable value is `WorkflowParser.MIN_SCHEMA` (=1). Schema 1 is schema 2 without `transcribe`, and schema 2 is schema 3 with three more retired fields, so reading with the current parser is the same as reading with each schema's own rules. ### Export · import > **Retired (2026-09-24)**: workflow export/import (`recly-workflows.json`) was removed. Today's export/import covers > only the recording processing settings (see "Fixed processing settings" below). What follows is a record. These are two items in the settings screen, and on the core side there are only `WorkflowRepository.exportJson()` / `importJson(json)`. Picking and writing the file is the shell's job (Android SAF, iPhone share sheet, Mac save panel, Windows file chooser). - **Export**: writes the saved document **exactly as serialized**. There is no separate format; the document itself is the format (`spec/workflow.schema.json`). The default file name is `recly-workflows.json`. **The device default workflow pointer is not included, and neither are secret values** — the file holds only `secretRef` names (ADR-008). - **Import**: parses and validates the file (§2), then **replaces the whole document.** There is no merge — the two devices' documents are not copies of each other, so there is no base to merge against. That is why a confirmation dialog first asks "Replace with N workflows". A file written in an old schema is migrated **exactly like** the local document, and a file that fails parsing or validation writes nothing and returns the parser's error list as is (the same display as editor validation). - Even if an import removes this device's default workflow, the pointer is left as is. A pointer with nothing to point to is not an error but the state in which `WorkflowSelector` picks nothing, and the shell asks the user to pick again (ADR-016). ### Saving · editing > **Retired (2026-09-24)**: the workflow editor and its save rules were removed. The concurrent-edit rule for saving settings is the > revision comparison in "Fixed processing settings" below. What follows is a record. - A save happens in one go in `WorkflowRepository.save`, in the order validate (§2 parser round trip) → replace `localDoc`. A failure is a save failure, and a failed save writes nothing. - A save stamps the document envelope (`revision += 1`, `updatedAt = now`, `updatedBy = deviceId`), because an exported file must be able to tell when and from which device it came. - Even on one device, two editors (two desktop windows, an import and an editor) can touch the same workflow. `WorkflowMutator` rereads the document inside a mutex and rejects with `MutationResult.Stale` if the `updatedAt` from when the editor opened has changed — there is no 3-way merge, so making the user reopen is the honest choice. ### First run > **Retired (2026-09-24)**: there is no seeding of the default workflow ("Memo") and no stamping of the device pointer. First run is the recording processing settings' > `initialize()` (see "Fixed processing settings" below). What follows is a record. - There is only one default workflow, "Memo" (Drive, `recly/memo/{{yyyy}}-{{MM}}`, mono) (user decision of 2026-09-04; the "Meeting" starter was removed). It is seeded with a fixed ULID (`00000000000000000000RECMEM`) and `updatedAt = 1970-01-01T00:00:00.000Z` — meaning nobody has edited it yet; the first edit stamps a real time. The name is created in the app language at the time of the first seed (en: "Memo", §7 rule 6). - `WorkflowRepository.seed(preferredDefaultId)` seeds the document if there is none, and stamps the default the shell picked ("Memo" on every device) **only when the pointer is empty**. It never moves a choice the user already made, and once stamped, it is never reverted — the remote document that would have justified a revert no longer exists. - Which path seeded the document first does not matter (a background enqueue's `current()` can run before the shell's `seed()`). The only condition is "is the pointer empty". ### Fixed processing settings 2026-09-24: recording is fixed to **record → upload the original → transcribe → upload the transcript** (`ProcessingPlan`: `drive.upload` → `transcribe` or `local.transcribe` → `transcript.publish` → `notes.organize` (2026-10-27, §8 "Organizing" — a no-op unless the user signed in to ChatGPT and agreed, `onError: continue`); with transcription OFF, only `drive.upload`). The phone offers three tabs (Record, List, Settings), the desktop its existing menu/tray plus processing settings, and the watch only recording and transfer. There are no user-editable workflows and no webhooks, and no compatibility layer that reads or migrates earlier workflow documents or settings. The overall direction and the conditions for a real-device release follow the [fixed processing flow plan](research/2026-09-24-fixed-recording-flow-plan.md). - `ReclyCore.processingSettings` uses a separate key `processing/settings` in `sync_state`. There is no new table and no DB version change. - The format is [`spec/recording-settings.schema.json`](../spec/recording-settings.schema.json) v1. It sets the storage location · minimum length, `local`/`external`/`off`, the language, and the external provider/key reference/endpoint/model. There is no global step array and no user workflow selection. Key **values** are never read, saved or exported. - There is no post-completion integration (webhook). The settings format (`spec/recording-settings.schema.json`) has no `webhook` field. - Export offers only the current recording settings. There is no export of earlier settings and no internal backup for recovery. - Starting from Siri/Shortcuts (`StartRecordingIntent`, no parameters) also uses the fixed settings. - **A recording pins the settings in force when it starts, and a retry the user presses does the remaining work with the current settings** (2026-09-25). A watch recording pins the phone's settings when its first in-progress meta arrives (2026-10-27, §3 "Watch → phone transfer contract"), so the parts organized while it records and its job run the same ones — or, if none arrived, when its final meta is filed. Automatic runs and automatic retries follow the job snapshot — saving settings alone never sends a waiting recording to a different provider. A manual retry (`JobService.retry`, failed or waiting state) rebuilds the plan with the current settings and merges it: succeeded steps keep their row and their definition from that time (the upload is not repeated), and the remaining steps start over with the new definition — a step whose definition changed also drops its earlier submission state (`state_json`) — steps missing from the new plan are deleted unless already finished, and newly added steps are added. The recording's pinned settings (`processing/recording/{id}`) are also changed to those settings. Retrying a failed recording after changing the key or fixing the CLOVA address runs with the new key and address. - **Storage** (2026-10-02, §3 "Storage location"): the settings' `storage.provider` (`drive` by default · `icloud` · `folder`). The single entry point for changing it is `setStorage`, which saves it immediately as the next revision. iCloud is accepted only on a device where the shell provided a container, and the local folder only where it provided one (`CoreDeps.localFolder`) — otherwise `Invalid`. Which folder was picked is the shell's, kept on the device, and not in this document. Saving the form and importing keep the storage this device has — it is a choice about the account, so each device decides it. If the storage changes while the form is open, the form takes the new revision and keeps its draft as is (`storageChanged`). - Speaker diarization and the minimum/maximum speaker count are not exposed in the UI. A new plan requests diarization automatically on providers/models that support it and infers the speaker count with the default range. A stored past `diarize=false` or speaker-count hint does not constrain a new plan. Groq and explicitly chosen non-diarizing OpenAI models use plain transcription. If the OpenAI model is left empty, the existing adapter's diarization-capable default model is used. An explicitly set model, provider or key is never replaced automatically. Local checks `supportsDiarization` at run time and reflects it in the actual request (`LocalTranscriptionRequest.diarize`), together with the head count the user gave (`expectedSpeakers` = `context.participants`, or null); an engine without diarization still transcribes. An engine that diarizes puts its own label on every segment, and the core renames them `S1, S2, …` in order of appearance and marks the transcript `identified` only when every segment has one (§8 "Result files", §10 "Shared rules for the shells"). - **Vocabulary** (2026-10-07): `transcription.vocabulary`, words and names the transcription should expect — at most 50, each trimmed and 1–40 characters on one line, unique ignoring case, in the order entered. One list for local and external modes. The form trims and drops blank entries (`ProcessingDraft.vocabulary`); a duplicate, an over-long entry or an entry with a line break is refused on save and import, and the error names its position, never its text. It is frozen into the plan like the other transcription settings (`vocabulary` on the `transcribe` / `local.transcribe` step), so a recording keeps the list it started with and a manual retry or a re-transcription takes the current one. What each provider receives of it is §8 "Vocabulary"; the settings show "{provider} does not use a vocabulary" where `SttProviders.supportsVocabulary(provider, model, language)` is false, and the same for an on-device engine whose `LocalEngineInfo.supportsVocabulary` is false (Apple `SpeechTranscriber` has no vocabulary input). - **Re-transcription** (2026-10-07) runs with the settings as they are when it is asked for — mode, provider, model, language, vocabulary (§10 "Re-transcription"). - If no settings are stored, `initialize()` creates the **Ready** state with the defaults (local transcription, memo folder `recly/memo/{{yyyy}}-{{MM}}`). It does not inspect old workflow documents or device pointers — there is no migration or review (`NeedsReview`) state. On builds and devices where the shell did not pass a local engine (`LocalTranscriptionEngine.installed == false`: Apple below OS 26, Android · Windows with less than 6 GiB of memory, Android in a 32-bit process, desktops without the sherpa-onnx native library), it prepares OFF instead of local — a method that always fails is never the default. The shell then hides the local option, and if local is already stored, shows it only together with a notice that it cannot be used. - Folder templates do not allow `{{workflowName}}` or `{{title}}` (there are no workflow names). - Settings that break constraints such as endpoint HTTPS or model length are rejected on save and import. Input is never truncated or silently corrected. - Save and import compare the current revision to prevent concurrent-edit overwrites. An imported revision/device is not adopted; the settings are saved as a new revision of this device. Future versions, unknown fields, explicit nulls and corrupt documents are not replaced with defaults. - Switching to OFF/local preserves valid existing external settings. Incomplete input drafts are managed in the shell, and the stored settings hold only values that passed validation, including inactive fields. Changing the method does not erase key values. - Per-provider `invokeUrl` · `model` are also remembered per provider, like keys (`transcription.providerDetails`, 2026-09-28). Choosing another provider and coming back restores them, whether mid-edit or after saving. One provider's values are never carried to another provider. Values that cannot be saved (an address with a placeholder left in it, and so on) are not remembered. The `invokeUrl` field is left empty, and the address format (`https://clovaspeech-gw.ncloud.com/external/v1/{appId}/{invokeKey}` and so on) appears only as placeholder text. - `read()`/`observe()` have no initialization side effects. The shell calls `initialize()` at startup. The watch does not run this initialization. - A new recording pins the settings revision in `processing/recording/` when it starts. A watch recording pins the phone's settings after the receipt is validated. A settings change does not alter existing recordings or in-progress jobs. - The runner uses the existing persistent queue with an internal fixed ID (`ProcessingPlan.ID`) and the `local.transcribe`/`transcript.publish` steps. It first saves the completed result locally and atomically, so a failed result upload does not rerun the API/model. - A local run shows `Transcribing on this device`, a wait for automatic resume shows `Transcription pending`, and no forced-retry button is offered. A local transcription waiting out its retry interval after a failure (a wait with `last_error` left) is not a wait for automatic resume. Like other steps, it shows `Retry pending`, the error and `Retry` (2026-09-27). In the settings' API key list, stored keys can be deleted one by one, with a confirmation before deletion. - The local settings have the same shape in all 4 shells (2026-09-26): a `Speech recognition model` row with the model name (Apple `Apple Speech`, Android · Windows `Qwen3-ASR 0.6B` — product names, so not translated); if the model is missing, `To transcribe on this device, download this model once (about 1GB).` (Apple leaves out the size because it cannot know it) and a `Download model` button; while downloading, the §9 square loader and `Downloading model…`; and `On-device transcription does not separate speakers.` (only where the engine reports no `supportsDiarization`, 2026-10-07). Once downloaded, the notice and the button disappear and only the name row remains. `Download model` cannot be pressed while a recording is starting, in progress or ending. On a metered connection (mobile data), Android asks first (`Download over mobile data?` — Cancel · `Download on Wi-Fi` · Download), and Windows asks when the capture helper's `--network-cost` answers metered (`Download over a metered connection?`; if unknown, it does not ask). - When the transcription method is switched to `On device` and the recording language is `Automatic` or `Korean and English`, the language changes to the app language, or to English if the model lacks that language — the local list has only explicit languages, so leaving it as is would make `Save` impossible to press. An explicit language the user chose is kept even if the model lacks it, with a line saying it is not supported (Apple · Android, 2026-09-29). - **Waiting for and downloading the model (2026-09-26).** A recording that cannot be transcribed locally because the model is missing is not a failure but a `NEEDS_MODEL` wait (§10). The list row shows `Waiting for speech model` (badge `NEEDS_MODEL`, warning tone) and a `Download model` chip, and the banner shows the same reason and the download action — the download happens right there instead of sending the user to settings. In local mode, when the engine exists, the model does not, and no recording is waiting yet, a `Transcribe on this device` card appears once above the recording screen (Mac popover, Windows tray) (`Not now` · `Download model`; `Not now` is remembered on the device and the card does not appear again). The body says only what gets downloaded — Android · Windows `On-device transcription needs Qwen3-ASR (988 MB), an open-source speech recognition model.`, Apple `On-device transcription needs Apple’s speech recognition model.` (2026-09-27). The user chooses the transcription method, so the card promises no outcome such as nothing being sent out once the model is downloaded. Once a recording is waiting, the card hides and the banner (with the count) is the only notice. Settings, banner, row and card share one download state per shell and show progress as `Downloading model… N%` (Android · Windows also show bytes, like `412 MB of 988 MB`; Apple shows only % because it cannot know the size). While downloading, the banner shows the progress and `Cancel download`, and waiting rows hide the chip (progress appears in the banner alone). When stopped, the first of a recording's fixes (§9 principle 2) is the emphasized `Download model`. With a partial download it is `Resume download`, and cancelling (`Cancel download` — distinct from the form's `Cancel`) keeps the downloaded part. On Android it is a WorkManager foreground job (progress and cancel in the notification), so the download continues after leaving the app, and choosing `Download on Wi-Fi` waits for an unmetered connection (`Waiting for Wi-Fi`). - Apple's local engine is the iOS/macOS 26 `SpeechTranscriber`/`SpeechAnalyzer`, and the user requests the language asset download in settings. For a shared asset installation already handed to the OS, the OS manages when it completes and when it is retried. The model counts as present when `AssetInventory.status` answers `installed` or `SpeechTranscriber.installedLocales` lists the locale — on macOS 26.6 the status still answered `supported` minutes after the system had finished the install, while `installedLocales` already listed it (2026-10-05). When a model download finishes, only `NEEDS_MODEL` waits for the same language resume automatically; completed uploads, saved transcription progress, other failures and the disconnected state are kept. The download is requested from the system regardless of thermal and power state (thermal and power limits apply only to transcription, 2026-09-27). If the request ends and the model is still missing (except on cancel), `The model didn’t finish downloading. Try again.` is shown — if only the button came back, pressing it would look as if it did nothing. One file is fed to one analyzer, and PCM is read through a bounded buffer. Multiple recording parts are joined losslessly, and temporary files are cleaned up on success, failure or cancellation. With speaker separation the transcriber also gives each word's time (`audioTimeRange`, 2026-10-08): a final result can run on across a change of speaker (two speakers in each of a 12- and a 17-second result, Korean, macOS 26.6), so a result is cut where its words change speaker. Each word takes the turn it overlaps longest (§10 "Shared rules for the shells", `assign`) — counted from where speech starts again when a pause falls inside its time, since a word's time begins where the word before it ended. Timing stays per segment; without speaker separation nothing changes. At thermal state `serious` or above it waits/stops (relaxed 2026-09-27 — `fair` is common even while charging, and the user cannot guess why it stopped). Low Power Mode does not stop it. Confirmed segments are saved, so once the device cools, it resumes automatically at the next chance to run. - Local computation runs one at a time per device and yields when a new recording starts. A platform run expiry/cancellation also cancels the native analyzer. Exception (2026-10-08): on Android and Windows, sherpa-onnx's speaker separation cannot be interrupted inside a block — the library ignores its progress callback's return value — so a cancellation waits for the block in progress (up to 20 minutes of audio, a few minutes of CPU) and takes effect at the next block boundary or PCM chunk. The local-only pass does not run Drive authentication, network requests or result publishing — except the upload and transcript steps bound for a local folder (§3 "Storage location"), which write to the device and make no request, so they need no connection and ignore Wi-Fi only, and `notes.organize` while organizing is off on the device, which then sends nothing (2026-10-27, §8 "Organizing"). The Wi-Fi constraint on network uploads stays as is. - The local engine on Android · Windows is Qwen3-ASR 0.6B INT8 (`Qwen3Asr`) on sherpa-onnx 1.13.8 (2026-09-26). The model (about 1 GB, 7 files) is not bundled in the app and is downloaded only through the user's download action (settings, a waiting recording or the banner, the first-run card) — it is pinned by commit, size and SHA-256, downloaded in 8 MB range requests that resume after an interruption, and only files whose hash matches are moved into place (`LocalModelStore`; paths in §15). The folder is Android `noBackupFilesDir/models/` and Windows `{dataDir}/models/`, a separate one per model revision. Once downloaded, it is never downloaded again — an update that changes only the runtime uses the same folder, and when a new model has been fully downloaded, the previous revision's folder is deleted. Silero VAD cuts the recording into speech segments, and segments are split into pieces of 20 seconds or less for decoding — for input beyond the model's context, sherpa-onnx returns empty text instead of an error. Confirmed segments are saved per piece, so cancel, resume and waiting take effect at piece boundaries. **Repetition guard** (2026-11-05, `RepetitionGuard`): every decoded piece passes through it before it is saved. Qwen3-ASR decodes greedily and can loop — transcribe.cpp's input-limits notes list `qwen3_asr` among the families that do — and a piece stuck in a loop used to become up to 256 tokens (`MAX_NEW_TOKENS`) of one phrase. sherpa-onnx 1.13.8 stops a loop of at most 3 distinct tokens over its last 64 and removes it (its Qwen3-ASR decoder, read 2026-11-05); the guard covers longer blocks and loops that stop by themselves. It counts **units of the text**: each Hangul, Han, Kana and Thai character, and each other run of letters, digits and marks, compared ignoring case; spaces, punctuation and symbols part units. sherpa-onnx's result tokens are not used as the unit — they do not always join up to the text (it removes U+FFFD from the text, not from the tokens), and a word is a different token with and without a space before it — only their count, to tell a decode that used up its budget (at least 240 tokens: sherpa-onnx leaves out Qwen's "language … " opening, which it looks for in the first 16). A piece that **ends in copies of one block** is cut back to what came before them and their first copy, when the copies cover at least 64 units — 8 copies for a block of up to 27 units, sliding to 4 by 48, blocks up to 128 (transcribe.cpp's bar, MIT) — or, after a decode that used up its budget, at least 3 copies over 32 units. The copies start where their boundaries most often meet a space or punctuation, so a cut never splits "감사합니다" just because "…니다" also ended the sentence before. A piece where **one unit makes up 75%** of the units or more and occurs at least 16 times is dropped (LiveSub-Offline-Translator's `NoiseTextFilter`, MIT). Emphatic repetition — "네 네 네", "no, no, no", "좋아요 좋아요" — is far below both bars, and no piece loses a word for being a particular word. A trim or a drop logs `local.repetition.removed` with `action` (`trimmed` · `dropped`), `units`, `removed` and `budgetHit` — counts, never text. It has not been checked against real looping Qwen output on a device (§20). Timing is per segment; speakers come from sherpa-onnx's `OfflineSpeakerDiarization` where the engine reports `supportsDiarization` (the models are §15 "Android · Windows local transcription models"). It runs on 2 CPU threads. Android waits at thermal state `SEVERE` or above (battery saver does not stop it, 2026-09-27); Windows has no thermal signal to wait on. Languages are chosen only from those this model knows (Ukrainian excluded; they are passed to the prompt by their English names). Windows extracts the native libraries from the jar into `{dataDir}/native/{revision}/` and uses them from there — the sherpa-onnx default loader extracts into a new temporary folder on every run, and a loaded DLL cannot be deleted. That folder is user-writable, so on every load it compares SHA-256 against the copy inside the app, rewrites it on a mismatch, and deletes previous revision folders (`Natives`). The Android APK includes sherpa-onnx only for the 64-bit ABIs (`arm64-v8a` · `x86_64`) and leaves out the C · C++ API libraries that JNI does not use — in a 32-bit process the local engine is not created. Inference runs inside the app process and stops when capture starts. **Real-device heat, long recordings and accuracy have not been verified** (§20). There is no automatic fallback to heavy CPU inference or an external API. - Local transcription v2 states `speakerIdentification` and `timing` explicitly. Without diarization — or with a segment the engine left unlabelled — it writes `speakers=[]`, `speaker=""`, and it does not present the result as having identified a single speaker. Reading v1 external API results and existing files is kept. ### Secrets - Name: `^[a-z][a-z0-9_]{0,31}$`. The recording processing settings hold only the name (`secretRef`) (ADR-008). The name is **the provider id as is** (`elevenlabs`, `clova` …, 2026-09-25) — each provider keeps its own key, so switching providers never sends another provider's key, and the settings screen has no name field. The settings screen and the transcription node on the recording screen show the provider by `SttProviders.displayName` (ElevenLabs, CLOVA Speech …, proper nouns that are not translated), while transcript files, logs and settings documents use the id as is. - Storage: | Platform | Implementation | Details | |---|---|---| | Android phone · Galaxy Watch | `androidx.security:security-crypto` 1.1.0 `EncryptedSharedPreferences` | The master key is `MasterKey` (`AES256_GCM`, wrapped by Android Keystore), key names `AES256_SIV` · values `AES256_GCM`, file `rec_secure`, entry names `{ns}/{key}`. The only thing the watch holds is the installation's device UUID (ADR-002) | | iPhone · Apple Watch · macOS | Keychain generic password | One item per `(ns, key)`, service is the app-name prefix + ns, accessibility `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | | Windows | Credential Manager | `CredReadW`/`CredWriteW`/`CredDeleteW`/`CredEnumerateW` via JNA, `CRED_TYPE_GENERIC`, `CRED_PERSIST_LOCAL_MACHINE` (not uploaded to roaming profiles) | `EncryptedSharedPreferences` · `MasterKey` are deprecated upstream, but **we keep using them** (the code records that fact with `@Suppress("DEPRECATION")`). With a direct DataStore + Keystore implementation, (a) the seal/unseal code and key rotation would fall on us, (b) there are zero users to migrate, and (c) `EncryptedSharedPreferences` decrypts even the key names, so `names(ns)` works without a separate name index (a separate index drifts from the real store through restores and crashes). If the library is actually removed, we move then. On the Windows development host (macOS), `DevFileSecureStore` (plaintext base64 JSON) is used instead — it is **development only** and is never selected on Windows. - The entry point for values is **`ReclyCore.secrets`** (`SecretsRepository.put/delete/get/names`). The core owns a single namespace, and it must have a single entry point, so shells do not write to `SecureStore` directly. - **Values never leave the device.** Not in files, not in exports, not to the watch. On a new device the user enters them again — in exchange, a key leaking from one device does not leak the other devices along with it. - If the value is missing at run time, that step is FAILED immediately with `MISSING_SECRET` (no retry), and `onError` applies. - UI: the shared secret (API key) list offers only name lookup and deletion, and appears inside the external transcription item only when external transcription is chosen (2026-09-25; changing the method does not delete keys). If the chosen provider's key is stored, an **"API key … ✓ Saved on this device"** row with `Replace key` · `Delete` is shown instead of the input field — the ✓ is the same character as the chips' selection mark, in the success color (§9 "Every state is color + text"). The input field is not filled with a fake mask (`••••`), and the mask is not used as placeholder text: placeholder text disappears as soon as typing starts and screen readers do not read it properly (W3C WAI · NN/g), and Compose M3 keeps the placeholder of a labeled field transparent until focus. Pressing `Replace key` brings up an empty input field and `Cancel`. The list below (`Other providers’ keys`) shows only the other providers' keys. The value of a new key is entered in the external transcription item of the recording processing settings. The webhook signing key generation action (en `Generate a webhook signing key`) was retired (2026-09-24). - **An unreadable secure store fails closed.** If the shell's Keychain/Keystore/Credential Manager refuses the list query itself (`errSecMissingEntitlement`, `errSecInteractionNotAllowed` on a locked device, an ACL denial), that exception passes through the core unchanged — reading it as "none" would wrongly put "No key on this device" on steps that have a `secretRef`, and the `tokens` cleanup of `ReclyCore.disconnect` would report a namespace that still holds tokens as emptied. So `disconnect` throws, which leaves the shell still owing the cleanup (`REVOKED_CLEANUP_OWED`) and lets it show a retry. - **Deleting a key runs only after confirmation** (2026-09-10). The key name is shown first. Cancelling keeps the key. The confirmation screen does not show the key value. This is common to Android · iPhone · macOS · Windows. - **"Disconnect" does not delete secrets** (§3). The values are not derived from the account but are this device's settings, and once deleted they cannot be recovered from anywhere. ### Tokens - The Google access/refresh token lives in the same store as secrets, in a different namespace (`tokens`). It is not synchronized. - The core calls only `TokenProvider.accessToken()`. Refreshing and prompting the user to sign in again are the shell's job (§6). - The ChatGPT sign-in (§15 §10) is the core's own, in a namespace of its own (`chatgpt`: `host`, `registration`, `session` and its pieces), so a Drive disconnect — which empties `tokens` — leaves it alone. The session is split into 2,000-character pieces because a Windows credential holds 2,560 bytes: `session.a.0`, `session.a.1`, … with `session` (`a:2`, the generation and the count) written last and the next session in the other generation, so a write cut short leaves the previous one whole. --- ## 6. Authentication (formerly docs/06) This section is about Google Drive authentication. iCloud (§3 "Storage location") has no app authentication — the system uses the device's iCloud account — and neither does the local folder. ### GCP project 1. Create the project and enable the Drive API. 2. OAuth consent screen: External, **published to Production** (under Testing, the refresh token expires after 7 days). The app name, logo and privacy URL are required, and after publishing, an app with only non-sensitive scopes passes without verification. 3. Scope: `https://www.googleapis.com/auth/drive.file` **only.** It is non-sensitive and sees only the files the app created. Nothing is kept in appdata any more, so `drive.appdata` is not requested (§5). **Do not add other scopes** (calendar and the like are sensitive → they trigger the verification process). 4. Client IDs (one per platform, Google policy): | Client | Type | Identifier | |---|---|---| | Android | Android | Package name + signing SHA-1 (for debug and release each) | | iOS | iOS | Bundle ID `app.recly` | | macOS | iOS type (keeps the existing client and callback scheme) | Bundle ID `app.recly.mac` | | Windows / JVM | Desktop app | loopback redirect `http://127.0.0.1:{port}` | | Wear OS · watchOS | None | The phone acts on its behalf | The client files (`google-services.json`, `GoogleService-Info.plist`, `client_secret*.json`) are in `.gitignore` and are injected locally at build time. If `GIDClientID` in the `Info.plist` of the two Apple apps is a placeholder, the sign-in button is disabled and the Job of a stopped recording is parked at `NEEDS_AUTH`. ### Android - Sign-in: Credential Manager `GetSignInWithGoogleOption(serverClientId)` — only the "Sign in with Google" button flow is used. Sign-in happens only when the user presses **Connect Drive** in Settings. Google classifies the bottom sheet (`GetGoogleIdOption`) as an automatic prompt the app raises on its own and the button flow as the counterpart of a button the user pressed (the Firebase quickstart and Flutter `google_sign_in` draw the same line); this app never raises sign-in on its own, so it does not use the bottom sheet. The old "allowed accounts → all-accounts bottom sheet → button" ladder never reached the button on Android 17 devices, because the system `CredentialSelectorActivity` waited without ever being drawn (2026-10-02). Restoring the account and the Drive permission when the app is reopened does not go through this sign-in. - If the button flow fails with `NoCredentialException`, the result is `SignInResult.NoAccount`. The UI opens the system add-account screen with `Intent(Settings.ACTION_ADD_ACCOUNT, EXTRA_ACCOUNT_TYPES=["com.google"])`, and on return retries sign-in **only once** (to prevent a loop). - After a cancellation (`GetCredentialCancellationException`) or a Play Services failure, no other account-picker screen is opened. - No nonce is used. The ID token is used only to identify the account and is never sent to a server, so there is nothing to bind a replay attack to. - The add-account branch is logged as `auth.signIn.fallback=addAccount`. - Authorization: `Identity.getAuthorizationClient(activity).authorize(AuthorizationRequest{scopes: drive.file})` → `accessToken` (1 hour). Silent for an account that has already allowed it. - Refresh: the app does not hold a refresh token itself. `TokenProvider` calls `authorize()` again when the token is within 60 seconds of expiry. If `hasResolution() == true` (re-consent needed) comes back in **a WorkManager context with no activity**, the Job is set to `NEEDS_AUTH` and a notification asks the user to open the app. **When the app comes to the foreground** (`MainActivity.onStart`), if the user is signed in and there is a `NEEDS_AUTH` Job, the activity calls `authorize()` again without the user pressing anything (2026-09-04) — for an account that has already allowed it, this passes silently and the Job returns to `PENDING`; if consent is really needed, the consent screen appears right there. If the user cancels, the banner stays and the next start asks again. The buttons on the banner and the row make the same call (when signed out, they lead to sign-in in Settings). - The Drive row in Settings shows connected (account email + `Disconnect`) only when **both the account and the Drive permission are present** (2026-09-29). The state where the consent screen was closed after sign-in, leaving only the email saved and no permission, shows, as on iPhone, `Drive not connected` + a `Connect Drive` row, and that button tries again (starting from account selection; there is no separate "Allow again" row). Permission = an access token is in secure storage (`AndroidTokenProvider.held`). While a disconnect is owed (`disconnectPhase.owed`), it is the disconnect row regardless of the permission. - **Closing** the account picker or the consent screen **says nothing** (`SignInResult.Cancelled` · `AuthorizeResult.Cancelled`). A failure shows only `Could not connect Drive` in the danger color under the row (the same sentence as on iPhone), and the Play Services reason is not put on screen but logged (`auth.signIn.failed` · `auth.authorize.failed`, `reason`). If the device has no Google account, the same spot shows `Add a Google account, then connect Drive again`. Success is told with no sentence, by the row changing to the account (the same as on iPhone). - Storage: only the access token + its expiry time, plus the account email for picking the same account again (`account/email`), in secure storage. Signing out deletes both. ### iOS · macOS - Uses AppAuth for **OAuth Authorization Code + PKCE (S256) requesting only `drive.file`**. `openid` · `email` · `profile` are not requested. The flow is `Connect Drive` → choose a Google account and allow the Drive permission → back to the app, with no separate consent to sharing name and email. It follows Google's consent format for a single non-sign-in scope. - Authentication happens in the system authentication session (`ASWebAuthenticationSession`). The existing app window is used as the presentation anchor, and the app shows no notice window of its own such as “Continue signing in in your browser”. Google's authentication screen and the operating system's confirmation remain. - The existing iOS-type client ID and the `/oauth2callback` of the reversed client ID scheme are kept. The `GIDClientID` key in `Info.plist` is a name kept for compatibility with the existing settings and release checks. - AppAuth handles the random `state` and PKCE verification, the code exchange and token refresh. It requests `access_type=offline`, and the connection is shown as complete only after the response's actual Drive permission, client ID and refresh token are verified and saving to the keychain succeeds. A denied permission or a cancellation returns to the unconnected state, and a save failure is never shown as a successful connection. - Credentials are kept as AppAuth state in the `app.recly.drive.oauth` keychain item. The Mac uses the same login keychain as before, and the iPhone uses a this-device-only keychain that is accessible after the device is unlocked. A token rotation result is saved before it is returned. A token within 60 seconds of expiry is refreshed, and a 401 clears the cache, forces a refresh and then follows the core's retry rules. - The old GoogleSignIn `auth` keychain item is read with GTMAppAuth, and only credentials that have the Drive permission are migrated. The old item and the email sign-in hint are deleted only after the new item is saved. The profile and ID token are not copied into the new item. The migration neither revokes Google permissions nor requests new consent. So a previously granted profile permission itself is not revoked automatically at Google; when the user disconnects and connects again, the Drive-only request is used. - A new connection does not request, store or show the account email, and Settings shows `Drive connected`. - A missing keychain item and a read failure are told apart. A read failure is never treated as unconnected in a way that skips revoking the permission. Authentication or refresh results that arrive late during a disconnect are not saved. - Disconnecting POSTs the refresh token to `https://oauth2.googleapis.com/revoke` and then cleans up the keychain and local work. No token is passed in a redirect. The existing disconnect phases, failure recovery and record of revocation failures are kept, and a keychain deletion failure is left as a local cleanup failure to retry. Revocation applies per Google Cloud project. - **Background URLSession**: the token is refreshed before each chunk transfer, and **a 401 replans that chunk** (ADR-015). - App Review 4.8: Recly has no account creation or authentication of its own; Google authentication is used for access to the user's Drive. Recording, and playing back recordings stored on the device, work without a Google connection. Under the current contract, transcription is preceded by a Drive upload. The review notes present the actual build's unconnected record · save · play path. ### Windows (JVM) - Not in the core but in Kotlin in `windows/app`: a Ktor server on a temporary 127.0.0.1 port → the system browser opens `https://accounts.google.com/o/oauth2/v2/auth` (PKCE S256, `access_type=offline`) → the code is received → exchanged at `https://oauth2.googleapis.com/token`. The refresh token lives in Windows Credential Manager, and refresh is `JvmTokenProvider` (60 seconds before expiry). - The redirect is `http://127.0.0.1:{port chosen by the OS}`. Not `localhost` — RFC 8252 §8.3 "the use of `localhost` is NOT RECOMMENDED … avoids inadvertently listening on network interfaces other than the loopback interface". The port is not registered (§7.3 "MUST allow any port to be specified at the time of the request"; Desktop-type clients do not take redirect URIs at all). The server opens only when sign-in starts and closes when the response arrives. - `state` is a 128-bit random value, and a mismatch is rejected (§8.9). The redirect is accepted **only once** — any program on this device can knock on that port. A second request gets only the "This sign-in has already been handled" page. - `prompt` is **not attached on the first sign-in** ("the user will be prompted only the first time your project requests access", "include `prompt=consent` only when necessary"). Signing in again on a device that already has a refresh token means switching accounts, so only then is it `prompt=select_account`. - `include_granted_scopes` is not used ("Incremental authorization is not supported for installed apps or devices"). There is no `login_hint` either — no profile scope is requested (ADR-009), so the app never knows an address to fill in. **Windows does not store the account email.** - Internal sign-out is **local only** and does not call `https://oauth2.googleapis.com/revoke`: "Revocation removes all OAuth 2.0 scopes previously granted to a **project**, invalidating any issued access or refresh tokens for all clients registered under that project." The remaining grant expires after 6 months without use. Only the app's "Disconnect" calls `/revoke`. - `invalid_grant` during refresh means the grant is dead → discard what is stored and sign in again (`AuthRequiredException`). The same code at the exchange step means a PKCE mismatch, so the two are told apart by which call failed. - A Desktop-type `client_secret` is not a secret ("Installed apps … cannot keep secrets"). Still, it differs per developer, so it is not committed. - Opening the browser has 30 seconds, waiting for consent 5 minutes. `Desktop.browse` blocks, so it is opened in a separate scope and only the **waiting side** gives up. The success page is a single inline HTML page — if it loaded even one external resource, this URL, which contains the code, would leak out as the `Referer`. ### Wear OS · watchOS No authentication. The watch does not access Drive (ADR-002). (Sending the workflow summary was retired on 2026-09-24.) ### The core interface ```kotlin interface TokenProvider { /** A valid access token. Throws AuthRequired when refresh fails or re-consent is needed. */ suspend fun accessToken(): String /** Lets the shell force a refresh after a 401 */ suspend fun invalidate() } ``` When the core gets a 401, it calls `invalidate()` and retries once; on another 401 it sets the step to `NEEDS_AUTH` (no retry count is used up; it resumes when the user signs in). ### Refresh token limits 100 per account per client ID. 6 devices × a few reinstalls leave plenty of room. A token expires after 6 months without use — phones and desktops are fine, and a device that has not been used for a long time signs in again. --- ## 7. i18n (formerly docs/07) The UI supports **23 languages**: English (en), Korean (ko), Japanese (ja), Simplified Chinese (zh-Hans) · Traditional Chinese (zh-Hant), Spanish (es), French (fr), German (de), Brazilian Portuguese (pt-BR) · European Portuguese (pt-PT), Arabic (ar), Hindi (hi), Russian (ru), Italian (it), Polish (pl), Turkish (tr), Filipino (fil), Bengali (bn), Urdu (ur), Swahili (sw), Vietnamese (vi), Persian (fa), Thai (th). The default language follows the system language and can be changed inside the app (setting name **App language**). The transcription language is a separate setting from the UI language (§8). Its setting name is **Spoken language** — the language spoken in the recording, not a translation target. ### Rules 1. **The base language is English** (the default value of the resource keys). Region tags are normalized to a supported language, and only an unsupported language falls back to English. For Chinese, an explicit Hans/Hant script takes precedence; without one, TW/HK/MO map to Traditional and everything else to Simplified. For Portuguese, a bare `pt` and Brazil map to pt-BR and every other region to pt-PT (CLDR). Arabic, Urdu and Persian get an RTL layout. 2. **Language setting values**: `system` (default) and the 23 language tags above. **It is a per-device setting and is not synchronized** (it does not go into the recording processing settings). It is stored by platform convention (Android `LocaleManager.applicationLocales` + DataStore, Apple `UserDefaults`, Windows `java.util.prefs`). **The settings UI is one row that shows the current language as its value**, and on all 4 shells the list opens from the dropdown at the end of the row (value + `▾`, §9 principle 4) (2026-09-29; the phone used a dialog) — however many languages are added, the row stays one line. The list has **no** `System default` item: only each language's own name (`English`, `한국어`, `日本語`, `العربية`, etc.); while nothing has been chosen yet, the app follows the system language and **that language appears selected** (the row shows its name too) — in exchange, we accept that there is no item that returns to following the OS after an explicit choice. The names are not translated in any language. A choice takes effect immediately (rule 3), and flags are not used. 3. **Runtime switching**: changing the setting changes the UI without a restart (Android `setApplicationLocales` recreates the activity, SwiftUI uses the `\.locale` environment value, Compose recomposes from the locale state). AppKit/UIKit notices (NSAlert, UNNotification), the tray menu, Live Activity and complication text are looked up explicitly in the current app language (`String(localized:bundle:locale:)`, Android `createConfigurationContext`). 4. **Every user-visible string is a resource**: UI text, notifications, dialogs, error guidance, accessibility labels, widgets/complications, the tray. **Not made into resources**: log events and fields (`rec.*`, `shell.*`), file and folder naming rules, the recording processing settings JSON, code identifiers. Numbers are numbers — a value such as a participant count is written as a number, not as a sentence, and the only values that become sentences are `Unknown` and `6+`. 5. **User strings the core produces**: instead of natural language, the core puts a **message key** (`CoreMessage` enum: `NEEDS_AUTH`, `DRIVE_REAUTH`, `MISSING_SECRET`, `FROZEN`, `STALE`, `DRIVE_STORAGE_FULL`, `AUTH_REJECTED`, …) into `StepRun.lastError` and exceptions, and each platform translates the key. An argument is attached as `NAME:{arg}` and what the provider said as `NAME|{detail}`; the part after `|` is not translated and is shown as is, in monospace, under the sentence. The shells read it with `CoreMessageRef.parse`. Old sentences already stored in the DB are shown as they are (compatibility). 6. **Retired (2026-09-24)** — The **default workflow name** is created in the app language at the time of the first seed (en: "Memo"). Changing the language later does not change the name, because it is user data. The seed ID is fixed (§5). 7. **Dates, times and numbers use the platform locale formatters** — the pattern itself is a resource, so a Korean device reads "8월 28일 15:04" and an English device reads "Aug 28, 3:04 PM". File name timestamps (ISO) do not change. 8. **Jurisdiction-specific consent notices** (§12) are per-language resources, but the links and the list of jurisdictions are shared. 9. **Completeness tests**: each platform has a test that "every key exists in all 23 supported languages" and a check that "no Hangul literal remains in UI source" (allow list: logs, tests, comments). RecKit additionally confirms by scanning that "every key a view draws is in the catalog" (it fails the day a key with a mismatched spelling, such as a curly apostrophe, is used). 10. **Device name substitution rule**: the word a sentence uses when it refers to this device is fixed per shell — Android and iPhone use `phone`, macOS **Mac**, Windows **PC**, and a sentence that does not specify which device it is (the core's `CoreMessage`, RecKit sentences shared by several shells) uses `this device`. Sentences with the same meaning must differ across shells only in this one word and be otherwise identical letter for letter — "This phone's secrets" / "This Mac's secrets" / "This PC's secrets", "Delete on this phone only" / "…this Mac only" / "…this PC only". The watch has no sentences of its own and follows the phone's. 11. **Cross-shell dictionary**: a sentence that appears in two or more shells is **identical letter for letter** in those shells (the node notation policy of §9 screen principle 1, the words of the status badges, editor field labels, the guidance on the secrets screen, etc.). `CrossShellDictionaryTest` (Android jvm test) reads the Windows `.properties` and the RecKit · RecMac · RecPhone catalogs directly and compares en and ko. Format arguments are written differently per platform (`%1$s` ↔ `%@`), so they are normalized before comparison. **A dictionary sentence that only one shell says so far** is locked by that shell's own test (this test rejects a one-shell line), and it moves here once the other shells have the key — `Receiving from the watch` · `Uploading on another device` · `Transcribing on another device` from 2026-09-04 (§9 screen principle 2) were added by three shells on the same day, so they are in this dictionary from the start. The existing English and Korean resources are kept. The shared dictionary for the additional languages lives in `localization/translations/`, and `scripts/localize.py` generates the Apple String Catalog, Android XML and Windows properties from it. `python3 scripts/localize.py --check` checks for missing messages, format arguments and that the generated output matches. Only technical identifiers and the languages' own names are explicitly exempted in `localization/unchanged.json`. Quantity sentences are written in a form whose grammar does not break with the number, and Android uses the `other` plural resource. ### Platform mapping | Platform | Resources | Language setting UI | Switching | |---|---|---|---| | Android phone | `values/strings.xml` (en) + per-language `values-*/`, `android:localeConfig` | Settings → language row → dropdown | Platform `LocaleManager.applicationLocales` (API 33+; the app is a plain ComponentActivity, so the AppCompat path is a no-op) | | Galaxy Watch | Same approach (no settings UI) | — | Follows the phone app's language, as the Apple Watch follows the iPhone's: the phone sends its app language over the Data Layer and the watch applies it as its per-app locale (`LocaleManager`, API 33+); the system language until one has arrived (2026-10-15) | | iPhone · Apple Watch · macOS | String Catalog (`Localizable.xcstrings`) — RecKit has the shared catalog and each app has its own | Settings → language row → dropdown | SwiftUI `\.locale`; AppKit/UIKit strings use an explicit locale lookup; `AppleLanguages` is not touched | | Windows desktop | `strings_{language}.properties` + `Str` enum keys | Settings window → language row → dropdown | Locale `StateFlow` → recomposition, tray menu rebuilt | | Core | `CoreMessage` keys | — | — | ### Windows desktop design notes - **The resource format is `.properties` + a `Str` enum.** Many of the sentences the tray app says are built outside composition — `TrayIcon.displayMessage` balloons, the sign-in redirect page Ktor serves, `Recents.stateLabel`. A plain table that can be read immediately from any thread is the only shape that fits. It is read directly as UTF-8 (`Properties.load(InputStream)` is ISO-8859-1). - **Type-safe keys:** the `Str` enum is the key list, and a property key is the enum name lowercased with dots (`STATUS_WAITING` → `status.waiting`). A string that is not in the table does not compile in the first place, and `StringTableTest` cross-checks the en and ko tables against the enum. - **Argument format:** positional arguments of `String.format(locale, …)` (`%1$s`, `%1$d`) — the same notation as the Android side, so translations carry over as is and a translation can change the order. - **Nesting · verbatim:** `UiMessage` has two cases, `Res(key, args)` / `Text(verbatim)`, and an argument can itself be a `UiMessage`. They are resolved together at draw time, so changing the language redraws the whole thing. - **Switching:** `Localization` holds a single `StateFlow`, and composition reads it with `collectAsState()` and passes it down as a parameter. Outside composition, code reads `localization.current` on the spot. The tray menu is a pure list built by `trayMenu()`, and `Main.kt` only carries it over to AWT items. - **Storage:** a single `language` key in the `app/recly/windows` node of `java.util.prefs`. --- ## 8. Transcription (formerly docs/08) `transcribe` (STT + speaker diarization) is the step that goes into the fixed plan when the recording processing settings choose an external API as the transcription method (§5 "Fixed processing settings"; when local, it is `local.transcribe`). It runs where the other steps run, on **the device that runs the job** (phone · Mac · Windows), and there is no server. The device calls the STT API directly with the user's key and writes the result to the Drive recording folder. ### Principles - **Serverless** — every call goes device → an API on the user's account (Drive, STT). No intermediate relay, no callback URL. So STT providers are used only through a **pollable asynchronous API or a synchronous API** (callback-only modes are not used). - **BYO key** — the key is a `secretRef` in the existing secret store (§5). If it is missing, `MISSING_SECRET`. - **Drive is the bus** — result files are placed in the recording folder `{folder}/{base}/` (for a recording that chose iCloud or a local folder, the same folder there, §3 "Storage location"). Other devices, agents and the user only need to look at those files. There is no separate device-to-device communication. - **One track, one file** — one file, joined from the parts of one track (`mono` or `mix`) by remux, is uploaded. Per-part transcription is not used, because the speaker labels would differ from part to part. The one exception is a recording over the provider's declared limit: it goes as a few overlapping pieces of that track, whose labels are matched up again (§8 "Length · size limits"). ### Step definition #### `transcribe` ```json { "id": "stt", "type": "transcribe", "provider": "clova", "secretRef": "clova_key", "invokeUrl": "https://clovaspeech-gw.ncloud.com/external/v1/1234/abcd…", "language": "ko", "diarize": true, "speakers": { "min": 2, "max": 6 } } ``` | Field | Default | Meaning | |---|---|---| | `provider` | required | `assemblyai` \| `clova` \| `rtzr` \| `openai` \| `groq` \| `together` \| `mistral` \| `elevenlabs` \| `deepgram` \| `azure` \| `daglo` \| `speechmatics` \| `rev` \| `gladia`. A value the client does not know is a validation error (`UnknownProvider`) | | `secretRef` | required | The provider key. See the provider table for the value format | | `invokeUrl` | — | Its meaning differs per provider (`WorkflowParser.invokeUrlUse`). **Required**: `clova` (per-app invoke URL) · `azure` (resource endpoint). **Optional**: `openai` · `groq` · `together` · `mistral` · `speechmatics` — empty means the provider's default endpoint; a value takes its place (self-hosting · region). **Others**: a value is a validation error. A trailing `/` is trimmed. The required providers have a template (`WorkflowParser.invokeUrlTemplate`: `clova` → `https://clovaspeech-gw.ncloud.com/external/v1/{appId}/{invokeKey}`, `azure` → `https://{resourceName}.cognitiveservices.azure.com`) that the settings screen fills into an empty field, and a URL with a `{…}` left in it is a validation error (`InvokeUrlPlaceholder`) | | `language` | `ko` | `ko` \| `en` \| `ko-en` \| `auto`. A value the provider cannot accept is mapped by the adapter to the closest value | | `diarize` | `true` | Whether to request speaker diarization | | `speakers.min` / `speakers.max` | 1 / 10 | Speaker count hint. If the meta `context.participants` is present, it **overrides** them with `min = max = participants` (information from the time of recording is more accurate than the workflow default). The upper bound is 10 people, so `6+` means the whole range above it | | `vocabulary` | `[]` | The words and names of the processing settings (§5 "Fixed processing settings"), frozen with the plan. What reaches the provider is §8 "Vocabulary" | | `model` | provider default | Provider-specific model name. A free string; the provider does the validation. `clova` · `assemblyai` · `azure` · `rev`, which have a fixed model or no model to choose, do not read this value (`SttProviders.acceptsModel`), so the fixed processing plan does not send it and the settings screen hides the input field. When the provider is changed on the settings screen, the model and `invokeUrl` are filled with the values remembered for that provider (empty if there are none; placeholder text for the address format of a required provider) — so that another provider's name or address does not carry over (§5 `providerDetails`) | Input track: `mono` if the recording's `tracks` has `mono`, otherwise `mix`. If neither exists, the step is `FAILED(NO_INPUT_TRACK)` (non-retryable). Precondition: `drive.upload` must come **before** this step (`TranscribeNeedsUpload`) — because the folder the result files go into is that step's output. A re-transcription uploads nothing, so its `transcript.publish` names the recording's folder itself (`folderId`, §10 "Re-transcription"). Output: `{ transcript: { jsonFileId, txtFileId, language, speakerCount, durationSec, provider, model }, files: [ … ] }` — `files[]` is the `name/bytes/sha256/drive{fileId,webViewLink}` of the result files (json · txt) (the shape the webhook payload read; webhooks were retired on 2026-09-24). ### Providers The list for a new external transcription setting, and its default choice, follow the order **ElevenLabs → CLOVA → AssemblyAI → RTZR → OpenAI → Groq → Together → Mistral → Deepgram → Azure → Daglo → Speechmatics → Rev → Gladia**. A saved provider is not changed. iPhone (App Store) shows only the eight in §15 "iPhone providers", in the same order (2026-09-29). The transcription languages are the 20 choices `ko`, `en`, `ja`, `zh-cn`, `zh-tw`, `es`, `fr`, `de`, `pt`, `ar`, `hi`, `ru`, `it`, `id`, `tr`, `vi`, `th`, `nl`, `pl`, `uk`, plus the existing `ko-en` · `auto`. The initial language of a new setting is decided from the device language. Only the choices the provider and model support are shown. CLOVA keeps Korean · English · mixed · Japanese · Simplified/Traditional Chinese, and Daglo keeps the verified Korean · English · mixed. Mistral is limited to 13 languages, RTZR sommers to Korean · Japanese, and English-only Groq models and special Deepgram models to English. Apple local queries `SpeechTranscriber.supportedLocale(equivalentTo:)` on the actual device, and whether the model is installed is shown separately. If an API accepts only ISO language codes, the two Chinese choices are sent as `zh`, and as `cmn` for Speechmatics · Rev. The Chinese script is not force-converted. The rationale and implementation scope follow the [global language settings record](research/2026-09-24-global-language-settings.md). | provider | Auth (`secretRef` value format) | Submit | Polling | Diarization · hints | Language mapping | Default model | |---|---|---|---|---|---|---| | `elevenlabs` (Scribe) | Header `xi-api-key`; value = key | `POST https://api.elevenlabs.io/v1/speech-to-text` multipart(`file`, `model_id`, `language_code`, `diarize`, `num_speakers`, `timestamps_granularity=word`, `tag_audio_events=false`), synchronous | None (synchronous) | `diarize`, `num_speakers` (only when a single value is known) | `ko`→`ko`, `en`→`en`, `ko-en`→`ko`, `auto`→omitted | `scribe_v2` | | `clova` (Naver CLOVA Speech long-form) | Header `X-CLOVASPEECH-API-KEY`; value = key string. `invokeUrl` is a step field | `POST {invokeUrl}/recognizer/upload` multipart(`media`, `params` JSON), `completion: "sync"` (≤2 h; the result is the response body) | None (synchronous). HTTP timeout 15 minutes | `diarization.enable`, `speakerCountMin/Max` (≤10) | `ko`→`ko-KR`, `en`→`en-US`, `ko-en`→`enko`, `auto`→`ko-KR` | — | | `assemblyai` | Header `authorization`; value = key | Default base URL `https://api.eu.assemblyai.com/v2` (EU region — excluded from training, §15 "Provider retention policies", 2026-09-29). `POST /v2/upload` (bytes) → `upload_url`; `POST /v2/transcript` `{audio_url, speech_models:["universal-3-5-pro","universal-2"], language_code, speaker_labels, speakers_expected?}` | `GET /v2/transcript/{id}` → `queued`/`processing`/`completed`/`error`. `Waiting(30s)` | `speaker_labels`, `speakers_expected` (when min=max) | `ko`→`ko`, `en`→`en`, `ko-en`→`ko`, `auto`→`language_detection: true` | `universal-3-5-pro` → `universal-2` (the documented default routing, 2026-09-25 — a language the first model cannot handle, such as Korean, falls through to the second model. The transcript file's `model` is the response's `speech_model_used`) | | `rtzr` (Return Zero) | Value = `{clientId}:{clientSecret}`. `POST /v1/authenticate` → JWT (6 h), cached (step state) | `POST /v1/transcribe` multipart(`file`, `config` JSON) → `id` | `GET /v1/transcribe/{id}` → `transcribing`/`completed`/`failed`. `Waiting(30s)` | `use_diarization`, `diarization.spk_count` (passed only when min=max) | `sommers` supports only `ko`/`ja` → `ko`→`sommers`; `en` · `ko-en` · `auto` use `model_name: "whisper"` + `language: "en"`/`"multi"`/`"detect"` | `sommers` | | `openai` | Header `Authorization: Bearer {key}` | `POST {base}/audio/transcriptions` multipart(`file`, `model`, `language`), synchronous (the response body is the result). base defaults to `https://api.openai.com/v1`, replaceable with `invokeUrl` | None (synchronous) | Decided by the model name — if the name contains `diarize`, `response_format=diarized_json` + `chunking_strategy=auto`; if it starts with `whisper`, `verbose_json` + `timestamp_granularities[]=segment`; otherwise `json`. No speaker count hint | `ko`→`ko`, `en`→`en`, `ko-en`→`ko`, `auto`→omitted (automatic detection) | `gpt-4o-transcribe-diarize` with `diarize`, otherwise `whisper-1` | | `groq` | Header `Authorization: Bearer {key}` | `POST {base}/audio/transcriptions` multipart, synchronous. base defaults to `https://api.groq.com/openai/v1`, replaceable with `invokeUrl` | None (synchronous) | **None** — Groq has no speaker diarization, so `diarize` is ignored and speakers are `null`. `verbose_json` fixed | Same as above | `whisper-large-v3-turbo` | | `together` | Header `Authorization: Bearer {key}` | `POST {base}/audio/transcriptions` multipart, synchronous. base defaults to `https://api.together.ai/v1`, replaceable with `invokeUrl` | None (synchronous) | `diarize=true` + `response_format=verbose_json` + `timestamp_granularities=segment`, plus `min_speakers`/`max_speakers` (range) | Same as above, but `auto`→`language=auto` (without `language` this API uses `en` — API reference, 2026-09-25) | `openai/whisper-large-v3` | | `mistral` (Voxtral) | Header `Authorization: Bearer {key}` | `POST {base}/audio/transcriptions` multipart, synchronous. base defaults to `https://api.mistral.ai/v1`, replaceable with `invokeUrl` | None (synchronous) | `diarize=true` + `timestamp_granularities=segment`. No speaker count hint | Same as above | `voxtral-mini-latest` | | `deepgram` | Header `Authorization: Token {key}` | `POST https://api.deepgram.com/v1/listen?…` query (`model`, `smart_format=true`, `punctuate=true`, `utterances=true`, model-improvement opt-out `mip_opt_out=true`), the body is the raw file bytes, synchronous | None (synchronous) | Sends only `diarize_model=latest` (sending the old `diarize` flag along with it is rejected). No speaker count hint | `ko`→`language=ko`, `en`→`language=en`, `ko-en`→`language=ko`, `auto`→`detect_language=true` | `nova-3` | | `azure` (Fast transcription) | Header `Ocp-Apim-Subscription-Key`; value = key. `invokeUrl` is the resource endpoint (step field, **required**) | `POST {invokeUrl}/speechtotext/transcriptions:transcribe?api-version=2025-10-15` multipart(`audio`, `definition` JSON), synchronous | None (synchronous) | `diarization.enabled` + `maxSpeakers` (clamped to the 2..35 the API accepts). With `diarize: false`, `diarization` is left out entirely | `ko`→`["ko-KR"]`, `en`→`["en-US"]`, `ko-en`→`[]` (multiple locales make the whole file be processed as one language, so mixed speech is left to the multilingual model with no locale — recommended by the fast transcription guide, 2026-09-25), `auto`→`[]`. `profanityFilterMode: "None"` (the default `Masked` masks profanity) | — | | `daglo` (Daglo) | Header `Authorization: Bearer {key}` | `POST https://apis.daglo.ai/stt/v1/async/transcripts` multipart(`file`, `sttConfig` JSON) → `rid` | `GET https://apis.daglo.ai/stt/v1/async/transcripts/{rid}` → `transcribed` (done) / `input_error` · `transcript_error` · `file_error` (terminal failure) / anything else is in progress | `speakerDiarization.enable`, `speakerCountHint` (when a single value is known and is 2 or more) | `ko`→`ko-KR`, `en`→`en-US`, `ko-en`→`mixed`, `auto`→`ko-KR` | `general` | | `speechmatics` | Header `Authorization: Bearer {key}`. base defaults to `https://eu1.asr.api.speechmatics.com/v2`, replaceable with `invokeUrl` (e.g. a `us1` host) | `POST {base}/jobs` multipart(`data_file`, `config` JSON) → `id` | `GET {base}/jobs/{id}` → `running` (in progress) / `rejected` · `deleted` · `expired` (terminal failure, resubmit) / `done`. On `done`, `GET {base}/jobs/{id}/transcript?format=json-v2` in the same poll | `diarization: "speaker"` (otherwise `"none"`). No speaker count hint | `ko`→`ko`, `en`→`en`, `ko-en`→`ko`, `auto`→`auto` (language identification; the detected language is read from the tokens' `alternatives[].language`. The job config has `language_identification_config.low_confidence_action: "allow"` — the default rejects when it is not confident), `zh-tw`→`cmn` + `output_locale: "cmn-Hant"` (the default output is Simplified) | `enhanced` (`operating_point`) | | `rev` (Rev AI) | Header `Authorization: Bearer {key}` | `POST https://api.rev.ai/speechtotext/v1/jobs` multipart(`media`, `options` JSON) → `id` | `GET …/jobs/{id}` → `in_progress`/`failed`/`transcribed`. On `transcribed`, `GET …/jobs/{id}/transcript` (`Accept: application/vnd.rev.transcript.v1.0+json`) in the same poll. **Result wait limit of 8 hours** (non-English jobs take up to 6 hours to process, so the default 2 hours is not enough) | `skip_diarization` (= the inverse of `diarize`). No speaker count hint | `ko`→`ko`, `en`→`en`, `ko-en`→`ko`, `auto`→`ko` (leaving the field out means English by default, not detection, so it is always sent) | — (`transcriber: "machine"`) | | `gladia` | Header `x-gladia-key`; value = key | `POST https://api.gladia.io/v2/upload` multipart(`audio`) → `audio_url`; then `POST https://api.gladia.io/v2/pre-recorded` JSON → `id` | `GET https://api.gladia.io/v2/pre-recorded/{id}` → `queued` · `processing`/`error`/`done` | `diarization`, `diarization_config.number_of_speakers` (when a single value is known) or `min_speakers`/`max_speakers` | `ko`→`["ko"]`, `en`→`["en"]`, `ko-en`→`["ko","en"]` + `code_switching: true`, `auto`→`language_config` omitted | — (`model` is sent only when specified) | The adapters live in the `transcribe/` package of `core/commonMain` as `interface SttProvider { submit(ctx, file): Submitted; poll(ctx, ref): PollResult }`, and `TranscribeRunner` knows only remux → submit → poll → normalize → Drive write, regardless of the provider. The default model names are updated to each provider's current GA model. Since the user can change `model`, **the core does not validate the name.** **OpenAI's two defaults stop working on 2027-02-26.** OpenAI's deprecations page (announced 2026-08-26) shuts down `whisper-1`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` and `gpt-4o-transcribe-diarize` that day and names `gpt-transcribe` as the replacement. `gpt-transcribe` answers only `json` and `text`: in a real call on 2026-11-05, `verbose_json`, `diarized_json`, `srt` and `vtt` were each rejected with `400 unsupported_value`, and `stream=true` sent text deltas with no times. As a default it would turn a recording sent as one file into a single segment with no speaker, so the defaults stay as they are (2026-11-05). In January 2027 we look again for an OpenAI model that diarizes; without one, the last release before the shutdown makes `gpt-transcribe` the default and stops counting `openai` as diarizing. A model the user set explicitly is not replaced (§5 "Fixed processing settings"). #### Length · size limits A one-hour meeting is roughly 14 MB at 32 kbps AAC. Still, every provider has a cap, and going over it gets a 4xx rejection (`UNSUPPORTED_AUDIO`, not retried). **Only the limits a 32 kbps recording can realistically reach** are declared by the provider in `SttProvider.limits` (`SttLimits(maxBytes, maxDurationSec)`). **Limits that vary by tier or plan are not declared** — we would then reject, or split, a recording that a higher plan would take whole. Such limits, and the limits 32 kbps cannot reach, are still learned only from the provider's 4xx, as before. What the declared limits decide (2026-10-27, `TranscribeChunks`): - **Within them** — the track's parts in the meta, `bytes` and `durationSec` summed — the recording goes as one file, as it always has. The runner checks the joined file right before uploading and, if it is over after all, fails with `UNSUPPORTED_AUDIO` before sending any bytes (not retried). The detail states the actual value together with the limit, like `30 MB exceeds openai's 25 MB` — the shell shows it as is under the `UNSUPPORTED_AUDIO` sentence. - **Over them**, it goes as **overlapping pieces of the same track**, planned from the meta's part list. A piece is consecutive whole parts within **90%** of every declared limit (the margin covers the joined file's container), and every piece after the first starts with **the last part of the one before** — a whole part, 5 minutes, of overlap — then new parts. The plan has the fewest pieces that fit, as even as they can be: `clova`'s 2 hours hold twenty-one parts, so 3 hours (36 parts) go as parts 1–18 and 18–36. Only when no plan can — one part alone, or two parts in a row, over 90% of a limit — does it fail with `UNSUPPORTED_AUDIO` before sending any bytes, the detail naming both; for a provider that took 25 minutes, `45m exceeds {provider}'s 25m; parts 1 and 2 together are 30m, over 90% of 25m`. - The pieces go **one after another**, each joined, checked and submitted like a whole recording; a synchronous provider answers each in turn, an asynchronous one is polled piece by piece. `state_json` carries the plan, the piece in flight and its ref; a finished piece's result goes to `.chunk-{stepRunId}-{n}.json` in the local recording directory (it can run to megabytes, too much for a row every poll rewrites) before the state moves past it, and these files are deleted once the result files are written. So a process that dies never pays for a finished piece again and goes on polling the one in flight, and a timeout or a provider failure drops only that piece's ref ("Polling · status"). - **Putting them back together**: each piece's times move by the length of the parts before its first one, onto the joined file's axis, so normalization is the same as for one file. The **seam** lies inside the shared part, where the longest run of words both pieces heard — the same text lowercase and without punctuation, starting within 1 second of each other — is cut at the middle of its widest pause between consecutive words, the quietest moment both agree on. A run shorter than 3 words takes the earlier piece's widest pause in the middle half of the shared part instead; results without words take the middle of the part. The earlier piece keeps what starts before the seam and the later one what starts from it; a segment across the seam is split at its words (its text rebuilt from them), and one without words goes by its start. **Speakers**: inside the shared part, every pair of labels is scored by how long both speak at the same moment, and the largest scores of at least 3 seconds pair up one to one. A label left over — someone the shared part did not hear — is then matched **by voice** (2026-10-27) with the device's own speaker-embedding model (`CoreDeps.speakerEmbedder`; on Android and Windows the speaker diarization model of §15 "Android · Windows local transcription models", once downloaded; on the iPhone and Mac the bundled WeSpeaker embedding of §15 "Apple on-device speech model assets", each span run the way FluidAudio re-embeds one): for each such label and each label of the earlier pieces it could be, up to 60 seconds of its segments of 1.5 seconds or more — each trimmed 0.25 s at both ends and at most 10 s of it, taken evenly through its time, cut in two where one part file ends — are read from the recording's own part files, all asked for at once, and each label gets a centroid, the mean of its unit embeddings. The most similar pairs at the model's own cosine threshold or more (`SpeakerEmbedder.linkThreshold`: 0.6, `SpeakerTurns.LINK_THRESHOLD`, on Android and Windows; 0.8 on the iPhone and Mac, where different people of one real recording came to 0.77 and one person to 0.83 or more) join one to one, never with an earlier label another label of the same piece already took: two labels of one piece stay two people. A label still left over is someone new, under a label no one else has — unless the expected speaker count (§8 `speakers`, `context.participants`) is already reached, when it joins the label it shares the most with. Without the model, when embedding fails, or for a label without enough speech, the pieces pair by the shared part alone, as before — `transcribe.voices.unavailable` or `transcribe.voices.failed` at WARN; `transcribe.voices` gives the labels asked and answered. The embeddings exist in memory for the merge only. Labels stay empty where the provider gave none. Model and language come from the first piece, and `transcript.json` has no `jobRef` — no single job made it. - With `language: auto`, the later pieces are **not** told the language the first one found: providers report it in their own forms (a locale, an ISO code, a name), and for `rtzr` the language also picks the model, which would change between pieces. | provider | Limit | Checked before upload | |---|---|---| | `openai` | File 25 MB (roughly 1 hour 44 minutes at 32 kbps). `gpt-4o-transcribe-diarize` is reported to accept only up to about 25 minutes, separately from that (unofficial) | 25 MB is checked. The diarize model's 25 minutes is unofficial, so it is not checked | | `groq` | Free tier file 25 MB | No — it varies by tier | | `together` | Audio 4 hours (file 80 MB) | Only the 4 hours is checked. 80 MB cannot be reached at 32 kbps | | `gladia` | Audio 135 minutes (standard plan. Enterprise is 4 hours 15 minutes) | No — it varies by plan | | `clova` | Synchronous call 2 hours | Yes | | `azure` | Fast transcription audio 5 hours | Yes | | `daglo` | Audio 4 hours | Yes | | `rtzr` | File 4 hours (2 GB) — file STT documentation, 2026-09-25 | Yes | #### Vocabulary 2026-10-07. The list of §5 "Fixed processing settings" travels on the request each adapter already sends — no new endpoint — in the field that provider documents, with that provider's caps, **verified against each API reference on 2026-10-07**. The rule is that a vocabulary never costs a transcription: where the field is not documented for the model or the language, or a combination could fail a request that works without it, nothing is sent (`Vocabulary.applies`; the settings read it through `SttProviders.supportsVocabulary`). Terms are kept in the order entered; what a provider would refuse is dropped, never cut. Nothing is sent for an empty list. | provider | Field | What is sent | Nothing is sent for | |---|---|---|---| | `assemblyai` | `keyterms_prompt`, JSON array | ≤ 200 terms (Universal-2's ceiling) of ≤ 6 words and ≤ 50 characters | languages outside Universal-3.5 Pro's set — `ko`, `ru`, `id`, `th`, `pl`, `uk` — and `ko-en`, `auto`: they run on Universal-2, where the effect is not documented. Never `word_boost` or `prompt` | | `deepgram` | `keyterm` (Nova-3) or `keywords` (Nova-2, Nova, Enhanced, Base), one repeated query parameter per term | `keyterm`: terms up to an estimated 400 tokens — over 500 fails the whole request; `keywords`: single words, ≤ 100, no intensifier. Never both | `auto` (`detect_language` with keyterms is not documented); other models | | `openai` | `prompt` | the terms joined with ", " up to an estimated 200 tokens (Whisper reads 224) — `whisper-1`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` | `gpt-4o-transcribe-diarize`, the plan's default when it diarizes: it takes no prompt | | `groq` · `together` | `prompt` | the same | a model that is not Whisper | | `mistral` | `context_bias`, one repeated multipart field per term | ≤ 100; spaces become `_` and commas go (item pattern `^[^,\s]+$`) | any language but `en` (context biasing is "experimental" elsewhere). Its combination with `timestamp_granularities` is undocumented, like the `language` one Recly already sends | | `elevenlabs` | `keyterms`, one repeated multipart field per term | ≤ 100 (more adds a 20-second minimum bill), under 50 characters, ≤ 5 words, none of `<>{}[]\` | a model that is not `scribe_v2` | | `clova` | `params.boostings` | one entry `{"words": "a, b, …"}` — the reference's own example — commas taken out of the terms, one-character terms left out (one syllable is not boosted) | `ko-en` (`enko` is not named) and the languages keyword boosting does not cover ("Korean and English only") | | `rtzr` | `config.keywords` | ≤ 500 of ≤ 20 characters: complete Hangul syllables only on `sommers`, Hangul · Latin · digits on `whisper` | anything but Korean audio | | `azure` | `definition.phraseList.phrases` | ≤ 2,000 | `ko-en` and `auto` — no locale, the multilingual model, where a phrase list is not documented | | `daglo` | `sttConfig.keywordBoost {enable, keywords}` | every term; the boost level is left at its default | anything but `ko-KR` (Korean, and `auto`, which this adapter sends as Korean) — add-on features are Korean only | | `speechmatics` | `transcription_config.additional_vocab [{content}]` | ≤ 1,000 of ≤ 6 words, no `sounds_like`; `auto` included | an `operating_point` other than `enhanced` / `standard` | | `rev` | `options.custom_vocabularies [{phrases}]` | ≤ 500 without digits, ≤ 12 words, no word over 34 characters, Basic Latin for English and Latin letters for `es` · `fr` · `de` · `pt` | any other language — a list Rev cannot use fails the whole job, and Korean is not documented | | `gladia` | `custom_vocabulary: true` + `custom_vocabulary_config.vocabulary` | ≤ 500 plain strings (Gladia replaces close matches after transcribing; it is not biasing) | — | Some providers bill the field as an add-on (pricing pages, 2026-10-07): AssemblyAI keyterms +$0.05 per hour, Deepgram keyterm prompting $0.0013 per minute pay-as-you-go, ElevenLabs keyterms a surcharge on the transcription. On the device, the Android and Windows Qwen3-ASR engine may hand the list to sherpa-onnx as its `hotwords` stream option (shell work, `LocalEngineInfo.supportsVocabulary`); Apple `SpeechTranscriber` has no vocabulary input. Sources: each provider's API reference, feature guide and pricing page, checked on 2026-10-07. ### Audio preparation (remux) `CoreDeps.audio.concat(parts: List, out: File)` — the shell implements it. It joins the parts of the same track in part-number order **losslessly** (segment boundaries are lossless, so the AAC frames are copied as is. No re-encoding). | Platform | Implementation | |---|---| | Android | `MediaExtractor` + `MediaMuxer` (AAC track copy, pts continued across parts) | | Apple | `AVAssetReader` → `AVAssetWriter` (`outputSettings: nil`, passthrough). `AVMutableComposition` + `AVAssetExportSession(presetPassthrough)` fails with `AVErrorOperationNotSupportedForAsset` because each part has a different format description | | Windows / JVM desktop | Bundled ffmpeg (ADR-019) `-f concat -safe 0 -c copy`. ffmpeg exits 0 with truncated output even when a part is missing, so the existence of the parts is checked first in Kotlin | Encoder padding (each part has frames ~0.18 s longer than its presented duration) is trimmed by keeping only the frames of which at least half falls within the presented duration — otherwise the timestamps of the later parts shift further with every part. `gaps` (meta) is ignored — the timeline of the joined file is "the time where there is audio", and the result timestamps are mapped back to the recording timeline with `parts[].startOffsetSec` (`start/end` in `transcript.json` are relative to the recording). The output is a temporary file and is deleted when the step ends (unrelated to the part retention rules). ### Polling · status The result of `StepRunner.run` includes **`StepOutcome.Waiting(retryAfterSec, state)`** (§10). - `Waiting` does **not consume** `attempts`. The Job goes to `WAITING(next_run_at = now + retryAfterSec)`, the step stays `PENDING`, and `state` (submission ref · token · submission time) is saved. So **while transcription is in progress there is no budget notion of "when" to retry** — there is only the next polling time. - If the time since submission exceeds **the result wait limit the provider declared** (`SttProvider.resultTimeout`, default 2 hours; only `rev` has 8 hours — non-English jobs take up to 6 hours to process, so giving up at 2 hours would abandon a live job and pay for the same audio again) or the provider returns a **terminal failure state** (`failed`/`error`), the runner **clears the state** and then throws `FAILED(RESULT_TIMEOUT | PROVIDER_ERROR)` (retryable) — the Executor does not clear state on failure, so it has to be cleared for **the retry to submit anew**. Transient errors during polling (network · 5xx · 429) keep the state and keep polling the same ref. - The shell's scheduler adapter wakes up again at `next_run_at` (Android WorkManager `OneTimeWorkRequest` delay, iOS `BGProcessingTaskRequest.earliestBeginDate`, desktop timer). iOS does not guarantee the exact time, so `runDueJobs` is also called when the app enters the foreground. - A synchronous provider (`clova sync`) that receives cooperative cancellation while waiting for the HTTP response drops the request and, instead of `Waiting`, **resubmits** (there is no result ref). Synchronous calls are harmless on desktop but can exceed the execution time budget in the mobile background — the recommended default provider on mobile is `assemblyai`/`rtzr` (asynchronous). ### Errors | reason | Retry | Condition | |---|---|---| | `MISSING_SECRET` | No | This device has no key with that name | | `AUTH_REJECTED` | No | 401/403. The UI shows **"Check the key"** + a way into the settings | | `QUOTA` | Yes (No when the balance or usage limit is exhausted) | 429 (`Retry-After` first), 402. When the provider says the balance or usage limit has run out (Rev `insufficient_balance` · `invoicing_limit_exceeded`, RTZR 429 `A0001`), a retry gets the same answer, so it stops — the notice says "check its limits and billing" (2026-09-25) | | `PROVIDER_ERROR` | Yes | 5xx, network, timeout, Deepgram 422 (upload interrupted · delayed), provider `failed`/`error` status (message preserved) | | `UNSUPPORTED_AUDIO` | No | File rejected with a 4xx, a limit exceeded before submission (declared by the provider, the limit in the detail), or a job the provider says failed because of the audio (Gladia `error_code` 4xx, Rev `invalid_media` · `empty_media` · `duration_*`, CLOVA `ERROR_AUDIO_*`) — sending the same file again gets the same answer, so it is not resubmitted (2026-09-25) | | `NO_INPUT_TRACK` | No | No input track | | `RESULT_TIMEOUT` | Yes | The provider's result wait limit exceeded after submission (default 2 hours, `rev` 8 hours) | The reason is written to `step_run.last_error` as a **`CoreMessage` code** (§7 rule 5). Six of them are `NAME|what the provider said` — the shell renders `NAME` as a sentence in its own language and shows the part after `|` untranslated, as is, under the sentence. Only `MISSING_SECRET` takes an argument (`MISSING_SECRET:{secretRef}`). For `MISSING_SECRET` · `AUTH_REJECTED`, `StepReport.needsKey` turns on the "Check the key" action. **The key is fixed in the recording processing settings, so it goes to that screen.** ### Result files They are written to the recording folder `{folder}/{base}/` under the following names. A copy under the same names is also kept in the local recording directory (for the app's detail screen; kept regardless of the part deletion rules). | File | Contents | |---|---| | `{base}.transcript.json` | The schema below. The canonical copy for machines | | `{base}.transcript.txt` | For people and agents. One line = `[HH:MM:SS] S1: text` — or `[HH:MM:SS] Minsu: text` once the user named that speaker (2026-10-07, §8 "Editing"); a segment without a speaker has no label. A line break when the speaker changes or a segment passes 60 seconds. Within a line, segments are joined by a space — by nothing where both sides of the join are Han, Kana or full-width punctuation, as Japanese and Chinese are written; the space stays for Hangul, Thai and Latin and at a mixed join such as Han next to Latin or digits (2026-11-05, `WritingSystem.separator`; files written before stay as they are until the transcript is written again; the reader's paragraphs and the list's first words join the same way, §9 "Transcript reader") | | `{base}.transcript.md` | **Local folder only** (§3 "Storage location"), for notes apps such as Obsidian that open only Markdown (2026-10-03). Front matter `title` (only when there is one; a JSON string, which YAML reads as a double-quoted scalar) · `recordingId` · `startedAt`, then the lines of `.txt`, each its own paragraph. A rename and an edit rewrite it | Drive writes follow the same idempotency rule as `drive.upload` (same name + same md5 is skipped; otherwise it is **overwritten** — after a rerun the latest result is canonical). **What a version is** (2026-10-07). On Drive the `.json` and `.txt` files carry `appProperties.reclyTranscript` — `transcribed` when a transcription wrote them (the first one or a re-transcription), `edited` when the user's edit did — set with the content in the same request: a new file in the `files.create` multipart upload, an existing one with the multipart form of `files.update` on the upload URI (`PATCH …/upload/drive/v3/files/{id}?uploadType=multipart`, the metadata part `{"appProperties": …}` first and no `parents`, which an update cannot set; Drive "Upload file data" and `files.update`, checked 2026-10-07). A watcher such as recly-events can so tell a new transcript from a correction. The recording's folder carries `appProperties.transcriptAt`, the version of the newest transcript in it — its `editedAt`, else its `createdAt` — written after every publication and pushed edit. It is not advisory: without it the other devices keep the copy they have, so a publication whose stamp fails fails its step like a file write (the retry skips the files already there by md5 and stamps again), and an edit whose stamp fails stays pending; an iCloud folder keeps it in `{base}.folder.json`, a local folder keeps nothing. Each pull compares it with the copy on this device and reads `.transcript.json` again when the folder's is newer — another device edited or transcribed it again — and reads it once for another device's recording that has never been read here, so a search finds it; at most 10 files a pass, none while an edit of this device's is still on its way out or the other device is still transcribing, and never over a newer copy here (`RemoteRecordings.refreshTranscripts`). A version counts as read only once it is written here — a download or a file that fails is tried again by a later pass, and so is a folder whose stamp names a transcript that is not in it yet; a folder with neither is looked in once. `transcript.json` schema: `spec/transcript.schema.json`. ```json { "schema": 1, "recordingId": "01J9ABCDEF0123456789ABCDEF", "track": "mono", "language": "ko", "provider": { "name": "rtzr", "model": "sommers", "jobRef": "…" }, "createdAt": "2026-08-29T03:10:00.000Z", "durationSec": 3612.4, "speakers": [ { "id": "S1", "name": null }, { "id": "S2", "name": null } ], "segments": [ { "start": 0.0, "end": 3.2, "speaker": "S1", "text": "시작하겠습니다.", "words": [ { "start": 0.0, "end": 0.6, "text": "시작하겠습니다." } ] } ] } ``` - Speaker ids are normalized from the provider labels to `S1, S2, …` in order of appearance — an on-device engine's labels too (2026-10-07, §5 "Fixed processing settings"). `name` is `null` until the user names the speaker (§8 "Editing"). - `words` is included when the provider gives it and omitted otherwise. `start/end` are seconds (decimal), on the **recording timeline**. - With `diarize: false`, `speakers` is the single `[{"id":"S1"}]`, and every segment is `S1`. - `me: true` marks the person who made the recording, on a desktop recording (§8 "Me and others"); it is absent for everyone else. - `editedAt` is there once the user has edited the transcript (§8 "Editing"). ### Editing 2026-10-07. `ReclyCore.editTranscript(recordingId, edit): EditResult` changes the transcript the detail screen shows, for this device's recordings and other devices' alike. `TranscriptEdit` is one of four concrete classes: | Edit | Effect | |---|---| | `SetText(segmentIndex, text)` | The segment's words, trimmed. Its `words` (timings) are dropped — they no longer match | | `SetSpeaker(segmentIndex, speakerId)` | Who says it: an id the transcript has, or with `speakerId` null a new speaker `S{n+1}` after the highest id there is. On a transcript nobody was identified in (`speakerIdentification: unavailable`) the first speaker given is given to **every** segment and the transcript becomes `identified` — the schema allows no mix of named and unnamed segments — and the editor then splits the others off | | `RenameSpeaker(speakerId, name)` | What the speaker is called, at most 100 characters; null or blank clears it, and the id shows again | | `Batch(edits)` | The edits in order, all or nothing, so the editor saves once | - An edit stamps `editedAt`; a speaker no segment uses any more leaves the list; an edit that changes nothing writes nothing. - It is refused with `EditResult.Busy` while a job of the recording is queued, running or waiting, or the on-device engine is working on it: that transcription would write over the edit. A job that has settled — done or failed — does not block it. `Invalid(reason)` is an edit that names a segment or speaker the transcript does not have; `NoTranscript` is a recording with none. - Saved here at once — `.transcript.json`, `.txt`, and the `.md` of a local folder — and `observeResults` emits it without touching the player. The folder's copies follow, marked `edited`, with the folder's `transcriptAt`: right away when they can, otherwise with the next pull (`transcript/pending/{recordingId}` in `kv`, cleared only by the write it stands for). - Edits run one at a time, from reading the transcript to marking it pending, under the lock a pull's refresh takes to write a transcript it read from the folder (`RemoteRecordings.transcriptWrite`): two quick edits apply in order, the second on the first, and a refresh that read the folder before an edit writes nothing while the edit is pending or the copy here is at least as new. - A publication that failed and is retried after an edit publishes the edited copy — the edit is of that very result (same `createdAt`). A re-transcription replaces edits: the shells ask first, and the edited transcript stays in this device's versions (§8 "Versions"). ### Versions 2026-11-04 (user decision). Every transcript a recording has on this device is kept as a version, so any change — a manual edit, a change asked for in Ask, a transcription again — can be undone. `ReclyCore.transcriptVersions(recordingId)` lists them, newest first — the one shown first, `current` — as `TranscriptVersion(id, kind, at, current)`; `restoreTranscript(recordingId, versionId): EditResult` brings one back. - **What keeps a version.** Every writer of `{base}.transcript.json` keeps the transcript it writes, under the lock they all take, right before it writes — after the copy it replaces, when that one was not kept yet (written before versions existed, or downloaded from the folder when opened): `TRANSCRIBED` (a transcription — the first or one again — on either path; the names a publication fills in replace that version rather than adding one), `EDITED` (an edit, a speaker renamed or names filled in from the detail), `CORRECTED` (a change asked for in Ask, §8 "Ask"), `RESTORED`, and `RECEIVED` (another device's change read from the folder at a pull). `at` is the version's `editedAt`, else its `createdAt`. A write of the transcript already kept last keeps nothing new. - **Only this device's** (2026-11-04, user decision). `versions/` in the recording's directory: `index.json` and one file per version, the transcript as it was. The newest 20 are kept (`TranscriptVersions.MAX`), the one shown included. Nothing of it goes to the recording's folder, so another device lists the versions it had itself; it goes with the recording when that is deleted. - **Restore** saves the version as the transcript with `editedAt` now — an edit, so the folder's copy and the other devices follow — and keeps it as a `RESTORED` version, so a restore is undone the same way. Refused like an edit (`Busy`) while a transcription of the recording has not settled; `Invalid` for a version this device does not keep; bringing back what is shown changes nothing. Notes are not made again. - **Logs**: `rec.transcript.restored` (recordingId), and `rec.transcript.edited` with `kind` `restored`. ### Exports 2026-10-07. `ReclyCore.exportFile(recordingId, format): String?` writes one file for a share sheet and returns its path, or null when there is nothing in that format. `ExportFormat` is `TXT` (the `.txt` lines), `MD` (the `.md` above and — 2026-10-28 — the recording's notes as their own section (`notes.markdown`, §8 "Organizing") before a `## Transcript` one; the `.md` in a local folder is written without them), `NOTES` (the notes as text, `notes.text`, `… .notes.txt`), `SRT` and `VTT` — one cue per segment, the speaker's name or id in front when speakers were identified; a segment longer than 7 seconds or its writing system's cue length is cut between its words where it has word timings and stays one cue where it does not — and `AUDIO`, the playback track (`mix`, else `mono`) joined into one `.m4a` by the shell's lossless `AudioTools.concat`, after any part the retention sweep took is fetched back. The file is named for people — `2026-08-26 Weekly meeting.srt`, the date in the recording's own time zone and without what file systems refuse, the title cut to 100 characters and to what fits in a 255-byte UTF-8 name, never inside a character, or the recording's `{base}` when it has no title — in a directory of its own under `{dataDir}/exports/`. Exports are a cache: the first export of a process removes every earlier one, each later one those older than an hour. **Cue length** (2026-11-05, `WritingSystem.cueLimit`). A cue is measured in half-width units — a Hangul, Han or Kana character or full-width punctuation counts 2, anything else 1 — against two lines of the Netflix Timed Text Style Guides (partnerhelp.netflixstudios.com, read 2026-11-05): Japanese 13 full-width characters a line (article 215767517, change log 2025-12-19), Korean 16 (216001127, 2025-07-07), Simplified Chinese 16 (215986007, 2025-07-04) and Traditional Chinese 16 (215994807, 2025-12-19), at most two lines each — 52 units for Japanese, 64 for Korean and Chinese. The Korean and Japanese guides count a half-width character, space or punctuation mark as half a character; the Chinese guides do not say, and Recly counts it half there too. Everything else keeps 84 characters, two lines of the English (USA) guide's 42 (217350977, 2025-12-19). A segment is measured as East Asian when its Hangul, Han and Kana letters are at least as many as its other letters, and as Japanese when Kana is among them — a Korean line with an English word is Korean, an English line with a Japanese place name English. The speaker's name in front is not counted. ### Meta hint `context.participants` §3. Integer, optional. Where it is filled in: - Desktop · phone: the participant count is chosen in the title dialog after stopping (2·3·4·5·6+·Unknown), only while transcription is on — it is the speaker-count hint and nothing else (2026-10-10). The default "Unknown" = omitted. - Watch: none (the phone receives the transfer and enqueues it right away). The processing plan's `speakers` default applies. ### Webhooks > **Retired (2026-09-24)**: there are no webhooks (§4). The shape of the result file entries (`track: "transcript"`) remains in the step output `files[]`. The result files are added to the `files[]` of §4: `track: "transcript"` (two entries, json · txt). Only when the preceding `transcribe` step succeeded. ### AI requests 2026-10-09 (ADR-001, revised); 2026-10-28 (user decision: Summarize folded into Organizing). Every request Recly makes of the user's ChatGPT plan — a recording's notes (§8 "Organizing"), a question about it (§8 "Ask") and its title and speaker names (§8 "Titles and speaker names") — goes through `POST https://api.openai.com/v1/responses` (`store: false`, `stream: true`; `Summaries.complete`), with the model chosen in Settings → ChatGPT, OpenAI's first listed model by default (§15 §10). The model list leaves out `gpt-6-sol`, `gpt-5.6-sol` and `gpt-5.6-luna` (`ChatGptAccount.HIDDEN_MODELS`, 2026-10-09, user decision), and `gpt-5.6-terra` since `gpt-6.1-terra` came (2026-10-29, user decision). Only text goes: audio never does, since the plan's API takes neither audio nor transcription, which is why transcription stays the processing plan's (§8 "Providers"). - **Language.** Notes and answers are written in one language named in English with its tag (`Korean (ko)`) (`SummaryLanguage`, 2026-10-09): the transcript's spoken language when it is one; for a mix (`ko-en`) the app's language when it is part of the mix, else the mix's first; for automatic detection the app's language, with its script or region (`zh-Hans`, `pt-BR`) — any of the 23 app languages (§7) and the transcription languages; an unknown tag falls back to English. "The app's language" is the one it shows now: a shell passes a change with `summaries.setLocale(tag)`, so a new language needs no restart (2026-10-10) — on Android from wherever the language changes, the system's per-app language screen included (2026-10-11). - **About you** (2026-10-10). `About you` in Settings → Summary, up to 300 characters, trimmed, tells the model what matters to the user; notes and questions send it. `SummaryPreferences(aboutMe)` is this device's, in `kv` (`summary/prefs/…`), like the model. `Me` in a transcript (§8 "Me and others") is named with the word for "me". - **On request, or with consent.** OpenAI's terms ask for express consent before any background use. Ask and the detail's fills are on request; the background uses — §8 "Organizing" and §8 "Titles and speaker names" at every publication — are for a user who agreed to them, under the one consent §8 "Organizing" asks for (2026-10-29). - **Citations.** `SummaryCitations.parse(text)` is the one reading of `[HH:MM:SS]` and `[MM:SS]` (minutes and seconds up to 59) in an answer, which the shells turn into taps that play from that time (§9 "AI view"). - **Errors** are `CoreMessage`s: `CHATGPT_SIGN_IN_REQUIRED` (no sign-in, or OpenAI ended it), `CHATGPT_USAGE_LIMIT` (`subscription_sharing_usage_limit_exceeded`, or a 429), `CHATGPT_PLAN_REQUIRED` (`subscription_sharing_user_not_eligible`, or a grant without `chatgpt.tokens.use.direct`), `TRANSFER_CONSENT_REQUIRED` (iPhone, before anything is sent) and `PROVIDER_ERROR` with OpenAI's code or admission `detail` as its detail. A stream that ends without `response.completed` is a failure, not a short answer. - **Summaries, removed** (2026-10-28, user decision). More → `Summarize`, its formats (`General`, `One-on-one`, `Lecture`, `Interview`, `My format`), `Edit summary`, `summary.v1.json` and `{base}.summary.json` are gone: a recording's notes do what they did, in one shape the user adjusts (§8 "Organizing"). Summary files already written stay where they are and are not read. Since 2026-10-30 the UI calls the notes `Summary`, so `Summarize`, `Summarize again` and `Edit summary` are the notes' own items again (§8 "Organizing"); nothing of the removed feature came back. ### Organizing Since 2026-10-30 the UI calls the notes `Summary` (Korean 요약) and the memo `Notes` (Korean 메모), after Notion's naming; this document and the code keep "notes" and "memo". 2026-10-27 (user decision; ADR-001's third exception); 2026-10-28 (user decision: Summarize folded in — one shape, which the user adjusts and edits, after Granola's templates and editable notes). A recording's **notes** — an overview, its key points, decisions, to-dos and open questions, and the items of the user's own sections — are organized through the user's ChatGPT plan, part by part while it is recorded and again from its transcript after Stop (`ReclyCore.notes`, `Notes`). Always on, with no switch and no setting to turn it off, for a user signed in with ChatGPT (§15 §10) who agreed once; and on request, `Summarize again`. What the user edited or deleted stays as they left it. For anyone not signed in nothing in this section happens. The requests are §8 "AI requests". - **Consent.** OpenAI's Sign in with ChatGPT terms ask for express consent before background use of the plan. The sign-in shows it, and the shell calls `notes.consent()` when the user signs in under it; a user who was signed in before this existed is asked once, at their first recording start — `Continue` (`consent()`) or `Sign out` — for as long as `notes.needsConsent()` (and `observeNeedsConsent()`) say so: signed in, or with a sign-in OpenAI ended, and not agreed. The answer is this device's, in `kv` (`notes/consent`), until the user signs out of ChatGPT: `chatGpt.signOut` clears it, so the next sign-in asks again. On iPhone the same action also allows the plan as a destination (`TransferTargets.chatGptSummary()`, §15 "iPhone providers"); without it nothing is sent. While that destination is not allowed yet, the same sheet also carries that permission's whole disclosure — who, the endpoint, what is sent, OpenAI's policy, the box "I turned off “Improve the model for everyone” in ChatGPT." and where to withdraw it — and `Continue` waits for the box; no second dialog. - **Items.** `decision` (something the people settled), `todo` (something someone will do — a spoken commitment is a to-do with an owner), `question` (raised and not answered), `point` (a key point: something else worth remembering — a fact, a figure, an idea, a conclusion; after Stop only) and `section` (an item of one of the user's own sections, with that section's id). Each has its text, in the notes' language (§8 "AI requests" — `SummaryLanguage`), an owner for a to-do — the transcript's speaker label, never a name — a deadline only when one was said, and its evidence: the time of the line it is on (`[HH:MM:SS]`, read back as seconds) and a quote copied from that line verbatim. After Stop an item no single line can carry — it runs through the whole conversation, or only the memo says it — has neither, and so no time (2026-11-06, user decision); one said over a few lines in a row takes the line that says the most of it, an item that gives only one of the two is dropped, and running notes always have both. The time is never in the text (2026-11-04): the shells show it beside the item, so the request asks for none and a time copied in anyway is dropped (`NoteText.withoutTimes`). Only the owner carries a label (2026-10-28): the requests that write words ask for no speaker label in them — an item's text, a summary, the overview — and for a person only as the transcript names them or their role, since a label left in the text stays after the user names that speaker, beside the owner's name. - **Settings** (Settings → Summary; `notes.preferences()`, `setPreferences`, `observePreferences`). `NotesPreferences(instructions, sections)`, this device's, in `kv` (`notes/prefs/instructions`, `notes/prefs/builtIns`, `notes/prefs/sections`). `Summary instructions`, up to 1,000 characters, trimmed: the user's own words on how to write the notes ("to-dos grouped by owner", "keep technical terms in English"), which every request follows where they do not change the answer's format, the kinds or the evidence rules. `Sections` (2026-10-29, the user's decision: the user sees every section the notes are made of, and edits them all): one list (`NoteSection(id, name, description, kind)`) — the built-in groups first, in their order `Decisions` · `To-dos` · `Key points` · `Questions`, then up to three sections of the user's own. Each has a name (up to 40 characters, one line) and what goes in it (up to 200). - A built-in group's id is its kind (`point`, `decision`, `todo`, `question`). An empty name or description is its own: the app's words, in the app's language (the `Key points` heading; `Facts, numbers, ideas and conclusions worth remembering.`, `What was agreed or decided.`, `Who will do what, and by when.`, `Questions still without an answer.`), and the fixed meaning in the request. The user may rename it, describe it anew — the request then asks for it in their words — or remove it, and it is no longer asked for: an answer that gives one anyway is not kept. Whatever its words, a group keeps its rules: a to-do's owner and due date, a decision as it stands at the end, a question only while unanswered, key points after Stop only and apart from the other groups. Nothing is stored for the groups as they come; the user's way is kept in `notes/prefs/builtIns`, each group once, in its place. With every group removed and no section of the user's own, nothing is asked while recording — no part is transcribed for notes — and after Stop each conversation is asked for its summary alone, which the overview is made of. - A section of the user's own without a name is dropped, and a new one gets an id. Each is asked for as a kind of its own (`section1`…) and listed like the built-in groups, with evidence; an item that fits a user's section goes there. - The sections a recording's notes were made with are kept in its notes file — a built-in group with the name the user gave it — so their headers read the same on every device and after Settings change; a recording keeps the sections it started with while it records, and `Summarize again` uses Settings as they are then. - **Quote check.** An item is kept only when its quote, normalized (case and full-width Latin folded, everything but letters and digits dropped), matches at least 0.8 (difflib's partial ratio) the transcript's text whose words overlap ±10 s of its time — running on past the window by as many words as the quote has; a segment without word timings counts whole. The rule is the long-recording evaluation's `quote_check` (`scripts/long-recording-eval`), ported as it was measured. An item that fails it is dropped, and is not kept as an item with no evidence. An item with no evidence has no quote to check, so nothing holds it to the recording. - **What is sent.** The transcript's lines, `[00:12:03] S1: …` — the speaker's label, never a name the user gave — at most about 20 seconds each (a longer segment with word timings is split, as in the evaluation). Phone numbers, e-mail addresses and runs of eight digits or more become `[phone#1]`, `[email#1]` and `[number#1]` — the same value the same placeholder within a request — and are put back into the answer on the device, before the quote check. With the lines: the user's instructions and About you (§8 "AI requests"), and the recording's memo under its own heading, its numbers and addresses hidden the same way (§8 "Memo"; not in the request that finds where conversations change). No title, never audio. - **While recording (provisional).** Each part of the transcription track (`mono`, else `mix`) — one every 5 minutes (ADR-006) — that the recorder files while a recording of this device is in progress (`RecordingRepository.addPart`) — and, on the phone, each part of a watch's recording that arrives while the watch still records (2026-10-27, §3 "Watch → phone transfer contract": filed and listed by its in-progress meta, `TransferReceiver.partReceived`), but not an import, and never on the watch itself — is transcribed **on its own** with the settings the recording froze when it started (§5; a watch's, when its first in-progress meta arrived): the external provider through the same adapter, submission and polls as the job (every 15 seconds, for at most 30 minutes), or the on-device engine, one run at a time with the job's own (`LocalTranscriptionService.transcribePart`) and without speaker separation. There is no overlap between parts and no linking of their speakers: each part's labels are its own. Its transcript is kept beside the recording (`.provisional-p001.json`). A part of fewer than 40 words stops there. Otherwise one request asks for that part's **new** items — key points wait for the final pass: the fixed instructions, the items already shown (`NOTES SO FAR`, a deleted item not among them), the end of the part before (`PREVIOUS PART`, at most 2,400 characters of its lines, for context) and this part's lines. The notes are append-only: an item shown is never changed or removed while recording, and a reversal is a new item; an item the model merely repeats — the same words, or the same moment in the same words — is not shown twice, and its time must lie in this part. Parts go one at a time, in the order filed. **No retries**: a part that cannot be transcribed, a provider error or a ChatGPT error is logged and the part left out; the final pass covers it. - **After Stop (final).** The processing plan's last step, `notes.organize`, after `transcript.publish` (§5). With organizing off — not signed in, not agreed, or on iPhone the destination not allowed — it succeeds at once and sends nothing (output `{"notes":"skipped"}`; the offline pass may run it then, §5). Otherwise it reads the recording's transcript and splits it into **conversations**: mechanically at every silence of more than 10 minutes between speech segments; then a stretch longer than 45 minutes is asked, in one request with its lines, where its conversations change (`{"boundaries": ["HH:MM:SS", …]}` — a change of the people, the place or the occasion, not of subject; a boundary within a minute of the stretch's ends or of the one before is ignored). Each conversation of 40 words or more gets one request with its lines, answered with its items and a summary of three to five sentences. The **overview**, three to five sentences over the whole recording, is that summary when there is one conversation, else one small request over the conversations' summaries. Each conversation finished is kept in the step's state, so a try after a wait asks only what is left — unless the words sent would differ (a speaker named meanwhile changes nothing sent; Settings or the memo changed meanwhile do, §8 "Memo"). - **Reconciliation.** The final items replace the provisional ones, and those of an earlier final pass: an item not made again disappears without notice, and one made again keeps its id. An item the user deleted (`deleteItem`, or emptied in Edit summary) never comes back — not from a later part, a final pass, `Summarize again` or a transcription again — by the evaluation's matching rule: the same kind (and section), and the model's texts at least 0.6 alike (difflib's ratio after normalizing) or, when both have a time, the same moment within 15 seconds. An item the user edited keeps their words: made again — matched by the texts alone, since by its moment a different item would be hidden behind them — it keeps its id and their text over the model's new one; not made again, it stays (one from a part loses its owner, a part's label being its own). An overview the user edited stays theirs. - **Editing** (2026-10-28). `notes.edit(recordingId, overview, items)` — Edit summary (§9 "AI view"): `overview` null leaves it, empty removes it, otherwise replaces it (up to 1,200 characters); each `NoteEdit(itemId, text)` replaces an item's words (one line, up to 500 characters) and an emptied one is deleted as `deleteItem` deletes. Only the words: the owner, the due date and the evidence stay the model's. True when anything changed. The file keeps the model's text beside the user's (`userText`, `editedAt`), so matching goes by what the model said; words put back to the model's own make the item the model's again. Nothing on screen says an item was edited. - **Organize again** (2026-10-28). `notes.organizeAgain(recordingId)` — More → `Summarize` / `Summarize again` — runs the final pass once more from the recording's transcript as it is now, with Settings as they are now, and answers null or a `CoreMessage` code. It is on request: it needs the sign-in (`CHATGPT_SIGN_IN_REQUIRED` otherwise, also when OpenAI ended it) and, on iPhone, the plan allowed as a destination (`TRANSFER_CONSENT_REQUIRED` before anything is sent), but not the consent above, and it waits for nothing: a failure is `RecordingNotes.failure` until the next try, and the next try starts over. A recording already being organized here is not asked twice, and a recording's final passes — the job's and Summarize again — run one at a time. Beside it go the names of the speakers who have none (2026-10-29, §8 "Titles and speaker names"). - **Errors after Stop** go through the job's own retries and `CoreMessage`s, and never hold up the upload or the publication, which come before: `CHATGPT_USAGE_LIMIT` waits five hours (the plan's shorter usage window), `CHATGPT_PLAN_REQUIRED` a day and `CHATGPT_SIGN_IN_REQUIRED` — OpenAI ended the sign-in — six hours between tries (the step's `retry.maxDelaySec` is a day); anything else takes the usual backoff. Signing in again lets a step waiting for a sign-in or a plan go at the next pass. After 8 tries the step fails with `onError: continue` and the job is done without notes. A job with only its notes left (`StepReport.notesOnly`) is shown as done, is no transcription in the way of an edit or a fill (§8 "Editing") or of a transcription again unless its notes are being made right now (§10 "Re-transcription"), and is not in the folder's `pending` marker (§3). - **A transcription again** (§10 "Re-transcription") makes the notes again; deleted items stay deleted. Notes still waiting from before are closed when it is queued, never made from the transcript it replaces. - **Model and request.** The model Settings → ChatGPT picked, for every pass. The request is §8 "AI requests"'s (`POST /v1/responses`, `store: false`, streamed, the same errors; §15 §10); every answer is one JSON object, read leniently, as a fill's (§8 "Titles and speaker names"). - **What the shells read** (`ReclyCore.notes`). `observe(recordingId)` — never failing — gives a `RecordingNotes` that changes as items arrive, after a final pass, an edit or a pull, and when a speaker is named: `stage` (`NONE`, `PROVISIONAL`, `FINAL`), `overview`, `points`, `decisions`, `todos`, `questions` and `sections` (`NoteSectionItems(id, name, items)`, in the order the notes were made with), final notes in the order the pass wrote them, which an item Ask or a pass adds later follows, and provisional ones newest first (latest time first, 2026-11-06); `organizing` while a part or a final pass of it is being worked on here; `failure`, the last `Summarize again` of it that failed here; and `model`, the plan's name for the model they were made with. `get(recordingId)` is the same now. A `NoteItem` has `id`, `kind`, `text` (the user's words once edited), `owner`, `due`, `atSec` (null with no evidence), `quote` (empty then), `provisional` and `section`; `owner` comes resolved — the name the user gave that speaker in the transcript, the app's word for `Me` for an unnamed `me` (2026-10-30, §8 "Me and others"), or null when the speaker has none or the item is provisional, never `S1`. `deleteItem(recordingId, itemId)` removes an item for good (false when there is none). - **Copy and share** (2026-10-28). `notes.text(recordingId)` is the notes as text — the overview, then `Decisions`, `To-dos`, `Key points`, `Questions` and the user's sections, each a heading over `- ` items with ` — owner`, ` · due` and, when the item has a time, `[HH:MM:SS]` — for `Copy all` and Share → `Summary` (§8 "Exports"); the Markdown transcript the share sheet gets carries the same as its own section. The headings are the app's: each shell passes its resources' words with `notes.setHeadings(NotesHeadings(…))` at start and whenever the app's language changes, as it passes the locale — except a built-in group the user renamed, which is headed by that name (`RecordingNotes.names`, `NoteNames`). With them goes the app's word for `Me` (`NotesHeadings.me`), an unnamed `me`'s as an owner. - **Storage.** `{base}.notes.v1.json` beside the parts (`spec/notes.schema.json`): the stage, the overview (and when the user edited it), the items — each with its provisional flag, owner label, evidence, conversation, section and the user's words — the tombstones of deleted items, the sections the notes were made with, the conversations' spans and summaries, the parts the provisional pass is done with, the model and its name, and the times. Final notes, and every change to them here — a pass, an edit, a deletion — also go to the recording's Drive or iCloud folder as `{base}.notes.json` with the folder's `notesAt` (§3 "Drive layout"), once the folder exists, so the account's other devices show them and their edits: they read it at the list's pull, or when the recording is opened (once a run). The newer version (`updatedAt`) wins, and notes here that have not gone up yet are kept over the folder's; deletions travel as tombstones. A recording in a local folder keeps its notes on this device and writes nothing for them into the folder, which no other device reads (§1c). It survives the 7-day audio cleanup and goes with the recording. The parts' transcripts made while recording are deleted once the final notes are written. The user's memo is a file of its own beside them, the same way (§8 "Memo"). - **Cost.** With an external provider each part is transcribed twice — once on its own while recording, once in the whole after Stop — and the provider bills both; every part (up to twelve an hour while recording) and conversation is a request that counts toward the ChatGPT plan's usage. - **Log events**: `organize.consent` and `organize.consent.cleared`; while recording `organize.part.transcribed` (part, provider, segments), `organize.part.start` (part, model, how many values became placeholders), `organize.part.done` (items, and how many were dropped, matched a deletion or repeated), `organize.part.skipped` (`reason`: `short`, `not_offered`, `consent`, `provider`, `no_key`, `engine`) and `organize.part.failed` (`stage` `transcribe`, `extract` or `internal`, and `reason`); after Stop `organize.final.start` (conversations, model), `organize.final.boundaries` (block, answered, kept), `organize.final.done` (conversations, items, tombstoned, dropped, overview, withoutTime), `organize.final.skipped` (`off`, `no_transcript`) and `organize.final.failed` (`reason`); `organize.item.deleted`; `organize.edited` (items, deleted, overview); `organize.again.start` and `organize.again.failed` (`reason`); `organize.preferences.read.failed`; `organize.resumed` (jobs) after a sign-in; the storage's `remote.notes.pushed`, `remote.notes.push.failed`, `remote.notes.read` and `remote.notes.read.failed`. A sign-in hook that fails is `chatgpt.hook.failed`. Ids, counts and codes only — never a word of the transcript or of an item. ### Memo Since 2026-10-30 the UI calls the notes `Summary` (Korean 요약) and the memo `Notes` (Korean 메모), after Notion's naming; this document and the code keep "notes" and "memo". 2026-10-30 (user decision, after Notion AI Meeting Notes' Notes tab; it reverses the 2026-10-10 "no memo per recording"). A recording's **memo** is the user's own text about it — an agenda, who is there, what to keep track of — typed while it records, after Stop before its notes are organized, or any time after. It is the user's input, kept apart from the notes, which are the model's output (§8 "Organizing"): nothing rewrites it, and it is never shown inside the notes. - **API.** `memos.get(recordingId)` and `memos.observe(recordingId)` give the text, empty when there is none; `memos.set(recordingId, text)` keeps it as typed, line breaks and spaces included, cut to 2000 characters (`Memos.MAX`), and writes only when it changed. A memo can be written at any time — while recording, while the recording is processed, for a recording of another device — unlike the title sheet's `updateTitle`, which refuses once processing started. Emptying it keeps an empty text rather than deleting the file, so the change reaches the other devices; an empty or blank memo is as good as none: nothing reads it, and only the detail's `Notes` chip, always there, shows it. - **Storage.** `{base}.memo.v1.json` beside the parts (`spec/memo.schema.json`): the recording id, the text and `updatedAt`. Every change also goes to the recording's Drive or iCloud folder as `{base}.memo.json` with the folder's `memoAt` (§3 "Drive layout") five seconds after the last change (`Memos.PUBLISH_DELAY_SEC`), so typing does not upload each keystroke — or once the folder exists, for a recording still recording. The pending upload is a `kv` row (`memo/pending/{recordingId}`) and the version taken in another (`memo/seen/…`), as for the notes. A pull reads `{base}.memo.json` again when the folder's `memoAt` is newer than the copy here, at most `RemoteRecordings.REFRESH_PER_PASS` a pass, and opening a recording that has none here reads it once a run; a memo with an upload pending here is never replaced. The newer copy wins whole — one user's devices, no merge. A local folder keeps the memo on the device, like the notes. - **Watches.** Neither watch has a memo: neither has text input, and a watch's recording is on the watch until it is transferred. Its memo is written on the phone, once the recording has arrived there. - **What reads it.** Wherever a recording's transcript goes to the user's ChatGPT plan, its memo goes too, trimmed, when it is not blank: each part while recording (the memo at the moment the part is asked), each conversation and the overview after Stop (the memo read when the pass starts, the same for the whole pass), `Summarize again` (the memo at the moment it is pressed), `Ask` and the title and speaker-name fills (§8 "Ask", §8 "Titles and speaker names"). Only the request that finds where a long stretch's conversations change goes without it. It is put in the request's input, not its instructions, under a fixed English heading, with numbers and addresses hidden as the transcript's are (§8 "Organizing" — "What is sent"): ``` MEMO — the user's own memo about this recording, typed by them before, during or after it. Use it to judge what matters to them and to spell names and terms as they do; follow what they ask in it where that does not change the JSON format, the kinds or the evidence rules. It is not part of the recording: every item still needs a line of the transcript. ``` The final pass, the only one that may write an item with no line, ends it `It is not part of the recording: what only it says has no line of the transcript.` instead. So a fact only the memo has is an item with no time there (2026-11-06, user decision; from 2026-10-30 every item kept a moment of the recording), and running notes still hold every item to a line. The quote check reads the quote and not the item's words, so such a fact can also land in an item's text or the overview, and it stays there (2026-11-06, user decision). A try after a wait asks again from the start when the memo changed meanwhile, as when Settings did. Changing the memo after the notes are written runs nothing and marks nothing: the next `Summarize again` reads it. - **Shells.** The recording screens of the phones, the Mac's popover and the Windows tray popup have a `Notes` row while recording; the detail has a `Notes` chip beside the transcript and the summary, always there (§9 "AI view"). Every memo editor saves as the user types — 0.8 s after the last keystroke and when it closes — and `Done` only closes it, so a popover that closes on Escape or a click elsewhere loses nothing. The search finds a memo's lines (§10 "Search"). - **Logs**: `memo.saved` (recordingId, chars); the storage's `remote.memo.pushed`, `remote.memo.push.failed`, `remote.memo.read` and `remote.memo.read.failed`; a shell's `shell.memo.save.failed` when a save could not be made. Ids and counts only — never a word of the memo. ### Ask 2026-10-10 (user decision); 2026-11-04 (user decision: one composer for questions and changes, at the foot of the detail — §9 "Ask"). The user asks ChatGPT about one recording, or asks for a change to it, in their own words — and one request can change the transcript and the summary together (§8 "AI requests"). `summaries.ask(recordingId, preset, question)` takes one of the two: - **A preset**, a plain question answered in the notes' language: `ACTION_ITEMS`, `OPEN_QUESTIONS`, `TRANSLATE` (the whole transcript, line by line, into the app's language — offered only when the transcript is in one other known language) and `MY_SPEAKING` (feedback for `Me` — offered only when the transcript has one); the follow-up email draft is gone (2026-11-04, user decision). `askPresets(recordingId)` says which apply. It sends the transcript's lines with the speakers' names, About you and `Me`, the recording's memo (§8 "Memo"), then `Question:` and the preset's question, and changes nothing. - **The user's own words** — a question, or a change: what to fix, rename, add, remove or rewrite, with anything pasted under it that helps (names and terms, background, or another transcript of the same conversation made where the sound was clearer) — at most 200,000 characters (`AskRequest.MAX`, room for a two-hour conversation's transcript pasted under it; the shells' field takes no more). - **What is sent** (`AskPrompts`). The transcript one line per segment — `#12 [00:01:23] S1: …`, the segment's number, its time and its speaker id (the segments that say nothing left out) — then `SPEAKERS`, each id with the name the user gave, `Me` for an unnamed `me`, or `no name`; then, when the recording has final notes, `SUMMARY` — the overview, then each section by its id (a built-in group under its English name and the name the user gave it, a section of the user's own with what goes in it), one line per item: `@7 text (owner: …; due: …) [HH:MM:SS]`; the memo when it says something (§8 "Memo"); and `REQUEST`, the user's words as typed, trimmed. About you goes in the instructions when set. With a memo, the instructions also say that it is the user's own text and material only when the request says to use it, and — as the fills do (§8 "Titles and speaker names") — that a name in it names a speaker only where the transcript shows which one, a role or anything else the memo says about someone not being enough (2026-11-05). Every request with a summary is told to add only what the transcript says, each item with its line, and to say in `answer` what only the request or the memo says. No title, never audio, and no placeholders: a number is often what is being corrected. The model is the one Settings → ChatGPT picked, at its own effort, given 30 minutes. - **What comes back.** One JSON object: `answer`, the words for the user in the request's language — for a question the answer, plain text with `[HH:MM:SS]` where it helps; for a change nothing, or one short sentence on what could not be changed — and only when the request asks for a change (a question never changes anything): `lines`, the transcript's segments that change, each `{"n": 12, "text": "…"}` (the segment's whole corrected words) and/or `"speaker": "S2"`; `names`, a speaker's name only where the request or its material says who they are, never guessed from how someone speaks or what they talk about; `overview`, the summary's whole overview rewritten; `items`, the summary's items that change, each `{"n": 7, "text": "…"}`, an empty text removing one; `add`, items to add, each `{"section": id, "text": "…", "line": 12}` and for a to-do `owner` (a speaker id) and `due`. A name or term corrected in one document is corrected in the other too. Read leniently, as a fill's answer is (§8 "Titles and speaker names"); an answer that is no such object is all words for the user. Kept only where it fits: a segment that is there and says something, its words on one line (a line break becomes a space), at most 5,000 characters and not its own words again; a speaker id the transcript has, on a transcript whose speakers were identified; a name as a fill takes one (at most 100 characters, never `Me` or an id, never one two speakers were both given, not the name the speaker already has); an item the summary has, a section it has; an item's words without the times copied into them (`[HH:MM:SS]`, a run of them or a range — the item's time is shown beside it; 2026-11-04) and without the `(owner: …; due: …)` its summary line gives it copied after them (the owner and due date are shown beside it too, and the instructions say an item's text is its words alone; 2026-11-05), words that were only these changing nothing; an added item's evidence is the segment it names — its start and its first 300 characters — and an item that names no segment that is there and says something is dropped, as it would sit at 00:00:00 with nothing behind it (2026-11-05). Segments are never split, merged, moved or retimed; a segment whose words change loses its word timings, as with a manual edit. - **Saved at once** (2026-11-04, user decision: no review step — `Undo` and the versions take it back). The transcript's changes are one edit (§8 "Editing": `editedAt`, the folder's copy marked `edited`, pushed now or with the next pull, a `CORRECTED` version — §8 "Versions") of the transcript as it is when the answer arrives: a segment, speaker or name the user changed while ChatGPT answered keeps the user's change, and a transcript transcribed again meanwhile, or whose job has not settled, is not touched. The summary's are saved as Edit summary saves the user's (§9 "AI view" — "Editing"): an item rewritten or added is the user's, which `Summarize again` keeps; one removed is never shown again; the overview rewritten is the user's. Only final notes are changed, and they go to the recording's folder as an edit does. The answer carries `AskChanges` — the segments whose words or speaker changed, how many speakers were named, the summary's items changed or added by id, how many it removed, and whether the overview was rewritten — or none when nothing was saved. - **Undo.** `summaries.undoAsk(recordingId)` brings the transcript back to the version before the change (`restoreTranscript`, kept as a `RESTORED` version) and the notes to the file before it — each only while it is still as the change left it — and drops the changes from the answer: `false` when any of it was changed again since (the rest is undone). It is kept with the answer, in this process; once the answer is closed, Version history still brings the transcript back (the summary's changes stay). - The answer is `AskState` per recording, one run at a time (asking again while one runs answers that one), kept in this process only — never written, uploaded or synced — until it is closed (`clearAsk`); what it changed stays. It needs the same permission on iPhone and has the same errors as every request (§8 "AI requests"). - **On request only**: it needs the sign-in, not the consent to background use (§8 "Organizing"). A changed transcript does not make the notes again — a request that needs both changes both. - **Logs**: `summary.ask.start` (recordingId, model, the preset or `own`, whether a summary went, request characters), `summary.ask.done` (recordingId; with changes, how many places of the transcript and of the summary), `summary.ask.failed` (recordingId, reason code), `summary.ask.undone` (recordingId, whether all of it was); the saves themselves are `rec.transcript.edited` with `kind` `corrected` and `organize.edited` with `by` `ask`. Never a word of the transcript, the summary or the request. ### Me and others 2026-10-10 (user decision). A desktop recording made in meeting mode has `mic` and `sys` tracks (§3); after transcription the core marks the person who made it in `transcript.json` (`speakers[].me: true`), on both the external and the on-device path (`MeAndOthers`). The shell's `AudioTools.levels(file, windowSec)` gives each part's peaks per 0.25 s window, like the waveform, and each track's own silence threshold (`SilenceRanges`) says where it is sounding — loudness is never compared across the tracks, whose scales differ. A window is the user's when `mic` sounds and `sys` is quiet, and the call's when `sys` sounds. - Identified speakers are kept: the one whose speech is at least 70 % the user's (and at least 3 seconds of it) becomes `me` — one at most, and nobody is split. Without speakers, the lines that are at least 60 % the user's and the rest become two speakers, numbered in order of first appearance, and the transcript counts as identified. - Nothing changes when either track is silent throughout (a room recorded in meeting mode has no call to tell apart), when the recording has no `sys` track, or when a `mic` or `sys` part is not on this device — "Transcribe again" after the 7-day cleanup fetches only the track it transcribes, so it comes back without `me` (`transcribe.me.skipped`). - The files and the model write an unnamed `me` as `Me`, a fixed word like the ids; the shells show it in the app's language (`나`). Renaming it is renaming a speaker, and `me` stays. In the notes (2026-10-30) an unnamed `me` owns their to-dos under the same word, on screen and in `Copy all` (§8 "Organizing" — "What the shells read"). ### Titles and speaker names 2026-10-15 (user decision); 2026-10-29 (user decision: no boxes — automatic). A recording's title, when it has none, and its speakers' names come from the user's ChatGPT plan: at every publication of its transcript on this device, for a user signed in who agreed to the plan's background use, and on request from its detail — its names also with `Summarize` (`AutoFill`). (From 2026-10-15 to 2026-10-29 the title sheet after Stop had two checkboxes, `Fill in the title with ChatGPT` and `Fill in speaker names with ChatGPT`, remembered on each device and asked per recording; the user chose automatic instead.) - **At a publication.** Every `transcript.publish` (§5 "Fixed processing settings") of this device — a recording made here, a watch's recording this phone transcribes, an import, a re-transcription — first asks the plan for what the recording lacks (`FillNeeds`) — the publish runner's `TranscriptFill` — **before anything of the transcript goes up**, so the folder's first copy has it, and so do recly-events' event and its `get_transcript` (§15 §9). Only for a user signed in to ChatGPT who agreed to the notes' consent (`Notes.agreed()` — §8 "Organizing"; signed out or not agreed, nothing is asked and nothing is logged, the no-op `notes.organize` is for anyone else). The title sheet after Stop has no box: its title field's placeholder reads `Filled in automatically` while a title left empty will be filled in — transcription on, signed in, agreed (and on iPhone the destination allowed) — and `Untitled` otherwise (§9 principle 5). A typed title is the recording's, and only the names are asked. A publication tried again asks again for what is still missing; nothing is kept between tries. - **From the detail.** More → `Fill in title` and More → `Fill in speaker names` (§9 "AI view") ask for a recording already transcribed, this device's or another's: `fillTitle(recordingId)` and `fillNames(recordingId)` — both `fill(recordingId, title, names)` for one half — answer `FillResult`: `Filled(title, names)` (whether a title was given and how many speakers were named; ChatGPT may have told neither), `NothingToFill` (nothing asked is missing — the title is there, every speaker who speaks has a name, or nothing was said), `NoTranscript`, `Busy` (a job of the recording has not settled, the rule of an edit — §8 "Editing") or `Failed(reason)` with a `CoreMessage` code, `TRANSFER_CONSENT_REQUIRED` on iPhone before anything is sent, as for `Summarize`. One run per recording: asking for what the run going asks for answers that run; asking for the other half waits for it and then runs on its own (a second request — the halves are never folded into a request already sent). A run goes on when its screen closes. - **With Summarize** (2026-10-29, user request). More → `Summarize` and `Summarize again` (`notes.organizeAgain`, §8 "Organizing") also ask for the names of the speakers who have none, as `Fill in speaker names` does (`AutoFill.withOrganize`) — never the title. It goes beside the notes' request and neither waits for the other: the notes are written from speaker labels and show the names once they are given. Only when Summarize goes ahead — signed in and, on iPhone, the destination allowed, the recording not already being organized — and nothing is sent when every speaker who speaks has a name. What it meets is logged, not shown: the detail reports the notes' result. It is not in "What is being filled in" either (2026-10-29, user report — `Filling in speaker names…` over the chips and `Summarizing…` under them were two loaders for one action): the notes' `Summarizing…`, and the lists' `Summarizing` badge, are what say it is going on. - **What is being filled in** (`observeFills()`, a list of `FillRun(recordingId, title, names)`, one per recording). A fill is in it from the moment it asks the plan until the answer is applied — or the request fails, or the pass is cut short — whichever trigger started it: a publication's or the detail's — not Summarize's (above). `title` and `names` are what that request asks for: the halves asked that the recording lacked. A fill that sends nothing (nothing to fill, a job in the way, no permission, the pause) and an ask waiting for the run before it are not in it. The lists' `Filling in` badge and the detail's line under its header read it (§9 principle 2, "AI view"). - **What is asked** (`FillNeeds.of(title, transcript)`, the rule the shells offer the two items by: `title` for the title, `names` — `speakers` not empty — for the names). A title when the recording has none at that moment — a title from the stop sheet, an import's file name or a rename is never replaced — and a name for each speaker who says something, has none and is not `me` (§8 "Me and others"); of these, only the halves asked. Nothing is sent when there is nothing to fill: nothing said, or the half asked already there. - **The request** is §8 "AI requests"'s (§15 §10) with its own instructions: the transcript's lines with their times and the speakers' names, and under them the recording's memo when it says something (§8 "Memo": a name in it may be a speaker's, given only where the transcript itself shows which speaker it is — a role or anything else the memo says about someone is not enough, 2026-11-05) — no title, never audio — and the answer asked for is one JSON object: `title` in the notes' language (§8 "AI requests"), at most 20 characters in Korean, Japanese or Chinese and 40 in any other language, in a few words rather than a sentence (2026-10-29, user request: titles of 60 ran long), and `speakers`, each asked id's name only as the transcript gives it — what they call themselves, or what the others call them when talking to them, without honorifics that go with a name (`님`, `씨`, `さん`, `Mr.`), a form of address used alone (`팀장님`) kept — else null. A name said to someone is the listener's, and someone only talked about is no speaker. The model is the first of `Summaries.FILL_MODELS` the plan offers — `gpt-6.1-sol` at reasoning effort `low`, then `gpt-6.1-terra` at `low` — else the one Settings picked, at its own effort; a plan that refuses `reasoning` is asked again without it (it is not in OpenAI's list of fields to leave out; a real request on the Mac on 2026-10-15, gpt-6.1-sol at `low`, was accepted). The order was measured with Codex CLI on 2026-10-15: twelve transcripts in Korean, English, Japanese and a mix — meetings, a call, a lecture, a memo, names only talked about — twice each; `gpt-6.1-sol` at `low` named all 56 speakers right and invented none, in 7.5 s median, `gpt-5.6-terra` at `medium` invented one in 5.9 s, `gpt-6-luna` invented two or three at any effort, and `gpt-6-astra` took about 50 s. `gpt-6.1-terra` took `gpt-5.6-terra`'s place on 2026-10-29 (user decision, 5.6 outdated) at its own default effort, without being measured. It is given two minutes (`Summaries.FILL_TIMEOUT_SEC`). - **The answer** is read leniently (`FillAnswer.parse`: the object inside any fence or prose) and only for what was asked: the title's first line, cut to 100 characters; a name for an asked id only, up to 100 characters (§8 "Editing"), never `Me` or an id, and never one two speakers were both given — neither gets it. - **Applied.** At a publication, as part of the transcription, not as an edit: no `editedAt`, and the files go up marked `transcribed` (§8 "Result files"). The title is set only if the recording is still untitled (`RecordingRepository.fillTitle`) and is pushed to the folder — its `description` and `meta.json` — before the transcript. The names go into the result file and the local copy before the first upload is tried, so a publication that has to try again finds nothing left to ask. When the user's edit is what goes up (a failed publication retried after an edit, §8 "Editing"), only the local copy is rewritten. From the detail, the title goes the same way and the names as an edit (`editTranscript` with `RenameSpeaker`s): `editedAt` is set and the folder's copy is marked `edited` — the transcript is already there, and this is a change the user asked for; a speaker named meanwhile keeps that name. - **Never in the way.** At a publication a skip or a failure publishes the transcript as it was, and the step is not retried for it (`summary.fill.start` — with `title`, `names` and `speakers`, what the request asks for — `.model` — the model and effort asked — `.done`, `.skipped`, `.failed`, and `.effort.refused` when the plan refused `reasoning` and was asked again without it, each with `trigger` `publish`, `manual` or `organize`; ids, flags, counts and the model only, never a title or a name). Signed out, not agreed, or ChatGPT not offered in the storefront: nothing is asked. iPhone: skipped until ChatGPT is an allowed destination (§15 "iPhone providers"); the notes' consent sheet gives that permission with its `Continue`. OpenAI asks apps to stop requests at the usage limit and when the plan no longer covers the app (developers.openai.com/siwc "Errors and recovery", 2026-10-15): `CHATGPT_USAGE_LIMIT` pauses the publications' fills for five hours — the plan's shorter usage window — and `CHATGPT_PLAN_REQUIRED` for a day (`summary/fill/paused_until`). The detail's items are not paused — the user asked — but set the same pause when they meet either. - **Where it runs.** At a publication, on the device that runs the recording's job — the phone (a watch's recordings included), the Mac or the Windows PC. In the offline pass (a local folder, on-device transcription) it is the one network call: offline it fails at once and the transcript is published without it. - **Consent.** OpenAI's Sign in with ChatGPT terms (2026-09-29, checked 2026-10-15) ask for express consent before background use of the plan. The notes' consent (§8 "Organizing") is that consent for both background uses: its sheet says the transcript text goes to the plan as the user records and after they stop, that its decisions, to-dos and questions are listed and a missing title and the speakers' names filled in, that audio is never sent, and that it counts toward the plan (reworded 2026-10-29). The detail's items, like `Summarize again` and Ask, are on request. ### What is not included Entering the participant count on the watch, and recognizing a speaker from one recording to the next. (Telling the user from the call on a desktop was added on 2026-10-10 — "Me and others" above.) (On-device diarization, speaker names and transcript editing were added on 2026-10-07.) There is no Gemini adapter either — its speaker diarization works only through the prompt and its timestamps cannot be trusted, so it cannot keep the `start/end` contract of `transcript.json`. The adapter interface is the same, so adding a provider means adding one adapter. --- ## 9. Design system "Blueprint" (formerly docs/09) The principle in one line: **intent, not decoration**. ### Trends → application | # | Trend | Recly application | |---|---|---| | 1 | AI collaboration (a supporting layer, never forced) | Transcription is **chosen in settings: local, external API, or OFF**. New installs use local (OFF in builds without a local engine), and the external API runs with the user's key. The UI rule is "supporting layer": the original (parts · timer · status) is always the main content, the transcript is a separate layer beside or below it, and the result never covers or replaces the display of the original. Only recordings with a result get an entry point in their list row, and it opens in the transcript tab of the detail screen. There is no persuasion UI such as automatic suggestions, automatic runs or "Improve with AI" | | 2 | **Purposeful motion** (status signals, deliberate micro-delays) | For **rare, high-stakes actions** such as starting/stopping a recording, uploading and signing in, a visible processing state of 0.3–0.8 seconds (the button changes to "Saving…" and shows "✓" when done). No decorative animation. All motion respects `reduce motion` | | 3 | **Raw aesthetics** (monospace · grid · wireframe) | **The center of Recly's visual language.** Timers, part numbers, file names, sizes, sha and status words are monospace (the words are translated since 2026-10-08; codes such as `NEEDS_AUTH` stay in logs and files). Screens are square nodes on a visible grid; the recording screen is the mono timer and the square record node (the device, transcription and status nodes were removed 2026-11-04), and the processing settings are offered as the existing sectioned table. No filler illustrations, no gradient overlays | | 4 | Inclusive visuals (control) | **Reduce motion and high contrast follow the system settings as they are** — the app does not repeat the same toggles inside itself (duplicate controls are not control). WCAG AA (text 4.5:1, graphics 3:1). Swipe hints have a static alternative | | 5 | **Fluid typography** | A continuous scale instead of breakpoints: Compose `sp` + interpolation based on window size, SwiftUI Dynamic Type + `ScaledMetric`, clamp interpolation by window width on Windows. The user's font size setting is respected | | 6 | Authorship (Crafted, not prompted) | **No icon mascot.** Instead, **honest system indicators**: version · build · device ID · open-source notices in monospace at the bottom of settings. No "Handmade" marketing copy (product honesty) | | 7 | Anti-Liquid Glass | Glass **only on chrome (tab bar · toolbar · menu bar popover)**, never over content. Blur + gradient lighting only, no refraction distortion. The system tab bar follows the system default, but its text contrast is checked and its background is filled when needed | ### Tokens - **Palette** (neutral + a single accent): - Light: background `#F7F7F5` (paper), surface `#FFFFFF`, grid lines `#E6E6E2`, body `#111111`, secondary `#5E5E5A`, accent **`#0F62FE`** (blueprint blue), recording/danger `#DA1E28`, success `#198038`, warning `#B28600`. - Dark: background `#0E0F12`, surface `#16181D`, grid lines `#23262D`, body `#F2F2F0`, secondary `#9A9CA3`, accent `#4589FF`, recording `#FA4D56`, success `#42BE65`, warning `#F1C21B`. - **High-contrast mode** (only in shells where the system reports that setting — Apple `colorSchemeContrast`, Windows `win.highContrast.on`, Android API 34+ `UiModeManager.contrast` · API 36+ high-contrast text state): grid lines and secondary text are promoted to the body color, accent saturation is kept, borders are 2dp. - The **input field border** is `inputBorder` (the secondary text color), separate from the decorative grid. It is at least 3:1 in light and dark, and focus uses the accent color and a thicker border. - **Derived tokens** (2026-10-15, written down from what the shells already share): `warningInk` `#8A6800` / `#F1C21B` — warning *text* (the warning hue itself is only for borders and fills); `onAccent` · `onDanger` `#FFFFFF` light / `#0E0F12` dark — text on an accent or danger fill; `selectedLine` = the line width + 1 — focus and selection edges; the search match tint, the accent at 16 % (the current match 40 %); **`disabled`** = the secondary color at 50 % opacity — the ink of anything disabled. Being an opacity of the secondary color, it stays lighter than enabled ink in high contrast, where the secondary color becomes the body color. - **`grid` is decoration only**: dividers, the dot grid, the waveform loader's ghost ticks. It is never a control's ink or edge — a disabled control draws in `disabled`, and the edge of a chip, a dialog card or a menu is `inputBorder` (2026-10-15). - Every copy of the palette outside the shells' theme files — the Android and iPhone widgets, the Live Activity, the Wear tile and complication, the Apple Watch widgets, the Windows tray icon — takes the **dark** value on a dark ground and the light value on a light one (2026-10-15: the Live Activity's `Stop` was the light red on its dark ground, 3.8:1). A test reads every copy against the theme files. - **Typography**: UI body = the platform system sans-serif (Roboto / SF Pro / Segoe UI — no bundled local fonts; Korean glyphs are guaranteed). **Data = monospace** (Android `FontFamily.Monospace`/Roboto Mono, Apple SF Mono, Windows Cascadia/Consolas): timer `12:34`, part `p001`, file names, bytes, status words (`Waiting for Drive`), device ID. Scale: 12 / 14 / 16 (body) / 20 / 28 / 44 (timer; 20 in the popover and the tray, beside their record node, 2026-11-04) — fluid interpolation. Nothing is smaller than 12, watches included — on Wear at its 227dp layout basis, which a smaller face scales as a whole (touch targets excepted: they stay 48 on every face). The timer is mono at regular weight with 1 of tracking on every shell; a button's label is 14 whether it is sans or mono (2026-10-15). A dialog's or sheet's body — a question, a consent or disclosure notice, the ChatGPT welcome — and a banner are 14 in every tone, the size of the rest of the app's running text; a row's secondary line and a section's footnote stay 12 (screen principle 4). No text is left to the platform's default style (17 on iOS, 13 on macOS, 16 in Material 3), which drew the same sentence at a different size on every shell (2026-10-28). Line height is 1.4 for sans and 1.35 for mono on the Kotlin shells; Apple keeps the platform's own. - **Watches** have their own sizes, fitted to the round and square faces they were laid out on (2026-09-29 Play review): Wear timer 34, label 13, small 12; Apple Watch timer 20, status 14. Status words are mono there too, and an idle record node is filled with `surface`, as on the phones. - **Widgets** — the iPhone's and Android's home screen widgets, the Live Activity, the Apple Watch complication and the Wear tile — take the same sizes rather than the platform's text styles (2026-10-28): the clock 20 where it has a line of its own, a word 14, and 12 in a compact slot or on a curved label; the Wear tile the watch's label 13 and small 12. On Apple they grow with the user's text size by the factor the app measures. A tile can draw only Roboto, so its words are in the sans; a Wear complication is drawn by the watch face in its own type. - **Time format** (2026-10-08, user decision): under an hour `MM:SS` (`02:03`), from an hour `HH:MM:SS` (`01:02:03`). Every time inside one recording's screen — the detail clock, transcript time buttons, find and search hit times — takes its format from the recording's total length, so they share one width; live timers (record screens, popover, tray, both watches) follow the elapsed time itself. Exported files (`.txt`, `.md`, `.srt`, `.vtt`) and spoken times keep their own formats, and an Ask answer's citations show as ChatGPT wrote them (`[00:12:34]`), since they are part of its text (2026-10-10), and Apple's widgets, Live Activity and watch complication use the system's self-updating timer (`0:12`), the only text a widget can update every second (2026-10-11). Examples elsewhere in this document written `00:12:34` stand for a time in this format. - **Shape**: corner radius 4 (nodes) / 8 (cards) / 0 (table rows) / **2 (badges)** / 24 (a bottom sheet's top corners, the iPhone's system sheet, on both phones 2026-10-31) — squares smaller than a node, such as status badges, checkboxes, radios and switch thumbs, use half the node radius (`Radius.badge`). The round record button is a **square node + thick border** (72×72; while recording, filled + mono timer). On phones (Android · iPhone, 2026-09-29) and the desktop (Mac · Windows, 2026-10-14), **text buttons are at least 88 wide** (dp/pt): a two-character button such as `Open` (`열기` in Korean) does not become the narrowest thing on the screen, and short buttons side by side line up at one width. `Close` · `Delete` · `Cancel` (with `Discard recording` and the desktop's `Quit`), data buttons in the fixed-width font (utterance times), glyph buttons (`⋯`) and the value of a dropdown keep their own width (user decision). At 360dp · 375pt, in Korean and English, this was tuned so that no new line breaks appear — a recording's fixes wrap in a flow (screen principle 2 below), and the mobile data confirmation dialog (three buttons) always stacks vertically. On the desktop a line of buttons that does not fit wraps rather than overflowing: the Details lists' open row wraps its fixes, and the Mac's player bar wraps at the detail pane's narrowest. The watches are unchanged. - **Spacing**: multiples of 4, base rhythm 8/16/24. Visible grid: 8dp dotted background (6% opacity, off in high contrast). The named steps are `xxs` 2 (a label and its value) · `xs` 4 · `s` 8 · `sm` 12 · `m` 16 · `l` 24 · `xl` 32; a literal is for artwork only. - **Component measurements** (2026-10-15): one table for every shell, so a component is the same size wherever it is drawn. The rule that chose them: where two shells agreed the third moved, where all three differed the value on the 4 grid won, and the minimum touch size decides a control's height, so its vertical padding is the small one. | Component | Measure | |---|---| | Screen header | 16 above, 12 below, 16 at the sides | | Button | padding 8 × 4, height — and for a data button in mono, width — at least the touch minimum, label 14 | | Status badge | padding 8 × 4, radius 2, centered, wrapping between words rather than cutting one; fades 150 ms when its state changes; in a ledger row, under the title at its own width (2026-11-04) | | Time and speaker button | drawn at the status badge's size and corner — padding 8 × 4, radius 2, label 12 (mono for a time or a speaker id) — inside a target of at least the touch minimum each way (2026-10-28): the transcript's and the notes' time buttons and the speaker badge, the Ask presets, Version history's `Restore` and the popover's and tray's `Settings` and `Quit` (2026-11-04), and every action on the list's banner — `Check key`, `Connect Drive`, the model's download and its cancel (2026-11-05, user decision); in a notes row, among the presets and in the popover's and tray's footer the target takes only the box's height in the layout and reaches the minimum past it (2026-10-31), so the footer is as low as its boxes: 8 above and below them, 40 in all on both desktops (2026-11-04) | | Record node | 72 square, a 3 border in the recording colour, the 22 stop square inside; in the popover and the tray (2026-11-04) 44, a 2 border and the 14 stop square, the timer beside it at 20, 12 apart — the phone's size outweighed the ledger in a panel that small — and in the 44 row with the timer the track, 12 from it to the edge; the input lines under them, 4 apart | | Table row, switch row | 12 top and bottom, 16 at the sides, height at least the touch minimum; a disabled switch row's words in `disabled`; the switch track at the end of its touch box | | Section header | 8 below | | Ledger row | 12 top and bottom; the time column as wide as the longest time in that shell's mono font (62 Android and Apple, 68 Windows) | | Text field | inset 12 × 8, height at least the touch minimum; its label in the label face (12 medium, tracked); focus: accent at `selectedLine` | | Chip | label 14; room for its `✓` kept while unselected, so selecting moves nothing; edge `inputBorder`; chosen and disabled, a dashed `disabled` edge at `selectedLine`. A compact chip (2026-11-04, user decision: the detail's `Transcript` · `Summary` · `Notes`) is drawn at the status badge's size and corner, label 12, its target the touch minimum wide and only the box's height in the layout, reaching the minimum past it as a notes row's time does | | Switch | thumb radius 2; disabled: a dashed track in `disabled` | | Dialog | title 20; body 14 in every tone — the tone is the colour only; card edge `inputBorder` over a scrim — on a sheet of its own (iPhone, Mac) the sheet is the card and no second edge is drawn inside it, the text 16 from its edges; an option row 8 above and below inside the touch minimum, its mark on the label's first line | | Bottom sheet, scrim | phones (2026-10-31): the page and its dot grid, top corners 24; under a sheet or a dialog a dim of black at 20 %, the iPhone's | | Banner | body 14 on the surface inside an edge of its tone — warning, or danger when it says something failed (a delete the storage refused, an import that failed); height at least the touch minimum, with the focus ring | | Empty list | title 16, hint 14 | | Focus ring | accent at `selectedLine`, inside the control, square like it — every focusable control, not only fields | - **Lines**: connectors and dividers 1dp (2dp in high contrast), straight lines and right angles only. - **Motion**: standard easing `ease-in-out 200ms` — `cubic-bezier(0.42, 0, 0.58, 1)`, the curve SwiftUI's `easeInOut` is, on the Kotlin shells too — status-transition badges `fade 150ms`, processing state shown for at least 400ms (kept even when the real work is faster, at most 800ms). **With `reduce motion`, "instant transition + text status only"** — what is turned off is the *transition*; the text status itself, such as "…" and "✓", stays. - **Icons**: geometric thin lines (1.5dp), platform system icons first (Material Symbols Outlined / SF Symbols light). The phones' tab bar glyphs are the iPhone's SF Symbols `dot.square` · `list.bullet.rectangle` · `slider.horizontal.3`, which Android draws in the same line (2026-10-31). ### App icon The master is the single **`docs/design/icon.svg`** ("A · Record node", 1024×1024 artboard): on a dot-grid background (64 pitch, r=5, `#888884` 28%), a node square (560, radius 40, border 28) with a record square inside it (240, radius 24). The colors are the tokens as they are. 1. **One master** — nothing is redrawn per platform; it is **only exported**. 2. The **monochrome template** (22×22 grid: outer square x3 y3 w16 h16 radius 2 border 1.5, inner x8 y8 w6 h6 radius 1) is used by the menu bar, the tray and the Android `monochrome` layer. Rasters under 64px are also drawn in these proportions instead of from the master — the master's border drops below one pixel and disappears. 3. The **recording variant** exists only in the menu bar and tray. Only the inner square is red (Light `#DA1E28` / Dark `#FA4D56`); the outer one keeps the body color. 4. The **quick-start variant** exists only on the watch's second launcher item, "Recly Quick Rec" (§11 W5), so the two launcher items do not look like one app twice (2026-10-14). The master's ground with the record square alone, no node: on the 108dp adaptive canvas a `#DA1E28` square x36 y36 w36 h36 radius 3.6, the same filled square as its `monochrome` layer — the square the complication already shows while recording (`mipmap-anydpi-v26/ic_quick_start.xml` + `drawable/ic_quick_start_{foreground,monochrome}.xml`). Platform outputs: Android phone and watch use vector adaptive icons (`res/mipmap-anydpi-v26/ic_launcher*.xml` + `res/drawable/ic_launcher_{background,foreground,monochrome}.xml`, inside the 66dp safe zone of a 108dp canvas), Apple uses each app's `Assets.xcassets/AppIcon.appiconset` (iOS light/dark/tinted; macOS 16~512 @1x/@2x with a 10% margin + squircle), the macOS menu bar uses the `MenuBarIcon`/`MenuBarIconRecording` image sets, and Windows/jpackage uses `windows/app/src/main/icons/recly.{ico,icns,png}`. All rasters are committed, so the build does not run the scripts. To regenerate them (macOS only): ``` swift scripts/render-icons.swift python3 scripts/make-ico.py --check windows/app/src/main/icons/recly.ico ``` ### Screen principles 1. **Recording screen = instrument panel**: a mono timer and the square record node — on the phones the timer in the middle and the node at the bottom; in the Mac popover and the Windows tray the node at the start with the timer beside it, `00:00` while idle, the track beside the timer (screen principle 6) and the two input lines under them (§12 "Input lines") — all of them up in every state, so a start or a stop changes what they say and moves none of them (2026-11-04, user decisions: the desktops' `Start recording` and `Stop recording` buttons gave way to the phones' node, so a start looks alike on every device; the header's edge stays put). Only the recording's memo row (§8 "Memo") comes and goes, under the input lines while a recording runs: a memo belongs to one recording, so before a start there is none to write in (2026-11-04, user decision). The three state nodes that stood above them — device, transcription, state, joined by straight lines — were removed from all four shells the same day (user decision): the device said nothing the user does not know, the transcription provider is in Settings, and what the state said, the timer, the record node (`…` while starting or saving) and the ledger's badges already say. While recording, only the elapsed time, as a mono timer (2026-09-03: part numbers, segment boundaries and track metrics were removed — they are data that is not shown to the user). These two form the screen's boundary. - The **line below the record node** (phone, 2026-09-29) is used only when there is something to say: in the idle state, only the message left by the start or stop just made (recording failed, finishing the save failed, too short so not uploaded, and so on). `Waiting` · `Opening` · `Recording` · `Saving` are not written there, because the timer and the record node already say them. The line keeps its height even when empty, so the node does not move, and it is cleared when the user goes to another tab and comes back — it is news of that moment, not a message to leave on the tab. The Mac popover and the Windows tray took the same rule on 2026-11-04 (user decision), once their headers had the phones' node and timer: their line, at the ledger's end, says news only and is left out when there is none — `Waiting` was most of what it said, and it meant nothing the `00:00` above did not. The watches' lines are unchanged. - **What a start or a stop says** (2026-10-15, every shell and both watches, in the line each one has): a recording that ends without the user stopping it (the capture failed or the helper stopped) says `The recording failed`, is queued untitled and asks for no title; a start that fails says the same. After `Save` the line says what the queue did with the recording when that is not the usual — `Too short — skipped`, `Review recording settings to continue`, `The audio has already been cleaned up`, `Already handled`; a queue that refused it says `Could not create the job`, and a title that arrived after the upload took the folder name says `Too late to save the title — the upload has started`. The record control is disabled while `Starting` and while `Saving` — there is no Stop during a start. With the microphone off, a start is refused with `The microphone permission is required.` and the platform's settings button. Under 200 MB free, a start is refused with `Not enough storage to record`. A recording stopped under 50 MB says `Recording stopped: storage full` in a notice, as the battery stop does, and its line says what any stop says (§3 "Free storage on the device"). - The record screen's header is the app name only: the meta line ` · ` was removed from all four shells (2026-10-08, user decision — the device ID is in Settings → About). The device node, removed with the other two on 2026-11-04, said the source's name in the app language — `Phone` / `Desktop`. - **Display of the workflow in use** (ADR-016) — **retired (2026-09-24)**: there is no workflow to choose, so the picker and the `Choose a workflow` prompt are gone. What follows is a record. The workflow node in all four shells shows **only its name**. The picker lists only workflow names, and the selected one is a filled chip (✓) — "selected" is not written out. A single-selection control shows its selection state by itself; adding words only makes it longer. Choosing in the picker changes this device's pointer on the spot. In the workflow list, the row in use gets the badge `In use`, and the action on the other rows is `Use`. **When there is no selection, or the workflow the pointer pointed to is gone**, both the node and the picker show, in all four shells, the single line `Choose a workflow` — in the same place, it says that just starting runs nothing and how to fix that. - The **status node** (removed 2026-11-04 with the other two; a record) said the recorder state as a word — `Ready` · `Starting` · `Recording` · `Saving` — and, while the recorder was idle, `Uploading` (a job of this device running) or, on the phones, `Receiving` (a recording coming in from the watch) with the loader beside it, `REC` beating both. The timer and the record node say the first four, and the ledger's badges the other two. While saving, the timer keeps the recording's final length and goes back to `00:00` only when the recorder is idle (2026-10-08, every shell and both watches). - The **loader** is a rotating 8pt square, used next to the text of work that runs without progress — the badges `Importing`, `Filling in` and `Summarizing`, and `Downloading model…`. No system spinner is used — only the list's pull-to-refresh is allowed as an exception (§3 "Recordings from other devices", 2026-09-29). There are only two loaders: this one, and the **waveform loader**, used only in the waveform's place (the detail screen in screen principle 2). The waveform loader does not rise and fall or flow like playback and recording waveforms, and does not leave the places it has passed filled — flowing waves, bouncing bars, breathing and filling from the left are playback or recording indicators on the various platforms, so they are not used (2026-09-26 research) — with `reduce motion`, the word only. The square's turn moves nothing but the square (2026-11-04: on Apple a turn started as the loader appeared once lent its endlessness to a resized list, which shook until the app quit; the hidden badges that measured the status column, kept still for that, went with the column the same day). The ledger is updated even mid-pass through the core's job row observation (`jobs.observe()`) (Apple · Android · Windows 2026-09-03; Windows reads Settings → Accessibility → Visual effects → Animation effects, `SPI_GETCLIENTAREAANIMATION`, 2026-10-15). - **Quick start** (iPhone, 2026-10-07): a Home Screen widget (small: the square record node; while recording, the mono timer and a stop node) and Lock Screen widgets (circular: the record icon or the timer; rectangular: `Recly` + `Record` or the timer) run the existing start and stop App Intents, opening the app where iOS requires it, like the Control. Gallery name `Record`, description `Start or stop a recording.` 2. **List = ledger**: row = `time (mono) · title (and its preview line, or its status badge) · length`. In all four shells, regardless of language, the time column is **`MM-dd` over `HH:mm`** (month first; a fixed-width pattern, not locale formatting — only the spoken sentence uses locale formatting). The status is a text badge (both color and text), and the failure reason is a translated message key. The badge says a translated word, not the code (2026-10-08, user decision): `Done` · `Failed` · `Retrying` · `Waiting` · `Uploading` · `Receiving` · `Transcribing` · `Filling in` · `Summarizing` · `Importing` · `Recording` · `Waiting for Drive` (`NEEDS_AUTH`) · `Needs permission` · `Waiting for model` · `Storage full` · `Too short` · `Waiting for iCloud` · `Waiting for folder` · `Unknown` (ko `완료` · `실패` · `재시도 대기` · `대기` · `업로드 중` · `받는 중` · `전사 중` · `채우는 중` · `요약하는 중` · `가져오는 중` · `녹음 중` · `Drive 연결 대기` · `허용 필요` · `모델 대기` · `저장 공간 부족` · `너무 짧음` · `iCloud 대기` · `폴더 대기` · `알 수 없음`); the code names below are the states. **A done row wears no badge** (2026-11-04, user decision — a `Done` on every finished row said nothing): the ledger has no status column — its header reads `Time` · `Title` · `Length` on every list — and a row in any other state wears its badge under its title, in the preview line's place, at its own width; once the row is done the badge goes and the preview comes back. The row's spoken sentence still says its state, `Done` included. A word wider than the title column wraps inside its badge onto a second line (centred, never cut). A word too long for its line breaks only at the language's own hyphenation points, with a hyphen (2026-10-11 — badges and banner lines; Compose Desktop has no hyphenation, so Windows only wraps between words there). Status words written in Hangul, Kana or Han characters are set in the sans face, not the mono one — mono has no such glyphs and leaves full-width gaps (`업로드 중`); Latin words, times and counts stay mono (Apple sets a whole string with such characters in sans, counts included). The notice banner says `Waiting for Drive: 3` for uploads that wait for Drive (on a window narrower than 600 pt/dp every banner line gives its sentence the full width, with its button end-aligned under it — 2026-10-11, both phones). The banner wears no badge (2026-11-05, user decision): beside its button, at the button's size, `Failed` read as a second button that did nothing, and the line's colour — red for a failure, the warning's for a wait — and the rows' own badges already say the state. Under the title, a row whose recording has a transcript on this device shows its first words on one secondary line (`core.previews`; phones and the desktop Details lists 2026-10-08, the popover and tray ledgers too since 2026-10-28, so the short list and the Details list read alike). In the desktop Details lists only the open row carries its reason, its diagnostic line and its actions (`Download model`, `Retry` by the ledger's rule, `Check the key`, `Open Drive storage` on a full Drive) — the other rows are title, preview or badge, time and length only (2026-10-11). The open row's band appears only when it has something to say or fix: `Delete` moved to the detail's More menu (2026-10-15, user decision — a finished row's band held Delete alone, a wide empty strip that read as an accordion to collapse). The Details list is a selection, not an accordion: clicking the open row again — or a search hit or a tray or popover row for it — changes nothing, with no reload and no flicker. **Three things happening elsewhere** (2026-09-04, §3 "Recordings from other devices") are read **before** the job state mapping — none of the three is a job of this device, so they cannot be read from the queue: `RECEIVING` (being received from the watch = `receiving`; its `status = recording`, so left alone it would show as `REC`) · `UPLOADING` (another device is uploading = `remoteUploading`; the **same word** as this device's upload — the badge says what, not where) · `TRANSCRIBING` (another device is transcribing = `remotePending` with `transcribe`). All three are accent. `RECEIVING` offers **nothing but its page** (its `Delete` disabled with the reason, no retry or Drive link — the transfer is still writing it; 2026-10-08: this sentence follows the shells, which all open the detail). Another device's `UPLOADING` has no retry or Drive link, but offers Delete (2026-10-08, four shells): the adopted-row dialog, which deletes the folder from Drive (§3 "Retention · deletion"). `TRANSCRIBING` is like an adopted `DONE` row (details · delete). The length column keeps the placeholder used when there is no `durationSec` (it does not make up `0:00`). In the header count, the first two count as `Waiting` and `TRANSCRIBING` is not counted. `NEEDS_AUTH` waiting for a Drive connection (badge `Waiting for Drive`, 2026-10-08) also counts as `Waiting` — it is work that resumes by itself once Drive is connected, not a failure (2026-09-29, both phones). The spoken states are `Receiving from the watch` · `Uploading on another device` · `Transcribing on another device` (§7). **A recording being filled in** (2026-10-15, user decision; §8 "Titles and speaker names") — in the core's `observeFills()`, whether a publication or the detail asked — shows `Filling in` (accent, with the 8 pt square loader to its left) on every list: the phones, the popover and tray ledgers, and the desktop Details lists. It is read after `REC` and `RECEIVING` and ahead of everything else — the other things happening elsewhere and the job state mapping — and the row goes back to what its job says when the run ends. The header count and the row's actions still go by the job state. The row's spoken sentence says `Filling in` too, so a screen reader hears what the badge shows. **A recording being organized** (2026-10-29, user report — Summarize again from the detail left the list saying `Done` while the detail still said `Summarizing…`; §8 "Organizing") — in the core's `notes.observeOrganizing()`, the final pass or `Summarize again`, the same reading as the detail's `Summarizing…` — shows `Summarizing` (accent, with the square loader, as `Filling in`) where the row would say `Done`, on every list, until the pass ends, however it ends; then `Done` again. Any other state stays as it is — a part organized while recording leaves `Recording`, a transcription still running leaves its own word — and a fill is read first. The header count and the row's actions go by the job state; the spoken sentence says `Summarizing`. The watches show no notes, so they have no such row. **A row opens its recording's page** (2026-10-28, user decision, every list: phones, popover, tray and Details lists — a tap that only expanded the row, then a second tap on `Details`, was one step too many). A row carries no buttons; the popover's and tray's bring the Details window to the front at the recording. What can be done about a recording is on its page: `Open in Drive` (when there is a link; the desktops' `Show in Finder` / `Open the folder` for an iCloud or local-folder recording) and `Delete` (disabled with the reason while recording or importing; an upload, this device's or another's, offers it since 2026-10-08 — the core stops a running upload first, §3 "Retention · deletion" — and so does a recording receiving from the watch since 2026-10-30) are the More menu's last two groups ("Detail header and More menu" below). A recording with no transcript has its **fixes** — the waiting line, the reason with its diagnostic, and the buttons in one flow that wraps onto further lines (no vertical listing): `Download model` · `Open Drive storage` (a full Drive) · `Review transfers` (the iPhone, a job waiting for the transfer consent) · `Retry` (**in the failure states `FAILED` · `NEEDS_AUTH` · `NEEDS_SPACE`, and in `RETRY`, which waits for backoff after a failure** (all four shells, 2026-09-04; excluding `TRANSCRIBING`, where the provider is transcribing — that is someone else's clock) **only**) · `Check the key` (`AUTH_REJECTED`). On the phones they stand centred under the page's transcript notice, and for a failed or waiting job their reason — or, while the model downloads, its progress — takes the place of the sentence that sent the user to the list; a recording waiting for Drive keeps its `Connect Drive` alone. On the desktops they stand under the open row of the Details list, beside the page (above). A delete from the page closes it, and the list says how it went. Tapping the list tab while the detail is open closes the detail first (Android; the iPhone detail is a sheet that covers the tab bar). In the detail, the original is the main content: the transcript is layered on top of **local part playback** (play/pause + an `elapsed / total length` mono clock; a playhead moves over the waveform (the parts decoded into peaks of 0.25-second windows; the played section is accent), and dragging or tapping the waveform seeks to that time — Apple · Android · Windows 2026-09-03; the track is `mix` if there is one, otherwise `mono`; if the file is not on this device, `No audio on this device`). All four shells (2026-09-03; Apple uses a RecKit shared view + AVQueuePlayer, Android uses media3 ExoPlayer, Windows has the bundled ffmpeg decode to PCM and plays it through `SourceDataLine` — the JVM cannot decode AAC). For an uploaded recording, parts that are not local are fetched from Drive with `core.audio()` (`Fetching from Drive…`/`Could not fetch from Drive`). **There is no text while fetching** (2026-09-26, four shells): the waveform's place holds the **waveform loader** (§9 "Motion" — a 10-cell band in the secondary text color passes one cell at a time from left to right over short ghost ticks in the grid color), and the play button's place holds **button-shaped progress** (an accent border the same size as the button fills with accent in proportion to the bytes received, and when it is full, that spot becomes the play button). Progress rises part by part (the core's `audio(recordingId, progress)` reports the bytes received against the total bytes of the parts to be sent). For a recording fetched from Drive, once the peaks are ready, the bars rise once from the center line, starting from the left (0.75 seconds; immediately with reduce motion). With reduce motion (Android · Apple), the band stops and `Fetching from Drive…` is written again — Windows does not know that signal, so its band always runs. Screen readers read the progress cell as `Fetching from Drive…` and a percentage. Until the fetch decision is made, the play button is not shown; if only part of the audio was fetched, only the contiguous leading part plays. **The waveform is decoded only once** (2026-09-27, four shells): the shell stores the peaks it decoded as the core's `WaveformPeaks` (`waveform.v1` — `RWF1`, the window count, 16 bits per window) next to the parts in the recording folder (`core.recordings.saveWaveform`), and opening the detail reads that first (`core.recordings.waveform`; if the window count differs from the selection being decoded this time, it decodes again). When a recording finishes on this device (on a phone, when the watch recording has been fully received), it is precomputed in the background. While decoding because there is none, the waveform's place holds the **waveform loader** instead of a flat line (which reads as silence) (ghost ticks only with reduce motion; screen reader `Loading waveform…`), and the play button stays as it is. This file is not uploaded; it survives the 7-day local audio cleanup (which deletes only the parts), so the waveform is drawn even before the audio is fetched from Drive again, and it is deleted together with the recording. While recording on this device, playback is not offered. There is no "Upload now" (2026-09-02: a recording without jobs does not need uploading, and waiting jobs run at their time). The menu bar and tray popovers and the desktop detail window use the same ledger — **they read 20 rows at a time and add the next 20 rows when the last row becomes visible** (infinite scroll; 2026-09-04, previously the popover and detail window showed the 5 most recent rows). **On Android and iPhone, the detail's playback area is fixed below the body** (2026-09-10). Only the transcript body scrolls; below the waveform, elapsed/total length is on the left and the play/pause button at the right edge. In both states the button's minimum width is 120pt on iPhone and 120dp on Android, and on Android the button is at least 48dp tall. The playback area does not cover the body. The audio download failure notice is shown below the mobile playback controls, which keeps the button right-aligned. **macOS and Windows keep the existing top playback area and the desktop window and split layouts.** All four shells offer transcript text selection, copy all and seeking by utterance time, and search titles and transcripts (**Search** below, 2026-10-07 — until then no transcript search button or field was shown). Copy all is a row of **Share** (below, 2026-10-07; until then a text-less copy icon left of `Rename` in the header). Its accessibility name is `Copy all`, and after copying, a check mark and the accessibility name `Copied` signal completion. It copies the whole original text with the times. Seeking by time is disabled while recording and while audio is being checked or downloaded, and times outside the range of audio actually held are disabled too. Copy is offered whether or not there is audio. Utterance groups are cached when the result changes, and long utterances from the same speaker are also split, starting at the next segment, at a 60-second or 1,200-character threshold and shown in a lazy list (a single segment returned by the provider is itself never split). The whole string is not re-joined on every playback clock tick. Android and Windows waveforms have visible keyboard focus and 5-second left/right seeking. All four shells observe job, step and recording changes in an open detail and refresh the transcription result. When only the transcript or the title changes, the playback and reading positions are not reset. Recording state and audio parts are observed separately from the transcript, so when a recording ends or parts are added while the detail is open, the playback area updates without reopening the screen. iPhone and macOS stop existing playback first, in the recording start preparation step, and then start capture. Until capture has fully stopped, playback and seeking are blocked, and the block is also released when starting the recording fails. Android stops playback when a recording starts on this device, and blocks playback and seeking (waveform · utterance times) while recording. The start and end of other recordings are also reflected in an open detail. It distinguishes waiting for transcription / no transcription step / job failed / read failed / empty transcript / body. A recording whose upload waits for Drive says `Waiting for Drive. Connect Drive to upload and transcribe.` with a centred `Connect Drive` button, instead of pointing back to the list (2026-10-08). A read failure offers reading the result again. On an explicit retry, if the local transcript is corrupted, the remote copy is fetched through the existing Drive path, verified, and then swapped in. If a newer valid local result appears during the download, that result is kept. Normal lookups are local-first, and healthy files are not downloaded again. An empty transcript offers a notice to play the recording. While playback is preparing or buffering, no separate notice text is added, so the player height and the position of the surrounding content do not change. Real playback failures are shown on screen and retried on the next playback request. Windows also shows ffmpeg's abnormal exit and exit-wait timeout as failures, excluding exits caused by the user's stop or seek. The detail header has the title and, at its end, `Share` and `More` (**Detail header and More menu** below, 2026-10-07), in one row (2026-10-08): Android has no `Close` button — the system back and re-tapping the list tab close the detail; the iPhone sheet has a quiet `xmark` icon button (`Close`) after `More` and stays swipe-dismissable except while an edit has unsaved changes. Internal IDs are not shown (2026-09-29 — they are not information for the user to read). The list's first read shows a loading notice, and a truly empty list shows a notice that this is where recordings collect (desktop windows also show a start recording button, §9 screen principle 8). (2026-09-03: the parts table in rows was removed — part numbers, tracks, bytes and sha are not shown to the user) The detail additions of 2026-10-07 (§3 "Metadata", §8 "Editing" · "Exports", §10 "Re-transcription" · "Search" · "Shared rules for the shells") keep the rule above: the original stays the main content, the transcript a layer beside it, and nothing is offered on the user's behalf. - **The same list on every surface** (2026-10-15, flow-parity pass): the desktop Details windows' headers count like the phones' list header — `12 · 2 waiting · 1 failed` — and their rows are time · title (with its preview or its badge) · length; the popover and tray ledgers say `Loading…` before their first read, as the lists do. The fixes of a job waiting on the provider draw `Waiting for the transcription result — 12 min elapsed` on every shell (the phones' page, the Details windows' open row), and a `Retry pending` reason is in the warning tone, not red. `Retry` on a recording waiting for Drive (`NEEDS_AUTH`) runs the Drive fix — the sign-in — instead of queuing a retry that cannot pass, and the list banner's `Connect Drive` starts the sign-in as the detail's does; a retry the core refuses says `This cannot be retried right now`. What a list or page action did is said above the list — the Details windows included; a delete that went as asked is not announced (the row leaving says it), and one the core answers `Busy` for says `Still transcribing on this device — try again once it has finished`. The banner's count line is `Recordings waiting: 3`. A full Drive's fixes offer `Open Drive storage`, and the Details windows' More menu has `Open in Drive` (Drive) or `Show in Finder` / `Open the folder` (a local folder) for the open recording. - **Import**: phones put an `Import` icon button at the end of the list header (accessibility `Import audio`), which opens the system picker for audio and video; the share sheet reaches it too (Android lists `Recly` for audio and video, iPhone has the share extension `Import to Recly`, which only saves the file into the app group's inbox, says `Open Recly to import.` and closes — it does not open the app; Recly imports the inbox when it next opens or becomes active). The Mac's Details list header has `Import audio…` (⌘I — a menu-bar app has no File menu) and takes dropped files; Windows has it as an icon button in the Details list header (`Import audio…` on hover) and in the tray menu, and takes files dropped on the window. The recording appears at once as a ledger row in `IMPORTING` (accent + the square loader), titled with the file name without its extension, and then goes on like any recording — no title dialog. A failure says `Could not import this file` with the core's reason, on the row or, without one, as a notice. Several files are imported one after another, each its own row. - **Search**: phones put a field at the top of the list (Android a Blueprint field, `inputBorder`, radius 4, a leading search icon and a trailing clear `×`; iPhone the same field, RecKit's `ListSearchField`), the Mac and Windows Details windows above their list; the popover and tray ledgers have none. Placeholder `Search titles and transcripts`, 200 ms after the last keystroke, at most 50 results. While the field has text the results replace the ledger: the time column · the title with its matches tinted · up to two snippet lines (secondary; a match's background is the accent at 16 %) · the mono time of the first transcript hit at the row end. A result opens the detail at that hit, every match tinted, with the **find bar** at the top of the transcript — `‹ 2 / 7 ›` and a close `×`; the arrows move between matches and do not seek; closing it removes the tints. Phones get the find bar only from search; on the desktops ⌘F / Ctrl+F also opens it for the open transcript, with a query field of its own (2026-10-15, both desktops), or else focuses the list's field. No results: the centred `No recordings match` over the dimmed `Search looks in titles and in transcripts on this device.` - **Detail header and More menu**: at the header's end, two icon buttons, `Share` and `More` (Windows: `Export…` and `More`, each named on hover; the Mac keeps `Share` in the window toolbar and `⋯` in the detail header). `More` lists, in two groups parted by a hairline (2026-10-10, the same on every shell): `Rename` · `Edit transcript` · `Transcribe again` · `Version history` (2026-11-04, §9 "Transcript reader"); `Summarize` (`Summarize again` once there are notes) · `Edit summary` · `Fill in title` · `Fill in speaker names` (§9 "AI view"; the group is absent where ChatGPT is not offered). Asking about the recording, and changing it by asking, is the composer at the foot of the page, not a menu item (2026-11-04, §9 "Ask"). Two more groups follow (the desktops 2026-10-15, the phones 2026-10-28): the recording's folder — `Open in Drive` (when there is a link), or on the desktops `Show in Finder` / `Open the folder` — and last, the red `Delete`, which opens the delete dialog over the page (§3 "Deleting in the app"; no keyboard shortcut, user decision; disabled with `Still recording` or the importing word while the recording is being written). The menu is never cut short: it may be as tall as the window. An item that cannot run now stays in the menu, disabled, with the reason as its second line — `No transcript yet` or `Transcribing…`; `Transcription is off in Settings`; `Not uploaded yet` (`Transcribe again` on a recording that has not reached the storage) or `Waiting for Drive` (its pending job waits for a Drive connection); `Still recording` while it is being written — for every item, and More is shown then too (2026-10-10). The items stay disabled while any job of the recording is unsettled, but `Transcribing…` is said only while a transcription of it is queued or running (2026-10-08); `No audio on this device` — so the menu says why instead of hiding it. A start the core still refuses shows the same reason for a few seconds under the header; nothing is silently ignored (2026-10-08). - **Share / export**: on phones `Share` opens a bottom sheet titled `Share`, one row per format (icon · label · format): `Transcript` (`Text · .txt`), `Transcript for note apps` (`Markdown · .md`), `Subtitles` (`SubRip · .srt`), `Subtitles for the web` (`WebVTT · .vtt`), `Audio` (`M4A`), then `Copy all`. A file row calls `exportFile` and shows `LoadingText` `Preparing…` in its trailing area until the system share sheet opens with the file. Rows that need a transcript are disabled with `No transcript yet`, `Audio` with `No audio on this device`, and every row — `Audio` included — with `Still recording` while it is being written (2026-10-15, every shell). The Mac's toolbar `Share` menu has the same formats (`NSSharingServicePicker` under the button), `Copy all` and `Save as…` (`NSSavePanel`, the core's file name); Windows' `Export…` has the five formats, each a save dialog with the core's file name, and `Copy all`. - **Transcript reader**: while playing, the utterance group under the playhead gets a 2 px accent bar on its start edge and an accent time button; the text stays the body color and no other row is dimmed. The list keeps the active group in its upper third (200 ms, instant with reduce motion). If the user scrolls during playback, following pauses and a **`Back to playback`** pill (accent outline, radius 8, height 32, centred above the player) appears; tapping it scrolls back and follows again, and it goes away when playback stops. **Speaker badges**: each group header shows its speaker as a quiet badge (radius 2, secondary border) — the name the user gave (sans) or the id `S1` (mono) — and tapping it opens the speaker menu, in reading mode too — disabled with `Transcribing…` while a transcription of the recording runs (2026-10-15, every shell). A transcript without speakers shows no badge, and there is no legend: speakers are told apart by label only, with **no speaker colours** — not on the waveform bars, not as tinted rows (2026-10-02 user decision, restated 2026-10-07). While a re-transcription runs, the old text stays under a one-line status, the square loader and `Transcribing again…` (external) or `Transcribing on this device` (local), until the new transcript replaces it (the reading position goes back to the top only if the user had not scrolled). A group's segments run on as the transcript file joins them (§8 "Result files"): nothing between Han, Kana or full-width punctuation, a space elsewhere — so the find bar finds a Japanese or Chinese phrase that crosses two segments; the list's first words (`core.previews`) join the same way (2026-11-05, every shell; `TranscriptDocument.separator` for a reader that builds its own groups). - **After an answer's changes** (2026-11-04, §9 "Ask"). The words of each segment it changed are tinted the way a search match is — the accent at 16 % behind them (`TranscriptDocument.segmentRanges` where the reader draws the core's blocks, Android and Windows; the Apple reader finds them in its own paragraphs, `TranscriptMatch.segments`) — for as long as the answer is on screen. No other mark. - **Version history** (2026-11-04, §8 "Versions"). More → `Version history`, in the first group after `Transcribe again`, disabled with the reasons `Edit transcript` has. A sheet on the phones (a bottom sheet on iPhone, a dialog on Android) and a modal dialog on the desktops: the title `Version history` with a ✕, then one row per version, newest first — what made it (`Transcribed`, `Edited`, `Corrected`, `Restored`, `Changed on another device`) over its date and time (the device's locale and time zone), secondary — and, on every row but the first (the transcript shown, which has no button and no label), a quiet `Restore` at its end, at the status badge's size (2026-11-04, "Component measurements") (accessibility `Restore the version from {date}`), `Restoring…` while it runs. No confirmation: the version replaced is kept, and the first row of the list undoes it. A restore closes the sheet and shows the transcript; refused, it says why under the list (`Transcribing…`, or `Could not restore this version`). Under the rows, secondary: `This device keeps the last 20 versions. Restoring one saves it as the transcript and keeps the one it replaces.` The watches have none. - **Editing and speakers**: the speaker menu (from a badge) has `Rename speaker` and `Change speaker for this line` → every speaker by name or id, and `New speaker`. `Rename speaker` is a dialog — `Speaker name`, the field `Name` prefilled, `Cancel` · `Save`; an empty name clears it and the id shows again. Changes from the menu save at once (`editTranscript`, inline `Saving…`; no `✓` after — the change shows itself, 2026-10-30). **Edit mode** (More → `Edit transcript`) turns the transcript into one plain text field per segment, finer than the reading groups, each with its time button (it seeks; the player stays usable) and its speaker badge (the menu acts on the draft; a transcript without speakers offers `Add speaker` on each line). The header reads `Edit transcript` with end-aligned `Cancel` · `Save` (`Save` accent; `Done` alone while nothing changed); on phones they sit below the content, above the keyboard, as in principle 3. `Save` → `Saving…` → reading mode. Leaving with changes asks `Discard your changes?` / `Your edits to this transcript will be lost.` with `Keep editing` · `Discard` — by any route: Back, another tab, re-tapping the list tab, another row, a search hit, the popover or tray row, closing the window (2026-10-15, every shell); a draft belongs to its recording and is never saved into another. Editing is unavailable while a transcription job of the recording runs (reason `Transcribing…`). The editor's footer (secondary, 12) says `Saving updates the transcript files in your storage. It does not start your agent again.` — with iCloud or a local folder only its first sentence, because the agent line is about recly-events on Drive (§15 §9). - **Playback**: one quiet chip shows the speed (`1×`, mono) and opens a menu — `0.75×` `1×` `1.25×` `1.5×` `1.75×` `2×` (`✓` on the current one) and a toggle row `Skip silence`. With skip silence on, the chip carries a small accent dot at its top-end corner (accessibility value `Speed 1.5×, skip silence on`). Pitch is preserved, and both are this device's preferences, not synced. Skip silence seeks over the core's `SilenceRanges` (§10 "Shared rules for the shells"); the clock always shows the real position in the recording. Phones: the waveform, then elapsed / total (start) · the speed chip · Play (end, ≥ 120). Desktop: Play · elapsed / total · the speed chip. 3. **Workflow editor = node graph** — **retired (2026-09-24)**: the user workflow editor and list were removed. The sentences about the editor and list are a record; the general layout rules at the end (from "iPhone supports portrait and left/right landscape") and the unsaved key input rule remain valid. Record: trigger (device) → step nodes → end. Nodes are square, connectors straight, and the selected node has an accent border. Steps are added with `+` on a connector. If there is already a `drive.upload`, `Drive upload` in the add menu is disabled (2026-09-04: a second upload does nothing if it targets the same folder, and if it targets a different folder it becomes a copy that later steps do not know about — webhooks and transcription read only the last upload; this is an editor rule and the parser does not block it). Webhooks are one per endpoint, so several of them are natural. On mobile, Save and Cancel sit below the input area and account for the keyboard and the safe area. While typing, the graph collapses and the input area scrolls. The transfer consent notice can be checked even while typing. Errors are explained in the scroll area and take the user to the related input area. With large text, buttons wrap, and the Android recording screen at small heights and with large text can scroll. Next to `New workflow` at the top of the workflow list is a borderless info icon. Its accessibility name and desktop tooltip are `How to set up transcription`. On mobile a bottom sheet, and on desktop a popover attached to the icon, explains Drive connection → transcription step → provider key → workflow selection. Opening the guide does not rearrange the list, and even on narrow screens the help and create buttons stay in the same right-hand action area. It does not block the first run. Apple screens that place an X close button at the top right of the guide center the title and the button vertically in the same row. In all four shells, the workflow list enters editing through each item's `Edit` button. The name and description area is information with no click action, and `Use` and delete are independent actions. `Edit` and `Use` are shown as blue accent buttons. In the mobile list, `Edit` is right-aligned together with delete, immediately to its left, with a minimum width of 96pt on iPhone and 96dp on Android. `Use` is on the left, and when the actions wrap with large text, the edit · delete area stays right-aligned. The item in use has delete disabled, and the list does not add a notice telling the user to choose another workflow first. Edit follows the existing unsaved-changes protection procedure. iPhone supports portrait and left/right landscape, and at low heights and with accessibility text sizes the recording screen scrolls. On Android, narrow headers and large text separate the title from the action row, and tab names show up to two lines. On short landscape screens, the tab icon row is omitted and the waveform and the playback clock are placed side by side. When the keyboard opens, the editing header that duplicates the tab bar collapses to make room for the save area. Long Apple button labels reflow to at most three lines within the screen width. Apple Watch scrolls its main screen and wraps status text, and Wear also allows multiple lines for long workflow names and transfer status. The discard confirmation for unsaved workflow input was retired together with the editor (record). The remaining unsaved input is the API key field of the recording processing settings, and all four shells discard it **without confirmation** (code checked 2026-09-29): `Cancel` during `Replace key` and choosing another provider (the field switches to that provider's) clear the entered value, and coming back to the same provider does not restore it. `Cancel` on the settings draft only reverts to the saved settings, so if the provider is unchanged the key field is unchanged too. On mobile, switching tabs preserves drafts, and Android's back applies only to the current tab. 4. **Settings = sectioned table**: Account / Language / Theme / Capture (per platform) / Uploads (phone) / Recording processing / Settings file (desktop) / Privacy (phone) / About (version · build · device ID · open-source notices, mono; the desktops add the `Privacy Policy` link, which the phones keep under Privacy — 2026-10-15; no `Open data folder` since 2026-10-29, user decision: the privacy policy gives the folder's path) — in this order, and a section the shell does not have is skipped (2026-09-29). The settings file — export and import of the recording processing settings — is a utility, so the desktops show it as a section of its own after the features, ChatGPT included ("AI view"); the phones keep it at the end of Recording processing (2026-10-06). **Theme** is three chips common to all four shells, `System default` · `Light` · `Dark`, and like language it is a local setting of this device — when unset, it follows the system's `prefers-color-scheme` (Windows 2026-09-01, the other three 2026-09-04). Settings hold no technical values (segment length and the like) — values the user cannot change are not shown (2026-09-04). The secondary description under a row uses small sans-serif (`12sp`, scaling with the user's font size), the secondary color, and 16 left/right / 8 top/bottom spacing. Settings that pick one item from a growing list — `App language`, and recording processing's `Provider` · `Spoken language` — are a **dropdown** at the row end in all four shells (2026-09-29): a box shaped like a quiet button with the current value and `▾`; tapping it opens the list (on Android and Windows a square popup with the same border and radius, no motion; on Apple the platform menu), and the chosen item gets the same `✓` as chips. For accessibility it is one element — the name is the row title, the value is the current value, the role is button. A value with nothing to choose (`Speech recognition model … Qwen3-ASR 0.6B`) has no box, only secondary-color text, so it looks different from a choice. A choice among only three (theme, transcription method) stays as chips. On phones (Android · iPhone), this chip row **fills** its line (2026-09-29): so that the chips alone do not float on the left of a screen where every other control sits at the right edge. If all chips fit at the same width, they share the line evenly; if a long name (`System default`) keeps them from fitting, the remaining space is added equally to each chip's own width — no chip gets narrower than its own width. If they do not fit on one line, they wrap, and each line is filled (Android `FillRow`, Apple `FillLayout`). In desktop windows the three words would stretch to the panel edge, so they stay left-aligned. The Drive connection status also uses the common settings row. On the right is a single quiet `Disconnect` button (2026-10-08: no longer red — disconnecting deletes no recording), and one confirmation dialog, whose confirm button is the primary one, completes it. Signed out, the row's `Connect Drive` is an accent outline button on every shell, as the local folder's `Choose folder` / `Change folder` is in every state (2026-10-15). What a connect or a disconnect did is said under the Drive row in Settings — `Could not connect Drive` in the danger tone, without the platform's raw reason, and no modal panel; the popover's or tray's status line may carry a short copy (2026-10-15, every shell). Google Drive and iCloud store recordings in a folder a month (`recly/memo/{{yyyy}}-{{MM}}`) and show no folder setting; only with a **local folder** is the folder a choice — two chips, `Monthly folders` (`recly/memo/{{yyyy}}-{{MM}}`) and `One folder` (`recly/memo`), with today's resolved path in mono under them, shown only while the storage is the local folder (2026-10-09, user decision; the core's `ProcessingStorage.uploadFolder()`). The stored value is still the template, and a value set elsewhere shows as Monthly and is left untouched until a chip is tapped. Android settings fields are the same Blueprint field as search and `Vocabulary` (2026-10-08). Windows says nothing about its capture helper while it works; a helper that is missing or does not answer is one warning-tone line, `Recording can’t start on this PC. Reinstall Recly.`, and what went wrong stays in the log (`shell.ready`) (2026-10-09, user decision — no helper version, path or self-test in Settings). The outside shortcut that is always in place, `Microphone` → `Open System Settings`, is a quiet (gray) button — it is nothing to draw attention to. The desktops show that row in Capture whether or not access is on, with the guidance as its subtitle while it is off (Windows: `Open Windows Settings`; Windows reads the microphone setting again at every start, 2026-10-15). **A control whose only effect is to open a web page outside the app is a text link, not a button** (2026-10-09, user decision): the accent color, the size of the text it sits in (12 in a secondary line), a dotted underline in the accent color, no border or fill, 60 % opacity while pressed, the link role for accessibility and invisible padding up to the minimum target (`TextLink` among each shell's Blueprint components). `Privacy Policy` is the link itself, the row's only content; ChatGPT's `Manage usage` is the second line of the account row. Buttons stay for actions inside the app, and dialogs keep their own link style. When permission is denied, the recording screen shows a separate accent-colored `Open Settings` (2026-09-29, phones). Their text uses the same `14sp` token as the surrounding rows, buttons and links, and they keep the minimum click target (Apple · Windows 44, Android 48) and the user's font scaling. - **Vocabulary** (2026-10-07, §5 "Fixed processing settings"): a `Vocabulary` row right after `Spoken language` in all four shells, edited as chips — a field (`Add a name or term`) where Enter/Return or `Add` adds a chip, a remove `×` on each chip (accessibility `Remove {term}`), one chip per line of a paste — the row reads its label, then the chips, then the field (2026-10-15, every shell). Past 50 terms or 40 characters an inline warning-tone line says `Up to 50 terms, 40 characters each.` and the refused entry is cleared from the field. The description under it says where the list goes: `Names and terms to spell correctly. They are sent with the audio to {provider}.` (external), `Names and terms to spell correctly, used on this device.` (local), or `{provider} does not use a vocabulary.` / `On-device transcription does not use a vocabulary.` — the list is kept. It is part of the processing draft, so `Cancel` · `Save` appear when it changes (principle 8). - **On-device speaker separation** (2026-10-07, §15 "Android · Windows local transcription models"): in local mode, iPhone and Mac show the value-only row `Speaker model` → `pyannote community-1` (secondary text, no box — the models are bundled) and `Speakers are separated on this device.`; Android and Windows show `Speaker model` → `pyannote 3.0 · ERes2Net` with the speech model's states — a fresh download fetches both and its size is the sum, and a device that already has the speech model gets the speaker row's own block in the speech row's pattern — `The model is 41 MB.` and `Download model` (`Resume download` · progress) — and `Speakers are separated on this device, sentence by sentence.` (Qwen3-ASR gives segment times only, so a change of speaker inside one sentence is not split). These replace `On-device transcription does not separate speakers.` wherever the engine separates speakers. A missing speaker model never blocks a transcription — the transcript arrives without speakers — and a download starts only from the user's tap. The head count asked after stop (`People in the room`) is the speaker-count hint. 5. **Notifications · dialogs**: **title + one-line description + at most 2 buttons.** The processing state is inline (the button changes to "Saving…"), and on completion a badge. A two-way question does not get a third option. The detail's dialogs of 2026-10-07 keep this shape: `Transcribe again?` with one line built from the current settings — `With AssemblyAI · Korean.` or `With on-device transcription · Korean.`, plus ` Your edits are replaced.` when the transcript was edited or has speaker names — and `Cancel` · `Transcribe`; `Discard your changes?` (`Keep editing` · `Discard`); `Speaker name` (`Cancel` · `Save`); `Rename` opens `Recording title` with the field `Title` (`Cancel` · `Save`), the same field label as the title sheet after Stop (2026-10-15, every shell). The title sheet after Stop has `Discard recording` · `Save` — the red `Discard recording` deletes the recording that just ended, as `Cancel` did (§3 "Titles"; relabelled 2026-10-08, user decision). Its title field's placeholder is `Filled in automatically` while an empty title will be filled in at the publication — transcription on, ChatGPT signed in, the notes' consent given, and on iPhone the destination allowed — and `Untitled` otherwise (2026-10-29, user decision; §8 "Titles and speaker names"). There is no checkbox: the two of 2026-10-15 are gone. On the iPhone the title sheet after Stop, every dialog presented on a sheet of its own and the detail's `Share` sheet stand on the bottom edge — the screen's full width, only the top corners rounded, as the full-height sheets (the detail, Ask) do — over a dim, rising in the standard 200ms (at once with reduce motion). A question is closed by its answers only: no swipe, no tap on the dim. `Share`, a list with no answers, also closes with a tap on the dim (2026-10-29, user decision: iOS 26 floats a shorter system sheet as a card inset from both sides and the bottom). 6. **macOS menu bar**: a popover (glass allowed) with the record node and the timer (screen principle 1) + the recent ledger (infinite scroll, 20 rows at a time) + actions. The Windows tray has the same structure (a Compose popup window). While recording, a live waveform of the track being recorded (peaks of 0.1-second windows, recording color) runs beside the timer, right of it to the popover's edge; while idle the same strip is the empty track, a flat dot in `grid` per slot, and a recording fills it from the right (2026-11-04) — the iPhone recording screen has the same band under the timer (Apple 2026-09-03). The Windows tray popup has the same strip beside the timer — the helper sends peaks of 0.1-second windows as `level {peaks[]}` events (2026-09-03). The Android recording screen has the same band under the timer — it reads `MediaRecorder.getMaxAmplitude()` every 0.1 seconds (2026-09-03). The Mac also has the App Intents `Start recording` · `Stop recording`. There is no global keyboard shortcut (removed 2026-10-09, user decision). 7. **Watch**: mono timer + square start/stop, **one-line status**, recordings still waiting to transfer — while the transfer pass is finding the phone and handing over files, the same recordings are spoken as `Sending` (2026-09-04; tiles and complications follow the same rule). Waiting and sending are two questions the user asks about the same recordings, and only the transfer pass knows which one applies. Apple Watch always says `Waiting to send` (complication `Waiting`) until the phone acks (2026-10-05): `WCSession` cannot tell a queued file from one that is moving — `isTransferring` is true until delivery and the system may throttle it — so "sending" would be a guess. The transfer state shares the status line rather than taking a second one, and is said **without a count** — what the user asks is whether any recordings are left, and the number does not change what they do (2026-10-05, both watches, tiles and complications included). The line says one thing, in this order: what a stop had to report (a failure, a deferred save), what the recorder is doing, transfers the phone refused (Wear), then recordings still on the watch. Idle with nothing left, it is blank and keeps its height, because the hollow button already says the watch is ready. A stop that went as asked is not announced. The Galaxy Watch tile draws the app's square record node (outlined; filled with the inner square while recording), not a Material pill button, and says nothing when idle with nothing to send (2026-10-08). **Both watches alike** (2026-10-15): the screen reads timer (`00:00` when idle) → status line → record node; the recorder's words are the phones' — `Starting`, `Saving` — on the screen, the Galaxy Watch's tile and complication (the Apple Watch complication keeps its idle and recording looks: a widget timeline is too slow for a state that lasts a second); a recording that failed says `The recording failed` without the platform's reason; a transfer the phone refused says `Transfer failed`, tiles and complications included; the node is disabled while starting and saving. Haptics: start, stop, and the success haptic when the phone acks a recording's meta — the transfer is complete only then, not when the recording is queued. A tap on a complication never stops a recording (a face is brushed by accident): the Apple Watch's starts one when idle and only opens the app while recording; the Galaxy Watch's only opens the app, because its one-tap starts are the tile and `Recly Quick Rec`. The Galaxy Watch's setup guide (double home key) has no Apple Watch counterpart — the Action button is assigned in the system's own settings. After a permanent microphone deny, the Galaxy Watch adds a warning-tone line saying where in Settings to allow it. 8. **Button placement** (2026-09-25, common to mobile and desktop; directions are by start/end, so they flip in RTL): - A button group inside a form or settings block is **end-aligned (right)**. The confirming action is at the very end, and `Cancel` is right before it. In the recording processing settings, `Cancel` · `Save` appear **only when something has changed** — two disabled buttons do not take up room all the time, and their appearing is itself the signal that there is something to save. - Actions attached to one item go at the end of that item's row (Drive disconnect, deleting another provider's key). If the item spans several lines, they are end-aligned below it. Buttons right below an input field, value or notice (`Save key`, `Replace key` · `Delete`, `Download model`, the confirmation in the permission revocation notice) are also end-aligned below it. Groups of a different nature are separated by a heading (recording processing's `Settings file`: export · import). - A single recovery button below a centered notice (empty list, transcript load failure, microphone denied) is centered. - An empty list (2026-09-26) shows, in the vertical center of the space the list would take, one line in the body color (`No recordings yet`) and below it one dimmed line (`Recordings you make appear here.`), and a button is placed one step (24) away. Phones have `Record` in the tab bar right below, so they show no button; only desktop windows (Mac detail, Windows recording window) show `Start recording`. The short ledger in popovers and the tray shows only one line. - In the command row of the menu bar popover and the tray, the primary actions (start/stop · settings) are at the start, and secondary actions and quit at the end. - When dialog buttons do not fit on one line, they stack at full width and keep their order (primary action last). - A menu item that cannot run now is not hidden: it stays, disabled, with its reason as the item's second line (More, Share; 2026-10-07). ### AI view Since 2026-10-30 the UI calls the notes `Summary` (Korean 요약) and the memo `Notes` (Korean 메모), after Notion's naming; this document and the code keep "notes" and "memo". 2026-10-09 (§8 "AI requests"); 2026-10-28 (Summarize folded into the notes, §8 "Organizing"). Nothing about it is offered unprompted (trend 1): there is no banner, and no button outside the More menu and Settings. - **Settings → ChatGPT**, after Recording processing, laid out like the Drive rows. Signed out: `Use your ChatGPT plan` / `Summarize recordings with the plan you already have. Recly charges nothing for it.` with `Continue with ChatGPT` (OpenAI's wording for the action, checked 2026-10-09). While the browser is open the button shows its processing state and a line `Finish signing in in your browser` has a quiet `Cancel`; when the system could not open a browser it says `Could not sign in to ChatGPT` with the detail `no browser opened` (2026-10-15, every shell). Signed in: the account's email / `Using your ChatGPT plan` with a quiet `Sign out` (no confirmation — signing in again restores it; when OpenAI did not confirm the revocation, a muted footnote under the account row says so, on every shell — 2026-10-11) and, under that line, the text link `Manage usage` (ChatGPT's usage settings, principle 4), then a `Model` picker of the plan's models (hidden when the list could not be read). Ended by OpenAI: the email / `OpenAI ended this sign-in. Continue with ChatGPT to sign in again.` in the warning tone, with `Continue with ChatGPT`. The footnote ends the section: what goes — the transcript text and the notes settings, never the audio — for notes and questions, and that they count toward the plan. The first sign-in on a device shows once `You’re using your ChatGPT plan` / `Summaries in Recly use your ChatGPT plan. You can manage usage in ChatGPT settings.` with `Manage usage` and `Got it`. The section holds the provider alone — its sign-in and its model — so that another AI provider can take its place later (2026-10-28, the user's direction); what the notes are asked for belongs to no provider and sits under `Summary`. - **Settings → Summary** (2026-10-28), right after ChatGPT, signed in or not: `Summary instructions`, a field of two lines growing to eight (placeholder `e.g. Group to-dos by owner. Keep technical terms in English.`; under it `Every recording’s summary follows this.`); `Sections`, a label over every section the notes are made of (2026-10-29) — the built-in groups the user kept, then theirs — each the same row: a one-line `Section name` field, a `What goes in it` field of up to three lines and a quiet `Remove` (its accessibility label `Remove {name}`). A built-in group's fields hold its own words while the user has not changed them — its heading and what goes in it, in the app's language — and those words are their placeholders; a section of the user's own has the placeholders `e.g. Risks` and `e.g. Anything that could delay the launch, and who raised it.`. Under the rows a quiet `Add section` while the user has fewer than three of their own, which adds an empty section and focuses its name; then a quiet `Restore defaults` while a built-in group is removed, renamed or described anew, which brings the four back in their own words and leaves the user's sections as they are, saved at once; under them `The summary starts with an overview, then these sections. Each item shows when it was said.`. Then `About you`, a field of up to three lines (`Summaries and answers use this to judge what matters to you.`). The fields save when editing ends (`notes.setPreferences`, and About you as before); a built-in group's field left empty, or holding its own words, saves as its own and shows them again; a section of the user's own left without a name is not kept and goes from the list then; `Remove` saves at once. Return adds a line in the instructions and in what goes in a section, and ends a section's name and About you. A change applies to what is organized from then on; notes already made keep their sections until `Summarize again`. - Both sections and the More items are absent where the core says `Unavailable` (§15 "China mainland App Store"). - **The detail.** More → `Summarize` — `Summarize again` once the recording has notes — then `Edit summary`, disabled with their reason like the other items: `No transcript yet`, `Transcribing…`, `Still recording`, `Sign in to ChatGPT in Settings`, `Summarizing…` (while `organizing`). `Summarize` asks no question — what the user edited or deleted stays — and selects the `Summary` chip; on iPhone the ChatGPT permission is asked first (`TRANSFER_CONSENT_REQUIRED`), then it goes on. After `Edit summary`, `Fill in title` and `Fill in speaker names` (2026-10-15, §8 "Titles and speaker names"; two items, the title and the names apart — `fillTitle`, `fillNames`) — each disabled with the first reason that applies, in this order: `Still recording`; `No transcript yet` (a transcript with nothing said included) or the reason of an unsettled job (`Transcribing…`); `Sign in to ChatGPT in Settings`; `Filling in…` while a fill of that half runs (`observeFills()` — a publication's too); then `Already has a title` (`Fill in title`, `FillNeeds.title` is false) or `No unnamed speakers` (`Fill in speaker names`, `FillNeeds.names` is false). They open no view: the title shows in the header and the names in the transcript. While a fill of the recording runs, from either item or at its publication, the line under the header says so with the square loader: `Filling in the title…`, `Filling in speaker names…`, or both lines, the title's first, when one request asks for both; it goes when the run ends, and steps aside while the transcript or the notes are edited, as the other lines there do. An item asked when there turned out to be nothing to fill says its own reason (`Already has a title` or `No unnamed speakers`) there for a few seconds. `Fill in speaker names` that ChatGPT answered without naming anyone — the transcript does not say who is who — says `No speaker names found in the transcript` the same muted way (2026-10-31: an answer, not a failure); `Fill in title` answered without a title says the failure sentence alone. A failure says `Could not fill in the title` or `Could not fill in speaker names` with the core's sentence under the header for a few seconds, as refused starts do (principle 2, "Detail header and More menu"); on iPhone the ChatGPT permission is asked first, as for `Summarize`. - **The chips.** Chips under the header — at their own width, on the page under the header's line (2026-10-31: Android's filled band gave way to the iPhone's row), compact (2026-11-04, "Component measurements") — switch the body: `Transcript`, `Summary` once the recording has notes or `Summarize` was asked, and `Notes`, always there (2026-11-06, "Memo" below). The detail opens on `Transcript`; no chips while the transcript is edited, and while notes or the memo are edited they step aside only for the keyboard. The `Summary` body is §9 "Notes view": while the recording is organized here, the square loader and `Summarizing…` over the notes there are; a failure of `Summarize` (`RecordingNotes.failure`): a centred notice — the `CoreMessage` sentence, or `Could not summarize` with the detail — and one centred button: `Manage usage` (primary) for the usage limit, `Retry` (quiet) for the rest, none when the sentence already says to sign in or that the plan cannot be used; the notes there are stay under it. Under the notes, `ChatGPT · ` (secondary, 12) and the copy icon (2026-10-28; until then a quiet worded button) — `Copy all` to a screen reader, a check mark and `Copied` for a moment after it — which copies `notes.text` — the overview and every group with its heading, owners, due dates and `[HH:MM:SS]`. - **Editing** (2026-10-28). More → `Edit summary`, right after `Summarize again`, only while the recording has notes (disabled `Summarizing…` while it is organized, `Still recording` while it records; absent while the transcript is being edited). It turns the `Summary` body into fields the way transcript editing does: the header `Edit summary`; the overview as one multi-line plain-text field when there is one; then each group's header over its items, each item one multi-line field holding its words, with its owner and due date muted under it, not editable, and no time button. `Cancel` · `Save` at the end (`Save` primary, `Done` alone while nothing changed; on phones under the fields, above the keyboard — and while the keyboard is up the `Transcript` / `Summary` chips step aside), `Saving…` → back to the notes (`notes.edit`). Two secondary lines under the fields: `An item you empty is deleted when you save.`, then where it goes — `Saving updates the summary in your storage, and your other devices show it.`, or `Saving keeps the summary on this device.` for a local folder or a recording not uploaded yet. Leaving with changes — Back, closing, the `Transcript` chip — asks `Discard your changes?` / `Your edits to this summary will be lost.` (`Keep editing` · `Discard`). Nothing marks an edited item afterwards. - **Memo** (2026-10-30, §8 "Memo"). The `Notes` chip is always there and More has no item for it (2026-11-06, user decision: a memo can be typed or changed and the summary made again, so an empty one is not hidden behind More). On a recording with no memo it opens with its field focused, there being nothing to read; with one it shows the memo without the cursor in it, so a phone's keyboard does not come up over what is there to read. The `Notes` body is that field alone — one multi-line plain-text field filling the body, the placeholder `Agenda, who's there, what to keep track of` — saved as it is typed, 0.8 s after the last keystroke and when it is left (closing, another chip, another recording), so there is no `Save`, no `Cancel` and nothing that says it was saved; the field takes no more than the 2000 characters kept. Under it, secondary, `Read along when the summary is made.` — only while signed in to ChatGPT with organizing agreed to, the only time it is true. Nothing in the notes marks what came from the memo. - **While it records** (2026-10-30). The recording screen of both phones, the Mac popover and the Windows tray show a `Notes` row under the recording's own lines while it records, there even when the memo is empty, its first line that says anything muted at its end. On the phones it opens the same field, focused, and its footnote under the title `Notes` — a bottom sheet on iPhone, as its other sheets there, a dialog on Android — with a primary `Done` that only closes it, the field having saved as it went; the popover and the tray, too small for a dialog over them, open the field under the row, focused, and a click on the row folds it again, with no `Done`: the field saves as it goes, and Stop or closing the popover keeps what was typed (2026-11-05, user decision). While the field is open, the row is only its name, without the box or the memo's first line. A click anywhere else in the popover or the tray, or the popover closing, takes the field's focus, so its box is not left blue (2026-11-05, user); the tray's popup is drawn anew when it opens. The watches have none: a memo is typed, and a watch's recording gets one on the phone or computer once it is there (the `Notes` chip, §8 "Memo"). - **Ask** (2026-10-10; 2026-11-04, user decision: one composer for questions and changes, §8 "Ask"). At the foot of the detail, on every shell — under the transcript or the summary on the desktops, over the player on the phones — a hairline, then on the surface, 16 at the sides and 8 above and below: what the last request came to, then the composer. It is there wherever ChatGPT is offered and the recording has a transcript that says something — not while the transcript or the summary is being edited, and not on the `Notes` chip. The phones put the player away while the composer has the cursor, the keyboard leaving no room for both. - **The composer.** One field, a line that grows to six and then scrolls, the placeholder `Ask, or say what to change`, no more than `AskRequest.MAX` characters, and a primary `Send` at its end, enabled while the field says something. On the desktops Return sends and Shift+Return adds a line; on the phones the keyboard's Send sends, and a paste keeps its lines (another transcript is pasted whole). While the field has the cursor and is empty and nothing it came to is over it, the preset chips are over it, drawn as time buttons are ("Component measurements", 2026-11-04) (`Action items`, `Open questions`, `Translate to {language}`, `Feedback on how I spoke`, as `askPresets` allows); a tap asks. Sending empties the field. The chips go while an answer is up, so the bar stays low (2026-11-04); its ✕ brings them back. While a request runs the field stays on, so the next one can be typed, and only `Send` (and Return) waits for it (2026-11-04, user decision); the chips stay away until it is done. While nothing can be asked the field is off and says why in its placeholder: `Still recording`, `No transcript yet`, the reason of an unsettled job (`Transcribing…`), `Sign in to ChatGPT in Settings`. On iPhone the ChatGPT permission is asked first, as for `Summarize`. - **What it came to**, over the composer with a ✕ at its end that closes it (none while it runs): `Working…` with the square loader; the answer as selectable plain text with its line breaks, scrolling in its place past a third of the page's height, and never past 240 on the phones and 320 on the desktops (2026-11-04: a short window keeps most of its transcript), then `ChatGPT · ` (secondary, 12) and the notes' copy icon; what it changed — `Changed: Transcript 2 · Summary 1` (secondary, only the documents it changed; a transcript's count is its lines and names, a summary's its items, those removed and the overview) with a quiet `Undo` at its end (`Undoing…` while it runs; refused, `Could not undo` under the header for a few seconds, as refused starts say); `Nothing changed` (secondary) when a change was asked and nothing was saved; or the failure notice the notes use (`Could not answer`, `Retry`). Each `[HH:MM:SS]` in an answer (`SummaryCitations`) plays the recording from that time (2026-10-10: seek and play): mono, accent, no underline — a dotted underline means a web page — with a target of at least the minimum each way, laid over the selectable text; where two targets overlap, the citation whose glyphs are nearest the tap wins; each has its own accessibility element `Play from 00:12:34`; past the recording's end, or with no audio, it is muted and inert; `Copy all` copies the brackets too. - **The changes on the page.** The transcript's tinted (§9 "Transcript reader" — "After an answer's changes"), and the summary's: an item changed or added, and an overview rewritten, have the same tint behind their words. When only one of the two changed, the body turns to it. The tints go with the answer: when it is closed or undone, when the transcript is edited or a version restored, or when another recording is opened — which also empties the field and drops the answer (2026-10-15). Edit summary, Version history, `Transcribe again` and `Summarize again` stay in More: the composer is the quick way, not the only one. - **Elsewhere.** Share lists `Summary` (`.txt`, `notes.text`) after `Transcript for note apps`, disabled with `No summary yet`; a search hit shows a matching line of the notes as `Summary · …` and opens on the `Summary` chip when nothing else matched, and a matching line of the memo as `Notes · …`, opening on the `Notes` chip when only the memo matched; an unnamed `me` speaker is `Me` (§8 "Me and others"). ### Notes view 2026-10-27 (§8 "Organizing", user decisions); 2026-10-28 (key points, the user's sections, editing). Nothing to switch on, and nothing says provisional or edited: the notes simply change when a pass lands. - **The detail** (Android phone, iPhone, Mac, Windows). The `Summary` chip's body (§9 "AI view"). The overview comes first (plain text), then the groups in the order `Decisions` · `To-dos` · `Key points` · `Questions` (2026-11-06, user decision: the Summary is a document to read, so what was settled and what is left to do come first, and its items are not sorted by time) — under the name the user gave a group, if they did — then the user's sections under their names, each a section header over its rows, on the page: no table fill and no lines between rows, the section headers part the groups (2026-10-31, the iPhone's on both phones). A group with no items is absent, header included; with no overview and no items there is no block and no empty state. The `Summarizing…` line or the failure notice stays on top. - **A row** is one sentence, as tall as its words, 16 at the sides and 8 above and below (2026-10-31: at the touch minimum a list of one-line items read double-spaced): ` — ·