Repository navigation
feat: each message says whether its time has milliseconds, and a whole-second twin is a duplicate - #1968
Conversation
CONTEXT.md names the operation Export and lists Pull under its Avoid, but the crate that runs an Export from a server, its types, the desktop command, the state file and the recorded tool name all said Pull. - The crate is message-crate-export at crates/libs/export/ (library message_crate_export), with ExportConfig, ExportReport, the private Export type, ExportJournalEvent, ExportJournalState and EXPORT_JOURNAL_NAME. - The state file is .message-crate-export-state.jsonl. The old file is not read. - The server records the tool as message-crate-export. The old name is not mapped. - The desktop command is export (src-tauri/src/commands/export.rs, ExportArgs), and the web app calls it as invokeExport. - CLAUDE.md, AGENTS.md, the developer pages, the Export guide, the OpenAPI document and the crate READMEs name the new crate. ADR 0001 and ADR 0012 keep their text and gain a dated note. Closes #1906 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md keeps file paths, tool names and crate names out of CHANGELOG entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Export's own working directory and the step that fills it said pull, which CONTEXT.md avoids for Export. The directory is .exported (EXPORTED, the exported field on ExportDir), and the step is the fetch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The server stored a message's time as whole seconds, dropping the milliseconds WhatsApp, Apple Messages and SMS Backup & Restore record, so two messages in one second were ordered by sort_order alone. messages.timestamp (and staging_messages.timestamp and message_versions.edited_at) now hold RFC 3339 UTC with three fractional digits, such as 2015-03-12T18:04:22.250Z, always in that one form so the text still sorts in time order. Lists order by it as before, and the API returns it in Message.timestamp and edited_at. The content key and the near-time pass still match at whole seconds. A search's day bounds are written in the same millisecond form, since a bound without a fraction sorts after a stored time of the same second. The search for the end of a time zone's gap now steps in whole seconds, because a half second left in it would now show in the bound. Tests: a fixture with sub-second times keeps them through import and the API, two messages 300 ms apart list in time order on both message routes, and a day begins at its first millisecond in search. Each fails without the change. The schema change rebuilds an existing database. Closes #1096 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fetch already names fetching an Asset, so the step before the format step is the export step, after the invokeExport call it makes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
The search day bound, format_utc_timestamp and the tests that build stored times now all call models::utc_timestamp_text, so the form that text comparison relies on is defined once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ExportMeta gains backup_taken_at_unix_ms, and schema_version goes from 10 to 11; a version-10 file is refused by its version. Each exporter fills the date from its source: an iPhone backup's Manifest.plist date for Apple Messages and WhatsApp, a Mac chat.db's modification time, the backup_date attribute of an SMS Backup & Restore file, the newest modification time of the files read for iMazing, OpenExtract, GO SMS Pro, SMS Backup+ and an Android WhatsApp database. The demo seed dates its backups at its reference time. ir-format carries the date in every format it writes and reads: JSON and JSON Lines through serde, CSV in a backup_taken_at_unix_ms column, EML and MBOX in an X-ME-Backup-Taken-At-Unix-Ms header. A value that is not a whole number is refused rather than read as no date. Part of #1924. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Staging keeps each staged row's backup date (staging_messages .backup_taken_at), and messages keeps the date of the backup that decided the stored copy. Between two copies of one message from one source, when both backups have a date, the copy from the later backup gives the message its deletion mark, mark or no mark, and its text and earlier versions, whatever the versions' times say; a copy from an earlier or the same backup changes neither. This holds in one import in either file order and across imports in either order, so the duplicate flag, which follows the text, does too. Where either backup has no date, the rules for files without one hold: a mark adds, now also from the second file of one import, and an edit is taken when its newest earlier version is newer. The Import Run records the newest backup date its files name, and the HTTP API answers it as backup_taken_at on ImportRun and ImportRunSummary. A message answers its own backup_taken_at, which an Export Run writes back into the conversation file as the newest date of the conversation's messages. The schema change rebuilds the database; nothing migrates. Part of #1924, #1741, #1804. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…made Settings → Storage → Import history opens a run's details with a Backup line: the backup the desktop app recorded for the run, and when that backup was made, from the run's new backup_taken_at. A backup that records no date says so. The owner's view of another account's run carries neither and shows nothing. Part of #1924. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The common message page documents export.backup_taken_at_unix_ms, where each exporter reads it from, and the rule the import applies; the CSV and mail references list the new column and header; the import and storage pages say which backup wins and where the date shows. The CHANGELOG entry for 0.11.0 says files at schema version 10 are refused. Closes #1924. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o feat/1924-backup-taken-at
…es' into feat/1923-time-precision # Conflicts: # CHANGELOG.md # crates/server/server/src/db/staging.rs
…x row back #1957 reindexes a stored message only when a staged row carries a mark, because a promotion never cleared one. With #1924 a later backup can clear an Unsent mark, and that message stayed out of the search index. The promotion now names every message whose mark it changes, set or cleared, in _promote_mark_map, and the reindex set reads that table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…out one Two reads of one Mac's chat.db within one second carry the same date, and the later-backup rule read them as the same backup, so an unsend or an edit between them was lost. Equal dates now cannot decide, in SQL (later_backup_sql) and in Rust (the new later_backup helper, which the in-import copy rule uses instead of restating the comparison), and the undated rules hold: they change nothing for the same backup read again. add_staged_copy_mark no longer returns a flag its caller drops. The architecture doc says the date is kept to the second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Messages keeps chat.db in write-ahead mode, so a row written since the last checkpoint moves only chat.db-wal's time, and two reads of one Mac could carry the same date or an iPhone backup could read as later than a deletion the Mac already held. The date is now the newest modification time of chat.db, chat.db-wal and chat.db-shm. The format doc also says equal dates fall back to the rules for files without one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sages share it An Export Run stamped every message of a conversation file with the newest backup date of any of them. Imported elsewhere, a message decided by an older backup then claimed the newest date and ignored a backup made between the two, and a message with no date gained one. The file carries one date for all its messages, so it now names one only when every message has the same, and none otherwise, which keeps the rules for files without a date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SMS Backup & Restore writer left backup_date off the root element, so an Export or conversion to XML read back in was dated by when Message Crate wrote the file and read as the newest backup. The writer now writes the newest backup date of the conversations it holds, the rule the reader follows for a conversation read from two files, and leaves the attribute out when none has one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n-at #1963 made every stored time three-digit milliseconds and changed format_utc_timestamp to take milliseconds. The backup date now goes to it whole, so it is stored to the millisecond like every other time, and the docs that said "to the second" say so. CHANGELOG keeps both sides' Upgrading entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A message in the conversation file gains a required time_precision, seconds or milliseconds, and schema_version goes from 11 to 12; a version-11 file is refused by its version. The flag, never the time, says whether a time has milliseconds, since a millisecond time can end in .000. Every exporter writes the precision it already knew: iMazing, OpenExtract, GO SMS Pro's PDU files and SMS Backup+ without X-smssync-date write seconds; Apple Messages, WhatsApp, SMS Backup & Restore, GO SMS Pro's XML and SMS Backup+ with X-smssync-date write milliseconds. When an exporter keeps one copy of a message, a whole-second copy that takes a millisecond copy's time takes its precision too. The demo seed writes milliseconds. ir-format carries the field in every format: JSON and JSON Lines through serde, CSV in a time_precision column, EML and MBOX in an X-ME-Time-Precision header. A blank or unknown value is refused rather than guessed. Part of #1923. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two paragraphs the merge of #1963 edited ran past the wrap width. Test fixtures that stand for a stored backup date now use the three-digit millisecond form the server writes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
staging_messages and messages gain time_precision, seconds or milliseconds, from the conversation file, and the HTTP API answers it as time_precision on a Message. An Export Run writes the stored value back into the conversation file. The schema change rebuilds the database; nothing migrates. Part of #1923. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s millisecond twin Within one source, a message whose time is in whole seconds is hidden as the duplicate of a message that matches it in everything else and has milliseconds in the same second, so an SMS Backup+ message imported once from a mail timed by its Date header and once from one timed by X-smssync-date is shown once, with its milliseconds. Before, the exact pass hid a duplicate only when another source held it. The content key stays at whole seconds, and the stored flag decides, never the time: a millisecond time that ends in .000 is not taken for a whole second. Part of #1923. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecision Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The common message page, the export structure, the CSV columns, the mail archive headers and the message transfer page describe time_precision at schema version 12. The architecture note gains the rule that, within one source, a whole-second message is the duplicate of its millisecond twin, with its reason. The changelog says version-11 files are refused. Part of #1923. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…feat/1923-time-precision # Conflicts: # CHANGELOG.md # crates/libs/api-types/src/lib.rs # crates/server/server/src/db/staging.rs # crates/server/server/src/db/staging/tests.rs # crates/server/server/src/test_support.rs # crates/server/server/tests/fixtures/apple-messages-sub-second-times.jsonl # web/src/lib/serverApi.types.ts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
origin/main gained #1966 as one squashed commit whose tree is the feat/1924-backup-taken-at head this branch already merged, so this branch's tree stands as it is.
|
Findings with no line in the diff at 19ff774. F1 · Spec · spec ( F2 · Spec · judgement ( F3 · Correctness · bug ( |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each gains ALL and parses by finding the variant whose name matches, as Deletion does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… pass It was compared as text, so an unknown value counted as milliseconds, where the message read path refuses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s copy wins The twin is hidden under the cross-source winner, which pick_winner ranks by attachments and import order, not precision. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…precision Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds says milliseconds The two copies share a guid, so the copy stored first kept its flag and a whole-second copy stored first stayed seconds for good. A staged or promoted copy that says milliseconds now raises the stored flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssage's time A copy cut to the second elsewhere that kept the guid of a message with other milliseconds was marked milliseconds at a .000 time it never had. promote_time_precision returns nothing, and its phase names what it does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…are rewrapped Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Answers to the findings in #1968 (comment):
Deferred to #1969. The twin rule runs wherever the dedupe runs (the server's
Fixed in 6bfdb84, with the time guard in d6f19fb: |
|
Review summary for f833bc8 Merge check before the review: the branch recorded #1966's squash (7a8435e) with Findings, by axis:
Totals: 16 Fixed, 0 Declined, 3 Deferred (#1969, #1970, with two findings sharing #1970). No commits were needed for CI failures: the ready run on f833bc8 passed. No merge of the base was made. There was a Spec review. No user threads are open. |
Feature Description
Each message in the conversation file now says whether its source recorded its time to the millisecond or in whole seconds (
time_precision:secondsormilliseconds). The server stores the flag, answers it on the API, and an Export Run writes it back out. With the flag, the dedupe can hide a whole-second message as the duplicate of a message from the same source that matches it in everything else and has milliseconds in the same second, so the message is shown once, at its time to the millisecond. A millisecond time that ends in.000is not taken for a whole second: the flag decides, never the value.For people who import the same phone from backups that record time differently, for example SMS Backup+ mails timed by
Datebeside mails timed byX-smssync-date. Asked for by the decision of 2 October 2026 on #1096.User Story
As a person who imports two backups of one phone, one that records whole seconds and one that records milliseconds, I want each message shown once, at its exact time, so that my conversation has no doubled messages and keeps the order the phone recorded.
Implementation Details
IrMessagegains a requiredtime_precision(message_ir::TimePrecision, now serdelowercase). A version-11 file is refused by its version (check_schema_version), never upgraded.one_copy_per_messagenow returns the precision it keeps with each time, so a whole-second copy that takes a millisecond copy's time (GO SMS Pro PDU beside the XML) takes the millisecond precision too.message_time); it is now written. iMazing, OpenExtract, GO SMS Pro PDU-only messages, and SMS Backup+ mails withoutX-smssync-datewriteseconds; Apple Messages, WhatsApp, SMS Backup & Restore, GO SMS Pro XML and SMS Backup+ withX-smssync-datewritemilliseconds. Each exporter's fixture test checks the field; SMS Backup+ gains a test for theDatefallback.time_precisioncolumn; EML and MBOX gain a requiredX-ME-Time-Precisionheader (the repo'sX-ME-prefix; the issue text saidX-MC-*). A blank, missing or unknown value is refused rather than guessed.messages.time_precisionandstaging_messages.time_precision(NOT NULL,CHECKin the two values). The import stages and promotes the flag; the message read path decodes it strictly.flag_exact_content_key_dupesnow readstime_precision. A newcontent_key_group_flagssets aside, within each content-key group, every whole-second message whose own source also holds a millisecond message in the group, runs the existing cross-source rule (exact_group_flags) over the rest, and hides each set-aside message under the rest's winner (always shown). The content key stays at whole seconds. A source that holds a message only in whole seconds, or only with milliseconds, keeps every copy as before.message-crate-exportmaps the API'stime_precisionback into the file.milliseconds(every source it imitates records milliseconds).Tests, each verified to fail without the change it covers:
dedupe::tests::a_whole_second_message_is_the_duplicate_of_its_millisecond_twin_in_one_sourceandimports_api::tests::time_precision::a_whole_second_copy_and_a_millisecond_copy_are_shown_once_with_the_milliseconds(imported once from a whole-second file and once from a millisecond file, both orders, shown once with.250): fail with the twin rule disabled.dedupe::tests::a_millisecond_time_ending_in_000_is_not_whole_seconds: fails with the twin rule disabled, and also fails under a rule that reads.000from the time instead of the flag.schema_version::tests::refuses_a_version_11_file_by_name: fails withSCHEMA_VERSIONat 11.imports_api::tests::time_precision::each_precision_is_kept_through_import_the_api_and_an_export_run(secondsand a.000millisecondsmessage, through import,GET /v1/messagesandGET /v1/exports/{id}/messages): fails when staging drops the flag.export::project::tests::a_message_keeps_the_precision_the_server_stored: fails when the export ignores the stored flag.ir-formatevery_format_keeps_whether_a_time_has_milliseconds(JSON, JSONL, CSV, EML, MBOX): fails when the CSV writer or the mail writer drops the flag.Key Files Changed
crates/libs/ir/src/{lib.rs,identity.rs,projection.rs,schema_version.rs}- the field, version 12, precision throughone_copy_per_messagecrates/libs/ir-format/src/{write.rs,read_csv.rs},crates/libs/mail/src/{headers.rs,lib.rs,parse.rs}- CSV column andX-ME-Time-Precisioncrates/exporters/sms-backup-restore-exporter/src/read.rs,crates/exporters/imessage-ir-exporter/src/convert.rs- the two exporters that build messages outside the projectionschema/sql/messages.sql,schema/sql/staging.sql,crates/server/server/src/{models.rs,imports_api/staging.rs,db/staging.rs,db/conversation_messages.rs}- storage and the APIcrates/server/server/src/dedupe.rs- the whole-second twin rulecrates/libs/api-types/src/lib.rs,crates/libs/export/src/project.rs- the API type and Exportdocs/architecture/contacts-identities-and-messages.md- the dedupe rule and its reasonHTTP API Changes
Message.time_precision(new, required):secondsormilliseconds(newTimePrecisionschema). Every route that answers aMessagecarries it:GET /v1/messages,GET /v1/conversations/{id}/messages,GET /v1/exports/{id}/messages, and the rest.POST /v1/imports/{id}/batches: a message line withouttime_precision, or a header at schema version 11, is refused with422naming the line or the version.docs/src/assets/openapi.json) and web types (web/src/lib/serverApi.types.ts) regenerated.Database Changes
schema/sql/*.sqlchanged (every database rebuilds empty and re-imports; the fingerprint is computed, nothing to bump)messages.time_precisionandstaging_messages.time_precision,TEXT NOT NULL CHECK (time_precision IN ('seconds', 'milliseconds')).How to Test
cargo test -p message-crate-server --lib -- dedupe:: time_precisionandcargo test -p message-ir-format -p message-ir -p message-crate-export.X-smssync-dateand once with onlyDate, in two runs; import both withmessage-crate-server import(dedupe on) and checkGET /v1/messageslists it once, with milliseconds and"time_precision": "milliseconds".time_precisioncolumn and theX-ME-Time-Precisionheader.Checklist
./scripts/check-pr.shpasses and formatter rewrites are committedRun locally on the head:
./scripts/check-pr.sh(pass),cargo test --workspace(2826 passed, 0 failed),cargo test --manifest-path src-tauri/Cargo.toml(pass),cd web && npm test(2062 passed) andnpm run build(pass),./scripts/check-generated-api-types.sh(match),cd docs && npm run check(0 errors) andnpm run build(pass).Related Issues
Closes #1923
Builds on #1096 (merged as #1963) and #1924 (merged as #1966). The branch was cut from #1966's branch and merged #1963's; both are now on
main, and this pull request's diff againstmaincarries none of their commits' changes.Screenshots / Demo
No screen changes.
Deployment Notes
dedupe: true,message-crate-server import,dedupe-cross-source,reset-demo). The desktop app's push does not ask for dedupe today.chat.dbfrom before iOS 11 / macOS 10.13 records whole seconds but is written asmilliseconds, because the Apple Messages Reader's protocol does not say which form it read (Apple Messages writes milliseconds for a chat.db that records whole seconds #1970). The desktop app's import never asks for dedupe, so the twin rule does not run through it (The desktop app's import never hides duplicates #1969). SMS Backup & Restore XML written back from the common message cannot carry the flag (documented incommon-message.md)..000share a guid; the stored message saysmillisecondswhichever came first (promote_time_precision,add_staged_copy_milliseconds).🤖 Generated with Claude Code