fix: aggregate pre-migration history items under their migrated identifiers#22
fix: aggregate pre-migration history items under their migrated identifiers#22chrishenzie wants to merge 4 commits into
Conversation
Things Cloud changed identifier formats at some point in its history.
Old history items (Task3, Task4, Area2, ...) key objects by identifier
strings such as uppercase UUIDs; newer items (Task6, ...) key the same
logical objects by 22-character Base58 strings. The history contains no
record linking an old identifier to its replacement. Instead, the new
identifier is derived from the old one:
current = Base58(SHA1(legacy_identifier_text)[:16])
Recurring-task instances are the one special case. Their composite
identifiers (<uuid>-YYYYMMDD) hash in two steps: the <uuid> prefix
first, then its raw 16-byte digest followed by the "-YYYYMMDD" text.
EncodeLegacyIdentifier implements this derivation so that history
replay can map old items onto the identifiers newer items use. Both
formulas were reverse-engineered from a real account history and
verified there: the derived identifiers account for every legacy object
in a 239,199-item history with none unresolved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Chris Henzie <chrishenzie@gmail.com>
Identifiers that resemble the <uuid>-YYYYMMDD recurrence form without matching it exactly (non-digit date, wrong date length, invalid uuid prefix, missing separator) must hash as plain identifier text, not through the two-step recurrence derivation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Chris Henzie <chrishenzie@gmail.com>
Histories that predate the identifier migration contain the same logical objects under two identifier generations: full content in old items (Task3, Task4, Area2, Tag3, ChecklistItem2, ...) keyed by UUID-style identifiers, and later changes in newer items (Task6, ...) keyed by Base58 identifiers. Replaying both under their raw keys corrupts the aggregated state twice over: the old objects survive as stale duplicates, and the newer changes apply to objects that have no content. Things.app links the generations by deriving the new identifier from the old one (see EncodeLegacyIdentifier). Apply the same derivation during replay: store items of legacy kinds under their derived identifiers, and rewrite the identifier lists inside their payloads (area, project, heading, tag, parent tag, recurrence, delegate, and checklist-to-task references) so relationships also point at derived identifiers. Deletes carried by legacy items and by legacy "Tombstone" records remove the derived object. Items of current kinds are not touched. Validated against a real 239,199-item account history: replayed state matches the Mac's local Things database exactly for all 13,167 tasks, 6 areas, and 10 tags (identity, title, type, status, trash state, and area/project/heading relationships). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Chris Henzie <chrishenzie@gmail.com>
things-cli caches aggregated state as JSON and resumes incremental sync on top of it. A cache built by older replay logic keys legacy objects by their raw identifiers and can never self-heal, because later sync only appends newer history on top. Stamp the cache with a version and treat any other version (including its absence) as no cache, forcing one full replay through the current logic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Chris Henzie <chrishenzie@gmail.com>
arthursoares
left a comment
There was a problem hiding this comment.
This is excellent work — careful reverse-engineering, honestly scoped, and the write-up is a model PR description. Before reviewing I verified independently:
- Clean-room check of the derivation: I re-implemented
Base58(SHA1(legacyID)[:16])and the two-step recurrence variant from scratch using only your prose description (not the code), and both committed test vectors (LXmxn9gakySzcEjKj1DtgD,H8Xu72gj7fooPuYoBMZ5TK) reproduce exactly — the tests genuinely pin the documented algorithm. - No self-inflicted rehashing:
things-cliandexample/write only current kinds, so the remapping never touches identifiers this SDK creates. - Cache invalidation path: version mismatch →
nilcache → full replay from index 0; every existing user cache gets rebuilt once through the corrected logic. Confirmed the caller handles it correctly. - Build, all tests, and
go vetpass;splitLegacyRecurrenceIdentifier's fixed 45-byte length correctly excludes the raw/URN/braced UUID formsuuid.Parsewould otherwise accept.
The substance is approvable as-is. Requesting changes only for the mechanics below.
Required
- Rebase onto
developonce #23 lands, and rungofmt. I've retargeted the base todevelop(this repo integrates there;mainis for tagged releases). Note the interaction we confirmed: this branch and #23 merge with no textual conflict, but the mergedtypes.gois gofmt-dirty (the tag-constant block keeps pre-ItemKindTombstonePlainalignment), which fails the lint gate #23 introduces.types.goon this branch is also not gofmt-clean on its own.
Asks (non-blocking, would love to have)
- Real test vectors, if you're comfortable. The current vectors are synthetic and (as verified above) pin the documented algorithm — but nothing in-repo can falsify the algorithm against Things itself; that evidence lives entirely in your one-time 239k-item sqlite diff. Two or three anonymized real legacy→current pairs from your history (an ordinary object, a recurrence instance, and one reference-field pair) would make the suite independently falsifiable. Identifiers are random, so the privacy exposure is minimal — but it's your data, your call.
- Non-empty identifiers in the adjusted tests. The
memory_test.gochanges assert onEncodeLegacyIdentifier(""). The empty-id collapse is behavior-equivalent tomain(everything empty previously collapsed onto the""key, now ontohash("")), so no regression — but blessing it in tests reads as intended behavior. Realistic ids would document intent better. - One-line comment on the
Area2tgfield. Legacy area payloads carrytg(tag IDs) which isn't rewritten. Harmless today —updateAreaignoresTagIDsentirely — but it's a landmine if area→tag handling is ever added. A comment at the area case inUpdatewould defuse it. (Also noted in #24.)
Questions (genuinely asking, not blocking)
- Did your validation data give any signal on mixed-generation events — a legacy-kind item carrying an already-Base58 reference (would be double-hashed) or a current-kind item carrying a legacy reference (never normalized)? Your zero-extras result suggests neither occurs, but if you saw explicit evidence either way it'd be worth a sentence in the
encodeLegacyIDscomment. - The recurrence split accepts calendar-invalid suffixes like
-20230230. Presumably Things' own rule is shape-based too — did you see anything confirming that, or is it inferred from the matching?
On your restructuring offer
Keep this PR scoped as-is. The sync/ gap and lifting the shared pieces (kind predicate + payload rewriting) into the root package are now tracked in #24 — the offer is gratefully accepted there if you want to take it on, and includes deciding rebuild-vs-migrate for existing sync databases.
(Review assisted by Claude Code + an adversarial Codex pass; all findings above were verified empirically before posting.)
|
#23 is merged into The non-blocking asks and questions from the review stand as written — happy to merge once the rebase is up. |
The symptom
On an account with history going back to 2018,
memory.Statereports tasks that should not be there. Old items that were long since completed, deleted, or edited into something else show up alongside the current ones, and some current tasks come back empty (no title, default status) even though the app shows them correctly. Task counts come out well above what Things itself displays. Accounts created recently look fine, which is why this has gone unnoticed: it only appears once a history is old enough to span the identifier change described below.Why it happens
Things Cloud has used two generations of object identifier over the life of the service. Older history items (
Task3,Task4,Area2,Tag3,ChecklistItem,ChecklistItem2,Tombstone) key objects by identifier strings, usually an uppercase UUID. Newer items (Task6,Area3,Tag4,ChecklistItem3,Tombstone2) key the same logical objects by a 22-character Base58 identifier.When Things migrated, it did not write any mapping record into the history, and it did not rewrite the old items. Both generations are still in the log, and a client replaying that log has to know that the two identifier forms can refer to the same object. Things derives the new identifier from the old one by hashing it:
Recurring-task instances use a composite identifier of the form
<uuid>-YYYYMMDDand hash in two steps: the UUID prefix first, then that raw 16-byte digest followed by the-YYYYMMDDtext.The SDK replayed both generations under their own raw keys, so one object became two entries in state. The old entry kept whatever the object looked like at migration time and was never touched again, which is the stale task that surfaces. The new entry started empty and only received whatever changes happened after the migration, which is why post-migration tasks with no recent edits appear blank. Deletes have the same split: a legacy delete or a
Tombstoneremoves the legacy key and leaves the current one behind.The derivation is the only link between the two generations. There is no mapping commit to read instead. I checked: the large commits around area deletions look like migration tables but contain only
{"ar":[]}payload edits, and the kinds the SDK skips (Settings4/Settings5,Command) carry no cross-references either.The fix
EncodeLegacyIdentifierimplements the derivation, andstate/memoryruns every pre-migration item through it during replay: the envelope key, the identifier-valued reference fields on those items (taskar,pr,agr,tg,rt,dl; checklistts; tagpn), and the deleted-object identifier on legacy tombstones. Items of current kinds are untouched, and no arbitrary string is ever rewritten, so an account with no pre-migration history replays exactly as before.things-clialso needed a change. It caches aggregated state as JSON and resumes incremental sync on top of it, so a cache written by the old logic keeps its wrongly-keyed objects forever: later sync only appends newer history and never revisits them. The cache now carries a version, and any other version (including its absence, i.e. every existing cache) is treated as no cache, which forces one full replay through the corrected logic.Validation
Unit tests cover the derivation against exact vectors, including the recurrence form and near-misses of it that must hash as ordinary text, plus replay tests for each pre-migration kind.
Beyond that, I replayed a full 239,199-item history through this branch and diffed the resulting state against the
main.sqlitethat Things.app maintains on the Mac for the same account, comparing task identity, title, type, status, trash state, and area/project/heading relationships, plus area and tag identity and title:Before the fix the same history produced 1,458 extra pre-migration tasks. Each one's derived identifier is unique and accounts for a current object: 1,326 map to a task that still exists in sqlite, 132 map to an identifier whose last history event is a delete, and none are left unexplained. That mapping was established from identifiers and timestamps alone, without matching on titles.
The comparison does not yet cover dates, notes, task tags, recurrence, delegates, or checklist items, so treat the zero as validating the identifier handling rather than every field the SDK exposes.
Known gap
The
syncpackage keys the same pre-migration kinds by their raw identifiers and has the same bug. I left it alone deliberately: fixing it changes what gets written to a user's existing database, so it needs a decision about whether those databases are rebuilt or migrated in place, and that does not belong in this change. If you would rather see the shared pieces (the kind predicate and the payload rewriting) lifted out ofstate/memoryinto the root package now sosynccan reuse them later, say so and I will restructure.Assisted by Claude Code + Codex