# Bidirectional Sync — Engineering Design This document specifies how the **narrative text canvas** and **SysML diagram canvas** stay in sync with the underlying SysML model. It's the engineering substrate beneath everything in [implementation-plan.md](implementation-plan.md) milestone M5, and the integrity layer that lets [socrates.md](socrates.md)'s impact analysis assume a coherent model snapshot. This is the trickiest piece of Phase 1 engineering. The doc is dense intentionally — every decision below has knock-on consequences and reversing one usually means reversing several. --- ## 1. The problem Both surfaces display the same underlying SysML model. When the user edits one, the other must reflect the change. This is non-trivial because: 1. **Different edit granularities.** Text edits are character-level. Diagram edits are node-level. The two need a common alphabet. 2. **Mixed content in narrative.** Prose (free-form, no model implication) and chips (model references) are intermingled. Most text edits affect *neither* the model nor the diagram; some affect both. 3. **In-flight ordering.** Multiple edits can land while a previous one is still applying. We need a deterministic order. 4. **Mixed apply policies.** Some edits should auto-apply instantly; some must route through Socrates as proposals (per [socrates.md §2.5](socrates.md)). 5. **Cursor preservation.** A remote-originated change must not disrupt the user's caret or selection. 6. **Latency tolerance.** Users will not accept network-roundtrip latency on every keystroke. The design below addresses all six. The cost is a non-trivial server-side reconciliation engine; the alternative is a worse UX. --- ## 2. Core design decisions, at a glance | # | Decision | Rationale | |---|---|---| | 1 | **Hybrid: optimistic-local for prose & low-impact ops; server-authoritative for high-impact ops.** | Pure server-auth round-trips every keystroke; pure optimistic creates merge nightmares. Prose is local-free; model ops gate on impact class. | | 2 | **Common alphabet: `ModelOp`.** | Both surfaces emit and consume the same op type. Single source of truth for what a "change" means. | | 3 | **Single chokepoint: `applyOps()`.** | Every model mutation — from text, diagram, proposal approval, or detection — funnels through one function. Validation, classification, persistence, and fan-out happen in one place. | | 4 | **Chips reference by `refId` only; labels render from model.** | Eliminates whole categories of sync bugs. A chip never holds stale text. | | 5 | **Server-assigned monotonic `version` per project.** | Sequential consistency without CRDT. The single-writer pattern works because we have a single server authority. | | 6 | **SSE for fan-out, REST for ops.** | Bidirectional WebSockets are overkill for the actual traffic shape (ops out, patches in). | | 7 | **Optimistic-local with temp refIds for instant chip insertion.** | Lets `/block Foo → create new` feel instant; reconciliation maps temp → canonical id. | | 8 | **Per-transaction op batching.** | Pasting structured text yields one API call, not five. | | 9 | **Per-client `clientId` filters self-echo.** | The originating client doesn't re-apply its own op when the SSE patch echoes back. | | 10 | **Impact classification at the server.** | Clients don't decide whether an op auto-applies vs. opens a proposal — server does, with full model + dep-graph context. | --- ## 3. The vocabulary: `ModelOp` `ModelOp` is the canonical edit unit. Every change to the SysML model is expressible as a sequence of `ModelOp`s. ```ts // lib/sync/ops.ts export type ModelOp = // Block ops | { kind: 'add-block'; tempId?: string; block: Block } | { kind: 'update-block'; blockId: string; patch: Partial } | { kind: 'remove-block'; blockId: string } // Property ops | { kind: 'add-property'; blockId: string; tempId?: string; property: Property } | { kind: 'update-property'; blockId: string; propertyId: string; patch: Partial } | { kind: 'remove-property'; blockId: string; propertyId: string } // Association ops | { kind: 'add-association'; tempId?: string; association: Association } | { kind: 'update-association'; associationId: string; patch: Partial } | { kind: 'remove-association'; associationId: string } // Constraint ops | { kind: 'add-constraint'; tempId?: string; constraint: Constraint } | { kind: 'update-constraint'; constraintId: string; patch: Partial } | { kind: 'remove-constraint'; constraintId: string } // Requirement ops | { kind: 'add-requirement'; tempId?: string; requirement: Requirement } | { kind: 'update-requirement'; requirementId: string; patch: Partial } | { kind: 'remove-requirement'; requirementId: string } | { kind: 'add-relation'; requirementId: string; relation: RequirementRelation } | { kind: 'remove-relation'; requirementId: string; relationIndex: number }; ``` **Why `tempId`?** When the client optimistically creates an element before the server has assigned a canonical id, the op carries a `tempId` (e.g., `tmp_a8f3...`). The server may use it as the canonical id (preferred — no rewrite) or generate a new id and return a mapping. Either way, the client uses `tempId` to correlate its local optimistic state with the server's confirmation. **No `move-block`, `reorder-property`, `set-position`?** Those are `update-block` with a `position` patch. Keeping the op set narrow simplifies the validator and the impact classifier. --- ## 4. The single chokepoint: `applyOps()` ```ts // lib/sync/applyOps.ts interface ApplyOpsArgs { projectId: string; ops: ModelOp[]; source: { kind: 'text' | 'diagram' | 'proposal' | 'detection' | 'seed'; clientId?: string; // for self-echo filtering proposalId?: string; // present when source.kind === 'proposal' }; expectedVersion?: number; // optimistic concurrency check } interface ApplyOpsResult { status: 'applied' | 'proposed' | 'rejected'; // 'applied' — ops landed, model version bumped, patches fanned out appliedOps?: ModelOp[]; // canonical-id-resolved version of the input newVersion?: number; idMapping?: Record; // tempId → canonicalId // 'proposed' — ops queued as a Proposal, no model mutation yet proposalId?: string; // 'rejected' — validation failed errors?: Array<{ opIndex: number; code: string; message: string }>; } async function applyOps(args: ApplyOpsArgs): Promise; ``` **What it does, in order:** 1. **Concurrency check.** If `expectedVersion` is present and ≠ current model version, reject with a version-mismatch error. Caller should fetch the latest snapshot and retry (rare). 2. **Resolve tempIds.** Replace `tempId` with canonical ids in each op (use the tempId itself as canonical when possible — most temp ids are pre-validated UUIDs). 3. **Validate each op against the current snapshot.** Structural rules (S1–S5 from [sysml-modeling.md §7](sysml-modeling.md)) apply. An op that violates structure is rejected — entire batch fails atomically. 4. **Classify impact.** Each op gets a `low | medium | high` impact label based on the dep graph + op kind (see §7). 5. **Branch on the highest impact in the batch:** - All low → apply; persist; fan out. - Any medium or high → open a Proposal, persist the ops as `Proposal.ops`, **do not mutate the model yet**. Fan out a `proposal-opened` patch only. 6. **On apply:** - Run validators (semantic + traceability rules) against the new snapshot. If any new error appears, **rollback** and rejected. - Write a `ChangelogEntry` with payload = ops, source, reasoning (from proposal thread if applicable). - Bump model version. - Update projection tables (`Block`, `Association`, etc.). - Trigger background detection job (debounced). - Emit SSE patch. 7. **Return.** Result tells the caller what happened and supplies any tempId → canonical mapping. `applyOps` is the only function that mutates the model. Period. No backdoor. Detection findings, Socrates suggestions, proposal approvals, seed-screen handoff — all of them call this. --- ## 5. Text → ops: deriving `ModelOp`s from a ProseMirror transaction The narrative is a TipTap (ProseMirror) document. A custom inline node `chip` carries `{ refId: string, kind: 'block' | 'property' | 'association' | 'requirement' | 'constraint' }`. Crucially, **the chip does not store the displayed label** — labels are looked up live from the model snapshot at render time. ### 5.1 Transaction inspection A ProseMirror `Transaction` is a sequence of `Step`s. The relevant ones: - **`ReplaceStep`** — replaces a slice of the doc with another slice. Inserting or deleting a chip node shows up here. - **`ReplaceAroundStep`** — block-level structural change (heading promotion, etc.). Rarely produces ops. - **Mark steps** — irrelevant for chips. - **Attribute steps (`AttrStep`)** — used for in-place chip edits (e.g., changing `refId`). Rare; we generally re-create the chip. The text → ops pipeline lives in `lib/sync/text-to-ops.ts`: ```ts function transactionToOps( tr: ProseMirrorTransaction, snapshot: SysMLModel ): ModelOp[] ``` It walks each Step, detects chip insertions/removals/attr-changes, and emits ops. A pure prose edit (no chip touched) returns `[]`. ### 5.2 Chip insertion → ops A new chip lands in the doc. Two cases: - **Reference an existing element.** `refId` resolves in the snapshot. **No op emitted** — inserting a chip is a textual reference, not a model mutation. The dep graph picks up the new reference automatically because chips-in-narrative is a derived projection. - **Create a new element.** User typed `/block Foo` and picked "create new" from autocomplete. The slash-menu handler: 1. Generates a `tempId` (`tmp_${uuid}`) 2. Inserts the chip with `refId = tempId` 3. Emits an `add-block` op with `tempId` and the new block's defaults 4. Sends to server via `applyOps` 5. On success, replaces the chip's `refId = tempId` with the canonical id from `idMapping` ### 5.3 Chip removal → ops (the interesting case) User deletes a chip from prose. Removing the chip ≠ removing the underlying element — the element might still appear in the diagram, or be referenced by other chips, or satisfy a requirement. Two cases: - **Element is still referenced elsewhere** (other chips, diagram, dep graph) → **no op**. The chip removal is a pure text change. - **Element has no remaining references** → emit a *candidate* `remove-X` op, but mark it as `medium`-impact in classification. This routes through Socrates: "You just removed the last reference to `Tutor`. Should I delete the block from the model?" User confirms or undoes. This is the right default. Auto-deleting elements when the last chip is removed would surprise users. Asking is cheap. ### 5.4 In-place rename A chip's *displayed* label is rendered from the model. To rename, the user clicks the chip, an inline editor pops, edits the label, hits enter. That fires an `update-block` op with `patch: { label: 'New name' }`. Every other chip referencing this block re-renders automatically (label is a model lookup) — no per-chip mutation needed. This is why chip-as-pure-reference is load-bearing: rename is one op; the diagram, the rail, every chip in the narrative all reflect it without per-surface plumbing. ### 5.5 Paste behavior User pastes content from another document (or from another part of the same doc) that contains chips. Each pasted chip's `refId` may or may not resolve in this project's model: - Resolves → keep the reference. - Doesn't resolve → strip the chip but keep the label as plain text, with a margin note: "Reference to `Foo` couldn't be resolved here." (User can re-create as a new element via slash-menu if desired.) Pasted prose without chips is just prose — no ops. ### 5.6 Slash-menu autocomplete Slash commands (`/block`, `/property`, `/req`, `/assoc`, `/constraint`) trigger autocomplete: - Typing `/block ` shows a fuzzy-search list of existing blocks - Selecting one inserts a chip with that block's id - "Create new" at the bottom inserts a chip with a `tempId` and emits the corresponding `add-X` op - Property-chip flow first prompts for the parent block (drilldown), then fuzzy-searches that block's properties Property chips' `refId` is a composite: `${blockId}.${propertyId}`. ### 5.7 Batching A single ProseMirror transaction can produce multiple ops (paste of structured text, multi-chip selection delete). All ops from one transaction are sent as one batched `applyOps` call — atomic on the server side. --- ## 6. Diagram → ops: deriving `ModelOp`s from React Flow events React Flow exposes `onNodesChange`, `onEdgesChange`, `onNodesDelete`, etc. Each callback gives us structured events. | React Flow event | ModelOp | |---|---| | Node added (drag from palette) | `add-block` (or `add-actor`, `add-constraint` based on palette item) with `tempId` | | Node deleted | `remove-block` (medium-impact if dependents exist) | | Node position changed (`drag` end) | `update-block` with `patch: { position }` (debounced — only emit on drag end) | | Node label edited (inline) | `update-block` with `patch: { label }` | | Node properties edited (sidebar) | `update-block` with `patch: { properties }` (or per-property `add-property` / `update-property` / `remove-property`) | | Edge added | `add-association` with `tempId`; default `kind: 'association'` until user picks | | Edge label edited | `update-association` with `patch: { label }` | | Edge kind changed (sidebar) | `update-association` with `patch: { kind }` | | Edge deleted | `remove-association` | All of these flow through `lib/sync/diagram-to-ops.ts` and end up at `applyOps`. **Position changes are special.** They're cosmetic, frequent (during drag), and must not block UI. Approach: - Local state updates on every `onNodesChange` (smooth dragging) - Server `update-block { position }` only fires on `dragEnd` - Position ops are always `low`-impact; they auto-apply - The SSE echo of a position op to the originating client is filtered (clientId match) **Edges have no `tempId` round-trip dance** because edges are simple — server-side an `add-association` is just an insert, no race. --- ## 7. Impact classification `lib/sync/classify.ts` maps each `ModelOp` to `low | medium | high` based on the current model + dep graph. ### 7.1 The rules ``` low (auto-apply) add-block, add-actor, add-constraint — pure addition; no breakage add-property — pure addition add-requirement (with no relations) — pure addition; rule T1 will warn but not break add-relation — strengthens traceability add-association — additive; affects dep graph but doesn't break update-block { position } — cosmetic update-block { label } — chips re-render; nothing breaks update-property { name } — same update-requirement { text } — same update-association { label } — same update-constraint { label } — same medium (propose, single-card iteration ≤ 3) update-block { kind } — actor → block changes semantics update-property { type | multiplicity } — may invalidate values update-association { kind } — association → composition changes semantics update-association { fromBlockId | toBlockId } — re-anchors a relationship update-constraint { expression | appliesTo } — semantic shift update-requirement { relations } — affects traceability remove-property — may break chips & validation remove-association — affects dep graph remove-constraint — relaxes invariants remove-requirement — affects traceability remove-block (dependents = 0) — clean removal but worth confirming high (propose, modal recommendation card) remove-block (dependents > 0) — broad blast radius update-block { kind: '*' → 'system' or vice versa } — re-roots the SoI remove-relation (last satisfier of a req) — leaves req untraced any op explicitly tagged high by a project-specific constraint — Phase 1.5 ``` ### 7.2 Why server-side? The classifier needs: - The current model snapshot - The dep graph - Sometimes the result of running validators on the post-op snapshot All of which live server-side. Doing classification on the client would require shipping the dep graph to every client, which is wasteful, and would let a buggy or stale client mis-classify. Server is authoritative. ### 7.3 Batches For a multi-op batch, the batch's class is `max(class for each op)`. Any single high-impact op promotes the whole batch to high. ### 7.4 Override hooks A future user preference ("always confirm even cosmetic edits") can shift the threshold. MVP ships defaults only. --- ## 8. The SSE patch protocol After `applyOps` succeeds, the server emits patches over an authenticated SSE stream subscribed by every active canvas for the project. (Authentication TBD in Phase 1 since we have no users — placeholder.) ```ts // lib/sync/patches.ts export type SyncPatch = | { kind: 'ops-applied'; ops: ModelOp[]; // canonical ids; tempIds resolved version: number; sourceClientId?: string; } | { kind: 'ops-rejected'; sourceClientId: string; // only sent to the originating client errors: Array<{ opIndex: number; code: string; message: string }>; attemptedOps: ModelOp[]; } | { kind: 'proposal-opened'; proposalId: string; title: string; ops: ModelOp[]; impactSummary: ImpactSummary; sourceClientId?: string; } | { kind: 'proposal-resolved'; proposalId: string; status: 'approved' | 'rejected'; } | { kind: 'validation-issues'; issues: ValidationIssue[]; // delta — added/removed/changed version: number; } | { kind: 'detection-finding'; finding: Finding; } | { kind: 'snapshot-resync'; snapshot: SysMLModel; // sent on version skew or manual request version: number; }; ``` ### 8.1 What each canvas does On receiving a patch, each canvas: 1. **Filter self-echo.** If `sourceClientId === myClientId`, the originating client already applied this op locally and updated its temp→canonical mapping. Drop the patch. (Exception: a sourceless patch — e.g., a detection finding — always applies.) 2. **Version check.** If `version <= localVersion`, drop (late-arriving). If `version > localVersion + 1`, request snapshot resync. 3. **Apply ops to local state.** For text canvas: convert the ops to a ProseMirror transaction (chip refIds get re-rendered with new labels; chips for removed elements get stripped). For diagram canvas: feed React Flow a node/edge update. 4. **Preserve cursor / selection.** See §10. 5. **Re-render.** ### 8.2 Why SSE, not WebSocket? Traffic is asymmetric: high-volume ops out (REST), low-volume patches in (SSE). SSE is one-way server→client, perfect for this shape. WebSockets add complexity for no benefit here. Plus SSE auto-reconnects with `Last-Event-ID` for free. ### 8.3 Backpressure Client ignores patches if more than 50 are queued (rare; only on slow CPU). Triggers a snapshot resync to recover. Not a real concern for MVP scale. --- ## 9. Optimistic-local — making chip insertion feel instant Naive flow for `/block Foo → create new`: 1. T+0: user picks "create new" 2. T+1: send `add-block` to server 3. T+50–200: server processes 4. T+50–200: server SSE patch arrives 5. T+50–200: chip pops in That's 50–200ms of nothing happening between user action and visible result. Unacceptable for a tool used every second. Optimistic-local flow: 1. T+0: user picks "create new" 2. T+1: TipTap inserts chip with `refId = tempId`. Block immediately appears in the diagram (the diagram listens to local state too). Rail updates. 3. T+1: client sends `add-block` op with `tempId` to server. 4. T+50–200: server confirms, returns canonical id. 5. T+50–200: client maps `tempId → canonicalId` in: - The chip's `refId` attr in the ProseMirror doc (single attribute change, cursor unaffected) - The block's id in local React Flow state - The dep graph - Any open Socrates thread anchored to this element 6. T+50–200: SSE patch arrives, but it has `sourceClientId === myClientId` so it's filtered. ### 9.1 Failure rollback If the server rejects the op (rare — validation failure): - Client receives `ops-rejected` patch - Local state rolls back: chip is removed from the doc; if a corresponding diagram node was created locally, it's removed; rail re-renders - An inline error appears at the user's cursor: "Couldn't create block — `name already exists` (S5)" ### 9.2 Which ops can be optimistic? Only ops that classify as `low`-impact can be optimistic. Medium/high go through proposal flow which is inherently asynchronous and has its own UI (cards, iteration), so latency is expected and tolerable. This gives us the ergonomics of optimistic-local where it matters (typing, dragging) without the merge-hell of optimistic-everywhere. --- ## 10. Cursor and selection preservation ### 10.1 Text canvas When a remote-originated `ops-applied` patch lands while the user has a caret in the doc, we must not disrupt their position. ProseMirror has a built-in mechanism: `Transaction.mapping` maps document positions through a transaction. Approach: convert the incoming ops to a ProseMirror transaction, then map the user's selection through it before re-rendering. - An op affecting only the model (no chip insert/delete) → no transaction needed; just trigger chip re-render via React state update (chip labels are model-derived, so React's reconciliation handles it). - An op that removes a chip → ProseMirror transaction deletes the chip node; selection mapped through (caret moves left if it was inside or after). - An op that adds a chip from a remote source — rare, only happens with proposal approval — places the chip at the appropriate location (server tells us); selection unchanged unless cursor was at the insertion point. ### 10.2 Diagram canvas React Flow tracks selected node ids. After a remote patch: - Selected node still exists → re-select. - Selected node was removed → select its nearest dep-graph neighbor, or no selection. - Selected node was renamed → no action; React Flow re-renders with new label. ### 10.3 Active dock thread If the dock thread's anchor element is removed by a remote patch, the thread is auto-archived with a system message: "Anchor element `Foo` was removed; thread archived." User can unarchive if desired. --- ## 11. Ordering, versions, clientIds Each browser tab gets a stable `clientId` (UUID generated on connection, persisted in `sessionStorage`). The server maintains a monotonic `version: number` per project. Each successful `applyOps` increments it. Each op-emitting client sends `expectedVersion` with its op batch. If the server's current version differs, the client gets a version-mismatch error and must resync. ### 11.1 Single-writer correctness Because all mutations go through `applyOps`, which is a synchronized critical section per project, the version sequence is total and deterministic. No CRDT needed. ### 11.2 Lost ops on disconnect If the SSE connection drops mid-edit, the client's local state may diverge from the server. On reconnect: - Client sends its `lastSeenVersion` along with any buffered ops. - Server replays patches between `lastSeenVersion + 1` and current version. - Client applies them. - Then client's buffered ops apply (with `expectedVersion = currentVersion`). - Any conflict triggers `ops-rejected` and rollback. If the gap is too large (>200 patches), server sends a full `snapshot-resync` instead of replay. Client replaces local state. ### 11.3 Multi-tab Two tabs of the same user editing the same project: - Each tab has its own `clientId`. - Each receives the other's patches via SSE. - Optimistic-local ops that conflict (e.g., both tabs delete the same block) — server applies first, rejects second, second client rolls back. - Realistically rare in single-user MVP; system handles it. --- ## 12. Detection findings — not part of the model, but on the wire Detection findings (assumptions, risks, validation issues) are *attached* to model elements but don't mutate the model graph. They flow through the same SSE channel as a separate patch kind: ```ts { kind: 'detection-finding', finding: Finding } ``` Findings are persisted as projection rows; they have their own lifecycle (open / dismissed / resolved). The sync pipeline's only job is delivery — findings show up in the rail, margin, or dock per the rules in [socrates.md §2](socrates.md). Validation issues from `lib/sysml/validate.ts` work the same way: re-run on every successful apply, broadcast as `validation-issues` patches. --- ## 13. Failure modes & edge cases | Failure | Behavior | |---|---| | **LLM down during proposal mediation** | Proposal opens with stub `impactSummary: { ready: false }`. UI shows "analysis pending." User can wait or approve without analysis (modal warns about the risk). Retry on reconnect. | | **Network drop mid-edit** | Local optimistic state preserved. Reconnect → replay queue → resolve via §11.2. | | **Server rejects an op the user already saw locally** | Roll back; show inline error at the affected element. | | **Two devices delete the same element simultaneously** | First wins. Second receives `ops-rejected: element not found`. | | **User edits a chip whose underlying element was deleted remotely** | Chip is stripped on patch arrival; user sees their edit "vanish." We surface a margin note: "`Foo` was removed elsewhere." | | **Stale snapshot in client** | Detected via version-skew → snapshot resync. | | **Validation fails post-apply (rare; means a previously valid model became invalid)** | Apply is rolled back atomically. User sees "couldn't apply: [reason]." Should never happen with a correct validator — it's a defense-in-depth check. | | **Proposal approval after underlying model has shifted** | Proposal apply re-validates against current snapshot. If ops no longer valid, proposal is auto-rejected with explanation; user must re-author. | | **Hallucinated id in a Socrates-suggested op** | Caught at `applyOps` validation step; op rejected; Socrates re-prompted. | | **Op batch partial failure** | Atomic — entire batch fails or succeeds. No partial-apply state. | --- ## 14. Module layout ``` lib/sync/ ops.ts ModelOp type, op constructors, op codecs applyOps.ts The chokepoint — server-side classify.ts Impact classification (low/medium/high) text-to-ops.ts ProseMirror transaction → ModelOp[] diagram-to-ops.ts React Flow events → ModelOp[] ops-to-text.ts ModelOp[] → ProseMirror transaction (for remote patches) ops-to-diagram.ts ModelOp[] → React Flow node/edge updates patches.ts SyncPatch type, encode/decode sse-server.ts Per-project SSE channel; fan-out sse-client.ts Client-side subscriber; reconnect; replay client-state.ts Optimistic-local state, tempId tracking, rollback reconcile.ts Glue: receive patches → apply locally with cursor preservation ``` This layout maps cleanly onto M5 of the build sequence. --- ## 15. Verification End-to-end tests that exercise the full sync stack: 1. **Single-tab smoke:** open editor; insert a chip via slash-menu; verify diagram updates, rail updates, DB has the new block, version bumped. 2. **Optimistic-local rollback:** simulate a server rejection; verify local state rolls back cleanly; verify error message at affected element. 3. **Multi-tab convergence:** open same project in two tabs; edit in tab A; verify tab B's canvas updates within 500ms. 4. **Multi-tab conflict:** delete the same block in both tabs simultaneously; verify one wins, other gets `ops-rejected`, both end in same state. 5. **Disconnect-replay:** disconnect SSE; make 3 local ops; reconnect; verify all 3 apply. 6. **Disconnect-snapshot:** disconnect SSE; trigger a 200+-op gap (simulated); reconnect; verify snapshot resync replaces local state. 7. **Cursor preservation:** caret in middle of paragraph; remote op renames a chip in the same paragraph; verify caret position unchanged. 8. **Proposal flow:** delete a high-dep block; verify proposal opens, model not yet mutated; approve; verify ops apply atomically; verify changelog reasoning is captured. 9. **Paste of structured chips:** paste content with 3 valid chip refs and 2 invalid; verify 3 keep, 2 strip with margin notes. 10. **Position-drag flood:** drag a node continuously for 3 seconds; verify only one `update-block` op fires (on dragEnd); verify all tabs converge on final position. Round-trip property tests on `text-to-ops ∘ ops-to-text` and `diagram-to-ops ∘ ops-to-diagram` for the structural ops we expect in real usage. --- ## 16. Open questions - **Conflict-free chip-text collisions.** Two tabs simultaneously rename the same block to different labels. First wins; second gets rolled back. Is "last write wins" acceptable, or do we need a merge UI? *MVP: last write wins.* - **Op coalescing.** Should consecutive `update-block { position }` ops within N ms be coalesced server-side to reduce changelog noise? *Probably yes, but defer until we see real changelog growth.* - **Soft vs. hard validation on apply.** If a soft warning appears post-apply (e.g., T2 — block becomes unused), do we still apply? *Yes — soft warnings don't block.* If a hard error appears, it's a validator bug. *Defense-in-depth rollback.* - **Detection-job triggering.** Currently debounced 3s after last apply. Should certain ops (e.g., `add-requirement`) trigger detection immediately? *Maybe — defer to M8 calibration.* - **Cross-tab `clientId` reuse.** Each tab gets its own. Is there a use case where two tabs of the same user should share a clientId for self-echo filtering? *No — each is independent.* - **History compaction.** The changelog grows monotonically. At what size do we compact (e.g., snapshot-and-prune ops older than 90 days)? *Defer to Phase 2 — branching introduces real history needs.*