Collapse the sprawling, partly-stale docs/sync-and-op-log/ tree into a
small authoritative set and make the sync-correctness invariant
partly lint-enforced instead of convention-only.
Docs:
- Delete superseded/duplicate/provably-stale design, plan, and
background-research docs (quick-reference, the architecture-diagrams
monolith, the "Hybrid Manifest" docs describing code that does not
exist, completed long-term plans, LLM-synthesis analyses).
- Salvage load-bearing decision history into the surviving docs before
deletion: rejected-alternatives rationale -> operation-log-architecture
("Why this architecture"); vector-clock pruning incident history ->
vector-clocks.md; archive-payload optimization -> architecture E.7.
- Add contributor-sync-model.md as the single-invariant entry point
(one user intent = one op; replayed/remote ops must not re-trigger
effects), with a decision table mapping to the enforcing linters.
- Repoint external/internal cross-refs; add CONTRIBUTING.md + CLAUDE.md
pointers; record the migration in a dated docs/plans/ design doc.
Enforcement (new eslint-local-rules):
- no-actions-in-effects (error): effects must inject LOCAL_ACTIONS /
ALL_ACTIONS, never the raw @ngrx/effects Actions stream.
- no-multi-entity-effect (warn, heuristic): flags a literal returned
array of >=2 action-creator calls; docstring + valid-case specs pin
exactly which shapes are and are not detected.
- run-specs.js runner wired into `npm run lint` via test:lint-rules;
refuses to run under test-framework globals and counts RuleTester.run
invocations so a spec that asserts nothing fails instead of passing.
- Correct the ALL_ACTIONS JSDoc in local-actions.token.ts to match
reality (archive-operation-handler uses LOCAL_ACTIONS).
Reviewed via parallel multi-agent review; findings W1/W2/W4 and a
dangling doc anchor addressed.
21 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. packages/shared-schema/src/vector-clock.ts re-exports those algorithms for existing shared-schema import paths. 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 appendWithVectorClockUpdate). 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
appendWithVectorClockUpdate(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(clock, [clientId])prunes to MAX before storage, preserving only the uploading client's ID - 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 and re-exported from packages/shared-schema/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's ID |
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. In the unlikely event it does trigger, the worst case is 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.
The SyncImportFilterService Algorithm
Implemented in src/app/op-log/sync/sync-import-filter.service.ts:
- Find the latest full-state op — check current batch AND local store (via
getLatestFullStateOpEntry()), keep the one with the latest UUIDv7 ID - 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)- 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/shared-schema. 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 | packages/shared-schema/src/vector-clock.ts |
| 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 (protected
client IDs, 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 three of the four layers.
isLikelyPruningArtifact was dropped (known false positives, unnecessary at
MAX = 20). Only the same-client check remains — always mathematically
correct (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.