Skip to content

feat(server,cli,mcp): report each branch's head, start and times in the branch listing - #1012

Open
ragnorc wants to merge 11 commits into
ModernRelay:mainfrom
ragnorc:feat/branch-list-details
Open

ragnorc wants to merge 11 commits into
ModernRelay:mainfrom
ragnorc:feat/branch-list-details

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

What & why

GET /graphs/{graph_id}/branches returns names only. A client that shows each branch's head and start, such as OmniGraph Studio's branch menu and review, has to read GET /commits?branch= for every branch, which is a full lineage read each.

GET /branches?detailed=true, the MCP branches_list tool with detailed, and omnigraph branch list --detailed add a details array beside the unchanged branches. For each branch the caller may read:

Field Source
head (CommitOutput) The head row of the branch's latest __manifest version.
from The native ref's BranchContents.parent_branch, as a logical name.
from_commit_id The head row of the branch's clone at BranchContents.parent_version. While the branch has written nothing, that is its head.
created_at BranchContents.create_at, recorded to the second and reported in µs.
updated_at The head's time when the branch wrote it, otherwise created_at.

from and from_commit_id are the values the POST /branches receipt (BranchCreateOutput) names. That holds for a start at a past commit (#1016), because that start clones the commit's own version. On main the optional fields are absent.

The listing writes nothing and stores nothing. Every field comes from the branch refs and each branch's own __manifest, and no lineage is read.

Backing issue / RFC

  • Fixes an accepted issue
  • Is an RFC PR, or implements an accepted RFC
  • Trivial fast-lane

No accepted issue or RFC backs this yet. The change is row E9 of OmniGraph Studio's engine-change plan (specs/012-omnigraph-engine-changes), which has no filed issue. It needs an issue or maintainer acceptance before merge.

Draft RFC 0060 (#677, branch operations v2) only says "list/inspect branches: read live logical refs from one accepted manifest snapshot; expose graph identities, not storage-owner names". It defines no listing fields. This listing exposes logical names only, never native ref names.

The names follow the accepted HTTP API conventions RFC (#1089, docs/rfcs/2026-10-19-http-api-conventions.md):

  • the boolean query parameter is a bare adjective (detailed), the example that RFC itself names;
  • identifiers are *_id;
  • times are *_at in µs;
  • an absent optional field is omitted rather than null.

That RFC keeps the branch list unpaged until a separate page-token design, and says that design "also owns the branch-list details that #1012 adds, so the branch page is designed once". This PR does not page the listing. When that design lands, details pages with branches.

The graph access RFC's "one set of operations" rule, which #1139 implemented for MCP, puts the option on all three surfaces: the HTTP query parameter, the MCP tool argument and the CLI flag.

Checklist

  • Change is focused (one logical change)
  • Tests added/updated for behavior changes
  • Public docs updated: branching guide, CLI reference, server routes, skill reference, changelog, openapi.json
  • Reviewed against docs/dev/invariants.md:
    • No new durable state and no shadow source (invariant 12).
    • Reads are bounded per branch and flat in history (invariant 11).
    • Each branch's details are checked against read on that branch (invariant 10).

Local verification

All runs at head d549bca, which merges main at da1c463 (#1076, #1089, #1131 to #1133, #1139 and the rest since 7fd9cc9), with RUST_MIN_STACK=16777216 and the failpoint features omnigraph-engine/failpoints,omnigraph-cluster/failpoints. target/ was cold, so these are focused runs on the touched packages (omnigraph-engine, -server, -cli, -api-types, -catalog, -mcp), not the whole workspace graph.

  • OMNIGRAPH_UPDATE_OPENAPI=1 … openapi::openapi_spec_is_up_to_date: regenerated openapi.json, then every openapi:: test passes, including spec_follows_the_api_conventions.
  • Integration tests matching branch_list, openapi::, mcp::, cli_mcp, catalogue_scope and parity_branch_list: engine 4, server 121 and CLI 11 pass.
  • --lib for all six packages and forbidden_apis: all pass except one server test, api_error_tests::an_uncertain_engine_failure_logs_the_status_its_response_sends. It failed once in the full run and passes 3 of 3 alone. It checks tracing log capture, which its own comment says a concurrent test can disturb, and this PR does not touch that file.
  • cargo clippy --locked -p … --all-targets -- -D warnings -W clippy::dbg_macro over those packages: clean.
  • cargo fmt --all --check, python3 scripts/check-docs.py, bash scripts/check-agents-md.sh, typos: clean.
  • Not rerun this round: the moto S3 runs (bound_storage, branch_control_cost::…_s3; the listing's reads did not change since 16b832f, where both passed at 5/8 per branch, flat at 52), the GQT corpus and the DST suite.

Notes for reviewers

What "created" means. A branch name can have several lifetimes ({logical}.{ULID} native refs). The listing describes only the live lifetime. After a delete and recreate, it reports the second creation and its start. from names the source as it was named then; that branch may since have been deleted or recreated.

Start, not merge base. from_commit_id is the creation point and stays fixed for the lifetime. A later merge moves the merge base (GET /branches/merge-base) but not the start; the engine test shows both. The merge-base lineage logic answers a different question, costs two lineage captures per branch, and has nothing to answer once the source branch is deleted.

No creator. Branch creation records no actor; the actor only gates policy. An earlier revision of this PR recorded one with a second write of the new ref. That revision was dropped because of the liability it added:

Cost. One open of main's __manifest and one ref listing. Then, per readable branch, a checkout and row scan of its latest version, plus the same for its creation version once the branch has written a commit. Branches are read 8 at a time.

branch_control_cost.rs pins the scans exactly: one per branch that has written nothing, two per branch that has, and none added by history. It also pins a measured read budget per backend. On an S3 emulator (moto) the listing costs:

Part of the listing Requests
Fixed (main plus the ref listing) 4
Per branch with no commits of its own 5
Per branch with commits 8
6 branches, before and after 40 more commits on one branch and on main 52 → 52

A local store counts 1 and 3 per branch, because Lance reads local files without going through the counting wrapper.

Policy. The listing keeps GET /branches's scope-free read check. Each branch's details are also checked against read on that branch, as GET /commits?branch= is, and a branch the caller may not read is listed in branches alone. With today's YAML policies, a scope-free read grant implies read on every branch, so this is defense in depth; the engine test covers the filter.

Names.

Layer Names
Engine Omnigraph::branch_list_detailed(&mut ReadCheck) -> BranchListing { branches, details: Vec<BranchDetail> }
Catalog list_graph_branches_detailed; live_named_branches (the one live-branch enumeration, which list_graph_branches now also uses)
HTTP BranchListQuery.detailed, BranchListOutput.details: [BranchDetailOutput]
Mappers branch_list_output, branch_detail_output
MCP branches_list with detailed (BranchesListArguments); its output schema declares details
CLI branch list --detailed; text keys mirror the JSON (head=, from=, from_commit_id=, created_at=, updated_at=)

The omnigraph://graphs/{graph}/branches resource stays names-only.

Tests.

  • branching::branch_list_detailed_reports_head_start_and_lifetime checks every start against its create receipt. It covers:
    • unwritten and written branches, a child of a branch, and a start at a past commit;
    • a merge that moves the merge base but not the start;
    • a fast-forward into an unwritten branch;
    • a delete and recreate;
    • cleanup --keep 1;
    • the per-branch read filter.
  • db::upgrade::tests::assert_converted checks starts served from the legacy history after a storage upgrade.
  • branch_control_cost::branch_list_detailed_reads_bounded_per_branch_and_flat_in_history (+ _s3).
  • Server:
    • data_routes::remote_branch_list_create_merge_flow_works: details equal GET /commits heads and the create receipt, and the absent fields are omitted on main;
    • auth_policy::branch_list_is_denied_by_a_branch_scoped_read_rule_as_the_route_is: detailed=true is refused like the names;
    • mcp::mcp_read_parity_tools_equal_their_routes: branches_list with detailed equals GET /branches?detailed=true;
    • openapi::: the detailed parameter, its 400 and BranchDetailOutput pass spec_follows_the_api_conventions.
  • CLI: parity_matrix::parity_branch_list (embedded vs served), cli_data::branch_list_outputs_sorted_branches (text).
  • forbidden_apis: branch_list_detailed is registered as read-only.

Merged since:

…branch listing

`GET /branches?detail=true` and `omnigraph branch list --detail` add a
`details` array beside the names: for each branch the caller may read, its
head commit, the branch (`from`) and commit (`fork`) its current lifetime
was created from, the actor that created it, and when it was created and
last moved. Every field derives from the branch refs and each branch's own
`__manifest` (the latest version, and the creation version once the branch
has written a commit); no lineage is read, so the cost per branch is flat
in history.

Branch creation now records its actor on the new native ref
(`omnigraph.created_by_actor_id`) right after the native create. A failure
there leaves the branch without an actor and reports uncertain completion.
@cursor

cursor Bot commented Oct 16, 2026

Copy link
Copy Markdown

Review of #1012 at 6778c11549a636c2d284484d3242bb050a896f42

Recommendation: COMMENT. I found no code defect that blocks merge. Merge only after a maintainer accepts the scope (blocking item B1). I recommend O1 before merge; the other items are optional.

Why, what, how, impact

  • Why. A client (OmniGraph Studio) shows each branch's head and fork point. Today it reads GET /commits?branch= for each branch. That is a full lineage read per branch.
  • What. GET /branches?detail=true and omnigraph branch list --detail add a details array. Each entry has head, from, fork, actor_id, created_at and updated_at. Branch creation now records the creating actor on the new Lance ref, under omnigraph.created_by_actor_id.
  • How. The listing reads the live refs once. For each branch the caller may read, it reads the head row of the branch's latest __manifest version. When the branch has written a commit, it also reads the head row of the branch's clone at BranchContents.parent_version. It reads no lineage.
  • Impact. The client cost goes from N lineage reads to 5–8 requests per branch (the PR's moto measurement), flat in history. The cost of this is one new durable ref key, one new seam, and one new failure outcome on create.

Contract and workload I reviewed against

  • Contract. For each live branch the caller may read: head is what a read of the branch sees. fork is the source head at creation; it stays fixed for the branch lifetime. from, actor_id and created_at describe the creation, as the create receipt did. A recreated name is a new lifetime. A branch the caller may not read lists by name only.
  • Workload. An interactive branch menu. The listing is read often. Branch count is tens to hundreds. History depth grows without bound. So the cost must stay flat in history and linear in branch count.

Findings

Blocking (merge gate, not a code defect)

  • B1. No accepted issue or RFC backs the change. CONTRIBUTING.md requires one for a non-trivial PR, and the PR body says so. The scope decision matters here because the PR adds a durable key to every new ref. Draft RFC 0060 (rfc: branch operations v2 #677) plans one current branch registry. If it lands, the creator must move into that registry, or the registry must read this key. Ask a maintainer to accept the key as part of the branch format, or to defer it.

Recommended before merge

  • O1. The CI-run cost gate cannot see an extra scan per branch (branch_control_cost.rs:122). Evidence is in the inline comment. A temporary extra read_manifest_rows_projected per branch left manifest_reads at 8 and 20, and the gate passed. Only manifest_scans moved (5→7, 13→19). The S3 twin is the sensitive gate, but CI does not set a bucket for it. Gate the local slope on manifest_scan_count (measured: 2 per written branch), and give each backend its own read budget. This is invariant 13: a cost claim needs an instrument that detects the regression it claims to prevent.

Optional

  • O2. Second copy of the live-branch enumeration (branch_detail.rs:68). ManifestCoordinator::list_graph_branches holds the same rule and the same duplicate-incarnation error. The create-namespace check at branch_control.rs:173 repeats that error too. Extract one helper and use it in both listings, so that branches and details cannot disagree about which refs are live branches.
  • O3. head_is_own repeats state::exact_head (branch_detail.rs:125, :166). Use the existing function, so that "this lifetime wrote the head" has one definition.
  • O4. No test exercises the legacy fork path (branch_detail.rs:173). LegacyAt::PreGenesis fails the whole listing for the graph. Add one assertion to the storage-upgrade owner.
  • O5. Coordinate with feat(branch): start a branch at a graph commit #1016. feat(branch): start a branch at a graph commit #1016 refactors ManifestCoordinator::create_branch into create_branch_from(ds, name, version) and adds create_branch_at. Put the creator write in that one shared door, so commit-started branches also record their actor (lib.rs:2370).
  • O6. Pin the fast-forward case in the existing test (branching.rs:406). The existing merge is a true merge. I ran a temporary probe for a fast-forward into a branch that has not written; it passed (details inline). One assertion keeps that head_is_own decision covered.
  • O7. Docs (docs/user/branching/index.md). Add three facts. (a) Each branch is read on its own; the details are not one cut across branches. (b) A listing that races a create can show actor_id: null for a branch whose actor is written a moment later. (c) After an uncertain create, a retry gets 409 already exists; list the branches to confirm.

Tradeoffs and software liability

Liability removed

  • N client-side full-lineage reads per listing. Every field comes from authority that already exists: the refs, each branch's __manifest, and the collector's existing base-version retention. There is no shadow table and no queue. The Lance branch spec defines metadata (a string map) and createAt (seconds) in the ref file, so the PR uses documented format fields.

Liability added

  • One durable key on Lance refs. Retirement must preserve it, and a future registry must import it or drop it. I checked that older binaries ignore it: manifest_branch_is_live reads only the retirement key (branch_control.rs:266-287).
  • One new create outcome. A usable branch now exists while the call reports Uncertain. Only two CompletionEvidence values exist, so Uncertain is the closest fit.
  • About four more object-store requests per create that names an actor.
  • A second routine writer of ref objects. Before, only retirement rewrote a ref. Now every served create rewrites one. This makes the torn-read retry in read_branch_refs (lance.md, "Branch ref reads") carry more load.

Why the two writes cannot be avoided on Lance 11. Branches::create is pub(crate) and always writes metadata: HashMap::new(). replace_metadata is an unconditional read-then-put (lance-11.0.0/src/dataset/refs.rs, create and replace_metadata). The choice is justified on 11. At the Lance bump that accepts metadata at create, fold the actor into the create, then delete the seam and the uncertain path.

Five similar changes. If later creation provenance (for example #1016's start commit) follows this pattern, keep one post-create write that takes a map. Do not add one ref rewrite per field.

Failure and cost shape. One branch's read error fails the whole listing. This is consistent with invariant 8: no plausible partial state. Cost is about 8N requests per call with no cache. A fork is immutable for its native ref, so invariant 12 allows a version-pinned cache if listing frequency needs one. I did not measure latency, and I make no performance claim beyond the request counts below.

Verified by code inspection

  • The fork version stays readable. The collector keeps each graph branch's oldest own __manifest version (collector.rs:668-703, called at :973-974 with keep_base = branch.is_some()). Lance's clone writes that base at parent_version in the branch's own tree (lance-11.0.0/src/dataset.rs, create_branch).
  • A fast-forward merge publishes a new commit row on the target (exec/merge.rs:5208-5247). The probe confirms this.
  • Only the fork needs the legacy fallback. A live head is always stamped at the current format (legacy.rs, SourceRole::LiveHead).
  • Every create door passes the actor: POST /branches and the GQ statement (handlers.rs:2836-2876), the load's implicit fork (loader/mod.rs:426) and the embedded CLI (client.rs:1976-1980). An embedded actor is self-asserted. The dev guide correctly calls the key provenance, not authority.
  • Policy. The scope-free read check runs first. Then each branch is checked against read. A policy error stops the listing and is not treated as a denial (handlers.rs, server_branch_list).

Validation

Reviewed head: 6778c11549a636c2d284484d3242bb050a896f42. It merges main 864495a8 into 8f04f77c. I used a clean isolated worktree, Rust 1.97.1, and RUST_MIN_STACK=16777216. All commands used -j 3 --locked -p omnigraph-engine -p omnigraph-server -p omnigraph-cli --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints.

Command (suffix) Result
--test integration --test failpoints --test forbidden_apis --no-run built
--test integration -- branching:: data_routes::remote_branch_list_create_merge_flow_works auth_policy::branch_statements_take_their_routes_policy_decision auth_policy::branch_list_is_denied_by_a_branch_scoped_read_rule_as_the_route_is openapi::openapi_spec_is_up_to_date parity_matrix::parity_branch_list cli_data::branch_list_outputs_sorted_branches cli_data::catalogue_scope_reads_the_binary_registries_without_a_store engine 55 passed (54 at head, plus my temporary probe); server 4 passed; CLI 3 passed
--test integration -- branch_control_cost::branch_list_detailed_reads_bounded_per_branch_and_flat_in_history --exact passed. Reads 8 (2 branches) and 20 (6 branches); scans 5 and 13; 20 reads after 40 more commits
…_s3 twin skipped: OMNIGRAPH_S3_TEST_BUCKET was not set. Libtest reports ok, but the body did not run
--test failpoints -- branch_create_reports_an_unrecorded_creator_as_uncertain native_branch_controls_reclassify_lost_acknowledgements 2 passed
--test forbidden_apis 35 passed
Temporary probe: fast-forward into a branch that has not written passed: FastForward; fork stayed at the load commit; updated_at equals the fast-forward commit time
Temporary mutation: one extra scan per branch cost gate still passed; reads unchanged; scans 7 and 19 (O1)

I reverted all temporary edits and pushed nothing. The worktree is clean at the reviewed head.

CI at the exact head. All required checks are green: Test Workspace, Lint, Format, GQT (4 cells), DST, RustFS (2 shards), Azurite, Storage Upgrade Compatibility, the V5↔V10 fence and the server aws feature.

Not run locally. I did not run the full workspace suite, Clippy, rustfmt, the GQT corpus, DST, or S3/Azure suites. For those, I rely on CI.

Regression proof (standard 2). This is a feature, not a bug fix, so no before/after regression proof applies. The new tests call new APIs (branch_list_detailed, create_branch_as), so they cannot compile at 864495a8; I do not count that as proof. GQT cannot express this contract: the GQ branch list statement stays names-only, and the creator window needs a failpoint seam. The Rust owners the PR extends (branching.rs, failpoints.rs, server data_routes.rs and auth_policy.rs, CLI parity_matrix.rs) are the cheapest existing owners.

Unknowns. The S3 request counts are the author's moto numbers; I did not reproduce them. I did not test the legacy fork path (O4) or listing latency.

.await
.map_err(OmniError::storage)?;
let head = read_manifest_rows_projected(&branch).await?.head;
if head.native_branch.as_deref() != Some(native) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

O3 (optional). state::exact_head(head, Some(native)) (state.rs:1035) already answers "did this lifetime write the head". Use it here and at line 125, so the rule has one definition.

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.

Done in a82600d. Both sites call state::exact_head.

assert_eq!(
details["feature"].fork.as_ref(),
Some(&loaded),
"a merge does not move the fork"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

O6 (optional). This merge is a true merge, because both sides wrote. The fast-forward case also depends on the head_is_own decision. I ran it as a temporary probe: create feature, write on main, then branch_merge("main", "feature"). Result: FastForward. head was the new commit, fork stayed at the load commit, and updated_at was the new commit's time. The probe passed at this head. Add these three assertions to this test so the case stays covered.

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.

Done in a82600d. The test now fast-forwards main into an unwritten branch ff. It asserts that the merge is FastForward, that the head and updated_at follow the new commit, and that the fork stays at the creation commit.

.checkout_version(contents.parent_version)
.await
.map_err(OmniError::storage)?;
let fork = match crate::legacy::at_version(root, &created.session(), legacy, &created).await? {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

O4 (optional). No test reaches this Some(at) arm. All new tests use graphs created at the current stamp. Also, LegacyAt::PreGenesis makes served fail, and that error fails the whole listing for the graph. Add one assertion to the storage-upgrade owner (the stamp-13 / 0.11 journey that builds branches, a merge, a deleted and recreated branch, and a fork of a deleted branch). After the upgrade and one write on a forked branch, branch list --detail --json must report fork equal to the commit the branch was created at.

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 in a82600d to db::upgrade::tests::assert_converted, which runs on every converted route (stamps 13, 9 and 8). After conversion, the detailed listing reports:

  • feature, which wrote its own head, with its fork at main's commit 2. That fork is read from the legacy history at the pre-upgrade creation version, the Some(at) arm.
  • fresh, forked from feature at commit 5.
  • child, forked from the retired gone at commit 3.

On PreGenesis: it means the creation version lies below main's first commit, so the branch was created before the graph had any commit. I have no fixture that produces that history. For now it fails loudly, like any other failed read, rather than inventing a fork. If such graphs exist, fork: null would be the honest answer, and that belongs in a change that comes with the fixture.

/// created from, each with one scan of the rows. Measured at exactly 8 requests
/// on an S3 emulator (5 for a branch that has written none); a local store
/// counts 3, because Lance reads local files without the counting wrapper.
const DETAILS_PER_BRANCH_BUDGET: u64 = 8;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

O1 (recommended). On a local store this gate cannot see an extra __manifest scan per branch. I tested it. I added one more read_manifest_rows_projected(&branch) in read_branch, and the test still passed:

head with extra scan
manifest_reads (2 / 6 written branches) 8 / 20 8 / 20
manifest_scans 5 / 13 7 / 19

manifest_reads is the only gated counter. Locally it moves 3 per written branch against a budget of 8. The S3 twin is the sensitive gate, but CI sets no bucket for it.

Suggestion:

  • Gate the local slope on manifest_scan_count: exactly 2 per written branch at this head.
  • Keep a separate read budget per backend: 3 local, 8 on S3. DELETE_PER_BRANCH_BUDGET in this file already pins the exact local value.
  • Add manifest_scan_count to the history-flatness assert_flat.

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.

Done in a82600d. The local gate and its S3 twin now share one body, assert_branch_list_detailed_cost.

  • Scans are gated exactly on every backend: +1 for a branch that has written nothing, +2 for a branch that has written. The scan count must not move across DEEP_HISTORY_DEPTH commits (assert_flat with slack 0).
  • Each backend pins its own measured read budget: local 1 and 3, S3 5 and 8.

Your extra-scan mutation now fails the exact scan assertion.

let root = root_uri.trim_end_matches('/');
let main = open_manifest_dataset_with_session(root, None, control_session).await?;
let mut named = Vec::new();
for (native, contents) in list_live_manifest_branch_contents(&main).await? {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

O2 (optional). This loop repeats ManifestCoordinator::list_graph_branches (lib.rs:2472). It has the same skip of main, the same logical-name mapping and the same "more than one live native incarnation" error. The create-namespace check at branch_control.rs:173 repeats that error over a filtered subset. Extract one helper that returns (logical, native, BranchContents), sorted, and use it in both listings. Then branches and details cannot disagree after a later change to what counts as a live branch.

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.

Done in a82600d. live_named_branches in branch_detail.rs is now the only enumeration, and both list_graph_branches and list_graph_branches_detailed call it. I left ensure_manifest_branch_create_namespace alone. By design it reads only the refs that overlap the target, so that another branch's malformed retirement record cannot fail a create (lance.md, "Branch ref reads"). That makes it a different read rather than a copy.

Comment thread crates/omnigraph-catalog/src/lib.rs Outdated
let Some(actor) = actor else {
return Ok(());
};
crate::branch_control::record_branch_creator(&ds, &native, actor)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

O5 (coordination). #1016 changes this function. It moves the native create into create_branch_from(ds, name, version) and adds create_branch_at. Whichever PR lands second must put record_branch_creator in that shared door. If not, branches started at a commit list actor_id: null.

Separately, on the Lance side: Lance 11 Branches::create is pub(crate) and always writes empty metadata, so this second write is required on 11. At the bump that accepts metadata at create, fold the actor into the create, and delete BRANCH_CREATE_PRE_CREATOR and the Uncertain path.

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.

Agreed. #1016 has the same author. Whichever PR lands second moves record_branch_creator into the shared create_branch_from, so a branch started at a commit also records its actor. At the Lance bump that accepts metadata at create, the actor moves into the create, and BRANCH_CREATE_PRE_CREATOR and the uncertain path go away.

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.

Superseded: the creator is no longer recorded (81f5943), so there is no write to move into create_branch_from.

- One live-branch enumeration (`live_named_branches`) serves both
  `list_graph_branches` and `list_graph_branches_detailed`, so names and
  details agree on what is live; `head_is_own` uses `state::exact_head`.
- The cost gate pins scans exactly on every backend (one per branch that
  has written nothing, two per branch that has, flat in history) and a
  measured read budget per backend (local 1/3, S3 5/8).
- The storage-upgrade owner asserts forks served from the legacy history;
  the branching test pins a fast-forward into an unwritten branch.
- The branching guide says the listing is not one cut across branches,
  that a racing create can list without an actor, and how to confirm an
  uncertain create.
…nch-list-details

# Conflicts:
#	crates/omnigraph-cli/src/cli.rs
#	crates/omnigraph-cli/src/main.rs
#	crates/omnigraph-cli/src/output.rs
#	crates/omnigraph/src/db/mod.rs
#	crates/omnigraph/src/db/omnigraph.rs
#	docs/user/operations/server.md
@ragnorc

ragnorc commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Review addressed in a82600d, and the branch now merges main at ff9519f.

  • O1–O4, O6: replies are on each thread.
  • O5: coordination with feat(branch): start a branch at a graph commit #1016 is noted on its thread.
  • O7: the branching guide now says three things:
    • Each branch is read on its own, so the details are not one cut across branches.
    • A create still recording its actor lists with actor_id: null for that moment.
    • After an uncertain create, a retry answers 409 already exists, so list the branches to confirm.
  • B1: stays with the maintainers. They decide whether to accept omnigraph.created_by_actor_id as part of the branch ref format or to defer it.

The merge needed one adaptation to #1047: branch list --detail now lives in commands/history, and its writer is write_branch_list_human.

Locally, with RUST_MIN_STACK=16777216, on b50afff:

  • cargo test --workspace --exclude omnigraph-gqt --exclude omnigraph-gqt-served --exclude omnigraph-dst --locked --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints --no-fail-fast passed.
  • Both workspace clippy graphs and cargo fmt --all --check are clean.
  • The docs checks and typos are clean.

@cursor

cursor Bot commented Oct 18, 2026

Copy link
Copy Markdown

Review of #1012 at b50afff53ebc1e8ecc99de367bcb5be2d8dbf2cf

Recommendation: COMMENT. Merge after a maintainer accepts the scope (B1). O1, O2, O3, O4, O6 and O7 are fixed. O5 stays a landing-order note. I found no code defect that blocks merge.

Why, what, how, impact

  • Why. A client shows each branch's head and fork point. Today it reads GET /commits?branch= for each branch. That read walks lineage.
  • What. GET /branches?detail=true and omnigraph branch list --detail add a details array. Each entry has head, from, fork, actor_id, created_at and updated_at. Branch creation records the creating actor on the new Lance ref, under omnigraph.created_by_actor_id.
  • How. The listing reads the live refs once. For each branch the caller may read, it reads the head row of that branch's latest __manifest version. When the branch has written a commit, it also reads the head row at BranchContents.parent_version. It reads no lineage.
  • Impact. The client cost goes from N lineage reads to a flat per-branch read. The cost of this is one durable ref key, one seam, and one uncertain outcome on create. This follow-up makes the two listings share one live-branch enumeration, and it makes the cost gate fail when a listing adds a scan.

Earlier findings

B1. Still open (merge gate, not a code defect). No maintainer has accepted the scope. The pull request still has no accepted issue or RFC, and it still adds a durable key on every new ref. Draft RFC 0060 (#677) still plans one current branch registry. A maintainer accepts the key as part of the branch ref format, or defers it. If the registry lands later, it must import this key or drop it.

O1. Fixed. assert_branch_list_detailed_cost in crates/omnigraph/tests/integration/branch_control_cost.rs now gates scans exactly on every backend. A branch that has written nothing adds 1 scan. A branch that has written adds 2 scans. History depth uses scan slack 0. Each backend has its own read budget: local 1 and 3, S3 5 and 8.

At this head the local gate printed:

Case __manifest reads scans
One added unwritten branch +1 +1
2 written branches, plus main 8 5
6 written branches, plus main 20 13
After 40 more commits on b0 and main 20 13

The step from 2 written branches to 6 is 3 reads and 2 scans per added branch. Those are the local budgets, so <= is tight here.

I then added one extra read_manifest_rows_projected in read_branch. The gate failed. Scans were +2 and the assertion expects +1. Object reads stayed +1, because a local store does not count the file read. The scan assertion is the check that sees the extra scan. I removed the extra call.

O2. Fixed. live_named_branches in crates/omnigraph-catalog/src/branch_detail.rs is the only enumeration. list_graph_branches and list_graph_branches_detailed both call it. Names and details agree on which refs are live.

ensure_manifest_branch_create_namespace stays a separate read. It checks the source and the names that share the target path. A bad retirement record on some other branch cannot fail a create. That is the branch-ref fence in docs/dev/lance.md.

O3. Fixed. Both head_is_own sites call state::exact_head. For a named branch this is the same test as before: the head row's native_branch equals that ref. For main, exact_head(&head, None) is true when the head row names no native branch. Main has no created_at, so updated_at still uses the head time. The public main row does not change.

O4. Fixed. assert_converted in crates/omnigraph/src/db/upgrade/tests.rs checks the detailed listing on every converted route (stamps 13, 9 and 8). I ran those three tests. feature wrote its own head, so its fork is read at parent version 2 through the legacy arm. Each run printed FORK_ARM ... version=2 arm=legacy for feature. The forks match the fixture: feature at commit 2, fresh at commit 5, child at commit 3 from retired gone. child and fresh have written nothing, so the fork is a copy of the head and they do not call the legacy arm. The test still asserts those fork ids.

LegacyAt::PreGenesis still fails the whole listing. The author has no fixture whose creation version sits below main's first commit. The refusal is the fail-closed outcome in invariant 8. A fork: null answer needs a fixture that produces that history.

O5. Still open, as coordination. #1016 at 118081c8 still moves the native create into create_branch_from and adds create_branch_at. That shared door does not call record_branch_creator. Whichever pull request lands second puts the creator write in that door. The author agreed on the thread. This head has one create door, and that door records the actor.

O6. Fixed. branch_list_detailed_reports_head_fork_creator_and_lifetime now fast-forwards main into the unwritten branch ff. I ran the test. It passed. The merge is FastForward. The head and updated_at follow the new commit. The fork stays at the creation commit.

O7. Fixed. docs/user/branching/index.md now states three facts. Each branch is read on its own, so the details are not one cut across branches. A create that is still recording its actor lists actor_id: null for that moment. After an uncertain create, a retry answers 409 (already exists). List the branches to confirm the branch exists.

Merge with main (ff9519fe)

The merge conflicts were in the CLI entry points, crates/omnigraph/src/db/mod.rs, crates/omnigraph/src/db/omnigraph.rs, and docs/user/operations/server.md. The resolution keeps both sides.

  • The engine exports BranchDetail and BranchListing next to the commit-page types from feat(commits): page a branch's commit list #1013.
  • ReadCheck documents the diff continuation and the detailed listing.
  • The server route table names GET /branches?detail=true and the paged commit list.
  • --detail is a flag on BranchCommand::List in crates/omnigraph-cli/src/commands/history. write_branch_list_human writes the text. Both CLI listing paths refuse og_catalog, including --detail.
  • MCP branches_list calls server_branch_list with default query parameters. It stays names-only.

#1013 pages GET /commits. A page returns the newest commits. The fork is the source head at creation, and a later write does not move it. One page does not answer that question. The listing still removes the N lineage reads.

#1059 refuses --as on reads, including branch list. --detail does not change that class. Branch create still records an actor.

Tradeoffs and software liability

The delta removes one drift risk. The names listing and the detailed listing now share live_named_branches. A later change to "what is live" cannot update one and leave the other. The cost gate now fails when a listing adds a scan per branch. That is invariant 13: the instrument detects the regression it claims to prevent.

The liabilities from the first review are unchanged.

  • One durable key on Lance refs. Retirement inserts its key into the existing metadata map and writes that map back (retire_branch_recoverably), so the creator key survives. Older binaries read only the retirement key.
  • One new create outcome. The branch exists while the call reports Uncertain.
  • About four more object-store requests per create that names an actor.
  • A second writer of ref objects. Create writes empty metadata. replace_metadata then replaces the whole map (lance-11.0.0 Branches::create and replace_metadata). Lance 11 Branches::create is pub(crate) and cannot take metadata. At the bump that accepts metadata at create, fold the actor into the create and delete the seam and the uncertain path.

Five similar provenance fields should share that one post-create write. Do not add one ref rewrite per field. #1016's start commit is the next field of this kind, and O5 is that shared door.

One branch's read error still fails the whole listing. That matches invariant 8. Cost stays linear in branch count and flat in history. A fork is immutable for its native ref, so a version-pinned cache is allowed later if listing frequency needs one. I did not measure latency.

Validation

Reviewed head: b50afff53ebc1e8ecc99de367bcb5be2d8dbf2cf. It merges a82600d7 with main ff9519fe. The previous review head was 6778c115. Isolated worktree. Rust 1.97.1. RUST_MIN_STACK=16777216. CARGO_TARGET_DIR=/workspace/target. Commands used --locked -p omnigraph-engine --features failpoints.

Command Result
--test integration branch_list_detailed 3 tests. 2 passed. The S3 twin skipped because OMNIGRAPH_S3_TEST_BUCKET was unset. Libtest reports ok, and the body did not run. Local counts are in the O1 table
Extra read_manifest_rows_projected in read_branch, then the local cost test Failed: scans +2, expected +1. Reads stayed +1. Reverted
--test failpoints branch_create_reports_an_unrecorded_creator_as_uncertain --exact 1 passed
--lib source_root_is_converted_once 3 passed (stamps 13, 9 and 8). Each printed FORK_ARM ... version=2 arm=legacy for feature

I removed the temporary log and the extra scan. I pushed nothing. The worktree is clean at the reviewed head.

CI at this exact head. Run 39103149837 completed with success. That includes Test Workspace, Lint, Format, GQT (4 cells), DST, RustFS (2 shards), Azurite, Storage Upgrade Compatibility, the V5 to V10 fence, and the server aws feature.

Not run locally. The full workspace suite, Clippy, rustfmt, the GQT corpus, DST, and the S3 and Azure suites. For those I use the CI run above. I did not reproduce the S3 request counts 5 and 8. I did not measure listing latency.

Regression proof. This delta does not fix a bug that a .gqt case can express. GQ branch list stays names-only. The creator window needs a failpoint seam. The new assertions extend the existing owners: branching.rs, branch_control_cost.rs, and db/upgrade/tests.rs. I did not run a before-and-after GQT case. A test that cannot compile on the parent is not proof. The extra-scan run above is the proof that the O1 gate now fails for the predicted reason.

@ragnorc

ragnorc commented Oct 19, 2026

Copy link
Copy Markdown
Contributor Author

Alignment note from #1016 (branch create at a commit).

#1016 adds a second native create path. ManifestCoordinator::create_branch_at(name, version, graph_commit_id) clones the __manifest version that published a commit; GraphCoordinator::branch_create_at calls it from Omnigraph::branch_create_from_impl when the request names from_commit. Both paths share the new private create_branch_from(ds, name, version), which holds the namespace check and create_branch_recoverably.

Creator. This PR records the creator in create_branch_as, after the native create. On the commit-start path nothing records it, so a branch started at a commit would list with no actor_id. Whichever PR lands second should record the actor on both paths. The simplest way is to move the record_branch_creator step into the shared create_branch_from, so create_branch_as and create_branch_at both take actor. The commit-start test in branching.rs could then assert the listed creator.

from and fork already agree. For a commit start, the new ref's parent_branch is the incarnation that wrote the commit, so the listing's from is the receipt's from. Its parent_version is the version that published the commit, and a start creates no commit of its own, so the listing's fork is the commit the receipt reports as from_commit_id. The receipt calls that commit from_commit_id and the listing calls it fork; one shared name would read better in Studio.

Merges main at b8e82ab, including ModernRelay#1016 (start a branch at a graph
commit), and reshapes the detailed branch listing:

- The creating actor is no longer recorded. Its second write of the new
  ref added a durable key, a crash seam and an uncertain create outcome,
  and a late landing after a delete could bring the deleted ref back as
  live. The listing is now purely derived and writes nothing.
- The start is reported as `from` and `from_commit_id`, the names the
  create receipt uses since ModernRelay#1016, instead of a `fork` commit object. A
  start at a past commit clones that commit's version, so the same read
  answers it.
- Following the draft HTTP API conventions: the query parameter is
  `detailed`, and absent optional fields are omitted instead of null.
@ragnorc ragnorc changed the title feat(server,cli): report each branch's head, fork and creator in the branch listing feat(server,cli): report each branch's head, start and times in the branch listing Oct 20, 2026
@ragnorc

ragnorc commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Reworked in 81f5943 and 87bc0a8, which also merge main, including #1016, Lance 13 (#1072) and #1091.

  • No creator. The listing no longer records or reports the creating actor. Recording it meant a second write of the new ref, which came with:

    • a durable key;
    • a crash seam;
    • an uncertain create outcome;
    • a mutable ref rewrite that, if it landed late after the branch was deleted, would bring the deleted ref back as live. rfc: storage outcomes per operation and restartable ownership #1093's argument that a late ref rewrite leaves a retired ref covers retirement, but not this write.

    So B1 and O5 no longer apply. The listing writes nothing.

  • The start mirrors the receipt. The detail reports from and from_commit_id, the names feat(branch): start a branch at a graph commit #1016 gave BranchCreateOutput, instead of a fork commit object. The engine test now checks every start, including a start at a past commit, against its create receipt.

  • Draft HTTP API conventions (rfc: standardize HTTP API conventions #1089). The query parameter is detailed, and absent optional fields are omitted instead of null.

  • Coordination. feat(cli)!: replace legacy routing with explicit targets #1090 (--target) touches the same BranchCommand::List; whichever PR lands second reconciles it.

@cursor

cursor Bot commented Oct 20, 2026

Copy link
Copy Markdown

Review of #1012 at 87bc0a812e881c58f94cca539c40cac0019f3c04

Recommendation: COMMENT. B1 and O5 are resolved. I found no code defect that blocks merge. One optional landing note remains (O1).

Why, what, how, impact

  • Why. A client needs each branch's head, the place that lifetime started, and the times. A separate GET /commits?branch= for each branch walks lineage.
  • What. GET /branches?detailed=true and omnigraph branch list --detailed add a details array beside the names. Each entry has head, from, from_commit_id, created_at, and updated_at. The listing does not record or report a creating actor.
  • How. One live-ref listing serves both the names and the details (live_named_branches). For each branch the caller may read, the listing reads the head row of that branch's latest __manifest version. When that lifetime wrote the head, it also reads the head row at BranchContents.parent_version. That version is the clone create_branch and create_branch_at write. Lance 13 Manifest::shallow_clone keeps the source version number and sets the new branch name on that manifest (lance-table-13.0.0 format/manifest.rs). Branches::create stores that same number as parent_version and stores create_at as Unix seconds (lance-13.0.0 dataset/refs.rs). The engine turns those seconds into microseconds. create_branch_at refuses the clone when that version's head is not the requested commit. The receipt and the listing therefore name one commit: the head of the cloned version.
  • Impact. The client cost goes from N lineage reads to one manifest read per branch, or two after the branch has written a commit. The cost stays flat in history. The durable creator key, the second ref write, the create seam, and the uncertain create outcome are gone. The listing writes nothing.

Earlier findings

B1. Resolved. The review at b50afff5 held merge until a maintainer accepted omnigraph.created_by_actor_id on every new ref. Commit 81f59437 removes that write. The diff against main 85a46138 has no created_by_actor, record_branch_creator, BRANCH_CREATE_PRE_CREATOR, detail=true, or fork field. The server, CLI, OpenAPI document, docs, tests, and create path do not keep the key. The key was never released, so stored graphs have nothing to migrate. A late replace_metadata after delete cannot bring a deleted ref back, because that write is gone. That is the #1093 case for this write.

O5. Resolved. #1016 is on this main. create_branch and create_branch_at share create_branch_from. There is no creator write to place in that door. branch_list_detailed_reports_head_start_and_lifetime checks the start against the create receipt for an unwritten branch, a written branch, a child, a start at a past commit after that branch has written, a merge, a fast-forward into an unwritten branch, a delete and recreate, and cleanup with keep_versions: 1. The start stays on the receipt. The head and updated_at follow the new commit.

Optional

O1. Landing order with #1090. #1090 replaces BranchCommand::List { uri, json } with List { json } and calls branch_list() only. This pull request adds detailed on that same variant (crates/omnigraph-cli/src/commands/history/args.rs). Whichever pull request lands second keeps detailed and calls branch_list_detailed when the flag is set. The author already noted this on the thread.

HTTP contract

The contract stays 0.13 (HTTP_API_CONTRACT). The accepted admission RFC uses one exact header value and moves that value for a breaking wire. Draft RFC #1089 says a new optional field needs no bump, and that a breaking reshape ships in one batch with one bump. detailed is an optional boolean. details is omitted when the query is absent. from, from_commit_id, and created_at are omitted on main. The names-only body still requires branches only. operationId stays cluster_listBranches.

#1074, #1076, #1078, and #1081 each move the contract to 0.14 for an incompatible change. A rebase of this pull request onto that stack keeps 0.14. This pull request does not move the constant.

Merge with main

87bc0a81 merges main 85a46138 (parents 81f59437 and 85a46138). That main brings #1016, Lance 13 (#1072), and the static branch-route fix (#1091). The pull-request diff against that main is the listing. It does not revert those changes. GET /branches stays the list route. #1091's delete routing is untouched.

retained_versions with keep_base keeps the oldest __manifest version of a named branch (crates/omnigraph/src/db/omnigraph/collector.rs). On a branch Lance created, that version is the clone, so cleanup --keep 1 leaves the start readable. The lifetime test covers that case. writes.md states the same fact.

Sibling pull requests and draft RFCs

Tradeoffs and software liability

The delta removes four liabilities: a durable ref key, a second writer of that ref, a crash seam on create, and an uncertain create result. Five more listing fields can come from the same two manifest rows. They do not need five more ref writes. The names listing and the detailed listing still share live_named_branches. The cost gate fails when a listing adds a scan. That is invariant 13.

One branch's read error still fails the whole listing (invariant 8). A branch that disappears during the listing keeps its name and has no details. That shape is also the shape of a branch the caller may not read. The race was already in the code reviewed at b50afff5. Cost stays linear in the branch count and flat in history. I did not measure latency.

Regression proof

This delta does not fix a bug a .gqt case can express. branch list in GQ still returns the name column only. The contract lives at the engine, HTTP, and CLI listing. The assertions extend the existing owners: branching.rs, branch_control_cost.rs, db/upgrade/tests.rs, data_routes.rs, auth_policy.rs, and cli_data.rs.

On this head I changed read_branch so a written branch reported its current head as from_commit_id, then ran:

cargo test -p omnigraph-engine --features failpoints --test integration branch_list_detailed_reports_head_start_and_lifetime --locked

The test failed at crates/omnigraph/tests/integration/branching.rs:418. The message was a written branch keeps the commit it started at. The left value was the written head. The right value was the create receipt. I restored the file. The same command then passed. The worktree is clean. I pushed nothing.

A test that cannot compile on main is not a before/after proof. branch_list_detailed does not exist on 85a46138.

Validation

Reviewed head: 87bc0a812e881c58f94cca539c40cac0019f3c04. It merges 81f59437 with main 85a4613831e5047a0c988ea0ad37768fa7dff4bb. Isolated worktree. Rust 1.97.1. RUST_MIN_STACK=16777216. CARGO_TARGET_DIR=/workspace/target. Commands used --locked.

Command Result
--test integration branch_list_detailed (engine, failpoints) 3 tests reported. 2 passed. The S3 twin printed skipping: OMNIGRAPH_S3_TEST_BUCKET is not set and did not run its body
Local cost counts from that run Unwritten branch: +1 read, +1 scan. 2 written branches plus main: 8 reads, 5 scans. 6 written plus main: 20 reads, 13 scans. After 40 more commits on b0 and main: 20 reads, 13 scans. The step from 2 to 6 written branches is 3 reads and 2 scans per branch. Those are the local budgets
Same lifetime test after the temporary wrong-start edit Failed, as above. Restored. Then 1 passed
--lib converted_once_and_is_then_current (failpoints) 3 passed (stamps 13, 9, and 8)
server --test integration remote_branch_list_create_merge_flow_works 1 passed
server --test integration branch_list_is_denied_by_a_branch_scoped_read_rule 1 passed. GET /branches?detailed=true is forbidden with the names route
CLI --test integration branch_list_outputs_sorted_branches 1 passed
cargo clippy -p omnigraph-catalog -p omnigraph-api-types -p omnigraph-engine -p omnigraph-server -p omnigraph-cli --locked -- -D warnings -W clippy::dbg_macro Exit 0. This is the library target of those packages
python3 scripts/check-docs.py Documentation OK (347 Markdown files checked)

CI at this exact head. Run 39385165789 (CI) completed with success, including Test Workspace, Lint, Format, RustFS (2 shards), Azurite, Storage Upgrade Compatibility, the V5 to V10 fence, and the server aws feature. GQT run 39385165404 and DST run 39385165448 also completed with success.

Not run locally. The full workspace suite, clippy --all-targets, rustfmt, the GQT corpus, DST, and the S3 and Azure suites. For those I use the CI runs above. I did not reproduce the S3 request budgets 5 and 8. I did not measure listing latency. Local clippy did not include integration-test targets. CI Lint did.

# Conflicts:
#	crates/omnigraph-cli/src/client.rs
#	crates/omnigraph-cli/src/commands/history/args.rs
#	crates/omnigraph-cli/src/commands/history/branch.rs
@cursor

cursor Bot commented Oct 21, 2026

Copy link
Copy Markdown

Review of #1012 at 16b832f54c1bef1e013083466df80aee265279f2

Recommendation: ready. I found no blocking defect. I have no optional finding.

Why, what, how, impact

This review covers the commits after 87bc0a81. The merge 771a269d brings main 7fd9cc90, including #1090. #1090 replaces per-command URI flags with one target resolver. Git recorded conflicts in crates/omnigraph-cli/src/client.rs, crates/omnigraph-cli/src/commands/history/args.rs, and crates/omnigraph-cli/src/commands/history/branch.rs. Commit 16b832f5 updates one CLI assertion.

The resolution keeps branch list --detailed. BranchCommand::List has detailed and json only. The command calls GraphClient::from_target. It calls branch_list_detailed when the flag is set, and branch_list otherwise. The list call passes no actor. branch list does not accept --as.

The new assertion is the #1090 contract for this command. Command::Branch requires a graph target with catalogue: false (planes.rs). The name og_catalog binds as DirectCatalogue (scope.rs). validate_target refuses that pair before a credential lookup and before a backend request (planes.rs). The code is target_incompatible (scope.rs). The label is branch list (planes.rs). The same test already expects this shape for mutate on og_catalog.

--detailed does not change the requirement. The match is BranchCommand::List { .. }. Both flag values refuse in cli_data.rs. The command does not reach branch_list or branch_list_detailed, and it does not open a store.

The old assertion expected `branch list` is not available under `og_catalog`. That string is catalogue_scope_refusal on GraphClient::Catalogue (client.rs). The CLI command for --target og_catalog no longer reaches that arm. The arm remains for a catalogue client that a caller builds directly. explain load still uses the inner message, because query allows the catalogue.

The update records a refusal. It does not record a successful listing. At 771a269d the old assertion failed. The process stderr was target_incompatible: branch list is not available for this target kind and scope. The command refused. A direct graph still lists. branch_list_outputs_sorted_branches passes for the names and for --detailed.

The branch-list handler is unchanged in this range. #1074 adds require_expected_actor as a route layer. The handler needs no new parameter. HTTP_API_CONTRACT stays 0.14. The OpenAPI info version stays 0.14.0. The OpenAPI diff against merged main adds detailed, details, and the start fields. operationId stays cluster_listBranches.

branch_detail.rs is unchanged since 87bc0a81. A written branch still reads its start with checkout_version(contents.parent_version) (branch_detail.rs). Lance 13 Branches::create stores that version, create_at as Unix seconds, and empty metadata (lance-13.0.0 src/dataset/refs.rs). The branch and tag format documents parentVersion and createAt.

Earlier findings

  • O1. Resolved. feat(cli)!: replace legacy routing with explicit targets #1090 is in this head. The resolution keeps --detailed and calls branch_list_detailed when the flag is set.
  • B1. Resolved. The creator key is absent. The listing writes nothing.
  • O5. Resolved. There is no creator write to place in create_branch_from.

Sibling pull requests

Tradeoffs and software liability

The merge drops the per-command URI and uses one target gate. --detailed stays a flag on the list leaf. Another list field can use the same two manifest rows and the same gate. The names list and the detailed list share that gate, so they agree on og_catalog.

This range adds no durable key, no ref rewrite, and no new create outcome. One branch read error fails the whole served listing. That behavior is the one reviewed at 87bc0a81.

Validation

Reviewed head: 16b832f54c1bef1e013083466df80aee265279f2. Merge parents: 87bc0a81 and 7fd9cc90. Current main 50283cef is two documentation commits after 7fd9cc90 (#1109, #1112). Isolated worktree. Rust 1.97.1. RUST_MIN_STACK=16777216. CARGO_TARGET_DIR=/workspace/target. Commands used --locked.

Command Commit Result
cargo test -p omnigraph-cli --test integration cli_data::catalogue_scope_reads_the_binary_registries_without_a_store -- --exact 771a269d Failed. 1 test ran. stderr: target_incompatible: branch list is not available for this target kind and scope
same command 16b832f5 1 passed. The test checks the plain command and --detailed
cargo test -p omnigraph-cli --test integration cli_data::branch_list_outputs_sorted_branches -- --exact 16b832f5 1 passed
cargo test -p omnigraph-server --test integration data_routes::remote_branch_list_create_merge_flow_works -- --exact 16b832f5 1 passed
cargo test -p omnigraph-server --test integration auth_policy::branch_list_is_denied_by_a_branch_scoped_read_rule_as_the_route_is -- --exact 16b832f5 1 passed

A first filter without the cli_data:: prefix reported 0 tests. That run is not evidence. The rows above use the module name.

CI at this head. Run 39531431099 completed with success, including Test Workspace, Lint, Format, RustFS, Azurite, and Storage Upgrade Compatibility. GQ Logic Tests and DST also completed with success.

Not run locally. The full workspace suite, clippy, rustfmt, GQT, DST, S3, and Azure. The engine lifetime and cost tests were not rerun. branch_detail.rs, branching.rs, and branch_control_cost.rs are unchanged since 87bc0a81, and Test Workspace on this head passed. I did not measure latency.

GQT cannot express this contract. The refusal is a CLI target check, before a query. The owner is cli_data.rs. The before and after runs are that test.

I pushed nothing.

The graph access RFC gives each operation one JSON contract on the CLI,
HTTP and MCP, and ModernRelay#1139 closed the MCP rows. `branch list` now has a
detailed form on the route and the CLI, so the tool takes the same
`detailed` argument, calls the route's handler with it, and declares
`details` in its output schema. The branches resource stays names-only.
@ragnorc ragnorc changed the title feat(server,cli): report each branch's head, start and times in the branch listing feat(server,cli,mcp): report each branch's head, start and times in the branch listing Oct 26, 2026

ragnorc commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Review of #1012 at d549bcaab124e41fa43fa3a62911703e77ea939b

Recommendation: ready. I found no blocking defect. I have no optional finding.

This review covers the commits after 16b832f5. The review at that commit was ready. Two merges bring main through da1c4636 (18272d17, d286a4c5). Commit d549bcaa gives the MCP branches_list tool the detailed argument that the route and the CLI already take.

Why, what, how, impact

  • Why. The graph access RFC gives each operation one JSON contract on the CLI, on HTTP, and on MCP. After feat(mcp): expose every non-streaming graph operation as a tool #1139, branches_list existed and returned names only. GET /branches?detailed=true and omnigraph branch list --detailed already returned details. An MCP client could not ask for that body.
  • What. branches_list takes optional detailed. The default is false. With detailed, the structured content is the route body: branches plus details. The omnigraph://graphs/{graph}/branches resource stays the names body.
  • How. BranchesListArguments reads graph and detailed, and it rejects an unknown field. The tool calls server_branch_list with BranchListQuery { detailed }. That is the route handler. The output schema adds details beside branches. head stays an object. The commit fields stay on CommitOutput.
  • Impact. The three surfaces now agree on this body. The change adds no read path, no durable key, and no second handler.

Earlier findings

The listing read is unchanged. crates/omnigraph-catalog/src/branch_detail.rs has the same git blob as at 16b832f5 (6176ed8bd84e56277203b246625fa67197bd0f03). A written branch still reads its start with checkout_version(contents.parent_version). Lance 13 BranchContents stores parent_version and create_at as Unix seconds (lance-13.0.0 src/dataset/refs.rs). The branch and tag format documents those fields as parentVersion and createAt. The merges did not edit that file.

The creator key stays absent. The listing writes nothing. The #1090 target gate stays in place. I did not reopen those findings.

The one JSON contract

detailed is one optional boolean.

Surface Absent or false True
HTTP GET /branches GET /branches?detailed=true
CLI branch list --json branch list --detailed --json
MCP branches_list with graph branches_list with detailed: true

All three serialize BranchListOutput. details is omitted when the flag is false. On main, from, from_commit_id, and created_at are omitted. A detail row always has name, head, and updated_at. That is the OpenAPI required list for BranchDetailOutput.

The MCP schema types the optional fields as string or integer, and it does not mark them null. The wire omits them. OpenAPI also allows null, because the fields are Option. The running code uses skip_serializing_if, so the body has no nulls. The parity row locks that body.

head in the MCP schema is { "type": "object" }. It does not copy CommitOutput. commits_get declares no output schema. A second copy of the commit fields would drift from the API type. The body still contains the commit. The parity row compares that body with the route.

The resource calls the same handler with BranchListQuery::default(), so detailed is false (mcp.rs). Its description says it returns branch names. That is the tool's default body. A resource has no argument slot for detailed.

The operation inventory still binds one operation, branch list, to branches_list and branch list. detailed is an argument of that operation. It is not a second operation. Tool names at this head match main da1c4636. The merge of #1139 did not drop a tool, and it did not add a second branch-list tool.

At d286a4c5, after both merges and before d549bcaa, the tool still parsed GraphArguments (graph only; an unknown field is rejected) and the handler passed ApiQuery(Default::default()). The route already accepted detailed. That was the gap this commit closes.

The call site is mcp.rs. The argument struct is arguments.rs. The output fields are catalog.rs.

Sibling pull requests

Tradeoffs and software liability

The delta closes one split. Before it, an MCP client that needed a head or a start had to leave the tool and call HTTP or the CLI, or walk commits. The tool now returns the same body as those surfaces, through the same handler.

The delta adds one optional field on an existing argument struct and one optional property on an existing output schema. It adds no durable state, no ref write, and no new manifest read. Five more optional flags of this kind should be fields on the route query, passed into the existing handler, with one parity row each. They should not grow a second listing function.

One branch read error still fails the whole listing. That is the behavior reviewed at 87bc0a81. Cost stays linear in the branch count and flat in history. I did not measure latency.

Validation

Reviewed head: d549bcaab124e41fa43fa3a62911703e77ea939b. Parent of the new commit: d286a4c5. Merges since 16b832f5: 18272d17 (main d5bf9fe4) and d286a4c5 (main da1c4636). Isolated worktrees. Rust 1.97.1. RUST_MIN_STACK=16777216. Commands used --locked.

Command Commit Result
cargo test -p omnigraph-server --test integration mcp::mcp_read_parity_tools_equal_their_routes --locked -- --exact --test-threads=1 d549bcaa 1 passed. CARGO_TARGET_DIR=/workspace/target
same command, with the detailed: true parity row added temporarily d286a4c5 Failed. The tool result was isError: true, code bad_request, message invalid MCP tool arguments. CARGO_TARGET_DIR=/tmp/pr1012-parent-target
cargo test -p omnigraph-mcp --lib tests::every_tool_parses_its_required_arguments_and_writes_need_the_write_gate --locked -- --exact d549bcaa 1 passed

A filter without the mcp:: prefix reported 0 tests. That run is not evidence.

The parent failure is the gap: the tool rejected detailed, so it did not return the route body. At the head, the same row passes, so the tool body equals GET /branches?detailed=true on a graph that has main and a feature branch with its own commits. I removed the temporary row. I pushed nothing. Both worktrees are clean.

GQT cannot express an MCP argument. The owner is crates/omnigraph-server/tests/integration/mcp.rs. The case is already in d549bcaa.

CI at this head. Run 40275397060 succeeded for Test Workspace, Lint, Format, RustFS, Azurite, Storage Upgrade Compatibility, the V5 to V10 fence, and the server aws feature. GQ Logic Tests failed. The failed job is GQT (dst): case::load_csv_integer_endpoint_memory.gqt ended with replay_mismatch. The gate job then failed because that qualification failed. The diff of crates/omnigraph-gqt, crates/omnigraph/src/loader, crates/omnigraph/src/engine, and crates/omnigraph-dst against main da1c4636 is empty. Main's GQ Logic Tests run 40253020886 at da1c4636 succeeded. I do not treat that dst mismatch as a defect in this listing.

Not run locally. The engine lifetime and cost tests, clippy, rustfmt, the GQT corpus, DST, S3, and Azure. branch_detail.rs is unchanged since 16b832f5, and Test Workspace on this head passed. I did not measure latency. I did not remeasure the S3 read budgets. Those reads are unchanged.

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.

1 participant