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.
It is also the index of accepted decisions: a decision recorded somewhere else — because it is long enough to stand alone, or because it is enforced as a contributor rule — must still be listed under Decisions Recorded Elsewhere.
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/package.json - Provider public exportseslint.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; batch upload engine removed August 2026)
Decision: SuperSync 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.
Note — batch upload engine deleted (2026-08, #9508): this decision was
originally written for the batch upload engine (processOperationBatch,
prefetchLatestEntityOpsForBatch, the SUPERSYNC_BATCH_UPLOAD flag). That
engine was never enabled in production and was deleted rather than rolled out;
the serial per-op path (processOperation) is the only upload engine. The
invariant below is engine-neutral and applies unchanged to the serial path.
The deleted batch code last lived at commit 924ddd7019. Re-open condition:
the batch engine processed a 25-op upload in ~10 SQL statements vs ~127 for
serial — resurrect it (from that commit, re-reviewed) only if per-upload
latency or transaction lock-hold time becomes a measured production problem.
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 writes07511ab45c) was dead code: under
RepeatableRead both conflict checks read one snapshot fixed at the
transaction's first statement, and the lastSeq increment raises a
serialization failure (40001) against any committed concurrent upload
before a re-check could run. Lowering the isolation level below
REPEATABLE READ would require reinstating a post-allocation re-check.Implementation:
user_sync_state row exists (lastSeq: 0); each
accepted operation then reserves its sequence number with an atomic
update({ lastSeq: { increment: 1 } }) on that row
(operation-upload.service.ts)createMany(..., skipDuplicates: true): a lost
duplicate-ID race surfaces as count === 0 and is handled in-transaction
(sequence rolled back, op classified as DUPLICATE_OPERATION) rather than
aborting the whole upload with a unique-constraint error; only a non-ID
unique conflict aborts the transactionREPAIR 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 insertionlastKnownServerSeq use the same per-user row lock
to reject an upload behind the latest SYNC_IMPORT or BACKUP_IMPORT before
insertion. The durable replacement marker is reconciled lazily from retained
operations for rows created before the marker existed.REPAIR purely
as a stand-in for an import row that pruning already deleted; a repair is not a
fence in its own right, and the client replays concurrent work on top of one
rather than dropping itlastSeq write requires replacing this safety
mechanism with an equivalent per-user serialization primitiveDocumentation:
packages/super-sync-server/docs/architecture.md,
docs/sync-and-op-log/sync-architecture.html#transport
Key Files:
packages/super-sync-server/src/sync/sync.service.ts - Upload transaction and sequencing 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:
deleteProjectStatus: ✅ Active (since August 2026)
Decision: Persisted and synced data evolves additively. Pick the change
channel by what actually changed (table below). Do not raise
CURRENT_SCHEMA_VERSION unless a change is both inexpressible as an additive
or derived field and would be misapplied — not merely ignored — by older
clients. This is the constructive counterpart to the bump policy (sync rule 10),
which says when not to bump but not what to do instead.
| What changed | Channel | Precedent |
|---|---|---|
| Local storage layout (stores, indexes, derived meta) | DB_VERSION ladder — local only, never transmitted | db-upgrade.ts v7 seeds the full-state-ops meta store |
| Shape of stored state (new field, legacy key, changed default) | Read-time normalization in the loadAllData reducer | migrateFocusModeConfig, migrateKeyboardConfig |
| Representation of an existing synced field | Dual field — new field wins, legacy re-derived from it on every write | normalizeStartOfNextDayConfig |
| Semantics of an operation | Payload marker / envelope, inert on older clients | LwwUpdatePayload; the v4 projectDeleteWins marker |
Rationale:
electron/start-app.ts is commented out) and the
update banner's dismissal is persisted. Any policy gated on "wait for the old
fleet to shrink" is a permanent no in disguise.createValidate
(excess properties are neither rejected nor stripped) and LWW patch application
goes through updateOne, a shallow merge that retains unknown keys. Renames
and removals are the dangerous shape — an old client that wins a conflict
re-emits the entity without the field, destroying it fleet-wide — and no bump
prevents that, because old clients keep writing regardless.Evaluation record (2026-08): raising CURRENT_SCHEMA_VERSION to 5 was
considered and declined. Neither candidate motivation survived: the
accumulated optional-field/runtime-default debt needs no migration (that pattern
is the answer, per sync rule 11), and the typed RRULE recurrence model can ship
as an additive field while the flat fields stay canonical and re-derived — see
#9664, which also corrects that plan's inverted cross-version gate. A migration
with no payload is pure cost.
Implementation: no new machinery — each channel above already exists and has a shipped precedent.
Documentation: Bump Policy §A.7.11, persisted-model-fields.md, AGENTS.md sync rules 10 and 11
Key Files:
schema-version.ts — the constant and its bump warningnormalize-start-of-next-day-config.ts — the dual-field templateglobal-config.reducer.ts — read-time normalization at loadAllDatadb-upgrade.ts / db-keys.const.ts — the local-only version ladderWhen to Update This Pattern:
CURRENT_SCHEMA_VERSION is raised (record what earned it)Status: ✅ Active (recorded 2026-08; describes the boundary that shipped 2026-03 in 3e2265fa57 / 020fd56504)
Decision: Super Productivity is never the authority for calendar state. It
reads calendars to show the day's commitments, and it may write a mirror of a
scheduled task back — but only through a plugin issue provider that opts into
the timeBlock contract, and only when the user has enabled that provider's
auto-time-blocking setting. Core code contains no calendar write path.
The boundary today:
| Surface | Writes? |
|---|---|
Built-in iCal/CalDAV URL feeds (src/app/features/schedule/ical/) | No — poll and parse only |
Built-in issue providers (src/app/features/issue/providers/*) | No — none implement timeBlock |
Plugin providers implementing timeBlock (google-calendar-provider, caldav-calendar-provider) | Yes, when isAutoTimeBlock is on |
Rationale:
TimeBlockSyncEffects
pushes task state one way — schedule, reschedule, title, estimate, done, delete
— into an event the app itself created. It never reconciles a user's edit of
that event back into the task, and it never touches events the app did not
create. That keeps the flow one-directional even though it writes, which is what
avoids the sync loop a true bidirectional design has to solve.isAutoTimeBlock is an unchecked box on the
provider's config form. Writing into someone's calendar is not something to
infer from an integration merely being connected (manifesto: opt-in, quiet by
default).packages/plugin-dev/
behind the timeBlock contract means core carries no vendor API surface, and a
broken provider degrades to read-only rather than breaking the app.RECURRENCE-ID/EXDATE, #8148). These are the parts
that would require answering conflict resolution between vector clocks and
ETags, and they remain unbuilt — see #5001 for the open bidirectional request.Implementation:
timeBlock: { upsertEvent, deleteEvent } in
packages/plugin-api/src/issue-provider-types.tstime-block-sync.effects.ts,
registered in feature-stores.module.tscalendar-event-actions.service.tsgenerate-calendar-task-id.ts).
Provider configuration does sync (ISSUE_PROVIDER in
entity-registry.ts).When to Update This Pattern:
Status: ✅ Active (recorded 2026-08; the alternative design is git show 07511ab45c:docs/long-term-plans/server-side-entity-versioning.md)
Decision: Conflict detection stays on vector clocks, pruned to
MAX_VECTOR_CLOCK_SIZE = 20. Server-side per-entity version counters (optimistic
concurrency control, the shape every centralized API uses) were designed in full
and are not being built. The design was never rejected on its merits by a
maintainer decision — it is recorded here as declined-by-default, because nothing
has yet justified its cost.
Rationale:
detectConflict in
packages/super-sync-server/src/sync/conflict.ts), but it does so by comparing
clocks the clients authored — the causal history stays client-owned. Entity
versioning moves that authority into the server. File-based providers (WebDAV,
Dropbox, local file) have no server to run it, so the vector-clock path must
survive regardless, and we would maintain two conflict systems instead of
one.supersync-encryption-architecture.md.Implementation: unchanged — see
docs/sync-and-op-log/vector-clocks.md.
The server prunes after conflict detection, before storage.
When to Update This Pattern:
These carry the same authority as the numbered records above. They live outside this file because they are long enough to stand alone, or because they are enforced as contributor/agent rules that must be read before touching the subsystem. Keep this table complete — if you record a decision somewhere else, add a row here.
| Decision | Where it lives |
|---|---|
| SuperSync database encryption at rest — no project-managed volume encryption; the LUKS and PostgreSQL-TDE attempts are retired as OpenVZ-incompatible | docs/supersync-encryption-at-rest-decision.md |
Schema-version bump policy — default to NOT bumping CURRENT_SCHEMA_VERSION; a bump never protects the released fleet and cannot be reverted | operation-log-architecture.md §A.7.11 Bump Policy, schema-version.ts, AGENTS.md sync rule 10 |
Required fields on persisted models — a new field on a persisted model is optional (?) plus a runtime default, never required | docs/sync-and-op-log/persisted-model-fields.md, AGENTS.md sync rule 11 |
One user intent = one op — effects inject LOCAL_ACTIONS; a multi-entity change is a meta-reducer, not an effect fan-out | docs/sync-and-op-log/contributor-sync-model.md, AGENTS.md sync rules 1–3 and 6 |
src/app layer boundary — core/ and ui/ must not import features/; lint-enforced, with a shrink-only grandfathered list | src/app/README.md, eslint.config.js (FEATURE_LAYER_FENCE) |
Add a new decision record when:
Do not rewrite a record's rationale in place when the answer itself is reversed. The reasoning history — why the old answer looked right, and what evidence flipped it — is the thing that stops the same idea being re-proposed a year later, and it is invisible in git blame. Instead:
❌ Superseded by #N and leave it where it is. Numbering must stay stable: ~30 code comments cite decisions by number.No record has been superseded yet, so there is no worked example. Decision #5 is the closest model for the content of step 2 — it records a rejected design, what it actually cost (~1,565 LOC of cross-cutting machinery for a single producer), why the headline benefit was never realized, and the commit (0893a86162) preserving the prior implementation. It is itself ✅ Active and does not demonstrate the status/replacement mechanics of steps 1 and 3.
This applies only when the answer changes. Fixing wording, adding a key file, or clarifying an existing decision is ordinary editing.
### N. [Pattern/Decision Name]
**Status**: ✅ Active | 🚧 Draft | ⚠️ Deprecated | ❌ Superseded by #N
**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]
This log deliberately does not use one-file-per-decision (docs/adr/NNNN-*.md):
AGENTS.md, a decision record's only teeth are being read — spreading them over 30 files means nobody reads all of them.docs/ already separates plans, long-term-plans, research, sync-and-op-log and wiki. A further location makes decisions harder to find, not easier.Note this is "one index, many locations", not "one file": Decisions Recorded Elsewhere deliberately sanctions authoritative decisions living in their own documents. What stays consolidated is the entry point, so that ~30 in-code citations (// See: ARCHITECTURE-DECISIONS.md Decision #2) resolve to one place.
Revisit when supersession chains actually accumulate, or when this file passes ~1000 lines. Then split by subsystem, not one file per decision, and keep the existing numbering so those citations stay valid.
src/app/README.md - Layer map: where things live and which dependency directions are lint-enforceddocs/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)