JEPA4Japan · チュートリアル

第18章:人とAIが同じ台帳を編集する

8,424文字 27分で読めます #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Server-Authoritative SyncとCRDTを比較し、AIを検証可能・Preview可能・Undo可能・監査可能な型付き操作に制限します。

コース進捗 コース目次 18レッスン中 18件を公開中

第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標

  1. 01 第1章:まだ描かない——Canvasはプロダクト設計ではない 公開中
  2. 02 第2章:すぐに記憶を失うピクセルの紙 公開中
  3. 03 第3章:お絵描きを再現可能なレシピにする 公開中
  4. 04 第4章:4枚の地図と1台のカメラ 公開中

第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール

  1. 05 第5章:ピクセル世界に台帳を作る 公開中
  2. 06 第6章:ランプが点いたときだけ描き直す——Render SchedulerとReactの境界 公開中
  3. 07 第7章:マウス、指、ペンに同じ言葉を話してもらう 公開中
  4. 08 第8章:細い縁を調べる前に大きな箱を探す 公開中
  5. 09 第9章:ツールは信号機であり、Booleanの袋ではない 公開中

第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧

  1. 10 第10章:触って気持ちよいエディターにする 公開中
  2. 11 第11章:描かれた文字は編集できる文字ではない 公開中
  3. 12 第12章:借りた画像を勝手に箱へ詰めてはいけない 公開中
  4. 13 第13章:タイムマシンと古い箱 公開中
  5. 14 第14章:正しく見えることと、本当に正しいことは違う 公開中

第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI

  1. 15 第15章:1万人を一人ずつ探さない 公開中
  2. 16 第16章:受付を厨房へ入れない——WorkerとGPUへの更新 公開中
  3. 17 第17章:車を自作するか、実績あるシャーシを買うか 公開中
  4. 18 第18章:人とAIが同じ台帳を編集する 現在のレッスン

まずは5歳児にもわかるゲームから

この章で覚える真実は一つだけです。人間も AI も、検証可能で追跡可能かつ取り消し可能な Action を通じてのみ、共有 Document を変更できます。

3人の子どもが1冊の物語を一緒に書きます。「物語の本文」は共有の本棚に置きます。それぞれのランドセルには「いま何ページを開き、どのペンを選んでいるか」が入ります。いま掲げている色旗は「指している場所と見ているページ」を表し、子どもが教室を出ると消えます。まず予想してください。旗の位置を物語へ永久に印刷したら、翌日はどうなるでしょうか。本が期限切れの動作で埋まります。では全員のランドセルを同期したらどうでしょうか。A がページをめくるたび、B の表示まで奪ってしまいます。

先生は共同執筆の方法を二つから選べます。一つ目では、変更メモをすべて先生へ渡します。先生が名前、権限、ページ番号を確認し、連続番号のスタンプを押して全員へ配ります。先生の版が authoritative です。二つ目では、全員が同時の線を統合できる特殊な透明紙を使います。変更を交換したあと、全員が同じ統合ルールを適用します。これは CRDT に似ています。ただし、どちらの紙も「子どもは校長先生の署名を消せない」という規則を自動では守りません。Permission と domain validation は依然として必要です。

AI は4人目の子どもです。渡すのは許可された8種類の図形ブロックカードだけで、本全体を持ち出せません。最初に Preview を並べ、影響するブロックを列挙します。承認が必要なら人間を待ちます。完了後には provenance と取り消しカードを残します。同じ番号の依頼を再送しても実行は一度だけです。

  1. 共有する物語Durable Document
  2. 自分のランドセルLocal Session
  3. いまだけの旗Ephemeral Presence
  4. AI には許可済みブロックだけTyped Action
  5. スタンプ前に PreviewValidation、Undo、Audit
まず判断してください。AI は「shape-7 を移動」できましたが、応答が失われました。再試行でまた移動するべきでしょうか。いけません。同じ Idempotency Key は最初の結果を返します。

おもちゃを Canvas に置き換える

おもちゃの物語Canvas Labライフサイクルと規則
共有する本文Durable DocumentShape、Binding、Comment、Asset reference。永続化し Migration する
自分のランドセルLocal SessionCamera、現在 Page、Tool、local Selection。プロダクトが選ばない限り broadcast しない
いまだけの旗Ephemeral PresenceCursor、共有 Selection、Viewport、online state。TTL を持ち永続化しない
子どもの名札Identityauthentication system が発行。client の自己申告を信用しない
先生のスタンプAuthoritative ServerValidation、ordering、conflict handling、broadcast
統合できる透明紙CRDT Shared Types同時 update を CRDT rule で merge
紙を運ぶ配達員Network ProviderCRDT update を転送し、業務 Permission は決めない
切断中のメモを入れる袋Offline Queuereconnect 後の replay 用に order、version、idempotency key を保持
定期的な製本Snapshot / Compactionarchive 可能な Operation Log を切り詰める
AI のブロックカードTyped AI Action有限 union。任意 code や任意 patch を許可しない
テーブル上の試し置きPreviewauthoritative revision を変えず、期限切れや cancel が可能
取り消しカードUndo Transactionこの committed action だけを戻し、remote history は戻さない
ブロックを借りる署名Provenance / Auditmodel、tool version、approver、affected IDs、result

この比喩には限界があります。CRDT は透明紙を重ねるだけではなく、unique client/clock、causal information、data type、provider に依存します。収束しても業務上正しい意味になるとは限りません。Presence の broadcast と TTL は provider/server が実装し、「disk へ決して書かれない」ことを自動保証しません。team が storage から明示的に除外する必要があります。AI Screenshot は機密 context です。「テーブルを見ているだけ」と呼んでも、permission、minimization、retention policy は免除されません。

まず誤った直感を捨てる

  • 「Last writer wins が一番簡単だ。」 他ユーザーの独立変更を丸ごと上書きし、offline client では特に危険です。最低でも revision/sequence と conflict policy が必要です。
  • 「CRDT には conflict がない。」 CRDT は replica を収束させられますが、A が object を削除し B が編集した場合の結果はプロダクトが定義します。Permission、reference、Asset、Schema も失敗し得ます。
  • 「tldraw sync は Yjs だ。」 第17章で、tldraw sync が server-authoritative reconciliation を使うことを確認しました。公式 project も Yjs/CRDT ではないと明記しています。
  • 「Presence も Record だから Snapshot へ入れる。」 Cursor と online state はすぐ失効します。永続化は行動を漏えいし、データを膨らませ、ghost user を復活させます。
  • 「Optimistic update が成功表示されたら commit 済みだ。」 client は pending と confirmed を区別します。server が拒否したら黙って保持せず reconcile します。
  • 「AI が JSON を返し JSON.parse に成功したら実行できる。」 Schema、semantic Validation、Permission、approval、idempotency、Rate Limit、atomic Transaction がまだありません。
  • 「AI に Store 全体を渡すほど賢くなる。」 relevant Structured Shapes、Selection、限定した interaction summary、permission-cropped Screenshot という最小 context のほうが安全で安定します。
  • 「AI が切断したら最初からやり直す。」 server がすでに commit していれば Delete/Move が重複します。Action ID と Idempotency Key を result table に同じ database Transaction で原子的に登録します。
  • 「Undo は古い Snapshot を復元する。」 後から届いた他ユーザーの変更まで消します。Local Undo は local origin/transaction だけを反転し、現在 state に対して再検証します。

Production backpack

前提となる契約

第5章は Document と Renderer を分離しました。第13章は Version、Migration、Transaction、Crash Recovery、3種類の scope を提供します。第17章は Build/Buy/Hybrid を定義し、tldraw 5.3.2 Store を projection としてだけ扱います。collaboration protocol も AI Actions も、同じ domain Command Boundary だけを呼べます。History、permission、audit を迂回してはいけません。

正式な知識

まず3種類のデータを分離する

Scope内容永続化Network一般的な owner
DocumentPage、Shape、Binding、Comment、Asset IDあり。version 管理reliable syncRoom / authoritative storage
SessionCurrent Page、Camera、Tool、private Selection、panel state任意で local既定では送信しないUser + device
PresenceCursor、shared Selection、Viewport、statusなし。TTLbest-effort、高頻度現在 connection

Identity は authentication token/session から得ます。Permission は room、record、action/field level に分けます。Asset Sync は Blob を operation log へ入れません。まず upload intent を取得し、upload、validation、scan を行い、immutable Asset ID を受け取ってから Document Transaction で参照します。pending/missing state の Document reference には placeholder と復旧経路があります。

モード1:Server-Authoritative Sync

flow は Client Diff → Authoritative Server → Reconciliation → Broadcast です。client は {operationId, baseRevision, schemaVersion, protocolVersion, commands} を送ります。server は Identity を authenticate し、room lifecycle、protocol/Schema、Permission、Command schema、reference、domain invariant を検証します。一つの database Transaction 内で Operation Log、Document revision、Audit を書き、monotonic Sequence を割り当て、confirmation を返して broadcast します。client は confirmed base と optimistic pending operation を保持し、authoritative result 到着後に未確定の local operation を replay します。

Reconnect は version handshake から始め、lastSequence で Incremental Update を要求します。log が compact 済み、または差が大きすぎる場合は Snapshot を download し、Offline Queue を replay します。すべての operationId は idempotent なので、queue item の再送で二重実行しません。Conflict は reject、field 単位の rebase、application-specific rule のいずれかで扱います。「2ユーザーが同時移動」なら最後に順序づけられた Move、relative delta の merge、明示的 conflict 表示などを選べますが、protocol がどれかを定めます。Server Validation は中央に集約しやすい一方、server/room が ordering と availability の中心となり、team が独自の offline merge rule を設計します。

Room Lifecycle には create、warm/load、active、idle flush、archive、delete/restore があります。各 room には一つの論理的 serialization pointが必要です。単一 actor/partition でも、conditional database write と transaction でもよく、物理 writer machine が一台という意味ではありません。Operation Log は replay と Audit を支えますが、無限成長する前に Snapshot + Compaction が必要です。server は「将来戻るかもしれない全 offline client」の acknowledgment を待てないため、protocol が cutoff を宣言します。lastSequence が cutoff より古い client には旧 log を渡さず、current Snapshot を download させ、互換性のある local queue を replay します。Compliance Audit は独立した retention policy を持ち、sync-log compaction と一緒に消せません。Schema Version は Document、Protocol Version は message を表します。一つの番号で両方を表してはいけません。

モード2:Yjs / CRDT

flow は Concurrent Shared Types → CRDT Merge → Network Provider → Optional Offline Storage です。Y.Doc 内の Y.Map/Y.Array/Y.Text は causal information を持つ update を発行します。順不同や重複で受信しても CRDT rule で統合し、最終的に収束します。Provider は WebSocket/WebRTC などで update を転送します。y-indexeddb は local offline state を永続化できます。Awareness は Cursor、Selection、user state 用の別の ephemeral protocol です。Y.Doc へ書かず Durable Document にしてはいけません。

Yjs UndoManager は tracked origin で local transaction だけを追跡し、remote user の変更を戻しません。CRDT は強い offline operation、decentralized/multiple provider、細粒度 concurrent merge に向きます。ただし通常 server が暗号化 update を見たり解釈したりできなければ、各 field change の前に authoritative Permission を強制するのは困難です。room access を provider/server layer で検証する、server-side Y.Doc の candidate copy に update を先に適用して invariant を検査する、shared type を partition する、controlled command gateway を設計する、といった選択肢があります。信頼できない update を authoritative Y.Doc へ先に書き、あとで確実に「undo」できると期待してはいけません。どの方法にも latency、optimistic-client reconciliation、security の tradeoff があります。CRDT は Asset Blob、Schema Migration、audit semantics、有料 Permission を自動では解決しません。

比較軸Server-AuthoritativeYjs / CRDT
Ordering/conflictServer sequence と application ruleCRDT data type の deterministic merge
Offline独自 queue/rebase/conflictupdate はあとで自然に merge。provider が persistence を決定
Permission validationcommit 前に直接 centralized validationupdate-level/post-merge validation は難しく特別設計が必要
Central dependencyRoom authority が中心merge に中心不要。provider は availability に影響
Undolocal Operation/Command を反転UndoManager が local origin を追跡
Presence独立 ephemeral channelAwareness protocol。Y.Doc とは別
AuditDomain Operation は説明しやすいraw update は説明しづらく semantic audit が必要
Best fit強い business rule、authoritative permission、auditable workflow強い offline、細粒度 concurrency、shared type

両モードで editor を構築でき、組み合わせることもできますが、名称を混同してはいけません。Canvas Lab の Visual Operations scenario は Permission、AI approval、Audit、business invariant を優先するため Server-Authoritative を選びます。Presence は独立 best-effort channel を使います。将来、強い offline text が中心になったら、Domain 全体を一度に CRDT へ置き換えず、選択 field への Y.Text を評価します。

AI-Native Canvas は制約されたプロトコルである

AI が受け取るのは、permission-filtered Structured Shape Data、必要な Screenshot(redacted、region-limited、briefly retained)、現在 Selection、限定した Interaction History summary、Action Schema だけです。許可する Action は CreateShape、UpdateShape、DeleteShape、MoveShape、ConnectShapes、AlignShapes、GroupShapes、AddComment の8種類だけです。JavaScript、SQL、任意 JSON Patch、URL fetch、未登録 Shape type を受け入れません。

各 Action Envelope は Schema/Version を含みます。Validation は構造、数値範囲、reference、cycle、domain invariant を検査します。Permission は実 cascade を解決したあと、action、field、Affected IDs を一つずつ確認します。Preview は commit 不可能な clone 上で before/after/diff/cost を生成します。Preview token は actor + document + baseRevision + canonical action hash + expiry に bind します。そうしなければ A を承認した token で B を submit できます。Approval Policy は削除、件数、sensitive object に応じて auto/confirm/deny を選びます。Transaction は Batch 全体を成功または失敗させます。Undo は現在 state に対して検証可能な inverse patch を保存し、古い Snapshot 全体を戻して後続 remote work を消しません。trusted model gateway が Provenance、つまり model、tool version、prompt hash、request/actor/approver を記録し、client claim を信用しません。Cancellation が効くのは commit 前だけで、commit 後は Undo を使います。Idempotency Key を request hash に bind し、unique constraint のある result を同じ database Transaction 内で登録します。Rate Limit は actor/room/action/cost 単位です。Audit Log は append-only で redacted です。

根拠と互換性(確認日:2026-08-29)

tldraw sync の TLSocketRoom は authoritative document を保持して diff を reconcile します。公式 tldraw sync と Collaboration server architecture を参照してください。公式 project は tldraw sync は CRDT ではない と明記しています。concurrent Shared Types、update、provider、offline support は Yjs Introduction、一時 user state は Awareness & Presence、local undo origin は Y.UndoManager を参照してください。provider ごとに encryption、authentication、persistence、scaling behavior が異なるため、採用実装を検証します。「Yjs を使う」だけでは Permission や offline UI の正しさを証明しません。

この章で積み上げる実装

開始地点: 第17章の Hybrid SDK は1台の machine で edit と export ができます。完了地点: Server-Authoritative room が Snapshot、Incremental Update、Sequence、Reconnect、Offline Queue、Optimistic Reconciliation、Presence、Asset protocol を扱います。AI は Preview/Approve/Cancel/Undo/Idempotent Retry/Audit を含む完全な制約 Action pipeline を通じて submit します。

次のファイルと interface を追加します。

  • src/collab/protocol.ts:Schema/Protocol version と operation/ack/resync。
  • src/collab/AuthoritativeRoom.ts:server validation、sequence、log、compaction。
  • src/collab/OfflineQueue.ts:idempotent reconnect。
  • src/presence/PresenceHub.ts:TTL、capacity、redaction。
  • src/ai/action-schema.ts:8種類の discriminated action。
  • src/ai/ActionService.ts:preview、approval、transaction、undo、provenance、rate、audit。
  • tests/collab/races.test.ts と tests/ai/actions.test.ts:failure/security regression。

まず AI ができることをすべて Zod schema の中へ封じます。unknown が parse を通過してはいけません。model が自由形式 Shape Record を送れるようにせず、field は allowlist で定めます。

import { z } from 'zod';

const ShapeId = z.string().regex(/^shape-[a-z0-9-]+$/);
const BindingId = z.string().regex(/^binding-[a-z0-9-]+$/);
const GroupId = z.string().regex(/^group-[a-z0-9-]+$/);
const CommentId = z.string().regex(/^comment-[a-z0-9-]+$/);
const Point = z
  .object({ x: z.number().finite().min(-1e7).max(1e7), y: z.number().finite().min(-1e7).max(1e7) })
  .strict();
const ShapeKind = z.enum(['task', 'note', 'decision']);
const EditableFields = z
  .object({
    title: z.string().min(1).max(500).optional(),
    status: z.enum(['todo', 'doing', 'done']).optional(),
    color: z.enum(['gray', 'blue', 'green', 'red']).optional(),
  })
  .strict();
const ShapeIds = z
  .array(ShapeId)
  .min(2)
  .max(100)
  .refine((ids) => new Set(ids).size === ids.length, 'duplicate ids');

export const AiActionSchema = z.discriminatedUnion('type', [
  z
    .object({
      type: z.literal('CreateShape'),
      id: ShapeId,
      shapeKind: ShapeKind,
      at: Point,
      props: EditableFields.required({ title: true }),
    })
    .strict(),
  z
    .object({
      type: z.literal('UpdateShape'),
      id: ShapeId,
      patch: EditableFields.refine((v) => Object.keys(v).length > 0, 'empty patch'),
    })
    .strict(),
  z.object({ type: z.literal('DeleteShape'), id: ShapeId }).strict(),
  z.object({ type: z.literal('MoveShape'), id: ShapeId, to: Point }).strict(),
  z
    .object({
      type: z.literal('ConnectShapes'),
      id: BindingId,
      fromId: ShapeId,
      toId: ShapeId,
      relation: z.enum(['blocks', 'relates']),
    })
    .strict(),
  z
    .object({
      type: z.literal('AlignShapes'),
      ids: ShapeIds,
      axis: z.enum(['left', 'center-x', 'top', 'center-y']),
    })
    .strict(),
  z.object({ type: z.literal('GroupShapes'), id: GroupId, ids: ShapeIds }).strict(),
  z
    .object({
      type: z.literal('AddComment'),
      id: CommentId,
      shapeId: ShapeId,
      text: z.string().min(1).max(2_000),
    })
    .strict(),
]);
export type AiAction = z.infer<typeof AiActionSchema>;
export const AiEnvelopeSchema = z
  .object({
    protocolVersion: z.literal(1),
    schemaVersion: z.literal(3),
    documentId: z.string().regex(/^doc-[a-z0-9-]+$/),
    actionId: z.string().uuid(),
    idempotencyKey: z.string().uuid(),
    baseRevision: z.number().int().nonnegative(),
    approvalToken: z.string().uuid().optional(),
    actions: z.array(AiActionSchema).min(1).max(100),
  })
  .strict();
export type AiEnvelope = z.infer<typeof AiEnvelopeSchema>;

ID prefix も Schema の一部です。広すぎる Id を共有すると DeleteShape が comment-* を受け入れ、実行の深い場所まで失敗しません。ShapeIds は重複も拒否し、[shape-a, shape-a] が「最低2個」を満たしながら1オブジェクトだけを操作することを防ぎます。provenance は信頼できない Envelope に置きません。authenticated model gateway が TrustedInvocation として別に渡します。

以下の executor は「parse して Store を直接 mutate」する方式を安全な pipeline に変えます。実 database は Repository.transaction の atomicity を実装する必要があります。Preview token は action hash、revision、actor、expiry を bind するため、他人の approval を再利用できません。

type Shape = {
  id: string;
  kind: 'task' | 'note' | 'decision';
  x: number;
  y: number;
  w: number;
  h: number;
  title: string;
  status?: string;
  color?: string;
  locked?: boolean;
};
type Binding = { id: string; fromId: string; toId: string; relation: 'blocks' | 'relates' };
type Comment = { id: string; shapeId: string; text: string; authorId: string };
type Group = { id: string; ids: string[] };
type Document = {
  id: string;
  revision: number;
  shapes: Map<string, Shape>;
  bindings: Map<string, Binding>;
  comments: Map<string, Comment>;
  groups: Map<string, Group>;
};
type Preview = {
  token: string;
  actorId: string;
  documentId: string;
  baseRevision: number;
  requestHash: string;
  affectedIds: string[];
  before: Document;
  after: Document;
  requiresApproval: boolean;
  expiresAt: number;
};
type Result = {
  actionId: string;
  actorId: string;
  documentId: string;
  requestHash: string;
  revision: number;
  affectedIds: string[];
  undoId: string;
};
type User = {
  id: string;
  canComment: boolean;
  editableRecordIds: Set<string>;
  canCreate: boolean;
  editableFields: Set<'title' | 'status' | 'color'>;
};
type TrustedInvocation = {
  actorId: string;
  model: string;
  toolVersion: string;
  promptHash: string;
};

const cloneDoc = (d: Document): Document => ({
  ...d,
  shapes: new Map([...d.shapes].map(([k, v]) => [k, { ...v }])),
  bindings: new Map([...d.bindings].map(([k, v]) => [k, { ...v }])),
  comments: new Map([...d.comments].map(([k, v]) => [k, { ...v }])),
  groups: new Map([...d.groups].map(([k, v]) => [k, { ...v, ids: [...v.ids] }])),
});
function idsOf(a: AiAction): string[] {
  switch (a.type) {
    case 'CreateShape':
    case 'UpdateShape':
    case 'DeleteShape':
    case 'MoveShape':
      return [a.id];
    case 'ConnectShapes':
      return [a.id, a.fromId, a.toId];
    case 'AlignShapes':
      return [...a.ids];
    case 'GroupShapes':
      return [a.id, ...a.ids];
    case 'AddComment':
      return [a.id, a.shapeId];
  }
}
function requireShape(doc: Document, id: string) {
  const shape = doc.shapes.get(id);
  if (!shape) throw new Error(`SHAPE_NOT_FOUND:${id}`);
  return shape;
}
function applyAction(doc: Document, a: AiAction, actorId: string): string[] {
  switch (a.type) {
    case 'CreateShape':
      if (doc.shapes.has(a.id)) throw new Error('ID_EXISTS');
      doc.shapes.set(a.id, {
        id: a.id,
        kind: a.shapeKind,
        x: a.at.x,
        y: a.at.y,
        w: 240,
        h: 112,
        ...a.props,
      });
      return [a.id];
    case 'UpdateShape': {
      const s = requireShape(doc, a.id);
      doc.shapes.set(a.id, { ...s, ...a.patch });
      return [a.id];
    }
    case 'DeleteShape': {
      requireShape(doc, a.id);
      const affected = [a.id];
      doc.shapes.delete(a.id);
      for (const [id, b] of doc.bindings)
        if (b.fromId === a.id || b.toId === a.id) {
          doc.bindings.delete(id);
          affected.push(id);
        }
      for (const [id, c] of doc.comments)
        if (c.shapeId === a.id) {
          doc.comments.delete(id);
          affected.push(id);
        }
      for (const [id, g] of doc.groups)
        if (g.ids.includes(a.id)) {
          const ids = g.ids.filter((shapeId) => shapeId !== a.id);
          if (ids.length < 2) doc.groups.delete(id);
          else doc.groups.set(id, { ...g, ids });
          affected.push(id);
        }
      return affected;
    }
    case 'MoveShape': {
      const s = requireShape(doc, a.id);
      doc.shapes.set(a.id, { ...s, x: a.to.x, y: a.to.y });
      return [a.id];
    }
    case 'ConnectShapes':
      requireShape(doc, a.fromId);
      requireShape(doc, a.toId);
      if (a.fromId === a.toId) throw new Error('SELF_BINDING');
      if (doc.bindings.has(a.id)) throw new Error('ID_EXISTS');
      doc.bindings.set(a.id, { id: a.id, fromId: a.fromId, toId: a.toId, relation: a.relation });
      return [a.id, a.fromId, a.toId];
    case 'AlignShapes': {
      const shapes = a.ids.map((id) => requireShape(doc, id));
      const value =
        a.axis === 'left'
          ? Math.min(...shapes.map((s) => s.x))
          : a.axis === 'center-x'
            ? shapes.reduce((n, s) => n + s.x + s.w / 2, 0) / shapes.length
            : a.axis === 'top'
              ? Math.min(...shapes.map((s) => s.y))
              : shapes.reduce((n, s) => n + s.y + s.h / 2, 0) / shapes.length;
      for (const s of shapes)
        doc.shapes.set(s.id, {
          ...s,
          ...(a.axis === 'left'
            ? { x: value }
            : a.axis === 'center-x'
              ? { x: value - s.w / 2 }
              : a.axis === 'top'
                ? { y: value }
                : { y: value - s.h / 2 }),
        });
      return [...a.ids];
    }
    case 'GroupShapes':
      if (doc.groups.has(a.id)) throw new Error('ID_EXISTS');
      for (const id of a.ids) requireShape(doc, id);
      doc.groups.set(a.id, { id: a.id, ids: [...a.ids] });
      return [a.id, ...a.ids];
    case 'AddComment':
      requireShape(doc, a.shapeId);
      if (doc.comments.has(a.id)) throw new Error('ID_EXISTS');
      doc.comments.set(a.id, { id: a.id, shapeId: a.shapeId, text: a.text, authorId: actorId });
      return [a.id, a.shapeId];
  }
}
function authorize(user: User, doc: Document, actions: AiAction[]) {
  for (const a of actions) {
    if (['CreateShape', 'ConnectShapes', 'GroupShapes'].includes(a.type) && !user.canCreate)
      throw new Error('FORBIDDEN_CREATE');
    if (a.type === 'AddComment' && !user.canComment) throw new Error('FORBIDDEN_COMMENT');
    if (
      a.type === 'UpdateShape' &&
      Object.keys(a.patch).some((k) => !user.editableFields.has(k as 'title' | 'status' | 'color'))
    )
      throw new Error('FORBIDDEN_FIELD');
    const existing = idsOf(a).filter(
      (id) =>
        doc.shapes.has(id) || doc.bindings.has(id) || doc.comments.has(id) || doc.groups.has(id),
    );
    if (a.type !== 'AddComment' && existing.some((id) => !user.editableRecordIds.has(id)))
      throw new Error('FORBIDDEN_ID');
    if (a.type === 'DeleteShape') {
      const cascade = [...doc.bindings.values()]
        .filter((b) => b.fromId === a.id || b.toId === a.id)
        .map((b) => b.id)
        .concat([...doc.comments.values()].filter((c) => c.shapeId === a.id).map((c) => c.id))
        .concat([...doc.groups.values()].filter((g) => g.ids.includes(a.id)).map((g) => g.id));
      if (cascade.some((id) => !user.editableRecordIds.has(id)))
        throw new Error('FORBIDDEN_CASCADE_ID');
    }
    if (existing.some((id) => doc.shapes.get(id)?.locked)) throw new Error('LOCKED_ID');
  }
}

type UndoPatch = {
  id: string;
  before: Shape | Binding | Comment | Group | null;
  after: Shape | Binding | Comment | Group | null;
};
function record(doc: Document, id: string) {
  return (
    doc.shapes.get(id) ?? doc.bindings.get(id) ?? doc.comments.get(id) ?? doc.groups.get(id) ?? null
  );
}
function inversePatches(before: Document, after: Document, ids: string[]): UndoPatch[] {
  return [...new Set(ids)].map((id) => ({
    id,
    before: structuredClone(record(before, id)),
    after: structuredClone(record(after, id)),
  }));
}
function canonicalRequest(e: AiEnvelope, invocation: TrustedInvocation) {
  return {
    protocolVersion: e.protocolVersion,
    schemaVersion: e.schemaVersion,
    documentId: e.documentId,
    actionId: e.actionId,
    idempotencyKey: e.idempotencyKey,
    baseRevision: e.baseRevision,
    actions: e.actions,
    invocation,
  };
}
function assertSameRequest(result: Result, hash: string, e: AiEnvelope, user: User) {
  if (
    result.requestHash !== hash ||
    result.actionId !== e.actionId ||
    result.documentId !== e.documentId ||
    result.actorId !== user.id
  )
    throw new Error('IDEMPOTENCY_KEY_REUSED_FOR_DIFFERENT_REQUEST');
  return result;
}

interface Repository {
  load(id: string): Promise<Document>;
  findResult(idempotencyKey: string): Promise<Result | undefined>;
  savePreview(actionId: string, preview: Preview, actorId: string): Promise<void>;
  loadApprovedPreview(actionId: string, token: string, actorId: string): Promise<Preview>;
  transaction<T>(
    work: (tx: {
      findResult(key: string): Promise<Result | undefined>;
      saveDocument(doc: Document, expectedRevision: number): Promise<void>;
      saveUndo(id: string, patches: UndoPatch[]): Promise<void>;
      saveResult(key: string, result: Result): Promise<void>;
      appendAudit(event: Record<string, unknown>): Promise<void>;
    }) => Promise<T>,
  ): Promise<T>;
}
interface Gate {
  rateLimit(actorId: string, roomId: string, cost: number): Promise<void>;
  isCanceled(actionId: string): Promise<boolean>;
  hashCanonical(value: unknown): Promise<string>;
  randomId(): string;
  now(): number;
}

export class AiActionService {
  constructor(
    private repo: Repository,
    private gate: Gate,
  ) {}
  async preview(raw: unknown, user: User, invocation: TrustedInvocation) {
    const e = AiEnvelopeSchema.parse(raw);
    await this.gate.rateLimit(user.id, e.documentId, e.actions.length);
    if (invocation.actorId !== user.id) throw new Error('PROVENANCE_ACTOR_MISMATCH');
    const requestHash = await this.gate.hashCanonical(canonicalRequest(e, invocation));
    const doc = await this.repo.load(e.documentId);
    if (doc.revision !== e.baseRevision) throw new Error('STALE_BASE_REVISION');
    authorize(user, doc, e.actions);
    const before = cloneDoc(doc),
      after = cloneDoc(doc);
    const affectedIds = [
      ...new Set(e.actions.flatMap((action) => applyAction(after, action, user.id))),
    ];
    const requiresApproval =
      e.actions.some((a) => a.type === 'DeleteShape') || affectedIds.length > 10;
    const preview: Preview = {
      token: this.gate.randomId(),
      actorId: user.id,
      documentId: e.documentId,
      baseRevision: e.baseRevision,
      requestHash,
      affectedIds,
      before,
      after,
      requiresApproval,
      expiresAt: this.gate.now() + 5 * 60_000,
    };
    await this.repo.savePreview(e.actionId, preview, user.id);
    return {
      actionId: e.actionId,
      token: preview.token,
      affectedIds,
      requiresApproval,
      summary: e.actions.map((a) => a.type),
    };
  }
  async commit(raw: unknown, user: User, invocation: TrustedInvocation): Promise<Result> {
    const e = AiEnvelopeSchema.parse(raw);
    if (invocation.actorId !== user.id) throw new Error('PROVENANCE_ACTOR_MISMATCH');
    const requestHash = await this.gate.hashCanonical(canonicalRequest(e, invocation));
    const old = await this.repo.findResult(e.idempotencyKey);
    if (old) return assertSameRequest(old, requestHash, e, user);
    await this.gate.rateLimit(user.id, e.documentId, e.actions.length);
    if (await this.gate.isCanceled(e.actionId)) throw new Error('CANCELED');
    if (!e.approvalToken) throw new Error('PREVIEW_REQUIRED');
    const preview = await this.repo.loadApprovedPreview(e.actionId, e.approvalToken, user.id);
    if (preview.expiresAt < this.gate.now()) throw new Error('PREVIEW_EXPIRED');
    if (
      preview.requestHash !== requestHash ||
      preview.actorId !== user.id ||
      preview.documentId !== e.documentId ||
      preview.baseRevision !== e.baseRevision
    )
      throw new Error('PREVIEW_REQUEST_MISMATCH');
    const current = await this.repo.load(e.documentId);
    if (current.revision !== preview.baseRevision) throw new Error('PREVIEW_STALE');
    authorize(user, current, e.actions);
    if (await this.gate.isCanceled(e.actionId)) throw new Error('CANCELED');
    const next = cloneDoc(preview.after);
    next.revision = current.revision + 1;
    const result: Result = {
      actionId: e.actionId,
      actorId: user.id,
      documentId: e.documentId,
      requestHash,
      revision: next.revision,
      affectedIds: preview.affectedIds,
      undoId: this.gate.randomId(),
    };
    return this.repo.transaction(async (tx) => {
      const duplicate = await tx.findResult(e.idempotencyKey);
      if (duplicate) return assertSameRequest(duplicate, requestHash, e, user);
      await tx.saveDocument(next, current.revision);
      await tx.saveUndo(result.undoId, inversePatches(preview.before, next, preview.affectedIds));
      await tx.saveResult(e.idempotencyKey, result);
      await tx.appendAudit({
        type: 'ai-action-committed',
        actionId: e.actionId,
        actorId: user.id,
        model: invocation.model,
        toolVersion: invocation.toolVersion,
        promptHash: invocation.promptHash,
        requestHash,
        affectedIds: result.affectedIds,
        revision: result.revision,
        approved: true,
        at: this.gate.now(),
      });
      return result;
    });
  }
}

saveResult、conditional saveDocument(expectedRevision)、inverse Patch、Audit は同じ database Transaction と idempotency unique index で保護します。transaction 内の lookup は早期 return を可能にしますが unique constraint の代わりにはなりません。Preview は before/after clone を短時間だけ保存できます。実際の Undo は affected record の before/after だけを永続化し、現在の authoritative state に対して check/rebase します。古い Document 全体は復元しません。失敗した domain Transaction は「committed」Audit を書きません。forbidden access や rate limit などの rejection は、さらに制限が強く同様に redacted な security log へ入れます。最後の cancellation check 後に Cancel と commit が競合した場合、先に成功した database operation が結果を決めます。commit がすでに成功したなら cancellation に成功したと偽らず Result を返し、Undo を提示します。

Yjs を選んでも Document と Awareness は別々のままで、local Undo は自分の origin だけを追跡します。

import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';

const doc = new Y.Doc(),
  shapes = doc.getMap<Y.Map<unknown>>('shapes'),
  LOCAL = Symbol('local-user');
const network = new WebsocketProvider('wss://sync.example.test', 'doc-42', doc);
const offline = new IndexeddbPersistence('doc-42', doc);
network.awareness.setLocalStateField('cursor', { x: 120, y: 80 }); // doc には書き込まれない
const undo = new Y.UndoManager(shapes, { trackedOrigins: new Set([LOCAL]) });
doc.transact(() => {
  const shape = new Y.Map<unknown>();
  shape.set('x', 10);
  shape.set('y', 20);
  shapes.set('shape-1', shape);
}, LOCAL);
await offline.whenSynced;
undo.undo();

物語へ戻りましょう。Y.Map は特殊な透明紙、Awareness は旗です。Cursor を doc.getMap('shapes') へ入れると、また旗を永久の物語へ印刷してしまいます。local origin によって、取り消しカードは自分の線だけを戻します。

テストは「通常の作成」だけでなく、重複処理、Permission rejection、切断、atomicity を証明します。

import { expect, test } from 'vitest';
import { AiActionSchema } from '../action-schema';

test('Action schema は任意 field と任意 code を拒否する', () => {
  expect(() =>
    AiActionSchema.parse({ type: 'DeleteShape', id: 'shape-a', javascript: 'document.cookie' }),
  ).toThrow();
  expect(() =>
    AiActionSchema.parse({ type: 'MoveShape', id: 'shape-a', to: { x: Infinity, y: 0 } }),
  ).toThrow();
  expect(() => AiActionSchema.parse({ type: 'DeleteShape', id: 'comment-a' })).toThrow();
  expect(() =>
    AiActionSchema.parse({ type: 'GroupShapes', id: 'group-a', ids: ['shape-a', 'shape-a'] }),
  ).toThrow();
});

test('同じ idempotency key の retry は同じ revision だけを返す', async () => {
  const first = await service.commit(approvedEnvelope, editorUser, trustedInvocation);
  const retryAfterReplyWasLost = await service.commit(
    approvedEnvelope,
    editorUser,
    trustedInvocation,
  );
  expect(retryAfterReplyWasLost).toEqual(first);
  expect(repository.documentWritesFor(approvedEnvelope.idempotencyKey)).toBe(1);
  await expect(
    service.commit(
      { ...approvedEnvelope, actions: differentActions },
      editorUser,
      trustedInvocation,
    ),
  ).rejects.toThrow('IDEMPOTENCY_KEY_REUSED_FOR_DIFFERENT_REQUEST');
});

test('権限のない object があれば Batch 全体が失敗し、部分 Action を残さない', async () => {
  const before = await repository.load('doc-a');
  await expect(
    service.commit(envelopeMovingAllowedAndLockedShape, limitedUser, trustedInvocation),
  ).rejects.toThrow(/FORBIDDEN|LOCKED/);
  expect(await repository.load('doc-a')).toEqual(before);
  expect(repository.auditEventsFor(envelopeMovingAllowedAndLockedShape.actionId)).toHaveLength(0);
});

npm exec vitest run tests/collab tests/ai を実行します。out-of-order、duplicate、version mismatch、conflict、offline replay、Presence TTL、AI schema/permission/approval/cancel/idempotency/atomic rollback/audit のテストが通ることを確認します。npm exec playwright test tests/multiplayer-ai.spec.ts を実行し、2ブラウザでの concurrency、disconnect/reconnect、AI Preview/approval/Undo、Keyboard/Screen Reader task が通ることを確認します。npm run capstone:verify を実行し、一つの aggregate Boolean ではなく、各 capability への evidence path があることを確認します。

最終 Capstone:Visual Operations Canvas

成果物には custom business Shapes、Text DOM editing、Image/Secure Assets、Connector/Binding、Snapping、Keyboard、History、Persistence、Migrations、PNG/SVG/JSON/high-resolution Export、Observability、Performance Budget のすべてを一緒に含めます。Worker/GPU は第16章の Benchmark で根拠が得られた場合だけ有効にできます。さらに Multiplayer、Offline Queue、Presence、AI Actions、Permission、Audit Log を加えます。「optional」な技術は、判断を省略することではなく、有効化または無効のままにする根拠があるという意味です。

わざと壊してみる

注入する障害症状証拠修正回帰テスト復旧
A が削除中に B が編集object が復活、または B が永久 pendingseq/conflict/auditdelete-vs-update rule を定義2-client barrier testreconcile/conflict 表示
2ユーザーが同時移動Shape が戻る、または点滅pending/confirmed traceSequence + product merge policyConcurrent-Move fixtureauthoritative position + pending replay
offline edit が旧 Schemareconnect parse failurehandshake versionsmigration 後 replay、または read-only rejectv2 offline→v3 serverlocal copy を export して upgrade
Presence を永続化reopen 後に ghost CursorSnapshot scope auditTTL Store を分離reload/expiry test誤 Record を削除
AI が二重実行Shape が2回移動、comment 重複Idempotency unique/auditAtomic Result Recordreply-loss retry最初の Result を返す
AI が権限外 object を編集sensitive content 変更denied audit/permission traceaffected ID ごとに authorizeMixed Batch testTransaction 全体を rollback
AI が途中切断半分の Shape Batchrevision/transaction logatomic commit全 step で disconnect同じ key で retry または Undo
Asset upload と Document が競合reference が永久 missingAsset/Document statesintent→scan→ready→referencereordered-completion testplaceholder/ready ID へ rebind
Server/Client Protocol が不一致message を誤 parsehandshake rejection明示的 compatibility windowv1↔v2 matrixread-only、refresh/fallback
Migration 後に旧 client reconnect新 field を旧 client が削除Schema guardold-client write を block し release 調整old-reconnect fixturequeue 保持、upgrade 後 replay

証拠をもって合格する

自動化された証拠手動の証拠合格条件
2-client race、offline reconnect、idempotency、Protocol/Schema skew、Presence TTL実2端末で disconnect/recover、conflict notice と Local Undo をリハーサルreplica が収束し、Session は奪い合わず、Presence は失効し、Undo が remote change を消さない
8種の AI Action schema、forbidden access、preview/approval、cancel race、atomic rollback、audit testdelete Preview、affected IDs、approval text、Undo、audit 可読性を確認AI は有限 Action を迂回できず、retry/disconnect で二重実行せず、audit は raw Prompt を漏らさない
capstone:verify capability list と performance budgetKeyboard/Screen Reader、low-end device、crash/upgrade/downgrade recovery のリハーサル17項目すべてに現行 code、test、Trace、ADR の証拠がある
マスター到達確認必須の回答
1. なぜこの Renderer を選んだかProduct semantics、object/change volume、accessibility、Benchmark を合わせて決め、Document は Renderer に依存しない
2. なぜ Document は Renderer に依存しないか永続する business truth で、Renderer は交換可能な projection だから
3. 座標をどう一貫させるか一つの Camera matrix、明示的 space、round-trip property test
4. Hit Test をどう scale させるか同等結果を保ちながら Geometry Narrow Phase の前に viewport/spatial Broad Phase を追加
5. なぜ Tool は state machine か各 event に一つの state/Transition/Commit boundary があり、Cancel を証明できるから
6. なぜ Text に DOM が必要かIME、Caret、Selection、Bidi、Clipboard、Screen Reader が browser text system を必要とするから
7. 3種類の scope をどう分けるかDocument は durable/shared、Session は local、Presence は TTL 付き transient broadcast
8. Undo の境界はどこかuser/AI の一つの意図を一つの Transaction とし、local committed work だけを反転
9. Migration をどう検証するかunknown→validate old→pure stepwise migration→validate current を fixture/idempotence/fault test で証明
10. Keyboard/Screen Reader をどう支援するかDOM Inspector、Focus Mapping、same-source Commands、Non-Drag Alternative
11. performance bottleneck を何が証明するかrepresentative device の p50/p95/p99 と input/model/query/render/present Trace
12. OffscreenCanvas は必要かmain-thread-contention の改善が transfer/protocol cost を上回る場合だけ有効化
13. GPU は必要かCanvas render/batching prototype が representative device 上の改善を証明した場合だけ有効化
14. なぜ自作または SDK かADR が product、TCO、license、migration、exit を比較し、この教材の spike は Hybrid を選択
15. Authority と CRDT の違いは前者は server が order/validate/reconcile、後者は provider が転送する shared type を同時 merge
16. なぜ AI を制約し取り消し可能にするかAI は信頼できない提案者で、schema、permission、preview、transaction、undo が影響を制限
17. disconnect/crash/upgrade/downgrade からどう復旧するかSnapshot+log+queue+idempotency、2 slots、version handshake、migrations、read-only Compatibility Renderer
  • Durable Document、Local Session、Ephemeral Presence が Schema、Store、network channel で明示的に分離されている。
  • Server-Authoritative と Yjs/CRDT の ordering、offline、permission、Undo、Presence、audit の違いを混同せず説明できる。
  • Snapshot、Incremental Update、Sequence、Reconnect、Offline Queue、Optimistic Reconcile、Compaction がテストされている。
  • 8種すべての AI Action に strict Schema、Validation、Permission、affected IDs がある。
  • すべての AI change が Preview から始まり approval policy に従い、Transaction、Undo、Provenance、Cancellation、Idempotency、Rate Limit、Audit が完全である。
  • Asset と Document の write order に明示的 protocol があり、Schema version と Protocol version は別である。
  • Capstone の Renderer、model、text、assets、migration、testing、performance、collaboration、AI にすべて証拠があり、一枚の screenshot で完成の代わりにしない。

5歳児に説明する

「CRDT」「Server-Authoritative」「Idempotency」「Transaction」「Audit」という言葉を使わないでください。共有する物語、自分のランドセル、いまだけの旗を混ぜられない理由を説明してください。先生のスタンプと統合可能な透明紙は何が違うでしょうか。AI が許可済みブロックだけを使い、同じメモを2回出しても作業を1回だけ行う必要があるのはなぜでしょうか。

専門用語を使わない参考解答

物語の本文は全員が長く見られる必要があります。自分が読んでいるページは自分のランドセルだけに入れます。友だちがいま指している場所は、その友だちが帰ったら消えるべきです。先生の方法ではすべての変更を先に検査して番号を付け、全員が先生のスタンプ入りの本を信用します。透明紙なら全員が書き、あとで同じ規則によって変更を組み合わせられますが、「誰が何を消せるか」には別の規則が必要です。AI は8種類のブロックカードからだけ選びます。まず例を並べて触れる場所を説明し、必要な承認を待ち、変更を一度に完了して、取り消しカードと署名を残します。切断で同じ番号のメモが2回届いても、最初の結果だけが有効です。