Seed Protocol
Concepts

Conflict detection

What the SDK does when the same data is changed in two places: the optimistic check on schema saves and where it stops, last write wins for items on one device and between published edits, and the conflict it records when a sync meets an unpublished edit.

The SDK has two conflict rules. Before saving a schema, it makes sure nobody else saved that schema since this instance loaded it. For items, the data your users actually edit, an unpublished edit stays the value when a sync brings in a different one from another device, and the SDK records a conflict your app can show. Nothing is merged and nothing is checked between tabs: there, and between two published edits, the newest write wins. Where that isn't enough, your app needs its own safeguards.

Where conflicts can come from

Seed data can be changed in two kinds of places at once:

  • On one device. Several tabs of the app, or several Node processes, share one local database. Their writes land in the same rows. See Multi-tab behavior.
  • Across devices. Each device has its own local database. The only data they share is what's been published: EAS attestations made by the user's account, which every device pulls in with EAS sync. Drafts never leave the device they were written on.

What the SDK checks: schema saves

Schemas, models and model properties are loaded into in-memory instances that you edit and then save. The design is optimistic concurrency: don't lock anything while the user edits, but check before writing that the data underneath hasn't moved.

When a Schema instance loads, it remembers the schema row's updatedAt from the database. Before a save, it reads the newest row for that schema again. If the database copy is newer than the one the instance loaded, someone else saved in between, and the save fails with a ConflictError instead of overwriting their change. The intended response is to reload and re-apply your edit.

ConflictError is exported from @seedprotocol/sdk, so you can test for it with instanceof; its fields and a try/catch that reloads are in Saving and conflicts.

Only saveNewVersion() throws it. Assigning to a schema field saves a draft in the background under the same check, but a conflict there is logged and kept on schema.lastSaveError instead. Models and model properties aren't checked (Model: Conflicts), and the check is skipped, letting the save go ahead, when the instance has no load timestamp or the database query fails.

saveNewVersion() also depends on what you pass as config.schema: if the app keeps passing the older file, the next start reports the older version and every later saveNewVersion() throws ConflictError, whether or not anything else changed the schema (saveNewVersion). The usual way to change a schema, editing seed.schema.json, bumping its version and restarting the client as Define a schema and add to it describes, doesn't go through the check.

Items

Nothing compares versions before an item write, nothing throws, and nothing merges. Which value shows depends on where the two writes came from.

On one device: last write wins

Each property value is a row in the metadata table. An assignment adds the edit to the outbox, and the leader tab applies it. When the property's newest row is a draft, the edit updates that row in place. When the newest row is a published value, the edit inserts a new draft row on top, unless the value is unchanged. Either way, the next write replaces the last one.

So if two tabs edit the same property, the edit applied last is what's stored: the leader applies a property's edits in the order they were added to the outbox. In the browser, every tab then hears about the write through the change feed and shows the stored value. Edits to different properties don't interfere, because each property has its own row. No conflict is recorded between tabs, since a conflict needs a synced value and drafts aren't attested.

Two Node processes sharing one database follow the same rule, but Node has no change feed, so a loaded item doesn't show the other process's write.

Across devices: the newest attestation wins

Devices don't exchange drafts, so the only cross-device case is published data. When the user publishes an edit from their laptop, @seedprotocol/publish writes new attestations from their account. Their phone, on its next sync, stores those attestations alongside whatever it had.

To show a property, the SDK picks one row per property:

  1. The newest row wins: a synced attestation is dated by its attestation time, and a local draft row by the time of its last edit. A revoked row counts like any other. Sync stores a property whose attestation was revoked on a live item as a row with no value, so an older value doesn't show through.
  2. Ties go to the row with the larger local ID.

If the laptop and the phone both publish an edit to the same property, both attestations stay on EAS, and every reader shows the one with the later attestation time. The other edit isn't lost, since it's still on-chain and in the local database, but nothing tells the user it was superseded.

Only attestations made by the item's own account count. Sync ignores a Version attested by another account than the Seed's, and a property attested by another account than its Version's, so someone else can't overwrite an item by attesting against it.

An unpublished local edit stays the value, whenever the synced value was attested. When sync writes an attestation newer than the edit, it moves the draft's time just past it, so the draft is still the newest row. getPublishPendingDiff(item) keeps listing the edit, and the next publish sends it over the synced value.

Ownership narrows who is in the race. The SDK only saves (assignments included), publishes and deletes items that belong to the user's owned addresses and drafts that have never had a publisher; items from watched addresses are read-only. So the cross-device conflicts an app normally meets are the same user, publishing from two places with an account they own.

Conflicts: a synced value over an unpublished edit

When the value sync brings in differs from both the unpublished edit and the published value the edit replaced, the SDK records a conflict on the property. The edit stays the value either way. Syncing the same attestation again isn't a conflict, nor is a synced value equal to either of the other two. An edit still waiting in the outbox when the sync runs gets the same check when the leader applies it. An Image, File or Html edit records none, whether it's saved or still waiting.

The conflict holds the three values (baseValue, remoteValue, localValue) and the synced attestation's UID. Read it as property.conflict or, for every property of an item, with await item.conflicts(). The itemProperty.conflict event carries it in the tab that ran the sync or made the edit. In the browser a loaded property shows its conflict once the change feed reports the sync's write; on Node, loaded items re-read their properties after each sync.

The user settles it: property.resolveConflict('local') keeps the edit, and resolveConflict('remote') deletes the property's unpublished values so the synced value shows. The next edit to the property also clears it. ItemProperty: Conflicts has the API, and What wins when both devices edit a helper that syncs and lists an item's conflicts.

What's not there

Some things you might expect from a sync engine don't exist:

  • No version numbers or updatedAt checks on items, and no ConflictError for them.
  • No three-way merge, and no CRDTs. A conflict keeps both values, and the user picks one.
  • No conflict check between tabs, or between two published edits.
  • No conflict hook in @seedprotocol/react. useItemProperty re-renders when its property.conflict is recorded or resolved, so a component can show it; to list an item's conflicts, call item.conflicts() after client.syncFromEas().

What this means for your code

  • Assume last write wins between tabs and between published edits. Design editing so one person edits one item in one place at a time, or accept that the latest save or publish is what readers see.
  • Check for conflicts after a sync when the user has unpublished edits, and let them choose with resolveConflict.
  • Render from the database, not from copies. Hooks and live queries show the value that's winning now. A value you copied into component state can hide a change from another tab.
  • Sync before publishing from a device that's been offline, so the user sees what was published elsewhere before overwriting it.
  • Add your own check where it matters. For example, record when the item was last published as the user starts editing, and compare again before publishing:
src/publishGuard.ts
import { client, getSeedPublishState } from '@seedprotocol/sdk'
import type { IItem } from '@seedprotocol/sdk'

// When the editor opens
export async function lastPublishedAt(item: IItem<any>): Promise<number | null> {
  const { lastAttestedAtMs } = await getSeedPublishState(item)
  return lastAttestedAtMs
}

// Before publishing: true if another device published this item since the editor opened
export async function publishedElsewhere(
  item: IItem<any>,
  seenAtMs: number | null,
): Promise<boolean> {
  await client.syncFromEas()
  const { lastAttestedAtMs } = await getSeedPublishState(item)
  return lastAttestedAtMs !== seenAtMs
}
  • Don't rely on ConflictError for schemas. Change schemas through seed.schema.json and a restart. If you do edit schemas in code, check schema.lastSaveError after an edit.

On this page