This document covers the internal mechanics of bidirectional sync between Trello and Apple Reminders: which fields are synced, how creates and updates are processed, how the Priority custom field works, how conflicts are detected and resolved, how potential duplicates are detected and resolved, and how echo-loops are prevented.
Fields fall into three categories based on how far they propagate:
These fields sync in both directions. Changes on either side are detected and pushed to the other.
| Trello field | Reminders field | Notes |
|---|---|---|
| Card name | Title | Truncated to 16,384 chars on both sides |
Card description (desc) |
Notes | Truncated to 16,384 chars on both sides |
Due date (due) |
Due date components | ISO 8601 ↔ DateComponents conversion |
Start date (start) |
Start date components | ISO 8601 ↔ DateComponents conversion |
Completion (dueComplete) |
isCompleted |
Boolean; uses dedicated complete/uncomplete change types |
| Priority custom field | priority (EKReminderPriority) |
Mapped via a managed “Priority” dropdown custom field; see Custom Fields |
| Card location | Location-based alarm | Trello address/coordinates ↔ EKStructuredLocation geofence alarm; server geocodes addresses when coordinates are missing |
These fields are stored on the server and displayed in the DailyRoundup app but are not written to Reminders (EventKit has no equivalent).
| Trello field | Storage | Display |
|---|---|---|
| Labels | trello_card_labels table |
Colored label pills in the task detail view |
| Attachments | sync_attachments table |
📎 name: url appended to reminder notes; attachment list in the app |
| Checklists | sync_checklists / sync_checklist_items tables |
Checklist items with completion status in the task detail view |
| Custom fields (non-priority) | trello_card_custom_field_values table |
Name/value pairs in the task detail view |
Card position (pos) |
sync_tasks.position column |
Used for ordering; Reminders uses its own list order |
| Reminders field | Trello field | Notes |
|---|---|---|
| Tag names | Labels | Reminders tags are mapped to Trello labels by name; new labels are created if no match exists |
Trello serializes due, start, and its “Remind me” custom field with fractional seconds (2026-01-15T00:00:00.000Z); Reminders-originated values arrive at second precision (2026-01-15T00:00:00Z). Stored timestamps are always second-precision regardless of origin:
sync_tasks columns, a pending change’s changed_fields, and a conflict’s recorded values go through trello_sync._normalize_date_value on every write path that carries a Trello-originated date.card_action_index reaches the same spelling by construction — its writers either format a parsed datetime (scheduler._iso, used by index_card) or copy an already-normalized sync_tasks value.This is a correctness requirement, not tidiness: the client’s SyncServerAPIClient.parseDate returned nil for the fractional form and applied that as “no date” while still acknowledging the change, which silently dropped every due date set on the Trello side (issue #492). The client now accepts both spellings as defence in depth.
Trello custom field values (trello_card_custom_field_values.value_date) are deliberately exempt — they are stored verbatim as read-only display data.
Card creation and updates follow different paths because Trello’s webhook payloads differ significantly between the two.
Create (createCard webhook): Trello’s createCard webhook payload only includes minimal card fields (id, name, idList, pos). It omits desc, due, start, dueComplete, and custom field items. To ensure all fields are captured, the webhook handler fetches the full card from the Trello API (GET /cards/{id}?fields=all&customFieldItems=true) before recording the pending change. The priority custom field is resolved from the card’s customFieldItems using the stored option ID mappings.
The server creates a sync_task record and inserts a pending_change with change_type=create. The iOS client pulls this change, calls EventKitStore.syncCreate() to create the EKReminder, and acknowledges the change. When the change carries is_completed: true, the client then marks the new Reminder complete with EventKitStore.syncComplete(). The row’s completion baseline is taken from is_completed, so a Reminder left incomplete would read as a local uncomplete on the next push and reopen the card. If marking it complete fails, the error is recorded and the row is stored as incomplete, matching the Reminder.
Update (updateCard webhook): Trello’s updateCard webhook includes only the changed fields in data.old (the previous values). The webhook handler extracts the new values from data.card for each field present in data.old. No API fetch is needed because the webhook payload contains the current values for all changed fields.
Applying an update on a device: SyncManager.applyChange writes each field an update carries to the Reminder and moves that field’s baseline, except a field the Reminder holds an unpushed edit to (issue #630). The device checks the title, description, due date, start date, and location for such an edit only when the change’s previous_values has an entry for the field, even a null one. For each field it checks, the device hashes the Reminder’s current value as the push pass does and compares it with the stored hash. A field whose hash differs keeps the Reminder’s value and its baseline, the diagnostics log records the task and fields under InboundLocalEditKept, and the change is still applied and acknowledged. A later push pass sends the edit on its old baseline, and the server judges it by the rules in Against the push’s baseline: applied through the device-applied exception to rule 3, or a conflict when Trello changed the field. Priority, position, completion, and “Remind me” are applied as the change carries them.
A difference that is not an edit keeps nothing, so it cannot hold back later inbound values:
[Label] brackets in other positions, which is the server rebuilding a title the device pushed (issue #530). Label names compare exactly here: the device’s bracket-sync setting is not read back from the server, and on an account without bracket sync a re-cased bracket is an edit to the card name. A title whose brackets the label-bracket heal re-cased is still applied to a Reminder that matches its baseline or holds the change’s previous_values title;A field whose Reminder value equals the change’s previous_values entry for it is applied as well. A Trello-origin update that carries a title, description, due date, start date, or location has a previous_values entry for each: the server’s value before the change was written, with dates normalized, and the location as a {location_name, latitude, longitude, address} object, or null for no location. The server queues two Trello-origin updates without them: the update the updateCard webhook records for a card the server did not track yet, whose task it creates from the card, and the recovery change the one-time timestamp normalization writes (issue #492), which carries a date the server already held. A previous location with no name also matches a geofence titled with its address, which is how a device names a location Trello reported without a name. The baseline cannot tell an edit made on the device from a value another device set that reached its Reminder through iCloud before it synced, but a Reminder that holds the server’s previous value did not edit the field on the device, so it takes the Trello change and moves its baseline. keep_trello is the exception: it sets the entry to the Reminders value the user rejected; see Resolution options.
A Reminder value that matches neither its baseline nor the change’s previous_values entry is kept, and the push decides it as above.
A field the change has no previous_values entry for is applied over the Reminder, as every field was before issue #630. This is what lets the app and the server ship in either order: a server that predates the change sends no previous_values and has no baseline rules, so a Reminder value a device kept and pushed would be written over the Trello change on the card. For the two updates the server queues without entries, this is the behaviour from before issue #630: no device has a Reminder yet for a card the server has just started tracking, and the recovery change carries a date the server already held.
Create (new reminder detected): During the push phase of a sync cycle, SyncManager compares the current Reminders items against known SyncTask records. Unrecognized items (no matching remindersItemId) are emitted as create changes with all current field values. The server calls TrelloClient.create_card() with title, description, due date, position, and card role, then creates the sync_task record.
Update (field hash mismatch): Each SyncTask stores a hash of every synced field’s last-known value. On each sync cycle, SyncManager recomputes hashes for the current reminder and compares them against the stored hashes. Only changed fields are included in the update change payload, minimizing unnecessary Trello API calls. After the server applies the update, the client refreshes the stored hashes. When Trello rejects a location write, the server still stores the rest of the change but reports error rather than applied, so the client leaves its stored hashes where they were and pushes the location again on its next sync (issue #630).
Priority is the only custom field that syncs bidirectionally. It is implemented as a managed Trello dropdown (list-type) custom field named “Priority” with three options: Low, Medium, and High. These map to EKReminderPriority values:
| Trello option | EKReminderPriority raw value | Constant |
|---|---|---|
| (none selected) | 0 |
.none |
| High | 1 |
.high |
| Medium | 5 |
.medium |
| Low | 9 |
.low |
When the first Reminders-side priority change is pushed for a list, the server calls ensure_priority_custom_field():
sync_custom_fields table.If the board is on a free Trello plan (custom fields require Standard or higher), the server logs a warning and silently skips priority sync for that list.
high → option ID) and calls PUT /cards/{id}/customField/{fieldId}/item with the selected option. Setting priority to none clears the custom field value.updateCustomFieldItem webhook fires when a user changes the Priority dropdown in Trello. The webhook handler reverse-maps the selected option ID back to a priority integer and emits a pending update change. On createCard, the priority is resolved from the card’s customFieldItems returned by the full card fetch.All other custom fields (text, number, date, checkbox, other dropdowns) are read-only: they are stored in trello_card_custom_field_values and displayed in the app but not synced to Reminders.
A conflict is a field whose Reminders-side edit the server cannot apply without possibly discarding a Trello-side value the user wants: either both sides changed it before a device synced, or a device edited it from a view older than the server’s. Only a subset of fields participate in conflict detection; the rest are last-write-wins.
title, description, due_date, start_date, and location. Location is eligible only through a push’s baseline (below); without one it is last-write-wins.
position, is_completed, priority, remind_me
A Reminders-side update change pushed to POST /dailyroundup/sync carries only new values, so the server checks it two ways before applying it. A change that fails either check records a conflict and applies nothing, and its result lists conflict_ids with the matching conflict_fields in the same order. Its other fields are not lost: a result other than applied leaves the device’s baselines where they were, so its next sync pushes those fields again while the conflicted ones are held back, and they apply then.
A change may carry a baseline object beside changed_fields: for each changed field, the value the device last synced, which its edit was based on (issue #630). A null value means the field was empty. location stands for the whole location as one {location_name, latitude, longitude} object. On a task that has a Trello card, each field with a baseline key is compared with the server’s current value, from sync_tasks or, for location, from sync_locations:
sync_tasks for it, and a dropped due date does not recalculate the auto-set “Remind me”. When every field is dropped the change reports applied with no writes.trello_value, the pushed value as reminders_value, and the baseline as last_synced_value, and the field is withheld from any undelivered Trello change, as the check below does. An unresolved conflict already open for the same task and field is reused rather than recorded again; its trello_value and reminders_value are replaced with the current ones, and its last_synced_value stays as first recorded.Rule 3 has one exception: a server value that came from a device. device_applied_values records, per task and field, the value the server last stored from a device, as stored (a truncated title, a normalized date, a location as its compact JSON or null): an applied push, with or without a baseline, and a conflict resolved with keep_reminders or keep_both. A field rule 1 dropped and a change that ended in a conflict record nothing. When neither the pushed value nor the baseline matches the server but the server’s value matches that record, compared as a baseline is, Trello never changed the field, so it is applied as under rule 2 and the server logs that its value came from a device. Two devices editing in turn are the case this covers: the iPad pushes 4 PM, and the iPhone, before it has synced that, pushes the 5 PM the user set there on its 3 PM baseline. A Trello edit replaces the server’s value, so the record stops matching, and in the incident behind issue #630 the iPhone pushing the iPad’s 4 PM back after Trello removed the date still conflicts. A Trello edit that sets the field back to exactly the recorded value cannot be told apart from it.
The comparisons tolerate each side’s spelling:
sync_label_brackets), the order of the same [Label] brackets and the case of their label names are ignored as well: the server rebuilds such a title with its labels at the account’s bracket position, label sync matches a bracket to a Trello label case-insensitively, and the label-bracket heal can rebuild a pushed [work] as the board label’s [Work] without updating device_applied_values. Without bracket sync a bracket is plain text in the card name, which the server stores as it is, so a title that only moves or re-cases a bracket still reaches the card. This holds for the pushed value, the baseline, and a recorded device-applied value. A title whose text outside its brackets is re-cased, or that adds a bracket, still reaches the card; with bracket sync, one that only re-cases a label name is dropped by rule 1. A baseline without a [Label] the server’s title has, such as one Trello added, is an old view, so the push conflicts rather than removing that label from the card. The pending-change check below still uses canonical_title.trello_value also carries the stored row’s address.This is what stops a device from writing back a value it never saw change. The undelivered-changes check below stops finding a Trello change once every device has acknowledged it, and in the incident behind issue #630 a due date removed in Trello was pushed back hours later, after both devices had acknowledged the removal, by a device whose Reminder still had the date; auto-set “Remind me” was then recalculated from it.
Rule 2 cannot catch a stale push when the device’s baseline for the field is empty. The kind of case meant is a value that reached the device’s Reminder without the device syncing it from the server, such as a value another device set that arrived through iCloud. Once Trello removes the value, the empty baseline matches the server’s empty value, and such a push is applied as a real edit. The client prevents the push when the Reminder holds the value the server had before the removal: the removal’s previous_values entry matches, so the device applies the removal, moves its baseline, and has nothing to push (see Trello → Reminders (inbound)). It does not when the Reminder’s value was never the server’s value before the removal, such as an edit another device had not pushed yet when Trello removed the field: the device keeps it as an unpushed edit and pushes it, and rule 2 applies it. A Trello-origin change without a previous_values entry for the field, which the server queues only for a card it did not track yet and for the timestamp recovery of issue #492, is applied over the Reminder, so the device has nothing to push either. When the device’s baseline is not empty, a push of such a value records a conflict instead.
A key absent from baseline, or a change with no baseline at all, as older clients send, leaves the field to the next check. Stub tasks, which have no Trello card yet, skip this check.
For conflict-eligible task fields that no baseline confirmed, the server checks for Trello-originated pending changes on the same task that no device has applied yet:
pending_changes for the task where change_origin=trello and no device has applied it yet.changed_fields.None, date format differences, canonical_title for titles) to avoid false positives.conflict is recorded for each conflicting field, with the task’s stored value as last_synced_value. As in the baseline check, an unresolved conflict already open for the same task and field is reused rather than recorded again, with its trello_value and reminders_value replaced by the current ones, so the devices are notified of it once.Both checks withhold the conflicting fields from the task’s Trello pending changes that no device has applied yet, so they are not applied or re-detected (issue #630). Each conflicting key is removed from a change’s changed_fields, and only a change left with no keys is marked with status conflict. The rest of a change still reaches the devices: a rename Trello reported in the same action as a conflicting due-date change is delivered. A create or move is marked conflict whole instead, because a device builds a Reminder from one. A change a device was served but has not acknowledged is applied by that device as served, and the other devices are served the reduced change. The Reminders-side change is not applied — both sides remain unchanged until the user resolves the conflict.
The client does not push a field while GET /dailyroundup/conflicts lists an unresolved conflict for that task and field, so its baseline for the field stays where it was. A device can still receive a Trello value for the field meanwhile. The webhook does not consult open conflicts, a change another device already acknowledged is never withheld, and a change served before the conflict was recorded has already gone out. A device applies such a value, and moves its baseline with it, only when its Reminder still matches its baseline for the field or holds the change’s previous_values entry for it, or when the change has no entry for the field. A Reminder that holds the edit the conflict held back, or a newer one, keeps it, since that edit has not been pushed (see Trello → Reminders (inbound)).
When the updateCard webhook stores a Trello edit to a field, or to the location, of a task with an unresolved conflict on it, the conflict’s trello_value is replaced with the value just stored, read back as the baseline check reads it. The conflict view then shows Trello’s current value. reminders_value is left as recorded.
After keep_reminders or keep_both, the server holds the Reminders value and records it in device_applied_values. It then queues a Trello-origin update carrying the stored value to every device, spelled as keep_trello spells it, with the server’s values from before the Reminders value was written as its previous_values, read before any write. Without that change, a device that applied a Trello value while the conflict was open would keep it: its Reminder matches its baseline, so it pushes nothing, and the card and the Reminder would differ with nothing to reconcile them. A device that already holds the value applies a no-op. The device whose push raised the conflict has not moved its baseline for the field, so its Reminder reads as an unpushed edit and keeps its value over the queued one. Its next push of the field is dropped by rule 1 when the Reminder still has the kept value, and applied through the device-applied exception to rule 3 when the user changed it while the field was held back.
Conflicts are resolved via POST /dailyroundup/conflicts/{id}/resolve, or together for one task via POST /dailyroundup/conflicts/resolve-batch, with one of three strategies:
| Resolution | Effect |
|---|---|
keep_trello |
The server’s current value is kept: the task’s sync_tasks field, or its sync_locations row for location, which the webhook keeps equal to the card. A Trello edit made after the conflict was recorded is therefore what is kept, not the conflict’s trello_value. Neither Trello nor the server’s record is written. A Trello-origin update pending change carries the value to every device, since the device whose push raised the conflict still shows its own value. A cleared title or description travels as "", a cleared date as null, and a location as the stored row’s address, location_name, latitude, and longitude, all null when the task has none. The change’s previous_values carries the Reminders value the user rejected for each field: the conflict’s reminders_value with dates normalized, and for location a {location_name, latitude, longitude} object, or null for no location. The device whose push raised the conflict still holds that value unpushed, and a device keeps an unpushed Reminder value over an inbound one, so without it that device would push the rejected value again and raise the conflict again. A Reminder whose value equals its previous_values entry therefore takes the kept value, and one the user changed again after the conflict keeps that newer edit, which its next push sends on the old baseline. The batch endpoint sends one pending change for all of the task’s fields. |
keep_reminders |
The Reminders value is written to the Trello card, and sync_tasks is updated to match. A title goes through the handling a device push gets (TrelloSync.card_title_write): when the account syncs label brackets, the card name is the title without its [Label] tokens, truncated to 16,384 characters and not written when the card already holds that truncated name; the stored title keeps its brackets, rebuilt at the account’s bracket position when truncation shortened it; and the brackets are synced to the card’s labels after the title is stored. Without bracket sync the title is truncated. A cleared date or description is sent to Trello as an empty string, which is what clears it. A location goes through the same card write a device push uses, which also updates sync_locations. The stored values are recorded in device_applied_values and then sent to every device in one Trello-origin update, spelled as for keep_trello, whose previous_values are the server’s values from before the Reminders value was written, as for any other Trello-origin update (see While a conflict is open). A written due date recalculates auto-set “Remind me” from the previously stored due date, as a device push that changes the due date does; the resolution carries no “Remind me” of its own, and an unchanged due date leaves it alone. An echo-loop fingerprint is recorded to suppress the resulting updateCard webhook, and each label change records its own. |
keep_both |
First, the server’s current value of each conflicting field is saved as the conflict’s trello_value: the sync_tasks field, or for location the sync_locations row with its address. A field whose stored value already is the conflict’s Reminders value is skipped. That is a retry after a failed duplicate create, and the record keeps the Trello side saved before the original was written. The original card and task then take the Reminders value, written, recorded, and sent to the devices as keep_reminders does, and before the duplicate is created, so a rejected write leaves no duplicate behind. A duplicate Trello card is then created from the saved records; _KEEP_BOTH_SUFFIX is empty, so nothing is appended to its title. Both endpoints give the duplicate each conflict’s saved Trello title, description, and due date, and the task’s stored value for a field without a conflict, which both sides share. The duplicate’s title goes through the same title handling as a new card, so a Trello title’s [Label] tokens become the duplicate card’s labels rather than part of its name. Neither endpoint gives the duplicate a start date, because create_card cannot carry one. For a location conflict the saved Trello name, coordinates, and address are set on the duplicate card and its sync_locations row. A create pending change then puts the duplicate in Reminders as a separate item, carrying its title, description, due_date, and start_date as stored on the duplicate, null when absent, its is_completed, and its address, location_name, latitude, and longitude when the duplicate has a location. A location write Trello rejected leaves the duplicate, and its create, without one. |
Boolean fields (is_completed, is_archived) keep their own handling: keep_trello and keep_reminders write the chosen completion or archive state, and keep_both creates a duplicate card carrying the Trello completion state while the original keeps the Reminders state. The duplicate is given no start date, and its create carries the same fields as a card-field duplicate’s, with is_completed true when the Trello side of an is_completed conflict was completed, so the device creates the duplicate Reminder completed. Nothing else would complete it: the duplicate’s task already matches its card, so reconciliation sends no complete.
An app build that predates issue #630 sets no geofence when a create carries a location, and does not complete the Reminder when a create carries is_completed: true. A keep_both duplicate of a location conflict delivered to such a device can have its location cleared by that device’s next push, and a completed duplicate is reopened in Trello by it. Update every device to a build with issue #630.
Unresolved conflicts are surfaced in three ways:
conflicts but does not notify again. On macOS, the notification includes action buttons for quick resolution.UNNotificationAction responses, allowing users to resolve conflicts directly from the notification without opening the app.When a new Reminders item is pushed to the server, the server checks whether it might already exist as a synced task before creating a new Trello card. This prevents a reminder that was created on another device (and already synced) from being duplicated if the local app sees it as new.
Duplicate detection only runs on Reminders-side create changes. It is skipped if the incoming item has no title, if the item already maps to a known sync_task, or if the item was previously reviewed and resolved as a duplicate.
The server queries all active (non-retired) sync_tasks for the account whose title matches the incoming reminder’s title, case-insensitively and after trimming whitespace. Tasks in any sync list for the account are considered, not just the target list.
Each candidate is scored against the incoming item’s fields. The title match is the entry condition from candidate selection and is not itself scored.
| Field | Points | Condition |
|---|---|---|
| Reminders creation-date proximity | +3 / +2 / 0 | Graded — see below |
| Due date | +1 | Both have the same date (date portion only, time ignored), or both have no due date |
| Description | +1 | Both have the same non-empty description after trimming whitespace (both-empty does not score) |
| Is completed | +1 | Only when the incoming item explicitly sends this field, and both sides are true (a shared incomplete state doesn’t count) |
The maximum possible score is 6.
When the incoming create payload includes a created_at timestamp (the EKReminder’s creation date) and the candidate task has a reminders_created_at, a graded bonus is added based on how close the two timestamps are:
| Time difference | Bonus |
|---|---|
| ≤ 1 hour | +3 — strong signal of a sync-caused duplicate |
| > 1 hour – 6 hours | +2 — moderate signal |
| > 6 hours | 0 |
This is the strongest signal because a sync-caused duplicate’s Reminders creation date is identical on both sides (or off only by processing lag), while distinct items — including recurring tasks with identical titles — are extremely unlikely to be created within the same narrow window. The bonus is skipped (0) if either timestamp is absent or unparseable. Changed from a distance-based penalty to this additive bonus in #431 to more strongly weight creation-date proximity as corroborating evidence.
The threshold for flagging is score ≥ 3 — reachable by strong creation-date proximity alone, by moderate proximity plus one corroborating field, or by full content agreement without creation-date data.
Even when the threshold is met, the duplicate is only flagged if exactly one candidate reaches the highest score. If two or more candidates tie at the top score, no duplicate is flagged: the match is considered too ambiguous to surface.
When scores are equal between candidates, a candidate in the same sync list as the incoming item is preferred over one in a different list.
Flagged duplicates are held in the potential_duplicates table with status unresolved and surfaced to the user in the app. The incoming Reminders item is not synced until the user resolves it. Two options are available:
| Resolution | Effect |
|---|---|
merge |
The incoming Reminders item is linked to the existing sync_task. No new Trello card is created. |
keep_both |
The incoming item proceeds through normal create processing, producing a new Trello card alongside the existing one. |
After resolution, subsequent pushes of the same reminders_item_id skip duplicate detection entirely.
Unresolved potential duplicates are shown in the sync status section of the app above the task list. Each entry shows the existing and incoming task fields side by side so the user can compare them before choosing a resolution.
Because every change the server makes to Trello triggers a webhook back to the server, and every change the app makes to Reminders is detected on the next sync cycle, echo-loop prevention is critical. Without it, a single change would bounce back and forth indefinitely. Four independent mechanisms work together:
echo_loop.py)When the server writes to the Trello API (e.g., creates or updates a card), it records a fingerprint in the echo_fingerprints SQLite table:
echo_loop.record("createCard", card_id) → key = "createCard:<card_id>", TTL = 30s
echo_loop.record("updateCard", card_id) → key = "updateCard:<card_id>", TTL = 30s
echo_loop.record("updateList", list_id, fields={"pos"}) → key = "updateList:<list_id>:pos", TTL = 30s
When a webhook arrives, the handler calls echo_loop.check_and_consume(action_type, card_id) before recording any pending change. If a matching fingerprint exists:
DELETE ... RETURNING to prevent two concurrent workers from both consuming the same fingerprint.200 OK, no pending change recorded).Fingerprints are stored in SQLite (not in-memory) so they are shared across all gunicorn worker processes. Expired entries are pruned opportunistically on each insert/check.
Fail-open design: If the database is temporarily unavailable, record() logs a warning but does not raise, and check_and_consume() returns False. This means the webhook is processed rather than suppressed — a safer default than silently dropping legitimate changes.
List-position writes are a special case (issue #491). The generic check in handle_webhook is keyed on a card ID, and an updateList event carries none, so the list-position fingerprint is consumed inside _handle_update_list’s position branch instead. It exists because answering a list-position echo is expensive: _reconcile_board_list_positions re-fetches the whole board, and the reorder drain issues one write per list, so without suppression a single reorder would trigger a board-wide reconcile for every list — each reading a board the drain is still part-way through rewriting. This is the opposite of the card case: a position-only updateCard echo is deliberately never suppressed, because processing it is cheap and usefully re-propagates the reorder to other devices.
Trello only fires updateList for lists whose position actually changed, so a drain’s fingerprints for the unmoved lists go unconsumed and expire on the 30-second TTL. Within that window one could swallow a genuine user-driven move of the same list on Trello; the board re-check the scheduler runs the moment the queue empties is what recovers it.
For createCard webhooks specifically, the handler checks whether the newly created card already has a sync_task with a reminders_item_id. If so, the card was created by the server in response to a Reminders-side change (via _apply_create), and the webhook is an echo. This check catches the race condition where the webhook arrives at a different gunicorn worker before echo_loop.record() executes.
SyncManager)The iOS/macOS app maintains an in-memory dictionary mapping remindersItemId → timestamp of the last Trello-originated apply. When detecting Reminders-side changes during the push phase:
private var recentlyApplied: [String: Date] = [:]
private let echoLoopWindow: TimeInterval = 5 // seconds
If a reminder was modified within the last 5 seconds of a Trello-originated apply (create, update, complete, delete), the change is suppressed and not pushed to the server. This prevents the app’s own EventKit writes from being detected as user changes and echoed back. An update that kept a field’s unpushed edit (see Trello → Reminders (inbound)) starts the window as well, so that edit is pushed on the first push pass after the window rather than in the same cycle.
seen_action_ids)Trello may deliver the same webhook event multiple times (e.g., from board-level and list-level webhook registrations, or network retries). The is_duplicate_action() function records each Trello action.id in the seen_action_ids table with a 5-minute TTL. Subsequent deliveries of the same action are silently dropped. The check uses INSERT OR IGNORE on a UNIQUE column for atomic first-writer-wins semantics.
GET /dailyroundup/changes protects against two different races when serving Trello-origin pending_changes to devices:
device_id acquires a 30-second exclusive lease (DEFAULT_LEASE_SECONDS) on the endpoint itself; a device polling while another holds it gets an empty change set with a lease block describing the holder (issue #284).Per-change claim (issue #445) — independently of that request lease, every change returned in a batch is stamped claimed_by/claimed_at for the requesting device at fetch time (not just when it’s later acknowledged). A different device’s GET /changes call excludes a change claimed within CHANGE_CLAIM_SECONDS (5 minutes — long enough to cover a realistic batch-apply duration, well past the 30-second request lease). This exists because applying a batch to EventKit and acknowledging each item can outlast the 30-second request lease: without the claim, a second device could acquire the lease once it expires mid-batch and be served the same still-unacknowledged change, independently materializing its own duplicate Reminder for it. The requesting device always sees its own claims regardless of age, so a lost response or an app crash/relaunch mid-batch doesn’t strand that device waiting out the claim TTL for its own changes.
change_deliveries records one row per (change, device) ack, and the change survives while another device still needs it.An ack therefore clears the acking device’s claim rather than deleting the row: the claim protects a batch that is being applied, and once it has been applied there is nothing left to protect, so the device still owed the change is served it on its next poll instead of waiting out CHANGE_CLAIM_SECONDS. An unacknowledged claim still simply ages out of the exclusion window.
The row is deleted when no device is left to deliver it to. A device is owed a change when it belongs to the change’s account, has polled within CHANGE_RETENTION_DAYS (7 days, the same window an unacknowledged change is kept for), and was registered before the change was queued — a device registered afterwards builds its copy from GET /dailyroundup/state, which already reflects the change. GET /dailyroundup/changes records the poll itself as the device’s liveness signal, and registers a polling device that is not in devices at all: registration happens when APNs hands the app a token, so a device that syncs without notifications would otherwise be invisible to this rule.
Deletion normally happens on the last device’s ack, but two acks landing together can each see the other’s device as outstanding. cleanup_stale_pending_changes reclaims such a row on its next pass, along with any row whose remaining devices have all gone quiet, so retention never depends on one ack being the one that deletes.
create and move are the exceptions, and keep the older model where the first ack retires the row for the whole account. They are the two changes the client can answer by materializing a Reminder — EventKit cannot move a reminder between calendars, so a move deletes the source and builds a new one in the destination list — and two devices each acting on one produce two Reminders for a single card, which round-trips into a duplicate Trello card. That is the duplicate the per-change claim was introduced to prevent (issue #445). Every other change type mutates or removes a Reminder the device already holds; when the client cannot find one it defers rather than creating.
Only the device-facing fetch sees delivered rows. Every other reader — conflict detection, the reconcile stand-downs, the dedup checks, the status backlog count, and the queue_size gauge — reads the undelivered set, which is what “pending” meant before rows began outliving their first ack. GET /dailyroundup/sync/status is the one that takes a device_id and answers for that device, so its pending_inbound_count and the next GET /dailyroundup/changes agree.
Beyond the 7-day window a change is pruned unread. The client’s own reconciliation converges the fields listed under Client convergence for single-consumption changes; a field that is neither converged there nor re-emitted by a reconcile pass stays as that device last saw it.
When a wait is caused by another device’s lease, the app names that device rather than showing an unexplained spinner. The client keeps one record of that — SyncManager.observedLease — and the current-state row, both loading rows in the sync status detail, and the macOS menu bar all render from it.
Three server signals set it, all carrying the same LeaseInfo shape and the same retry_after_seconds (the lease’s own remaining time):
423 Locked body from a lease-gated write,lease block on GET /dailyroundup/changes,lease block on GET /dailyroundup/sync/status.The third exists because GET /dailyroundup/conflicts and GET /dailyroundup/potential-duplicates take no lease: they are plain reads, slow only because the single worker is busy with someone else’s sync. The client’s refresh path fetches status immediately before both, so the holder is known by the time those spinners go up — including on a cold launch or push refresh with nothing pending inbound, which attempts no sync of its own and would otherwise never see a 423. (The two recovery re-reads, after a conflict or duplicate turns out to be already resolved, skip the status fetch and render against whatever the last one left.) Passing device_id on the status read suppresses the caller’s own lease. A holder belonging to a different account keeps the block but loses locked_by/locked_by_name on every emitter — the 423 bodies and /changes as well as the status read — since sync_lease is a server-wide singleton and the wait is real for every account while the holder’s name is not every account’s to see (issue #542). The timing fields are never suppressed, so the retry schedule is unaffected, and the server’s own lease-contention log lines still name the real holder: the redaction sits between the lookup and the response body, not between the lookup and the logger. Unlike /changes, the status response always carries the lease key, null when nobody holds it — a client tells that null (“nobody holds it”, so clear the recorded holder) from an absent key (“this server predates the field”, so leave the record alone).
The record is dropped by whichever comes first: the reported remaining seconds elapsing (clamped to 1–30s), a GET /dailyroundup/changes call succeeding without a lease block (which means the server handed this device the lease, so nothing else holds it), or a status read reporting no lease.
The first of those stands down while a lease retry is pending. The record’s deadline and the retry’s delay are the same number — both are the server’s retry_after_seconds through the same 1–30 clamp — so the record would otherwise lapse at the exact instant the attempt that renews it begins, and stay lapsed until that attempt’s first response came back through the busy worker, blanking the loading rows once per attempt (issue #541). A record with a retry pending is about to be re-checked, and the check settles it either way. The hold suspends the deadline rather than extending it: an attempt that comes back having learned nothing resumes the deadline it already had, which by then has usually passed and drops the record at once. Holds are counted, not a flag: isSyncing is false throughout the retry loop’s wait, so a push or the periodic timer can start a second cycle in the middle of the first one’s backoff, and a shared flag let that second cycle release a hold it never took. The record is deliberately not cleared at each performSync entry, so it survives the retry loop’s attempt boundaries instead of alternating with the plain wording every 1–30 seconds; a cycle that failed before reaching /changes learned nothing about the lease and leaves the record to expire on its own.
Status reads and the sync cycle write the same record from independent tasks, so each status read takes a token before its request goes out and its response is dropped if the token has been overtaken. Two orderings live in that token. The record revision settles status-versus-first-hand: a status response issued while a device held the lease but arriving after our own /changes call proved it released must not re-record a holder that is gone. Only first-hand events move it — a 423, or a /changes page that took the lease or found it taken; a status read landing does not, and neither does the client’s own expiry timer, whose deadline running out is not evidence and should not invalidate a read already in flight. A per-read sequence settles status-versus-status, which the revision cannot, since two reads straddling no first-hand event carry the same one: the later-issued read wins whichever arrives first. Reads overlap routinely — view-appear, push, post-sync, and notification-tap refreshes are four uncancelled tasks.
The mechanism has a bound worth knowing: GET /sync/status is served by the same single worker, so it queues behind whatever is slowing the reads. It names the holder when the contention is a foreign lease held across many short requests — the common shape — and degrades to the plain wording when one long request is blocking everything, since by the time status returns the lease may already be gone.
SyncManager.isLeaseHeld is a separate, per-attempt flag and is not a display signal: it gates the retry schedule and the per-cycle reconcile and push bail-outs, and goes false at every retry boundary while the lease it describes is still held.
pending_changes also carries one row type in the opposite direction — reorder_list with change_origin='reminders', written by PATCH /dailyroundup/lists/reorder and drained by the server, never served to a device. Neither GET /dailyroundup/changes nor GET /dailyroundup/sync/status can return them: both pass origin='trello', which is also list_pending_changes’s default.
Each row means “this list still owes Trello a position write”. The endpoint stamps every list a provisional pos on an evenly-spaced ladder and records the same value on the row, so the queued rows carry the order the user dragged. The drain sorts by that value to recover the requested order, then plans the final positions board-aware (issue #490) and overwrites the provisional ones — which is why the order comes from the rows and not from sync_lists.pos, whose values the drain is in the middle of replacing.
A new reorder discards the account’s undrained rows and writes its own in the same transaction, so the queue always describes the newest drag: a second reorder landing before the drain wins outright instead of interleaving with the set it superseded, and a retry re-plans against current intent rather than stale intent. Rows are deleted once applied, matching how Trello-origin rows are deleted once no device is still owed them rather than marked applied. A board whose live order cannot be read leaves its lists queued and retried, rather than being recorded database-only and reverted by the next refresh — the residual failure issue #490 accepted pending this change.
While any of an account’s rows are queued, both list-order reconcile paths (webhook._reconcile_board_list_positions and scheduler._reconcile_list_order) stand down, because mid-drain the board holds a half-applied order that would otherwise be read back as truth. The stand-down only counts rows younger than LIST_REORDER_STANDDOWN_SECONDS (15 minutes), so a permanently failing write cannot leave drift correction disabled indefinitely.
Reconciliation is a scheduled safety net (reconcile_list() in trello_sync.py) that periodically compares the server’s sync_tasks records against the actual cards in each Trello list. It catches discrepancies that webhooks miss: dropped deliveries, race conditions under concurrent gunicorn workers, and any other out-of-band changes.
For each active sync_task in a list, reconciliation checks whether the corresponding Trello card is still present. Cards are fetched with card_filter=all (open and archived) so that archiving a card does not cause its Reminders item to be deleted.
Because that fetch returns archived cards, an archived card is not treated as a live source of truth for its Reminder (issue #607). The user has filed it away, so the passes that would build a Reminder for it or edit one skip it. Four things still act on an archived card, each deliberately:
sync_tasks.archived_at into line with the card’s closed flag in both directions. The webhook side is told a card was archived exactly once, by the updateCard that archives it, so a dropped delivery would otherwise leave the stamp wrong for good and every later event on the card handled against it. This pass already fetches closed, so the repair costs only the write it makes;complete direction only — a card ticked complete means the task is done wherever it is filed, so that reading is honoured as a backstop for a dropped updateCard. The opposite reading is worthless, because archiving never sets dueComplete: an archived card reads incomplete whatever the task says. Taking that as drift is what undid complete_archived_task’s completion and restored the Reminder on the device. Note that the client’s inbound complete handler calls ensureReminderExists, so a task that never had a Reminder gets one built in the completed state — the #297 outcome, where the completion stays in the user’s recent history until the retention prune, not a live task;closed flag from the get_card it already makes rather than archived_at, because the stamp can be stale for a card that is absent from the list being reconciled — the repair pass only sees cards that are present.| Situation | Action |
|---|---|
| Card present | Refresh label and custom-field metadata; clear any archived_at stamp; heal missing/zero position from Trello pos |
| Card present but archived | Refresh metadata; stamp archived_at; complete the task if the card is ticked complete. No position heal, no bracket title heal, no re-sent create, no start-date convergence, and never an uncomplete |
| Card absent, moved to another synced list | Update sync_list_id to the destination list; emit a move pending change so the client moves the Reminders item — unless the card is archived and the task has no reminders_item_id, where the row follows and the change is withheld, since there is no Reminder to move and the client’s handler would build one |
| Card absent, not in any synced list | Retire the sync_task; emit a delete pending change so the client removes the Reminders item |
The webhook side holds the same rule only where it can. An arriving card — a cross-list move, or a moveCardToBoard — builds no Reminder when it is archived. That check has to fetch the card: Trello’s action payload carries the card’s identity plus only the attributes that changed, so a move reports idList and never closed, and a payload-only check would look right and never fire. A failed fetch falls back to treating the card as live, because losing a live card’s Reminder to a transient API error costs more than a Reminder for an archived one. A card already tracked here still follows its move — leaving sync_list_id behind would strand it on a list the card has left — though whether the device is told about the move now depends on the archived rule below.
Everywhere else on the webhook side the rule is held by reading sync_tasks.archived_at (issue #610). Trello reports only what an event changed, so the archiving updateCard is the one payload that ever mentions closed; recording it there is what lets a later rename — which reports name and nothing else — be recognised as an edit to a card that is no longer on the board. The archive branch stamps the column, the unarchive branch clears it (so normal handling resumes on the very next webhook, with no reconcile pass needed), and reconciliation repairs it in both directions from the closed flag it already fetches. Two paths outside the webhook maintain it too: the conflict-resolution unarchive clears it inline, because the updateCard it triggers is the server’s own echo and is suppressed before it reaches the handler that would; and POST /dailyroundup/tasks/complete-archived stamps every card it processes, since it fetches with card_filter=closed and so sees exactly the population whose stamp may be missing.
For a card whose stamp is set, the webhook handlers keep the server’s own records current from the event — labels, custom-field values, the sync_attachments row — and stop there: no pending change, and no write back to Trello. The card action index reads the same column (issue #609): the archiving updateCard deletes the card’s row, and any later event that still refreshes the index finds the stamp and removes the row rather than putting it back, so the auto-remind rule it drives never writes a custom field to Trello for an archived card. The client’s inbound handlers call ensureReminderExists, so a change for a card the user filed away either rewrites a Reminder that should have been left alone or builds one from nothing. Three changes are still emitted for an archived card, each deliberately:
complete, the exception reconciliation keeps — a card ticked complete means the task is done wherever it is filed. Trello can report a tick and a rename in one action, so the change carries is_completed alone: the event was let through for the tick, not for whatever else came with it;move, but only when the task already has a Reminder. Following the card is what issue #607 kept, on the grounds that the task has a Reminder either way and leaving sync_list_id behind would strand it. A task with no reminders_item_id is the case that reasoning does not cover, because the client’s move handler would build the Reminder: there the row still follows the card and the change is withheld;delete, from deleteCard or a departure off the board. Deleting is not archiving: the card is gone rather than filed away, so the task is retired and the Reminder goes with it, exactly as for a live card.The archive and unarchive branches are read before the cross-list move, because one PUT /cards/{id} can carry closed and idList together — Trello’s own restore does when the card’s original list is gone. Taking the move first returned before the stamp was touched, leaving it wrong in whichever direction the action went. An archive that carries a move still re-homes the row, without the arrival change: a sync_list_id left on the source list is found by that list’s next reconcile, which either re-emits the move or, for a single-list account, retires the task and deletes the Reminder of a card that was only archived.
An untracked card has no row to read a stamp off, so _create_task_stub answers with the same fetch the arrival path makes and creates nothing when the card is archived. Only a card that no active sync_task tracks reaches that path — which includes one whose task was retired by the completed-retention prune while the card stayed on Trello — and since the point is to create no row, the read is per event rather than once per card. An account with no Trello credentials has nothing to ask and falls back to treating the card as live, the same direction the arrival guard chose.
Edits made to a card while it was archived do not reach the device when the card comes back, and only some of them are recovered:
updateCard field path returns before writing sync_tasks or sync_locations, and no pass converges any of the four from the card, so the pre-archive values stand on the server and the Reminder while Trello holds the edit. This is an accepted cost rather than a consequence of the rule: withholding the change needs no more than skipping create_pending_change, and the four are dropped from the server’s record too because converging them later would need a pass that does not exist. Storing them without one would leave the server disagreeing with the Reminder instead, and would let an archive-era rename out through _heal_label_bracket_drift, which rebuilds titles from the stored one.move_url_links_to_description on, the deep pass’s link-mirror sweep runs — but _mirror_sweep_skips also passes over a completed task, one with no reminders_item_id, and one with an update already queued. An archived card is very often completed, by complete_archived_task or by the archive-after-completion path, so for the common case the link stays where it is.The card action index is closed by the same column (issue #609). The archiving updateCard deletes the card’s row there and then, rather than leaving the scheduler up to a day in which it could still move the card or fire its “Remind me”; unarchiving rebuilds the row from the dates the card already has, and the conflict-resolution unarchive does it inline for the same echo-suppression reason it clears the stamp. Any later event on a still-archived card that reaches the index removes the row instead of refreshing it, so a due date changed in the archive does not reach the index at all.
Two things fall out of deleting the row. A running Live Activity is ended before it goes, because the row is the only handle on one: once it is gone, nothing can find the card to end it and the activity would sit on the lock screen until iOS expired it. And the scheduler re-reads closed on the card it fetches at fire time and drops a row that outlives its card unused — a dropped webhook, or a card archived before any of this shipped — because the index is a cache and the card is the authority on whether it is still on the board.
The auto-“Remind me” rule has a related blind spot of its own, unrelated to archiving: it reads a due-date change off the previously indexed due_at, which cannot tell a card that has just gained a due date from one whose row is simply absent — a tick drops the row of a card with no future work left. Reading the second as the first rewrites a “Remind me” the user set by hand, so the rule asks the action instead: a card new to us arrives with whatever dates it has, an updateCard names the fields it changed in data.old, and no other action type can move a due date.
Before retiring a task whose card is absent, reconciliation calls TrelloClient.get_card() to fetch the card’s current idList. If the card is found in a different synced list belonging to the same account, the task is followed rather than retired. This handles two failure modes:
updateCard webhook was never delivered.update_sync_task(sync_list_id=…) was called, but the write was not yet visible to the reconciliation query (observed with SQLite WAL mode under concurrent gunicorn workers).The extra get_card API call is skipped entirely when the account has only one synced list, since there is nowhere else for a card to go.
Cross-account follows are rejected: if get_card returns a list ID that maps to a sync_list belonging to a different account, the task is retired rather than reassigned.
reconcile_list() returns a summary dict:
| Key | Description |
|---|---|
retired_count |
Tasks retired (card deleted or moved to a non-synced list) |
followed_count |
Tasks followed to another synced list (missed webhook or stale DB state) |
healed_count |
Tasks whose missing or zero position was healed from Trello |
refreshed_count |
Tasks whose label and custom-field metadata was refreshed |
archive_repaired_count |
Tasks whose archived_at stamp was brought back into line with the card’s closed flag, repairing a dropped archive or unarchive webhook |
trello_card_count |
Number of Trello cards seen on the list |
imported_count |
Untracked Trello cards imported as new tasks (deep mode only) |
resent_count |
Tasks missing a Reminder re-sent a create change (deep mode only) |
completion_reconciled_count |
Completion mismatches reconciled to Trello (deep mode only) |
start_reconciled_count |
Start-date drift converged to Trello (deep mode only) |
mirrored_count |
Cards whose link attachments and description were brought into agreement, excluding any whose Trello write failed (deep mode only) |
error |
Present only on early failure (e.g., Trello API error fetching cards) |
reconcile_list(sync_list, deep=True) runs the passes above and then converges
to Trello as the source of truth. The scheduler calls it on the startup and
daily cadence (after the card-action-index reconcile_all), under the
sync_lease for single-flight with device pushes:
sync_task
tracks (created directly on Trello, or whose createCard webhook was missed)
is imported as a new task with a create pending change, mirroring the webhook
import path. A recently-retired task for the same card is restored rather than
duplicated.reminders_item_id
(the device never built, or lost the link to, the Reminder) gets a fresh
create change when nothing is already queued for it. A NULL
reminders_item_id can mean the server lost the link rather than that the
device lacks the Reminder, so the client applies create idempotently —
reusing an existing Reminder for an already-tracked card instead of building a
duplicate (issue #399). The ack re-establishes the lost server-side link.dueComplete differs from the
task’s is_completed, Trello’s state is adopted and a complete/uncomplete
change is emitted so the device updates the Reminder. An archived card
completes but never uncompletes, per the rule at the top of this section.start differs from the task’s
start_date, Trello’s value is adopted and an update change carrying only
start_date is emitted. Start-date edits made in Trello never reached an
already-tracked card before issue #502, so stored values can be stale by any
amount; converging here heals them without waiting for another edit, and
covers any future webhook this pass misses. A cleared start date is carried as
an explicit null so the device can tell it from a change that does not mention
the field. Skipped: tasks with no reminders_item_id (there is no Reminder to
update, and a queued create already carries start_date), tasks with an
update already queued, so the pass never races a change in flight, and
completed tasks, matching the passes above. Archived cards are skipped by the
re-send, completion (uncomplete direction), and start-date passes, per the
rule stated at the top of this section.Mirror links — a card whose link attachments and description disagree is
brought back into agreement by the same three-way merge the webhook and
create paths use (link_mirror.py). This is the backstop behind those
triggers: the create paths mirror inline, but a card can still end up
mismatched through a Trello write that failed mid-pass, a webhook that never
arrived, or a card that predates the feature, and nothing else looks again.
Attachments are fetched inline on this pass’s existing card query (Trello’s
attachments nested resource), so the sweep costs no extra request, and a
card whose sides already agree plans no writes. Skipped when the account has
move_url_links_to_description off, in which case the attachments are not
requested either. Otherwise the skips match the start-date pass’s, for the
same reasons: archived cards and completed tasks, tasks with no
reminders_item_id (a create may have just been queued for them by the
re-send pass, and an update beside it can be claimed by a second device and
build a duplicate), and tasks with an update already queued. A card whose
Trello write fails is logged at ERROR and left out of mirrored_count; the
mirror reverts its shadow so the next pass retries.
This is the only deep pass that makes an unbounded number of Trello writes, so it renews the caller’s sync lease per card rather than relying on the scheduler’s once-per-list renewal (the shape of issue #324). A lease lost mid-sweep ends the whole run rather than moving to the next list.
Where the merge rewrites a description, the card’s desc is re-read first.
The snapshot every other pass works from was taken before this one wrote to
the cards ahead of it — minutes earlier on a long first sweep — and that
branch overwrites the whole field, so writing the snapshot back would discard
a description edit made in Trello inside the window. The extra read falls
only on the cards being rewritten; the attachment-only branches never touch
the description and re-read nothing.
The first deep pass after this shipped brings every pre-existing card into
agreement, not just newly broken ones. A card carrying a link attachment the
mirror has never seen has its URL copied into the description, which reaches
the device as an update. On an established board that is a one-time burst
of note edits — the same work the Settings “process existing URL attachments”
button does, now automatic.
SyncManager.reconcileWithServer is the client’s counterpart to everything above: once a day it compares local SyncTask rows against GET /dailyroundup/state and prunes any the server no longer tracks, deleting the associated Reminder.
That pruning runs as a single account-wide pass, not once per list (issue #493). Absence from one list’s state is not a retirement signal — a task that moved to another synced list is absent from its source list by definition, and the move pending change that would have corrected the client’s syncListId reaches only one device (see below). Pruning on that alone deleted the Reminder, which then round-tripped into a delete change and a hard, unrecoverable delete of the Trello card. Three guards now apply:
GET /dailyroundup/lists, not the device’s iCloud-KV copy. No inbound change type adds a list pair, so a device’s copy can lag behind a pair created elsewhere; using it as the denominator would produce a “complete” union blind to the very list a card had just moved into.The same reasoning gates the outbound delete emission in detectAndPushRemindersChanges, which is what the server turns into the Trello delete. A Reminder that this device cannot see is not sufficient evidence of deletion — that is also what a cross-list move looks like before iCloud propagates, when the destination list is not known to the device, or when its calendar does not resolve locally. The emission therefore requires the most recent card_metadata pull to affirm that the task still belongs to the list being scanned. This is deliberately fail-closed: an absent or unfetched entry withholds the delete, because a missed deletion leaves a stale card the next pull resolves, while a wrong one is unrecoverable.
For that gate to mean anything, loadCardMetadata runs before the push pass rather than at the end of the cycle. Its ownership map is in-memory only, and a background wake builds a fresh SyncManager for a single cycle — so populating the map after the scan left the gate reading an empty map on exactly the mostly-backgrounded devices this bug destroys data on.
_apply_delete refuses a delete whose claimed list disagrees with the server’s own record of where the task lives — that is, change.sync_list_id != task.sync_list_id. A Trello-side move re-homes sync_tasks.sync_list_id synchronously in the webhook, before any device pushes, while the matching move pending change reaches one device only. A device that never received it keeps scanning the source list and names that source list when it reports the Reminder gone. That disagreement is the signature of a stale view, and it is the one the incident produced.
The card is left untouched and the task is not retired; the device’s own next card_metadata pull re-homes it. The guard matters because the client-side gates above protect only devices running a build that has them, and one un-updated device on the account was enough to destroy the cards in the original incident.
A second guard covers the case where the server’s own record is the stale one — a dropped or not-yet-processed updateCard webhook. The device agrees with that stale record, so the check above cannot catch it, but Trello knows better: if the card is alive on another of this account’s synced lists, the delete is refused and the task kept, leaving reconciliation to re-home it via _find_card_in_other_synced_list. This closes a window of up to a day between a missed webhook and the next reconcile pass.
That second guard is only sound because a Reminders-side drag now moves the card as well (see below). While it did not, a card whose Trello list differed from task.sync_list_id was the normal steady state, and treating it as suspicious suppressed ordinary deletes and silently resurrected the Reminder.
A card found on a list synced by a different account, or on no synced list at all, is refused outright and the orphaned task retired — matching the account scoping the sibling guards already apply.
Dragging a reminder between two synced Reminders lists emits a Reminders-origin move. _apply_list_move relocates the Trello card to the destination list and then updates sync_tasks.sync_list_id to match (issue #497).
The card used to be left where it was, on the basis that Reminders→Trello list moves were a Trello-driven operation only. That made a Reminders-side drag the one supported way for a task and its card to disagree about their list permanently, and that ambiguity is precisely what stopped the delete guard above from acting on such a disagreement.
Order matters: the Trello write happens first, and a failure leaves sync_list_id untouched and returns an error so the client retries, rather than committing the divergence the change exists to remove. The position Trello assigns in the destination list is persisted from the response, since GET /card_metadata serves task.position straight from the database.
No echo fingerprint is recorded for the move. A fingerprint is field-scoped and matches exactly, and Trello’s data.old for a list move is not reliably one shape — it carries idList, often with pos when the card also lands at a new position. Recording both shapes leaves whichever did not match alive for its full 30-second TTL, where it can swallow a genuine Trello-side move of the same card, reintroducing exactly the stale record this change removes. Letting the echo through is the safer trade: the webhook re-homes a task already at the destination and emits a move whose destination is the reminder’s current calendar, which devices apply as a no-op. It also keeps the webhook’s pinned-reorder handling working for the moved card. (The card-action index is not refreshed either way — the cross-list branch returns before that step, as it already did for Trello-side moves.)
Two cases move the task without moving the card:
A cross-board destination. Trello can move a card across boards — PUT /cards/{id} documents idBoard for it — but the card loses its labels and custom field values, and the rest of the system assumes one board per account. Silently relabelling a card is worse than leaving it put, so this keeps the pre-#497 behaviour of re-homing the task alone.
The resulting divergence gets no carve-out in the delete guard. “The boards differ” describes both this case and a card that moved cross-board in Trello whose webhook was dropped — and the second is the destructive one. They cannot be told apart at delete time, so both defer. That does not strand the task: _find_card_in_other_synced_list is account-scoped but not board-scoped, so reconciliation re-homes the task to wherever the card actually is, after which task and card agree and an ordinary delete succeeds.
The same non-board-scoped follow means a cross-board drag is undone, not merely unsupported: reconciliation returns the task to the source list and emits a move that pulls the reminder back with it, so within a day the user sees their drag reversed. Cross-board synced lists are outside what the account model supports; nothing prevents configuring them.
A board is only treated as different when both ids are known and differ. sync_lists.trello_board_id was added by ALTER TABLE with no backfill and is absent from update_sync_list’s allowed fields, so a pre-migration row reads back empty and cannot be repaired. An unknown board must never count as evidence of a different one — that would make a same-board move skip the card write and let a stale record read as legitimate divergence, putting the delete back on the destructive path.
A refused delete is reported as deferred, not applied. An applied delete makes the client drop its local task, and every device would shed it the same way — leaving a live card with no reminder anywhere and a later move no device can apply or acknowledge, which then re-serves on every poll until it ages out.
The end state is not a retried delete. The reminder is already gone, so when reconciliation re-homes the record and emits a move, applySyncListIfChanged refuses to adopt the new list (there is no reminder to agree with), shouldEmitDelete then permanently withholds, and whichever device consumes the move finds no reminder to move and creates a fresh one in the destination list. From the user’s point of view the deletion is undone and the reminder reappears in the other list within a day, and they delete it again — this time with the card and the record agreeing, so it succeeds.
That is the intended trade: a deletion the user has to repeat, rather than a Trello card destroyed with no way back. Until reconciliation runs, the device re-pushes the delete each cycle and each is refused.
create and move are still delivered to one device only (see Pending Change Delivery), so a field carried only by one of those changes goes stale on every other device, with nothing to re-serve it. Every other change type is now delivered per device (issue #620), which closes that exposure for the fields they carry — but only within the 7-day retention window, after which an unread row is pruned.
Fields with that exposure are also converged from GET /dailyroundup/card_metadata, which SyncManager.loadCardMetadata pulls for every list on every sync cycle. The pull is idempotent and independent of the sync lease:
| Field | Pending change | Converged by |
|---|---|---|
position |
reorder |
applyPositionIfChanged (issue #481) |
sync_list_id |
move |
applySyncListIfChanged (issue #493) |
position is no longer single-consumption — reorder reaches every device — but its convergence stays: it is the backstop for a device past the retention window, and it costs nothing on a pull the cycle already makes.
sync_list_id adoption requires positive agreement with EventKit: a device adopts the server’s destination list only once its own copy of the Reminder is already in that list’s calendar. iCloud propagates the Reminder’s calendar change independently of this pull, and adopting ahead of it would make the next cycle read the Reminder as a Reminders-side move and push the Trello card back to the source list. A Reminder this device has not observed in any synced calendar is likewise not consent — that state is indistinguishable from a failed calendar resolution, which is what happens for every list when Reminders access is not .fullAccess.
Because of that precondition, sync_list_id convergence is a backstop rather than the fast path: when a device can already see the Reminder in the destination calendar, the outbound scan usually re-homes the task earlier in the same cycle by pushing a Reminders-origin move. The metadata pull covers the cycles where that push does not run — most notably when a lease conflict cuts the push pass short — without needing a server write. Correctness does not depend on either path being timely, since both destructive paths are now gated on server-reported ownership.
Background jobs (sync, merge, and the import/backfill batches) run as
fire-and-forget daemon threads that only mark their background_jobs row
terminal at the end. A systemctl restart (e.g. a deploy) kills the gunicorn
worker without awaiting those threads, so a job can be cut off mid-run. Four
layers keep the system converging regardless (issues #393, #491):
drain.py polls count_running_sync_jobs() until it
reaches zero or DAILYROUNDUP_DRAIN_TIMEOUT (default 30 s) elapses, run
before systemctl restart in the deploy workflow. Best-effort: the host unit
keeps serving requests while it polls, so it never blocks the deploy
indefinitely.Database.recover_interrupted_jobs(), called from
create_app(), flips any background_jobs row still running at boot to
error (preserving partial processed/total) so polling clients stop
waiting and re-push. This guarantees no dangling running jobs survive a
restart.pending_changes. Whatever the dead worker had not yet pushed is still
queued, and the scheduler’s next tick and its startup reconcile both drain
it (issue #491). This is why the reorder endpoint queues instead of pushing:
a serial chain of Trello calls inside a request has nothing to resume from
when the worker goes away.