Skip to content

Merge Policy

Graft stores SQLite databases as file snapshots, but many applications care about table rows and business objects. The merge policy is the layer that connects those two views.

It answers three questions during a repository merge:

  • Which SQLite surfaces can be interpreted as logical row or schema changes?
  • Which internal SQLite changes are safe to rebuild or ignore?
  • Which application-specific keys should be treated as semantic identity?

If Graft cannot answer those questions confidently, it leaves a conflict artifact instead of guessing.

Repository merge starts with file-level snapshots. For a modified SQLite file, Graft can inspect the base, ours, and theirs snapshots and build a row-level merge plan.

The row-level plan can auto-merge non-conflicting changes when:

  • both sides modify supported rowid or declared-primary-key rows without touching the same logical row
  • compatible schema additions can be expressed as ALTER TABLE ... ADD COLUMN
  • configured internal SQLite changes can be resolved safely
  • the resulting temporary database passes validation

Other cases remain as conflicts for graft conflicts, graft resolve, or manual resolution.

graft diff --json --rows includes a coarse logical_status so applications do not have to infer row semantics from a file-level modified status.

StatusMeaning
logical_changesGraft found supported row, schema, or opaque changes.
unsupported_logical_surfaceThe file changed and the diff touched SQLite surfaces that Graft cannot fully interpret. Treat this as conservative.
file_changed_no_supported_logical_changesThe SQLite file changed, but supported logical rows and schema have no net change. This can happen after insert-then-delete, update-back, freelist, or page-layout changes.
row_diff_unavailableRow diff could not be produced for this file, usually because the file was added, deleted, or a required snapshot is missing.

Example:

{
"path": "data.sqlite",
"change": "modified",
"row_diff_available": true,
"logical_status": "file_changed_no_supported_logical_changes",
"capabilities": ["rowid_table_rows", "primary_key_table_rows", "schema_entries", "opaque_table_detection"],
"limitations": [],
"tables": []
}

The row-level engine reports both capabilities and limitations.

Current capabilities:

  • rowid_table_rows: row changes in ordinary rowid tables
  • primary_key_table_rows: row changes in WITHOUT ROWID tables identified by their declared, typed primary key
  • schema_entries: schema entries from sqlite_schema
  • opaque_table_detection: changed tables that should stay file-level
  • semantic_insert_keys: insert conflicts based on configured semantic keys

Current limitation kinds:

  • virtual_table
  • fts_shadow_table
  • sqlite_internal_table
  • index_btree
  • utf16_text_encoding
  • generated_columns

Limitations do not always mean the merge failed. They mean the result should be presented with the correct caveat: some changed SQLite surface was handled by a resolver, left opaque, or not interpreted as ordinary rows.

Merge policy lives in .graft/config.toml under [merge].

[merge]
same_row_merge = true
default_semantic_keys = ["external_id"]
[merge.semantic_keys]
records = ["external_id"]
[merge.semantic_key_collations.records]
external_id = "nocase"
[merge.internal_resolvers]
sqlite_sequence = "sequence_max"
sqlite_stat1 = "rebuild"
sqlite_stat4 = "rebuild"
index_btree = "reindex"
[merge.schema_resolvers]
add_column = "alter_table_add_column"
[merge.generated_columns]
legacy_records = ["search_text"]
[merge.column_resolvers.records]
updated_at = "max_timestamp"
search_text = "recompute"

Applications should generate this config from their own schema policy. Graft’s built-in defaults are intentionally conservative; application tables often need application-owned semantic keys.

Graft has safe defaults for common SQLite internal state.

SubjectResolverMeaning
sqlite_sequencesequence_maxKeep a sequence value that is high enough for both sides.
sqlite_stat1, sqlite_stat2, sqlite_stat3, sqlite_stat4rebuildTreat statistics as rebuildable query-planner state.
index_btreereindexTreat index B-trees as derivable from table rows and schema.

Only allowed resolver/subject pairs are accepted. Unknown subjects or invalid resolver names are ignored rather than granting unsafe merge behavior.

The current public schema resolver is:

OperationResolverMeaning
add_columnalter_table_add_columnMerge compatible column additions by applying ALTER TABLE ... ADD COLUMN to the other side.

Schema deletes, incompatible modifies, same-name different definitions, and unknown schema operations remain conflicts.

Schema conflict reasons include:

  • schema_delete_conflict
  • schema_modify_conflict
  • schema_same_name_conflict
  • schema_conflict

Column-level details can include add_column, drop_column, rename_column, and modify_column.

SQLite rowid is the physical row identity for ordinary rowid tables. WITHOUT ROWID tables instead use the declared primary key, including all parts of a composite key and their SQLite value types. Some applications also have stable business identifiers, such as _id, id, or key.

Semantic keys add an application-level conflict check. For example, two branches that insert different rowids with the same _id should often conflict even though the rowids do not collide.

[merge]
default_semantic_keys = ["_id"]
[merge.semantic_keys]
settings = ["key"]
messages = ["thread_id", "external_id"]

Table-specific keys override the default for that table. The default applies only to tables that contain all configured columns.

Row conflict reasons include:

  • row_conflict: both sides touched the same row identity incompatibly
  • cell_conflict: an explicitly enabled same-row merge found different values for the same column
  • semantic_key_conflict: insert/insert, insert/update, or update/update results have the same configured semantic identity

BINARY is the default semantic-key collation. A table/column may opt into SQLite’s built-in ASCII NOCASE behavior:

[merge.semantic_key_collations.records]
external_id = "nocase"

Conflict output includes both physical identities, the display semantic key, and the selected collation.

Same-row field merging is intentionally opt-in:

[merge]
same_row_merge = true

When enabled, disjoint changed columns combine, equal values for the same column collapse, and different values for the same column remain structured cell conflicts. Delete/update remains a conflict.

Managed columns use only Graft’s finite resolvers:

ResolverMeaning
ignore_for_conflictPrefer Ours only when both sides changed the managed value; a one-sided change is retained.
max, minSelect the ordered maximum or minimum supported SQLite value.
max_timestampSelect the maximum integer or normalized sortable text timestamp.
recomputeOmit the column from replay and require application-owned recomputation and validation.

recompute never runs application code. It returns a recompute_required validation artifact and a candidate containing the other merged fields. After recomputing, call stageMergeSqliteResult; Graft captures that exact database, runs integrity and foreign-key checks in private storage, then stages it.

Generated columns can be difficult to reconstruct from raw SQLite pages. Use [merge.generated_columns] when an application knows that specific columns should be treated as generated and omitted from row-apply SQL.

[merge.generated_columns]
my_table = ["search_text", "computed_total"]

This is an application schema policy. It should match the schema that the application actually creates. This legacy setting is normalized to the recompute managed-column resolver.

The Node.js SDK exposes getMergePolicy, validateMergePolicy, and setMergePolicy. Version 1 is a data-only ABI with finite resolvers. Every effective policy has a stable policy_token; setting requires expectedPolicyToken. Merge plans bind this token and version into plan_token.

Three-way apply freezes the effective policy in the active merge journal. getMergeStatus reports that actual token/version, and policy changes are rejected until the merge is completed or aborted and replanned. All async SDK policy calls accept AbortSignal.

Auto-merge does not edit the live database in place. Graft applies the planned SQL to a temporary database, imports the resulting snapshot, and stages that snapshot as the merge result.

During apply:

  • foreign keys are disabled while SQL is applied
  • triggers are disabled while SQL is applied
  • PRAGMA integrity_check must pass afterward
  • PRAGMA foreign_key_check must pass afterward

The apply policy appears in conflict analysis JSON:

{
"apply_policy": {
"foreign_keys": "disabled_during_apply_checked_after",
"triggers": "disabled_during_apply",
"validation": ["integrity_check", "foreign_key_check"],
"same_row_merge": true
}
}

graft --db <path> conflicts --json can include row merge analysis for conflicted database files.

Important fields:

FieldMeaning
availableWhether row-level analysis was available for this file.
can_auto_mergeWhether Graft can apply the plan automatically.
blocked_reasonsWhy auto-merge is blocked.
row_conflictsRowid, declared-primary-key, or semantic-key conflicts.
schema_conflictsSchema conflicts with column-level details.
opaque_changesUnsupported or opaque SQLite surfaces that remain unresolved.
resolved_opaque_change_detailsOpaque/internal changes resolved by policy.
limitationsSQLite surfaces that should be shown as caveats.
apply_policyThe SQL apply and validation policy used by the planner.

Common blocked_reasons:

  • row_conflicts
  • schema_conflicts
  • opaque_changes
  • no_applicable_changes
  • add_delete_conflict
  • analysis_error

Use graft diff --json --rows when building UI around normal diffs. Use graft --db <path> conflicts --json when building merge UI.

Treat these states differently:

  • file changed plus logical_changes: show table/schema changes
  • file changed plus file_changed_no_supported_logical_changes: show this as file-only or logical no-op
  • file changed plus unsupported_logical_surface: show the limitation and keep a conservative path
  • conflicts with blocked_reasons: tell the user why auto-merge was blocked

That separation is the point of the policy layer: Graft stays general-purpose, while applications can supply the schema and identity rules that only they know.