Track source-device provenance on every data record - #39
Merged
Merged
Conversation
Every model (Contact, Tag, Group, Location, Activity, Interaction,
Note, Reminder, ContactRelationship, RejectedCalendarEvent) now
records who created it and who most recently locally edited it:
createdByDeviceId, createdByPlatform, createdByDeviceName
lastEditedByDeviceId, lastEditedByPlatform, lastEditedByDeviceName
A new utility `DeviceIdentity` exposes a stable per-install identity:
- `installId`: UUID generated on first launch and persisted to
UserDefaults["device.installId"]. Survives launches; only resets on
app uninstall.
- `platform`: "iOS" / "macOS".
- `deviceName`: snapshot of UIDevice.current.name (iOS) or
Host.current().localizedName (macOS) at the moment of capture.
Three lifecycle hooks tie the provenance into the data flow:
1. Model init sets all 6 fields to the local device on construction.
SwiftData only calls custom inits for in-code-constructed instances,
never when hydrating fetched records, so this only fires for fresh
records.
2. New `markLocallyEdited()` helper on each model is the single source
of truth for local mutations. Sets updatedAt, flips syncStatus to
.pending (unless already .deleted), and refreshes the three
lastEditedBy* fields. Replaces the scattered `record.updatedAt =
Date()` + `record.syncStatus = .pending` pairs.
3. Sync apply layer (ContactSyncApply + ModelSyncApply) round-trips
all 6 fields. Crucially, it uses *explicit nil* — if a remote
payload lacks a provenance field (record came from a pre-feature
client), the local copy is set to nil rather than inheriting the
receiving device's init defaults. Prevents false attribution.
To stay DRY in ModelSyncApply, two private helpers cover the 9
non-Contact models: `appendProvenance(&dict, ...)` for toDict and
`readProvenance(dict)` returning a 6-tuple for apply.
Schema migration is automatic SwiftData lightweight migration — no
currentSchemaVersion bump, no store wipe. Verified locally: the
existing macOS master store with 1288 contacts opened cleanly with
all 6 new columns appearing as NULL on every existing record. No
backfill — accurate ("we don't know who created these"). New records
get tagged correctly going forward.
DeviceIdentity is shared with the BlackbookServer target via
project.yml (the server doesn't construct records itself but the
model init still runs when sync-pull inserts new instances, so the
type needs to be in scope).
No UI surface — data is queryable via SQL on the master store. The
original motivating query — "which device created each duplicate of
Davina Adjani?" — is now answerable directly via:
SELECT ZFIRSTNAME, ZCREATEDBYPLATFORM, ZCREATEDBYDEVICEID
FROM ZCONTACT WHERE ZFIRSTNAME LIKE '%Davina%';
Note: BlackbookServer is NOT shipped via TestFlight. After this
merges, the local daemon needs a manual rebuild + reinstall (steps in
TEST_SCENARIOS.md).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every record (Contact, Tag, Group, Location, Activity, Interaction, Note, Reminder, ContactRelationship, RejectedCalendarEvent) now stores who created it and who most recently locally edited it. Six new optional fields per model:
A new
DeviceIdentityutility gives a stable per-install identity (UUID persisted to UserDefaults, regenerated only if the app is uninstalled). Platform-awaredeviceNamewrapsUIDevice.current.nameon iOS andHost.current().localizedNameon macOS.Lifecycle
markLocallyEdited()helper on each model is the single source of truth for local mutations: bumps updatedAt, flips syncStatus to .pending (unless .deleted), refreshes lastEditedBy*.Schema migration
Pure SwiftData lightweight migration —
currentSchemaVersionstays at 4, no store wipe. Verified locally: macOS master store with 1288 contacts opened cleanly with all 6 new columns appearing as NULL on every existing record. No backfill (per plan).Why no UI
User explicitly chose data-layer only. The motivating query — "which device made these duplicate Davina Adjani records?" — is now answerable directly via SQL:
Verification (already done locally)
Test plan
docs/test-scenarios/TEST_SCENARIOS.md.ZCREATEDBYPLATFORM='macOS',ZCREATEDBYDEVICENAME='Michael's Mac mini'.ZCREATEDBYPLATFORM='macOS',ZLASTEDITEDBYPLATFORM='iOS'.Full scenarios in
docs/test-scenarios/TEST_SCENARIOS.md.🤖 Generated with Claude Code