> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getbased.health/llms.txt
> Use this file to discover all available pages before exploring further.

# Sync and delta internals

> How getbased adds profile surfaces to encrypted cross-device sync without breaking local edits, tombstones, or import provenance.

getbased sync is local-first CRDT storage plus app-level delta surfaces. The relay carries encrypted Evolu data, but the browser still needs deterministic merge rules for profile arrays, maps, tombstones, import snapshots, and Agent Access state.

This page is the contributor contract for adding or changing a synced profile surface.

## Recent convergence safeguards

Clean manual sync, reconnect and tab resume use a dirty-aware pull path. Pending local edits still flush first; unconditional reconnect pushes must not republish stale scalar state with fresh timestamps. Serialize forced pulls and clone merge baselines so asynchronous overlays cannot mutate active data before persistence.

Delayed save callbacks use the latest active or stored profile, skip already committed dirty generations and re-read when a newer save arrives during storage access. Chat-only changes also reconcile after startup, across active and inactive profiles; see [Chat backup and Sync](/developers/chat-internals#backup-and-sync).

Biology Score context/explanation records merge by material and timestamp, preserving bounded variants through snapshot and delta overlays. A stale delta must not undo a completed local assessment.

## Core modules

| Module | Role |
| - | - |
| `sync-delta-surfaces.ts` | Names the profile surfaces that participate in delta sync |
| `sync-delta-surface-config.ts` | Per-surface identity keys, tombstone keys, map transforms, and user-visible labels |
| `sync-push-deltas.ts` | Builds and pushes deltas; advances snapshots only after successful remote application |
| `sync-apply.ts` / `sync-chat-apply.ts` | Applies inbound profile and chat changes safely |
| `sync-tombstones.ts` | Profile and item-delete propagation, including batched-delete quarantine |
| `sync-dirty-state.ts` | Durable per-profile edit generations used to protect pending local work |
| `sync-settings-state.ts` | Separates configured, active, and paused state for the current browser |
| `sync-lifecycle.ts` | Starts, pauses, resumes, and disconnects the browser runtime without conflating those operations |
| `sync-evolu8-candidate.ts` | Evolu 8 default runtime facade, one-time Evolu 7 identity handoff, explicit V7 rollback, and superseded V8 database cleanup |
| `sync-evolu8-identity-vault.ts` | Durable browser-local owner ID and mnemonic handoff that avoids reopening the V7 worker during normal V8 startup |
| `sync-backup-restore-state.ts` | Marks restored profiles dirty and carries a durable publish-before-pull recovery list across reload |
| `sync-profile-fields.ts` | Shared deterministic profile-field allowlist for push and pull |
| `sync-origin-state.ts` | Tab-local committed timestamps used to suppress source-browser update notices |
| `sync-relay-health.ts` | Authoritative storage checks, quota warnings, and owner-signed relay maintenance |
| `sync-diagnostics.ts` | Debug snapshots, status panels, and copyable diagnostics |

## Evolu 8 default and V7 identity handoff

Evolu 8 is the default browser Sync client. Existing users keep the same mnemonic, owner ID, and encrypted relay history; there is no new Sync identity and no relay-side data migration.

Evolu 8 cannot open Evolu 7's local SQLite database directly. On the first V8 start without a committed identity vault, `sync-evolu8-candidate.ts` therefore opens the V7 client without relay transports, reads its owner mnemonic, derives the V8 owner, and refuses the handoff if the owner IDs differ. It attempts to commit the verified owner ID and mnemonic to `sync-evolu8-identity-vault.ts` before starting the V8 database. A successful commit lets later normal starts avoid loading the V7 worker. If browser storage rejects the commit, the current start continues with the verified in-memory identity and the next start retries the V7 handoff.

Keep the V7 bundle and bridge while browsers that have not run since the upgrade may still need this one-time handoff. The query parameter `?evolu-client=v7` is an explicit temporary operational rollback, not a user-facing migration step. Identity restore and reset also update the V7 owner lazily so rollback cannot reopen a different identity.

Because V8 uses generation-named local databases for history resets, successful startup reclaims superseded generations only after it can take Evolu's exclusive database lock. An active database in another tab is skipped and retried on a later startup.

## Delta surface types

| Type | Examples | Identity rule |
| - | - | - |
| dated item arrays | `entries`, `changeHistory` | stable date/item fields plus surface-specific tombstones |
| stable-id arrays | `importSnapshots`, `supplements`, `healthGoals`, `notes`, `chatSummaries` | item `id` or configured key |
| maps | `manualValues`, `markerValueNotes`, `markerPlacements` | key transform, not array identity |
| scalar/profile state | `agentAccess`, `agentAccessWearableSeriesDays` | profile-level last-write-wins |

`importSnapshots` are user-visible because Settings → Data can reopen a saved import review. They must keep stable IDs and tombstones so deleting one source file does not delete unrelated same-day marker values.

`markerPlacements` uses immutable `gb:marker:*` or `custom:*` IDs as map keys. A row carries only the visual destination metadata; lab entries and dot-keyed companion maps are never re-keyed. Unknown marker IDs or destination categories remain in the profile for forward compatibility while the current runtime falls back to native display.

New supplement and medication records have `sm_...` IDs. The supplements delta config uses that stable ID when present and retains the legacy `name|startDate|type` hash only for unmigrated compatibility records. Period changes, structured product facts, and quality evidence must stay attached to the same item identity.

## Configured, active, and paused

Sync has three distinct browser states:

| State | Persisted meaning | Runtime behavior |
| - | - | - |
| Not configured | `labcharts-sync-enabled` is absent or not `'true'` | No local Sync identity is expected |
| Active | Configured and `labcharts-sync-paused` is absent | Timers, subscriptions, pushes, and pulls can run |
| Paused | Configured and `labcharts-sync-paused` is `'true'` | Identity/runtime storage and dirty markers remain, but relay traffic and inbound application stop |

Turning the user-facing switch off pauses; it must not run disconnect cleanup. Local saves still call the normal dirty hook while paused, including saves to inactive profiles. Resume rebinds subscriptions and flushes the durable pending work before any inbound merge. If that flush fails or is skipped, the pull fails closed.

Disconnect/reset is the explicit destructive operation for local Sync state. It forgets the Evolu owner and local planner/history keys, then reloads. It does not delete profile data or ciphertext already held by the relay.

## Device-local profile fields

`importBenchmarks` and `deletedImportBenchmarkIds` live in the imported-data blob for local persistence and full backups, but they must never enter cross-device sync. Their timing, loaded-model state, context, and hardware metadata describe the browser that ran the test.

The boundary has three parts:

1. benchmark saves use `saveImportedData({ immediate: true, skipSync: true })`;
2. `stripLocalOnlyProfileData()` removes both keys from legacy blob and v4 delta serialization; and
3. `preserveLocalOnlyProfileData()` restores the receiving browser's local values after an inbound merge.

Do not add these keys to a delta map. A remote payload must not erase or replace local benchmark history. Confirmed lab entries and `importSnapshots` remain normal synced surfaces even when their related model-test diagnostics are local-only.

## Snapshot advancement rule

Do not advance the local “last pushed” snapshot until the remote operation applies successfully. This protects local edits from being marked synced when the relay write failed or was rejected.

When adding a surface:

1. Add it to `sync-delta-surfaces.ts`.
2. Add identity/tombstone behavior in `sync-delta-surface-config.ts`.
3. Verify export/import behavior if the surface belongs in JSON backups.
4. Add apply/merge tests for local-vs-remote conflicts.
5. Add observability if the surface is user-visible in Settings diagnostics.

## No-op and dirty-generation rules

Equivalent maintenance saves must not create relay history. `data.ts` skips an imported-data write when the stored value is unchanged, and `sync-push.ts` skips an outbound profile payload when its deterministic content matches the last committed payload. Forced rebuilds are the explicit exception.

Every real local edit marks its profile dirty before the debounced push. The marker is a generation token, not a boolean:

1. a push captures the generation it is sending;
2. successful remote completion clears only that captured generation; and
3. an edit that arrives during the push leaves a newer generation dirty.

Before an inbound pull can merge remote scalar state, it flushes pending local work. If the flush cannot commit, the pull must fail closed rather than let stale remote state overwrite the local edit. This rule applies to inactive profiles too: durable profile storage, not only the active in-memory profile, is the source for their pending push.

`readProfileImportedData()` always uses `encryptedGetItem()` for named profiles. That wrapper owns the IndexedDB-backed `*-imported` route whether or not the blob contents are encrypted. A missing/unreadable named profile returns `null`, not a newly created default object. `sync-push.ts`, scheduled pushes, pull preflight, and compaction treat that result as a hard skip/failure so a storage read problem cannot become an authoritative empty relay update.

`sync-origin-state.ts` records exact committed `syncedAt` values for the current tab. An inbound subscription with one of those timestamps still refreshes local state but does not show the misleading “updated from another device” notice. Other tabs and devices do not share this in-memory marker and still show the notice.

## Existing-mnemonic restore ordering

Joining a Sync identity must finish application state before navigation. The Evolu 7 worker's default `restoreAppOwner()` behavior can reload before the returned promise resolves. A first-time join previously reloaded while Sync still had provisional `persist: false` state, so the restored owner could not pull relay data. The Evolu 8 facade preserves the same application contract.

For a user-entered existing mnemonic, `sync-identity.ts` calls:

```js theme={null}
await evolu.restoreAppOwner(normalizedMnemonic, { reload: false });
```

It then enables/persists Sync and completes getbased's join-finalization path before issuing the single controlled reload. The local seeding/recovery path remains separate. Tests for this contract must assert call ordering, not only that `restoreAppOwner()` was invoked.

## Backup-restore preflight

A full backup can restore data that the current relay history previously deleted. Before reload, `prepareRestoredProfilesForSync()`:

1. records the safe restored profile IDs under `labcharts-sync-backup-restore-pending`;
2. clears their per-surface delta snapshots;
3. clears delta cutover and the previous Sync timestamp; and
4. gives each restored profile a new durable dirty generation.

On the next active start—or after joining an existing identity—the inbound callback first force-pushes every pending restored profile, then flushes all remaining dirty profiles. Only after both phases succeed may remote tombstones or scalar state apply. The pending restore list is cleared only after a successful preflight, so a reload or failed relay write can retry safely.

## Relay storage and compaction

Relay quota measures append-only encrypted message history. The client gets authoritative `storedBytes` and `quotaBytes` from the owner-signed `/self/owner-storage` endpoint when the relay supports it; a local estimate is only a temporary fallback.

Self-service compaction is a guarded rebuild, not a blind delete:

1. flush every dirty local profile, including inactive profiles;
2. pull and merge the latest relay state;
3. call owner-signed `/self/compact-owner`;
4. reset local Evolu history under the same owner;
5. force-push a complete snapshot for every profile; and
6. verify that the relay contains the rebuilt history.

If any preflight push fails—or any required profile blob cannot be read—compaction must not begin. If deletion succeeds but rebuilding is interrupted, the UI keeps a **Retry rebuild** path so the same canonical device can finish without deleting local profile data.

## Tombstones and deletes

Deletes are explicit tombstones, not absence. This matters for:

* marker/date values that should stay deleted after another device still has the old value;
* per-file import snapshots where Settings → Data delete removes only markers owned by that snapshot;
* profile deletes, where single deletes propagate automatically but batched deletes are quarantined.

For batched profile deletes, receiving browsers show an apply/restore UI in Settings → Data. This prevents a stolen mnemonic from silently wiping every profile on every paired device.

## Agent Access sync

Agent Access state is part of the encrypted profile. The enabled state, read token, Agent Context key, relay metadata, and wearable-series window can travel to another paired browser. Wearable OAuth refresh tokens do not sync; each device must reconnect wearable vendors independently.

The encrypted Agent Access summary is produced by the same `buildLabContext()` formatter used by in-app AI, with context toggles bypassed because the Agent Access switch is the external-sharing permission. Context-card serialization must preserve explicit values such as digestive `none`/`normal` answers and Light & Circadian `skinType`; adding a stored card field requires assertions for both the in-app context and encrypted gateway payload.

## Verification checklist

Before shipping sync changes:

* run targeted unit tests for the touched surface;
* run browser sync specs if the surface is visible in Settings or the dashboard;
* verify tombstone behavior for delete/recreate conflicts;
* verify snapshot advancement after simulated failed pushes;
* verify equivalent saves and unchanged payloads do not create new pushes;
* verify pulls and compaction flush dirty active and inactive profiles;
* verify paused saves remain dirty, no subscription merge runs while paused, and resume pushes them before pulling;
* verify disconnect/reset clears local Sync identity/history while pause preserves them;
* verify an unreadable active or inactive profile cannot produce an empty push and stops compaction preflight;
* verify existing-mnemonic restore persists the joined owner before the controlled reload;
* verify a first V8 start derives the same owner ID from the V7 mnemonic, attempts the identity-vault commit before V8 starts, and safely retries after a persistence failure;
* verify a normal V8 start with a valid identity vault does not load the V7 worker, while explicit V7 rollback and identity changes keep both owners aligned;
* verify restored profiles clear stale planner state and are force-pushed before inbound tombstones;
* verify source-tab notifications are suppressed without suppressing receiving devices;
* check Settings → Data diagnostics for a useful label and no plaintext secrets;
* verify owner storage refresh and compaction/rebuild failure recovery when relay maintenance changes;
* test export/import if the surface should survive backups.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.