docs(sync): document archive-wins rule, singleton LWW, and stale op handling

Document 6 gaps found during documentation audit:

- Archive-wins conflict resolution rule (moveToArchive always wins
  over field-level updates regardless of timestamps)
- Two-level archive resurrection prevention (ConflictResolutionService
  + bulkOperationsMetaReducer pre-scan filtering)
- Singleton entity LWW update handling (adapter vs singleton vs
  unsupported storage patterns)
- Stale moveToArchive operation preservation with merged vector clocks
- Fix upload batch size from 100 to 25 in operation-rules.md and
  operation-log-architecture.md
- Update meta-reducer chain from simplified 7-phase to accurate
  8-phase, 15-entry structure matching meta-reducer-registry.ts
This commit is contained in:
Johannes Millan 2026-01-30 12:50:16 +01:00
parent d618238864
commit 4ab86ba0fe
6 changed files with 241 additions and 31 deletions

View file

@ -166,12 +166,34 @@ flowchart LR
style BackupCreated fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
```
### Archive-Wins Exception
When a `moveToArchive` operation conflicts with a field-level update (rename, time tracking, etc.), the **archive operation always wins** regardless of timestamps. This bypasses the normal LWW timestamp comparison because archiving represents explicit user intent that should not be reversed.
```mermaid
flowchart TD
subgraph ArchiveCheck["Archive-Wins Check (Before LWW)"]
Conflict["Conflict detected"] --> IsArchive{"Does either side<br/>contain moveToArchive?"}
IsArchive -->|"Yes"| ArchiveWins["🏆 ARCHIVE WINS<br/>Regardless of timestamps"]
IsArchive -->|"No"| NormalLWW["Proceed to normal<br/>LWW timestamp comparison"]
end
ArchiveWins --> CreateOp["Create new archive op<br/>with merged vector clock<br/>via _createArchiveWinOp()"]
style ArchiveWins fill:#fff3e0,stroke:#ef6c00,stroke-width:2px
style NormalLWW fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
```
**Why?** Without this rule, a concurrent field-level LWW Update could "resurrect" an archived task by replacing its state in the active store. The archive-wins rule is the first level of defense; the `bulkOperationsMetaReducer` provides a second level by pre-scanning batches for archive ops (see [06-archive-operations.md](./06-archive-operations.md)).
### Key Implementation Details
| Aspect | Implementation |
| ---------------------- | --------------------------------------------------------------------------- |
| **Timestamp Source** | `Math.max(...Object.values(vectorClock))` - max timestamp from vector clock |
| **Tie Breaker** | Remote wins (ensures convergence across all clients) |
| **Archive Exception** | `moveToArchive` always wins over field-level updates, bypassing timestamps |
| **Safety Backup** | Created via `BackupService` before any resolution |
| **Local Win Update** | New `OpType.UPD` operation created with merged vector clock |
| **Vector Clock Merge** | `mergeVectorClocks(localClock, remoteClock)` for local-win ops |

View file

@ -182,10 +182,63 @@ sequenceDiagram
Note over State: Either ALL changes applied<br/>or NONE (transaction semantics)
```
## LWW Update Meta-Reducer: Entity Type Handling
The `lwwUpdateMetaReducer` handles LWW Update actions (created when the local side wins a conflict). It distinguishes between three entity storage patterns:
```mermaid
flowchart TD
subgraph Input["LWW Update Action"]
Action["[TASK] LWW Update<br/>entityType + entityId + winningData"]
end
subgraph Lookup["Entity Registry Lookup"]
Registry["Look up entity storage pattern<br/>in entity registry"]
end
subgraph Patterns["Storage Pattern Handling"]
Adapter["ADAPTER ENTITIES<br/>━━━━━━━━━━━━━━━<br/>TASK, PROJECT, TAG, NOTE,<br/>TASK_REPEAT_CFG, ISSUE_PROVIDER,<br/>SIMPLE_COUNTER, BOARD, METRIC,<br/>REMINDER, PLUGIN_USER_DATA,<br/>PLUGIN_METADATA<br/>━━━━━━━━━━━━━━━<br/>adapter.updateOne() or addOne()<br/>+ relationship syncing"]
Singleton["SINGLETON ENTITIES<br/>━━━━━━━━━━━━━━━<br/>GLOBAL_CONFIG,<br/>TIME_TRACKING,<br/>MENU_TREE,<br/>WORK_CONTEXT<br/>━━━━━━━━━━━━━━━<br/>Entire feature state replaced<br/>with winning data"]
Unsupported["UNSUPPORTED<br/>━━━━━━━━━━━━━━━<br/>Map, array, virtual<br/>━━━━━━━━━━━━━━━<br/>Warning logged,<br/>no action taken"]
end
Input --> Lookup
Lookup --> Patterns
style Adapter fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
style Singleton fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style Unsupported fill:#fff3e0,stroke:#ef6c00,stroke-width:2px
```
### Adapter Entity Details
For adapter-backed entities, the meta-reducer handles two sub-cases:
| Condition | Behavior | Why |
| ---------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| Entity exists in store | `adapter.updateOne()` — replaces entity with winning data | Normal conflict resolution |
| Entity NOT in store | `adapter.addOne()` — recreates entity | Handles DELETE vs UPDATE race (entity was deleted locally but update won remotely) |
### Relationship Syncing for Tasks
After updating a task via LWW, the meta-reducer syncs related entity references:
| Field Changed | Relationship Synced |
| ------------- | ------------------------------------------------------------------ |
| `projectId` | `project.taskIds` updated to reflect new/old project membership |
| `tagIds` | `tag.taskIds` updated for each added/removed tag |
| `dueDay` | `TODAY_TAG.taskIds` updated (virtual tag, membership via `dueDay`) |
| `parentId` | `parent.subTaskIds` updated for new/old parent task |
**Key file:** `src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts`
## Key Files
| File | Purpose |
| ----------------------------------------------------------------------------- | --------------------------------- |
| `src/app/root-store/meta/task-shared-meta-reducers/` | Task-related multi-entity changes |
| `src/app/root-store/meta/task-shared-meta-reducers/tag-shared.reducer.ts` | Tag deletion with cleanup |
| `src/app/root-store/meta/task-shared-meta-reducers/project-shared.reducer.ts` | Project deletion with cleanup |
| File | Purpose |
| ------------------------------------------------------------------------------ | --------------------------------------------------------- |
| `src/app/root-store/meta/task-shared-meta-reducers/` | Task-related multi-entity changes |
| `src/app/root-store/meta/task-shared-meta-reducers/tag-shared.reducer.ts` | Tag deletion with cleanup |
| `src/app/root-store/meta/task-shared-meta-reducers/project-shared.reducer.ts` | Project deletion with cleanup |
| `src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts` | LWW Update handling (adapter/singleton/relationship sync) |

View file

@ -161,6 +161,61 @@ The server guarantees operations arrive in sequence order, and delete operations
| `deleteTaskRepeatCfg` | ArchiveOperationHandlerEffects → ArchiveOperationHandler | ArchiveOperationHandler removes repeatCfgId from tasks |
| `deleteIssueProvider` | ArchiveOperationHandlerEffects → ArchiveOperationHandler | ArchiveOperationHandler unlinks issue data |
## Archive Resurrection Prevention (Two-Level Defense)
When multiple clients are syncing concurrently, a race condition can cause archived tasks to "resurrect" — reappearing in the active store after being archived. This happens when a field-level LWW Update (e.g., rename, time tracking) arrives for a task that was concurrently archived.
The system uses a **two-level defense** to prevent this:
### Level 1: ConflictResolutionService (Archive-Wins Rule)
During LWW conflict resolution, if a `moveToArchive` operation conflicts with a field-level update, the **archive always wins** regardless of timestamps. This prevents the LWW update from overriding the archive intent.
```mermaid
flowchart TD
subgraph Level1["Level 1: Conflict Resolution"]
C1["Conflict: moveToArchive vs field update"]
C1 --> C2{"Archive-Wins<br/>Rule"}
C2 -->|"Archive wins"| C3["Create new archive op<br/>with merged vector clock"]
C2 -->|"No archive involved"| C4["Normal LWW<br/>timestamp comparison"]
end
style Level1 fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
```
**Key file:** `src/app/op-log/sync/conflict-resolution.service.ts`
### Level 2: bulkOperationsMetaReducer (Pre-Scan Filtering)
During bulk operation application (sync/hydration), the meta-reducer **pre-scans** the entire batch for `TASK_SHARED_MOVE_TO_ARCHIVE` operations. It collects all entity IDs being archived, then **skips** any `[TASK] LWW Update` operations targeting those entities.
This handles the **3+ client scenario** where LWW Updates can appear before or after archive ops in the same batch, bypassing Level 1 conflict resolution.
```mermaid
flowchart TD
subgraph Level2["Level 2: Bulk Operations Meta-Reducer"]
B1["Receive batch of operations<br/>[LWW Update, moveToArchive, ...]"]
B1 --> B2["PRE-SCAN: Collect all<br/>entity IDs being archived"]
B2 --> B3["For each operation in batch:"]
B3 --> B4{"Is this an LWW Update<br/>for an archived entity?"}
B4 -->|"Yes"| B5["⛔ SKIP<br/>(prevents resurrection)"]
B4 -->|"No"| B6["✅ Apply normally"]
end
style Level2 fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style B5 fill:#ffcdd2,stroke:#c62828,stroke-width:2px
```
**Key file:** `src/app/op-log/apply/bulk-hydration.meta-reducer.ts`
### Why Two Levels?
| Scenario | Level 1 (Conflict Resolution) | Level 2 (Bulk Pre-Scan) |
| ------------------------------------------------------- | ------------------------------------------ | ----------------------- |
| 2 clients: archive vs field update | ✅ Catches in LWW resolution | N/A (not in same batch) |
| 3+ clients: LWW Update arrives in same batch as archive | May not detect (already resolved upstream) | ✅ Catches via pre-scan |
| Hydration replay with mixed ops | N/A (not conflict resolution) | ✅ Catches via pre-scan |
## Key Files
| File | Purpose |
@ -168,5 +223,7 @@ The server guarantees operations arrive in sequence order, and delete operations
| `src/app/op-log/apply/archive-operation-handler.service.ts` | **Unified** handler for all archive side effects (local AND remote) |
| `src/app/op-log/apply/archive-operation-handler.effects.ts` | Routes local actions to ArchiveOperationHandler via LOCAL_ACTIONS |
| `src/app/op-log/apply/operation-applier.service.ts` | Calls ArchiveOperationHandler after dispatching remote operations |
| `src/app/op-log/sync/conflict-resolution.service.ts` | Archive-wins rule during LWW conflict resolution |
| `src/app/op-log/apply/bulk-hydration.meta-reducer.ts` | Pre-scan archive filtering during bulk application |
| `src/app/features/archive/archive.service.ts` | Local archive write logic (moveToArchive writes BEFORE dispatch) |
| `src/app/features/archive/task-archive.service.ts` | Archive CRUD operations |

View file

@ -1180,8 +1180,8 @@ interface OperationSyncCapable {
async uploadPendingOps(syncProvider: OperationSyncCapable): Promise<void> {
const pendingOps = await this.opLogStore.getUnsynced();
// Upload in batches (up to 100 ops per request)
for (const chunk of chunkArray(pendingOps, 100)) {
// Upload in batches (up to 25 ops per request)
for (const chunk of chunkArray(pendingOps, 25)) {
const response = await syncProvider.uploadOps(
chunk.map(entry => toSyncOperation(entry.op)),
clientId,
@ -1415,6 +1415,45 @@ When operations are rejected (either local or remote):
- `getUnsynced()` excludes rejected ops (won't re-upload)
- Compaction may eventually delete old rejected ops
### Archive-Wins Rule
When a `moveToArchive` operation conflicts with a field-level update (e.g., rename, time tracking changes), the archive operation **always wins** regardless of timestamps. This bypasses the normal LWW timestamp comparison because archiving represents explicit user intent that should not be reversed by a concurrent field update.
**Rationale:** If Client A archives a task and Client B concurrently renames it, the archive must win — otherwise, the LWW update would "resurrect" the archived task back into the active store by replacing its state.
**Implementation:** `ConflictResolutionService` checks whether either the local or remote side contains a `TASK_SHARED_MOVE_TO_ARCHIVE` action. If so, the archive side wins automatically, and a new archive operation is created with a merged vector clock (via `_createArchiveWinOp()`).
This is the **first level** of archive resurrection prevention. The **second level** is in the `bulkOperationsMetaReducer` (see [Area 10: Bulk Application](#area-10-bulk-application) in the quick reference), which pre-scans operation batches for archive operations and skips any LWW Update operations targeting entities being archived in the same batch. This two-level defense handles the 3+ client scenario where LWW Updates can arrive before or after archive ops in the same batch.
**Key files:**
- `src/app/op-log/sync/conflict-resolution.service.ts` — Archive-wins check and `_createArchiveWinOp()`
- `src/app/op-log/apply/bulk-hydration.meta-reducer.ts` — Pre-scan archive filtering
### Stale Operation Handling for moveToArchive
The `StaleOperationResolverService` treats `moveToArchive` as a special case alongside DELETE operations. When a `moveToArchive` op is rejected by the server due to concurrent conflicts, it is **re-created with a merged vector clock** instead of being discarded.
This is necessary because `moveToArchive` removes entities from the NgRx store (via the archive reducer), so `getCurrentEntityState()` returns `undefined` for archived entities. Without this special handling, the stale operation resolver would be unable to re-create the operation, and archived tasks would be lost.
**Implementation:** Before entity-by-entity processing, `StaleOperationResolverService` identifies bulk semantic operations like `moveToArchive` and re-creates them with the original payload and a merged vector clock, preserving the full task data in `MultiEntityPayload` format.
**Key file:** `src/app/op-log/sync/stale-operation-resolver.service.ts`
### Singleton Entity LWW Updates
The `lwwUpdateMetaReducer` handles LWW Update actions (created when the local side wins a conflict) differently depending on the entity's storage pattern:
| Storage Pattern | Entity Types | LWW Update Behavior |
| --------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------- |
| **Adapter** | TASK, PROJECT, TAG, NOTE, TASK_REPEAT_CFG, etc. | Individual entity replacement via NgRx entity adapter (`updateOne` or `addOne`) |
| **Singleton** | GLOBAL_CONFIG, TIME_TRACKING, MENU_TREE, WORK_CONTEXT | Entire feature state replaced with the winning data |
| **Unsupported** | Map, array, virtual patterns | Logged as warning; not supported for LWW |
For **adapter entities**, the meta-reducer also syncs relationships (e.g., `project.taskIds` when `projectId` changes, `tag.taskIds` when `tagIds` changes, `TODAY_TAG.taskIds` when `dueDay` changes, `parent.subTaskIds` when `parentId` changes).
**Key file:** `src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.ts`
### User Notification
A non-blocking snack notification is shown after auto-resolution:

View file

@ -133,7 +133,7 @@ This document establishes the core rules and principles for designing the Operat
- **Rule:** Normal operations should be batched with reasonable limits.
- **Limits:**
- **Max batch size:** 100 operations per batch for normal sync uploads.
- **Max batch size:** 25 operations per batch for normal sync uploads.
- **Max payload size:** 1 MB per batch to prevent timeout issues.
- **Exception:** `SYNC_IMPORT` and `BACKUP_IMPORT` bypass these limits but must be clearly marked as bulk operations and trigger immediate snapshot creation afterward.

View file

@ -245,18 +245,27 @@ Last-Write-Wins automatically resolves conflicts using timestamps.
**Key Invariants:**
| Invariant | Reason |
| --------------------------------- | --------------------------------------------- |
| Preserve original timestamp | Prevents unfair advantage in future conflicts |
| Merge ALL clocks | New op dominates everything known |
| Reject ALL pending ops for entity | Prevents stale ops from being uploaded |
| Mark rejected BEFORE applying | Crash safety |
| Remote wins on tie | Server-authoritative |
| Invariant | Reason |
| --------------------------------- | -------------------------------------------------------------------------- |
| Archive-wins rule | `moveToArchive` always wins over field-level updates, bypassing timestamps |
| Preserve original timestamp | Prevents unfair advantage in future conflicts |
| Merge ALL clocks | New op dominates everything known |
| Reject ALL pending ops for entity | Prevents stale ops from being uploaded |
| Mark rejected BEFORE applying | Crash safety |
| Remote wins on tie | Server-authoritative |
**Stale Operation Special Cases:**
| Operation Type | Stale Handling |
| --------------- | --------------------------------------------------------------------------------------------- |
| Regular UPDATE | Re-created with current entity state + merged clock |
| DELETE | Re-created with original payload + merged clock (entity gone from store) |
| `moveToArchive` | Re-created with original payload + merged clock (entity removed from NgRx by archive reducer) |
**Key Files:**
- `conflict-resolution.service.ts` - LWW resolution
- `stale-operation-resolver.service.ts` - Stale op handling
- `conflict-resolution.service.ts` - LWW resolution + archive-wins rule
- `stale-operation-resolver.service.ts` - Stale op handling (incl. moveToArchive special case)
---
@ -362,51 +371,67 @@ Meta-reducers enable atomic multi-entity changes in a single reducer pass.
```
┌─────────────────────────────────────────────────────────────────┐
META-REDUCER CHAIN
META-REDUCER CHAIN (8 Phases, 15 Entries)
├─────────────────────────────────────────────────────────────────┤
│ │
│ Action Dispatched │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Phase 1: operationCaptureMetaReducer │ │
│ │ Captures persistent actions → FIFO queue │ │
│ │ Phase 1: operationCaptureMetaReducer [MUST BE FIRST] │ │
│ │ Captures original state BEFORE modifications │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Phase 2: bulkOperationsMetaReducer │ │
│ │ Applies bulk ops during sync/hydration │ │
│ │ Unwraps bulk dispatches for hydration/sync │ │
│ │ Pre-scans for archive ops to prevent │ │
│ │ resurrection via LWW Update │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Phase 3: lwwUpdateMetaReducer │ │
│ │ Handles LWW Update actions (conflict wins) │ │
│ │ Phase 3: undoTaskDeleteMetaReducer │ │
│ │ Captures task context before deletion │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Phase 4: Tag/Project/IssueProvider Shared Reducers │ │
│ │ Cascade deletions across entities │ │
│ │ Phase 4: Core CRUD Meta-Reducers (dependency order) │ │
│ │ • taskSharedCrudMetaReducer │ │
│ │ • taskBatchUpdateMetaReducer │ │
│ │ • taskSharedLifecycleMetaReducer │ │
│ │ • taskSharedSchedulingMetaReducer │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Phase 5: Planner/ShortSyntax Shared Reducers │ │
│ │ Handle task movements and parsing │ │
│ │ Phase 5: Entity-Specific Cascades │ │
│ │ • projectSharedMetaReducer │ │
│ │ • tagSharedMetaReducer │ │
│ │ • issueProviderSharedMetaReducer │ │
│ │ • taskRepeatCfgSharedMetaReducer │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Phase 6: Feature Reducers (task, project, tag, etc.) │ │
│ │ Core state updates │ │
│ │ Phase 6: plannerSharedMetaReducer │ │
│ │ Syncs with task.dueDay and TODAY_TAG │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Phase 7: validateAndFixDataConsistency │ │
│ │ Repair any inconsistencies │ │
│ │ Phase 7: Synthetic Multi-Step Operations │ │
│ │ • shortSyntaxSharedMetaReducer │ │
│ │ • lwwUpdateMetaReducer │ │
│ │ (adapter / singleton / unsupported patterns) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Phase 8: actionLoggerReducer [MUST BE LAST] │ │
│ │ Pure logging only │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
@ -415,6 +440,13 @@ Meta-reducers enable atomic multi-entity changes in a single reducer pass.
└─────────────────────────────────────────────────────────────────┘
```
**Critical Ordering Rules:**
- `operationCaptureMetaReducer` must be at index 0 (captures pre-modification state)
- `bulkOperationsMetaReducer` must be at index 1 (unwraps bulk before other reducers run)
- `actionLoggerReducer` must be last (logs final state)
- Development mode validates these constraints at startup
**Why Meta-Reducers for Multi-Entity Changes?**
| Approach | Problem |
@ -544,8 +576,15 @@ Bulk application optimizes performance by applying many operations in a single d
│ │ │
│ ▼ (in bulkOperationsMetaReducer) │
│ ┌────────────────────────────────────────┐ │
│ │ // Pre-scan: collect archived IDs │ │
│ │ archivedIds = findArchiveOps(ops) │ │
│ │ │ │
│ │ for (op of operations) { │ │
│ │ action = convertOpToAction(op) │ │
│ │ // Skip LWW Updates for archived │ │
│ │ if (isLwwUpdate(action) && │ │
│ │ archivedIds.has(entityId)) │ │
│ │ continue; │ │
│ │ state = reducer(state, action) │ │
│ │ } │ │
│ │ return state │ │