Skip to content

Track source-device provenance on every data record - #39

Merged
mayeack merged 1 commit into
mainfrom
claude/source-device-provenance
May 30, 2026
Merged

mayeack merged 1 commit into
mainfrom
claude/source-device-provenance

Conversation

@mayeack

@mayeack mayeack commented May 30, 2026

Copy link
Copy Markdown
Owner

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:

createdByDeviceId, createdByPlatform, createdByDeviceName
lastEditedByDeviceId, lastEditedByPlatform, lastEditedByDeviceName

A new DeviceIdentity utility gives a stable per-install identity (UUID persisted to UserDefaults, regenerated only if the app is uninstalled). Platform-aware deviceName wraps UIDevice.current.name on iOS and Host.current().localizedName on macOS.

Lifecycle

  1. Init sets all 6 to local device on construction (SwiftData only runs custom inits for in-code-constructed instances, not fetched ones).
  2. markLocallyEdited() helper on each model is the single source of truth for local mutations: bumps updatedAt, flips syncStatus to .pending (unless .deleted), refreshes lastEditedBy*.
  3. Sync apply uses explicit nil — a remote dict without provenance fields nils them on the local copy rather than inheriting init defaults. Prevents false attribution for records that pre-date this feature.

Schema migration

Pure SwiftData lightweight migration — currentSchemaVersion stays 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:

SELECT ZFIRSTNAME, ZCREATEDBYPLATFORM, ZCREATEDBYDEVICEID
FROM ZCONTACT WHERE ZFIRSTNAME LIKE '%Davina%';

Verification (already done locally)

Check Result
iOS / macOS / BlackbookServer builds OK three BUILD SUCCEEDED
Schema migration on existing 1288-contact store OK clean, no wipe, no crash
New columns present on ZCONTACT OK all 6
Existing records have NULL provenance OK 1288 NULL, 0 set
Daemon restarts with epoch unchanged OK F80220BC-… preserved

Test plan

  • CI: Build & Test green on iOS + macOS schemes.
  • After merge → TestFlight ships new iOS + macOS Blackbook builds.
  • Local-only: rebuild + reinstall BlackbookServer per docs/test-scenarios/TEST_SCENARIOS.md.
  • Create a new contact on Mac → master row shows ZCREATEDBYPLATFORM='macOS', ZCREATEDBYDEVICENAME='Michael's Mac mini'.
  • Edit that contact on iPhone → master row shows ZCREATEDBYPLATFORM='macOS', ZLASTEDITEDBYPLATFORM='iOS'.
  • Pre-existing records keep NULL provenance.

Full scenarios in docs/test-scenarios/TEST_SCENARIOS.md.

🤖 Generated with Claude Code

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>
@mayeack
mayeack merged commit 9eb4c70 into main May 30, 2026
2 checks passed
@mayeack
mayeack deleted the claude/source-device-provenance branch May 30, 2026 00:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant