Every Bedrock repo stores its schema, taxonomies, and records in a Git-like directed acyclic graph (DAG) of commits. Writes never mutate committed history in place. Instead, edits land on a branch’s working state, a commit snapshots that working state into the DAG, and branches merge into each other through change requests.
Branches and working state
Every created repo starts with a default branch named main. Each branch has two layers:
- Head commit: The most recent immutable commit snapshot on that branch (
head_commit_id). - Working state: Uncommitted creates, updates, and deletes layered on top of the branch’s head commit.
Working state belongs to the branch, not to an individual caller’s session. Any member who reads a branch sees all uncommitted writes on that branch, and committing the branch snapshots every uncommitted change together.
Reads across the API and CLI accept either a branch name or a commit ID, never both:
- Passing a branch reads the live working state layered over that branch’s head commit.
- Passing a commit ID reads the immutable snapshot recorded when that commit was created.
- Writes always target a branch.
# Inspect main's head commit
bedrock branches get-branch --repo "$REPO" --branch main
# Create a branch from main's branch ID
bedrock branches create-branch --repo "$REPO" \
--data '{"name":"q4-budget","base_branch_id":"branch_01j..."}'When creating a branch through POST /repos/:repo_id/branches, supply either base_branch_id to branch from a branch’s head commit, or base_commit_id to branch from a specific historical commit. Supplying both keys fails validation.
You can delete a branch with DELETE /repos/:repo_id/branches/:branch (except main, which refuses deletion with ProtectedBranch, or a branch with an open change request, which fails with OpenChangeRequestExists) and restore a deleted branch with POST /repos/:repo_id/branches/:branch/restore.
Commits and working-state resets
After making schema or record edits on a branch, create a commit to record those changes permanently in the commit DAG:
bedrock commits create-commit --repo "$REPO" --branch q4-budget \
--data '{"message":"Add Q4 cloud and software line items"}'A commit records the calling user as the author, the timestamp, the parent commit ID, the commit message, and an optional co_authors array of objects such as [{"id":"user_..."}]. Listing the author or any co-author twice fails with DuplicateAuthor.
When committing or resetting a branch, the engine enforces these rules:
- No empty commits: If the branch has no uncommitted changes relative to its head commit,
createRepoCommitfails withEmptyCommit. - Whole-state validation: Before writing the commit, the engine validates the proposed state across all tables. If a required custom field is missing, a unique custom field has duplicates, or a formula fails, the commit fails with
CommitRefusedand lists every violation. - Working-state reset: To throw away all uncommitted edits on a branch and reset it to its head commit, call
DELETE /repos/:repo_id/branches/:branch/working-stateor runbedrock commits discard-working-state --repo "$REPO" --branch q4-budget.
Checkpoints
A checkpoint gives a commit a name, as a Git tag does: the budget you sent the client, the state you closed the quarter on, or a bid set. Checkpoints aren’t tags in Bedrock’s sense. Tag families and tags classify records, and they live on branches and merge like any record. A checkpoint marks history and is the same on every branch.
# Name main's head commit
bedrock checkpoints create-checkpoint --repo "$REPO" \
--data '{"name":"Q3 close","branch":"main","description":"Books closed for Q3"}'
# Name a commit from history
bedrock checkpoints create-checkpoint --repo "$REPO" \
--data '{"name":"Bid set","commit_id":"commit_01j..."}'
bedrock checkpoints list-checkpoints --repo "$REPO"POST /repos/:repo_id/checkpoints takes either branch, whose head commit it names, or commit_id, never both. A checkpoint names a commit only, so uncommitted changes on the branch aren’t part of it.
- It never moves. A checkpoint keeps its commit while the branch moves on. To mark a later commit, create another checkpoint.
- A name belongs to one live checkpoint. Creating a checkpoint with a name that a live checkpoint holds is refused with
CheckpointExists. - Deleting frees the name.
DELETE /repos/:repo_id/checkpoints/:checkpointremoves the checkpoint from the list, and the commit stays readable. A new checkpoint can then take the name, so a link or note that quoted the name reads the new commit.
To read data at a checkpoint or compare against it, pass its commit_id wherever a read takes a commit ID, for example --base-commit in bedrock diffs get-diff.
Diffs
You can compare any two revisions in a repo using GET /repos/:repo_id/diffs (bedrock diffs get-diff). Both the base and head sides of a diff can name either a branch (including its uncommitted working state) or a commit ID:
# Check if main has uncommitted changes relative to its head commit
HEAD=$(bedrock branches get-branch --repo "$REPO" --branch main | jq -r .head_commit_id)
bedrock diffs get-diff --repo "$REPO" --base-commit "$HEAD" --head-branch main
# Compare two branches filtered to a single custom model
bedrock diffs get-diff --repo "$REPO" \
--base-branch main \
--head-branch q4-budget \
--model line_itemThe optional model query parameter filters the diff to a single custom model by its api_name, or to one of Bedrock’s own models, such as tags, tag_families, or custom_fields. A diff comes a page at a time, in record_id order across models: each response’s data holds at most limit changes (100 by default), and its next_cursor passed back as cursor, with the same base, head, and model, reads the next page, until next_cursor is null. A branch side reads its working state as of each page’s request, so to read a diff that holds still across pages, diff two commits. Each change names its model and record_id, the change (added, modified, or removed), the record before and after (previous and current), and the changed_fields that differ. Each side reads as the API returns the record, without its ID; a custom model’s record is its field values keyed by field API name, as in a record’s fields. An extension file’s side carries its size in UTF-8 bytes in place of its content, which changed_fields still names when it changed; read the content at either commit.
Merge checks and three-way merges
Before merging a source branch into a base branch, neither branch may have uncommitted working state (otherwise the operation fails with DirtyBranch), and the source branch must contain commits not yet reachable from the base branch (otherwise it fails with NothingToMerge).
To preview what a merge between two branches entails without mutating either branch, run a merge check:
bedrock branches check-branch-merge --repo "$REPO" --branch main \
--data '{"source_branch":"q4-budget"}'The engine finds the lowest common ancestor commit of the two branches and computes a field-level three-way merge. If one branch edits a custom field on a record and the other branch edits a different custom field on the same record, both edits merge without a conflict.
An extension’s text files also merge by line. If both branches edit the same file but change different lines, both edits merge without a conflict.
The merge check answers ready, whether the merge can go ahead as it stands, along with fast_forward, the conflicts that need a decision, and the schema violations that the merged state holds. In a conflict, ours is the base branch, which receives the merge, and theirs is the source branch. Each conflict has an id and a kind:
kind |
What happened | Resolution actions |
|---|---|---|
column |
Both branches changed the same field of the same record to different values. For an extension’s text file, both branches changed the same or adjacent lines, one side made over 1,000 line edits, or the merged file would be too large. Taking a side takes that side’s whole file, and each side’s text is in values, not records. |
take_ours, take_theirs, or set with a value |
edit_delete |
One branch edited a record that the other branch deleted. | take_ours or take_theirs |
duplicate |
Two records share a value of a unique key after the merge. | take_ours, take_theirs, or keep_both |
custom_field_pair |
Both branches added a custom field with the same API name to the same custom model, or one branch renamed a custom field onto an API name the other branch added. When you drop an added custom field, its values move into the kept custom field, cast to its type as a change of type casts them. A value with no cast, such as cents into a percent, refuses the decision and names its records. If the branch that added it also wrote the kept custom field on a record, the decision is refused, naming the records, because the kept custom field holds only one of the two values. When you drop a rename, the renamed custom field gets its name back and keeps its values. | take_ours or take_theirs |
removed_custom_field_write |
One branch removed a custom field that the other branch wrote values into. | take_ours or take_theirs |
deleted_reference |
A record references a record that one branch deleted, such as a tag added under a parent that the other branch deleted. | take_ours or take_theirs |
parent_cycle |
Parent changes that are valid on each branch form a cycle when combined. | take_ours or take_theirs |
installation |
One branch uninstalled an extension that the other branch updated. See Merging across an update. | take_ours or take_theirs |
Conflicts and violations name models and fields as diffs do: a custom model or field by its api_name, and one of Bedrock’s own by its name in the API, such as tags and parent_id. A conflict’s records read as the API returns them. To resolve a field conflict with a value of your own, a set resolution’s value takes the field’s JSON form, as a record write sends it.
Change requests
Branches merge only through change requests. Opening a change request proposes merging branch (the source branch) into base_branch (the target branch):
# Open a change request
bedrock change-requests create-change-request --repo "$REPO" \
--data '{"branch":"q4-budget","base_branch":"main","title":"Add Q4 infrastructure budget","description":"Includes compute and storage line items."}'
# Add a review comment
bedrock change-requests create-change-request-comment --repo "$REPO" --number 1 \
--data '{"body":"Verified unit costs against vendor quotes."}'
# Merge the change request
bedrock change-requests merge-change-request --repo "$REPO" --number 1 --data '{}'Each change request receives a sequential integer number within the repo and moves through three statuses: open, closed, and merged. Its details count the records its changes add, modify, and remove in change_counts; to read the changes, diff its base_commit_id and head_commit_id. changed_extensions names the extensions at its head whose definition or files those changes touch. A closed change request can be reopened with POST /repos/:repo_id/change-requests/:number/reopen as long as its source and base branches still exist and no other open change request uses that source branch.
When you call mergeRepoChangeRequest, the engine checks these gates in order before it advances base_branch:
- CI merge gate: Every enabled
requiredCI check must have passed on the source branch’s head commit. If a required check is still running, reported findings, or errored, the merge fails withCIGateFailed. See CI checks. - Extension publication gate: Every extension version at the source branch’s head commit must have published. An extension the source branch never changed is judged by the target branch’s head instead. Otherwise the merge fails with
ExtensionPublicationGateFailed. See Extension authoring overview. - Conflict resolution and state validation: If the three-way merge produces conflicts, supply a resolution for each one in the
resolutionsarray of the merge payload. A resolution names the conflict’sconflict_idandkindand anaction, as in{ "conflict_id": "...", "kind": "column", "action": "take_theirs" }. If any conflict remains unresolved or the merged state violates a schema constraint, the merge fails withMergeRefusedand returns the full merge check, so you can inspect what remains.
When base_branch hasn’t moved since the source branch left it, the merge is a fast-forward: the base branch moves to the source branch’s head and no merge commit is written. Otherwise the engine writes a merge commit. Either way, the merge deletes the source branch unless the source branch is main.