ARCHITECTURE-DECISIONS.md
This document tracks significant architectural decisions and patterns in the Super Productivity codebase. When making changes that affect these patterns, reference this document and update it if needed.
Status: ✅ Active (since commit 400ca8c1, 2026-01-29)
Decision: The task.dueDay and task.dueWithTime fields are mutually exclusive in new data. When setting dueWithTime, dueDay must be cleared (set to undefined). When reading, dueWithTime takes priority over dueDay.
Rationale:
Implementation:
dueDay when setting dueWithTime (in meta-reducers)dueWithTime first; only check dueDay if dueWithTime is not set (in selectors)Key Files:
task.model.ts - Field definitions with JSDoctask-shared-scheduling.reducer.ts - Write implementationwork-context.selectors.ts - Read patternplanner.selectors.ts - Read patterntask.selectors.ts - Read patternWhen to Update This Pattern:
Status: ✅ Active (established pattern)
Decision: TODAY_TAG (ID: 'TODAY') is a virtual tag whose membership is determined by task.dueWithTime or task.dueDay, not by task.tagIds. The tag's taskIds field stores only the ordering of tasks, not membership.
Key Invariant: TODAY_TAG.id must NEVER be added to task.tagIds
Rationale:
Related: Uses the dueDay/dueWithTime mutual exclusivity pattern (Decision #1)
Key Files:
tag.const.ts - TODAY_TAG definitionwork-context.selectors.ts - Membership computationtask-shared-helpers.ts - Invariant enforcementWhen to Update This Pattern:
Status: ✅ Active (since May 2026)
Decision: Operation-log sync code is split by dependency direction:
src/app composes host-specific wiring, @sp/sync-providers owns bundled
provider implementations, and @sp/sync-core owns framework-agnostic reusable
sync primitives.
Rationale:
Implementation:
@sp/sync-core has no runtime dependencies and owns vector-clock algorithms
used by client/server compatibility pathspackages/shared-schema compatibility-re-exports generic vector-clock
algorithms from @sp/sync-core; @sp/sync-core must not import
@sp/shared-schema@sp/sync-providers depends on public @sp/sync-core plus provider runtime
helpers, while app factories inject credentials, platform bridges, validators,
OAuth routing, and configDocumentation: docs/sync-and-op-log/package-boundaries.md
Key Files:
packages/sync-core/src/index.ts - Core public APIpackages/sync-providers/src/index.ts - Provider public APIeslint.config.js - Package boundary enforcementsrc/app/op-log/sync-providers/sync-providers.factory.ts - App-side provider compositionWhen to Update This Pattern:
Status: ✅ Active (since May 2026)
Decision: SuperSync batch uploads derive conflict-safety from the shared
user_sync_state.lastSeq row write that reserves server sequence numbers, not
from PostgreSQL RepeatableRead snapshot isolation alone.
Rationale:
user_sync_state.lastSeq row forces
accepted writers for the same user to serialize on that row lockREPAIR snapshot must prove that its state includes the current
server prefix; the same row serializes that base-cursor check with later writesImplementation:
INSERT ... ON CONFLICT ... DO UPDATE SET last_seq = last_seq + deltaskipDuplicates; an unexpected unique conflict
aborts the transaction and lets the request retryREPAIR uploads persist repairBaseServerSeq on the operation row. The HTTP
handler rejects an obviously stale base before quota cleanup, and the upload
transaction repeats the check under SELECT ... FOR UPDATE before insertionlastSeq write requires replacing this safety
mechanism with an equivalent per-user serialization primitiveDocumentation: docs/sync-and-op-log/diagrams/02-server-sync.md
Key Files:
packages/super-sync-server/src/sync/sync.service.ts - Upload transaction and batch primitivepackages/super-sync-server/prisma/schema.prisma - user_sync_state.last_seqpackages/super-sync-server/tests/integration/repair-causality.integration.spec.ts - Real-PostgreSQL race coverageWhen to Update This Pattern:
Status: ✅ Active (since 2026-06-06, branch feat/completing-projects-48eeb4)
Decision: "Complete project" is a plain single-entity PROJECT flag flip (completeProject, OpType.Update, mirroring archiveProject → sets isDone/doneOn/isArchived). The accompanying resolution of unfinished tasks ("move to Inbox" / "mark done") runs first, as the normal per-task actions (moveToOtherProject / updateTask isDone) dispatched in a loop with the Rule #6 bulk-dispatch flush — not bundled into a single atomic multi-entity op.
Rationale: An earlier iteration made completion one atomic Batch op (completeProject) that marked/moved tasks inside the project-shared meta-reducer. Because that op deliberately routed around the normal per-task actions, every system that observes those actions had to be re-taught about completeProject separately:
affectedEntities multi-entity-ref feature threaded through sync-core, the sync server (+ a Prisma migration), shared-schema and the op-log — ~1,565 LOC, of which completeProject was the only producer.completeProject listener to re-derive the task changes the atomic op skipped.The atomic op's headline benefit — reversing the whole thing as one unit — was never realized: reopenProject only clears the project flags; it does not un-move or un-complete the resolved tasks. So the bundle paid a large cross-cutting cost for an undo guarantee it didn't provide. Decoupling makes the existing effects and per-entity conflict detection fire naturally and deletes ~1,750 LOC total (revert + decouple). Trade-off accepted: completion now emits N+1 ops (one per resolved task + the flag flip) instead of one, and there is a brief intermediate state — both fine for a rare, user-initiated action whose resolution is not atomically reversible anyway. One behavioral nuance vs. the old atomic op: when unfinished work is moved to Inbox, a task that was being actively tracked stays the current task (it was carried forward, not finished — consistent with Inbox's carry-forward intent); the mark-done path stops tracking the current task via the existing autoSetNextTask$ effect. The atomic op cleared the current task in both cases; the decoupled design intentionally keeps it for the carry-forward case.
Implementation:
completeProject({ id, doneOn }) in project.actions.ts; on(completeProject) flag flip in project.reducer.ts (guards INBOX_PROJECT). reopenProject clears the flags only.ProjectService.complete(id, doneOn) dispatches the flag flip; moveTasksToInbox() / markTasksDone() loop the normal per-task actions + setTimeout(0) flush.work-context-menu resolves unfinished work before calling complete().completeProject op or affectedEntities for it without re-justifying the full downstream cost above. Prior atomic implementation is preserved in history at commit 0893a86162.Key Files:
project.actions.ts, project.reducer.tsproject.service.ts — complete / moveTasksToInbox / markTasksDonework-context-menu.component.ts — completeProject() flowWhen to Update This Decision:
Status: ✅ Active (since July 2026)
Decision: A passkey submitted during account registration is stored as a
PendingPasskeyRegistration tied to its exact email-verification token. It is
promoted to the user's active Passkey set only when that token is consumed.
Rationale:
Implementation:
Key Files:
When to Update This Pattern:
Status: ✅ Active (since July 2026)
Decision: Project deletions created with schema v4 or newer carry an explicit
projectDeleteWins marker and beat concurrent project updates. Historical,
unmarked deletions keep timestamp-based LWW semantics.
This is a deliberate semantic trade-off: a concurrent project rename or field edit that is vector-clock CONCURRENT with a marked delete loses, regardless of which has the newer wall-clock timestamp. Deleting an entity another device is editing wins over the edit — the alternative (timestamp LWW) resurrects an empty project shell and silently loses its task subtree. The lost edit is only recoverable via local undo, not via sync.
Rationale:
deleteProject is one user intent whose reducer cascade removes the project,
active tasks, notes, sections, repeat configuration, and related archive data.
Reversing only the project entity after that operation loses data and violates
replay determinism.entityId to match its authenticated payload projectId, so a
tampered/replayed delete retargeted onto a live entity cannot win.Implementation:
deleteProject actions include projectDeleteWins: true; replacement
delete operations preserve that payload.Key Files:
task-shared.actions.ts — the PROJECT_DELETE_WINS_MARKER producerconflict-resolution.tsconflict-resolution.service.ts — the delete-wins classifierschema-version.tsproject-delete-wins-barrier-v3-to-v4.ts (registered in migrations/index.ts)When to Update This Pattern:
deleteProjectAdd a new decision record when:
### N. [Pattern/Decision Name]
**Status**: ✅ Active | 🚧 Draft | ⚠️ Deprecated | ❌ Superseded
**Decision**: [One-sentence summary of the decision]
**Rationale**:
- [Why was this decision made?]
- [What problems does it solve?]
**Implementation**:
- [How is it implemented?]
- [Key techniques or patterns used]
**Documentation**: [Link to detailed docs]
**Key Files**: [List of primary files implementing this pattern]
**When to Update This Pattern**: [Scenarios when someone should review/update this]
docs/sync-and-op-log/ - Operation log architecturedocs/long-term-plans/ - Future architectural plansWhen committing changes related to these patterns, reference this document and the specific decision:
feat(tasks): implement feature X
Uses dueDay/dueWithTime mutual exclusivity pattern (ARCHITECTURE-DECISIONS.md #1)