Files
Termind/openwiki/workflows/settings-and-cloud-sync.md
2026-10-19 12:41:32 +08:00

83 KiB
Raw Permalink Blame History

type, title, description, tags, sources, generated, verified
type title description tags sources generated verified
synchronization workflow Settings and Cloud Sync Settings mutation and persistence, device-local desktop language selection, and app-wide ownership of CloudKit synchronization, journals, recovery, and secret boundaries.
cloudkit
synchronization
settings
vault
metrics
history
devices
secrets
swiftdata
id resource
openwiki-source-a51ca858a040a90d4ab9c1c4 repo://Frameworks/TermindApplication/AppLanguage.swift
id resource
openwiki-source-162732a78d991992cfb9458d repo://Frameworks/TermindApplication/FeatureModels/DiagnosisController.swift
id resource
openwiki-source-4143deeb06ebc3198de62e2b repo://Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift
id resource
openwiki-source-cd830b9886c3f5d3d7181662 repo://Frameworks/TermindApplication/FeatureModels/NotificationSettingsModel.swift
id resource
openwiki-source-d7d857d696a915d0708e13b4 repo://Frameworks/TermindApplication/FeatureModels/SFTPSettingsModel.swift
id resource
openwiki-source-ff7e1a7fb4c2e41f0599b10c repo://Frameworks/TermindApplication/FeatureModels/TerminalCustomThemeDraft.swift
id resource
openwiki-source-4ae2a0061a7bd729d6e132b4 repo://Frameworks/TermindApplication/FeatureModels/TerminalSettingsModel.swift
id resource
openwiki-source-9fbc733f79d51a7265ee9e4a repo://Frameworks/TermindApplication/SettingsKey.swift
id resource
openwiki-source-44f3b562f3c7b739d591cf92 repo://Frameworks/TermindDomain/Sessions/SessionPersistence.swift
id resource
openwiki-source-1ca741e9f21312b87c75fa28 repo://Frameworks/TermindDomain/Settings/NotificationPreferences.swift
id resource
openwiki-source-36d16adefef02f0ed0cdc428 repo://Frameworks/TermindDomain/Settings/SettingsSyncTypes.swift
id resource
openwiki-source-46ef2fcd9e3aba751015021b repo://Frameworks/TermindDomain/Transfers/SFTPPreferences.swift
id resource
openwiki-source-1429ec01ac68a7770ce41977 repo://Frameworks/TermindDomain/Vault/CloudSyncSafety.swift
id resource
openwiki-source-e08ad235d9d92a20cf09290b repo://Frameworks/TermindDomain/Vault/SyncRecordFormats.swift
id resource
openwiki-source-46ff6ca103fc5aeb8793c80d repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncEventHandling.swift
id resource
openwiki-source-bbc2a6a0c20a2b31cad05f53 repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncJournal.swift
id resource
openwiki-source-1d1f503a260f304a969fcee4 repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycle.swift
id resource
openwiki-source-8a511ce5c3a76863609a6e6e repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncLifecycleSafety.swift
id resource
openwiki-source-178dc6c157419c602103a0ba repo://Frameworks/TermindInfrastructure/Cloud/CloudSyncReplicaStore.swift
id resource
openwiki-source-e9c12faee0c7be74b1cc02af repo://Frameworks/TermindInfrastructure/Cloud/ConnectionHistorySyncCoordinator.swift
id resource
openwiki-source-e0d1a5c458c249d9d88ff665 repo://Frameworks/TermindInfrastructure/Cloud/DeviceSyncCoordinator.swift
id resource
openwiki-source-894539337bc9140a109608f0 repo://Frameworks/TermindInfrastructure/Cloud/HistoryCloudRecordV1.swift
id resource
openwiki-source-0f2686dea17e03f18f688944 repo://Frameworks/TermindInfrastructure/Cloud/HistoryCloudRecordV2.swift
id resource
openwiki-source-31cbaa9f7ceabda76ea07ba4 repo://Frameworks/TermindInfrastructure/Cloud/HistoryRecordSyncCoordinator.swift
id resource
openwiki-source-3effdff7f6b04e2a66d8f651 repo://Frameworks/TermindInfrastructure/Cloud/MetricsSyncCoordinator.swift
id resource
openwiki-source-04ef2b2f02773f8833db9a8b repo://Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift
id resource
openwiki-source-2d71ca5687a3b29cda0a7006 repo://Frameworks/TermindInfrastructure/Cloud/TerminalThemeCloudRecordV1.swift
id resource
openwiki-source-eaa9d39ab551840299164d96 repo://Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift
id resource
openwiki-source-969607b796e062c1bad0a301 repo://Frameworks/TermindPersistence/CloudSyncRecordStateStore.swift
id resource
openwiki-source-1366e71322413c98e0ac1534 repo://Frameworks/TermindPersistence/CloudSyncSafetyStore.swift
id resource
openwiki-source-6bdc502f2e27478a98f042fa repo://Frameworks/TermindPersistence/HistorySyncStore.swift
id resource
openwiki-source-fe1dc993163bb9ff61397be7 repo://Frameworks/TermindPersistence/HostPersistence.swift
id resource
openwiki-source-f1caa8a8fe50f79a0e6087b9 repo://Frameworks/TermindPersistence/PersistentSFTPPreferences.swift
id resource
openwiki-source-6df73854db20ee83905a634c repo://Frameworks/TermindPersistence/SettingsSyncJournalStore.swift
id resource
openwiki-source-bcf5761569fe8b5098014524 repo://Frameworks/TermindPersistence/SettingsSyncStore.swift
id resource
openwiki-source-5bfb44617fb594292c716f44 repo://Frameworks/TermindPersistence/SwiftDataAIConfigurationStore.swift
id resource
openwiki-source-9eb2234b02897ade8e405374 repo://Frameworks/TermindPersistence/SwiftDataAISecretStore.swift
id resource
openwiki-source-bc351b6c699ff4bbf9f8c2b1 repo://Frameworks/TermindPersistence/SwiftDataVaultJournal.swift
id resource
openwiki-source-a0d614d618b11f103ec246ae repo://Frameworks/TermindPersistence/SwiftDataVaultStore.swift
id resource
openwiki-source-2f7267f869168e6fd20257dd repo://Frameworks/TermindPersistence/TerminalThemePersistence.swift
id resource
openwiki-source-79e0f2a939e874c6a6803c19 repo://Frameworks/TermindPersistence/TermindSchemaV28HostModels.swift
id resource
openwiki-source-8fda0c115d3df709fffe110c repo://Frameworks/TermindPersistence/TermindSchemaV29HostModels.swift
id resource
openwiki-source-15e5026028d13d2202844f68 repo://Frameworks/TermindPersistence/TermindSchemaV33IdentityModels.swift
id resource
openwiki-source-33f99816db979bf169f1eab5 repo://Frameworks/TermindPersistence/TermindSchemaV45KeyModels.swift
id resource
openwiki-source-3cfc52217a7a6835b4bffac1 repo://Frameworks/TermindPersistence/VaultSyncPersistence.swift
id resource
openwiki-source-ba9bcaaf5c4be2ca83356c1a repo://Frameworks/TermindPersistence/VaultWireV1Validation.swift
id resource
openwiki-source-65af64561fcb561a30da16fa repo://Frameworks/TermindPersistence/VaultWireV2.swift
id resource
openwiki-source-431c220c481c9034643e0f11 repo://Frameworks/TermindPersistence/VaultWireV4.swift
id resource
openwiki-source-e6dff91a0024d952d88a2b56 repo://Frameworks/TermindPersistence/VaultWireV5.swift
id resource
openwiki-source-eebe965a9f1a844dba69cccf repo://Frameworks/TermindPersistence/VaultWireV6.swift
id resource
openwiki-source-828d14fc26332f2c1760de77 repo://Frameworks/TermindPersistence/VaultWireV7.swift
id resource
openwiki-source-df95b4a93b36784556d7ac8c repo://Frameworks/TermindPersistence/VaultWireV8.swift
id resource
openwiki-source-0bb956065190c6d2f1f898a9 repo://Frameworks/TermindPersistence/VaultWorkspaceWireV2.swift
id resource
openwiki-source-f7f7803d6920f3e68b98385b repo://Frameworks/TermindPresentation/CloudSyncSettingsContent.swift
id resource
openwiki-source-6c30416f939593ee31f56380 repo://Frameworks/TermindPresentation/HostIconCatalog.swift
id resource
openwiki-source-e77eccb0637ba395d80ada96 repo://Frameworks/TermindPresentation/HostIntegrationSection.swift
id resource
openwiki-source-004087118989bdae6d2071e6 repo://Frameworks/TermindPresentation/InitialNotificationAuthorization.swift
id resource
openwiki-source-6256aeada274ebb6ce422d82 repo://Frameworks/TermindRemoteInfrastructure/Security/TerminalRecoveryStore.swift
id resource
openwiki-source-b8eae581ee8d4d73aa45f827 repo://FrameworkTests/CloudSyncCoordinatorLifecycleTests.swift
id resource
openwiki-source-bf7a64e7c819eb9279e2f9e4 repo://FrameworkTests/CloudSyncLifecycleTests.swift
id resource
openwiki-source-d785833dd230253fdc4946d7 repo://FrameworkTests/CloudSyncPhysicalDeletionTests.swift
id resource
openwiki-source-40cfadc2320da9a292c64d26 repo://FrameworkTests/CloudSyncSafetyPersistenceTests.swift
id resource
openwiki-source-eb9518e5c7eb3782fa0cccc0 repo://FrameworkTests/CloudSyncSafetyTests.swift
id resource
openwiki-source-d78ad50c7d68f56cf454a54d repo://FrameworkTests/DiagnosisTests.swift
id resource
openwiki-source-b7edf1d892ee300ba46cecd4 repo://FrameworkTests/HistorySyncTests.swift
id resource
openwiki-source-b41ffbd407a598b50512e10b repo://FrameworkTests/HostIconTests.swift
id resource
openwiki-source-1a0ee9f0072e44cc1e605224 repo://FrameworkTests/MCPServerMetadataTests.swift
id resource
openwiki-source-e722f35e92f0823055985cb7 repo://FrameworkTests/NotificationPreferencesTests.swift
id resource
openwiki-source-8428b9f5de49b8a561d93fa5 repo://FrameworkTests/PersistenceCompatibilityTests.swift
id resource
openwiki-source-01fd62d857f198c9f92cb8a5 repo://FrameworkTests/RetiredTerminalHighlightingTests.swift
id resource
openwiki-source-ffc9054d578eba7be684b885 repo://FrameworkTests/SessionPersistenceTests.swift
id resource
openwiki-source-a6f48b126bfcd6573fe06736 repo://FrameworkTests/SFTPSettingsTests.swift
id resource
openwiki-source-3b1e8e4540cb99cbbd7863f5 repo://FrameworkTests/SnippetColorTests.swift
id resource
openwiki-source-7a96834e0ec247f0a7610fde repo://FrameworkTests/TerminalCustomThemeTests.swift
id resource
openwiki-source-eaed8c88a9a3f24e3feb1f9c repo://FrameworkTests/WebAuthnVaultMetadataTests.swift
id resource
openwiki-source-906678adb91c2fce2d3fcaed repo://TFI/App/MobileAppModel.swift
id resource
openwiki-source-215c24353f612bb80e412229 repo://TFI/Features/Settings/MobileAppSettingsView.swift
id resource
openwiki-source-e21fae39ef7af76f86acbff6 repo://TFI/Features/Settings/MobileSettingsView.swift
id resource
openwiki-source-ccfded56a6676310f11a2981 repo://TFI/Features/Settings/MobileSyncSettingsView.swift
id resource
openwiki-source-4dbea0ff2808163cb4ecdd8f repo://TFM/App/AppModel.swift
id resource
openwiki-source-37c42ec0ebc182c487dd27aa repo://TFM/App/DesktopSettingsKey.swift
id resource
openwiki-source-873894299539cce9a737a860 repo://TFM/App/RootView.swift
id resource
openwiki-source-cfe2a6b22338023c37a5b503 repo://TFM/Features/Settings/SettingsPageView.swift
id resource
openwiki-source-0a0c39623be1c4e870d0f538 repo://TFM/Features/Settings/SyncSettings.swift
id resource
openwiki-source-44cb7a6172854edbfc9b5ccb repo://TFMTests/SettingsNavigationTests.swift
by at
openwiki/0.7.2 2026-10-19T03:39:12.674Z
by at
openwiki/0.7.2 2026-10-19T03:39:12.674Z

Settings and Cloud Sync

Termind replicates a SwiftData-backed journal into the private iCloud.com.akinokaede.termind CloudKit container; this is not SwiftData CloudKit mirroring (TermindPersistence.makeContainer uses cloudKitDatabase: .none). Five writable owners—Vault, Settings, Metrics, Connection History, and Device—each own an engine, subscription, binding, queue, and lifecycle. History and Device retain the same deployed zone, TermindConnectionHistoryV1, but use separate subscriptions, kind-filtered journals and state rows, and separate safety scopes (Device's scope has consumer device; History keeps the original empty consumer). Neither should consume, reset or acknowledge the other's records. HistoryRecordSyncCoordinator shares the record-handling implementation, not their engine or account state. The former legacy importer is retired: Vault ignores obsolete History/Device copies in its own zone; it neither imports nor currently schedules physical cleanup of those copies. CloudSyncJournal, HistoryRecordSyncCoordinator, VaultSyncCoordinator.

For embedded node enrollment, stable peer IDs, group preferences and device-local profile retirement, see Tailscale groups, embedded nodes, and SSH.

Settings routes and ownership

On macOS, WindowModel owns a per-window settingsRoute separate from app-wide preference models. SettingsPageView dispatches index → category → detail: App (including workspace preferences and diagnosis consent), Notifications, Terminal, Connections (SSH/SFTP), AI (providers, models, MCP servers, skills), and Sync. The index opens Pro as a paywall rather than a Settings category; a debug-enabled Logging route hangs directly off the index, and Developer is compiled only in DEBUG builds. SettingsRoute.parent drives toolbar back navigation: terminal custom-theme editor → custom themes → Terminal, provider models → provider → AI, and other detail pages → their category; back from index closes Settings and restores the previous destination, falling back to Vault if its workspace vanished. SettingsPageView, WindowModel, SettingsNavigationTests, SettingsNavigationTests.

Leaving a provider draft discards it only if unnamed and unconfigured; a new MCP draft with no valid name/namespace is deleted, whereas a skill with written content but no title is kept as a draft. Navigating between provider and its models does not prematurely clean it up. These route transitions act on shared app models rather than starting CloudKit directly. SettingsPageView, SettingsPageView, SettingsPageView.

SettingsKey contains local UserDefaults keys for both UI/selection and mirrors of synced preferences; being a key in this enum does not imply it is a cloud record. SettingsSyncSnapshot.recordPayloads determines what Settings actually journals: AI providers, MCP servers, roles, skills, web search, terminal, per-theme custom themes and per-appearance theme selections, SSH, SFTP, workspace and notification preferences. Retired terminal highlighting no longer participates in that snapshot or active sync. In contrast diagnostic consent (diagnostics.crashReportingConsent, retained to avoid resetting earlier choices), debug visibility/logging, review-invitation history and terminal font family/size/follow-theme are device-local. Consent is an optional per-installation choice, not a synced notification preference or a cloud toggle: absence requires a choice and never enables reporting; DiagnosisController persists an explicit choice before applying it to the reporter. Terminal theme and integration preferences do enter the Settings snapshot even though they are mirrored to defaults. SettingsKey, SettingsSyncStore, DiagnosisController, DiagnosisTests.

The desktop Show Status Bar toggle is a separate app-wide, device-local DesktopSettingsKey.showStatusBar default, enabled initially and shared by Settings and the View menu. It does not add a Settings sync record or per-window durable preference. The floating root overlay appears only for a selected workspace when enabled; it does not dock below or resize the detail area. Expanded/collapsed state is a separate app-wide local preference. RootView, DesktopSettingsKey.

On mobile, Settings navigation passes the app-wide models to App, Notifications, Terminal, Connections, AI and Sync. App appearance (mobile.appearance) and compact cards (mobile.compactCards) are local @AppStorage choices, as are debug visibility, review state and diagnosis consent. Workspace automatic renaming instead uses WorkspaceSettingsModel and displays storage failures in red; Sync delegates selection changes to MobileAppModel. MobileSettingsView, MobileAppSettingsView.

Local mutation, persistence errors, and composition

Settings views bind to app-wide preference models, not CloudKit records. TerminalSettingsModel, SSHSettingsModel and WorkspaceSettingsModel build a candidate value and persist it before replacing the live preferences. A failed save returns false, retains the old live value and sets storageError; a successful or remote application clears that error. SSH candidates and remote snapshots are normalized, and changed SSH preferences notify the connection coordinator through didChange. Workspace automatic-renaming Settings displays its model's storage error in orange. These are local persistence failures, distinct from a sync owner's account, fetch or upload status. TerminalSettingsModel, SSHSettingsModel, WorkspaceSettingsModel, SettingsPageView, AppModel.

Desktop TermindApp prepares SettingsSyncPersistence before constructing the live models. AI configuration and skills use synced adapters; terminal, notifications, SSH, SFTP and workspace models share the same settings persistence instance. Coordinators are injected through AppDependencies and omitted during XCTest, while settings persistence still exists. Store-startup failure is separate again: an in-use database presents the startup guard and exits, while other startup errors fail initialization. App composition does not put secret bytes into the settings projection; the Keychain and sync secret policy below remain separate boundaries. TermindApp, TermindApp.

flowchart TD
    Edit["Settings edit"] --> Candidate["Build candidate preferences"]
    Candidate --> Save["Save projection and journal intent"]
    Save --> Result{"Commit succeeds?"}
    Result -->|No| Error["Rollback and retain live preferences with storageError"]
    Result -->|Yes| Live["Update app-wide live preferences"]
    Result -->|Yes| Journal["Notify sync owner of durable changes"]
    Journal --> Gate["Cloud lifecycle fetch and upload gates"]
    Remote["Accepted remote settings"] --> Merge["Transactional journal merge and projection rebuild"]
    Merge --> Snapshot["Publish committed snapshot to AppModel"]
    Snapshot --> Live

Local edits persist before updating live models; remote snapshots reach them after commit without replaying a local edit. The cloud lifecycle gate is independent of local save success. SettingsSyncPersistence, ModelContext.commit, AppModel.

SFTP concurrency is a synced preference, not queue state

SFTPPreferences stores the global/per-host limit pair and strategy (smart, channels_only, connections_preferred). Defaults are 30 global, 10 per host and Smart. Local normalization clamps global concurrency to 1–100 and per-host concurrency to 1–50 without exceeding the global limit; reducing the global limit can therefore commit both numbers together. JSON decoding deliberately retains invalid numeric values for envelope validation rather than silently normalizing remote input. Missing strategy defaults to Smart for older payloads, but an unknown strategy string fails decoding. SFTPPreferences, SettingsEnvelope validation.

SFTPSettingsModel saves before publishing limits or strategy. Failure preserves the live preferences and emits no runtime callback; a changed remote snapshot clears the error and notifies once without saving again. Both app models wire didChange into connection/transfer preference reconciliation. The local singleton stores normalized JSON and mirrors all three values into defaults; the snapshot emits the .sftp record, not transfer jobs. SFTPSettingsTests pins paired-limit commits, failed-save isolation, remote deduplication and legacy/unknown strategy decoding. Model, Local storage, Desktop callback, Mobile callback, Tests.

Device-local desktop language selection

The App language picker stores a device-local preference, not a Settings journal envelope. Its binding writes SettingsKey.appLanguage and calls AppLanguage.store. Explicit German, English, French, Japanese, Korean, Simplified Chinese and Traditional Chinese choices store canonical de, en, fr, ja, ko, zh-Hans and zh-Hant values plus a single-element AppleLanguages override. Follow System stores system and removes AppleLanguages, restoring normal Bundle negotiation rather than writing a guessed system language. Missing or unknown preferences resolve to Follow System. Legacy identifiers that Foundation resolves as Simplified Chinese migrate through store to canonical zh-Hans; unknown and noncanonical Traditional Chinese identifiers are not rewritten. AppLanguage, SettingsPageView, SettingsSyncSnapshot, AppLanguageTests.

TermindApp captures launchLanguage during initialization (a DEBUG screenshot demo can override it) and injects its locale into main and external-authentication windows. Selecting a new language does not replace that launch-time choice or automatically relaunch the app. AppSettings separately captures AppLanguage.stored() in its own initial state, also named launchLanguage; when its selected value differs, it shows “The new language will be used after Termind relaunches.” The notice resolves through AppLocalization with the selected language's locale, not the existing window locale. This local view baseline is not the app's immutable launch capture. TermindApp, TermindApp, SettingsPageView.

flowchart TD
    Launch["App initialization"] --> Read["AppLanguage.stored"]
    Read --> Capture["Capture launchLanguage"]
    Capture --> Windows["Inject launch locale into windows"]
    Picker["Desktop language picker"] --> Store["AppLanguage.store"]
    Store --> Defaults["Write canonical appLanguage"]
    Store --> Choice{"Follow System?"}
    Choice -->|Yes| Remove["Remove AppleLanguages"]
    Choice -->|No| Override["Write single canonical AppleLanguages tag"]
    Picker --> Notice["Show selected-language notice when view baseline differs"]
    Defaults --> Next["Next app launch reads stored choice"]
    Next --> Read

Language storage affects the next launch; the current picker only stores the choice and presents a notice, with no automatic relaunch step.

AppLocalization chooses the de, en, fr, ja, ko, zh-Hans or zh-Hant localization bundle from the supplied locale, falling back to the main bundle if unavailable. Chinese script selection keeps Traditional and Simplified catalogs separate. The Traditional Chinese picker label is 正體中文 only when the device region is TW and DeviceCensorship.canDisplayTaiwanFlag is true; otherwise it is 繁體中文. This label policy does not change the zh-Hant stored tag. AppLanguage, AppLanguageTests. For wider localization/product boundaries, see Commerce, Localization, and Product Surfaces.

Selection and app ownership

Owner Zone and records Selection
Vault TermindVaultV1 / VaultItem configuration Master iCloud switch
Connection History TermindConnectionHistoryV1 / ConnectionHistoryItem Master and optional History
Device TermindConnectionHistoryV1 / DeviceItem Master, independently of History and Vault
Metrics Its own Metrics zone and journal Master and optional Metrics
Settings TermindAIConfigurationV1 / AIConfigurationItem, SSHPreferencesItem, SFTPPreferencesItem, WorkspacePreferencesItem, NotificationPreferencesItem, TerminalCustomThemeItem, TerminalThemeSelectionItem (legacy highlighting is ignored, not active) Master and optional Settings

The migration-only vault.sync.enabled participates in deriving the master selection if that switch has not been set. History does not inherit it: an unset History preference defaults to enabled, and migrateSyncPreferences does not copy the old Vault selection or environment into History. CloudSyncSettingsContent supplies the same UI to both platforms: a headerless iCloud toggle and aggregate status/actions, then “Optional Sync” toggles in History → Metrics → Settings order. There is no separate Vault or Device toggle. Enabling master sync is Pro-gated and attempts custom Host password migration to iCloud Keychain. SettingsKey, CloudSyncSettingsContent.

Desktop AppModel starts five owners on setup when Pro permits, and starts or pauses all five when Pro access is reconciled. Mobile constructs all five coordinators only for a persistent store, wires History and Device repository change callbacks to their respective owners, and starts them from reconcileSync() only with an active scene and Pro access. Scene activation, preference changes, and scene removal call reconciliation; backgrounding explicitly pauses all five. Both composition roots relay accepted remote changes into app-wide models, rather than making engines window-owned. Settings snapshots update AI, skills, terminal, SSH, SFTP, workspace and notification preferences; Metrics updates its live selections, and History/Device reload their models. Remote Vault changes are planned before replacing the Host projection. Removed Hosts close workspaces and clear related activity; on desktop they also cancel transfers, authentication and host tools. Route changes invalidate desktop forwarding/agent connections through willDeleteHost; mobile invalidates SFTP edits, disconnects retained workspaces and cancels transfers. Removed Hosts also invalidate desktop SFTP edits. Both stop affected forwarding rules before snapshot replacement and reconcile forwarding afterward; mobile also updates live activities. Local persistence continues to record changes when an engine is paused. AppModel, MobileAppModel, MobileAppModel, MobileAppModel.

Lifecycle and recovery

Each coordinator adapts its category journal to CloudSyncLifecycleStore and delegates CloudKit events to CloudSyncLifecycle. On start, it finishes pending asset cleanup, checks account availability and identity, prepares scoped safety and replica state, resets the journal on environment change, and discards a cursor if safety requires refetch, replica reconciliation is incomplete, or serialization is absent/invalid. It commits the recovery fence before checking/creating the zone or constructing the automatically syncing engine; a missing zone resets that owner's replica and journal. It reconciles eligible mutations with the engine queue, then fetches before sending. Explicit and automatic fetches may overlap: all current fetches must succeed before isReadyToSend and Settings secret-upload authorization become true. A fresh inventory compares observed IDs to previously present scoped records, reconciles missing IDs, and checkpoints the replica and decoder capability only after successful fetch/merge; journaled edits survive the fresh cursor. CloudSyncLifecycle, CloudSyncLifecycle, CloudSyncLifecycleSafety, CloudSyncSafetyPersistenceTests.

flowchart TD
    Selection{"Master and owner selection active?"} -->|No| Paused["Retire engine and keep journal"]
    Selection -->|Yes| Binding{"Account available and binding accepted?"}
    Binding -->|No| Waiting["Wait for sign in or account confirmation"]
    Binding -->|Yes| Prepare["Prepare consumer scope and replica inventory"]
    Prepare --> Cursor{"Capability changed or recovery or invalid cursor or incomplete replica?"}
    Cursor -->|Yes| Fresh["Clear serialized cursor and request full inventory"]
    Cursor -->|No| Fence["Commit recovery fence"]
    Fresh --> Fence
    Fence --> Zone["Check zone and reset own journal if missing"]
    Zone --> Engine["Create zone and engine then reconcile eligible queue"]
    Engine --> Fetch["Fetch zone and merge or quarantine records"]
    Fetch -->|Failure or unfinished fetch| Closed["Keep fence and upload gate closed"]
    Closed -->|Retry fetch| Fetch
    Fetch -->|All fetches succeeded| Inventory["Reconcile missing records and checkpoint scope"]
    Inventory --> Ready["Authorize eligible pending work"]
    Ready --> Batch["Recheck journal and restriction in synchronous batch"]
    Batch -->|Blocked| Held["Keep pending intent for this record"]
    Batch -->|Eligible| Send["Persist delete handoffs then send and acknowledge"]
    Send -->|Later sync| Fetch

The diagram shows one owner's fetch-before-send and scoped recovery gate; History and Device each run it over their shared zone with distinct consumer scopes. CloudSyncLifecycle, HistoryRecordSyncCoordinator.

A disabled master/category preference makes start pause the owner; disabled retry and account-merge confirmation do nothing. pause (also used by app Pro/background reconciliation), account sign-out or switch, and fatal delegate persistence failure retire an active engine: retireEngine changes the operation ID, cancels the operation task, clears readiness, Settings secret-upload authorization, scope and in-flight state, removes the engine synchronously, then schedules its cancel() in a detached MainActor task. This matters inside a CloudKit delegate callback: cancellation must not inherit its task-local callback context. Starting, retrying and scheduling local-change sends likewise use detached MainActor tasks. An old operation's late completion cannot change replacement status or authorize sends, and delegate events or batches for the old engine fail the identity check. A fatal delegate error rolls back, retires and reports failure; a normal cancellation (CancellationError or CloudKit .operationCancelled) is not logged or made a red failure. CloudSyncLifecycle, CloudSyncLifecycle, CloudSyncEventHandling, CloudSyncLifecycleTests, CloudSyncLifecycleTests.

stateDiagram-v2
    state "Retired and paused" as Paused
    state "Account and scoped recovery preparation" as Prepare
    state "Fetch pending with uploads closed" as Fenced
    state "Ready for eligible sends" as Ready
    state "Sign in required" as SignIn
    state "Account merge confirmation required" as Confirm
    state "Retired after persistence failure" as Failed
    Paused --> Prepare: Enabled start
    Prepare --> SignIn: Account unavailable
    Prepare --> Confirm: Account mismatch
    Prepare --> Fenced: Commit fence and create engine
    Fenced --> Fenced: Retryable fetch failure
    Fenced --> Ready: All fetches succeed and inventory commits
    Ready --> Fenced: New fetch begins
    Ready --> Paused: Disable or app pause
    Fenced --> Paused: Disable or app pause
    Ready --> SignIn: Sign out and retire
    Ready --> Confirm: Account switch and retire
    Ready --> Failed: Delegate persistence failure
    Fenced --> Failed: Delegate persistence failure
    Confirm --> Prepare: Confirm while enabled
    Failed --> Prepare: Retry while enabled
    SignIn --> Prepare: Enabled retry

Retirement changes engine identity immediately; asynchronous cancellation and late completions cannot reopen the retired upload gate. A retryable fetch failure, unlike retirement, keeps the same engine for retry. CloudSyncLifecycle, CloudSyncLifecycle, CloudSyncLifecycle.

A retryable zone-fetch failure keeps the engine but does not reopen uploading or clear the recovery fence; the pending journal remains authoritative and a later successful fetch reauthorizes sending. Network/rate/zone-busy errors report syncing for retry, notAuthenticated requests sign-in, and terminal errors report failure. Recovery stays required until outstanding fetch/send work completes safely. The status can separately report uploadable and blocked counts, with an update hint only for unsupported format—not corruption. Logging emits cloud.sync.failed with zone, stage and NSError domain/code, not raw records, account IDs or localized descriptions; expected cancellation is not an error. CloudSyncLifecycle, CloudSyncLifecycle, CloudSyncEventHandling.

Changing replicas and CloudKit deletions

An account mismatch records a pending user and requires explicit merge confirmation before any new-account engine exists. Confirmation resets the owner's journal before binding the new user, drops old-account tombstones and system fields, and requeues surviving local records. Old-scoped safety rows remain scoped to the old account; no legacy-quarantine preparation runs on confirmation. A changed TermindCloudKitEnvironment resets that owner's engine state and system fields and requeues envelopes without moving old-scoped restrictions. A missing zone or ordinary zone deletion recreates and requeues the corresponding replica; Device and History must not reset one another despite sharing a zone. By contrast CloudKit .purged or .encryptedDataReset retires the owner and purges that scope's local cloud cache and safety/replica state without recreating the zone or uploading old local data. Category asset-cache cleanup is tracked durably and finished on restart. CloudSyncLifecycle, CloudSyncLifecycle, HistorySyncStore.

Destructive purge and live projection invalidation

Settings prevents restart bootstrap from undoing a destructive reset: SettingsSyncJournal.purgeCloudCache() replaces its journal with non-uploadable baseline tombstone sentinels for web search, terminal, SSH, SFTP, workspace and notifications, marks bootstrap complete and clears engine state. The purge transaction records those deletions in the scoped replica sidecar. Consequently prepare() sees existing singleton envelopes instead of seeding the cleared defaults as new uploads. This is suppression of the old replica, not a ban on later intentional edits. Journal purge, Purge transaction and preparation.

A destructive zone notification is deliberately stronger than an ordinary record deletion or missing zone. The lifecycle retires the engine before persistence is touched. The category purge commits the cleared/materialized projection, journal suppression, active-scope safety/replica cleanup and a durable asset-cleanup marker. Settings then mirrors the committed defaults and publishes the replacement snapshot through didApplyRemote, not through a local Save action. This keeps live models from retaining and later resending the pre-purge snapshot. Other-account safety rows remain intact, and History/Device retain their category boundary despite sharing a zone. Settings purge, Coordinator purge, Physical deletion tests.

flowchart TD
    Event["CloudKit zone deletion"] --> Reason{"Deletion reason?"}
    Reason -->|Deleted| Reset["Reset owner replica and requeue journal"]
    Reset --> Recreate["Recreate zone"]
    Reason -->|Purged or encrypted data reset| Retire["Retire engine before persistence"]
    Retire --> Commit["Commit category purge and scoped cleanup marker"]
    Commit --> Publish["Publish committed replacement projection"]
    Publish --> Assets["Remove category asset cache"]
    Assets --> Result{"Cleanup succeeds?"}
    Result -->|Yes| Done["Clear cleanup marker"]
    Result -->|No| Hold["Keep durable purge and pending cleanup"]
    Hold --> Restart["Retry cleanup on startup before engine creation"]

Ordinary zone loss requeues data; destructive purge invalidates projections and never restores the old snapshot merely to retry asset cleanup.

Asset removal happens after the durable projection purge and notification. If removal fails, the owner stays retired and the cleared projection remains committed; restart must finish pending cleanup before creating an engine, even if the account/environment binding has changed. Tests reopen an on-disk store through failed and successful cleanup, assert no stale batch can be materialized, and verify that unrelated asset directories survive. These are fake-transport/persistence tests, not signed-device delivery evidence. Cleanup failure regression.

Durable journals and wire contracts

Settings app models persist through SettingsSyncPersistence: configuration, skills and preference changes are compared as per-record snapshot payloads, and the SwiftData ModelContext.commit saves the local projection and SettingsSyncJournal upload intent together, rolling back on failure. prepare() migrates legacy role keys and bootstraps the current projection, including subsequently added terminal, SSH, SFTP, workspace and notification singletons, after discarding inactive highlighting journal entries. Its didRecordChanges callback notifies SettingsSyncCoordinator; a paused or missing engine does not prevent the journal write. Remote batches instead use applyRemote to merge envelopes, rebuild the complete projection (including out-of-order roles and providers), and commit journal, system-field and safety updates without echoing an accepted remote value as a local edit. Legacy provider Vertex fields and local skill packages are preserved when a remote envelope lacks them; resulting local differences are recorded as pending changes. SettingsSyncPersistence, SettingsSyncPersistence, ModelContext.commit, SettingsSyncCoordinator.

The Settings write and receive paths below share a journal but not an app-model edit entrypoint. The same lifecycle gate and scoped safety checks also apply to the other four owners; each supplies its own journal, record decoder and projection. SettingsSyncPersistence, SettingsSyncCoordinator, CloudSyncLifecycle.

sequenceDiagram
    participant App as Settings model
    participant Store as SettingsSyncPersistence
    participant Journal as SettingsSyncJournal
    participant Coordinator as SettingsSyncCoordinator
    participant Life as CloudSyncLifecycle
    participant Engine as CKSyncEngine
    App->>Store: Save local settings edit
    Store->>Journal: Bootstrap and record changed envelopes
    Store->>Store: Commit projection and pending intent
    Store->>Coordinator: didRecordChanges
    Coordinator->>Life: noteLocalChanges
    Life->>Engine: Reconcile pending queue
    Life->>Engine: Fetch zone before send
    Engine-->>Coordinator: Fetched records and deletions
    Coordinator->>Store: Validate and merge remote envelopes
    Store->>Journal: Accept winners and rebuild projection
    Store->>Store: Commit metadata and quarantine updates
    Coordinator-->>App: didApplyRemote accepted snapshot
    Engine-->>Life: Fetch completed successfully
    Life->>Engine: Send eligible pending changes
    Engine->>Coordinator: Request change batch
    Coordinator->>Journal: Read eligible outgoing entry
    Coordinator-->>Engine: Materialized records and deletes
    Engine-->>Coordinator: Sent changes or conflicts
    Coordinator->>Journal: Acknowledge matching version or merge conflict

Settings' committed local mutation survives an unavailable engine; the fetch and quarantine fence prevents that mutation from being handed to CloudKit until safe. Received supported records update the live model only after the transactional merge. SettingsSyncCoordinator, SettingsSyncCoordinator, CloudSyncLifecycle, CloudSyncLifecycle.

Bootstrap writes baseline envelopes from local projections; subsequent real edits get ordered VaultVersion values and durable pending intent, including logical tombstones. Merge chooses the newer version, breaking equal-version payload ties deterministically; a local winner is requeued. Fetched values do not echo merely because they were read. Journal-held system fields preserve CloudKit optimistic-concurrency metadata; successful acknowledgements clear pending only for the matching sent envelope, including payload equality when migration seeds retain a baseline version, never a later edit or a quarantined ID. Outgoing eligibility derives from journal and scoped replica sidecars, not a restored CloudKit queue alone. CloudSyncJournal, HistorySyncStore, CloudSyncRecordStateStore.

Vault, Metrics and ordinary Settings records carry version/kind/entity/clock/deletion envelope metadata and validated payload hashes, storing small payloads inline and larger ones in CKAsset. Settings uses separate active SSH, SFTP, workspace and notification preference record types inside its existing zone so old clients filtering on type can skip them before decoding. Its record-type check also validates that the decoded preference kind agrees with the type. Vault normalizes device-local group/package expansion rather than syncing it; the V33 Identity shape has no lastUsedAt. Debug/logging, review history and terminal font family/size/follow-theme settings likewise stay device-local. CloudSyncRecordCoding, SettingsSyncCoordinator, SettingsKey.

Native History/Device writes use typed CloudKit fields, not JSON/asset payloads. Frozen V1 and V2 adapters validate metadata, record identity and live fields before interpretation, and clear optional fields when writing. History reads V1/V2 and writes V2: V1 accepts ssh, telnet, sftp, while V2 also accepts mosh and eternalTerminal. Live events require hostID and connectedAt, with optional endedAt and sourceDeviceID; Device remains V1 and requires displayName. Tombstones need only metadata. IDs remain v1/connectionHistory/<uuid> and v1/device/<uuid>, independent of format versions. Events can arrive before Hosts without CloudKit references. V1, V2, History persistence.

History retains the 250 newest events and journals tombstones for overflow and clear operations while leaving device records intact. Event UUID deletion is terminal even against a delayed update with a later clock; Device, unlike events, can have a later explicit incarnation. Per-scope sidecars remember cloud presence, confirmed deletions and the exact durable delete handoff before network submission. Full-inventory reconciliation treats previously present but unseen records as physical deletions; only a newer pending explicit incarnation after a causal deletion handoff is preserved for recreation (not terminal History events). An ambiguous delete retains the handoff for retry; an acknowledgement applies only to the handed-off version, not a newer local edit. This avoids falsely acknowledging a newer device recreation after a crash, while reobserved records and blocked IDs still obey the outgoing fence. Ordinary physical record deletion is not a zone reset or permission to resend the local snapshot: it cancels pending updates and removes the projection unless the durable causal-deletion fence proves a newer pending explicit incarnation. Only that exception clears old system fields and preserves recreation, subject to upload restrictions; terminal History events never use it. HistorySyncStore, CloudSyncRecordStateStore, CloudSyncRecordStateStore, CloudSyncLifecycle, CloudSyncSafetyPersistenceTests.

Retired Vault History is not imported or physically cleaned by the current owner. New native History/Device records are written only to TermindConnectionHistoryV1. The Vault coordinator filters obsolete History/Device IDs out of decoded records and journal eligibility. It neither imports those payloads nor applies their deletion to local activity, but does not queue physical deletes of old-zone copies; do not assume that disabling the importer removes already uploaded legacy records. Old importer state is removed in migrated stores; native History bootstrap starts from current local events/devices, not the old Vault journal's clocks or system fields. Fresh installs read previously uploaded native records in the shared zone. VaultSyncCoordinator, VaultSyncCoordinator, SwiftDataVaultJournal, HistorySyncTests, PersistenceCompatibilityTests.

Compatibility and upload fence

SyncRecordFormats versions records per family and kind; VaultVersion is a modification clock, not a wire version. SwiftData schema numbers are independent local migration contracts. Before decoding a payload or choosing historical defaults, readers check supported kind/version and field types. Frozen VaultWireV1 DTOs preserve shipped Host SSH keys/defaults, including disabled session persistence and automatic artwork. V2 adds Host agent forwarding, SSH Key hardware references and Snippet color through separate adapters. Its Host decoder requires agentForwardingEnabled; V1 retains historical defaults. V2 SSH Key encoding writes domain .external hardware keys as the frozen thisDevice spelling and restores .external by security-key algorithm; software .external keys fail encoding. VaultWireV1, VaultWireV2, VaultSyncCodec.

Record kind Read versions Write version
Vault Host V1, V2, V4, V5, V6, V7, V8 V8
Vault HostGroup V1–V7 V7
Vault SSHKey V1, V2, V3 V3
Vault Snippet V1, V2 V2
Vault WorkspaceTemplate V1, V2 V2
Other ordinary Vault kinds V1 V1
Native Connection History V1, V2 V2
Native Device V1 V1
Metrics selection V1, V2 V2

Host V3 is not a production reader. Record names retain their v1/ identity prefix despite format upgrades, and codec errors throw rather than falling back to lossy encoding. V6 adds Host/HostGroup terminal transports, V7 adds Wake-on-LAN and Host V8 preserves createdAt; older encoding rejects values it cannot represent. WorkspaceTemplate V2 adds Mosh and Eternal Terminal panes. These contracts are independent of local SwiftData migrations. The current compatibility suite tests V30→V31 and V31→V34 migration, not the removed V28 reopen fixture or CloudKit delivery. Format registry, Codec, Workspace V2, Compatibility tests.

Unsupported versions/kinds, malformed payloads, invalid native fields and known-ID conflicts are quarantined with reason, received bytes (full secure CKRecord archive for native fields) and system fields in the container/environment/account/zone/consumer scope. Valid remote records, including serverRecordChanged replies, merge in a transaction with system-field updates and quarantine removal; failed persistence rolls back the unblock. Future unknown record types in an ordinary fetch may be ignored, but an unexpected type on a conflicting known ID cannot bypass validation. Scoped restrictions persist across pause, retry, relaunch and normal account/environment/zone resets, retain local pending intent, and do not block unrelated records. History and Device restrictions cannot leak into each other. CloudSyncLifecycleSafety, CloudSyncSafetyStore, CloudSyncSafetyTests.

Three checks protect outgoing changes: the replica sidecar omits blocked IDs when it enumerates journal mutations; reconciliation removes obsolete/blocked restored engine entries; and the synchronous batch checks current engine, fetch readiness, mutation operation and restriction again in each record provider. Each batch admits at most 250 materialized changes in total (saves plus deletes), not 250 saves plus deletes; invalid/blocked entries do not consume that limit. A deletion cannot leave before its causal handoff is saved, and sent-record or delete acknowledgement cannot clear a newer pending incarnation. An unreadable safety/journal state fails closed rather than being read as an empty set. CloudSyncRecordStateStore, CloudSyncLifecycle, CloudSyncLifecycleTests.

A changed decoder capability ID in Vault, Settings and each native History/Device consumer forces a scoped refetch and checkpoints capability only after a successful fetch/merge; Settings computes its ID from participating record kinds at V1, while Metrics passes its versioned decoder capability ID. Vault's capability string is derived from sorted kind names and their sorted reading versions, not the local SwiftData schema or the writing version. Adding V4/V5 readers therefore replays unchanged CloudKit records skipped under earlier capabilities, without discarding pending local edits. No current fence can retrofit a previously shipped unsafe binary; adding silently ignored fields in an existing version without raising the breaking version remains unsafe. CloudSyncLifecycleSafety, VaultSyncCoordinator, HistoryRecordSyncCoordinator, SettingsSyncCoordinator, MetricsSyncCoordinator.

Snippet color is an explicit per-kind wire upgrade, not a consequence of local schema V51. V2 composes color with the frozen V1 fields; V1 encoding rejects colored snippets rather than losing color. A newer remote V1 Snippet clears color to .none, and tombstones retain V1/V2 compatibility. SnippetColorTests verifies both direct codec and journal/merge behavior. Treat successful historical decoding separately from whether an older writer can round-trip a colored value.

Host session persistence and icon preferences

Local schema history is separate from the Vault wire contract: V28 stores SSH values as port, identityID and charset. The V28→V30 lightweight migration retains those property names for SSH and adds disabled-by-default Telnet settings. TermindSchemaV29HostModels is the historical namespace of the model actually shipped in V30, not evidence of a separate live V29 projection. Its adapter maps the retained fields to the domain's separate SSH and Telnet profiles. Historical Host shape, Migration plan.

These are Vault Host/HostGroup configuration, not Settings singleton records. Both desktop and mobile Host editors bind HostSessionSection to the draft's session-persistence value; the shared section exposes enablement, backend (tmux, Zellij, zmx or Herdr, preserving unknown backend IDs), and on-connect choices Ask, Create, or Normal Shell. New and legacy Hosts default to disabled persistence with tmux and Ask. The shared HostOrganizationSection binds HostIconPicker to iconOverrideID. These controls change saved host configuration rather than the global terminal preferences. HostIntegrationSection, Desktop editor, Mobile editor, HostConnectionFields, SessionPersistenceTests.

Ordinary Host edits go through HostLibraryModel.saveHost and HostPersistence.save: projection and changed Vault envelopes are saved together, with context rollback and restoration of the previous library on failure. The V53 local Host shape introduced session-persistence columns; V54 retains those columns and adds iconOverrideID. Those schema steps do not select CloudKit wire versions. For Tailscale-managed Hosts, saveTailscaleHost stores both choices in the parent group's tailscale.hostPreferences keyed by stable Host UUID, then rebuilds the discovered Host projection. Managed peers are not standalone uploaded Host rows. HostLibraryModel, Tailscale preference projection, HostPersistence, V53 Host, V54 Host.

V4 wraps the frozen V2 Host or V3 HostGroup shape with session-persistence preferences. V5 wraps V4 with user artwork: a Host's optional iconOverrideID, or a sparse HostGroup iconOverrides dictionary. Group dictionaries require canonical lowercase UUID keys belonging to existing base host preferences. Encoding below V4 rejects non-default session preferences, and encoding below V5 rejects icon overrides: successful old-version encoding must not silently erase selections. Historical reads restore disabled persistence and automatic artwork. VaultWireV4, VaultWireV5, VaultSyncCodec.

V4/V5 are historical session/artwork layers underneath the current transport, Wake-on-LAN and creation-metadata formats. Saved preferences are not live recovery state: TerminalRecoveryStore is independent of SwiftData/Vault/CloudKit, keeps encrypted device-local checkpoints under Termind/TerminalRecovery, excludes its directory from backup and consumes checkpoints before returning resumable protocol state. Neither Settings nor History moves a live terminal session between devices. Recovery boundary.

flowchart TD
    Editor["Desktop or mobile Host draft"] --> Library["HostLibraryModel save"]
    Library --> Kind{"Managed Tailscale Host?"}
    Kind -->|No| Host["Host projection"]
    Kind -->|Yes| Group["Parent group hostPreferences"]
    Host --> Commit["Commit local projection and Vault journal"]
    Group --> Commit
    Commit --> Owner["Notify VaultSyncCoordinator"]
    Owner --> Gate["Fetch and scoped upload fence"]
    Gate --> Encode["Encode current Host V8 or HostGroup V7"]
    Encode --> Cloud["CloudKit VaultItem"]
    Cloud --> Decode["Validate kind and version then decode"]
    Decode --> Merge["Commit winning projection and cloud metadata"]
    Merge --> Publish["Publish accepted Vault snapshot"]
    Publish --> Replace["Platform app replaces library projection"]

Host preference edits use the Vault journal and wire contract, while sharing the lifecycle's fetch-before-send protection with Settings.

The Vault coordinator validates and merges fetched records and conflicts through HostPersistence.applyRemote, committing metadata and unblock before publishing accepted snapshots. Desktop and mobile replace their library projections through their existing remote-change plans; Tailscale projection rebuilds restore the group-backed choices. Artwork remains separate from system detection: saving a draft preserves the latest detected distribution, choosing Automatic clears the override, unknown icon IDs round-trip, and artwork-only changes do not trigger connection-route callbacks. VaultSyncCoordinator, HostIconTests, HostIconTests round trips, AppModel, MobileAppModel.

General icon choices resolve to system images without becoming distribution matches. An explicit unknown override remains stored but renders the server fallback (server.rack), rather than falling through to detected artwork. Automatic artwork instead tries an exact distribution match, then the first matching likeIDs entry, then generic Linux; no detection falls back to the server icon. The expanded icon tests exercise general choices through ordinary Host persistence and group-backed rediscovery as well as wire round trips. Artwork resolver, Icon regressions, Saved choices.

SessionPersistenceTests checks historical disabled defaults, backend/policy round trips, refusal of lossy old writes, group preference round trips and V52 disk migration/reopen. HostIconTests checks ordinary and Tailscale remote application, clearing and unknown IDs, failed-save rollback, old-format rejection, tombstones, and V53 migration followed by artwork save/reopen. Those cases verify codecs/local stores, not signed-device CloudKit delivery. The suite also exercises runtime session behavior with fake backends: SSH reconnect retains its attachment without replaying the startup snippet, and restoring Mosh/Eternal Terminal skips startup preparation while preserving the attachment. These runtime checks do not make recovery checkpoints synced Settings data. See Session persistence and SessionPersistenceTests.

Custom terminal themes: drafts, migration and replication

Custom themes are not one opaque replacement of the whole library. SettingsSyncSnapshot.recordPayloads emits one .terminalCustomTheme envelope per UUID and one .terminalThemeSelection per light/dark appearance. Independent edits to different themes can merge. Selections retain a built-in fallback and may arrive before their theme: the custom ID remains unresolved until that theme arrives. Deleting a selected theme or narrowing its applicability repairs affected choices without changing the unaffected mode. Settings snapshot, TerminalSettingsModel, TerminalCustomThemeTests.

UI edits live in TerminalCustomThemeDraft until a save entrypoint succeeds. Desktop autosaves valid changes and saves before navigation; invalid or remotely deleted drafts remain protected by the discard flow. Only a clean existing draft refreshes from the live library; dirty text is not silently overwritten by a remote edit. Save trims the name and uses TerminalSettingsModel.saveCustomTheme → saveTerminalThemes → saveLocal, committing projection and per-record intent before updating the live library. A failed write leaves the draft dirty/new and the live library unchanged. An existing draft cannot resurrect a remotely deleted UUID; Make Copy assigns a new identity so the content can be saved intentionally. Draft, Theme persistence, Draft tests.

The existing Settings owner writes native TerminalCustomThemeItem and TerminalThemeSelectionItem records in its existing zone. They use typed V1 metadata and fields rather than a JSON payload. Configuration is inline text or a raw UTF-8 configurationAsset above the shared inline threshold, with a SHA-256 configurationHash; readers reject inconsistent field combinations, corrupt hashes, invalid identities/appearance and unsupported versions. Tombstones clear live fields. Native-field quarantine preserves the record archive for later decoding. Committed remote snapshots reach applyRemoteThemes on both app models, not the draft Save path. Native theme contract, Settings owner, Desktop reconciliation, Mobile reconciliation.

Legacy dark/light custom configuration is migrated during preparation and after a remote journal merge. Migration uses deterministic IDs derived from appearance, base and text, and baseline clocks that cannot outrank an explicit newer choice. A selection envelope—including its tombstone—is the per-mode migration marker. Fresh defaults do not publish an empty migration; later legacy cloud settings can still seed the library. Once a native selection exists, old terminal-singleton writes do not erase that library. Migration, Remote materialization, Migration tests.

flowchart TD
    Draft["Local custom-theme draft"] --> Save["Save through TerminalSettingsModel"]
    Save --> Commit["Commit theme rows and per-record Settings intent"]
    Commit --> Owner["SettingsSyncCoordinator and fetch safety gate"]
    Owner --> Cloud["Native theme and appearance-selection records"]
    Cloud --> Validate["Validate typed fields and configuration hash"]
    Validate --> Merge["Merge journal and rebuild complete library"]
    Merge --> Models["Apply committed themes to both app models"]
    Models --> Refresh{"Existing draft clean?"}
    Refresh -->|Yes| Update["Refresh draft from live library"]
    Refresh -->|No| Keep["Keep unsaved local text"]

Custom-theme content and selection share the Settings transaction and lifecycle, but drafts are not replicated.

TerminalCustomThemeTests covers independent per-theme edits, selection-before-theme delivery, tombstones, failed batch rollback, raw UTF-8 assets and corruption, stable legacy migration and V56 store reopening. These tests exercise local persistence and record coding, not signed-device delivery.

Notification preferences across Settings and app models

NotificationPreferences is a synced Settings singleton, not a device-local authorization grant. It carries enabled state, independent assistant/terminal/SFTP delivery modes (never, unfocused, always), terminal system-notification versus bell delivery and a nonnegative terminal minimum duration. The defaults enable notifications, use unfocused for all three modes, system delivery and a five-second minimum. usesSystemNotifications excludes bell-only terminal delivery and disabled categories; the terminal duration threshold still applies to bell delivery. The decoder defaults a missing SFTP mode to unfocused for older payloads, but validates the nonnegative duration. NotificationPreferences, NotificationPreferencesTests.

SettingsSyncPersistence.prepare() bootstraps a missing notifications envelope even for stores already bootstrapped before this preference existed. saveNotificationPreferences validates then commits its SwiftData singleton and changed journal envelope together; materialize() restores default preferences on a winning remote tombstone. Settings uses NotificationPreferencesItem in the existing Settings zone rather than the older AIConfigurationItem record type; decoding verifies the envelope kind agrees with the record type. A supported remote winner updates persistence without invoking the local-change callback, then the coordinator notifies the app after commit; both platform composition roots apply the snapshot to their shared NotificationSettingsModel. The V40→V41 lightweight schema step adds PersistentNotificationPreferences, and the migration/reopen test verifies both the saved notification row and an older workspace setting survive. SettingsSyncPersistence, SettingsSyncPersistence, SettingsSyncPersistence, SettingsSyncCoordinator, NotificationPreferencesTests, MobileAppModel, AppModel.

Terminal keyword highlighting is retired, not another active Settings singleton. V52 removes its historical local model; preparation discards inactive envelopes and excludes it from active bootstrap/capability/upload state. The legacy TerminalHighlightPreferencesItem type and v1/terminalHighlighting/default key are recognized only to ignore fetched/conflict/deletion records before reconciliation and payload validation. Even malformed retired records cannot replace other preferences or create quarantine through that path. Historical server types and old clients require separate rollout review; do not recreate the deleted live models or editor. See Ghostty Terminal Integration and the retirement regression fixtures.

The notification model loads persisted preferences on construction; an invalid or failed edit does not change its live value, whereas a committed remote snapshot replaces it without writing or echoing. An active scene requests initial system authorization at most once per app-level model, only when loaded settings call for system delivery and no load error occurred; disabled notifications and bell-only terminal delivery do not prompt. The shared view modifier runs on active-scene transitions, but the model's one-shot guard deduplicates concurrent scenes and reactivation. NotificationSettingsModel, InitialNotificationAuthorization, NotificationPreferencesTests, NotificationPreferencesTests.

MCP metadata in the Settings data contract

SyncedMCPServer carries optional serverDescription alongside server identity, transport, tools, OAuth configuration and credential references. SettingsSyncSnapshot includes it in the per-server payload; older JSON without the description remains readable as nil. Locally, AIPersistence joins PersistentMCPServerMetadata by server ID when loading and replaces metadata rows within the caller-owned replacement transaction, inserting a row only for a non-nil description. This local storage shape is independent of SettingsEnvelope.currentFormatVersion (still 1); adding a SwiftData row does not itself upgrade a cloud wire contract. SyncedMCPServer, Snapshot, AI persistence.

MCPServerMetadataTests verifies that tool rediscovery replaces or clears the description without resetting an existing tool access policy, that missing-description payloads decode, and that a V58 store migrates and retains a subsequently saved description after reopening. These are configuration and local compatibility checks, not CloudKit delivery evidence. Metadata tests.

Hardware key metadata at the Vault boundary

Security-key SSH entries are Vault metadata, not Keychain private keys: HostLibraryModel.upsert validates their credential ID, 65-byte X9.63 public key and lack of certificate, normalizes storage to .external and clears credentialRef. The V45 persistent key row stores credential ID, public key and verification policy; on load a security-key algorithm normalizes older storage values to .external. V2 carries this reference between Vault replicas, so connection preparation can resolve both destination and jump credentials even when Keychain access fails. Availability for these entries comes from the persisted reference, whereas software-key availability still checks Keychain. Failed metadata saves or deletes restore the in-memory library; upgrading a V44 software key store still preserves its keys. This is distinct from syncing software-key private material. HostLibraryModel, HostLibraryModel, TermindSchemaV45KeyModels, WebAuthnVaultMetadataTests, WebAuthnVaultMetadataTests.

Settings secrets and operations

Settings synchronizes credential references, not secret bytes. After accepting provider or MCP-server references, SettingsSyncCoordinator marks them iCloud-backed. It migrates provider, Vertex and MCP references before a send/batch only when the current Settings fetch has authorized uploads and master plus Settings switches remain enabled. An active/failed fetch or engine retirement revokes authorization; AISecretStore verifies an iCloud copy before removing the device copy, and saves pending-deletion markers before deleting Keychain values so delayed delivery cannot resurrect a removed secret. SettingsSyncCoordinator, SwiftDataAISecretStore, CloudSyncLifecycle.

Deploy and verify category record types, zones and subscriptions in both CloudKit environments, especially active SFTPPreferencesItem and NotificationPreferencesItem; treat legacy highlighting as a separate mixed-version retirement check rather than a new-feature deployment requirement, and run signed multi-device checks for offline edits, master/optional switches, Pro expiration, account change, zone deletion, and delayed Keychain delivery. See CloudKit Schema Deployment and Signed-Device Release Readiness. When adding an owner, create an independent journal/state, subscription and safety consumer scope (and normally a zone), wire its own lifecycle/delegate and platform Pro/scene gating, and preserve every outbound fence. Do not reuse a sibling's system fields or cursor even if sharing a zone. For persistence and secrets see Persistent Data, Migrations, and Secret Boundaries; for app composition see Platform Runtime, App Composition, and Lifetimes.

Focused verification

  • CloudSyncCoordinatorLifecycleTests verifies independent Vault/Settings/Metrics bindings, engines and environment markers, Settings secret authorization after successful fetch and its revocation on retirement, and stale save acknowledgements preserving a newer theme selection—even when legacy migration retains the same baseline clock. These are adapter tests with fake transport, not CloudKit delivery tests. Coordinator lifecycle tests.

  • SettingsPortTests exercises the typed boundary: failed terminal saves retain applied preferences, remote terminal application skips the local-write callback, mobile provider edits preserve MCP servers/skills/unresolved roles, and migration preserves optional Settings selection while master is paused. New Vault credential storage requires master selection and Pro capability regardless of the legacy Vault toggle. SettingsPortTests.

  • Migration fixtures in SettingsSyncTests reopen a V12 store with the current migration plan and retain providers/endpoints/models, skills, web search and terminal custom text without materializing an optional sync choice or changing local font size. A completed-bootstrap fixture adds a missing terminal baseline without rewriting existing envelopes; the terminal round-trip checks defaults mirroring without a remote echo. These are local migration/merge tests, not signed-device CloudKit tests. SettingsSyncTests.

  • AppLanguageTests pins canonical defaults and AppleLanguages, Follow System removal, legacy Simplified Chinese migration, unknown-value preservation, region-dependent labels, catalog separation and the selected-language notice. It includes German, French and Korean persistence and system-negotiation restoration, and keeps Follow System first with explicit choices sorted by language code. SettingsSyncTests checks settings round-trips, local edits while disabled, no-op version stability, out-of-order roles and invalid-batch rollback. These tests do not require an automatic relaunch. AppLanguageTests, SettingsSyncTests.

  • SettingsNavigationTests verifies the Settings parent-route chain, back-to-content return (including a missing workspace), paywall presentation without a Pro route, and navigation through Assistant; DiagnosisTests verifies per-installation consent defaults, durable choices and revocation ordering. SettingsNavigationTests, DiagnosisTests.

  • CloudSyncPhysicalDeletionTests checks all five categories for remote deletion cancelling pending updates across restart, physical-delete batches and stale acknowledgements, causal recreation versus terminal History deletion, full inventories, active-account purge isolation, projection notification and durable asset cleanup. In particular, an ordinary pending edit is not sufficient evidence to recreate a remotely deleted record. Physical deletion tests.

  • CloudSyncLifecycleTests exercises fetch gating, recovery, overlapping events, account/environment/zone changes, destructive purge, stale-engine rejection and the combined save/delete batch limit. Its delegate-context tests verify detached fetch/send and engine cancellation; late fetch/send and fatal delegate failures cannot revive a retired engine. Disabled selection prevents start, retry and account confirmation.

  • CloudSyncSafetyTests uses five real owner adapters with fake CloudKit transport to check winner/queue reconciliation, quarantine at each upload boundary, conflict handling, malformed native fields, rollback, and healthy-peer progress; CloudSyncSafetyPersistenceTests covers scoped on-disk recovery and migrations.

  • HistorySyncTests checks typed native fields, independent History/Device scopes and switches, terminal event tombstones and retention, no retired-source import, and error/cancellation diagnostics. PersistenceCompatibilityTests checks current local V30/V31 migration cases and dropping retired importer state on V35 reopen.

  • CloudSyncCoordinatorLifecycleTests, TFMTests/SettingsSyncTests.swift, TFMTests/VaultSyncTests.swift and SyncRecordEvolutionTests cover per-owner gating, Settings secret authorization, journal/metadata exclusions, assets and wire evolution. WebAuthnVaultMetadataTests covers Keychain-free connection preparation and Vault round-trip, the frozen V2 thisDevice representation, invalid software external storage, metadata rollback, and V44 software-key reopen. NotificationPreferencesTests verifies settings record round-trip without echo, invalid remote duration, V40→V41 persistence reopening, edit failure and one-shot authorization across scenes. HistorySyncTests, CloudSyncSafetyTests, PersistenceCompatibilityTests.

Current Vault writers are Host V8, HostGroup V7, SSHKey V3, Snippet V2 and WorkspaceTemplate V2, with the historical readers listed above. Metrics selection writes V2 and reads V1/V2; its capability checkpoint forces refetch without losing newer pending local changes. Tailscale group configuration syncs as HostGroup data; managed peer Hosts and local profile/sign-in/cache/cleanup state are excluded. Command/SFTP browse history is memory-only; History remains solely the connection-event stream.