Files
Socrates/docs/sync.md
dtoro f1c4566576 Initial commit — design docs + Phase 0 validation harness
Sets up the Socrata project repo with:

docs/ — strategy and design documents
  - idea.md: full product vision
  - implementation-plan.md: Phase 0 + Phase 1 MVP plan
  - phase-0-validation.md: 2-week validation experiment strategy
  - phase-0-plan.md: concrete Phase 0 build plan
  - phase-0-results.md: Phase 0 gate outcome — GO for MVP
  - sysml-modeling.md: metamodel + SE discipline + validation rules
  - socrates.md: agent character, surfaces, modes, prompts, lifecycle
  - sync.md: bidirectional text↔diagram sync engineering
  - design-source/: HTML/CSS/JS handoff bundle from Claude Design

phase-0/ — validated harness (CLI, no UI, no DB)
  - LM Studio (local OpenAI-compatible) generation + detection + judge
  - PlantUML rendering for SysML model visualization
  - 10-seed corpus (8 working + 2 holdouts)
  - 5 corpus runs with iteration history in reports/
  - Final gate: 10/10 pass, mean 4.32/5, holdouts validated

Phase 1 MVP scope and milestones documented in implementation-plan.md.
2026-04-28 22:07:38 +02:00

30 KiB
Raw Blame History

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 milestone M5, and the integrity layer that lets 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).
  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 ModelOps.

// lib/sync/ops.ts

export type ModelOp =
  // Block ops
  | { kind: 'add-block'; tempId?: string; block: Block }
  | { kind: 'update-block'; blockId: string; patch: Partial<Block> }
  | { 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<Property> }
  | { kind: 'remove-property'; blockId: string; propertyId: string }
  // Association ops
  | { kind: 'add-association'; tempId?: string; association: Association }
  | { kind: 'update-association'; associationId: string; patch: Partial<Association> }
  | { kind: 'remove-association'; associationId: string }
  // Constraint ops
  | { kind: 'add-constraint'; tempId?: string; constraint: Constraint }
  | { kind: 'update-constraint'; constraintId: string; patch: Partial<Constraint> }
  | { kind: 'remove-constraint'; constraintId: string }
  // Requirement ops
  | { kind: 'add-requirement'; tempId?: string; requirement: Requirement }
  | { kind: 'update-requirement'; requirementId: string; patch: Partial<Requirement> }
  | { 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()

// 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<string, string>;  // 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<ApplyOpsResult>;

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 (S1S5 from sysml-modeling.md §7) 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 ModelOps 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 Steps. 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:

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 ModelOps 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.)

// 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+50200: server processes
  4. T+50200: server SSE patch arrives
  5. T+50200: chip pops in

That's 50200ms 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+50200: server confirms, returns canonical id.
  5. T+50200: 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+50200: 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:

{ 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.

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.