コース進捗 コース目次 18レッスン中 18件を公開中
第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標
第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール
第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧
第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI
まずは5歳児にもわかるゲームから
この章で覚える真実は一つだけです。人間も AI も、検証可能で追跡可能かつ取り消し可能な Action を通じてのみ、共有 Document を変更できます。
3人の子どもが1冊の物語を一緒に書きます。「物語の本文」は共有の本棚に置きます。それぞれのランドセルには「いま何ページを開き、どのペンを選んでいるか」が入ります。いま掲げている色旗は「指している場所と見ているページ」を表し、子どもが教室を出ると消えます。まず予想してください。旗の位置を物語へ永久に印刷したら、翌日はどうなるでしょうか。本が期限切れの動作で埋まります。では全員のランドセルを同期したらどうでしょうか。A がページをめくるたび、B の表示まで奪ってしまいます。
先生は共同執筆の方法を二つから選べます。一つ目では、変更メモをすべて先生へ渡します。先生が名前、権限、ページ番号を確認し、連続番号のスタンプを押して全員へ配ります。先生の版が authoritative です。二つ目では、全員が同時の線を統合できる特殊な透明紙を使います。変更を交換したあと、全員が同じ統合ルールを適用します。これは CRDT に似ています。ただし、どちらの紙も「子どもは校長先生の署名を消せない」という規則を自動では守りません。Permission と domain validation は依然として必要です。
AI は4人目の子どもです。渡すのは許可された8種類の図形ブロックカードだけで、本全体を持ち出せません。最初に Preview を並べ、影響するブロックを列挙します。承認が必要なら人間を待ちます。完了後には provenance と取り消しカードを残します。同じ番号の依頼を再送しても実行は一度だけです。
- 共有する物語Durable Document
- 自分のランドセルLocal Session
- いまだけの旗Ephemeral Presence
- AI には許可済みブロックだけTyped Action
- スタンプ前に PreviewValidation、Undo、Audit
おもちゃを Canvas に置き換える
| おもちゃの物語 | Canvas Lab | ライフサイクルと規則 |
|---|---|---|
| 共有する本文 | Durable Document | Shape、Binding、Comment、Asset reference。永続化し Migration する |
| 自分のランドセル | Local Session | Camera、現在 Page、Tool、local Selection。プロダクトが選ばない限り broadcast しない |
| いまだけの旗 | Ephemeral Presence | Cursor、共有 Selection、Viewport、online state。TTL を持ち永続化しない |
| 子どもの名札 | Identity | authentication system が発行。client の自己申告を信用しない |
| 先生のスタンプ | Authoritative Server | Validation、ordering、conflict handling、broadcast |
| 統合できる透明紙 | CRDT Shared Types | 同時 update を CRDT rule で merge |
| 紙を運ぶ配達員 | Network Provider | CRDT update を転送し、業務 Permission は決めない |
| 切断中のメモを入れる袋 | Offline Queue | reconnect 後の replay 用に order、version、idempotency key を保持 |
| 定期的な製本 | Snapshot / Compaction | archive 可能な Operation Log を切り詰める |
| AI のブロックカード | Typed AI Action | 有限 union。任意 code や任意 patch を許可しない |
| テーブル上の試し置き | Preview | authoritative revision を変えず、期限切れや cancel が可能 |
| 取り消しカード | Undo Transaction | この committed action だけを戻し、remote history は戻さない |
| ブロックを借りる署名 | Provenance / Audit | model、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 |
|---|---|---|---|---|
| Document | Page、Shape、Binding、Comment、Asset ID | あり。version 管理 | reliable sync | Room / authoritative storage |
| Session | Current Page、Camera、Tool、private Selection、panel state | 任意で local | 既定では送信しない | User + device |
| Presence | Cursor、shared Selection、Viewport、status | なし。TTL | best-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-Authoritative | Yjs / CRDT |
|---|---|---|
| Ordering/conflict | Server sequence と application rule | CRDT data type の deterministic merge |
| Offline | 独自 queue/rebase/conflict | update はあとで自然に merge。provider が persistence を決定 |
| Permission validation | commit 前に直接 centralized validation | update-level/post-merge validation は難しく特別設計が必要 |
| Central dependency | Room authority が中心 | merge に中心不要。provider は availability に影響 |
| Undo | local Operation/Command を反転 | UndoManager が local origin を追跡 |
| Presence | 独立 ephemeral channel | Awareness protocol。Y.Doc とは別 |
| Audit | Domain 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 が永久 pending | seq/conflict/audit | delete-vs-update rule を定義 | 2-client barrier test | reconcile/conflict 表示 |
| 2ユーザーが同時移動 | Shape が戻る、または点滅 | pending/confirmed trace | Sequence + product merge policy | Concurrent-Move fixture | authoritative position + pending replay |
| offline edit が旧 Schema | reconnect parse failure | handshake versions | migration 後 replay、または read-only reject | v2 offline→v3 server | local copy を export して upgrade |
| Presence を永続化 | reopen 後に ghost Cursor | Snapshot scope audit | TTL Store を分離 | reload/expiry test | 誤 Record を削除 |
| AI が二重実行 | Shape が2回移動、comment 重複 | Idempotency unique/audit | Atomic Result Record | reply-loss retry | 最初の Result を返す |
| AI が権限外 object を編集 | sensitive content 変更 | denied audit/permission trace | affected ID ごとに authorize | Mixed Batch test | Transaction 全体を rollback |
| AI が途中切断 | 半分の Shape Batch | revision/transaction log | atomic commit | 全 step で disconnect | 同じ key で retry または Undo |
| Asset upload と Document が競合 | reference が永久 missing | Asset/Document states | intent→scan→ready→reference | reordered-completion test | placeholder/ready ID へ rebind |
| Server/Client Protocol が不一致 | message を誤 parse | handshake rejection | 明示的 compatibility window | v1↔v2 matrix | read-only、refresh/fallback |
| Migration 後に旧 client reconnect | 新 field を旧 client が削除 | Schema guard | old-client write を block し release 調整 | old-reconnect fixture | queue 保持、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 test | delete Preview、affected IDs、approval text、Undo、audit 可読性を確認 | AI は有限 Action を迂回できず、retry/disconnect で二重実行せず、audit は raw Prompt を漏らさない |
capstone:verify capability list と performance budget | Keyboard/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回届いても、最初の結果だけが有効です。