Course progress Course outline 18 of 18 lessons available
Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates
Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools
- 05 Chapter 5: Give the Pixel World a Registry available now
- 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
- 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
- 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
- 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans available now
Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery
- 10 Chapter 10: Make the Editor Feel Right available now
- 11 Chapter 11: Drawn Text Is Not Editable Text available now
- 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
- 13 Chapter 13: Time Machines and Old Boxes available now
- 14 Chapter 14: Looking Correct Is Not Being Correct available now
Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI
- 15 Chapter 15: Do Not Search Ten Thousand Children One by One available now
- 16 Chapter 16: Keep the Front Desk Out of the Kitchen—Worker and GPU Upgrades available now
- 17 Chapter 17: Build the Car or Buy a Proven Chassis? available now
- 18 Chapter 18: People and AI Edit the Same Ledger Current lesson
Start with a game a five-year-old can understand
The one truth in this chapter: both people and AI may modify a shared Document only through validated, traceable, reversible actions.
Three children write one storybook together. The “story text” lives on a shared shelf. Each child’s backpack holds “which page I am on and which pen I selected.” A colored flag held up right now means “where my finger is and which page I am looking at,” and it disappears when the child leaves the classroom. Predict first: what happens tomorrow if flag positions are permanently printed in the storybook? The book fills with expired gestures. Now predict what happens if everyone synchronizes their backpacks. When A turns a page, A steals B’s view too.
The teacher can choose two ways to write together. In the first, every change slip goes to the teacher, who checks the name, permission, and page number, stamps it with a consecutive number, and broadcasts it. The teacher’s version is authoritative. In the second, every child uses special transparent paper that can merge concurrent strokes. After exchanging changes, everyone applies the same merge rules; that resembles a CRDT. But neither kind of paper automatically enforces “children cannot erase the principal’s signature.” Permission and domain validation are still required.
AI is the fourth child. It receives only eight approved shape-block cards and cannot carry away the whole book. It first arranges a Preview and lists which blocks will be affected. If approval is required, it waits for a person. After completion it leaves provenance and a reversal card. Resending a request with the same number executes it only once.
- Shared storyDurable Document
- Your own backpackLocal Session
- The flag right nowEphemeral Presence
- AI gets only approved blocksTyped Action
- Preview before stampingValidation, Undo, and Audit
Translate the toys into Canvas
| Toy storybook | Canvas Lab | Lifecycle and rules |
|---|---|---|
| Shared text | Durable Document | Shape, Binding, Comment, and Asset references; persisted and migrated |
| Your own backpack | Local Session | Camera, current Page, Tool, and local Selection; not broadcast unless the product chooses |
| The flag right now | Ephemeral Presence | Cursor, shared Selection, Viewport, and online state; TTL and no persistence |
| Child’s name card | Identity | Issued by the authentication system; never trust a client’s claim |
| Teacher’s stamp | Authoritative Server | Validation, ordering, conflict handling, and broadcast |
| Mergeable transparent paper | CRDT Shared Types | Concurrent updates merge by CRDT rules |
| Courier exchanging paper | Network Provider | Transports CRDT updates; does not decide business permissions |
| Bag for disconnected slips | Offline Queue | Preserves order, version, and idempotency keys for replay after reconnect |
| Periodic binding | Snapshot / Compaction | Truncates an Operation Log that is safe to archive |
| AI block card | Typed AI Action | Finite union; no arbitrary code or arbitrary patch |
| Trial arrangement on the table | Preview | Does not change the authoritative revision; can expire or be canceled |
| Reversal card | Undo Transaction | Reverses only this committed action, never remote history |
| Signature when borrowing blocks | Provenance / Audit | Model, tool version, approver, affected IDs, and result |
Where the analogy stops: a CRDT is not merely stacked transparent paper. It depends on unique clients/clocks, causal information, data types, and a provider. Convergence does not guarantee correct business semantics. Presence broadcast and TTL are implemented by a provider/server and do not guarantee “never written to disk”; the team must explicitly exclude it in storage. An AI Screenshot is sensitive context. Calling it “looking at the table” does not waive permission, minimization, or retention policies.
Kill the wrong intuitions first
- “Last writer wins is simplest.” It overwrites other users’ independent changes wholesale and is especially dangerous for offline clients. At minimum, use revision/sequence and a conflict policy.
- “CRDTs have no conflicts.” A CRDT can make replicas converge, but the product must still define the result when A deletes an object while B edits it. Permissions, references, assets, and Schema may still fail.
- “tldraw sync is Yjs.” Chapter 17 verified that tldraw sync uses server-authoritative reconciliation; the official project explicitly says it is not Yjs/CRDT.
- “Presence is also a Record, so put it in the Snapshot.” Cursor and online state expire quickly. Persistence leaks behavior, bloats data, and restores ghost users.
- “An optimistic update that appears successfully is committed.” The client must distinguish pending from confirmed. If the server rejects it, reconcile instead of silently retaining it.
- “AI returned JSON, so successful
JSON.parsemeans we can execute it.” Schema, semantic Validation, Permission, approval, idempotency, Rate Limit, and an atomic Transaction are still missing. - “Giving AI the entire Store makes it smartest.” Minimal context is safer and more stable: relevant Structured Shapes, Selection, a bounded interaction summary, and a permission-cropped Screenshot.
- “If AI disconnects, start over.” If the server already committed, rerunning repeats Delete/Move. Atomically register the Action ID and Idempotency Key in the result table.
- “Undo restores an old Snapshot.” That erases subsequent changes from other users. Local Undo reverses only a local origin/transaction and revalidates against current state.
Production backpack
Prerequisites
Chapter 5 decouples Document from Renderer. Chapter 13 supplies Version, Migration, Transaction, Crash Recovery, and three scopes. Chapter 17 defines Build/Buy/Hybrid and treats the tldraw 5.3.2 Store only as a projection. Both collaboration protocols and AI Actions may call only the same domain Command Boundary; neither may bypass History, permission, or audit.
Formal knowledge
Separate the three kinds of data first
| Scope | Contents | Persistence | Network | Typical owner |
|---|---|---|---|---|
| Document | Page, Shape, Binding, Comment, and Asset ID | Yes, versioned | Reliable sync | Room / authoritative storage |
| Session | Current Page, Camera, Tool, private Selection, and panel state | Optional local | Not sent by default | User + device |
| Presence | Cursor, shared Selection, Viewport, and status | No; TTL | Best-effort, high-frequency | Current connection |
Identity comes from an authentication token/session. Permission is divided into room, record, and action/field levels. Asset Sync does not put a Blob into the operation log. First obtain an upload intent; upload, validate, and scan; receive an immutable Asset ID; then reference it in a Document Transaction. A Document reference in pending/missing state has a placeholder and recovery path.
Mode one: Server-Authoritative Sync
The flow is Client Diff → Authoritative Server → Reconciliation → Broadcast. A client sends {operationId, baseRevision, schemaVersion, protocolVersion, commands}. The server authenticates Identity and checks room lifecycle, protocol/Schema, Permission, Command schema, references, and domain invariants. Within one database Transaction, it writes the Operation Log, Document revision, and Audit, assigns a monotonic Sequence, returns confirmation, and broadcasts. The client retains a confirmed base plus optimistic pending operations. After an authoritative result arrives, it replays any still-pending local operations.
Reconnect begins with a version handshake, then requests an Incremental Update using lastSequence. If the log has been compacted or the gap is too large, download a Snapshot, then replay the Offline Queue. Every operationId is idempotent, so retransmitting a queued item does not execute it twice. A Conflict may be rejected, rebased by field, or handled by an application-specific rule. “Two users move simultaneously” could select the last ordered Move, merge relative deltas, or show an explicit conflict, but the protocol must state which. Server Validation is straightforward to centralize. The cost is that the server/room becomes the center of ordering and availability, and the team must design its own offline-merge rules.
Room Lifecycle includes create, warm/load, active, idle flush, archive, and delete/restore. Every room needs one logical serialization point. It may be a single actor/partition or conditional database writes and transactions; it does not require one physical writer machine. An Operation Log supports replay and Audit, but before it grows without bound it needs Snapshot + Compaction. The server cannot wait for “all offline clients that might return in the future” to acknowledge, so the protocol declares a cutoff. A client whose lastSequence predates the cutoff no longer receives the old log; it downloads the current Snapshot and then replays a still-compatible local queue. Compliance Audit has an independent retention policy and cannot disappear with sync-log compaction. Schema Version describes the Document; Protocol Version describes messages. One number cannot stand for both.
Mode two: Yjs / CRDT
The flow is Concurrent Shared Types → CRDT Merge → Network Provider → Optional Offline Storage. Y.Map/Y.Array/Y.Text inside a Y.Doc emit updates carrying causal information. Receiving them in any order, including duplicates, integrates them by CRDT rules and eventually converges. A Provider transports updates over WebSocket/WebRTC or another channel. y-indexeddb can persist local offline state. Awareness is a separate ephemeral protocol for Cursor, Selection, and user state. It is not written to Y.Doc and must not become Durable Document.
Yjs UndoManager uses tracked origins to follow only local transactions, avoiding reversal of remote users. CRDTs fit strong offline operation, decentralized/multiple providers, or fine-grained concurrent merging. However, if a normal server cannot see or interpret encrypted updates, it is difficult to enforce authoritative Permission before every field change. Options include validating room access at the provider/server layer, applying an update first to a candidate copy of a server-side Y.Doc and then checking invariants, partitioning shared types, or designing a controlled command gateway. Never write an untrusted update into the authoritative Y.Doc first and hope to reject it later with a reliable “undo.” Every approach has latency, optimistic-client reconciliation, and security tradeoffs. CRDT does not solve Asset Blobs, Schema Migration, audit semantics, or paid permissions for you.
| Dimension | Server-Authoritative | Yjs / CRDT |
|---|---|---|
| Ordering/conflict | Server sequence and application rules | Deterministic merge of CRDT data types |
| Offline | Custom queue/rebase/conflict | Updates merge later by nature; provider decides persistence |
| Permission validation | Direct centralized validation before commit | Update-level/post-merge validation is harder and needs special design |
| Central dependency | Room authority is central | Merge needs no center; provider still affects availability |
| Undo | Reverse local Operation/Command | UndoManager tracks local origin |
| Presence | Separate ephemeral channel | Awareness protocol, still separate from Y.Doc |
| Audit | Domain Operations are easy to explain | Raw updates are hard to explain; require semantic audit |
| Best fit | Strong business rules, authoritative permissions, auditable workflows | Strong offline support, fine-grained concurrency, and shared types |
Both modes can build an editor and can be combined, but their names are not interchangeable. Canvas Lab chooses Server-Authoritative for the Visual Operations scenario because permissions, AI approval, audit, and business invariants take priority. Presence uses an independent best-effort channel. If strongly offline text later becomes central, evaluate Y.Text for selected fields rather than replacing the entire Domain with CRDT at once.
AI-Native Canvas is a constrained protocol
AI receives only permission-filtered Structured Shape Data, necessary Screenshots (redacted, region-limited, and briefly retained), the current Selection, a bounded Interaction History summary, and the Action Schema. The only allowed Actions are CreateShape, UpdateShape, DeleteShape, MoveShape, ConnectShapes, AlignShapes, GroupShapes, and AddComment. Do not accept JavaScript, SQL, arbitrary JSON Patch, URL fetch, or an unregistered Shape type.
Every Action Envelope contains Schema/Version. Validation covers structure, numeric ranges, references, cycles, and domain invariants. Permission checks the action, field, and Affected IDs after resolving the real cascade one by one. Preview generates before/after/diff/cost on an uncommittable clone. A Preview token must bind actor + document + baseRevision + canonical action hash + expiry; otherwise someone can approve A and use the same token to submit B. Approval Policy selects auto/confirm/deny based on deletion, count, and sensitive objects. Transaction makes an entire Batch succeed or fail. Undo stores an inverse patch that is validatable against current state; it never restores a whole old Snapshot and erases later remote work. A trusted model gateway records Provenance—model, tool version, prompt hash, request/actor/approver—rather than trusting client claims. Cancellation works only before commit; after commit, use Undo. Bind the Idempotency Key to a request hash, and register the result under a unique constraint in the same database Transaction. Rate Limit by actor/room/action/cost. Audit Log is append-only and redacted.
Evidence and compatibility (verified 2026-08-29)
tldraw sync’s TLSocketRoom retains the authoritative document and reconciles diffs; see the official tldraw sync and Collaboration server architecture. The official project states that tldraw sync is not a CRDT. See the Yjs Introduction for concurrent Shared Types, updates, providers, and offline support; Awareness & Presence for transient user state; and Y.UndoManager for local undo origins. Provider encryption, authentication, persistence, and scaling behavior differ; verify the chosen implementation. “Uses Yjs” does not by itself prove permissions or offline UI correctness.
Engineering increment for this chapter
Starting point: Chapter 17’s Hybrid SDK can edit and export on one machine. Finish line: a Server-Authoritative room supports Snapshot, Incremental Update, Sequence, Reconnect, Offline Queue, Optimistic Reconciliation, Presence, and an Asset protocol. AI submits through a complete constrained Action pipeline with Preview/Approve/Cancel/Undo/Idempotent Retry/Audit.
Add these files and interfaces:
src/collab/protocol.ts: Schema/Protocol version and operation/ack/resync;src/collab/AuthoritativeRoom.ts: server validation, sequence, log, and compaction;src/collab/OfflineQueue.ts: idempotent reconnect;src/presence/PresenceHub.ts: TTL, capacity, and redaction;src/ai/action-schema.ts: eight discriminated actions;src/ai/ActionService.ts: preview, approval, transaction, undo, provenance, rate, and audit;tests/collab/races.test.tsandtests/ai/actions.test.ts: failure and security regressions.
First seal everything AI may do into a Zod schema. No unknown may cross parse. Fields use an allowlist instead of letting the model submit a free-form Shape Record:
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 prefixes are part of the Schema too. If one broad Id is shared, DeleteShape accepts comment-* and fails only deep in execution. ShapeIds also rejects duplicates, preventing [shape-a, shape-a] from satisfying “at least two” while operating on one object. provenance is not placed in the untrusted Envelope. An authenticated model gateway passes it separately as TrustedInvocation.
The executor below turns “parse, then mutate Store directly” into a safe pipeline. A real database must implement atomicity for Repository.transaction. The Preview token binds action hash, revision, actor, and expiry, so one person’s approval cannot be reused:
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), the inverse Patch, and Audit must be protected by the same database Transaction and idempotency unique index. Looking up inside the transaction provides an early return but cannot replace the unique constraint. A Preview may store before/after clones briefly. Real Undo persists before/after only for affected records and checks/rebases against current authoritative state; it never restores an entire old Document. A failed domain Transaction does not write a “committed” Audit. Rejections such as forbidden access or rate limiting belong in a more tightly restricted, equally redacted security log. If Cancel races with commit after the last cancellation check, whichever database operation succeeds first decides. If commit already succeeded, return the Result and offer Undo rather than falsely claiming cancellation.
If Yjs is selected, Document and Awareness still remain separate, and local Undo tracks only its own 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 }); // Not written to 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();
Back to the storybook: Y.Map is the special transparent paper, while Awareness is the flag. Putting Cursor into doc.getMap('shapes') prints the flag into the permanent story again. Local origin makes the reversal card undo only your own strokes.
Tests must prove duplicate handling, permission rejection, disconnection, and atomicity—not just “normal creation”:
import { expect, test } from 'vitest';
import { AiActionSchema } from '../action-schema';
test('Action schema rejects arbitrary fields and arbitrary 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('retrying the same idempotency key returns only the same 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('an unauthorized object fails the entire batch and leaves no partial 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);
});
Run npm exec vitest run tests/collab tests/ai. Expect out-of-order, duplicate, version mismatch, conflict, offline replay, Presence TTL, and AI schema/permission/approval/cancel/idempotency/atomic rollback/audit tests to pass. Run npm exec playwright test tests/multiplayer-ai.spec.ts; expect two-browser concurrency, disconnect/reconnect, AI Preview/approval/Undo, and Keyboard/Screen Reader tasks to pass. Run npm run capstone:verify; expect an evidence path for every capability, not one aggregate Boolean.
Final Capstone: Visual Operations Canvas
The deliverable must include all of these together: custom business Shapes, Text DOM editing, Image/Secure Assets, Connector/Binding, Snapping, Keyboard, History, Persistence, Migrations, PNG/SVG/JSON/high-resolution Export, Observability, and a Performance Budget. Worker/GPU may be enabled only after the Chapter 16 Benchmark proves the case. Add Multiplayer, Offline Queue, Presence, AI Actions, Permission, and Audit Log. Every “optional” technology means “evidence supports turning it on or leaving it off,” not skipping the decision.
Break it on purpose
| Injected failure | Symptom | Evidence | Fix | Regression test | Recovery |
|---|---|---|---|---|---|
| A deletes while B edits | Object resurrects or B remains pending forever | seq/conflict/audit | Define delete-vs-update rule | Two-client barrier test | Reconcile/show conflict |
| Two users move simultaneously | Shape jumps back or flickers | pending/confirmed trace | Sequence + product merge policy | Concurrent-Move fixture | Authoritative position + replay pending |
| Offline edit uses old Schema | Reconnect parse fails | Handshake versions | Replay after migration or reject read-only | v2 offline→v3 server | Export local copy and upgrade |
| Presence is persisted | Ghost Cursor after reopen | Snapshot scope audit | Separate TTL Store | Reload/expiry test | Delete erroneous Records |
| AI executes twice | Shape moves twice or comment duplicates | Idempotency unique/audit | Atomic Result Record | Reply-loss retry | Return first Result |
| AI edits unauthorized object | Sensitive content changes | Denied audit/permission trace | Authorize every affected ID | Mixed Batch test | Roll back entire Transaction |
| AI disconnects midway | Half a Batch of Shapes | Revision/transaction log | Atomic commit | Disconnect at every step | Retry same key or Undo |
| Asset upload conflicts with Document | Permanently missing reference | Asset/Document states | intent→scan→ready→reference | Reordered-completion test | Placeholder/rebind ready ID |
| Server/Client Protocol differs | Message misparsed | Handshake rejection | Explicit compatibility window | v1↔v2 matrix | Read-only, refresh/fallback |
| Old client reconnects after Migration | Old client deletes new fields | Schema guard | Block old-client writes and coordinate release | Old-reconnect fixture | Preserve queue, upgrade, then replay |
Pass with evidence
| Automated evidence | Manual evidence | Passing condition |
|---|---|---|
| Two-client races, offline reconnect, idempotency, Protocol/Schema skew, and Presence TTL | Disconnect/recover on two real devices; rehearse conflict notice and Local Undo | Replicas converge; Sessions do not steal one another; Presence expires; Undo does not erase remote changes |
| Schemas for eight AI Actions, forbidden access, preview/approval, cancel race, atomic rollback, and audit tests | Inspect delete Preview, affected IDs, approval text, Undo, and audit readability | AI cannot bypass the finite Actions; retries/disconnects do not duplicate execution; audit does not leak the raw Prompt |
capstone:verify capability list and performance budget | Keyboard/Screen Reader, low-end device, and crash/upgrade/downgrade recovery rehearsals | All 17 mastery questions have current code, test, Trace, or ADR evidence |
| Mastery question | Required answer |
|---|---|
| 1. Why was this Renderer selected? | Product semantics, object/change volume, accessibility, and Benchmark decide together; Document does not depend on it |
| 2. Why does Document not depend on Renderer? | It is durable business truth; Renderer is a replaceable projection |
| 3. How do coordinates remain consistent? | One Camera matrix, explicit spaces, and round-trip property tests |
| 4. How does Hit Test scale? | Add viewport/spatial Broad Phase before Geometry Narrow Phase while preserving equivalent results |
| 5. Why is a Tool a state machine? | Every event has one state/Transition/Commit boundary, and Cancel is provable |
| 6. Why does Text need DOM? | IME, Caret, Selection, Bidi, Clipboard, and Screen Reader require the browser text system |
| 7. How are the three scopes separated? | Document is durable/shared, Session is local, and Presence is transient broadcast with TTL |
| 8. Where is the Undo boundary? | One user/AI intention is one Transaction, reversing only local committed work |
| 9. How is Migration validated? | unknown→validate old→pure stepwise migration→validate current, with fixtures/idempotence/fault tests |
| 10. How are Keyboard/Screen Reader supported? | DOM Inspector, Focus Mapping, same-source Commands, and a Non-Drag Alternative |
| 11. What proves the performance bottleneck? | Representative-device p50/p95/p99 and an input/model/query/render/present Trace |
| 12. Is OffscreenCanvas needed? | Enable it only when main-thread-contention gains exceed transfer/protocol cost |
| 13. Is GPU needed? | Enable it only when a Canvas render/batching prototype proves gains on representative devices |
| 14. Why build or use an SDK? | The ADR compares product, TCO, license, migration, and exit; this course’s spike selects Hybrid |
| 15. Authority versus CRDT? | The former server-orders/validates/reconciles; the latter concurrently merges shared types while a provider transports |
| 16. Why must AI be constrained and reversible? | It is an untrusted proposer; schema, permission, preview, transaction, and undo bound impact |
| 17. How does the system recover from disconnect/crash/upgrade/downgrade? | Snapshot+log+queue+idempotency, two slots, version handshake, migrations, and read-only Compatibility Renderer |
- Durable Document, Local Session, and Ephemeral Presence are explicitly separated in Schema, Store, and network channels.
- Explain ordering, offline, permission, Undo, Presence, and audit differences between Server-Authoritative and Yjs/CRDT without conflating them.
- Snapshot, Incremental Update, Sequence, Reconnect, Offline Queue, Optimistic Reconcile, and Compaction are tested.
- All eight AI Actions have strict Schema, Validation, Permission, and affected IDs.
- Every AI change starts with Preview and follows approval policy; Transaction, Undo, Provenance, Cancellation, Idempotency, Rate Limit, and Audit are complete.
- Asset and Document write order has an explicit protocol; Schema and Protocol versions remain separate.
- Renderer, model, text, assets, migration, testing, performance, collaboration, and AI in the Capstone all have evidence; one screenshot cannot stand in for completion.
Explain it to a five-year-old
Do not say “CRDT,” “Server-Authoritative,” “Idempotency,” “Transaction,” or “Audit.” Explain why the shared story, personal backpack, and current flag cannot be mixed. What differs between the teacher’s stamp and transparent mergeable paper? Why may AI use only approved blocks, and why must submitting the same slip twice perform the work only once?
A good jargon-free answer
Everyone needs to see the story text for a long time. The page I am reading belongs only in my backpack. Where a friend is pointing right now should disappear when they leave. In the teacher method, every change is checked and numbered first, and everyone trusts the teacher’s stamped book. With transparent paper, everyone can write and later combine changes by the same rules, but “who may erase what” still needs separate rules. AI may choose only from eight block cards. It first arranges an example and says what it will touch, waits for any required approval, completes the change all at once, then leaves a reversal card and signature. Even if a disconnect causes the same numbered slip to be delivered twice, only the first result counts.