# Preserving unique constraint invariants while merging ## Summary Concurrent inserts or updates can make multiple visible CRDT rows claim the same unique value. The current merge model resolves this without hiding rows because of the unique conflict itself: losing unique claims are materialized with deterministic conflict-free visible values. This is the `flag` policy. It is intentionally simple: - all rows remain CRDT facts; - all rows involved in a unique conflict remain visible domain rows; - one deterministic winner keeps the claimed unique value; - each loser keeps its own row identity and receives a conflict-free visible unique value; - no business conflict object is synced; - no foreign key retargeting is needed for unique conflicts. This policy is the only unique-conflict policy in space for the current implementation. ## Local writes Local inserts, updates, upserts, and restores obey the database's unique constraints. Two records cannot claim the same occupied value or non-null composite tuple in one local write. The database enforces this against stored rows and other rows in the batch; no additional domain reads are needed to pre-check uniqueness. A rejected write rolls back its domain changes and sync metadata together. Ordinary `ignoreConflicts` and upsert predicates still apply. Claims accepted independently on disconnected replicas can still conflict when merged. The policy below preserves those accepted claims and chooses their visible values. Passing an unchanged displayed alternative through a full-row save preserves the underlying claim and follows the normal field-touch rules. ## Current Policy: Flag Candidate construction, hidden-row release, canonical attempted-value lookup, and restoration are defined by [Rebuildable FK and unique projection model](projection-model.md#exact-projection-order). The `flag` policy operates only on the visible conflict groups supplied by that shared planner. For each conflict group among visible rows: 1. Compute each row's unique claim timestamp from the HLC of the unique-indexed field or fields. For an insert, use the insert HLC and any incoming field HLCs for the indexed fields. 2. Pick the winner deterministically by oldest unique-claim HLC, using the same full HLC ordering as the rest of CRDT merge. This includes node id as the HLC tie-break. Row id is only a final fallback for identical HLCs. 3. Materialize the winner's canonical claim. This lets a previously released visible loser reclaim its authored value after the prior winner is hidden or changes its claim. 4. Rewrite each loser's visible releasable unique-indexed value to a deterministic conflict-free value: - nullable values of a supported candidate type may be released to `null`; - text values receive a stable `__conflict__` suffix; - UUID values receive a deterministic synthetic UUID. 5. Keep every conflict-group row visible. Application code can decide whether and how to surface the changed visible value as a business conflict. For a composite unique index, fixed partition columns such as `spaceId` are not released, and not every remaining indexed column has to be releasable. The index is supported when at least one non-`spaceId` column can be released deterministically. Stable discriminator columns may remain unchanged; changing the releasable column or columns is enough to make the unique tuple conflict-free. Supported candidate types are defined centrally under [Dynamic validation](projection-model.md#dynamic-validation); nullability alone does not make a type supported. For the global foreign-key-only unique indexes that Serverpod emits for synced relations, every indexed FK column must be nullable under the current `flag` policy. Required FK columns cannot be released without violating either the non-null constraint, the FK constraint, or the unique constraint itself. The rewritten value is a materialized data value, not a synced conflict class. The core CRDT protocol does not need to expose unique conflict objects to users. ### Reserved generated values Authored values in unique text columns must not end in `__conflict__`, `__hidden__`, or `__park__`, where the UUID uses the hexadecimal `8-4-4-4-12` shape. The marker is lowercase; hexadecimal letters can use either case. These names belong exclusively to projection, including hidden-row release and temporary writes. Non-unique text is unrestricted. Non-nullable unique UUID columns that are not foreign keys reserve UUID version 8 for generated alternatives (`xxxxxxxx-xxxx-8xxx-xxxx-xxxxxxxxxxxx`). Conflict, hidden-row, and temporary parking values use the same deterministic hash inputs as before, with the version marker set to 8. Authored version-8 values in these columns are rejected; ordinary UUID versions, including 4, 5, and 7, remain supported. Primary keys, foreign keys, nullable UUID columns, and non-unique UUID columns do not use synthetic UUID release and are not subject to this rule. Local writes and incoming sync facts enforce this rule with `OfflineSyncReservedValueException`. Rejected transactions do not advance authored data or sync progress. A full-row update/upsert can echo its own unchanged materialized alternative: it keeps the original authored claim and uses the normal field-touch semantics. Explicitly authoring an alternative on another record is rejected, including exchanges of displayed alternatives. This keeps the existing deterministic text suffix construction. The resolver does not search currently occupied values for a free alternative. The original claim is what gets exported, so ordinary conflict projection and bootstrap remain valid under the input rule. ## Why Flag Is The Current Default The `flag` policy avoids the hard second-order effects caused by hiding or merging rows: - no child row needs to be moved from one parent row id to another; - `ON DELETE RESTRICT` children do not accidentally keep a unique loser alive; - cascade chains are not triggered by unique conflict resolution; - non-key field updates on both rows remain attached to their original rows; - the algorithm stays local to the conflicting unique index and affected rows. This keeps merge behavior associative, commutative, and idempotent without requiring a combined unique/FK fixed-point projection. ## Deferred Policies The following policies are plausible future schema-level options, but they are not part of the current implementation because they require a substantially more complex projection algorithm. Both policies should be projections over the canonical `flag` result, not separate unique-resolution algorithms. The `flag` layer computes conflict components, winners, losers, and released loser values once from CRDT facts: ```text unique facts -> conflict components -> winner + losers + released loser values ``` Future policies then interpret that canonical result: ```text flag = show all conflict-component rows; losers show released unique values overwrite = use flag winner; alias/hide losers in the visible projection merge = overwrite projection + deterministic field folding into winner ``` `merge` and `overwrite` must use the original unique claims and conflict components from the `flag` projection. They must not infer conflicts from only the already-released materialized values. ### Overwrite `overwrite` would keep a deterministic winner visible and hide the losers. To be safe, this cannot simply tombstone the loser. A hidden loser may already be referenced by visible children, including children using `ON DELETE RESTRICT` or deeper mixed cascade/restrict chains. A safe overwrite policy therefore needs an internal row-alias projection: ```text loserRowId -> winnerRowId ``` Foreign key projection would then consume the alias map and materialize child FK values through the winner where legal. Without this aliasing step, overwrite can produce visible orphans or order-dependent outcomes. ### Merge `merge` would also pick a deterministic winner and alias loser rows to it, but would additionally fold selected loser fields into the winner. This needs explicit deterministic per-column policies, such as: - `winnerWins` - `latestFieldWinsAcrossMergedRows` - `preferNonNullLatest` - `ignore` These policies must be schema-defined, deterministic on every replica, and included in schema compatibility checks. Arbitrary app callbacks inside core merge would not be safe unless they are deterministic, versioned, and available identically on every replica. ## Why Merge And Overwrite Are Deferred `merge` and `overwrite` combine unique projection with foreign key projection. The resolver would need to compute, at minimum: - unique conflict groups; - winners and losers for each group; - row aliases from loser ids to winner ids; - hidden-row projection; - field projections for merged rows; - foreign key repairs over aliases; - nested cascade/restrict/set-null/set-default effects; - fixed-point recomputation when FK repairs touch unique-indexed values. That interaction can grow quickly with graph depth, shared children, cycles, and rows participating in multiple unique indexes. The implementation would need a full recomputation oracle and incremental-closure equivalence tests before it is safe enough for real data. For now, the `flag` policy gives a safer and smaller invariant: ```text unique projection changes values, not row identity or row visibility ``` The `flag` winner/loser policy therefore remains independent of FK action semantics even though the shared planner feeds FK-safe candidates into unique resolution. ## Invariants - No visible duplicate remains for a unique index after projection. - Unique conflict resolution is deterministic from merged CRDT facts. - Merge replay does not create additional metadata churn. - Splitting the same operations into different sync batches converges to the same visible result. - Unique projection does not emit app-visible CRDT conflict objects. - Current `flag` projection does not hide rows or retarget foreign keys. ## Test Cases ### Current Flag Policy 1. Given two replicas concurrently insert different rows with the same unique value, when they merge, then both rows remain visible, the deterministic winner keeps the original value, and the loser receives a deterministic conflict-free value. 2. Given an older insert conflicts with a newer update on another row, when they merge, then the row with the older unique-field claim keeps the unique value, regardless of the other row's insert time. 3. Given two rows concurrently update the same unique field to the same value, when they merge, then both rows remain visible and only the deterministic winner keeps the claimed value. 4. Given the same conflict arrives in one batch or several smaller batches, when all operations have merged, then the same row keeps the unique value and the same rows receive the same conflict-free values. 5. Given a conflicting unique value is a nullable unique column, when the loser can be made conflict-free by setting the materialized value to null, then the loser remains visible and the unique index remains valid. 6. Given a conflicting unique value is a UUID column that is not an FK column, when the loser is released, then the materialized UUID is deterministic from the table, column, original value, and loser row id. 7. Given a space-scoped composite unique index contains a stable non-releasable discriminator and a releasable text column, when a loser is released, then the discriminator remains unchanged and the text column receives the deterministic conflict suffix. ### Deferred Merge And Overwrite Policies 8. Given overwrite is enabled for a unique index, when a loser has visible restrict children, then FK projection retargets those children through the row alias or blocks the projection deterministically; no visible orphan is produced. 9. Given merge is enabled for a unique index, when loser rows contain newer non-key field values, then field folding follows documented per-column policies and every replica computes the same winner row. 10. Given a row participates in multiple unique conflict groups, when merge or overwrite projection runs, then row aliases are deterministic and do not form cycles. 11. Given FK repair changes a unique-indexed visible value, when combined unique and FK projection runs, then the fixed-point result matches a full recomputation oracle. These deferred tests should not be implemented until the row-alias and combined projection model exists. ## What These Tests Prove - Current implementation safety: the `flag` policy preserves uniqueness without row hiding or FK retargeting. - Convergence: same CRDT facts produce the same visible unique values. - Idempotence: repeated merges do not keep rewriting the same conflict. - Space control: future `merge` and `overwrite` behavior is documented but not treated as supported until the projection algorithm can handle second-order FK effects.