Skip to content

rfc: schema apply with live branches - #963

Open
ragnorc wants to merge 11 commits into
ModernRelay:mainfrom
ragnorc:claude/schema-migrations-active-branches-fb6101
Open

ragnorc wants to merge 11 commits into
ModernRelay:mainfrom
ragnorc:claude/schema-migrations-active-branches-fb6101

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

What & why

Adds a draft RFC that allows schema apply on main while other branches are live. Today apply refuses unless main is the only branch, so every long-lived branch blocks every migration.

Under the proposal:

  • Each branch keeps its schema contract until a merge brings a newer one into it. Schema apply stays main-only.
  • Every contract carries a stored schema sequence, incremented by each contract write on main and carried by merges. It orders contracts. Content identity cannot, because a reversed change, such as a rename and back, reproduces an earlier identity.
  • This is the lower-liability rule. A merge across contracts takes the newer side's version of every table whose stored columns or full-text declarations differ, and refuses with schema_diverged when the older side changed such a table since the merge base. Schema apply refuses when a live branch changed a table it would change.
  • "Changed" means changed rows: index maintenance and compaction do not count.
  • Rows are compared only between versions of the same shape, so no version is prepared, converted or rewritten. Checks, dry runs and previews stay reads, and catch-up keeps the merge's pointer adoption.
  • This is a storage format change: stamp 17, one field in the schema_contract row, written by each branch's first publication. It fences older binaries on every scan and publication.
  • Future work lists three relaxations, each of which only turns refusals into merges and needs no format change: upgrading the older side's version, read-time mapping, and drops that touch no changed value.

Backing issue / RFC

Checklist

  • Change is focused (one logical change)
  • Tests added/updated for behavior changes (N/A: RFC only; required tests are listed in the RFC)
  • Public docs updated if user-facing surface changed (N/A: no behavior change)
  • Reviewed against docs/dev/invariants.md: the RFC's Invariants section covers each affected invariant and the one support-boundary sentence it changes

Local verification

  • python3 scripts/check-docs.py (docs venv) — Documentation OK (333 Markdown files checked)
  • bash scripts/check-agents-md.sh — OK
  • typos on the changed files — clean
  • Prototype (not committed): with only the main-only and equal-contract refusals lifted, the engine experiment passed 5 of 5 and the catalog merge-base test passed 1 of 1. The results are in the RFC's Evidence section.

Notes for reviewers

Draft RFC: allow schema apply on main while other branches are live.
Branches keep their schema contract until a merge brings main's in; a
merge across contracts is allowed only when every migrated table is
unchanged since the merge base on the older side, and schema apply
refuses when a live branch already changed a table it would migrate.
…ons-active-branches-fb6101

# Conflicts:
#	docs/rfcs/README.md
Order contracts by a stored schema sequence instead of content identity,
which a reversed change reproduces. Judge each table by its stored
signature across the base and both sides, so an older criss-cross base
cannot hide a write to a re-created property. Count full-text @index
declarations as table effects. Fence older binaries with storage stamp
17, budget the apply-time branch check, and align with the open
historical-read, change-feed, polymorphic-types, actor-views and
branch-operations proposals.

@ragnorc ragnorc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation: approve this draft RFC. I found no blocking defect in the proposed design. Keep the stated cost gate and implementation proof requirements. This approval does not qualify an implementation for release.

The PR addresses a practical restriction: one live branch currently blocks every schema apply. The proposal lets main evolve while each branch keeps its own schema. A sequence number orders schema changes, including a rename followed by its reversal. Merges compare stable table and property identities across the base and both sides. They adopt a migrated table whole when safe and refuse unsupported combinations. This PR changes documentation only.

Correctness and design. The proposal addresses the contract at its owning boundaries. Schema apply owns main's evolution. Merge owns the compatibility decision. The manifest publisher owns the atomic contract, table pins and lineage update. Removing only the existing refusals would leave mismatched contracts and pins. The proposed design closes that class of failure without a separate schema synchronization operation.

The base signature matters as much as the two endpoint signatures. A property dropped and recreated with the same name has a different identity. Comparing values by name could hide a write even when both endpoints use the same schema. The sequence also solves a different problem: equal schema content does not imply the same occurrence in history. Keeping it beside the content hash preserves the existing archive and drift checks.

I checked the physical assumptions against the pinned Lance 11.0.0 implementation, not just current upstream prose. OmniGraph's schema staging uses Project and Merge over unchanged fragments. Lance's detached commit path writes a separate manifest. Its FTS fallback confirms why analyzer declarations must travel with the adopted pin. The current public open path checks main's format before reading branch state. Already-open older processes remain the operator obligation stated in the RFC.

Tradeoffs and liability. This adds real permanent cost: a stored sequence, a format fence, several accepted catalogs, branch-aware schema reads, and more merge states. The benefit is narrower refusal and safe schema evolution without rewriting every branch. Reusing the existing publisher, authority checks, retained history and whole-version adoption keeps that cost contained. Five more migration kinds can use the same signature and validation rules. They should not each add a synchronization API, a side record or a special merge path.

The first version is deliberately conservative. Even nullable additions can block when a branch changed that table. An index rebuild also changes its registration without changing rows. I left one optional inline comment to make that consequence explicit in the operator contract and test matrix. Retained branch creation versions also delay removal of dropped values. These are practical costs for long-lived review and agent branches.

The apply check adds work proportional to branch inventory and retained-history structures. I did not measure its requests, bytes, memory or latency. The 1/16/256-branch instrument and measured work budget must remain prerequisites. Retries must consume the same bounded work allowance. The local schema gate does not fence another process, so merge refusal remains necessary after a later branch write.

Alignment. I surveyed all open PRs. The material dependencies are #959, #964, #951, #884 and #677. The RFC correctly separates current-contract query typing from archived-contract change decoding. Both the catalog and live coverage view in #959 must follow the writing branch. Access restrictions in #951 must apply across branches or refuse. Validation-tightening changes in #884 must enter the compatibility rule, and restore must not lower the sequence. The branch authority work in #825 must retain coherent per-branch captures.

I also checked Lance 13.0.0, including its merge-columns field-ID and nested-schema repair changes. They do not remove the need for OmniGraph's stable identities or atomic graph publication. The upstream schema contract still warns that independently changed branches can assign the same field ID. Whole-pin adoption avoids a new fragment-grafting dependency. I did not run OmniGraph against Lance 13.

Validation and limits. Reviewed exact head f3885a4606871cead0532d436d4c079cfd420d77 in a clean isolated checkout. The comparison base was f53bf4fb756899b58df37f6047f42e01ba0c41ce. Only the RFC and its registry row differ.

Local checks passed:

  • python scripts/check-docs.py, using the existing documentation environment: 257 Markdown files.
  • bash scripts/check-agents-md.sh: 54 links and 50 documents.
  • typos docs/rfcs/2026-10-12-schema-apply-with-live-branches.md docs/rfcs/README.md.
  • git diff --check f53bf4fb756899b58df37f6047f42e01ba0c41ce...HEAD.

I also ran this focused check with Rust 1.97.1 and the existing compatible build cache:

RUSTUP_TOOLCHAIN=1.97.1 CARGO_TARGET_DIR=/tmp/review-928/target \
RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable' \
RUSTC_WRAPPER= RUST_MIN_STACK=16777216 \
cargo test --locked -p omnigraph-engine --test integration -- \
  maintenance::full_text_rebuild_on_a_branch_stages_on_the_inherited_location_without_changing_main \
  maintenance::full_text_rebuild_replaces \
  changes::history_reads_are_the_same_after_a_merged_branch_is_deleted_and_recreated

Two tests passed, with none failed or ignored. The branch rebuild test confirms a changed pin and preserved branch rows. The history test confirms that history remains available after branch deletion, while full snapshot access refuses the retired incarnation. The full_text_rebuild_replaces filter selected no test without the failpoints feature and is not counted as evidence.

The exact-head CI Gate passed. Test Workspace, the pinned DST suite and cloud integration jobs were skipped. The ordinary GQT job reported success through its no-input-change skip path. That is not a GQT test pass.

There is no implemented schema-with-branches fix in this PR, so no behavioral before-fail/after-pass proof exists here. I did not reproduce the author's temporary prototype or ordering model. The required matrix must prove the implemented behavior later. GQT currently lacks the schema-apply step, so the existing Rust schema/merge owners are appropriate until that operation is expressible. I made no source or test edits and left the checkout clean.

Comment on lines +259 to +261
"Differs from the base" uses the merge's existing `same_manifest_state`
comparison by stable identity: path, published version, native ref, row count
and version metadata. The registration clock and alias are ignored.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: document maintenance-only refusals and add them to the proposed test matrix. This comparison also treats an index rebuild as a changed table. For example, create an untouched branch with an indexed Person table, then rebuild its full-text index. The existing writer publishes a new registration even though the rows do not change. The apply-time rule would then refuse a nullable property addition on main. If the rebuild happens after that apply, merge case 4 would refuse catch-up instead. The existing branch rebuild test confirms the changed pin. Please name this consequence in the user behavior and add before/after-apply rebuild cases to the test plan. It follows the draft's conservative whole-version rule, so I do not treat it as a blocking defect. Any future relaxation should prove logical equality generally, with registration equality as the cheap path, rather than add an index-specific exception.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered by the row-based definition of "changed". An index rebuild on the branch, before or after the apply, no longer changes the outcome, and the schema_apply.rs row tests both orders.

Comment on lines +352 to +354
buffered, else from one history record read. The merge capture path and
`pinned_graph_commit` are not reused: the first refuses differing contracts
and replaces a one-entry authority cache per branch, and the second opens a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: physical index maintenance decides whether apply and merge refuse. This is the case the deny-list names, but the Invariants section says "No deny-list shape is added."

"Changed the table" here (and in merge case 3/4 via same_manifest_state) means the registration differs: path, published version, native ref, row count, version metadata. Two existing branch-scoped operations change exactly that without changing any row:

  • ensure_indices_on(branch) publishes a new pin per indexed table (table_ops.rs#L456-L498: commit_staged_detached → published_dataset_version = expected + 1 → commit_updates_on_branch_with_expected).
  • rebuild_full_text_indices_on(branch) does the same (the case the existing thread at r4251845178 raises).

So whether schema apply succeeds on main, and whether a later catch-up merges or refuses schema_diverged, depends on whether someone built an index on the branch. Invariant 7 says "Missing coverage may change cost but not correctness". The deny-list rejects "a logical precondition based on physical index coverage … or staged layout". Today same_manifest_state is only a fast-path selector in merge (it changes cost, not outcome); this RFC promotes it to an admission predicate.

The refusal fails closed, so it isn't a correctness bug. But I'd treat it as blocking acceptance rather than optional: the RFC either needs a logical "changed" predicate or an explicit argued exception under invariant 7. A cheap, sound option already exists. Treat a branch registration as unchanged when its fragments' resolved data files, overlays and deletion files equal the base's. That machinery is already in merge (fragment_data_identical / resolved_data_file_key, merge.rs#L1896-L1990). Index-only commits keep every data and deletion file, optimize is main-only, and a mismatch still falls back to "changed", so the predicate stays conservative. Case 3's "take that side's version whole" also needs a rule for an index-only side: take the side with the newer contract.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. "Changed" now means changed rows (Logical change): equal registrations first, then lineage discovery by file identity, which index-only commits keep, then a row comparison. Index rebuilds and compaction change cost, not the answer. The one result physical state can still reach is the apply check's budget refusal (cannot prove), stated under invariants 7 and 11. Head 9a3f6213.

Comment on lines +157 to +163
does not release its creation version.

### Older binaries

The first publication by this release stamps a branch's `__manifest` 17.
Binaries that serve stamps up to 16 refuse a graph whose main is stamped 17,
before any read. `omnigraph upgrade` stamps every live branch at once for

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: the fence is stronger than this paragraph says, and the stamp is applied earlier than soundness requires.

(a) It isn't only checked at open. guard_row_layout → refuse_if_stamp_unsupported runs on every manifest scan (state.rs#L1095). It also runs in overwrite_placed before every publish (commit.rs#L108) and on the publisher's retry reload (lib.rs#L1591). An older process that already has the graph open therefore fails closed at its next load or CAS of a stamped __manifest. It can't write a sequence-less contract row over main, and it can't merge into or out of main.

What stays unfenced is narrower: an older process working only on a lagging branch that is still stamped 16. Working under that branch's own contract is what this RFC allows anyway. Please name the exact residual risk, or drop the operator obligation if none remains, and add the in-flight older-writer case to the crossversion_upgrade.rs row.

(b) Consider stamping at the first contract write instead of the first publication. Soundness (L190-194) only needs main to be stamped by the first sequence-bearing contract write. As written, any write by the new binary stamps main 17 and forecloses rollback, even for operators who never apply while branches are live. 15 and 16 do set the precedent, but this fence guards against a hazard that exists only once contracts can diverge. If you keep first-publication stamping, record the narrower option under Alternatives with the reason it was rejected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. Older binaries now states that the stamp is checked on every scan and before every publication, and names the residual case: an older process on a branch still at stamp 16, under that branch's own contract. The operator obligation is gone. Stamping at the first contract write is under Alternatives, rejected because the writer would need two row layouts. The crossversion row covers an older process that already has the graph open.

Comment on lines +196 to +199
branch held the same contract. The first apply under this release publishes on
main and stamps main 17 in that publication, so no contract row with a
sequence can exist beside a pre-17 contract that differs from it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: say whether the sequence enters SchemaContractDigest, which the cluster ledger persists. This list names same_graph_state, the authority token and the catalog cache key. It doesn't name SchemaContractDigest (prepared.rs#L6-L14). That type is #[serde(deny_unknown_fields)] and is stored durably in ledger v2's applied_revision.schema_contracts (execution.rs#L402). It is also what prepared-apply reconcile and settlement compare.

Adding a field would be a ledger protocol change. A binary without the field would refuse a ledger written by one with it, and that belongs under "Compatibility and reversibility". Keeping the digest content-only looks sufficient, because a prepared apply already binds base_manifest_version, the branch identifier and the graph head, so a reversed change (X→Y→X) can't satisfy a stale preparation. Whichever you choose, state it in the RFC and add a row for it to the cluster test plan.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. SchemaContractDigest stays content-only, so the ledger format does not change. The reason is under Schema sequence: a prepared apply already binds the base version, branch identifier and head. A cluster test row checks that a sequence increment leaves the ledger digest unchanged.

Comment on lines +190 to +194
- Every other publication carries the branch's contract row unchanged, as
today.
- A contract row written before stamp 17 has no sequence and reads as 0.

Sequence 0 is sound for every pre-17 row. Before this release, schema apply

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 (wording): "no contract row with a sequence can exist beside a pre-17 contract that differs from it" is false as written. Right after the first apply, main holds sequence 1 / X1 while a lagging stamp-16 branch holds a sequence-less X0. I think the claim you mean is: no two contract rows that both read sequence 0 differ. That follows because every pre-17 contract write required only main, so all live branches shared one contract until the first stamped write. That's also the property the "equal sequences must be byte-identical" check at L183-185 relies on.

It also helps to note that the base may be a sequence-0 commit whose content differs from a sequence-0 side; for example, main history from before an earlier only-main apply. That's fine, because identity comparison includes content, so it takes the classification path rather than the integrity error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. Schema sequence now says no two live branches' contract rows that both read 0 differ. A sequence-0 base with other content is expected, because the base is read by content, never by sequence.

Comment on lines +111 to +113
A read by snapshot or commit id uses the current contract of the branch that
wrote that commit, with tables rebound by stable identity as historical reads
already do.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: "the current contract of the branch that wrote that commit" is undefined when that branch no longer exists. The same rule appears at L373-381 for snapshot reads, the change-feed baseline and #959's binding.

Today point-in-time reads bind the handle's live contract and rebind tables by identity (omnigraph.rs#L2314-L2348). That is total: the handle always has a contract. The new rule is not total:

  • Writing branch deleted. Its commits stay addressable by id: merged into main, or retained as merge bases or tags. With no current contract to bind, what does the read do?
  • Writing branch deleted and recreated under the same name. graph_branch is a logical name. Without an incarnation check, the read binds the new lifetime's contract. Invariant 6 says never infer identity from a branch name. The test table lists "branch delete and recreate during snapshot binding", but the user-behavior section doesn't say what the outcome is.
  • Unpublished as commit proposals and unattached snapshots. These carry graph_branch in their candidate property. State whether they follow the same rule.

Please make this total. One option: bind the writing branch's contract only while the incarnation that wrote the commit is live, and otherwise fall back to main's current contract (today's behavior), naming the fallback in the user section. Another: bind the commit's own archived contract (schema_content_hash plus the lineage row's sequence). Either way, add the deleted-branch case to the point_in_time.rs / changes.rs rows.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. Reads by snapshot or commit id keep main's current contract with stable-identity binding (#959). That rule is total: deleted or recreated writing branches, unpublished commits and unattached snapshots all have an answer, and no identity comes from a branch name (Reads and maintenance; Alternatives). Only a live branch's change-feed baseline binds that branch's contract, and only while its captured incarnation is live.

Comment on lines +82 to +86
`omnigraph schema apply` on main succeeds while other branches exist, unless a
live branch changed, since its merge base with main, a table the apply
changes. The refusal names every such branch and table and gives two remedies:
merge the branch into main first, or delete it. Schema preview and plan report
the same list without refusing. A preview's list is advisory, including a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 (scope): state plainly that the most common migration on an active graph stays blocked. Motivation says long-lived agent and review branches block every schema change. Under this rule, adding a nullable property to a type any live branch has written still refuses: here at apply, and as schema_diverged at merge if the branch writes later. Agent branches typically write exactly the hot types.

So the RFC unblocks migrations on tables that no live branch has touched, plus type renames, enum widening, non-full-text @index and metadata edits. Future work's nullable-add conversion is what unblocks the motivating case. Please say this in the Summary and Motivation, so nobody reads the RFC as "migrations no longer wait for branches". Also consider whether nullable-add projection is cheap enough to bring into scope: it only needs null fill by stable property id inside the existing three-way cursors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed, with a narrower scope than this comment suggested. On 10-17 we chose the lower-liability rule. A migration still waits for a branch that changed a table it touches, and a branch that writes such a table after the migration cannot merge until its work is redone. Summary and Motivation now say so. Nullable-add relief is Future work relaxations 1 and 2. Each only turns a refusal into a merge and needs no format change.

Comment on lines +103 to +105
and `GET /schema?branch=<name>` show it and require read access to that
branch. `branch list` and `branch show` report each branch's contract identity,
its schema sequence, and whether it is behind main's.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 (cost): branch list reads active refs today. Reporting each branch's contract identity, sequence and behind-main flag adds a __manifest open and a contract-row read per branch, which makes it O(branches × manifest open). Under invariant 11, either put these fields on branch show only (or behind a flag), or include branch list in the blocked_on cost instrument alongside the apply check. The OpenAPI regeneration for the new fields and the branch query parameter should also appear in the Surfaces list.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. The fields are on branch show only, so branch list stays one ref read per branch. OpenAPI regeneration is listed under Surfaces, and a detailed listing such as #1012 may add the same fields at its measured cost.

Comment on lines +169 to +176
## Design

### Schema sequence

The `schema_contract` row and every graph lineage row gain `schema_sequence:
u64`, stored beside the contract head rather than inside it. A commit's
sequence is therefore available from its lineage row without opening its
snapshot. The archived schema object serializes the head with the source and

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: specify how the sequence is stored in the two places "every graph lineage row" actually covers.

  1. Buffered rows use an inherit encoding. A settled row in the __manifest buffer can carry INHERITED_SCHEMA_IDENTITY_VERSION and no head. It inherits the contract and content hash from the next newer commit (row.rs#L555-L604). So "available from its lineage row without opening its snapshot" holds only if the sequence follows the same inheritance and the same partial-identity refusal ("commit carries a partial schema contract identity"). A head plus a sequence plus an inherit marker needs one decode rule.
  2. __history extents are immutable Lance files whose packed record is fixed per format (history.rs#L1-L22). Adding schema_sequence there is a second format change beside the __manifest row. It needs its own reader rule ("an extent without the child reads 0"), and since the packed struct can't hold nulls, a present bit.

Neither changes the design, but the stamp-17 description should list both so the implementation and the crossversion_upgrade.rs row cover them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed by dropping it. The sequence lives only in the schema_contract row. The merge orders the two sides by their live rows and reads the base by content, so lineage rows, the buffered-row inheritance and __history extents are unchanged.

@cursor

cursor Bot commented Oct 15, 2026

Copy link
Copy Markdown

Review: RFC "Schema apply with live branches"

Verdict: request changes. The core design is sound, and I'd keep it: a stored schema sequence beside the contract head, a per-table stored signature over base/source/target, and taking a whole version only when one side changed it. There are two design gaps and a few spec gaps to close before this moves to accepted. No finding argues against the approach.

I checked the RFC against current main (fe69c039) and the unmodified Lance 11.0.0 source. Inline comments carry the evidence links.

P1: fix before acceptance

  1. Physical index maintenance decides refusals (inline at L352). "Changed the table" is registration inequality, and ensure_indices_on(branch) / rebuild_full_text_indices_on(branch) publish new branch pins without changing rows. So whether an apply or merge succeeds depends on derived index state. That is invariant 7 and the deny-list's "logical precondition based on physical index coverage", yet the RFC says no deny-list shape is added. I'd make this blocking, not the optional nit in the existing thread. Suggested fix: a data-file/deletion-file identity proof built on merge's existing fragment_data_identical, which stays conservative.
  2. Snapshot/commit-id binding isn't total (inline at L111, also covering L373). "The current contract of the branch that wrote the commit" is undefined when that branch has been deleted. When it has been recreated under the same name, the rule binds by name, which invariant 6 forbids. Unpublished and unattached commits aren't covered either. Define a fallback, or bind the commit's own archived contract.

P2: specify in the RFC

  1. SchemaContractDigest and the ledger (inline at L196). The digest is persisted (deny_unknown_fields) in ledger v2 schema_contracts. Say whether the sequence enters it. Content-only looks sufficient, since prepared applies already bind head and version.
  2. Lineage storage details (inline at L169). The sequence needs to follow the buffered-row inherit encoding (INHERITED_SCHEMA_IDENTITY_VERSION). __history extents are a second format change, which needs a present bit and a "missing reads 0" reader rule.
  3. Stamp fence (inline at L157). The stamp is enforced on every scan and every publisher CAS, not only at open. So the "operator obligation" is narrower than stated, or gone. Stamping at the first contract write rather than the first publication would also keep rollback open for operators who never use the feature.
  4. Scope honesty (inline at L82). The motivating case, a nullable add to a type that agent branches write, still refuses. Say so in the Summary and Motivation, or consider bringing nullable-add projection into scope.

P3: nits

  1. The Sequence-0 paragraph's last sentence is false as literally written; the invariant you mean is "no two sequence-0 rows differ" (inline at L190).
  2. branch list cost and OpenAPI regeneration (inline at L103).
  3. "Ordinary writers detect a contract change through their exact graph-head precondition" (L331-334): write revalidation also compares contract identity (test_write_revalidation_admits_changed_contract_content). Several catalog and engine tests also commit contract-only ManifestChange::SchemaContract changes without lineage through commit_changes, and they'll need to move with the new publisher refusal. Worth one sentence.
  4. On Unresolved question 2, I agree that BranchMerge is enough. The catch-up only replays a contract change already authorized on main. But say explicitly that a catch-up can tombstone a type on the target, so operators know a BranchMerge grant implies that.
  5. The "Review" subsection under Evidence ("A model review of the first draft…") is process history that the Decision log already records. Consider folding it in.

Claims verified against code

  • Every main-only refusal the RFC lists exists: apply (schema_apply.rs:160), prepared reconcile (prepared.rs:310-322), settlement (settlement.rs:250-258), the system-column upgrade, and the cluster preflight (execution.rs:1387-1398).
  • Merge refuses differing contracts and shares one catalog across all three images (omnigraph.rs:1602-1616). It matches tables by alias (type_key) and publishes only table updates. same_manifest_state ignores type_key and manifest_version and compares exactly the fields the RFC lists.
  • Only schema apply and the system-column upgrade emit ManifestChange::SchemaContract in production. The archived schema object embeds the head, so keeping the sequence outside it does keep schema_content_hash content-only.
  • The merge-base registry load runs before the bounded walk (100,000 visits, 32 MiB), as stated.
  • The accepted-catalog memo holds one entry. GET /schema has no branch scope. Branch creation from pinned snapshots is unsupported, so no older-contract branch can be minted.
  • Stamp 16 is current with a served range of 14 to 16, and no other RFC claims 17. An @index removal is refused by the planner, so full-text declarations only grow.
  • Lance: fix_schema renumbers duplicate field ids (lance/src/io/commit.rs:707), and the table-schema page's "must not … match the fields by name" quote is accurate.

Checks run on the PR head

  • python3 scripts/check-docs.py: OK (257 files).
  • bash scripts/check-agents-md.sh: OK.
  • typos v1.50.1 on the changed files: clean.
  • CI on the PR is green except Storage Upgrade Compatibility, which was pending.
  • No Rust tests apply: the PR is docs-only, and the prototype it cites isn't committed.

Summary: request changes. The design holds up. The main issues are that index maintenance on a branch can flip apply and merge outcomes (an invariant 7 / deny-list conflict), that snapshot reads have no binding rule once the writing branch is deleted or recreated, and that the ledger digest, lineage encoding and stamp-timing details are unspecified.

Address PR review. Refusals now depend only on changed rows, not on
registrations that index maintenance or compaction can move. A merge
across contracts brings the base and both sides to the newer contract
with schema apply's metadata-only evolution and runs today's merge, so
nullable adds, renames and full-text declarations merge with ordinary
conflicts and only drops of changed data refuse. The apply-time check
runs the same rule. Snapshot reads keep main's contract, the sequence
lives only in the schema_contract row, the ledger digest stays
content-only, and the stamp-fence and branch-list wording is corrected.
Comment on lines +140 to +142
`branch merge --dry-run` runs the same capture and classification and reports
the same outcome, including `schema_diverged` and the contract the merge would
publish, without effects.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 (blocking): merge preparation is a durable write, but the RFC uses it in operations that are reads today.

Merge preparation commits detached Lance versions (L294-307). Logical change also prepares the base before it compares rows (L285-287). Three surfaces reach that code:

  • branch merge --dry-run. Today it is a read. It takes no schema permit, no branch gate and no merge-input tag. It "stages only to process-local scratch, and commits and publishes nothing". Transports authorize it as a read of both branches (merge.rs#L5009-L5015, #L5086-L5093).
  • Schema preview, plan and the stored deployment preview. Today they only plan (schema_apply.rs#L186-L195). The deployment path captures its intent "without graph effects" (omnigraph.rs#L1240-L1242). L94-97 says these report the same branch list as the apply check.
  • The refusals. L260-261 refuses "before any effect", and L366-367 runs the apply check "before any table effect". A cross-contract Logical change can need a prepared base first, so a refusal can leave detached versions behind.

As written, a read-authorized actor can cause table commits on shared datasets, and "without effects" is not true.

Please choose one rule and state it:

  1. Comparison never commits. Then comparison needs a read-time mapping by stable property id. That reopens the alternative rejected at L564-567, because there would be two conversion paths. The other option is to show a public Lance 11 surface that reads an uncommitted Project/Merge result. I did not find one: StagedWrite adds fragments to a scan of the committed schema, not a new schema (table_store.rs#L776-L789).
  2. Comparison commits. Then name the staging witness that each prepared version records. State that dry run and preview write storage, and update their authorization and the dry-run contract. Add rows that prove no __manifest version names these versions and that cleanup reclaims them.

It also helps to say when preparation is needed at all. The branch's contract was written by some main commit. That commit is a common ancestor of the branch and main. When the merge base descends from it, the base and the branch share a contract, because sequences never decrease along ancestry. In that case the apply check needs no preparation. Only a criss-cross base can differ.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed by removing preparation. The merge takes the newer side's version of a migrated table whole and compares rows only between versions of one shape. Dry runs, previews and the apply check therefore read committed versions only, and refusals happen before any effect. Read-time mapping is deferred to Future work relaxation 2.

Comment on lines +264 to +268
to it (Merge preparation).
4. The merge runs today's classification, staging, validation and
publication against the merged catalog, matching tables across the three
versions by stable identity.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 (blocking): "today's merge unchanged" loses every merge fast path once versions are prepared.

Today's merge picks its route for each table by registration equality on the pins (merge.rs#L5338-L5352):

  • source equals target, or base equals source: skip the table;
  • base equals target: adopt the source's version (classify_adopt, or the proven-insert path for Blob tables);
  • otherwise: the general three-way walk.

A prepared version is a new detached version, so its registration never equals another pin. If step 3 runs before step 4, every prepared table takes the general walk.

Example. Main adds a nullable column to Person. A branch wrote only Company. A catch-up merges main into the branch. The branch's Person pin equals the base's pin, so today's rule adopts main's Person version. With preparation first, the merge prepares the base and the target. Then it restages main's whole Person delta onto a branch-local version. The rows are correct, but the cost changes from O(1) to O(delta). The branch also stops sharing main's version and index coverage, so later merges lose the fast path too. A catch-up of an active branch is the motivating case, so this is the common path.

The lineage gate has the same problem. It requires one Lance schema across the pins (#L2414-L2428). Lance Field equality includes id (field.rs#L135-L138). plan_schema_evolution gives each new column an id above that version's own max_field_id (table_store.rs#L4605-L4607). Two lines can therefore give the same added column different ids. For example, after the branch forked, main added a column, wrote rows, and dropped it again. The gate then falls back to the ordered walk.

Suggested rule.

  1. Select the route from the unprepared pins.
  2. Prepare only the versions that route reads or publishes. Adopt then needs at most the source's preparation, and none when the source already holds the merged contract.
  3. After preparation, let the gate compare stored signatures, not Lance field ids. This is sound: a fragment that both versions share lists only ids at or below both lines' max_field_id, so a new column is absent from it on both sides.

Add route assertions (adopt versus walk, ordered_cursor_scan_calls) to the merge_truth_table.rs and merge_fast_forward.rs rows. This also changes the "less merge-specific code" argument at L560-563, so please restate it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. Route selection uses the real registrations, as today. A migrated table resolves to a skip, or to adopting the newer side's version, so an untouched catch-up is a pointer change. The merge_fast_forward.rs row asserts the route and ordered_cursor_scan_calls. The field-id point no longer comes up, because no prepared version is compared.

Comment on lines +279 to +291
Whether a side changed a table since the base depends only on rows. The
existing cheap paths answer first: equal registrations, then the merge's
lineage candidate discovery, which compares the base's and the side's data,
overlay and deletion files by identity and reports no candidates when they
match. Index-only commits and schema evolution keep those files, so they cost
nothing here.

Only when candidates remain does the merge compare them. It brings the base's
version to the side's signature with the same preparation and runs the ordered
comparison over the candidates. Compaction therefore changes cost, not the
answer. The comparison runs under the merge's existing memory and spill
bounds, and only for a table where a lossy step meets a side whose files
differ from the base's.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: the cheap path does not run on every table, so a "cannot prove" refusal can still depend on physical work.

The lineage gate falls back to the full ordered walk for:

  • Blob tables (merge.rs#L2343-L2346);
  • pins at different table paths, for example pre-v11 table forks (#L2350-L2361);
  • pins without stable row ids or the exact-id key (#L2377-L2407);
  • pins whose Lance schemas differ (#L2414-L2428). The comment there gives the reason: a metadata-only schema change keeps fragments identical while the row comparison sees every row as changed. So discovery cannot "answer first" when the base and the side differ in signature. Preparation has to come first, which leads to the effect question in the comment at L140.

On these tables, any registration change takes the walk. An index-only commit is enough, for example ensure_indices_on(branch) on a Blob table. The apply check has a per-branch budget on comparison work and refuses with "cannot prove" when the budget runs out (L388-392). So an index build on a branch can turn a successful apply into a refusal. L500-501 and L518-520 say refusals never depend on index coverage.

Please do one of these:

  • extend the file-identity proof to these tables for this predicate; or
  • qualify the statement: the answer depends only on rows, and only the budget refusal can depend on physical state.

Also add a schema_apply.rs row: a Blob table, an index build on the branch, then a drop on main, with the expected outcome.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed by qualifying the claim. The answer depends only on rows. The budget refusal (cannot prove, branch_check_unproven) is the one result physical state can reach, and it is named in the user section and under invariants 7 and 11. Logical change now also says that a discovery which does not complete falls through to the ordered comparison. The Blob table, index build and drop row is in the schema_apply.rs tests.

Comment on lines +356 to +358
sequence. Ordinary writers detect a contract change twice: write revalidation
compares the captured contract identity, and the exact graph-head
precondition fails because every contract change advances the head. Catalog

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 (alignment with #785, optional): this sentence relies on the exact graph-head precondition as a second fence. The group commit draft composes entries staged against older pins onto a later pin (its blocked_on guard, group_commit_composes_inserts_staged_against_older_pins_onto_a_later_pin), so that precondition stops applying per entry. Under this RFC, a catch-up merge can change a branch's stored columns between an entry's capture and its batch's publication.

Please state that revalidation of the captured contract identity, sequence included, is the fence that must hold for each entry. A batch must refuse an entry captured under another contract. One sentence here and one in #785's admission rules is enough.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added under Merge publication: a batching publisher must refuse an entry captured under another contract identity, sequence included.


| Owner | Proof |
|---|---|
| `tests/schema_apply.rs` | Apply with live branches leaves their pins and contracts; an add, rename or full-text `@index` never refuses; a drop refuses only for a branch that changed the table, naming it; an index rebuild or compaction on the branch, before or after the apply, never changes the outcome; preview lists affected branches; budget and walk-limit refusals; incarnation recheck |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 (nit): tests/merge_fast_forward.rs does not exist. The owner is crates/omnigraph/tests/integration/merge_fast_forward.rs (see the testing guide). All other owner paths in this table resolve at this head.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the path is tests/integration/merge_fast_forward.rs.

@cursor

cursor Bot commented Oct 16, 2026

Copy link
Copy Markdown

Review at 936f0315: RFC "Schema apply with live branches"

Recommendation: request changes. The core design holds. Two specification gaps block acceptance. Both are in the new "prepare every version" merge path. Neither argues against the approach.

What this PR does

  • Why. Today one live branch blocks every schema apply. Long-lived review and agent branches therefore block every migration.
  • What. Main can apply a schema while other branches are live. Each branch keeps its contract until a merge brings a newer one. A stored schema sequence orders contracts, so a change and its reversal stay distinct.
  • How. A cross-contract merge first brings the base, source and target versions of each table to the newer contract with schema apply's metadata-only Lance commits. Then it runs today's merge. The only refusal is a drop of a property or type that a side changed. Storage stamp 17 fences older binaries.
  • Impact. This PR changes documentation only: the RFC and its registry row. If accepted, it is an irreversible format change (stamp 17, no in-place downgrade).

This revision resolves the earlier review points: row-based change detection, snapshot reads bound to main, the content-only ledger digest, sequence storage, the fence wording, the nullable-add scope, the branch list cost and the index-rebuild test row. I do not repeat them.

Blocking

  1. Preparation is a durable write, but the RFC uses it in reads (inline at L140). Today, branch merge --dry-run, schema preview, plan and the deployment preview make no effects. A dry run is authorized as a read. Logical change and merge classification can need a prepared version, which is a detached Lance commit. The "before any effect" refusals at L260 and L366 can also leave such commits. Choose one rule: comparison never commits, or comparison commits with a named witness, collector proof and a changed authorization.
  2. "Today's merge unchanged" loses every merge fast path (inline at L264). Merge picks skip, adopt or walk by registration equality on the pins. A prepared version never equals another pin, so every prepared table takes the general walk. The common catch-up case then restages main's delta onto a branch-local version instead of adopting main's version. The lineage gate also compares Lance field ids, and two lines can give the same added column different ids. Proposed fix: select the route from the unprepared pins, and prepare only what that route reads or publishes.

Non-blocking

  1. A "cannot prove" refusal can still depend on physical work (P2, inline at L279). Blob tables, pre-v11 forks and pins with different schemas never use lineage discovery. Under the apply check's budget, an index build on a branch can turn an apply into a refusal. Extend the proof, or qualify the invariant 7 statements at L500 and L518.
  2. Group commit alignment (P3, inline at L356): a composed batch must refuse an entry captured under another contract.
  3. Test owner path (P3, inline at L662).

Claims I checked and agree with

  • New columns get ids above Manifest::max_field_id (table_store.rs#L4605-L4607). In Lance 11, that maximum includes ids that data files still list (manifest.rs#L436-L450). So preparing each version on its own line cannot reuse a dropped column's id. This supports L467-475.
  • The publisher already applies RegisterTable, RenameTable, Tombstone, Update and SchemaContract in one batch, registrations first (publisher.rs#L534-L705). The merge publication at L333-347 needs no new change kind.
  • The fast-forward test is same_graph_state (merge.rs#L5208). Adding the sequence there makes a catch-up fast forward publish the contract, as L345-347 says.
  • Ordering: only main writes contracts, and merges carry the larger sequence. Contracts therefore form one chain, and the larger sequence is always the later main contract. The sequence-0 argument holds.
  • Binding snapshot reads to main's contract is total. #959 has merged, so binding by stable identity exists today.

Tradeoffs and liability

  • Contract. Migrations no longer wait for branches, except drops of tables a branch changed.
  • Mechanism. Per-branch contracts, a schema sequence, a merge preparation phase and stamp 17.
  • Expected work. Many long-lived branches, few applies, frequent catch-up merges.
  • Permanent cost. A format stamp with no in-place downgrade. A multi-entry catalog cache. Branch-scoped schema reads. One more merge phase. Apply cost that grows with branch count, which the blocked_on gate must measure.
  • After five more changes like this. New migration kinds fit the lossless/lossy signature rule, which is good. But if finding 1 needs a read-time mapping for dry run and preview, the design has two conversion paths. Every future step would then need both. L564-567 names that risk itself. Finding 1 decides whether one path is enough.
  • Compounding cost. Without a route rule (finding 2), each catch-up moves a branch's tables off main's versions. Later merges and index coverage then cost more each time.
  • Deprioritized quality. Efficiency (the per-branch apply check and preparation commits) is traded for robustness. That is acceptable while the measured cost gate stays a prerequisite.

Alignment

  • #884: the distributed-sequence draft relies on one writer for the type allocator. This RFC keeps apply main-only, so the two agree. The stamp-17 race is already noted.
  • #951: already covered by L442-454.
  • #996 and #818: their plan statistics and handle cache are keyed by pinned version. I found no conflict. The plan-replay row at L675 covers catch-up.
  • #785: finding 4.
  • No open PR changes the Lance version. Lance 12.0.0 and 13.0.0 release notes show no change to metadata-only Project/Merge or detached commits. lance#9547 changes merge_columns ids, which OmniGraph does not call on this path. I did not run OmniGraph against Lance 12 or 13.

Validation

  • Exact head 936f03151732024db527fe6e5517fa54d17c7483, merge base fe69c039, clean detached worktree. Diff: the RFC (+749) and docs/rfcs/README.md (+1).
  • python3 scripts/check-docs.py: Documentation OK (293 Markdown files).
  • bash scripts/check-agents-md.sh: OK (55 links, 51 docs).
  • typos 1.50.1 (the CI pin) on both changed files: clean.
  • git diff --check fe69c039...HEAD: clean.
  • Exact-head CI: CI Gate, Storage Upgrade Compatibility and PR Title pass. Test Workspace, Clippy and Format are skipped as docs-only. The GQT jobs pass through their no-input path, which is not a test run.
  • Lance sources: crates.io lance, lance-table, lance-core and lance-index 11.0.0. The lance-table checksum matches Cargo.lock.
  • Every cited OmniGraph file is unchanged between fe69c039 and current main (864495a8).

Limitations. I did not build or run Rust tests: the PR changes docs only and adds no implementation. The findings come from reading code. No behavior test can fail or pass here before an implementation exists. I did not measure any cost. I did not reproduce the prototype.

…ranches

Address the second PR review. Preparing every version wrote to shared
datasets inside dry runs, previews and the apply check, and defeated the
merge's skip and adopt routes. A merge now takes the newer side's version
of each migrated table whole and refuses when the older side changed it,
comparing rows only between versions of one shape, so checks stay reads
and catch-up keeps pointer adoption. Upgrading the older side, read-time
mapping and narrower drops become Future work relaxations that only turn
refusals into merges. Align with group commit, branch creation at a commit
and branch details, and fix a test owner path.
…ons-active-branches-fb6101

# Conflicts:
#	docs/rfcs/README.md

ragnorc commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: request changes

This RFC lets schema apply run on main while other branches remain live. Each branch keeps its schema contract until a merge publishes a newer contract on that branch. Schema apply stays main-only.

The rule never combines rows written under two schemas. For a table whose stored columns or full-text declarations differ, the merge takes the newer side's version whole when the older side did not change that table's rows since the merge base. Otherwise the merge returns schema_diverged before any effect. No step rewrites, converts, or prepares a version. Reads, dry runs, and previews stay free of effects. Uniform tables keep today's merge route.

That removes the two blocking defects from the review of 936f03151732024db527fe6e5517fa54d17c7483. One apply-check sentence still has two readings. Please state one success predicate in the user section and in step 4.

Earlier findings on 936f0315

Fixed. Preparation writes inside a read. The new rule has no prepare, evolve, or convert step. The dry run and the apply check read committed versions only (Logical change, Apply-time check). Invariant 5 holds for those operations.

Fixed. Migrated catch-up lost the fast paths. Route selection stays today's route for a uniform table. When the older registration equals the base, condition 2 is today's adopt or skip, and an untouched catch-up publishes a pointer. In crates/omnigraph/src/exec/merge.rs the per-table loop skips equal registrations, calls classify_adopt when the target still matches the base, and walks only the remaining tables. When the adopt does not advance HEAD, classify_general_adopt returns AdoptSourceState, and plan_adopted_source_state publishes a pointer or nothing.

Fixed. "Cannot prove" could depend on physical work. The Invariants section now says the budget refusal is the one outcome that can depend on physical state, and that it is a bounded-work result under invariant 11. The logical answer, when the check reaches it, depends only on rows.

Fixed. Group commit. Merge publication now requires a future batching publisher, including #785, to refuse an entry captured under another contract identity, sequence included.

Fixed. Test owner path. Required tests now name tests/integration/merge_fast_forward.rs and ordered_cursor_scan_calls. That function exists in crates/omnigraph/tests/integration/merge_fast_forward.rs.

This revision

Blocking (1). Step 4 states two success predicates for a later apply to a table a branch can already not merge. The inline comment has the scenario and the two predicates. Please keep one, in the user section and in step 4.

Optional (4). Whole-merge refusal wording. Unfinished lineage discovery versus "no candidates". Summary fence for older binaries. Shared-pin integrity sentence on condition 1.

Tradeoff and liability

The rule removes two liabilities: durable versions created inside a read, and a catch-up that restages every migrated table and skips adopt. It adds a permanent stamp 17 with no in-place downgrade, a schema sequence on the contract row, branch-scoped schema reads, a multi-entry catalog cache, and an apply check whose cost grows with the live branch count. The blocked_on measurement remains a prerequisite for the budget.

That is the right cut for this workload. Long-lived branches are common. Schema applies are rare. Catch-up is frequent. Robustness stays ahead of efficiency. Five further migration kinds can reuse the signature test and whole-version adoption. Future work only turns a refusal into a merge and does not add a second conversion path.

A nullable add on a table a live branch has written still waits. The Motivation section says so.

Validation

Head 4fb15738575df15aa29c61d79fc78c4e2ef6e28d. Author delta 21698f5de84c9e45fbce6a01450eb9edd237bd8f merged with main 9575b044a11ad3b65074bf2fadfcb9dc45d06107. Docs only. This pull request does not change runtime behavior, so there is no GQT or Rust regression to run before and after. Claims about current code were checked by reading that head.

  • Only-main refusals remain in crates/omnigraph/src/db/omnigraph/schema_apply.rs and in the cluster preflight crates/omnigraph-cluster/src/deployment/execution.rs.
  • The manifest stamp is 16. The served range is 14 to 16. guard_row_layout refuses an unsupported stamp on scan. guard_stamp refuses it before publication. A publication writes the current stamp (crates/omnigraph-catalog/src/migrations.rs, crates/omnigraph-catalog/src/commit.rs).
  • plan_lineage_merge returns no plan, and today's merge uses the ordered walk, when Lance schemas differ, table paths differ, the pin or stable-row-id or exact-id check fails, or the candidate byte budget trips. A completed discovery with an empty candidate set stages no update (crates/omnigraph/src/exec/merge.rs).
  • rows_equal returns the internal error schema-gated change row is missing column when the right row lacks a left user column. It does not treat that shape as unchanged (crates/omnigraph/src/changes/row_compare.rs).

Local checks in a detached checkout of this head:

  • PYTHONPATH set to the pinned markdown-it-py==4.0.0 and mdurl==0.1.2, then python3 scripts/check-docs.py: Documentation OK (333 Markdown files checked).
  • bash scripts/check-agents-md.sh: OK.
  • git diff --check 936f03151732024db527fe6e5517fa54d17c7483 21698f5de84c9e45fbce6a01450eb9edd237bd8f: clean.
  • typos 1.50.1 on docs/rfcs/2026-10-12-schema-apply-with-live-branches.md and docs/rfcs/README.md: clean.

CI on this exact head: run 39094641954 succeeded. Check AGENTS.md Links, workflow pin check, dependency guard, PR title, and storage-upgrade compatibility passed. Test Workspace, clippy, and rustfmt were skipped. GQ Logic Tests run 39094641899 succeeded because "Skip when no GQT input changed" ran. The GQT test steps were skipped. That is not a logic-test run.

Not done: no cargo test, no cost measurement, no prototype replay, no run on a later Lance release.

Inline findings

docs/rfcs/2026-10-12-schema-apply-with-live-branches.md line 392

Blocking: step 4 states two success predicates.

Apply 1 adds a nullable property to Person. The branch has not written Person. The check allows the apply.

The branch then inserts a Person. A merge in either direction returns schema_diverged. The branch can already not merge that table.

Apply 2 drops another property of Person.

  • The first sentence of step 4 refuses, because the branch changed Person.
  • The last sentence reports the table and does not refuse, because an earlier apply already made the table unmergeable.
  • Lines 375–376 say the check shares the merge implementation, so an apply refusal and a later merge refusal cannot disagree. Under the last sentence, apply 2 succeeds and the later merge still refuses.
  • The user section (lines 92–94) refuses only when a branch changed a table this apply changes. It does not name a base signature that differs, or the "cannot prove" budget refusal at lines 400–404.

Neither reading drops data. Both fail closed. The observable result differs: the apply succeeds, or the caller receives HTTP 409.

Please write one predicate in the user section and in step 4.

The predicate that matches "shares the merge implementation" is: refuse when, for a table this apply changes, the planned merge into that branch would refuse. A table the branch can already not merge refuses too, until the branch is deleted or the work is redone on a new branch. One implementation stays the authority.

The other predicate is: refuse only when this apply newly makes a table unmergeable. A table the branch can already not merge is reported and does not refuse. A second apply that changes that same table then succeeds. That predicate needs its own rule beside the merge rule, and the user section and lines 375–376 must change with it.

Name the budget refusal in the same user-facing list. "Cannot prove" is a third result. It is not a row-change refusal.

docs/rfcs/2026-10-12-schema-apply-with-live-branches.md line 168

Optional: the refusal is the whole merge.

Line 141 and the merge rule refuse the merge with schema_diverged before any effect. Invariant 4 gives that merge one publication. The other tables on the branch do not publish either.

This paragraph says the branch cannot merge that table. Please say the merge is refused. Redo or export is the remedy for the branch's other commits as well.

docs/rfcs/2026-10-12-schema-apply-with-live-branches.md line 324

Optional: an unfinished discovery is not "no candidates".

plan_lineage_merge in crates/omnigraph/src/exec/merge.rs returns no plan, and today's merge uses the ordered walk, when:

  • the candidate byte budget trips (lineage_side_candidates returns Ok(false))
  • Lance schemas differ across the pins
  • table paths differ
  • the version pin, stable row ids, or the exact-id key check fails

An empty candidate set after a completed discovery means unchanged. The loop in stage_lineage_table_merge then stages no update. Step 2 can be read as treating the misses above as that empty set. That reading drops the older side's writes.

Please say that a discovery which does not finish continues to the ordered compare in step 3. A missing user column during that compare is an integrity error. rows_equal in crates/omnigraph/src/changes/row_compare.rs returns schema-gated change row is missing column. It does not return unchanged. The compare walks the left row only. Condition 2's same-signature test is what keeps an extra user column on the right out of that compare.

docs/rfcs/2026-10-12-schema-apply-with-live-branches.md line 44

Optional: the summary overstates the old-binary fence.

"Older binaries refuse the graph once main carries it" is stronger than the user section at lines 180–189, and stronger than the code. guard_row_layout and guard_stamp refuse the __manifest version they scan or are about to publish (crates/omnigraph-catalog/src/migrations.rs). The stamp belongs to that version. A branch that is still at stamp 16 stays usable. A historical read of a stamp-16 main version still passes the guard. An older process fails at its next read or write of the latest main manifest.

Please use the user-section wording in this summary sentence.

docs/rfcs/2026-10-12-schema-apply-with-live-branches.md line 288

Optional: state the shared-pin integrity check.

Condition 1 keeps one version when the source and target registrations are equal, including on a migrated table. That is safe when every signature-changing contract write updates that table's pin in the same publication. The shared version then already satisfies the merged contract.

Please add one fail-closed sentence: a shared pin published under the merged contract must satisfy that contract's signature, or the merge returns an integrity error. This does not add a conversion path. It rejects a publication the signature rule cannot explain.

@ragnorc

ragnorc commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Alignment note from #1016 (branch create at a commit), following its review. The RFC already says the start refuses a commit whose contract differs from the source's current one. Three details that the relaxation will depend on:

  • What the check compares against. A commit start compares the commit's contract with the contract at the captured head of the branch that wrote the commit (prove_commit_start), not main's contract or a graph-wide one. Under this RFC each branch holds its own contract, so that anchor stays right: the new branch starts with a contract its writing branch held.
  • No contract write on the start path. The new branch's first __manifest version is a clone of the version that published the commit, so its schema_contract row is the commit's row as written, with its sequence once stamp 17 writes one. The start writes no contract row, so the sequence-0 soundness argument needs nothing from the start path except the refusal itself.
  • The relaxation is one removed refusal. schema_changed is a typed branch_start_refusal reason, and feat(branch): start a branch at a graph commit #1016's refusals are now decided in the engine with one answer on every door. Under this RFC the rule could become: refuse a commit whose contract differs from the writing branch's head only when that contract has no sequence (reads as 0). A differing contract that carries a sequence is an older contract, like a branch that has not caught up yet, and the started branch has changed nothing since its merge base. This should be confirmed against the merge and apply rules here.

One more consequence of the sequence joining the contract's identity: the start compares SchemaContractHead values. After a rename and back (equal content, different sequence), the commit's contract compares as different, which the relaxed rule would then allow, since both carry a sequence.

…y with live branches

Address the maintainer review of 4fb1573: the apply check refuses exactly
when the merge rule's condition 2 fails, including a table a branch can
already not merge, and names cannot prove as the third result. Restore the
keys-only validation delta for an adopted migrated table, state that a
refusal refuses the whole merge, that an unfinished lineage discovery falls
through to the ordered comparison, and that a shared version is checked
against the merged signature. Describe branch creation at a commit as it
landed, recheck the Lance facts on 13.0.0, and record the full-text refusal
and constraint removal with a dropped property.
@ragnorc

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Reply to the review of 4fb15738 and the #1016 alignment note. All of it is addressed in 48a3e363 (head 9a3f6213).

Blocking: one success predicate. The user section and apply-check step 4 now share one rule. The apply refuses exactly when, for a table it changes, the merge rule's condition 2 fails for some live branch: the branch changed the table since its merge base, or the base holds the table in another shape. A table the branch could already not merge after an earlier apply fails condition 2 again, so it refuses every later apply that changes it. The remedies are to delete the branch or redo its work on a branch created from main. cannot prove is named in both places as a third result, the budget refusal.

Optional points:

  • Whole-merge refusal. A schema_diverged refusal refuses the whole merge. None of the branch's commits publish, and the remedy covers its other tables too.
  • Unfinished lineage discovery. Only a completed discovery with no candidates means unchanged. A tripped budget, or a pin the discovery does not apply to, falls through to the ordered comparison. A missing user column there is an integrity error, never "unchanged".
  • Summary fence. The summary's older-binary sentence now matches the user section.
  • Shared-pin integrity. After condition 1: a shared version whose signature differs from the merged contract's is an integrity error.

Also fixed while rechecking: the narrowed revision had dropped the keys-only validation delta. Today's adopt route compares the base's and the source's rows by column name, which is the cross-shape comparison the prototype's add and rename cases hit. For an adopted migrated table, the delta now reads only __id, and __src/__dst for edges.

#1016. It is now described as it landed. A start compares against the contract at the captured head of the branch that wrote the commit, writes no contract row, and refuses a difference as schema_changed.

The RFC records the narrower refusal this design permits: refuse only a differing contract without a sequence. A start with a sequenced older contract is a lagging branch unchanged since its merge base, the commit itself. I checked that against the merge rule and the apply check; both apply unchanged. The rename-and-back case is noted: it compares as a different contract, and the narrower refusal allows it.

Main through 7fd9cc90:

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: request changes

Why. One live branch blocks every schema apply. Long-lived branches block every migration.

What. Main can apply a schema while other branches stay live. Each branch keeps its contract until a merge publishes a newer contract on that branch.

How. For a table whose stored columns or full-text declarations differ, the merge takes the newer side's version whole. It refuses when the older side changed that table, or when the base holds another shape. This revision gives the apply that same refusal, and it builds the adopt delta from keys only.

Impact. This pull request changes documentation only. Acceptance is a storage format change: stamp 17, with no in-place downgrade.

The one success predicate from the review of 4fb15738 is now in the user section and in step 4. A table the branch can already not merge refuses every later apply that changes it. cannot prove is the third result. The four optional items from that review are in the text. The #1016 note matches the code that landed. One new rule does not hold.

Blocking

The keys-only delta can drop an edge that keeps its id and changes an endpoint.

compute_adopt_delta compares each source row with the base row by column name. A difference becomes an upsert (crates/omnigraph/src/exec/merge.rs, around the src.equals(base) call). The new paragraph replaces that compare, for a migrated table, with inserted and deleted __id values, plus __src and __dst.

That sentence has two readings.

  • The delta is the set of inserted and deleted ids, and those rows carry the endpoints. An id that is on both sides is absent.
  • The same diff runs, and it reads only __id, __src and __dst. An id that stays, with a new endpoint, is still a change.

The first reading can publish an edge to a missing node. Validation checks the target plus the delta. It does not read the adopted version as the merged edge set (evaluate_edge_ri in crates/omnigraph/src/validate.rs). Path-b probes the target's committed edges. A moved edge that is absent from the delta is still its old endpoint there.

This shape is a supported write. An edge with no @key keeps a supplied id (docs/user/schema/index.md, IDs). LoadMode::Merge upserts that id. merge_load_edge_src_move_rechecks_vacated_src_cardinality in crates/omnigraph/tests/integration/validators.rs moves WorksAt id E1 from Alice to Bob. update on an edge is refused (execute_update in crates/omnigraph/src/exec/mutation.rs). Load merge is not.

Example. The base has edge e1 from Alice to Bob, and node Carol. Main adds a nullable property on the edge and merge-loads e1 from Alice to Carol. The branch deletes Carol and does not change the edge. Condition 2 holds, because the branch did not change the edge. The merge adopts main's edge and keeps the deletion of Carol. e1 is on both sides, so a delta of inserted and deleted ids does not contain it. Path-b still sees Alice to Bob. The merge publishes e1 to Carol, and Carol is gone.

Write the second reading. A surviving id whose __src or __dst differs is a change, and the check uses the new endpoints. Inserted and deleted ids stay in the delta, with their endpoints. The merge_truth_table.rs row must include this case: one stable edge id, one moved endpoint, and the new endpoint deleted on the other side. The existing adopt test merge_main_into_branch_validates_adopted_edge_against_branch_node_delete inserts a new edge id, so it does not cover this case.

An edge whose @key contains the endpoints changes id when an endpoint changes. That case is a delete plus an insert. The stable-id case is the one the first reading loses.

Optional

  1. The comparator does not report a missing column on either side. Line 387 says a user column missing from either side is an integrity error, and it names schema-gated change row is missing column. rows_equal_by_column walks the left row only (crates/omnigraph/src/changes/row_compare.rs). It reports a column the left row has and the right row lacks. A column that is only on the right does not produce that error. Name the left row. Condition 2 still keeps the two contract shapes equal.

  2. Two lead sentences still use the short refusal. The summary says the apply refuses when a live branch changed the table. The motivation says the branch cannot merge that table. The user section refuses the whole merge, and it also refuses when the base holds another shape. Use that predicate in both sentences.

  3. rfc: graph access and cross-process writes #1017 leaves one pin decision here. Graph access and cross-process writes says that, once this RFC lands, a branch registration is not a reason to refuse an apply, and this RFC decides what the apply pins in place of main's exact version. Add one sentence. A registration that lands during the attempt moves main. The attempt starts again. The check runs on the new branch set.

Tradeoff and liability

The one predicate removes a split between the apply check and the later merge. The keys-only delta removes the cross-shape column compare that the add and rename cases hit. The first reading also removes the endpoint move from the validation input. That can publish a dangling edge. The repair stays in the existing delta and the existing evaluator. It adds no second compare path.

Five later migration kinds can keep one signature test and one key delta. A later relaxation still only turns a refusal into a merge. Robustness stays ahead of the cost of a key compare. The blocked_on measurement remains the budget's prerequisite.

Alignment

I read the open drafts #1015, #884, #951, #1017 and #1093.

The Lance 13.0.0 claims in this revision hold. Manifest::max_field_id uses the schema and the field ids in referenced files. fix_schema renumbers a duplicate field id. A detached commit takes a random version with the high bit set. With no segment, the flat full-text path uses SimpleTokenizer.

Earlier findings

  • One success predicate: resolved.
  • Whole-merge refusal: resolved.
  • Unfinished lineage discovery: resolved. Optional 1 is the remaining comparator sentence.
  • Summary fence for older binaries: resolved. Optional 2 is the refusal sentence that remains.
  • Shared-pin integrity check: resolved.
  • feat(branch): start a branch at a graph commit #1016 compare target, no contract write, and the narrower start refusal: resolved. The narrower rule matches the merge rule and the apply check.

Validation

Head 9a3f62137587b9a8f82cf19feb567d8a5e8db3cb. Author delta 48a3e3631efc780357863462b44ebd5a93dcf285 (+134/−39 in the RFC). The merge commit brings main 7fd9cc9037c634e2f3284f1ede55a85a6f7b28e8. Current main is 50283ceff03b8e3a1902d30757fd0808ea277b7d. After 7fd9cc90 it adds only README commits. Code citations are from that main.

This pull request does not change runtime behavior. There is no GQT or Rust regression to run before and after. The keys-only rule is not implemented. A temporary logic test cannot fail on the old commit for this reason and pass on this head. The required proof is the Rust row named above, in the existing owner tests/integration/merge_truth_table.rs.

Code inspection at 50283cef:

  • Adopt delta: compute_adopt_delta in crates/omnigraph/src/exec/merge.rs.
  • Left-only row compare: rows_equal_by_column in crates/omnigraph/src/changes/row_compare.rs.
  • Edge RI path-b and the moved-src cardinality recount: evaluate_edge_ri and evaluate_cardinality in crates/omnigraph/src/validate.rs.
  • Stable edge ids and the load that moves one: docs/user/schema/index.md and validators.rs.
  • Branch start: prove_commit_start compares the commit contract with the captured head. branch_create_at clones that manifest version and writes no contract row.
  • Stamp 16, served range 14 to 16. refuse_if_stamp_unsupported runs on scan and before publish.
  • The only-main refusals remain in schema_apply.rs and in the cluster preflight.
  • A full-text call with no segment refuses with full_text_index_required.
  • A drop removes a constraint only when every field it names is a dropped property and the constraint is not a key (ends_with_dropped_properties in crates/omnigraph-compiler/src/catalog/schema_plan.rs).
  • plan_lineage_merge returns no plan, and today's merge uses the ordered walk, for a blob table, a different path, a failed pin, missing stable row ids, a failed exact-id check, different Lance schemas, or a tripped candidate budget.

Lance 13.0.0 source, unmodified crates.io package:

  • lance-table format/manifest.rs, max_field_id.
  • lance io/commit.rs, fix_schema and the detached random version.
  • lance io/exec/fts.rs, default_text_tokenizer.

Local checks on this head, in a detached checkout:

  • python3 scripts/check-docs.py after pip install -r scripts/requirements-docs.txt (markdown-it-py 4.0.0): Documentation OK (361 Markdown files checked).
  • bash scripts/check-agents-md.sh: OK.
  • typos 1.50.1 on the RFC and docs/rfcs/README.md: clean.
  • git diff --check on 4fb15738..48a3e363: clean.

CI on this exact head: run 39518878387 succeeded. Check AGENTS.md Links, the workflow pin check, the dependency guard, the PR title, and storage-upgrade compatibility passed. Test Workspace, clippy, and rustfmt were skipped. GQ Logic Tests run 39518878327 succeeded because "Skip when no GQT input changed" ran. The GQT test steps were skipped. That is not a logic-test run.

Not done: no cargo test, no cost measurement, no prototype replay, no run on a later Lance release.

Inline comments (not posted)

docs/rfcs/2026-10-12-schema-apply-with-live-branches.md line 338, right side:

Blocking: a keys-only delta of inserted and deleted ids drops a stable edge id whose endpoint moved.

compute_adopt_delta records an upsert when src.equals(base) is false (crates/omnigraph/src/exec/merge.rs). This sentence can be read as the set of inserted and deleted __id values only. An id on both sides is then absent.

Validation uses the target plus the delta (evaluate_edge_ri). Path-b probes the target's committed edges, not the adopted version. A moved edge that is absent from the delta is still its old endpoint there.

merge_load_edge_src_move_rechecks_vacated_src_cardinality shows the write: load merge keeps WorksAt id E1 and changes from. Please state that a surviving id whose __src or __dst differs is a change, and that the check uses the new endpoints. The merge_truth_table.rs row must include one stable edge id, one moved endpoint, and that endpoint deleted on the other side.

docs/rfcs/2026-10-12-schema-apply-with-live-branches.md line 387, right side:

Optional: the comparator reports a column missing on the right, relative to the left row.

rows_equal_by_column walks the left row only and returns schema-gated change row is missing column when the right row lacks that column (crates/omnigraph/src/changes/row_compare.rs). A column that exists only on the right does not produce that error. Please name the left row. Condition 2 still keeps the two contract shapes equal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants