Skip to main content

Branching (agents propose, humans merge)

A branch is a writable, zero-copy clone of a DuckLake database at its current head. Agents and pipelines write on the branch, a human reviews the change set and the row-level diff, then merges it into main in one stamped, tagged snapshot. Reads and writes on a branch never touch the live database, and a branch-only token makes writing main impossible for the agent that holds it.

Branching applies to ducklake databases only, and a branch cannot itself be branched.

How a branch is built

Creating a branch clones the database's DuckLake catalog at the Postgres level: every ducklake_* metadata table is copied into a new database under one repeatable-read transaction, so the copy is a consistent snapshot even while the parent is being written. No Parquet is copied. The clone's file references are rewritten to the parent's absolute locations, and its data path points at a sibling prefix, <parent prefix>__br_<id>/, so new files written on the branch land there and nowhere else.

The clone is registered as a tenant-db of its own (<parent>__br_<id>), served by one pool (__br_<id>, a single dual node) that attaches the catalog under the parent's catalog name. That is what makes a branch transparent to clients: the same SQL, grants, row and column policies, and unqualified table names apply on the branch exactly as they do on main. The branch pool inherits the parent's pool sizing, lockdown and idle-hibernation settings, so an idle branch scales to zero and wakes on its next statement.

The fork snapshot is recorded per branch and pinned: managed maintenance on the parent never expires it, so the files the branch reads in place stay put. Branch catalogs are never maintained themselves, and a database with live branches cannot be deleted.

Lifecycle

StepWhoWhat happens
createanyone who can connect to a pool of the databaseClone at head, start the branch pool, start the TTL
writethe same principal, targeting the branchOrdinary SQL through FlightSQL or MCP; ACL, RLS and CLS apply
changes and diffanyone with accessTouched tables classified as created, dropped, recreated, modified or altered, with row counts; conflicts against main; row-level diff per table
proposethe branch owner or any principal with accessRecords a merge request carrying the change set and conflicts as of now
mergea tenant admin other than the proposerFast-forward onto main, one snapshot, tagged, branch torn down
discardthe owner or a tenant adminPool, catalog and branch-only files freed; the row stays as history
expirythe managerLive branches past their TTL are discarded by a leader-gated sweep

Branch names are lowercase: a letter first, then letters, digits, _ or -, at most 48 characters. A name is free again once the branch is merged, discarded or expired.

Targeting a branch

  • FlightSQL and ADBC clients add a branch=<name> connection header next to tenant and pool (the Arrow Flight JDBC URL form is ?tenant=acme&pool=bi&branch=feature-x). Authorization runs against the named parent pool; only the routing target is swapped to the branch's pool.
  • qod sql --branch feature-x ... sends the same header.
  • MCP tools run_sql, list_tables and describe_table take an optional branch argument; database stays the parent name.

Merge semantics (fast-forward only)

The change set is recomputed at merge time. A merge is refused when:

  • main touched any table the branch touched since the fork, or dropped a schema holding one (409 merge_conflict, listing the tables);
  • the branch altered a table's columns, or touched views or macros (422 merge_unsupported; v1 merges created, dropped, recreated and data-modified tables);
  • the caller is the proposer (403 self_merge_forbidden);
  • expectedMainSnapshot was given and main moved past it (409 concurrent_write).

Otherwise the manager spawns a short-lived merge node with the branch attached read-only next to main and runs one transaction: created tables are copied with CREATE TABLE AS, dropped tables dropped, and for modified tables the rows the branch deleted or updated are removed by row id and the current branch rows for every inserted or updated row id are inserted. Row ids are stable across the clone and preserved by DuckLake on update, which is what makes the replay exact for every interleaving. The commit is stamped with the approver as author and a message naming the branch, the merge id and the proposer; the resulting snapshot is tagged merge-<branch>-<id>.

A merge that loses a DuckLake commit race to a concurrent writer answers 409 concurrent_write, marks the merge request failed and reopens the branch: propose again to retry.

Keeping agents off main

Mint the agent's personal access token with --branch-only:

qod auth pat create --name agent --branch-only --database acme_tpch

INSERT, UPDATE, DELETE and DDL on the live database are then refused with write_requires_branch; reads are unchanged, writes on any branch work, and every child token inherits the flag. Combined with the absence of a merge tool on MCP, the agent can only ever propose. This gate applies to MCP and REST callers; the raw FlightSQL wire has no token concept.

From the CLI

qod branch create  --tenant acme --db acme_tpch --name feature-x --ttl-hours 24
qod sql --tenant acme --pool bi --branch feature-x "UPDATE nation SET n_comment = 'reviewed' WHERE n_nationkey = 3"
qod branch changes --tenant acme --db acme_tpch --branch feature-x
qod branch diff --tenant acme --db acme_tpch --branch feature-x --schema main --table nation
qod branch propose --tenant acme --db acme_tpch --branch feature-x
qod --profile reviewer branch merge --tenant acme --db acme_tpch --branch feature-x
qod branch list --tenant acme --db acme_tpch --all
qod branch discard --tenant acme --db acme_tpch --branch feature-x

qod branch show returns a branch with its merge history; schema-diff gives the column-level view of one table. The same operations are on the admin console (tenant page, Branches tab, see the admin UI guide) and on REST under /api/branch/*.

Configuration

KeyDefaultEnvMeaning
quack-on-demand.branching.enabledtrueQOD_BRANCH_ENABLEDKill switch for every branch endpoint, tool and the expiry sweep
quack-on-demand.branching.defaultTtlHours168QOD_BRANCH_DEFAULT_TTL_HOURSBranch lifetime when the creator sets none; 0 never expires
quack-on-demand.branching.sweepSec300QOD_BRANCH_SWEEP_SECExpiry sweep interval (60s floor)
quack-on-demand.branching.maxPerDatabase20QOD_BRANCH_MAX_PER_DATABASELive branches per database
quack-on-demand.branching.mergeTimeoutSec600QOD_BRANCH_MERGE_TIMEOUT_SECBounded wait for the merge transaction; a late commit is still detected and recorded
quack-on-demand.branching.nodeReadyTimeoutSec120QOD_BRANCH_NODE_READY_TIMEOUT_SECWait for the ephemeral merge node to accept connections

Limits in v1

  • Branches fork at the parent's current head; fromSnapshot on create is reserved and refused otherwise.
  • No branch of a branch, no three-way merge or rebase, no cross-branch queries in one statement.
  • Column changes, views and macros on a branch do not merge; apply that DDL on main and use a fresh branch for the data.
  • The diff is paginated JSON, not an Arrow stream.
  • Audit records every create, propose, merge, discard and expiry (branch.* actions); statement history shows branch statements under the branch's pool name.