I tried to include as much up-to-date guidance as possible but given the
dynamic nature of SuperSync at the moment this note will require some
updates as the new sync backend is rolled out to non-SuperSync
integrations.
When encryption was enabled, forceUploadLocalState (triggered by "Use local"
in conflict dialog) caused double-encryption: the upload service encrypted the
state payload, then the file adapter encrypted the entire file. On download,
only the outer layer was decrypted, leaving the state as an opaque string that
couldn't be hydrated — making "Use remote" silently fail and forcing a
ping-pong loop between clients.
Fix: _uploadSnapshot now uses getStateSnapshot() (matching _buildMergedSyncData)
instead of the passed state parameter, since file-level encryption already
handles security.
Also: clean up dead return values in _buildMergedSyncData, reset
_lastSyncTimestamps in _uploadSnapshot for consistency, and update stale
piggybacking doc reference.
Remove duplicate LegacySyncProvider enum and use SyncProviderId everywhere.
The two enums had identical values but were separate types for historical
reasons, making the "Legacy" name misleading since providers are actively used.
Add limitVectorClockSize to OperationLogCompactionService._doCompact()
which was the remaining saveStateCache caller that persisted unpruned
clocks. Update vector-clocks.md exhaustive pruning table with the three
new client-side pruning locations. Add boundary tests at exactly
MAX_VECTOR_CLOCK_SIZE for snapshot and hydrator services.
Remove references to deleted docs/ai/ files, update stale comments about
protectedClientIds and pruning-aware comparison to reflect current REPLACE
semantics, fix MAX=30→20 heading, client_0..29→19 comment, use ?? over ||,
preserve lastSeq in server clean-slate path to prevent sequence reuse, and
add clarifying comments for mock limitations and validation guards.
- Add deterministic tie-breaking test for vector clock pruning with
equal counters (shared-schema)
- Remove last isLikelyPruningArtifact reference from entity versioning doc
- Use JWT_EXPIRY_PASSKEY (7d) for replaceToken instead of 365d magic link
expiry — token replacement is a security action, shorter lifetime is safer
- Replace localeCompare with locale-independent comparator in vector clock
tie-breaking to ensure deterministic behavior across environments
- Fix 5 additional stale MAX=30 references in docs and tests (now 20)
- Update authentication.md to reflect dual JWT expiry tiers
- Clean up isLikelyPruningArtifact references in docs and LEGACY_MAX in tests
Lower the cap to leave headroom for future increases and surface
size-related edge cases earlier. All pruning logic is MAX-agnostic
so this is a safe constant change with documentation updates.
At MAX=10, pruning triggered frequently enough (11+ unique client IDs from
reinstalls/new browsers) to require 4 defense layers compensating for
information loss: pruning-aware comparison, protected client IDs with
migration, isLikelyPruningArtifact heuristic, and same-client check.
At MAX=30, pruning almost never triggers (needs 31+ unique client IDs).
A 30-entry clock is ~500 bytes — negligible bandwidth. This allows removing
most defense layers while keeping two cheap backward-compat checks for old
10-entry pruned data still on servers.
Removed:
- Pruning-aware mode in compareVectorClocks (standard comparison now)
- Protected client IDs mechanism (storage, migration, preservation)
- selectProtectedClientIds function
- Clock normalization in SyncImportFilterService
Kept temporarily (backward compat with old 10-entry data):
- isLikelyPruningArtifact with LEGACY_MAX=10
- Same-client check (always mathematically correct)
Replace the scattered, contradictory document with a coherent 13-section
architecture reference covering the full vector clock system: core
operations, pruning, conflict detection, SYNC_IMPORT filtering, defense
layers against pruning artifacts, and step-by-step scenario traces.
When a client with an established vector clock (10+ entries) received a
remote SYNC_IMPORT/BACKUP_IMPORT with a fresh clock, mergeRemoteOpClocks()
merged the import's clock into the old clock instead of replacing it.
This caused clock bloat (11+ entries), which led to server-side pruning
dropping the import's entry (lowest counter). Other clients then saw
these ops as CONCURRENT with the import and discarded them.
Fix: In mergeRemoteOpClocks(), when a full-state op is present, use its
clock as the base instead of the existing local clock. Regular ops
continue to merge normally.
The sanitizeVectorClock() DoS cap was changed to 5x MAX (50 entries) but
comments in CLAUDE.md, vector-clocks.md, sync.types.ts, and
validation.service.ts still referenced the old 3x MAX (30) value.
The retry counter incremented per-op instead of per-entity per cycle.
Multiple ops for the same entity in one batch would burn through all
MAX_CONCURRENT_RESOLUTION_ATTEMPTS immediately, causing permanent
rejection on the first sync cycle instead of allowing 3 retry cycles.
Also uses toEntityKey utility instead of manual string construction
and fixes docs/code mismatch (>= vs ===) for pruning-aware comparison.
* fix(sync): prevent infinite loop when concurrent modification resolution keeps failing
When vector clock pruning makes it impossible to create a dominating clock
(e.g., entity clock has MAX entries and client ID isn't among them), the cycle
"upload → CONFLICT_CONCURRENT → merge clocks → upload → reject again" repeats
endlessly. This adds a per-entity retry counter (MAX_CONCURRENT_RESOLUTION_ATTEMPTS=3)
that permanently rejects ops after exceeding the limit, breaking the sync loop.
The counter resets when a sync cycle completes with no rejections (healthy state).
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* fix(sync): move vector clock pruning after conflict detection to fix root cause
The infinite sync loop happens because it's mathematically impossible to build
a dominating clock with MAX_VECTOR_CLOCK_SIZE entries when the entity's clock
already has MAX entries and the client's ID isn't among them. The merged clock
needs MAX+1 entries (all entity clock IDs + client ID), but client-side pruning
drops one entity clock ID. The server's pruning-aware comparison then sees the
dropped key as non-shared and returns CONCURRENT instead of GREATER_THAN.
Fix: Move limitVectorClockSize from validation (before comparison) to
processOperation (after comparison, before storage). The full unpruned clock
is now used for conflict detection — all entity clock IDs are present so
bOnlyCount=0 → GREATER_THAN. Storage still gets the pruned clock.
Client-side: Stop pruning in SupersededOperationResolverService. The server
handles pruning after conflict detection.
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* fix(sync): tighten vector clock sanitize limit from 100 to 3x MAX_VECTOR_CLOCK_SIZE
The old sanitize cap of 100 entries was unnecessarily wide. Since conflict
resolution clocks are at most ~12-15 entries (entity clock MAX=10 + client ID
+ a few merged), cap at 3x MAX (30) for DoS protection while leaving ample
room for legitimate clocks.
Also update server-side pruning tests to use realistic clock sizes (20 entries
instead of 50) to stay within the new sanitize limit.
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* docs(sync): document vector clock pruning invariant and infinite loop fix
- Add "Pruning and the Pruning-Aware Comparison" section to vector-clocks.md
explaining the critical invariant: server prunes AFTER comparison, not before
- Add rule 13 to CLAUDE.md to prevent future regressions
- Update conflict resolution key files table with rejected-ops-handler and
superseded-operation-resolver services
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* docs(sync): update last-updated date in conflict resolution docs
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* test(sync): update pruning tests to reflect server-side pruning design
Client no longer prunes vector clocks during conflict resolution — the
server handles pruning after conflict detection. Tests now verify the
merged clock is sent unpruned with all keys preserved.
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* test(sync): fix client-side pruning tests and add server-side regression test
- Update 7 SupersededOperationResolverService tests to verify client
does NOT prune (server handles pruning after conflict detection)
- Remove redundant `newClock` alias in superseded-operation-resolver
- Add regression test: MAX+1 entry clock accepted as GREATER_THAN
when it dominates a MAX entry entity clock (the core infinite loop fix)
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
---------
Co-authored-by: Claude <noreply@anthropic.com>
* docs(research): add recurring events industry standards analysis
Comprehensive research comparing Super Productivity's recurring task
implementation against RFC 5545 RRULE standard and major applications
(Google Calendar, Todoist, Things 3, TickTick).
Key findings:
- Current implementation covers basic patterns but lacks nth weekday,
last day of month, and end conditions (COUNT/UNTIL)
- Recommends adopting rrule.js library for RFC 5545 compliance
- Proposes incremental migration path preserving existing functionality
- Notes "after completion" mode as competitive advantage to preserve
https://claude.ai/code/session_01Rhuxtn9JKKhh4J3iLApaWX
* docs(research): add gap analysis and implementation plan for recurring events
Gap analysis compares current TaskRepeatCfg model against RFC 5545 RRULE
and major applications (Google Calendar, Todoist, Things 3, TickTick).
Implementation plan details 4-phase approach:
- Phase 1: Add rrule.js, create DST-safe wrappers
- Phase 2: Enable new patterns (nth weekday, last day, end conditions)
- Phase 3: Lazy migration strategy
- Phase 4: Natural language display, iCal export
https://claude.ai/code/session_01Rhuxtn9JKKhh4J3iLApaWX
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(e2e): stabilize undo task delete sync test
Two flakiness sources fixed:
- Click on task element could activate title inline editor, causing
Backspace to edit text instead of triggering delete. Now clicks the
drag handle which calls focusSelf() without entering edit mode.
- Replaced deleteTask helper with inline sequence to avoid wasting
2s of the 5s undo snackbar window on dialog-detection timeout.
* refactor: address code review findings from 2026-02-03
- Extract getBreakCycle helper to replace error-prone `cycle - 1 || 1`
pattern at 3 call sites
- Add clarifying comment on intentionally broad 'timed out' match
- Reduce Pomodoro E2E test from 9 to 5 sessions (sufficient coverage)
- Remove dead _isTransientNetworkError wrapper from DropboxApi
- Extract stubWindowConfirm helper in task reducer tests
* fix(sync): prevent Formly from clearing provider config on show (#6345)
resetOnHide: true caused Formly to reset field values when provider
fieldGroups transitioned from hidden to visible, discarding user input
if sync was enabled before selecting a provider.
* fix(tasks): fix huge space between emoji and text in tag/project menus
Use matMenuItemIcon attribute on emoji spans so they project into the
icon slot of mat-menu-item instead of the text slot. Update emoji icon
sizing to 24x24px to match mat-icon and add overflow: hidden.
Closes#5977
* fix(tasks): guard against undefined task entities in selectors and archive (#6359)
Prevent TypeError crashes (reading 'dueWithTime', 'dueDay', 'issueProviderId') caused
by orphaned IDs in NgRx state. Fix archive merge to deduplicate IDs, filter orphans,
and use correct entity precedence (young over old). Add defensive null guards to
selectors and archive/task service methods.
* fix(tasks): guard against undefined task in mainListTasksInProject$ (#6360)
* fix(tasks): detect and sanitize orphaned task IDs to prevent startup crashes (#6359, #6360)
Orphaned task IDs (entries in task.ids without matching entities) caused
TypeError on app startup. Fix addresses three layers: validation now
flags orphaned IDs instead of silently skipping them, loadAllData
sanitizes IDs on load as a safety net, and data repair no longer crashes
when encountering orphaned IDs it's trying to fix.
* fix(sync): prevent recurring task duplication across clients
Remove SuperSync special-case that bypassed initial sync wait, causing
repeatable task effects to fire before sync completed. Add post-sync
cleanup effect that detects and removes stale duplicate repeat instances
when multiple active instances exist for the same repeat config.
* fix(sync): restore WebDAV provider compatibility warning text
* feat(sync): mark WebDAV and LocalFile sync options as experimental
* feat(plugins): add UI Kit with inject-first CSS strategy for iframe plugins
Introduce a lightweight CSS reset (UI Kit) that auto-styles basic HTML
elements in plugin iframes to match the host app theme. Injected after
<head> so plugin styles always win by source order.
UI Kit provides: element resets (body, headings, buttons, inputs, tables,
links, code, lists, hr), .btn-primary/.btn-outline button variants, and
.card/.card-clickable components.
All bundled plugins updated to use UI Kit classes, removing redundant
custom CSS (-542 lines net). Pico CSS removed from automations plugin.
sync-md converted from hardcoded colors to host theme variables.
* feat(plugins): extract shared CSS utilities into UI Kit
Move .text-muted, .text-primary, .page-fade and @keyframes fadeIn from
plugin CSS into the UI Kit so all iframe plugins get them automatically.
Add box-shadow focus ring to input:focus for better accessibility.
Remove per-plugin focus overrides now covered by the UI Kit.
- Remove redundant subset condition in compareVectorClocks
- Add clarifying comment for conservative return in hasVectorClockChanges
- Batch per-key verbose logs into single summary log
- Add defensive warning for negative startingSeq in file-based sync
- Extract magic timeout constant in E2E test
- Update stale doc date
- Replace fragile VectorClockComparison[result] enum lookup with safe cast
- Return CONCURRENT instead of EQUAL when only one side has non-shared
keys in pruning-aware mode (safe direction: triggers LWW instead of
silent skip)
- Fix docs claiming clocks "reset" at MAX_SAFE_INTEGER (they throw)