* fix(sync): deduplicate surgical sync retries Persist split-file configuration and acknowledge operation IDs already committed remotely after a lost upload response. * fix(sync): preserve import author during clock pruning Keep the active causal full-state author in oversized stored clocks and reuse the stored protected IDs when classifying response-loss retries. * test(sync): harden supersync failure coverage Exercise real upload endpoints and exact operation IDs across response loss, validation failures, schema blockers, full-state boundaries, vector pruning, and concurrent edits. * fix(sync): heal a corrupt primary on duplicate-only uploads The .bak recovery path caches the CORRUPT primary's rev precisely so this cycle's conditional overwrite repairs sync-ops.json. The duplicate-retry short-circuit read that cache and returned before the write, leaving the primary corrupt whenever the recovered buffer already held every pending op. Flag the recovered entry and let those uploads fall through. Also stop synthesising a serverSeq for ops already in the buffer: the field is optional, the upload and download paths number ops differently, and a mixed batch could hand two ops the same value. * perf(sync): look the full-state author up once per upload Batch upload is off by default, so the guarded batch path was not the one serving production: the serial path queries the causal full-state author per op inside a single transaction, and a clock of 21-50 entries passes validation and trips the guard on every one of them. Memoize the author per transaction and resolve it lazily, so only an op whose clock actually overflows pays, and only once. This also retires the batch pre-scan and its loop-carried author, leaving both paths on one mechanism. Report the lookup through ProcessOperationResult so the upload summary stops under-reporting round-trips, and record why reconstructing the stored protected set loosens id-collision detection. * test(sync): wait for the committed title in renameTask renameTask blurred the textarea, slept 300ms and returned without ever checking the rename landed. Blur -> dispatch -> re-render outruns that delay on a loaded machine, so a following sync uploads without the rename op and the caller asserts against a task that was never renamed — which is what supersync 3.1 hits on CI but never locally. Wait for the new title instead, mirroring markTaskDone's done-state wait and the e2e no-waitForTimeout rule. * test(sync): dispatch focus so renameTask actually commits renameTask relied on el.focus() to emit a focus event, but these tests drive two clients as separate pages and only one page can hold focus, so on CI the event often never fires. TaskTitleComponent then keeps _isFocused=false, and resetToLastExternalValueTrigger resets tmpValue to the stored title on the next task-object emission. Blur therefore computes wasChanged=false, task.component skips update(), and the rename is silently dropped without ever becoming an op. That is supersync 3.1: client A's rename lives only in tmpValue, A syncs and uploads nothing, B uploads its done op, A downloads it, the task ref changes and the title reverts to the original — exactly the state the CI artifact captured. A real user always has real focus, so the app itself is unaffected. Dispatch focus explicitly, mirroring the synthetic input/blur already used here. Also correct the previous commit's claim: the toBeVisible wait matches tmpValue, a component-local signal rendered in both template branches, so it never observed the committed title and could not have fixed this. * docs: revert incidental prettier reformat of unrelated docs The master merge ran prettier across files it pulled in, reformatting three documents this branch has no business touching: markdown table cell padding plus *emphasis* -> _emphasis_, with no content change. handover.md documents two unrelated branches entirely. Restores them to master. vector-clocks.md keeps its edits — those are this branch's own and describe the pruning protection. * refactor(sync): drop the full-state author lookup's roundtrip accounting resolveFullStateAuthor memoizes per transaction, so the lookup it counts fires at most once per upload — the plumbing existed to report a number that is always 0 or 1, on a log line already counting dozens. The memo and its own accounting cancelled out. Removing didQuery lets resolveFullStateAuthor return string | undefined and getPruneProtectedIds return string[], instead of both carrying a tuple purely to feed the counter. uploadDbRoundtrips and the batch path's own counter are untouched. * test(sync): assert the committed store title in renameTask The focus dispatch did not fix supersync 3.1 — the shard failed again with an identical snapshot (original title, done, rename gone), so that diagnosis was wrong. Stop guessing at the trigger and make the helper able to observe the thing in question. task-title renders tmpValue, a component-local signal, in BOTH its editing and idle branches. Every DOM assertion here therefore matches as soon as the synthetic input event fires, whether or not an op was ever captured — which is why two rounds of "wait for the title" changed nothing. Read the store instead, via the __e2eTestHelpers.store hook the timeSpent helper already uses. This is a diagnostic as much as a fix: it splits the two remaining explanations. If renameTask now fails, the rename never becomes an op and the bug is in how the test drives the edit. If it passes and 3.1 still fails at the merge assertion, the op is captured and lost during sync — a real defect, and the test is right to fail. * test(sync): move the 3.1 disjoint-merge rewrite out to #9095 3.1 was the last red shard, and it turned out to be right: the store-backed renameTask passes, so the rename IS captured as an op, and the test still fails at the merge assertion — the op is committed and then lost during sync. Filed as #9095. That bug is pre-existing and cannot be reached by anything in this PR: the file-based adapter is not used by SuperSync, and the server-side author memo only engages for clocks over 20 entries where this test carries about three. 3.1 is also the only test here that exercises neither of this PR's fixes — it races a title change against a move-to-done, which is conflict resolution, not retry dedup or clock pruning. So it moves to #9095 rather than holding verified sync fixes red. The rest of the hardening stays: the fault injections whose globs never matched a real endpoint, the schema-mismatch test that asserted nothing, and the compaction suite that called an endpoint which never existed are what actually cover the fixes here. Restoring the old 3.1 puts a misleading test back, so it now carries a comment saying why it proves little and where the real one lives. The strengthened version is kept on test/issue-9095-disjoint-merge-repro. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
25 KiB
Vector Clocks Architecture
1. Overview
Vector clocks track causality — "did this client know about that operation?" — rather than wall-clock time, which can drift between devices. They are the foundation of conflict detection and SYNC_IMPORT filtering in Super Productivity's sync system.
Core Type
interface VectorClock {
[clientId: string]: number;
}
Each entry maps a client ID to a monotonically increasing counter. A clock with {A: 5, B: 3} means "this state includes A's first 5 operations and B's first 3 operations".
Constants
| Constant | Value | Purpose |
|---|---|---|
MAX_VECTOR_CLOCK_SIZE |
20 | Maximum entries in a pruned clock |
At 6-char client IDs, a 20-entry clock is ~333 bytes — negligible bandwidth. A user needs 21+ unique client IDs (reinstalls/new browsers) before pruning triggers, which is extremely unlikely for a personal productivity app.
2. Core Operations
Three operations — compare, merge, and prune (limitVectorClockSize) — are implemented in the generic sync-core package (packages/sync-core/src/vector-clock.ts), used by both client and server. Two operations — initialize and increment — are client-only (src/app/core/util/vector-clock.ts), which also wraps the shared operations with null-handling and logging.
Create
initializeVectorClock(clientId) → { [clientId]: 0 }
Increment
incrementVectorClock(clock, clientId) → { ...clock, [clientId]: clock[clientId] + 1 }
Throws on overflow (approaching MAX_SAFE_INTEGER). The only recovery is a SYNC_IMPORT to reset clocks.
Compare
compareVectorClocks(a, b) → EQUAL | LESS_THAN | GREATER_THAN | CONCURRENT
Standard vector clock comparison. Missing keys are treated as zero.
Merge
mergeVectorClocks(a, b) → { [key]: max(a[key], b[key]) for all keys in a ∪ b }
Creates a new clock that dominates both inputs.
3. Where Vector Clocks Live
Per-Operation Clock
Every Operation carries a vectorClock field — the global clock state at the time the operation was created. This is the primary mechanism for causality tracking.
Global Clock Store
Stored in IndexedDB (SUP_OPS database, vector_clock object store) as a VectorClockEntry:
interface VectorClockEntry {
clock: VectorClock; // Current global clock
lastUpdate: number; // Timestamp of last update
}
The global clock is the single source of truth for the client's current causal knowledge. During local operation capture, it is updated atomically with operation writes (single IndexedDB transaction via appendWithVectorClockOverwrite). The remote merge path (mergeRemoteOpClocks) updates the clock in a separate write after reading the current state.
Snapshot Clock
The state_cache stores a vectorClock representing the clock at compaction time. This serves as a baseline for entities that haven't been modified since the last snapshot.
Entity Frontier
Per-entity latest clocks, computed on demand by VectorClockService.getEntityFrontier(). Built by scanning operations after the snapshot. Used for fine-grained conflict detection.
4. Vector Clock Lifecycle (Normal Operations)
Step 1: Local Operation Created
In operation-log.effects.ts:
VectorClockService.getCurrentVectorClock()reads the global clock from thevector_clockstoreincrementVectorClock(currentClock, clientId)creates a new clock with the client's counter incremented- The operation is created with this full, unpruned clock
appendWithVectorClockOverwrite(op, 'local')writes the operation AND updates the global clock in a single atomic IndexedDB transaction
Key invariant: Normal operations carry full (unpruned) vector clocks. No client-side pruning happens during capture.
Step 2: Upload to Server
In sync.service.ts (processOperation):
ValidationService.validateOp()sanitizes the clock (DoS cap at 2.5×MAX = 50 entries) but does NOT prunedetectConflict()compares the full unpruned incoming clock against the existing entity clock- If accepted:
limitVectorClockSize()prunes to MAX before storage, preserving the uploading client and, when present, the latest causal full-state author - The pruned clock is stored in the database
Step 3: Download by Other Clients
In operation-log-store.service.ts (mergeRemoteOpClocks):
- Each downloaded operation's clock is merged into the local global clock
- For full-state operations (SYNC_IMPORT/BACKUP_IMPORT/REPAIR), the global clock is replaced (not merged) with the import's clock, then remaining ops are merged on top — existing entries not present in the import's clock can be lost
- For non-full-state downloads, the merge preserves all existing entries (inherits new entries without losing existing ones)
Key Insight
Normal operations are NEVER pruned client-side. The server prunes after comparison but before storage. This asymmetry is critical — see Section 6 for why.
5. Pruning
Why Pruning Exists
Clocks grow with each new client. Without bounds, a user who has used many devices would have ever-growing clocks. Pruning limits clocks to MAX_VECTOR_CLOCK_SIZE (20) entries.
The limitVectorClockSize Algorithm
Input: clock, preserveClientIds[]
If entries ≤ MAX: return clock unchanged
Otherwise:
1. Add entries from preserveClientIds first (capped at MAX)
2. Fill remaining slots with highest-counter entries (sorted descending)
3. Return clock with exactly MAX entries
Implemented in packages/sync-core/src/vector-clock.ts. The client wrapper in src/app/core/util/vector-clock.ts adds logging and passes [currentClientId] as the preserve list.
When Pruning Happens (Exhaustive List)
| Location | When | What's Preserved |
|---|---|---|
Server processOperation() |
After conflict detection, before storage | Uploading client + active full-state author |
Server getOpsSinceWithSeq() |
Aggregating snapshot vector clock | Requesting client |
Client SyncHydrationService |
Creating SYNC_IMPORT during conflict resolution | Current client only |
Client ServerMigrationService |
Creating SYNC_IMPORT during migration | Current client only |
Client RepairOperationService |
Creating REPAIR operation | Current client only |
Client OperationLogSnapshotService |
Saving snapshot to state cache | Current client only |
Client OperationLogCompactionService |
Compaction (saving snapshot + deleting old ops) | Current client only |
Client OperationLogHydratorService |
Restoring snapshot during hydration | Current client only |
| Client normal op capture | NEVER | N/A |
Client SupersededOperationResolverService |
NEVER (conflict resolution) | N/A |
Pruning is Rare
With MAX=20, a user needs 21+ unique client IDs before pruning triggers. The server preserves both the uploader and the latest causal full-state author when they are present in the incoming clock. Preserving that boundary edge prevents high-counter historical clients from making a post-import operation appear concurrent to the importing client. Other pruned edges can still cause one extra server round-trip (false CONCURRENT → client resolves → re-uploads with >MAX clock → GREATER_THAN → accepted).
6. Conflict Detection & Resolution (Server Upload)
Server-Side Flow
- Server finds the latest operation for the same entity (
findFirstbyentityType + entityId, ordered byserverSeq desc) - Compares incoming clock vs existing clock using the full unpruned incoming clock
- Possible outcomes:
GREATER_THAN→ accept (incoming op causally succeeds existing)EQUAL+ same client → accept (retry of same operation)EQUAL+ different client → reject (suspicious clock reuse)CONCURRENT→ reject (true conflict)LESS_THAN→ reject (superseded)
- If accepted: prune clock, then store
Client-Side Resolution
When the server rejects an operation:
- Client receives rejection with
existingClock SupersededOperationResolverService.resolveSupersededLocalOps():- Merges the global clock + all superseded ops' clocks + snapshot clock + extra clocks from force download
- Calls
mergeAndIncrementClocks()— no client-side pruning! - Creates new LWW Update ops with the merged clock
- Re-uploads → server compares the full merged clock (which now has MAX+1 entries or more) →
GREATER_THAN→ accept - Server prunes the merged clock before storage
Critical Invariant: Server Must Prune AFTER Comparison
If the server pruned before comparison, it would be impossible to build a dominating clock when the entity clock already has MAX entries and the client's ID isn't among them.
Safety net: RejectedOpsHandlerService tracks resolution attempts per entity. After exceeding MAX_CONCURRENT_RESOLUTION_ATTEMPTS (3) consecutive failures, ops are permanently rejected.
7. SYNC_IMPORT / BACKUP_IMPORT / REPAIR Handling
The Core Rule: Clean Slate Semantics
An import is an explicit user action to restore all clients to a specific state. Operations without knowledge of the import are dropped:
| Comparison | Meaning | Action |
|---|---|---|
GREATER_THAN |
Op created after seeing import | Keep |
EQUAL |
Same causal history as import | Keep |
CONCURRENT |
Op created without knowledge of import | Drop |
LESS_THAN |
Op is dominated by import | Drop |
CONCURRENT ops are dropped even from unknown clients. This ensures a true "restore to point in time" semantic.
How Import Clocks Are Created
| Source | Method | Clock Construction |
|---|---|---|
BACKUP_IMPORT (clean slate) |
BackupService |
Fresh clock {newClientId: 1} — small, no pruning issues |
| Server migration | ServerMigrationService |
Merge all local op clocks + global clock → increment → prune to MAX |
| Sync hydration (conflict resolution) | SyncHydrationService |
Merge local clock + state cache clock + remote snapshot clock → increment → prune to MAX |
| Auto-repair | RepairOperationService |
Get current global clock → increment → prune to MAX |
Full-State Operations Skip Server Conflict Detection
In detectConflict(), operations with opType of SYNC_IMPORT, BACKUP_IMPORT, or REPAIR return { hasConflict: false } immediately. These operations replace entire state and don't operate on individual entities.
Multi-Entity Ops and Server-Side Conflict Detection (issue #8334)
An op may carry entityIds: string[] (batch actions: deleteTasks, moveToArchive, __updateMultipleTaskSimple, round-time-spent, task-repeat-cfg/board/issue-provider batches). detectConflict() checks the incoming op against all of its entityIds. The op must also be looked up by all of its entities once stored — otherwise a later stale write to a non-first entity would find no prior writer and be wrongly accepted as non-conflicting.
To make that symmetric, the operations row stores:
entity_id— the client-supplied scalar. For batch ops the client sets it toentityIds[0](operation-log.effects.ts), but the server does not enforceentity_id === entityIds[0]. It is the lookup key for single-entity ops and the first entity of multi-entity ops, and is also used by duplicate detection.entity_ids— the entity set for multi-entity ops only (atext[]column; populated viagetStoredEntityIds(op), which returns[]for single-entity ops). Keeping single-entity rows out of this column keeps theGIN(entity_ids)index small and the array-branch lookup cheap.
The lookups in conflict.ts match a requested entity as the scalar entity_id or a member of entity_ids:
detectConflictForEntity(single) — Prismawhere: { OR: [{ entityId }, { entityIds: { has: entityId } }] }, ordered byserver_seq. TheORspans theentity_idbtree and theentity_idsGIN, so the planner uses aBitmapOr+ sort bounded by the entity's stored version depth (op-log pruning keeps that small). If a real-PostgresEXPLAINon a deep-history entity ever shows this hot path is a problem, the escalation is two orderedLIMIT 1lookups (scalar btree +entity_idsGIN) taking the higherserver_seq— the array side stays small because the column is multi-entity-only.detectConflictForEntities/prefetchLatestEntityOpsForBatch(batch) — raw SQL unnestingCASE WHEN cardinality(entity_ids) > 0 THEN entity_ids ELSE ARRAY[entity_id] END, with aentity_ids && ... OR entity_id = ANY(...)prefilter so theGIN(entity_ids)index (migration20260613000001) and the existingentity_idbtree stay usable.
Forward-only by design: rows written before migration 20260613000000 have an empty entity_ids array and fall back to the scalar entity_id (= first entity) in the CASE expression / scalar branch above — there is no UPDATE backfill. Entities 2..n of already-stored multi-entity ops were never persisted and are unrecoverable, so they remain invisible to conflict detection until that entity gets a fresh write. This residual is bounded: client-side LWW is unaffected (the client persists the full op and VectorClockService.getEntityFrontier() fans each op out to every entity), and the server only builds an authoritative snapshot from non-encrypted ops (replayOpsToState() throws on encrypted ops), so the pre-fix gap could only surface a stale value to a fresh client on non-encrypted self-hosted servers.
The SyncImportFilterService Algorithm
Implemented in src/app/op-log/sync/sync-import-filter.service.ts:
- Find the latest full-state op — the last full-state op in the downloaded batch wins (server apply order); otherwise use the non-rejected local full-state entry with the greatest local sequence. UUIDv7 IDs are identities, not causal clocks, because a device clock can move backwards.
- For each non-full-state operation in the batch:
- Compare
op.vectorClockvs import clock GREATER_THANorEQUAL→ keepCONCURRENT+ same client as import + higher counter → keep (same-client check)CONCURRENTagainst automaticREPAIR→ keep and replay after the repair boundary- Otherwise → filter
- Compare
Same-Client Check
If an op is from the same client that created the import, with a higher counter, it's definitely a post-import op. A client can't create ops concurrent with its own import — counters are monotonically increasing. This check is always correct and cheap (~15 lines).
8. Key Scenarios (Step-by-Step Traces)
Scenario 1: Two-Client Sync (No Conflicts)
Initial state: Client A and B both know about each other
A's global clock: {A: 3, B: 2}
B's global clock: {A: 3, B: 2}
Step 1: A creates a task
A increments: {A: 4, B: 2}
Op carries clock: {A: 4, B: 2}
A's global clock updated to: {A: 4, B: 2}
Step 2: A uploads
Server compares op clock {A: 4, B: 2} vs latest entity clock (none) → no conflict
Server stores op (no pruning needed, 2 entries < MAX)
Step 3: B downloads
B receives op with clock {A: 4, B: 2}
B merges into global clock: max({A: 3, B: 2}, {A: 4, B: 2}) = {A: 4, B: 2}
Step 4: B creates a task
B increments: {A: 4, B: 3}
B's global clock updated to: {A: 4, B: 3}
Scenario 2: Concurrent Modification (Conflict Resolution)
Starting state: Both clients synced
A's clock: {A: 3, B: 2} B's clock: {A: 3, B: 2}
Step 1: Both modify the same task offline
A creates op: {A: 4, B: 2}
B creates op: {A: 3, B: 3}
Step 2: A uploads first → server accepts (no prior op for this entity)
Server stores: {A: 4, B: 2}
Step 3: B uploads
Server compares: {A: 3, B: 3} vs {A: 4, B: 2}
A=3 < 4 (b greater), B=3 > 2 (a greater) → CONCURRENT → reject
Server returns existingClock: {A: 4, B: 2}
Step 4: B resolves
SupersededOperationResolverService merges:
globalClock={A: 3, B: 3} + existingClock={A: 4, B: 2} + opClock={A: 3, B: 3}
merged = {A: 4, B: 3}, incremented = {A: 4, B: 4}
Creates new LWW Update op with clock {A: 4, B: 4}
NO client-side pruning
Step 5: B re-uploads
Server compares: {A: 4, B: 4} vs {A: 4, B: 2} → GREATER_THAN → accept
Server stores (pruned if needed, but only 2 entries here)
Scenario 3: SYNC_IMPORT with Small Clock (Clean Slate)
Step 1: Client A does BACKUP_IMPORT (full data restore)
Creates SYNC_IMPORT op with clock: {A: 1}
Uploads to server
Step 2: Client B has been working offline
If B never saw A's state: B's clock: {B: 5}
Compare: {B: 5} vs {A: 1} → CONCURRENT → filtered ✓
If B had previously synced with A: B's clock: {A: 3, B: 5}
Compare: {A: 3, B: 5} vs {A: 1} → GREATER_THAN → kept ✓
(B's ops were created with knowledge beyond the import point)
9. Invariants
Rules that must hold for the system to be correct. Use these to verify implementations and tests.
-
Normal ops carry full (unpruned) vector clocks. No pruning in
operation-log.effects.ts. -
Server prunes AFTER comparison, BEFORE storage.
processOperation()callslimitVectorClockSize()afterdetectConflict()succeeds. -
Client does NOT prune during conflict resolution.
SupersededOperationResolverServicesends full merged clocks; the server prunes after accepting. -
compareVectorClocksproduces identical results on client and server. Both import from@sp/sync-core. The client wrapper only adds null handling. -
Full-state ops skip conflict detection on server.
detectConflict()returns{ hasConflict: false }for SYNC_IMPORT, BACKUP_IMPORT, and REPAIR. -
CONCURRENT ops are FILTERED (not kept) against SYNC_IMPORT — unless identified as legacy pruning artifacts or same-client ops. Clean slate semantics — this is the explicit, correct behavior.
-
Global clock is REPLACED (not merged) on remote SYNC_IMPORT.
mergeRemoteOpClocks()starts from the import's clock as the base, then merges remaining ops on top. This prevents clock bloat. -
DoS cap is NOT pruning.
sanitizeVectorClock()rejects clocks with > 2.5×MAX (50) entries entirely — it doesn't prune them down. This is a validation gate, not a size reduction.
10. Key Files Reference
| Concept | File(s) |
|---|---|
| Core algorithms (compare, merge, prune) | packages/sync-core/src/vector-clock.ts |
| Compatibility re-export for existing shared-schema imports | (removed — imports now target @sp/sync-core directly) |
| Client wrappers (null handling, logging, validation) | src/app/core/util/vector-clock.ts |
| Global clock management, entity frontier | src/app/op-log/sync/vector-clock.service.ts |
| Operation capture (no pruning, atomic clock update) | src/app/op-log/capture/operation-log.effects.ts |
| Clock persistence | src/app/op-log/persistence/operation-log-store.service.ts |
| Import filtering + same-client check | src/app/op-log/sync/sync-import-filter.service.ts |
| Conflict resolution (no pruning, merges clocks) | src/app/op-log/sync/superseded-operation-resolver.service.ts |
Conflict resolution (LWW logic, mergeAndIncrementClocks) |
src/app/op-log/sync/conflict-resolution.service.ts |
| SYNC_IMPORT creation (sync hydration) | src/app/op-log/persistence/sync-hydration.service.ts |
| SYNC_IMPORT creation (server migration) | src/app/op-log/sync/server-migration.service.ts |
| REPAIR creation | src/app/op-log/validation/repair-operation.service.ts |
| Server: conflict detection + prune after comparison | packages/super-sync-server/src/sync/sync.service.ts |
| Server: DoS cap (sanitize, no pruning) | packages/super-sync-server/src/sync/services/validation.service.ts |
| Server: snapshot clock pruning during download optimization | packages/super-sync-server/src/sync/services/operation-download.service.ts |
11. History & Rationale (why pruning is the way it is)
Decision-history behind the current pruning design (previously in a separate
research doc, now git-only). Load-bearing context for anyone changing
MAX_VECTOR_CLOCK_SIZE or the prune ordering.
Compare before pruning — and the bugs that proved it
Never prune a vector clock before using it in a comparison. Pruning removes information: a missing entry is ambiguous — "never knew about this client" vs "entry was pruned" — so a pre-pruned comparison returns CONCURRENT instead of EQUAL/causal. Two independent incidents established this:
- Riak #613: pruning before comparison caused "sibling explosion" — objects accumulated hundreds of siblings that could never resolve because pruned clocks always compared CONCURRENT.
- Super Productivity (Feb 2026): with
MAX = 10, server pruning before comparison caused an infinite rejection loop — a client merges all clocks + its own ID (11 entries), the server prunes to 10, the non-shared key forces CONCURRENT, the server rejects, the client re-merges, the loop repeats.
Fix in both systems: compare the full unpruned clock, then prune only before storage. This is the invariant in §6 and §9.
Why MAX = 20 (the 10 → 30 → 20 evolution)
The original defense against the Feb-2026 loop was a 4-layer scheme (broad
protected-client tracking, pruning-aware comparison, an
isLikelyPruningArtifact heuristic, the same-client check) — symptom treatment.
The root cause was that MAX = 10 was too small, making pruning frequent and
interacting badly with SYNC_IMPORT.
Commit d70f18a94d raised MAX 10 → 30 (later reduced to 20 — a 20-entry
clock is ~333 bytes, negligible) and removed the broad tracking and comparison
heuristics. The server now has one narrow storage-only exception: it preserves
the latest causal full-state author so post-import operations retain that
boundary edge.
isLikelyPruningArtifact was dropped (known false positives, unnecessary at
MAX = 20). Only the same-client check remains — always mathematically
correct during conflict comparison (monotonic counters are definitive) and
independent of MAX. At
MAX = 20, pruning needs 21+ distinct client IDs, extremely rare for a
personal productivity app, so the pruning path is effectively dormant
(see §5 "Pruning is Rare").
Future options (only if the server becomes the coordinator)
In a server-authoritative model, clock growth could be bounded without pruning via Dotted Version Vectors (bound to server vnodes, not devices), bounded reclaimable client IDs (needs a registration/retirement protocol), or periodic stable-cut GC (needs all-to-all clock reporting). None apply to the current dumb-relay model.