zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Cloud-Primary Storage — ワークスペースを唯一の保管先とする転換

zudo-text は「ローカルの markdown エディタ + オプションのクラウド同期」から、cloud-primary なテキストエディタへ転換します。ローカルワークスペースディレクトリという概念そのものを廃止し、ユーザーコンテンツはすべて既存の暗号化クラウドワークスペースに置きます。ターミナルフレームと Rust PTY バックエンドも同時に退役します。

このページはその転換の canonical な契約書です。epic #4204 の意思決定 D1–D15 を、後続の sub-issue が質問なしに実装できる粒度で確定させたものです。実装中に「ここはどう決まっていたか」で迷ったら、コードコメントではなくこのページを参照してください。逆に、このページと異なる実装をしたい場合は、まずこのページを更新してください。

転換前のスナップショット

転換前の main の状態にはタグ pre-cloud-pivot が打たれています。ローカル FS ミラーリング時代の挙動を確認したいときはそこを見てください。Sync Architecture の local-fs-mirroring に関する記述も転換前のモデルの記録です。

Why — 二重構造をやめる

現状の zudo-text には、同じコンテンツに対する保管先が二つあります。

  • ローカルワークスペース~/Documents/zudo-text/<appname>/ 以下の実ファイル。Rust の commands/files.rs が読み書きし、notify crate が監視する。

  • クラウドワークスペースworkers/sync-server(D1 + R2 + SyncRoom DO)上の暗号化ブロブ。@takazudo/cloud-sync + @takazudo/cloud-crypto がクライアント側を担当する。

この二重構造は、機能を足すたびにコストを二重に払わせます。新しいボードを作れば「ローカルパスで読む実装」と「ワークスペースに push する実装」の両方が必要になり、iOS では「ワークスペースから引いてローカルに書き戻す」ハイドレーションという第三の経路まで生えました(ios-sync-hydrate.ts — S12/D14 で削除済み)。同期の一貫性バグはほぼすべてこの境界で発生しています。

そして決定的な事実として、転換先はすでに動いています。web エディタビルドは、ローカルファイルシステムをまったく使わずに renderer の全機能を動かしています。packages/backend-bridge/src/rest-adapter.tsisCloudWorkspaceReady() 分岐(71 箇所)がインメモリのワークスペースモデルを提供し、workspace-path-model.ts がアドレッシングを、cloud-genesis.ts が初期化を、SyncOutbox が耐久性のある書き込みバッファを担っています。

つまりこの epic は新規開発ではなく、すでに実証済みの実装を共有コアとして抽出し、デスクトップと iOS をそこに向け、ローカルワークスペースとターミナルのサブシステムを削除する作業です。

The shape of the pivot

転換前転換後
ユーザーコンテンツの真実ローカル .md ファイル(ワークスペースは同期先)ワークスペース(唯一の保管先)
アドレッシング絶対パス(プラットフォーム依存)workspace-relative POSIX パス
アプリインスタンスの指す先ワークスペースディレクトリちょうど一つのワークスペース
初回起動ワークスペースを自動 scaffoldonboarding へルーティング
ローカル FS を触る面ほぼ全機能External File Editor のみ
ターミナルcore.terminal + Rust PTY廃止
オフラインローカルファイルがあるので常に動く書き込みは outbox がバッファ、cold boot はローカル暗号化ミラーがあればオフラインでも成立(epic #4808、D7)

哲学的には、これは zudo-text の哲学 の後退ではありません。エディタと外部ドキュメントシステムの契約は依然として「frontmatter 付きの markdown ドキュメント」であり、スキーマはワークスペース側に宿ります。変わるのはドキュメントがどこに置かれるかだけで、何がドキュメントかは変わりません。ローカルの .md を直接編集したいユーザーのための面は External File Editor として残ります(D5)。

Doc Cloud はこのワークスペースの第三の保存先ではありません。 bridge.docCloud は外部 zudo-doc-cloud サービスの Personal authoring API へ接続する明示的な外部サービス面であり、本文・asset・outbox を bridge.workspaceFiles / bridge.assets に複製しません。逆に Doc Cloud の calling token、generation/history guard、tab、preview object URL も workspace settings に入りません。契約全体は Doc Cloud Unified Frame を参照してください。

D1 — workspaceFiles を一級の bridge surface にする

現状

ワークスペースモデルは rest-adapter.tsモジュールプライベートな Map です。

// packages/backend-bridge/src/rest-adapter.ts:171
const workspaceFiles = new Map<string, string>();

bridge.workspaceFiles という namespace は存在せず、grep -rn "workspaceFiles" はこのファイルにしかヒットしません。各機能面(messages / notes / inbox / pins / workspace / files)がそれぞれ独自に isCloudWorkspaceReady() を見てこの Map を直接触っています。したがってこれは「既存 API のリファクタ」ではなく新しい API の追加です。

決定

ワークスペースコアを packages/backend-bridge/src/workspace-core/ に抽出し、renderer には bridge.workspaceFiles として公開します(このディレクトリは S4 が作るのでまだ存在しません)。 epic D1 が挙げる read / write / delete / list / subscribe / metadata の 6 操作に加えて、再帰列挙の listAll を第 7 の操作として置きます(ボードのディレクトリ走査と類似度検索のコーパス走査が必要とするため。list の直下のみでは代替できません)。

/** workspace-relative POSIX パス(= workspace key)に対するメタデータ。 */
export interface WorkspaceFileMetadata {
  path: string;
  /**
   * サーバが割り当てた version。そのキーについて upload の ack を一度も
   * 受け取っていなければ 0。既知の version があるローカル書き込みでは
   * 保持する(0 は「サーバ version が存在しない」の意味であり、
   * 「upload が in-flight」ではない)。
   */
  version: number;
  /** ISO 8601。下の provenance 表のとおり。ハイドレーションや read で作り直してはならない。 */
  createdAt: string;
  /** ISO 8601。同上。 */
  updatedAt: string;
  /** 平文コンテンツのバイト長。ローカルで算出する(ciphertext の長さではない)。 */
  size: number;
}

export interface WorkspaceDirEntry {
  name: string;
  isDir: boolean;
}

export type WorkspaceChange =
  | { path: string; action: "upsert"; content: string; origin: "local" | "remote" }
  | { path: string; action: "delete"; origin: "local" | "remote" };

workspaceFiles: {
  /** 不在なら null。containment 違反なら throw(D4 参照)。 */
  read: (path: string) => Promise<string | null>;
  write: (path: string, content: string) => Promise<void>;
  /** 実際に存在して削除したなら true、不在なら false。 */
  delete: (path: string) => Promise<boolean>;
  /** 直下の子のみ。ディレクトリはパス prefix から推論される(ワークスペースに実体はない)。 */
  list: (dirPath: string) => Promise<WorkspaceDirEntry[]>;
  /** prefix 配下の全 key を再帰的に列挙。ボード/コーパス走査用。 */
  listAll: (prefix?: string) => Promise<string[]>;
  /** モデルに key が無いときだけ null。存在すれば必ず完全な値を返す(下記 pending-write 規則)。 */
  metadata: (path: string) => Promise<WorkspaceFileMetadata | null>;
  /** local/remote × upsert/delete の 4 通りすべてを emit する。 */
  subscribe: (
    listener: (changes: readonly WorkspaceChange[]) => void,
    options?: { prefix?: string },
  ) => () => void;
};

決めておくべき細部を明示します。

  • read は不在で nullthrow しない)。既存の bridge.files.readTextisFileMissingError() を成立させるために No such file or directory を throw していますが、workspaceFiles は messages.read / notes.read と同じ null 返しに揃えます。呼び出し側の分岐が素直になります。

  • subscribe は origin を渡し、ローカル削除も emit する。 リモート由来とローカル由来を listener が区別できることが必須です。エコーループガードはワークスペースコアの内側(ChangeTracker)にありますが、UI 側は「他デバイスの変更だからトーストを出す」といった判断をします。ローカル削除を emit し忘れないこと — 自デバイス発の変更は後続の pull から self-device filter で除外されるため、subscribe が流さなければ別ビューや popout の購読者は永久に古い状態のままになります。

  • list の順序は保証しない。 現在の listDirectChildren はディレクトリを先に、それぞれ Set の挿入順で返します(ソートしない)。表示順は呼び出し側の責務です。

  • bridge.files は local-only のまま。 External File Editor 専用の低レベル面であり、ワークスペースモードでの分岐を持ちません(D5)。この namespace が workspace 側と混ざらないことが、この契約の要点です。

永続メタデータと restart-stable ordering

現状の最大の欠陥は、メタデータがハイドレーション時に捏造されることです。

// packages/backend-bridge/src/rest-adapter.ts:211
const workspaceNoteMeta = new Map<string, { mtime: string; birthtime: string }>();

getWorkspaceNoteMeta は未知の key に対して mtimebirthtime の両方を now で作ります。結果、cold start のたびに全ノートのタイムスタンプが「今」になり、mtime でソートする面の並びが再起動をまたいで変わります。pins はさらに悪く modifiedAt: "" です。

原因は wire ではなくデコード層にあります。サーバはすでにタイムスタンプを返しています。

// packages/cloud-sync/src/types.ts:87 — GET /sync/snapshot の 1 ページ
export interface SnapshotPage {
  files: Array<{
    id: string; encryptedPath: string; contentHash: string;
    encryptedSize: number; version: number;
    createdAt: string; updatedAt: string;
  }>;
  cursor: number; nextPageCursor: string | null; hasMore: boolean;
}

POST /sync/pull の各行も version / encryptedSize / changedAt を運びます。にもかかわらず、適用層の型がそれを捨てています。

// packages/backend-bridge/src/cloud-sync-bridge.ts:651
export interface AppliedRemoteChange {
  path: string;
  action: "upsert" | "delete";
  content: string;
}

したがって決定は三つです。

  1. AppliedRemoteChange を拡張し、wire が運ぶメタデータをワークスペースモデルまで通す。 ただし wire は 4 フィールドすべてを一様に運んではいないので、フィールドごとに出所を決めます。

    フィールド出所
    sizeローカルで算出(復号後の平文のバイト長)。wire の encryptedSize は ciphertext の長さなので使わない。
    versionsnapshot の version、pull の version、または upload の ack。CloudSyncClient.uploadFile() はレスポンスを捨てず ack(FileUploadAck)を返す。PUT のレスポンスに cursor は含まれない{ id, workspaceId, encryptedPath, contentHash, encryptedSize, version } のみ)ので、ack が運ぶのは version だけ。
    updatedAtsnapshot の updatedAt、pull の changedAt、またはローカル書き込みの実時刻。D1 の datetime('now')"YYYY-MM-DD HH:MM:SS"(UTC だが ISO ではない)ため、wire のデコード境界で ISO-8601 UTC に正規化する — Safari はこの綴りを Invalid Date として拒否し、V8 はローカル時刻として解釈するので、正規化しないとデバイスごとに並び順が変わる。
    createdAtsnapshot の createdAtpull は createdAt を運ばないため、pull で初めて見た key は「最初に観測した updatedAt と同値」を createdAt とし、以後上書きしない(後の full snapshot が正しい値に訂正する)。

    ここで許されるのは「実際に起きたイベントの時刻を記録する」ことだけです。ハイドレーションや read の時点で now を作るのは禁止で、それが現在の getWorkspaceNoteMeta の欠陥です。

  2. pending-write のメタデータは null にしない。 metadata()null を返すのは モデルに key が存在しないときだけです。まだ upload の ack が返っていない key は、version: 0 +(ローカル書き込み時刻の)createdAt / updatedAt + ローカル算出の size という完全な値を返します。ack が返った時点でサーバ由来の値に置き換えます。「サーバメタデータが無いから部分的な値を返す」という状態は作りません — WorkspaceFileMeta の全フィールドが必須なので、実装者が表現できないからです。

  3. UI の並び順キーはワークスペースに永続化されたデータからのみ導出する。 具体的には、番号付きノートはファイル名のスロット番号、名前付きノートは辞書順、archives は frontmatter の semantic time fields またはファイル名由来の日付、それ以外はサーバメタデータ。ハイドレーション時に生成された値をソートキーにしてはならない。 これが restart-stable ordering の定義であり、S4(#4208)の「再起動シミュレーションをまたいでメタデータが安定」というテストが検証する内容です。

messages.list が現在使っている extractDateFromFilename(filename) ?? new Date().toISOString() のフォールバックは、後半が捏造なので上記 3 に違反します。ファイル名から日付が取れない場合はサーバ updatedAt、それも無ければ「不明」として末尾に置く、という決定的な順序にしてください。

version-row invariant — 1 操作 = 1 書き込み

サーバはファイルの PUT ごとに version 行を 1 つ記録します。したがって 1 つのユーザー操作が同じ key を 2 回以上書いてはならず、内容が変わっていない key は 1 回も書いてはならない、というのが Note History と Checkpoints が意味を持つための前提です。

転換前のデスクトップはこれをミラー側の差分で守っていました。notes_tidy_up が Rust 側で番号を振り直したあと、pushAfterTrayRenumber(epic #4176)が事前スナップショットと読み戻しを比較し、実際に内容が動いたスロットだけを push していました。

cloud-primary では workspace-core が唯一の writer なので、この判定は workspace-core の内側に置きます。S9 の実装時点で workspaceNotesTidyUp / workspaceNotesReorder / workspaceInboxTidyUp / workspaceInboxReorder生き残る全スロットを無条件に書き直していたため、すでにコンパクトなトレイで tidy-up を押すだけでトレイの全ノートに version 行が増えるという不整合がありました(web/iOS も同じコードパスなので同じ問題を持っていました)。4 経路すべてを upsertIfChanged(モデルの現在値とバイト一致なら書かない)に通して修正済みです。

さらに durable outbox は、arm セッション内で各 key の最後に成功した upload 内容を baseline として保持し、再保存された内容がバイト一致なら PUT 自体を省略します。個別 writer の upsertIfChanged が漏れても「変更のない key は書かない」という規則を sync 境界で全体に強制する最後のガードです。baseline は delete・remote apply・restore で無効化され、前セッションから復元した queue には引き継がれません。

昇順の 1 パス書き換えでは各スロットが最大 1 回しか書かれないので、比較対象として読むモデルの値は常にそのスロットの変更前の内容になります。回帰ガードは tauri-adapter.push.test.ts の「no-op tidyUp/reorder は 1 バイトも書かない」「移動したスロットだけを 1 回ずつ書く」です。

同じ規則は renderer 側の書き込み面にも適用します。 ボードのコミットパス(tauri-app/renderer/lib/kanban-board-commit.ts、S14 / #4218)は各ペイロードを書く前に現在値を読み、バイト一致なら書きません。プランナは変更のないカードを既に落としているため実質的にマニフェストが対象で、これが無いと「元の位置に戻すだけのドラッグ」がジェスチャごとにマニフェストの version 行を増やします。同じ sub で .kanban-staging/ の二重書き込みパスを撤去しました — ステージング + 本書き込み + 削除で 1 編集あたりの書き込みが 3 倍になっていたうえ、.kanban-staging/ は D4 の namespace 表に無いので notes として全デバイスにゴミが複製されるためです。ステージングが実際に担保していた「本番キーに触れる前にバッチが書けることを証明する」は、全ターゲットキーの normalizeWorkspacePath 事前検証に置き換えました(ワークスペースの書き込みがキー単位で拒否される決定的な経路はこれだけです)。

cursor と model は必ず一緒にシードする

永続 cursor(zudotext.syncCursor.v1.<workspaceId>)はプロセスを越えて生き残りますが、in-memory モデルは生き残りません。転換前はこの非対称が問題になりませんでした — cursor が指す状態はローカルディスクが持っていたからです。cloud-primary では、再起動直後は「cursor は N まで持っていると言っているが、モデルは空」という状態になり、N からの差分 pull は「N 以降に変わったもの」だけを返します。多くの場合それは空で、完全に同期済みのアカウントなのにユーザーには空のワークスペースが見えることになります。

したがって不変条件は「cursor は、それが記述しているモデルと一緒にシードされなければならない」です。実装は 2 段構え:

  • workspace-core が isWorkspaceModelSeeded() を公開します。「モデルが空かどうか」ではなく「権威あるソース(snapshot / ミラーの hydrate / cursor 0 からの drain / テストの seed)がモデルを埋めたか」を表します。空であることで判定してはいけません — 本当に空のワークスペースと未ロードのワークスペースは内容では区別できず、内容で判定すると空のワークスペースを持つユーザーは毎回の sync でフル snapshot を引き直すことになります。フラグは clearWorkspaceModel() でリセットされ、それは bridge の disarm ハンドラが呼ぶので、別ワークスペースへの再 arm では正しく「未シード」に戻ります。

  • 各アダプタの drain は、!isWorkspaceModelSeeded() && getDeviceCursor() > 0 のときに差分 pull の前に snapshot を取ります(tauri-adapter.ts の cold-model guard)。S10 のブートパスが既に hydrate 済みならモデルは seeded なのでスキップされます。

disarm 時には平文モデルを捨てます。 disposeCloudSync() は client と鍵を落としますが、平文コンテンツは別のストアであり、転換後はそこに全ノートが入っています。残したままにすると、サインアウト後のセッションが前のユーザーのノートを供給し続け、次のサインインやワークスペースの再バインドが新しい差分をそのファイル群にマージする(ワークスペースをまたぐコンテンツ混入)ことになります。setWorkspaceDisarmHandlersetWorkspaceUploadAckHandler と同じ一方向の依存パターンで、bridge は不透明なコールバックだけを持ちます。

arm されていない書き込みは、モデルに触れる前に拒否します。 applyLocalUpsert はモデルにコミットしてから outbox に渡すので、ゲートが無いと un-armed 状態の編集が「保存されたように見えて」耐久ステップで失敗し、リロードで消えます(リトライ元も無い)。arm 済みで耐久ステップだけが失敗した場合はモデルの書き込みを意図的に残す(編集はセッション中生き残り、outbox がリトライする)ので、ゲートの条件は readiness であって「enqueue が成功したか」ではありません。

hydrate/subscribe の seam(オフライン対応の受け口)

ワークスペースモデルの hydrate/replay 入口は、snapshot の HTTP 呼び出しに直結させず、iterable + cursor を受け取れる形にします。レコードにはメタデータを必ず含めます

{ encPath: string; ciphertext: ArrayBuffer; version: number; createdAt: string; updatedAt: string }

createdAt / updatedAt を落とすと、ハイドレーション後に metadata() が値を持てず(そして「read 時に now を作らない」規則があるので埋めることもできず)、restart-stable ordering が成立しません。これは #4233 の永続ミラーが差し込む面でもあり、ミラーは ciphertext と一緒にこのメタデータも保存する必要があります。subscribe は前述のとおり local/remote × upsert/delete の 4 通りを emit します。ミラー自体はこの epic では作りませんが、この seam を最初から用意しておけば後でコアを作り直す必要がありません。

D2 — workspace-per-app binding と config.json v2

現状

~/.config/zudotext/<name>/config.json の v1 スキーマはフィールド一つです。

// tauri-app/core/src/generator/app_config.rs:40 write_app_config
let value = json!({
    "workspace": workspace_path.to_string_lossy(),
});

読み手は 3 箇所(resolve_project_root / enumerate_app_configs / list_known_workspaces_from_config_root)+ 逆引き 1 箇所(find_leaf_for_workspace)で、いずれも serde 構造体を持たず serde_json::Value["workspace"].as_str() を見て、失敗時は黙って skip します。

そして起動時のフォールバックが問題です。

// tauri-app/src/lib.rs resolve_project_root()
// ... config.json が読めて / parse できて / workspace キーがあって / パスが存在すれば return
// それ以外は無条件に:
let default_dir = home.join("Documents/zudo-text").join(&app_name);
std::fs::create_dir_all(&default_dir).ok();
default_dir.to_string_lossy().to_string()

自動 scaffold に落ちる経路が 4 通り(読めない / parse できない / キーがない / パスが消えた)あり、戻り値が String なので呼び出し側には区別できません。「binding が無いから onboarding へ」というシグナルを返す経路が存在しません。

決定

config.json v2 のスキーマ:

{
  "workspace": {
    "id": "..."
  }
}

ワークスペースパスは持ちません。アプリインスタンス(ROOT と各 LEAF)はちょうど一つのワークスペースにバインドされるという制約が、このスキーマそのものです。

one-instance-one-workspace ルールの解釈が変わる点を明示します。 転換前の web エディタは、アカウントのワークスペース数が 2 以上なら MultipleWorkspacesError で fail fast していました(#2317)。バインディングが暗黙だった時代は「どのワークスペースを指しているか」を表現する手段がなく、複数所有は本当に手詰まりだったからです。v2 config でインスタンスごとの明示的なバインディングを持つ以上、1 アカウントが複数ワークスペースを所有する状態(text app ごとに 1 ワークスペース)は正常な定常状態であり、fail fast ではなく ワークスペースピッカーで選ばせます。制約は「1 インスタンスが指すワークスペースはちょうど 1 つ」に絞られ、「1 アカウントが持つワークスペースはちょうど 1 つ」ではありません。MultipleWorkspacesError は S10(#4214)で削除済みで、resolveWorkspaceBindinglistAccountWorkspaces + bindWorkspaceId に置き換わっています。ワークスペース数によるガードを再導入しないこと。

起動時の解決規則:

  • v2 config で workspace.id が読めた → そのワークスペースに bound。unlock フローへ。

  • config が無い / 読めない / parse できない / v1 形状(workspace キー) → すべて unbound。onboarding へルーティングする。

  • unbound は決して ~/Documents/zudo-text/<app>/ の自動 scaffold を起こさない。この自動 scaffold は削除する。

v1 config が unbound に落ちるのは意図的です。プレリリース方針(ルート CLAUDE.md)どおりマイグレーションコードは書きません。既存の v1 config を持つ開発機は onboarding を一度通るだけです。

これに伴い、起動時解決の関数シグネチャを変えます。resolve_project_root() -> String は「workspace binding または unbound」を表現できる型(Option/専用 enum)を返すものに置き換えます。4 通りのフォールスルーが呼び出し側から見えることが要件です。

bind-only-after-unlock: binding の永続化は、unlock が成功し snapshot が取れた後にのみ行います。「ワークスペースを作った直後に config を書いたが unlock に失敗して起動不能」という状態を作らないための規則です。S6(#4210)が persist/clear のプリミティブを実装し、S10(#4214)の onboarding がそれを使います。

workspaces.json との関係: 同じディレクトリにある workspaces.json(旧 tauri-app/core/src/workspace_registry.rs)は旧ローカルディレクトリ型ワークスペースのレジストリであり、その概念と一緒に退役済みです(S19 / #4223 で削除)。config.json はジェネレータだけが書き、ワークスペース切り替えでは書き換わらない、という現在のライフサイクルの違いも、ワークスペース切り替え自体が消えることで解消します。

workspace(現 bridge.localDir、epic #4991 Wave 1 で改名)bridge namespace の縮小(S9 で実施済み): レジストリ/スイッチャのメソッド — listAll / listKnownWorkspaces / reorder / register / remove / updateMetadata / switchTo / addExisting / setDir / scaffold — は types.ts と 3 アダプタすべてから削除されました。クラウドワークスペースに向けて再実装したのではなく、消えたのだという点が重要です(1 インスタンス 1 ワークスペースなので列挙も切り替えも対象がない)。残っているのは絶対パスを喋る呼び出し側のための読み取り 2 つだけです。

生存メソッド転換後の実装
getDir()WORKSPACE_SYNTHETIC_ROOT を返す(実ディレクトリではない合成値)
listFiles(path, filter?)合成ルート配下のワークスペースモデルを列挙(filter の契約は不変)

どちらもトランジショナルです — D4 のとおり workspace-relative パスが唯一のアドレッシングになり、残る workspace コンシューマー(ボード、diff など)は Wave 4 で workspaceFiles に移ります。

D3 — settings の三分割

AppSettings は現在、性質のまったく違う 3 種類のデータが 1 つのオブジェクトに同居しています。デスクトップではマシンローカルな .zudotext.settings.json に、web では localStorage(zudotext.webSettings.v1)に丸ごと入り、web 側は stripConnectionConfig() で接続系フィールドを読み書き両方で潰すという回避策が入っています。

決定 — 3 レイヤに分ける

レイヤ置き場所内容同期
local bootstrap identityconfig.json v2 + key storeワークスペース id、サーバ/認証 env、device id、鍵の参照されない(デバイス固有)
synced preferencesワークスペースドキュメント .zudotext.settings.jsonユーザー設定(color、editor、shortcuts、frameset、pins、boards …)される(ワークスペース key の一つ)
transient stateメモリ + zudotext.localState.v1 相当activeDraft / draftCount、subscription のランタイム状態、派生値されない

general.projectRoot はスキーマから削除しますpackages/app-defaults)。ワークスペースルートという概念自体が無くなるので、これを identity/scope 入力として使っている箇所(例: useSkillRegistryLoader({ workspaceRoot }))はワークスペース identity に置き換えます。同様に sync.cloudWorkspaceId は synced preferences ではなく local bootstrap identity 側の値であり、web が stripConnectionConfig() で潰していた回避策は不要になります(canonical なワークスペース id ストアは sync-auth-state.workspaceId — D8)。

.zudotext.settings.json はワークスペースドキュメントになる

これは現在の挙動からの明確な変更です。今は .zudotext.settings.json意図的に同期されていませんcloud-genesis.ts の scaffold にも含まれず、REST アダプタはワークスペースモードで settings.getnull を返します)。理由はデバイス絶対パスの general.projectRoot を含んでいたからで、その理由が上の削除で消えます。

LWW の注意(v1 で受容する): 設定はドキュメント単位で丸ごと push されるため、2 デバイスが同時に別のセクションを編集すると、後に書いた側のドキュメント全体が勝ち、もう一方の編集は失われます。セクション単位のマージやフィールド単位の CRDT は v1 では実装しません。この制約はコードコメントにこのページへの参照付きで明記してください。実用上の緩和は「設定ダイアログを開いている間だけ書く」という現在の書き込み頻度の低さです。

デバイス固有の見た目が必要な場合の逃げ道はすでにあります: deviceOverrides: Record<string, DeviceOverride>OverridableSettings(general / color / editor / vim / shortcuts / moveButtons / quickActions / ios)の deep-partial 上書きを持ちます。同期される base + デバイスごとの上書き、という構造は維持されます。

SyncOutbox の再スコープ

// packages/cloud-sync/src/outbox.ts:47
export interface OutboxScope {
  workspaceRoot: string;   // web では "" 固定
  workspaceId: string;
}
// :136
export function outboxStorageKey(scope: OutboxScope): string {
  return `${OUTBOX_STORAGE_PREFIX}${scope.workspaceId}::${scope.workspaceRoot}`;
}

workspaceRoot は web では常に空文字なので、複合キーは実質デスクトップでしか判別に使われていません。ワークスペース概念が消えるので、スコープはワークスペース identity のみにします。cursor の永続キー(zudotext.syncCursor.v1.<workspaceId>)はすでに workspaceId 単独なので、これに揃う形です。

SyncOutbox の他の性質は変えません — 平文パスをキーにしたコアレッシング、upsert と delete tombstone の両方を seal してから localStorage へ(平文パスも本文も at-rest には出ない)、10 秒の trailing quiet window(連続編集でも最長 60 秒まで deferral、設定値には 1 秒の safety floor)、指数バックオフ、remove(path) の removal barrier による anti-resurrection、dispose() が永続エントリを残すこと。これらはオフライン姿勢(D7)の基盤です。削除も upsert と同じ durable queue を通るため、削除の再起動後の再送も現行契約で保証します。

D4 — workspace-relative addressing

key の規則

ワークスペース key はワークスペースルート相対の POSIX パスです。packages/backend-bridge/src/workspace-path-model.ts が唯一の権威です。

  • 先頭・末尾のスラッシュなし、.. セグメントなし、区切りは / のみ。

  • 大文字小文字を区別する。

  • NFC 正規化しない。 macOS は NFD を出しますが、正規化するとデバイス間でキーが一致しなくなるため、バイト列を保存します(workspace-path-model.ts の冒頭コメント)。

  • normalizeWorkspacePath()// の畳み込み、trim、先頭 / の除去、. と空セグメントの除去を行い、.. があれば throw します。

一物理ファイルに一ワークスペース key が不変条件です(#4178)。workspaceMessageKey(f)workspaceNoteKey("archives", f) はバイト単位で一致します — messages 面が裸のファイル名を喋るだけで、キーは常に archives/ を含みます。

namespace

classifyWorkspacePath() の判定順(先に一致したものが勝つ):

判定namespace同期
.zudotext.settings.json(ルート直下)settingsされる(D3 で変更)
/ を含まないroot-fileされる
第 1 セグメント .zudotextdot-zudotextされる(frontmatter schema、skills など)
第 1 セグメント .trashtrashされる(本 D4 で変更)
第 1 セグメント pinspinsされる
archives/ の直下の .mdindex 始まりを除く)messagesされる
その他notesされる

archives/2024/x.md は直下ではないので messages ではなく notes です。これは意図的な区別です。

.trash/ の除外を撤廃する

転換前、.trash/ はクラウドに書かれませんでした。ガードはパスモデルではなく push のチョークポイントにありました。

// packages/backend-bridge/src/cloud-sync-bridge.ts(S9 で削除済み)
export function isLocalOnlyTrashPath(path: string): boolean { /* ".trash" / ".trash/" / "/.trash/" */ }
//   recordLocalUpsert:        if (isLocalOnlyTrashPath(path)) return false;
//   recordLocalDelete:        if (isLocalOnlyTrashPath(path)) return false;
//   recordLocalDeleteDurable: if (isLocalOnlyTrashPath(path)) return true;   // 成功を偽装

ローカルファイルが真実だった時代はこれで良かった(ゴミ箱はローカルの都合)のですが、cloud-primary では .trash/ に入れた瞬間にコンテンツが消えることになります。したがって 3 箇所のガードを削除し、.trash/ を通常のワークスペース namespace として同期します。ゴミ箱が再起動とデバイス切り替えを越えて生き残ることが、この変更の目的です。

状態: 実装済み(S9 / #4213)。ヘルパーとガード 3 箇所は削除され、isLocalOnlyTrashPath は存在しません。反転は両方向から固定されています —cloud-sync-bridge.test.ts が「トラッシュパスは特別扱いされない」ことを、cloud-sync-bridge.outbox.test.ts が「トラッシュの本文が正規の暗号化パスで実際にアップロードされる」ことを検証します。Kanban のゴミ箱も S14 / #4218 でこれに乗り、.trash/ 直下の workspace-relative キーになりました(lib/kanban-trash.ts)。

削除後に注意すべき副作用: 現在 .trash/ キーは in-memory モデルには入るのに push されない、という非対称があり、読み出し側(workspace.listFiles / files.readDir)は namespace でフィルタしていません。同期されるようになる以上、ユーザーに見せる一覧が .trash/ を含まないことは呼び出し側で明示的に担保してください。

containment チェックが safe_path を置き換える

Rust 側の safe_path によるトラバーサル検査(ワークスペースルート配下かの確認)は、ワークスペースルートが無くなるので機能しなくなります。代替は workspace key containment です。

  • すべての書き込み・読み出しは normalizeWorkspacePath() を通す。.. は throw。

  • 絶対パス由来の入力は toWorkspaceRelative() を通し、null(= 合成ルート配下でない)なら throw。現在の files.readText / writeText / deleteFile が投げている path is not under the workspace root: <path> がこの形です。

  • このチェックは ワークスペースコアの内側に置きます(S4)。各機能面がそれぞれ検査する形にはしません。

  • サーバから来た復号済みパスは validateDecryptedPath()#4182、fail-closed)を必ず通します。AEAD の復号成功は真正性を証明しますが構造的な安全性は証明しないため、構造的に使う前に再検証が必要です。加えて canonical 形であることnormalizeWorkspacePath() の出力とバイト一致)も要求し、一致しなければ黙って正規化せずそのレコードを quarantine します"a//b.md""a/./b.md" を受け入れると一つの物理ファイルが二つ目のワークスペース key を持ち、#4178 の「一物理ファイルに一ワークスペース key」が壊れる(version 履歴も分岐する)ためです。

合成ルートの扱い

REST アダプタはワークスペースモードで getProjectRoot()WORKSPACE_SYNTHETIC_ROOT"/__workspace__")に、getHomeDir()WORKSPACE_SYNTHETIC_HOME に偽装しています。これは絶対パス前提の既存呼び出し側を生かすためのシムです。転換後の方針は明確です。

  • workspaceFiles を使う新しい呼び出し側は、合成ルートを一切見ない。workspace-relative パスだけを扱う。

    S9 時点の実測(Wave 3 レビュー後に更新)

    デスクトップ側の bridge.files は S9 でディスクミラーを失いました。ただし S9 直後のレビューで、絶対パス前提の既存呼び出し側(frontmatter スキーマ、ボード読み取り、skills ローダ)が合成ルート配下のパスを files.* に渡し続けていることが判明したため、合成ルート配下のパスだけをワークスペースコアへ回すトランジショナルなルーティングをデスクトップアダプタにも追加してあります(isWorkspaceAbsolutePath ゲート)。ローカル実パス(EFE)は従来どおり実ファイルシステムに行きます。REST アダプタの files.* のワークスペース分岐と同じ理由・同じ撤退点です — Wave 4 の各機能 sub が呼び出し側を workspaceFiles に移し終えた時点で両アダプタから撤退します。workspace namespace は S9 で getDir / listFiles の 2 つだけに縮小済みです(下記 D2 参照)。

  • 合成ルートは撤退するトランジショナルなシムである。 最終状態では bridge.files にワークスペースモードの分岐は 1 つも残らず(D1 / D5 のとおり local-only)、getProjectRoot() / getHomeDir() も合成値を返しません。撤退点は明示されています: bridge.files のワークスペース分岐(readText / writeText / deleteFile / mkdir / readDir)は S9(#4213)でデスクトップのディスクミラーと一緒に消え、それらを絶対パスで呼んでいた最後の利用者は Wave 4 の各機能 sub(ボード、diff、pins、Note Tray)が workspaceFiles に移し替えます。toWorkspaceRelative / toAbsolute はその移行期間中の境界変換としてのみ使い、移行完了後は EFE のローカル絶対パスに触れる用途では使いません(そちらはワークスペースの外なので変換対象ではない)。

  • getProjectRoot() を identity として使っている箇所は D8 でワークスペース id に置き換える。合成ルートを identity として使ってはならない — 全ワークスペースで同じ値になるためです。

プロバイダのパススキーマは 2 系統をはっきり分けます(S7 / #4211)。core.kanban-board.initialBoardPath / core.todo-board.initialBoardPath / core.mindmap-board.initialBoardPath は workspace-relative パス。core.external-file-editoropenPaths / activePath / treeRoot と pin の initialFiles / initialTreeRootローカル絶対パスのまま(ローカルブラウザなので)。validate-settings.ts のバリデータはこの二系統を明示的に区別します。

D5 — EFE が唯一のローカル面

External File Editor(core.external-file-editor)は、ワークスペースの外にあるローカルファイルを開くための面として残ります。これは:

  • bridge.files(絶対パス)、ネイティブダイアログ、Spotlight(bridge.fileSearch、macOS 限定)を使い続ける。

  • EFE のコンテンツはワークスペースに push しない。 ユーザーが他のリポジトリの .md を開いて編集したものが、勝手に自分のワークスペースに複製されてはいけません。

  • ローカルファイル監視(files.watchExternal / onExternalChange)も EFE のために残る。

  • ディレクトリツリーは EFE フレーム内の折りたたみ pane として残る。 standalone Directory View provider、cross-frame open event、toolbar panel button は廃止され、行クリックは同じ EFE session の loadFile(path) を呼ぶ。

この線引きが D1 の「bridge.files は local-only」の実質的な意味です。二つの namespace が別のストレージを指していることを、実装時に取り違えないでください。

旧 Directory View の pin/leaf state は pre-release policy により disposable です。migration/compatibility reader は持たず、opaque な旧 pin は provider-not-found になり得るため、必要ならユーザーが削除または settings を reset します。ファイル本文を削除する意味ではありません。 directoryView.lastSidebarWidthPx という setting 名だけは EFE 内蔵 tree の global 幅として残ります。

D6 — terminal / PTY / activeFile / message-workspace の同時退役

以下は互いに依存しているので、まとめて消します。

  • ターミナルフレーム core.terminal — renderer のペイン、xterm.js の配線、settings セクション、app-defaultsterminal スキーマ、コマンドパレットエントリ、ショートカット(S2 / #4206、S5 / #4209)。

  • Rust PTY バックエンドcommands/terminal.rsportable-pty 依存、AppState の PTY セッション。

  • activeFile ポインタbridge.activeFile(3 アダプタ全部)、use-active-file-pointer.ts、Rust の commands/active_file.rs + core/src/active_file.rs、Active File Pointer のドキュメント、対応する e2e spec。

activeFile を一緒に消す理由は明確です — 唯一の消費者がターミナルの PTY 環境変数注入だったからです。ターミナルが無くなればポインタを読む者がいません。加えて、この API は set / get / clear のすべてが getWorkspaceRoot() を必須引数として渡す設計で(<workspace>/.zudotext/state/active-file.json を解決するため)、ワークスペース概念そのものに依存しています。

状態: 実装済み(S13 / #4217)。bridge.activeFile と両 Rust コマンドは 3 アダプタ全部から削除され、use-active-file-pointer.ts とドキュメント(Active File Pointer ページ、.claude/commands/l-active.md テンプレート)も削除済み。use-active-workspace-info.ts は他の 9 箇所の消費者(projectRoot/appName 解決)が使い続けるため残っている — activeFile 専用ではなかった。

  • message-workspace/ — dev リポジトリ用の Claude Code ライティングリソース(コマンド・エージェント・スキル)。ワークスペース概念と一緒に退役します(S27 / #4231)。状態: 実装済み。ディレクトリは退役の経緯を示す tombstone README のみを残し、コマンド・エージェント・スキル・CLAUDE.md は削除済み。リポジトリルートの archives/ ディレクトリも唯一の消費者が message-workspace だったため合わせて削除。

D7 — オフライン姿勢(v1 で受容 → epic #4808 で解消)

このセクションはもともと v1 の姿勢を正直に書いた記録でした — 「オフラインで動く」と一括りにできる状態ではなく、成立する保証と成立しない保証を分けて書いていました。epic #4808(Offline Workspace Mirror、260810 sweep)が当時の残存限界をまとめて解消したので、以下は現在の姿勢です。

成立するもの(現在):

  • upsert(コンテンツ保存)はプロセス再起動をまたいで耐久性がある。 SyncOutbox が seal した状態で localStorage に永続化し、バックオフ付きで再送します。ネットワークが無くても保存操作は成功します。

  • 削除もプロセス再起動をまたいで耐久性がある。 delete tombstone は upsert と同じ durable な SyncOutbox queue に seal された状態で localStorage に永続化され、バックオフ付きで再送されます。

  • 起動後の読み出しはオフラインで完結する。 モデルがシードされてさえいれば、その後の読み出しにネットワークは要りません(epic #4808 以降は snapshot に限らず、ミラーからのシードでも同じ保証が成立します)。

  • デスクトップの cold boot はネットワーク不要で成立する(v1 の限界を解消)。 ローカルの暗号化ワークスペースミラー(IndexedDB、ciphertext-at-rest、per-workspace)があれば、AEAD probe による fail-closed アームでモデルをミラーからシードし、そのままオフラインで読み書きできます。ネットワークがあれば mirror hydrate の直後に cursor デルタのみを pull し、snapshot の全体ウォークは発生しません。

  • オフラインで新規作成・削除したファイルは、再起動後も UI から見え続ける(v1 の限界を解消)。 write-path subscriber がローカル書き込みのたびに暗号化してミラーへコミットするため、次の cold boot はミラーからシードでき、outbox がサーバへ再送し終わるのを待つ必要がありません。

残る既知の限界(受容済み):

  • ミラーがまだ存在しない、または使えない状態でのオフライン cold boot は、今まで通り snapshot 経由 = ネットワーク必須です。 初回 boot(一度もオンラインで起動していない)、ミラーの破損(AEAD probe 失敗)、eviction 直後(サインアウト直後の再サインイン前)がこれに当たります。この場合は "ワークスペースを開けない" ままで、accepted residual として明記します。

削除の耐久性については、もともと転換前から存在していた #4237 の問題は、このepic で解決されました。delete tombstone が durable SyncOutbox queue に seal されることで、オフライン削除とそれに続く再起動でも tombstone が失われなくなります。

ミラーのアーキテクチャと実装は Encrypted Local Workspace Mirror が canonical です(スキーマ・cursor 原子性契約・write-path subscriber・cold-boot hydrate・オフライン fail-closed アーム・eviction・再接続時の conflict ルール)。ソース issue #4233(暗号化ローカルワークスペースミラー)は epic #4808 のサブとして実装され、closed 済みです。

オフライン空状態(offline-boot-screen.tsx、旧 ios-offline-screen.tsx)は、S12(#4216)で一度この姿勢に合わせて再点検されたのち、epic #4808 のオフライン UX サブでプラットフォーム非依存のゲートへ一般化されました — オフライン + 使えるミラー無しならブロッキング画面、オフライン + ミラーからアーム済みならそのまま <App> に進み「オフラインで作業中」インジケータを表示します。

D8 — workspace id を単一の identity にする

canonical なワークスペース id ストアは既に決まっています。

// packages/backend-bridge/src/sync-auth-state.ts:34
export function getWorkspaceId(): string

歴史的に 3 つのストアがあり(settings.sync.cloudWorkspaceId / sync-auth-state.workspaceId / cloud-sync-bridge.storedWorkspaceId)、canonical は 2 番目です。転換で全消費者をこれに寄せます。

消費者現在の identity転換後
deep link zudotext://open?workspace=ワークスペース絶対パスの文字列一致(use-deep-link-routing.tsworkspace id
AI chat の会話ストレージキーworkspace id(${workspaceId ?? "local"}:${conversationId}workspace id("local" フォールバックを削除)
push wakeup の arming キーworkspace id(${serverUrl}::${workspaceId}変更なし
SyncContextブラウザは bridge 真実、デスクトップは settings を見る二重経路bridge 真実に統一
note history / checkpointsworkspace-relative パス + armed client の暗黙のワークスペース変更なし
activeFile ポインタワークスペース絶対パスを必須引数削除(D6)
general.projectRoot を scope 入力に使う箇所ワークスペース絶対パスworkspace id(D3 でフィールド自体を削除)

deep link は汎用の <scheme>://open ルートとしてアプリ側で解釈され、ワークスペース id と相対パスを使って対象を開きます。具体的な URL 形式とパラメータは Deep-Link Scheme Protocol を参照してください。

合成ルート "/__workspace__" を identity にしてはいけません。 全ワークスペースで同じ値になるので、リンクの宛先も deep link のマッチも壊れます。現在 REST アダプタのワークスペースモードでは resolveWorkspaceId() がまさにこれを返しており、これが D8 が必要な理由の実例です。

D9 — BM25 を Rust から TS へ移植する

転換前、類似ノート検索は Rust 実装(zudotext_core::similarityindex.rs + tokenizer.rs)で、ローカルワークスペースの Note Tray ディレクトリをコーパスにしていました。REST アダプタはワークスペースモードで 明示的に非対応でした(isSupported: () => !isCloudWorkspaceReady()query は空結果)。ワークスペースが無くなればコーパスも無くなります。

決定: BM25 を TS に移植し、ワークスペースモデルの上で動かす。

移植対象の仕様は Rust 側に完全に閉じているので、そのまま持ってこられます。

  • ハイパーパラメータ K1 = 1.5B = 0.75

  • IDF は Robertson/Sparck-Jones の平滑化形 ((n - nqi + 0.5) / (nqi + 0.5) + 1.0).ln()

  • tf_norm = (tf * (K1 + 1)) / (tf + K1 * (1 - B + B * doc_len / avg_doc_len))avg_doc_len.max(1.0) でガード。

  • score > 0 のみ採用、score 降順 → パス昇順で決定的にタイブレーク、top_k で切る。

  • スニペットは最良一致近傍の約 150 文字窓。

  • query_countedunfiltered_result_count を記録 → キーワードによる部分文字列 retain → filtered_result_count を記録 → 切り詰め。

トークナイザが移植の核心です。 日本語の分割と stopword がここに入っており、id() メソッドはトークナイザが変わったときにキャッシュ済みインデックスを無効化するために存在します。移植時はこの id の役割を保ってください。

移植先の面は狭く、seam も既にあります。renderer の消費者は 1 箇所(components/similar-notes-finder/use-similar-notes-finder.tsbackend オプション、デフォルト getBackend().similarDocs)で、コントローラが必要としているのは isSupported / query / getContent の 3 つだけです(writeContent / rebuildIndex に renderer の消費者はいません)。参照実装としては mock アダプタの JS BM25 スコアラmock-adapter.ts:2323)が最も近いです。DTO(SimilarDocResult / SimilarDocsQuery / SimilarDocsQueryResponse)と共有正規化(normalizeSimilarDocsQuery、空スコープで throw、limit を 1–50 にクランプ)はそのまま維持します。

実装済み(S21 / #4225)。 エンジンは packages/backend-bridge/src/similarity/tokenizer.ts + bm25-index.ts)、ワークスペースコーパスと無効化は packages/backend-bridge/src/workspace-core/similarity.ts にあります。Rust 側(commands/similarity.rs、core の similarity / similar_docs / archives_cacheAppState.similarity_index/api/similar-docs/*)は削除しました。トークナイザは lindera + IPADIC ではなく Intl.Segmenter(辞書同梱なしでブラウザバンドルに載る)を使うため、tokenizer.rsid() に相当するキャッシュ世代は不要になりました — インデックスは永続化されず、ワークスペースの変更イベント(subscribeWorkspaceFiles)とモデル置換で捨てられるだけだからです。POS タグが取れないぶんの差分(stopword を短いかな列で代替、辞書形への正規化なし)は tokenizer.ts に明記してあります。

D10 — assets をワークスペースモードに乗せる

解決済み(S22 / #4226、E2EE Assets / #5303)。 TauriAdapter / RestAdapter は cloud workspace が armed のとき、共有 workspace-core/assets.ts 実装へ分岐します。renderer に見える bridge.assets.*list / saveFile / importFile / readFile / deleteFile)の平文契約は変えず、共有コアがネットワーク境界でファイル名とバイト列を暗号化・復号します。

決定: assets は workspace file namespace へ入れず、独立した暗号化面のままにします。sync-server は (workspace_id, encrypted_filename) からランダムな upload ごとの R2 key(assets-e2ee/{workspaceId}/{random-object-id})への対応、暗号化 envelope のサイズ、upload 時刻だけを保存します。共有コアは平文 25 MiB 上限を upload 前に守り、server は固定 61-byte envelope を加えた上限を守ります。hasActiveSub write gate は upload と delete の両方に実装済みです(以前の「唯一 gate がない write surface」という記述は #4693 以後 stale でした)。

importFile(sourcePath) はプラットフォーム分割します。 サーバはローカルパスを読めません(POST /api/assets/import は常に 400 を返す)。したがって Tauri ではローカルバイナリをローカル files 面で読んでアップロードし、web では capability gate で off にします。UI 側(ファイル添付ボタン、ドロップ、ペースト)の挙動は変えません。

E2EE 契約: filename は path cipher の deterministic AES-256-GCM(HMAC-derived 96-bit IV)、バイト列は random IV の AES-256-GCM + HMAC で暗号化します。server は平文 filename、bytes、MIME を受け取りません。MIME は元から server 必須ではなく、client の asset-kind.ts が復号した拡張子から Rust 実装より広い拡張子集合を使って導出します。

ただし traffic analysis までは隠しません。同一 workspace 内の filename equality と filename の UTF-8 byte length、平文 byte size + 固定 overhead、upload timing、list/read/delete access pattern が漏れます。deterministic 96-bit IV は namespace が極端に大きい場合の理論的 nonce collision caveat も引き継ぎます。assets を平行面に保つ理由は、outbox に binary arm がないこと、最大 25 MiB blob を IndexedDB mirror に入れないこと、全 device が全 asset を pull する設計にしないことです。base64 transport は bridge 互換性のため残し、binary/streaming 化を follow-up とします。

D11 — 課金と developer allowlist

(ユーザー決定、2026-07-29) 一般的な無料 tier は設けません。サブスクリプションゲート(自動 30 日トライアル付き)は全ユーザーに適用したままです。

そのうえで、allowlist された開発者アカウントには恒久アクセスを与えますADMIN_SUBS / ADMIN_EMAILS のパターンをそのまま写した DEV_ACCOUNT_SUBS(primary、better_auth|<user.id>)+ DEV_ACCOUNT_EMAILS(dev-login のフォールバック)を hasActiveSub() の内部で判定します。初期 allowlist はオーナーの Better Auth アカウントです。

写すべき非対称を明示します(admin-middleware.ts の実装がお手本)。

  • subs は大文字小文字を区別し、trim のみ。Better Auth の canonical subject は better_auth|<user.id> です。

  • emails は trim + lowercase し、authMethod === "dev" のときだけ有効。本番の service JWT は検証済み sub で認可し、email で allowlist を通してはいけません。

  • 両方空なら「未設定」として扱う(admin-middleware は明示的に 403 を返します)。

実装上の注意: hasActiveSub(db, userId) は現在 c.envUserPayload も受け取っていないため(user.sub / user.email / user.authMethod にアクセスできない)、シグネチャの拡張が必要です。workspace-access.ts に env 由来の allowlist が入るのはこれが最初のケースです。実装は S3(#4207)。

参考として現在のトライアル境界: subscription_status ∈ { active, past_due } は通過、trialtrial_end_date + TRIAL_GRACE_MS(3 日)まで通過するので実効 33 日。GET /api/v1/subscriptionisTrialPastGrace"expired" を表示しつつ hasActiveSub はまだ通す、という意図的な二つの読み方が併存しています。

D12 — thin-launcher のバンドル機構は触らない

ROOT/LEAF の thin-launcher の仕組み(Thin Launcher Contract)は変更しません

  • assembleChild のスタブクローン + Info.plist の identity 書き換え + ad-hoc codesign + quarantine 除去。

  • stamp-leaf.rs のヘッドレス CLI。

  • .app の stem からのランタイム role 判定(resolve_exe_app_name() == "zudotext" なら ROOT)。

  • Contents/Resources/core-path サイドカーによる共有コアの参照。

変わるのは 「アプリインスタンスが何を指しているか」の半分だけです — ワークスペースディレクトリではなく workspace id を指す(D2)。生成フローの settings ステップとワークスペース scaffold 部分は D2/D3 に合わせて作り直しますが(S23 / #4227、S24 / #4228)、バンドルの物理的な組み立ては無変更です。

D13 — popout ウィンドウのワークスペースハイドレーション

これは見落としやすい罠なので明示的に決めます。

popout は別の WKWebView = 別の JS realm です。 したがってモジュールスコープのシングルトンは全部空の状態で始まります。bootstrap/tauri.tsx は popout でも initBackend(createTauriAdapter()) と設定ロードを走らせますが、<App /> は通りません — SyncProvider も useSyncBootstrapinitCloudSync も走りません。結果、popout の中では workspaceFiles のモデルは空で、鍵も armed でありません。

現在これが問題にならないのは、popout のコンテンツ取得経路が「絶対パス → files.readText」だからです。EFE の popout は props で { openPaths, activePath }(identity のみ、コンテンツは運ばない)を受け取り、自分の session cache がディスクから読みます。cloud-primary ではこの経路が消えます。

決定: 二つの選択肢のうち簡単な方を選び、コードにその判断を記録する。

  1. 鍵を共有キーストア(workspace-key-store.ts)から arm し、popout ごとに必要なワークスペース状態をハイドレートする。 独立性は高いが、ウィンドウごとに snapshot/pull のコストがかかる。

  2. ホストウィンドウのバスを経由して読み書きをプロキシする。 既存の relay 機構に乗る。

既存の cross-window 機構がそのまま使えることを踏まえてください。バスは Tauri の broadcast イベントで、frame_popout_emit_event が唯一の relay で、許可されたイベント名は core/src/popout_relay.rsALLOWED_RELAY_EVENTS(9 個)に固定され、ペイロードは 32 KB 上限です。ホスト → popout の状態複製のパターンは既にあります: use-popout-sync-host.ts(ホスト側、SyncProvider から呼ばれ、スナップショットを broadcast し、frame:popout-sync-state-request に応答し、popout の代わりに auth.login() を実行する)↔ contexts/popout-sync-context.tsx(popout 側、RETRY_DELAYS_MS = [0, 300, 1000, 3000, 10_000] のバックオフ付き。Tauri の listen() の登録が非同期なため)。

新しい relay イベント名が必要なら ALLOWED_RELAY_EVENTS に追加します(allowlist 方式なので追加なしでは通りません)。32 KB のペイロード上限は、ノート本文をイベントで運ぶ設計の制約になります — 選択肢 2 を採るならチャンク化か、識別子だけ運んで popout 側が読む形が必要です。

既存の popout 独立性の契約(共有インメモリ状態を持たない、last-writer-wins は inbox-provider.tsx に文書化済み)は維持します。実装は S18(#4222)。

決定(S18 で確定): 選択肢 1 — ウィンドウごとに arm + ハイドレート

選択肢 2(ホスト経由のプロキシ)は採りません。 32 KB の relay 上限にノート本文が収まらないため、チャンク化 RPC と ALLOWED_RELAY_EVENTS の追加、request/response の相関付けが必要になります。選択肢 1 は既存の popout 独立性の契約(popout は自分でコンテンツを読む)をそのまま維持でき、D13 が挙げるコスト(popout ごとに 1 回の snapshot)は起動時に毎回払っているコストと同じです。

実装は tauri-app/renderer/lib/popout-workspace.ts(arm シーケンス)と components/popout-workspace-gate.tsx(React ゲート)。シーケンスはホストの起動と同一で、bridge.appBinding.read()bindWorkspaceIdtryRearmFromStore です。ワークスペースを必要としない provider は EFE / core.empty のみ(D5)で、それ以外は既定でゲートされます — 後から追加される provider を自動的に保護するためです。

realm は別だが永続ストアは共有 — secondary window の禁止事項

これが選択肢 1 の唯一の実装上の罠です。Tauri のウィンドウは WKWebView のストレージを共有し、macOS login Keychain のアイテムも同じ app identity の全ウィンドウから共有されるので、workspace id だけで keyed された状態はホストと奪い合いになります。 該当するのは 4 つで、popout は setSecondaryWindowScope(windowLabel)packages/backend-bridge/src/cloud-sync-bridge.ts)で最初の 3 つを、tryRearmFromStorepreserveStoredKeyOnFailure で 4 つ目を回避します。

共有状態popout が無防備に arm すると対策
outbox キュー(zudotext.syncOutbox.v1.<workspaceId>SyncOutbox は enqueue ごとにインメモリの全エントリマップを書き出すため、2 つのウィンドウの persist が互いの行を消し合う。アップロード自体はメモリから走るが、クラッシュ耐性という耐久性の保証が両方で静かに失われるOutboxScope.windowId を付けて popout 専用キーにする。サーバ側は last-writer-wins で解決
device id(X-Device-Idchanged_by_devicedevice id は自著マーカーです。collapseRawRows は「最新の変更が自分の device id なら、そのコンテンツはローカルにもう持っている」と判断して行を捨てます。ウィンドウごとにワークスペースモデルが別なのでこの前提が崩れ、ホストは popout の編集を self-echo として捨てて適用せず、あとで古い状態で上書きしますホストの device id にウィンドウラベルを足す(device-1.popout-frame-7)。frame ごとに安定なので device 行が無限に増えることはない
pull cursor(zudotext.syncCursor.v1.<workspaceId>popout の snapshot head が共有キーに書かれ、ホストの次回起動が「未適用の変更をもう見た」と誤認するpopout は cursor を一切 persist しない(インメモリのみ)
永続マスターキー(workspace-key-store.ts。macOS desktop は login Keychain、iOS / web は localStoragearm 失敗時に clearWorkspaceKey(workspaceId) が走ると、正常に動いているホストの次回起動を自分のワークスペースからサインアウトさせる。しかも popout には unlock 画面がない。キーは workspace id ごとに分かれている(#4609)が、popout は常にホストと同じ workspace id で arm するため、保存メカニズムにかかわらずこのスロット衝突は解消されないpreserveStoredKeyOnFailure: true。キーを捨てる判断はホストの起動経路だけが行う

popout 専用キューの引き取り

popout 専用の outbox キーを持たせると、popout が debounce 中/リトライ中(たとえばオフライン)に閉じたときにそのキーが取り残されます。 ホストも通常のコールドスタートも素の <prefix><workspaceId> しか開かず、hasPersistedOutboxEntries は意図的に window 付きキーを無視するので、そのままではその編集は「同じ frame をもう一度 pop out したときだけアップロードされる」状態になり、その間にホストが上書きできます — 静かなデータロスです。

対策は adoptOrphanedWindowQueues(workspaceId, storage)packages/cloud-sync/src/outbox.ts)で、ホストの SyncOutbox を構築する直前に走ります。行は sealed で path が見えず(per-path マージにはワークスペース codec が必要で、この層は持っていません)、閉じたウィンドウの古い編集で生きている編集を潰すのは避けたいので、ホスト側のキーが存在しないときだけ引き取ります。複数の orphan がある場合は enqueuedAt 順に並べて seq を振り直します(seq はインスタンスごとのカウンタでウィンドウ間では意味を持たず、ローダーは seq だけで並べて path ごとに最後の行を採用するため)。ストレージの書き込み順は ホストキーの setItem が成功してから orphan キーを削除 です(逆順だと quota で setItem が落ちたときにキューの唯一のコピーが消えます)。

引き取りが arm 経路だけだと、ホストが arm 済みのまま走っている最中に popout が閉じた場合、そのキーは次の arm / 次回起動まで誰も読みません。その間にホストが同じ path を保存していると、遅れて再生された popout の行がサーバの新しい内容を古い内容で上書きします(last-write-wins の逆転)。そこで 2 つ手を入れています。

  • その場で引き取る: SyncOutbox.absorbOrphanedWindowQueues(windowIds)(上記モジュール関数に委譲したうえで、ホストキーを読み直して生きているキューにマージする)を、ホスト側が frame:popout-closed を受けた時点で呼びます(adoptOrphanedOutboxQueues([windowLabel])usePopoutSyncHost)。ホストのキューが空でないときは要求だけ覚えておき、drain した flush パスの最後で自動的に再試行します(「ホストキーが存在しないときだけ」という制約はそのまま)。

  • 閉じたウィンドウだけを対象にする: popout が複数開いているとき、window 付きキーを無条件に全部さらうとまだ開いている popout のキューまで奪って削除してしまい、そのウィンドウとホストが同じ行の別スナップショットを上げ合います。そのため mid-session の経路は閉じたウィンドウのラベルだけを渡します(windowIds なしの無指定形は arm 経路専用 — 起動してきたホストから見える window 付きキーは前セッションのものだけです)。

  • 古い行は捨てる: SyncOutbox は path ごとに「最後にアップロードした内容の enqueuedAt」を覚えていて、引き取った行がそれ以前のスナップショットなら upload せず破棄します。両方の時刻は同一マシンの Date.now() なので直接比較できます。プロセスをまたいだアップロードは記録されないので、これが守るのは今回開いたセッション内の窓だけです。

  • 引き取った行は「元のウィンドウが書いた/消した」ままアップロードする: delete も upsert も同じ扱いです(#4404 で upsert 側も delete 側の配管に揃えました)。path を書き換えた/モデルから消したのは popout だけで、ホストはまだ古い内容のファイルを持っているからです。ホスト自身の device id でアップロードすると、次の pull でその change-row を collapseRawRows が self-authored と見なして捨て、popout の内容がホストのモデルに反映されず、次のホスト側保存でサーバ上の古い内容が復活します。そこで引き取り時に orphan キーのサフィックス(= window ラベル)を行に記録 し(PersistedRow.originWindowId。sealed の外側 — 引き取り層は鍵を持たずキー移動しかできず、復号できる頃にはキーが消えているため)、アップロード時に PUT / DELETEX-Device-Id をその popout の id(<deviceId>.<label>windowScopedDeviceId の逆)に戻します。こうするとホストから見て他デバイス発の change-row になり、通常の remote-apply 経路で popout の書き込み/削除がホストのモデルにも反映されます。

  • 引き取った行の replay が、後から入ったホストの編集を巻き戻さないようにする: 上の「元のウィンドウとしてアップロードする」は、その change-row をホストから見て他デバイス発にするということです。つまり同じ path にホスト自身のより新しい変更があると、その行が pull で降りてきて新しいローカル内容を popout の古いスナップショットで上書きします(tombstone なら削除します)。ホスト側の新しい変更はその後ホストの id で上がり、その pull 行は self-authored として捨てられるので、リロードするまでモデルだけが古いまま残り、次の保存でサーバの新しい状態まで壊れます。キュー自体は「引き取った行がまだ queue にいる間にホストが編集した」ケースを path 単位の coalesce で吸収しますが(adopted 印により live な enqueue が必ず勝つ)、アップロードが wire に出てから、その行を pull し終えるまでの窓は残ります。そこで adopted な upload / delete を送り出す直前に(サーバはハンドラ内で change-row を書くので、その WS 通知が自分のレスポンスを追い越すことがあります)cloud-sync-bridge.tsadoptedReplayGuards に「この wire path をこの origin device id で書く」と記録し、collapseRawRows は survivor がその組に一致しかつ同じ path の変更がまだ outbox に残っている(= 自分の新しい内容がまだサーバに届いていない)ときだけその行を落とします。ガードはその wire path に触れた最初の pull で必ず破棄されるので、同じラベルで開き直した popout の正当な編集を後から食べることはありません。残る窓は、ホストの新しい PUT が先に着地して outbox が空になったうえで、単一ページの pullAndDecrypt が 2 行をページ境界で分断した場合だけです(本番の pull は syncDrain で head まで読み切るため、両方の行が同じ collapse に入りホスト自身の行が survivor になります)。

  • live なキュー操作と衝突させない: 引き取りの復号は load() を再利用せず専用経路(mergeAdoptedRows)で行います。enqueue / remove はコンストラクタの loadPromise しか待たないので、await を挟みながら entries に挿入すると割り込まれます(seq の若い enqueue が「古い」と判定されて捨てられる、remove の barrier を無視して復活する)。codec.open全部先に済ませ、entries への反映は同期ブロック 1 つにすることで、並行する enqueue / remove は必ずその前か後になります。加えて引き取った行には adopted 印を付け、同じ path への live な enqueue は seq の前後にかかわらず勝つようにしています(seq は同一インスタンスの enqueue 同士でしか比較できません)。

popout は失敗時にサインイン / genesis / workspace picker / パスワード入力を出しません(ホストのセッションを継承しているため)。代わりに「メインウィンドウで直してから開き直してください」という小さな状態パネル+リトライを出し、dock-back の strip は出したままにします(OS の閉じるボタンだけが出口という行き止まりを作らないため)。

鮮度

snapshot はマウント時の 1 回だけで、popout は drain ループを持ちません(それは SyncProvider にあり popout はマウントしない)。したがってその後のホスト側の編集は、ウィンドウを開き直すまで反映されません。これは popout について文書化済みの last-writer-wins そのもので、pivot 前に 2 つのウィンドウが同じファイルをディスク上で競合させていたのと同じ挙動です。

検証の範囲

mock ビルドで #/popped-out/… に直接ナビゲートすると、モジュール状態が本当に空の新しい realm が得られます(そのために main-mock.tsx に popout 分岐を追加しました)。これで「popped-out のボード / inbox が中身を描画する」ことは e2e で証明できます(e2e/popout-workspace-hydration.spec.ts)。一方 arm シーケンス自体は mock では踏めません — mock アダプタは workspace-core のシングルトンではなく自前の per-instance な workspaceFiles マップを持つため、ゲートは素通りします。実 WKWebView 2 枚を要する検証は mac ゲートです。

D14 — iOS を同じ workspace-only モデルに収束させる

iOS には現在、第三の経路があります。Rust が無条件にローカルワークスペースを作り、

// tauri-app/src/lib.rs resolve_project_root() の iOS 分岐
let workspace = doc_dir.join("zudo-text");
std::fs::create_dir_all(&workspace).ok();

renderer がワークスペースの変更をそこに書き戻します。

// 旧 ios-sync-hydrate.ts:58(S12 で削除済み)
export async function hydrateAppliedChanges(
  applied: readonly AppliedRemoteChange[],
): Promise<HydrateResult>

この経路は削除します。 iOS は他のプラットフォームと同じく共有ワークスペースコアを通して読み書きします。

削除にあたって保存すべき不変条件が一つあります。ハイドレートは syncDrain の applyFn の内側で呼ばれています。

const { applied } = await syncDrain(async (changes) => {
  const result = await hydrateAppliedChanges(changes);
  ...
});

これは durability invariant です — syncDrain が返った後にハイドレートすると、書き込まれていないノートを越えて cursor が進んでしまいます。in-memory モデルへの適用に置き換えるときも、「cursor の永続化は適用が成功した後」という順序を崩さないでください。

cloud-genesis-flow.ts には既に必要な抽象があります: export type GenesisSink = "disk" | "web""disk"hydrateAppliedChanges"web"applyWorkspaceChanges(in-memory シード、recordLocalUpsert を呼ばないので二重 push しない)です。転換は本質的に「"web" を唯一の sink にする」ことです。

iOS の onboarding / cold-start / cloud-config / key-store のフローは S10 の共有ブートパスに収束させ、iOS 固有の key-at-rest 姿勢の記述(WKWebView のサンドボックス + data-protection クラス、#2335)は正確なまま維持します。WSL では typecheck と unit test しかできないため、実機/シミュレータでの検証チェックリストは issue にコメントで残します(S12 / #4216)。

状態: 実装済み(S12 / #4216)。ios-sync-hydrate.tshydrateAppliedChanges)、pages/ios-onboarding.tsxhooks/use-ios-cold-start-sync.tslib/ios-device-id.tscomponents/ios-cloud-config-dialog.tsx、旧 iOS 専用認証設定 helper、hooks/use-ios-lifecycle.ts はすべて削除しました。bootstrap/tauri.tsx は macOS と iOS の両方に同じ Tauri レンダラバンドルを使う唯一のエントリポイントなので、AppBootdesktopColdStartPlatform)は最初から iOS の起動パスにもなっていました — 削除したモジュール群は、その上に生えていた到達不能な第三の経路です。cloud-genesis-flow.tsGenesisSink"disk" | "web")も撤去し、createCloudWorkspace は常に applyWorkspaceChanges で in-memory モデルへ直接シードします。オフライン空状態(当時 ios-offline-screen.tsxIosOfflineScreen、現 offline-boot-screen.tsxOfflineBootScreen)は D7 の「cold boot はネットワーク必須」に合わせて bootstrap/app-boot.tsx の先頭ゲートへ移動し(<App> 内の旧 IosOfflineGuard は撤去 — D7 いわく起動後の読み出しは完全にオフラインで完結するため、再接続の一時的な瞬断でブロックする理由がない)、mac/simulator での実機検証チェックリストは epic #4204 のコメントに残します。なおこの「先頭ゲート」は S12 時点の姿で、epic #4808 では boot 解決が失敗を分類した後段へ移り、iOS 限定でもなくなりました(上の D7 の節を参照)。

D15 — サーバ: setup-encryption を atomic claim にする

POST /api/v1/workspaces/:id/setup-encryption は現在 last-writer-wins です。

// workers/sync-server/src/handlers/workspace-handlers.ts
const workspace = await c.env.DB
  .prepare("SELECT id FROM workspaces WHERE id = ? AND user_id = ?")  // 所有者確認のみ
  .bind(workspaceId, user.userId).first();
if (!workspace) return c.json({ error: "Workspace not found" }, 404);

await c.env.DB.prepare(
  `UPDATE workspaces
   SET encryption_salt = ?, verification_hash = ?, verification_salt = ?, updated_at = datetime('now')
   WHERE id = ?`,                                                 // 前提条件なし
).bind(body.encryptionSalt, body.verificationHash, body.verificationSalt, workspaceId).run();

return c.json({ ok: true });

SELECT は所有者を確認するだけで encryption_salt を読みません。UPDATE には AND encryption_salt IS NULL がなく、RETURNING もなく、meta.changes も見ません。レスポンスの { ok: true } からは「自分が claim した」のか「他人の鍵を上書きした」のか区別できません。

これが実害になるのは、salt が非秘密だが load-bearing だからです。新しいランダム salt で導出した鍵は既存のワークスペースを復号できません。つまり上書きは単なる race ではなく破壊的です — 先行クライアントのブロブが、上書きされた鍵の下で孤児になります。

クライアント側には既にベストエフォートのガードがあり(runGenesisScaffoldgetWorkspace().encryptionSalt !== nulllistFiles().length > 0 を確認して GenesisWorkspaceAlreadyClaimedError を投げる)、残存 TOCTOU も cloud-genesis.ts:41-50 にコメントで記録されています。

決定: サーバ側を条件付き UPDATE にする。

  • UPDATE workspaces SET … WHERE id = ? AND encryption_salt IS NULL

  • meta.changes === 0 なら、格納済みの (encryption_salt, verification_hash, verification_salt) を読み戻して分岐する。

    • ワークスペースが存在しない / 所有者が違う → 404

    • 格納済みの三つ組が送信された三つ組と一致200(idempotent replay)。レスポンスが失われた勝者のリトライがこれに当たる。これを 409 にすると、クライアントは自分の claim を「他デバイスが所有している」と誤読して onboarding を誤った分岐に送る。

    • 異なる三つ組 → 409(本当に競合する claimant。鍵素材を上書きしてはならない)

  • レスポンスでどちらの分岐を通ったかクライアントに伝える({ ok: true, claimed: true | false })。

これにより claim が真に atomic になり、クライアント側の 2 往復ガードは「早期に分かりやすいエラーを出す」ための最適化に格下げできます。実装は S3(#4207)。同 sub-issue では multi-workspace の前提(1 ユーザー 1 ワークスペースを暗黙に仮定している箇所)の監査も行います。

Non-goals — この epic でやらないこと

範囲を明示しておきます。以下は意図的に外しています。

  • オフライン cold boot と、削除 / オフライン新規作成の耐久性(暗号化ローカルミラー)— この epic では #4233 のフォローアップとして D1 の seam だけ用意した。実装自体は epic #4808 で完了済み — 現在の姿勢は D7、実装は Encrypted Local Workspace Mirror

  • 設定のセクション単位マージ / CRDT — v1 はドキュメント全体の LWW(D3)。

  • マイグレーションコード — プレリリース方針。v1 config も旧 settings も旧ワークスペースレイアウトも、作り直せる前提(ルート CLAUDE.md)。

  • thin-launcher のバンドル機構の変更 — D12。

  • iOS の鍵の at-rest 強化(Keychain / Secure Enclave)#4458 は paid Apple Developer membership のブロック待ち。iOS / web の localStorage 平文 at-rest はプレリリースの受容済みトレードオフとして継続するが、macOS desktop は login Keychain 実装済み(Sync Architecture の Key-at-Rest 節)。

Reference — 関連ドキュメント

  • Sync Architecture — 暗号化、outbox、WebSocket、conflict resolution。local-fs-mirroring の記述は転換前の記録。

  • Cloud-first onboarding (web editor) — 転換先が既に動いている面のユーザー向け説明。genesis、cold-start re-arm、unlock。

  • zudo-text の哲学 — frontmatter contract と「スキーマはワークスペース側」。ワークスペースが保管先になっても不変。

  • Thin Launcher Contract — ROOT/LEAF のバンドル機構(D12 で無変更)。

  • Backend Bridge — アダプタパターンと namespace 一覧。

Summary

  • ワークスペースが唯一の保管先になる。ローカルワークスペースディレクトリという概念は消える。

  • 転換先は新規開発ではない — web エディタが既に workspace-only で全機能を動かしている。それを共有コアに抽出してデスクトップと iOS を向ける作業。

  • bridge.workspaceFiles(read / write / delete / list / listAll / subscribe / metadata)がワークスペースコンテンツの契約面。bridge.files は EFE 専用の local-only 面として残り、合成ルートのシムは撤退する。

  • メタデータはフィールドごとに出所が決まっており、ハイドレーションや read の時点で now を作らない。並び順キーはワークスペースに永続化されたデータからのみ導出する。metadata()null なのは key がモデルに無いときだけ。

  • アプリインスタンスは config.json v2 でちょうど一つのワークスペースにバインドされる。binding の永続化は unlock 成功後。v1 config は unbound = onboarding。

  • settings は 3 層に分かれる: local bootstrap identity / 同期される .zudotext.settings.json(v1 はドキュメント全体の LWW)/ 同期しない transient state。

  • workspace-relative POSIX パスが唯一のアドレッシング。.trash/ の除外を撤廃してゴミ箱を永続化する。containment チェックが safe_path を置き換える。

  • workspace id が唯一の identity。合成ルート "/__workspace__" を identity にしてはいけない。

  • ターミナル / PTY / activeFile / message-workspace/ はまとめて退役する。

  • オフラインは upsert / 削除の耐久性に加え、cold boot とオフライン新規作成の可視性も epic #4808(暗号化ローカルミラー)で解消済み。D1 の seam がその受け口だった。詳細は D7 と Encrypted Local Workspace Mirror

  • 迷ったら、このページを更新してから実装する。