Skip to content

feat(changes): commit entity diffs and a durable change feed - #519

Merged
aaltshuler merged 75 commits into
mainfrom
change-feed
Aug 19, 2026
Merged

feat(changes): commit entity diffs and a durable change feed#519
aaltshuler merged 75 commits into
mainfrom
change-feed

Conversation

@ragnorc

@ragnorc ragnorc commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

What & why

Implements RFC-030 C0–C3: exact first-parent commit diffs, a stateless durable change feed with caller-owned cursors, and an exact snapshot-plus-cursor baseline reset flow.

The embedded SDK, HTTP API, and CLI share the same graph-level change semantics. Public results use node/edge vocabulary and logical values; no Lance paths, table versions, fragments, or other physical storage details are exposed.

Backing issue / RFC

Checklist

  • Change is focused (one logical change)
  • Tests added/updated for behavior changes
  • Public docs updated
  • Reviewed against docs/dev/invariants.md

What changed

  • Adds bounded commit-diff, feed, and baseline surfaces across the engine, HTTP/OpenAPI, CLI, and shared API types.
  • Uses one typed row comparator for CDC and branch merge, including null-faithful nested values and managed-Blob identity tied to immutable data-file placement.
  • Runs ordered scans through a spill-capable per-operation envelope: a 150 MiB memory pool and 100 GiB scratch threshold, with bounded input batches and refusal when spilling is bypassed.
  • Caps encoded continuation tokens at 4 KiB. Short IDs remain exact; long IDs use a bounded prefix plus SHA-256 and a rare verification rescan.
  • Binds feed scope and warm named-branch freshness to the captured Lance BranchIdentifier, closing same-name delete/recreate ABA windows.
  • Streams CLI auto-pagination page by page while preserving the existing aggregate JSON bytes and withholding the durable cursor until the terminal page.
  • Projects change-route failures through a strict graph-vocabulary allowlist, with typed branch 404, diff 409, retention-gap 410, recovery 503, and redacted internal 500 responses.
  • Integrates current main, retaining the branch-control, Blob-test, and net-zero merge work while keeping CDC and merge comparison logic unified.

Contract notes

  • Feed delivery is at least once. A durable cursor advances only after a complete commit.
  • A schema boundary that cannot be decoded safely is a typed refusal; consumers reset through the baseline handshake.
  • Historical readability depends on retained manifest and participating table versions.
  • CLI baseline installation is POSIX-only and fails closed elsewhere.
  • C4 and later RFC phases remain design-stage.

Verification

  • cargo test --workspace --locked --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints — passes
  • cargo clippy --workspace --all-targets --locked -- -D warnings -W clippy::dbg_macro — passes
  • cargo clippy --workspace --all-targets --locked --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints -- -D warnings -W clippy::dbg_macro — passes
  • cargo fmt --all --check, git diff --check, scripts/check-agents-md.sh — pass
  • Focused receipts: changes 45/45, changes_cost 6/6, forbidden API guards 19/19, branching 41/41, composite_flow 3/3 on the default stack, warm-read cost/ABA 16/16, net-zero merge 11/11, and server OpenAPI 98/98.

Greptile Summary

Implements graph-level commit diffs, a durable caller-cursor change feed, and snapshot-plus-cursor baseline recovery across the engine, HTTP API, CLI, and shared wire types.

  • Adds bounded first-parent change enumeration and continuation tokens.
  • Exposes matching SDK, HTTP/OpenAPI, and CLI surfaces.
  • Adds spill-aware resource limits, historical-schema handling, and regression coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
crates/omnigraph/src/changes/feed.rs Implements bounded first-parent feed polling, continuation validation, historical-gap handling, and complete-commit cursor advancement.
crates/omnigraph/src/changes/enumerate.rs Provides the shared spill-aware ordered entity-change enumerator and page-budget behavior.
crates/omnigraph-cli/src/main.rs Adds commit-change, feed-poll, and durable POSIX baseline command dispatch.
crates/omnigraph-server/src/handlers.rs Adds HTTP handlers and graph-vocabulary error projection for the new change surfaces.
crates/omnigraph-api-types/src/lib.rs Defines shared change-feed request, response, filter, baseline, and structured-error wire types.

Sequence Diagram

sequenceDiagram
    participant Consumer
    participant Surface as SDK / HTTP / CLI
    participant Feed as Change feed
    participant Manifest
    Consumer->>Surface: Poll with start or durable cursor
    Surface->>Feed: Resolve branch-scoped position
    Feed->>Manifest: Capture first-parent cut and snapshots
    Manifest-->>Feed: Commit lineage and historical table versions
    Feed-->>Surface: Complete blocks or page token
    Surface-->>Consumer: Page token for partial commit
    Consumer->>Surface: Continue with page token
    Surface->>Feed: Resume captured cut
    Feed-->>Surface: Terminal page and durable cursor
    Surface-->>Consumer: Cursor advances past complete commits only
Loading

Reviews (18): Last reviewed commit: "Merge main into change-feed" | Re-trigger Greptile

Context used (3)

ragnorc added 14 commits August 15, 2026 21:34
A manifest-pinned Lance version that cleanup reclaimed previously surfaced as
a generic storage string from the shared dataset opener. Historical reads and
the upcoming change surfaces need to distinguish retention from arbitrary I/O
failure without parsing error text, so the opener now classifies Lance
VersionNotFound/DatasetNotFound/NotFound under an exact version pin as the
typed HistoricalVersionReclaimed error. The server maps it to an internal
error for now; change surfaces will rewrite it into their own typed gap.
Introduce the deliberate SDK types for per-commit entity changes and the
change feed: one cause per block, node/edge kind, opaque graph type identity
plus name, logical entity id, operation, and exact before/after images with
edge endpoints inside each image. Updates carry both images by contract.
Physical vocabulary (table keys, incarnations, Lance versions, row addresses)
is structurally absent from these types.

The module also owns the server-owned page/feed ceilings and the engine
mirror of the five reserved Lance virtual column names, pinned by a unit test
so exact-name matching replaces prefix heuristics that would hide legal
user properties such as _row_custom. The internal net-diff types are
untouched and remain unserialized.
Three payload kinds share one checksummed base64url envelope: a commit page
token continues one bounded commit diff, a feed cursor is the caller-owned
durable position after a complete commit, and a feed page token continues one
interrupted poll at its captured cut. Kind and version tags make every
cross-use, corruption, or unsupported-version case a typed rejection with a
reason that names both sides, and the server maps the typed rejection to a
400 with the stable 'change cursor rejected: ' prefix.

The module also owns the opaque graph type-id projection (a hash of the
persisted schema identity domain plus immutable table identity: rename-stable,
drop/re-add-fresh, numbers never exposed) and the canonical filter digest that
binds continuations to their scope, collapsing explicit match-all selections
to the same digest as an absent filter.
Derive one graph commit's entity changes against its first parent by an
ordered-by-id merge of every changed table lifetime's two pinned endpoints.
Logical operation is defined only by the two graph-visible states; physical
row lineage never becomes graph identity. Inserts carry the exact child
after-image, deletes the exact parent before-image, and updates BOTH exact
images — the merge keeps both popped rows and reads each image from its own
pinned handle.

Row equality is typed and structural: non-Blob user columns compare by Arrow
logical equality on one-row slices (null, empty string, and empty list stay
distinct — no display-string join), while Blob columns compare payload-free by
physical descriptor identity with an exact payload byte-compare only on a
descriptor tie, so compaction cannot surface phantom updates and unchanged
Blob siblings cost zero payload I/O. Images decode with the commit-era
physical schema, never the live catalog, and only the five exact reserved
Lance virtual columns are excluded.

A strict schema gate runs over every changed interval before anything is
emitted: paired lifetimes must share one user schema fingerprint and a
non-empty added or removed lifetime is a typed boundary refusal — schema
evolution is never synthesized into entity changes. The parentless genesis
commit is a typed refusal, reclaimed pinned versions become the typed feed
gap, and page continuation is an opaque checksummed token bound to the graph,
commit, and filter scope. Emission order is frozen as (nodes first, opaque
type identity, id, operation rank).

The cost instrument pins bounded opens (at most two per changed interval) and
Blob laziness flat, and pins the exact ordered-merge full-table scan term as
a growing tripwire: candidate pruning over the Lance row-version columns is
the sanctioned follow-up that must flip it to a flat assertion.
One poll captures a coherent branch cut — lineage head, Lance-native branch
incarnation witness, genesis, and the commit projection from a single
branch-pinned coordinator open — then walks the first-parent chain from the
caller's position toward that cut, emitting one block per commit through the
shared enumerator. Adjacent edges share their snapshot endpoint, so a poll
costs one manifest snapshot resolution per commit examined plus one.

The durable cursor advances only over complete commits: a page ending inside
a block carries only a page token (a partial block can never be checkpointed),
a page ending at a boundary carries the cursor with a caught-up flag, empty
blocks for physical-only commits advance the position, and a page token keeps
its originally captured cut so commits landing mid-poll stay outside it.
Start modes are explicit (now, after an exact on-chain commit, or the
beginning including inherited history on a named branch); a missing cursor is
never an implicit beginning.

The server holds no consumer state: any handle or process resumes from the
caller-owned cursor. Cursor scope is validated in a fixed order — graph
identity, graph-history genesis, branch name, branch incarnation witness
(fencing same-name delete/recreate), and filter digest — each mismatch a
distinct typed rejection. An unreadable first commit surfaces the typed feed
gap or schema boundary; a later one ends the page atomically at the previous
block boundary so the next poll surfaces the same typed error
deterministically. Commits examined per poll are bounded (128 default, 512
ceiling); the cost instrument pins the caught-up poll data-flat and the
backlog walk's per-commit terms honestly.
Recovery from a feed gap is one atomic handshake, never a bare head ID a
caller must race an export against. Under the export-cut permit — held from
before head capture until the last byte, so cleanup, schema apply, branch
replacement, and root deletion cannot remove the selected coordinates
mid-stream — the engine captures the branch's change cut coherently, streams
the data-only entity snapshot pinned AT that head commit, and mints the
cursor that resumes the feed immediately after it, bound to the same branch
incarnation and filter scope.

The snapshot honors the scope's kind and type-name dimensions; operation
filters bind only the resume cursor's feed scope, since a snapshot has no
operations. A commit landing after the capture is outside the snapshot and is
the first block the resumed feed yields; a failed export returns an error, so
a usable cursor structurally cannot outlive a broken snapshot.
Add the graph-vocabulary wire contract for commit entity diffs and the change
feed: cause stated once per block (authored_at is the authorship time,
deliberately not labeled a commit time; authored_branch is the branch the
commit originally landed on), entity changes with opaque graph type identity
plus name, logical id, operation, and exact before/after images with edge
endpoints inside each image. The finite diff and the feed carry separate
continuations: a page token continues one bounded response, the durable
cursor appears only on a terminal page together with a caught_up flag. The
baseline handshake speaks a request body plus a terminal
{"baseline": {snapshot_commit_id, resume_cursor}} stream record.

No wire field carries a table key, table or incarnation id, physical or
manifest version, row address, part marker, completeness flag, change index,
or caller byte limit — physical vocabulary is structurally absent from the
contract. ErrorOutput gains two additive rolling-safe details: a 410 feed gap
(cursor plus first unreadable commit) and a 409 diff refusal (parentless
commit or schema boundary); ErrorCode stays closed. Mapping from engine types
lives in one place here, shared by the server and the CLI's embedded arm so
the two transports translate identically.
GET /graphs/{graph_id}/commits/{commit_id}/changes returns one bounded page
of the entity changes a commit made relative to its first parent, in graph
vocabulary with exact before/after images and the cause stated once. Large
commits continue via the opaque page token.

Because the response carries row images, read authorization binds to the
branch the commit landed on — deliberately stricter than commit-show's
branch-free read. The route parses its query with a strict allow-list over
repeatable kind/type/op filters: an unknown parameter is a 400, so a caller
byte limit or physical vocabulary can never silently ride the surface.
Typed failures map onto the transport: rejected continuations are stable
'change cursor rejected: ' 400s, parentless-genesis and schema-boundary
refusals are 409s with the structured refusal detail, reclaimed history is
the server's first 410 with the structured feed-gap detail, and above-ceiling
limits stay 413.
GET /graphs/{graph_id}/changes polls one branch's first-parent change feed:
complete blocks in order, the durable cursor only on a terminal page with a
caught_up flag, and a page token when a bounded page ends inside a block so a
partial block can never be checkpointed. Position selection is explicit —
cursor, start (now | beginning | after:<commit_id>), and page_token are
mutually exclusive, and a missing position means now, never an implicit
replay of all history. Read authorization binds to the polled branch; the
strict query allow-list from the commit-diff route applies unchanged.
POST /graphs/{graph_id}/changes/baseline streams the data-only entity
snapshot pinned at one coherently captured head over the bounded export
transport (reserve before capture, two-chunk backpressure, disconnect
cancellation), then emits exactly one terminal
{"baseline": {snapshot_commit_id, resume_cursor}} record — sent only after
every snapshot record succeeded, so an interrupted stream structurally
carries no usable cursor. A baseline is a full data export and requires the
export action.

The engine side reuses the one move-only export-cut type: the served baseline
capture returns the pre-minted handshake beside a cut pinned at the captured
commit, and the source guard now registers exactly these two cut-capture
surfaces.
omnigraph commit changes <commit_id> lists one commit's entity changes with
the cause header stated once; without --page-token it auto-paginates the
whole block, with it it fetches exactly one page and prints the next token.
omnigraph changes poll follows a branch's feed from --cursor or --start
(now | beginning | after:<commit_id>), always consuming page tokens
internally and stitching split blocks back together so only the terminal
durable cursor is ever printed. omnigraph changes baseline streams the
snapshot to --out and prints the handshake; the remote arm holds back the
final NDJSON line and refuses a stream that ends without the terminal
record, so an interrupted transfer yields no usable cursor.

Both arms of every verb share the api-types filter translation, keeping
embedded and served behavior identical by construction. The commands are
ordinary data-plane verbs in the capability matrix.
Two vocabulary gates make the graph-only wire contract structural: a
data-route walk over real commit-diff, feed, and baseline-terminal responses
rejects every physical storage key (table keys, incarnations, manifest and
table versions, row addresses, part markers, completeness flags, change
indexes, byte limits), and a spec-side walk over every change schema plus the
change routes' parameters enforces the same set in the OpenAPI contract.

The auth matrix pins bearer requirements on all three routes and the policy
split: a read-scoped actor polls the feed and diffs commits but cannot
capture a baseline, which is a full data export. Parity rows drive commit
changes (with internal page-token walking), filtered diffs, a
beginning-replay poll, and the baseline handshake through both the embedded
and served arms of the CLI — on the twin fixture they must agree exactly,
cursors included.
The changes guide gains the three public surfaces: exact per-commit entity
diffs (cause once, before/after images, frozen order, empty physical-only
blocks, typed genesis and schema-boundary refusals), the durable first-parent
change feed (explicit start modes, cut pinning, block-boundary-only cursors,
at-least-once consumption, typed scope rejections), and the baseline
handshake as the sole reset after a retention gap. The server reference adds
the three endpoint rows with their auth actions and the pagination note; the
CLI reference adds commit changes and the changes poll/baseline commands;
the test map records the new engine, server, and parity owners; and the
design document records the shipped contract details (exact-merge-only v1
with its growing cost tripwire, strict schema gate, continuation bindings,
feed stop rules, baseline framing, and the manifest-history retention
dependency).
The two additive change-surface detail fields pushed ApiError past clippy's
128-byte Err ceiling, flagging every non-async function that returns it.
Shrink the type back under the ceiling instead of allowing the lint: the
message becomes a boxed str and the merge-conflict list a boxed slice, both
converted at the one IntoResponse boundary. Also settle two test-side lints
(a redundant mutable binding and a filter/next_back chain) and keep the CLI
modules' items ahead of their trailing test modules.
Comment thread crates/omnigraph/src/changes/feed.rs
Comment thread crates/omnigraph/src/changes/feed.rs
Comment thread crates/omnigraph-cli/src/main.rs Outdated
Comment thread AGENTS.md
Within one kind, blocks must emit types in lexicographic order of the opaque
entity_type.id the caller actually sees — the published continuation key —
and a bounded paged walk must resume across type boundaries on that same key.
The current implementation orders by an internal numeric identity whose
projection into the published id is a hash, so this six-type fixture fails
(719/720 of runs; the identity domain is random per graph, so a coincidental
match of the two orders is possible but vanishing). Verified failing with the
order-mismatch symptom before the fix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dd5f8c4d42

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/omnigraph/src/changes/model.rs
Comment thread crates/omnigraph/src/db/graph_coordinator.rs
Comment thread crates/omnigraph-cli/src/client.rs Outdated
…tity

Block emission and continuation resume now share one key: the PUBLISHED
opaque type identity. Interval plans compute the projection once, sort by
(kind rank, opaque id) so multi-type commits emit in the exact lexicographic
order a caller can reproduce from the response, and the continuation key
carries that same string. The commit and feed page-token payloads drop their
numeric stable-table and incarnation fields entirely — a token's decodable
JSON never exposes physical identity components, closing the gap between the
continuation contract's wording and the payload bytes (the SHA-256 tail is
integrity, not encryption). Pre-release wire format; no compatibility shim.
A page-token resume whose first remaining change exceeds the poll's byte
budget must surface ResourceLimitExceeded immediately: the resumed poll's
budget is fresh, so an empty-page exhaustion already proves the change cannot
fit any page of this size. Verified failing before the fix with the predicted
symptom — the poll returns Ok with an empty block-boundary page whose cursor
is still parked before the split commit, deferring the same typed error to
the following poll.
An empty-page exhaustion means the budget is untouched regardless of whether
the poll started fresh or resumed a page token, so the single change provably
exceeds this poll's own byte ceiling. Return the typed resource limit
immediately instead of an empty boundary page that only deferred the same
error to the following poll.
A failed 'changes baseline' run must not destroy the previous good snapshot
at --out or leave partial-file residue beside it. Verified failing before the
fix with the predicted symptom: the destination is truncated to empty because
the file is created before capture begins.
Stream the snapshot into '<out>.partial' beside the destination and rename it
over --out only after the terminal handshake completes. Rename within one
directory is atomic on the same filesystem, so a failed or interrupted
capture leaves the previous snapshot untouched and removes its own partial
file instead of truncating the destination before capture even starts.
The schema gate proves the two endpoints share one user schema with a
name-keyed, order-insensitive fingerprint, but the comparator zipped blob
signatures positionally — a physical column reorder within one lifetime would
pass the gate and then mark every blob-bearing row a phantom update. Sort the
signatures by column name at batch preparation so the zip aligns by name.
Defense in depth: no supported writer reorders columns within a lifetime
today, so there is no integration reproduction; the sort simply makes the
comparator honor the same order-insensitivity the gate does.
The design document's continuation section lists enforced bounds among a
page token's bindings; the shipped v1 deliberately binds identity, scope, and
position only — row limits stay per-request and byte ceilings server-owned —
so replay is position-stable rather than page-size-stable. Record that
deviation with its rationale, and note that continuation positions are now
keyed by the published opaque type identity so token payloads carry no
numeric physical components.
Keying resume and ordering by the published opaque identity left the raw
table interval unread on the plan; keep only the fields the enumeration
consumes.
@ragnorc

ragnorc commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Review findings from the automated pass have been dispositioned; nine follow-up commits are on the branch:

Fixed

  • Physical table identities readable in page tokens + block order vs the published type id — continuation keys and block ordering are now both keyed by the published opaque type identity; token payloads carry no numeric table or incarnation components. Pinned by commit_changes_blocks_order_by_published_type_id (test-first).
  • Feed resume hides oversized change — partially confirmed: the typed resource limit did surface, but only on the following poll behind a confusing empty page. An empty-page exhaustion now returns the typed limit immediately. Pinned by change_feed_resumed_oversized_change_is_a_typed_resource_limit (test-first).
  • Baseline truncates the previous snapshot — the CLI now streams into a sibling .partial file and atomically renames over --out only after the terminal handshake. Pinned by changes_baseline_failure_preserves_existing_out_file (test-first).
  • Blob reorder causes phantom updates — unreachable via supported writers (no writer reorders columns within a lifetime, and a drop/re-add changes the stable-property marker the schema gate already refuses), taken as defense in depth: blob signatures now compare by column name, matching the gate's order-insensitive fingerprint.

Resolved by documentation

  • Bind pagination limits into continuation tokens — deliberate deviation, now recorded in the design document's implementation amendment: tokens bind identity, scope, and position; replay is position-stable, not page-size-stable, because binding bounds would reject legitimate client reconfiguration with no correctness gain.

Comment thread crates/omnigraph-cli/src/main.rs Outdated
The RFC-030 §4.4 obligation to prove the ordering path bounded is not
discharged. Verified against pinned Lance 10.0.0 source: an ordered-by-id
scan plans as a single-partition SortExec with no fetch, backed by an
UnboundedMemoryPool with spill structurally disabled (no Scanner knob, no
env override), so resident memory is O(table projected width) — embeddings
included. Shared by branch merge, the change enumerator, the commit diff,
and export; it is the mechanism behind the recorded branch_merge OOM.

Sharpen merge-complexity.md from 'high memory/spill risk' to the precise
fact, add the open obligation to RFC-030 §14, and name the instance in the
invariants resource-bounds gap.
…ow_ props

The cross-branch net diff compared rows by a display-string signature that
rendered null and "" identically and skipped every _row_-prefixed column.
Two regression cells prove a null->"" flip and a change to a legal
_row_notes property are both silently dropped (the change set comes back
empty). RED against the current comparator; the next commit turns them green.
commit_snapshot re-proves each commit's manifest head, but plan_intervals then
opens the per-table datasets SEPARATELY, by (branch path, numeric version) using
the warm session. A named branch deleted and recreated at the same path and
version AFTER the head proof but before the table open retargeted the open to
the replacement branch's rows, which the feed emitted under the captured
commit's label — a second ABA window the earlier commit_snapshot fix (first
window) did not cover.

Add TableStore::open_at_entry_verified: for a named-branch entry it opens
cache-bypassing (so a stale warm handle cannot mask the retarget) and requires
the opened dataset's manifest e_tag to match the entry's recorded incarnation,
failing closed with the same typed refusal otherwise. Main entries cannot ABA
and keep the warm path. plan_intervals uses it, guarded by a new
CHANGE_FEED_PRE_TABLE_OPEN failpoint.

The new failpoint cell isolates a single commit (start after its parent) so only
the table open — not a later commit's commit_snapshot — can catch the
recreation. Verified RED: with the unverified open the poll emits the
replacement branch's "new" row as the feature commit's change; GREEN after.

Residual: if version_metadata.e_tag() is ever absent, only the manifest-head
proof covers the window; the e_tag path closes it on local FS (synthetic
inode/mtime/size e_tag) and object stores.
The engine scope carries only `type_names` and the HTTP `type` parameter is
interpreted only as a current type name, but §3.3 read as if an opaque type
identity were also a filter dimension. Supplying an emitted opaque type ID
therefore matched nothing silently. Amend the RFC to say type filtering is
by-name-only and that the opaque identity on emitted changes is a display/join
key, not a filter input — closing the over-promise rather than building an ID
filter dimension across engine, cursor digest, HTTP, CLI, and baseline (F6).
Human `commit changes`/`changes poll` output was lossy in two ways: an
endpoint-moving edge update printed only `after.or(before)` endpoints, hiding
the old pair, and `render_change_value` rendered a JSON null identically to the
literal string "null" while a missing key and an empty string both rendered
empty.

Render an endpoint move as `old_from -> old_to => new_from -> new_to` (insert,
delete, and endpoint-preserving updates keep the single pair), render JSON null
as the sentinel `<null>` (so the string "null" stays `null`), and render an
absent key in a diff as `<absent>` — so null, "null", empty, and absent never
collide. `--json` output is unchanged and remains the exact form. Unit tests
pin the four ambiguous states and every endpoint case (F7).
The baseline install fsync'd the temp file and atomically persisted it over
`--out`, then tried to fsync the parent directory with
`if let Ok(dir) = File::open(..)` / `let _ = dir.sync_all()` — swallowing both
the open failure and the sync failure. A resume cursor could then print for a
rename that never reached disk, exactly the acknowledged-before-durable shape
the fsync barrier exists to prevent.

Extract a `sync_dir` helper that on Unix opens the directory and fsyncs it,
propagating any failure so the caller's `?` aborts before the cursor prints; on
non-Unix it is a documented no-op (a directory is not a file-fsync durability
primitive there and opening one as a file fails). A unit test proves `sync_dir`
returns an error instead of swallowing it (F5).
The backlog cost test measured only manifest and data reads, so it could not
see the CPU/allocation term: a poll clones the whole backlog (cut head → cursor)
into its first-parent chain via `chain_after`, which grows with the backlog even
when the page ceiling is small — invisible to the IO counters.

Add a `feed_commits_visited` probe following the existing `graph_build_count`
idiom (no-op in production, task-local unset), record the chain length at the
poll, surface it through the shared cost harness `IoCounts`, and assert in the
backlog test that it equals the backlog and grows with it. This makes the
bounded-visit fix (a forward-child projection, tracked as the B1 follow-up)
measurable rather than a silent regression surface (F4).
Correct RFC-030 §14's managed-Blob identity bullet (the fragment-id qualifier
was superseded by the immutable data-file path), add a second-review-round
amendment covering the data-file identity (P1-A), the shared typed merge
comparator (P1-B), the per-table-open incarnation re-prove (P1-C), the
feed_commits_visited counter (F4), and the F5-F7 polish. Extend the testing.md
map: the Overwrite same-length Blob CDC cells, the nested-list merge cell, the
second-window feed ABA failpoint, and the feed_commits_visited backlog pin.
@ragnorc

ragnorc commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Second review round — addressed

A second review pass found three correctness gaps and four polish items, all now fixed on this branch (full failpoint-superset workspace suite green: 76 test binaries, ~2195 tests).

Correctness

  • Managed-Blob identity is the immutable data-file path, not the fragment id. The fragment-id qualifier still aliased a same-length managed-Blob update across a full-table Overwrite (fragment ids reset to 0) as unchanged — a reachable silent data-loss gap in both the commit diff and the feed. Now qualified by the data file's globally-unique immutable path, resolved I/O-free from the row's fragment. New regression: commit_changes_detects_same_length_blob_update_after_overwrite + its feed twin.
  • Merge classification uses the shared typed comparator. The three-way merge classifier compared rows via array_value_to_string, which is not injective for nested Arrow values — a [String] change from ["a, b"] to ["a","b"] rendered identically and was dropped as a no-op. It now routes through the same rows_equal the diff/feed use. New regression: branch_merge_detects_nested_list_value_change.
  • The feed re-proves branch incarnation at the per-table open (second ABA window). The manifest-head proof left a second window: the per-table datasets are opened separately by (branch path, version), so a delete/recreate between the proof and the open could retarget to a replacement branch. The open now bypasses the warm cache and requires the reopened dataset's manifest ETag to match the recorded incarnation. New failpoint cell: change_feed_poll_refuses_delete_recreate_aba_before_table_open.

Polish

  • Baseline install now propagates a parent-directory fsync failure instead of swallowing it (no resume cursor prints for a rename that is not durable).
  • Type filtering documented as name-only (the opaque type id on emitted changes is a display/join key, not a filter dimension).
  • Human change output distinguishes an endpoint-moving update (old -> old => new -> new), a JSON null (<null>), the literal string null, an empty string, and an absent key. --json output is unchanged and remains exact.
  • Added a feed_commits_visited cost probe so the backlog walk's CPU/allocation term (invisible to the IO counters) is measurable; a bounded forward-child projection is the tracked follow-up.

Comment thread crates/omnigraph-cli/src/main.rs

Copy link
Copy Markdown
Collaborator

Current-head re-review 1/2 — correctness and authorization

Reviewed exact head 810e093c. Several earlier defects are fixed, but I still don't think this is merge-ready.

  1. Branch normalization creates a policy bypass. The feed and baseline handlers authorize the caller's raw branch string, while the engine later trims it. With a policy that permits read/export on unprotected branches, branch=%20main%20 or {"branch":" main "} is classified by Cedar as unprotected and then resolves to protected main. Normalize once before both authorization and engine use, and add feed + baseline policy tests.

  2. Direct commit diff can retarget after named-branch delete/recreate. commit_changes_page resolves commit C to (manifest_branch, manifest_version), then reopens whatever currently occupies those coordinates without checking that the reopened snapshot still reports C as that branch's graph head. A replacement branch can reuse the numeric version, so both child and parent may come from replacement state and the later per-table ETag check cannot detect the earlier retarget. The feed's commit_snapshot already performs the missing graph_head == commit_id proof; the finite diff needs the same structural check.

  3. Warm feed refresh installs a new head beside stale lineage. ManifestCoordinator::refresh_for_live_read returns no lineage rows whenever the refreshed state has an exact graph-head row; GraphCoordinator consequently leaves commit_graph unchanged. After another handle/process commits, the next poll sees the new head but cannot find it in the old map (lineage chain is missing commit), and subsequent polls stay stale.

  4. The per-table ABA guard still fails open when its ETag witness is absent. open_at_entry_verified checks only under if let Some(expected); otherwise it accepts the reopened dataset. The local failpoint test passes because pinned Lance synthesizes an ETag for that fixture, but it does not exercise the permitted None state. Missing expected or actual incarnation evidence should fail closed or use a different persisted witness.

  5. The feed-wide byte budget resets semantically at each commit. emitted_this_call is local to one enumerate_commit_changes invocation. Once PageBudget.remaining_bytes reaches zero, the first change of every later commit is still force-emitted as a “solo” oversized change. One response can therefore carry one over-budget event per commit, potentially hundreds, rather than one oversized event on its own page.

These are current-code findings even where a bot thread is marked resolved.

Copy link
Copy Markdown
Collaborator

Current-head re-review 2/2 — boundedness, durability, and shipped status

The public graph-vocabulary direction is good, but the implementation and RFC status still overstate the evidence:

  1. RFC-030's mandatory ordering bound remains open. OrderedRows::open asks Lance for order_by(id) before page accounting. On pinned Lance 10 this is a single-partition SortExec backed by UnboundedMemoryPool, so a one-row page can first materialize/sort a whole wide table. RFC §4.4 says the implementation must not do this, while §14 admits it still does and the header nevertheless says C0–C3 are “Accepted/shipped.” This should remain unaccepted until the bound is real, or the public phases should be narrowed.

  2. Poll CPU/allocation still grows with total history. Every cut clones and sorts the full commit map, rehashes it, and walks to genesis. It then clones the full unread chain before applying max_commits. The new cost test proves flat manifest I/O, not flat hot-path CPU or allocation; a caught-up poll remains O(history), and a small backlog request walks all unread history before its ceiling.

  3. Concurrent CLI baseline captures can detach the output file from the returned cursor. Unique O_EXCL temp files fixed staging collisions, but two processes can still replace the same --out and then print their own cursors without exclusion. One process can print cursor H2 after another process has replaced the file with snapshot H1, causing skipped changes. Hold an output lock through replace + cursor delivery, return a versioned output path, or atomically store the cursor with the snapshot.

  4. The durability claim is false on supported non-Unix targets. sync_dir is an unconditional no-op there, yet the command prints a cursor claiming the rename is durable. On Windows, syncing the temporary file before MoveFileExW does not durably flush the namespace replacement. Implement a durable/write-through replace or fail closed before printing the cursor.

  5. CLI auto-pagination still defeats page bounds. The private client appends every commit page and feed block into aggregate Vecs before producing output. A large merge/backlog can OOM the client even if every HTTP page is bounded. This is also the one still-unresolved review thread and should not be called shipped SDK/CLI behavior while deferred. The public engine SDK does not currently provide the promised streaming auto-pagination helper either.

  6. The OpenAPI graph-vocabulary gate is incomplete. Change operations reference generic ErrorOutput, whose reachable schemas still contain table_key-bearing merge/manifest/key-conflict shapes. The test scans only component names beginning with Change/CommitChanges/EntityChange rather than traversing operation refs. Baseline's successful NDJSON response also has no schema, so generated clients cannot discover the entity records or terminal cursor handshake.

CI is nominally green at this head, but the canonical workspace/failpoint suite, RustFS integration, and V5↔V6 fence were skipped. I would fix the authorization and snapshot-coherence defects first, remove the “accepted/shipped” claim while the mandatory gates are open, and then re-review C2/C3 after ordered scanning, history traversal, and client output are genuinely bounded.

The second-window ABA witness was the manifest e_tag alone, which is not a
sufficient native-branch incarnation witness: a store may persist no table
e_tag (the check silently skipped when the stored value was absent), and
content equality is a heuristic, not identity — the repository's own release
notes state a manifest e-tag is not a substitute for an incarnation witness.

Make the witness logical and store-independent: after plan_intervals opens the
per-table datasets, every named branch it opened is re-proven by a fresh,
cache-bypassing manifest snapshot at the same pinned version, which must still
report the graph_head the enumeration's captured snapshot carries. A recreated
fork's manifest at that version carries different lineage commit ids (or lacks
the version), so the poll fails closed on every store; the per-open e_tag
comparison is retained as defense-in-depth. Main pays no extra resolution.

A new CHANGE_FEED_SKIP_ETAG_WITNESS behavior seam (boolean is_enabled
failpoint, not an error injection) simulates an e_tag-less store; the new
failpoint cell combines it with the pre-table-open park plus a branch
delete/recreate and requires the typed refusal. Verified RED with the re-prove
disabled: the poll emitted the replacement branch's row under the captured
commit's label; GREEN with it.
A poll cloned the complete head-to-cursor backlog into a Vec before the
max_commits ceiling was consulted, and walked that chain a second time for
on-chain cursor validation (resolve_position -> is_on_chain repeated the same
head-down traversal). A max_commits=1 poll over a large backlog therefore
performed roughly two full backlog walks and a backlog-sized allocation, and
the feed_commits_visited counter recorded only the second walk.

Build a forward first-parent child index during cut construction (the same
walk that finds genesis, so no extra asymptotic cost), make on-chain
validation O(1) membership, and walk the chain FORWARD from the cursor
collecting at most max_commits plus one sentinel element that proves more
commits remain — the boundary/caught_up semantics are unchanged. The counter
now records the complete chain-walk term.

New cost cell pins a max_commits=1 poll flat across backlog depths (visited,
manifest reads, and data opens all bounded by the ceiling); the existing
backlog cell keeps the unbounded-ceiling walk pinned at exactly the backlog.
Every poll rebuilt the cut from scratch: clone the full lineage projection
into a map, walk to genesis, and (now) build the child index — O(total
history) CPU and allocation per poll even when caught up, invisible to the
manifest-IO counters the warm-cut work made flat.

A cut is a pure projection of __manifest, so it is exactly valid while the
manifest incarnation (version + e_tag/timestamp witness) is unchanged — the
same probe the warm poll already pays. Cache one Arc'd cut per handle keyed by
that incarnation: a caught-up same-branch poll now reuses it at O(1), any
publish/refresh misses and rebuilds, and invalidate_read_caches clears it
alongside the other read caches (the incarnation key carries correctness; the
clear is the same hygiene the handle caches get). Cross-branch polls keep the
uncached capture path.
…e guard

Two baseline-install gaps: (1) on non-Unix platforms sync_dir was a silent
no-op while the printed resume cursor still claimed the rename was durable —
and std's rename is not write-through there, so a crash could lose the
namespace replacement after the cursor printed; (2) two processes capturing to
the same --out could each atomically replace it and print their own cursor, so
one could print a cursor for a snapshot the other had already replaced — a
consumer restoring that file and resuming from the printed cursor would skip
the changes between the two snapshots.

Fail closed at command entry on non-Unix (before any capture work) with a
typed message naming the missing durability barrier; a durable/write-through
replace is the sanctioned future lift. After the install barrier, re-read the
installed file's terminal baseline record (bounded tail read) and refuse to
print a cursor that does not match it — the file's own terminal record remains
the authoritative pairing for any later reader.
The change routes' spec referenced the generic error envelope, whose reachable
schema graph carries the write-path conflict shapes with physical storage keys
— so generated clients saw storage vocabulary the runtime projection never
emits, and the existing vocabulary gate missed it because it scanned only
Change*-prefixed component names.

Add ChangeErrorOutput: a wire-compatible projection of the error envelope
restricted to what the read-only change routes can produce (code, message,
resource_limit, change_feed_gap, change_diff_refusal). Point all three change
routes' error responses at it, and give the baseline's NDJSON success response
a schema (the terminal ChangeBaselineRecord handshake) so clients can discover
the cursor contract. A new reachability gate walks every schema transitively
referenced by the change operations and requires storage properties absent and
the generic envelope unreachable — closing the name-prefix blind spot.
The RFC header claimed C0-C3 shipped without qualification while two mandatory
obligations remain open; name them in the status (the §4.4 ordered-scan memory
bound and bounded client auto-pagination) and annotate the C2 phasing row.
Record name-only type filtering as a deliberate v1 scope decision with the
sanctioned ID-filter extension path. testing.md maps the e_tag-less second-
window ABA cell (logical post-open witness), the small-ceiling bounded-poll
cost cell, and the OpenAPI reachability gate.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 6361039. Configure here.

Comment thread crates/omnigraph-cli/src/main.rs
Comment thread crates/omnigraph-cli/src/main.rs Outdated
@ragnorc

ragnorc commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Third review round — all findings addressed

Two further review passes (one implementation review, one current-head re-review) surfaced four correctness/consistency findings and six status/boundedness findings. All were validated against the code and the pinned Lance 10.0.0 source; every confirmed defect is fixed on this branch (full failpoint-superset workspace suite green: 76 binaries / 2207 tests here, 81 / 2287 on the follow-up branch).

Correctness

  • Branch-ABA witness is now logical, not an e_tag. The per-table e_tag comparison silently skipped when the stored e_tag was absent, and an e_tag is not a sufficient native-branch incarnation witness. After the per-table opens, every opened named branch is re-proven by a fresh cache-bypassing manifest snapshot at the pinned version whose graph_head must match the captured one — store-independent, so it holds on e_tag-less substrates. Verified RED (with the re-prove disabled and an e_tag-less-store seam active, the poll emitted the replacement branch's row) → GREEN. The e_tag check remains as defense-in-depth.
  • Candidate pruning requires loadable row-version metadata per changed fragment. Confirmed in the pinned Lance source: a missing — or even failed-to-load — _row_last_updated_at_version sequence silently defaults the column to 1, which would empty the candidate window for begin > 1 and drop real updates. The classifier now requires present, decodable metadata on every changed fragment; any gap falls back to the exact merge. The pruned cost/image tests staying green are the live proof real keyed-write fragments pass.
  • max_commits now bounds feed work. The poll cloned the whole unread backlog (and walked it twice — validation repeated the traversal) before consulting the ceiling. Cut construction now builds a forward first-parent child index in its existing genesis walk; on-chain validation is O(1) and the chain walk collects at most max_commits plus one sentinel. A new cost cell pins a max_commits=1 poll flat (visits, manifest reads, data opens) across backlog depths.
  • Caught-up polls are O(1) CPU. The cut (a pure projection of the manifest) is cached under the manifest incarnation the warm poll already probes; any publish misses and rebuilds.

Durability / client honesty

  • changes baseline fails closed on non-POSIX platforms at command entry (the directory-fsync durability barrier does not exist there; a durable/write-through replace is the recorded lift), and a concurrent-capture guard re-reads the installed file's terminal record and refuses to print a resume cursor that does not match it.

Spec / status

  • The OpenAPI vocabulary gate is now reachability-based. The change routes referenced the generic error envelope, whose reachable schema graph carried storage-keyed conflict shapes. They now reference a graph-vocabulary ChangeErrorOutput projection, the baseline NDJSON response declares its terminal ChangeBaselineRecord schema, and a new gate walks every schema transitively referenced by the change operations, requiring storage properties absent and the generic envelope unreachable. (SDK note: the vendored spec copy needs a re-sync after merge.)
  • RFC status no longer overstates. The header names the two open obligations gating full acceptance — the §4.4 ordered-scan memory bound (shared merge/diff/export debt) and bounded client auto-pagination — and the C2 phasing row is annotated. §11 audits the persisted no-delete marker explicitly (no format bump: read-advisory in every direction), and §3.3 records name-only type filtering as a deliberate v1 scope decision with the sanctioned ID-filter extension path.

Still open, by design and now recorded in the RFC header: the §4.4 bounded ordered read and streaming client auto-pagination — both tracked follow-ups rather than silent claims.

The concurrent-capture guard expected the terminal handshake record inside the
snapshot file, but the baseline file contract is deliberately snapshot-only —
the handshake goes to stdout. Every successful capture therefore bailed after
persisting and never printed a resume cursor. No test caught it: the parity
cell compares the two arms to each other (two identical failures count as
parity) and the only direct CLI cell asserts the failure path.

Replace the content probe with witnesses that match the contract: an exclusive
flock on a stable .lock sibling serializes cooperating captures of the same
--out from before staging through cursor delivery (closing the
validate-then-replaced-before-print window between omnigraph processes), and a
file-identity check (device+inode of the File persist() returned versus the
path) refuses to print a cursor when any other writer replaced --out after the
install. Both are Unix-only, like the rest of the install barrier.

New cell pins the success path end-to-end — exit 0, a non-empty paired
handshake on stdout, snapshot rows in the file, and no handshake record inside
it — so a guard that breaks successful captures can never pass the suite again.
…e gaps

Three defects from the same review batch, each proven red first:

Warm refresh paired a new head with stale lineage. refresh_for_live_read took
the state-only fast path whenever the branch head row merely existed, so after
another handle committed, the refreshed head was absent from this handle's
lineage projection and every later poll failed with 'lineage chain is missing
commit' — permanently. The fast path now requires the durable head to be a
commit the projection already contains; otherwise lineage is re-read
atomically with the state. Red test: a second handle's commit broke the first
handle's next poll with exactly that error; green: the poll emits the foreign
commit and stays healthy.

Snapshot-by-commit resolution could retarget after branch delete/recreate. The
shared Snapshot resolution reopened (branch, version) and echoed the request's
commit id without proving the reopened manifest still names it as that
branch's head — the same structural proof the feed's commit_snapshot performs,
now applied at the one shared resolution point so the finite commit diff and
every snapshot-by-commit consumer inherit it. The two existing branching ABA
cells now exercise this earlier structural refusal (their exact-message pins
widened to the shared incarnation-witness phrase; the Blob-level witness
remains for the windows commit resolution cannot see).

An exhausted byte budget leaked one oversized change per commit. The
solo-oversized forward-progress rule is scoped per enumerate call, and the
poll loop only checked remaining rows — so once bytes hit zero, every later
commit force-emitted its first change. The loop now stops at the block
boundary on zero remaining bytes. Red test: three oversized commits arrived
as three solo blocks in one page; green: one per page across three polls.
The feed and baseline handlers authorized the caller's raw branch string while
the engine trims it later, so a padded spelling like ' main ' was classified
by Cedar as an unprotected named branch and then resolved to protected main —
a branch-scope policy bypass. Normalize once (trim; empty-after-trim is a 400)
before both authorization and the engine call, so Cedar and the engine judge
the same identity. The export route carried the identical pattern and gets the
same normalization (baselines are exports of the same data).

New policy cell: under an unprotected-only read/export rule with main
protected, branch=%20main%20 on the feed and a padded baseline body are both
403 exactly like plain main; before the fix the padded baseline streamed
main's full export.

Residual, deliberately out of scope: several pre-existing routes (query,
change, load, snapshot, branch controls) authorize the same raw shape and
deserve one audited normalization pass with per-route policy tests as a
follow-up.
@ragnorc

ragnorc commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Fourth round — re-review 1/2 dispositions (all five validated; four fixed, one already closed)

All five findings from the correctness/authorization re-review are addressed on this branch, plus one functional repair the bots caught in my own previous fix. Full failpoint-superset workspace suite green (76 binaries / 2211 tests), each defect proven red first.

  1. Branch normalization policy bypass — fixed. The feed and baseline handlers authorized the caller's raw branch string while the engine trims late, so branch=%20main%20 was classified by Cedar as an unprotected named branch and then resolved to protected main. Both routes (and the export route, which carried the identical pattern) now normalize once before authorization and use the same normalized identity for the engine call. New policy cell: under an unprotected-only rule with main protected, the padded feed query and padded baseline body are both 403 exactly like plain main — the diagnostic run before the fix showed the padded baseline streaming main's full export. Residual, flagged for a separate audited pass: several pre-existing routes (query/change/load/snapshot/branch controls) authorize the same raw shape.

  2. Direct commit diff delete/recreate retarget — fixed. The shared snapshot-by-commit resolution reopened (branch, version) and echoed the requested commit id with no proof. It now requires the reopened manifest to still name that commit as its branch's graph head — the same structural proof as the feed's commit_snapshot, applied at the one shared resolution point so the finite diff and every snapshot-by-commit consumer inherit it. The two existing branch-ABA cells now exercise this earlier structural refusal.

  3. Warm refresh pairing a new head with stale lineage — fixed. refresh_for_live_read took the state-only path whenever the head row existed, so a commit from another handle broke every later poll with 'lineage chain is missing commit', permanently. The fast path now requires the durable head to be a commit the lineage projection already contains. Red test reproduced the exact permanent failure; green: the poll emits the foreign commit and stays healthy.

  4. ETag-less per-table ABA — already closed by the earlier logical-witness fix. The load-bearing second-window witness is now reprove_named_branch_heads (fresh manifest reopen comparing graph_head at the pinned version — store-independent), with the per-table e_tag comparison demoted to defense-in-depth. The e_tag-less state is exercised directly by the CHANGE_FEED_SKIP_ETAG_WITNESS seam cell, which was proven red with the logical witness disabled.

  5. Byte budget resetting per commit — fixed. The poll loop only checked remaining rows, so once bytes hit zero every later commit force-emitted its first change as another 'solo' oversized event. The loop now stops at the block boundary on zero remaining bytes; red test showed three solo blocks in one page, green shows one per page across three polls.

Also this round: the bots caught that my previous concurrent-capture guard expected the baseline handshake inside the snapshot file — which the file contract deliberately never carries — so every successful capture failed after persisting. Repaired with contract-true witnesses (an exclusive lock on a .lock sibling serializing cooperating captures through cursor delivery, plus a device+inode identity check on the installed file), and the missing success-path CLI cell now pins exit 0 + paired handshake + snapshot-only file, closing the gap that let the break through (the parity cell counts two identical failures as parity).

@aaltshuler
aaltshuler merged commit da466ba into main Aug 19, 2026
10 checks passed
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