Skip to content

Add blob upload: PUT /graphs/{id}/blob and omnigraph blob put#372

Closed
ragnorc wants to merge 4 commits into
mainfrom
claude/console-grade-read-surface-a0d9h4
Closed

Add blob upload: PUT /graphs/{id}/blob and omnigraph blob put#372
ragnorc wants to merge 4 commits into
mainfrom
claude/console-grade-read-surface-a0d9h4

Conversation

@ragnorc

@ragnorc ragnorc commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

What & why

Adds the blob upload half of the blob surface that merged in #367: PUT /graphs/{id}/blob accepts raw payload bytes as the request body (no base64, no JSON envelope) and materializes them into one existing row's Blob cell as a single atomic graph commit, with omnigraph blob put exposing the same contract over both the embedded and remote arms. This closes the workflow that previously required chunking: a 33 MB PDF whose base64 form (~44 MB) died at the 32 MiB ingest body limit now uploads with curl -T big.pdf in one call — verified live end-to-end (200 in ~0.3 s, SHA-256-identical GET round-trip, stale If-Match → 412).

Backing issue / RFC

Checklist

  • Change is focused (one logical change: the blob upload surface, in four reviewable commits — engine writer, HTTP endpoint, CLI verb, parity rows)
  • Tests added/updated for behavior changes (7 engine tests incl. If-Match flow and multi-blob-column carry; 5 route tests incl. 412/413; 4 CLI tests; 4 parity rows with exact cross-arm ETag equality; forbidden_apis registry extended)
  • Public docs updated (server.md blob-upload contract incl. the two Hyrum-facing notes; CLI reference; OpenAPI regenerated)
  • Reviewed against docs/dev/invariants.md — no new writer kind: write_blob_at composes the Mutation-v9 protocol via a shared publish-tail helper extracted from mutate_one_attempt, so recovery classifies its sidecar identically to a one-row mutation; If-Match parsing stays at the transport boundary (invariant 11); the write is admission-gated like every write endpoint

Notes for reviewers

  • Semantics: update-only (404 on a missing row — create the row via load/mutate, then attach the payload); optional If-Match (strong comparison, entity-tag lists, * = row exists) answers 412 with the current validator; unconditional PUT is last-writer-wins. Snapshot addressing is rejected on the write.
  • Deliberate retry-classification divergence: unlike .gq updates (retryable = false), the blob PUT reprepares on pre-effect ReadSetChanged — it is a declarative full-cell replacement whose whole read set (row scan + precondition) is re-derived per attempt, which is exactly what makes last-writer-wins absorb unrelated conflicts AND keeps If-Match evaluated against the fresh base. Commented at the site.
  • Budget contract: the effective max payload is 32 MiB minus the row's other materialized content; the replaced cell's old payload is neither read nor charged (a new target-column-excluding variant of the materializing scan). A row whose other blob columns hold external references has them inlined by the write — the already-documented update behavior.
  • BlobWriteOutcome::PreconditionFailed is a #[must_use] Ok variant, not an error — 412 is a well-formed answer, and the closed error taxonomy stays untouched. Both CLI arms map it to one identical typed error (parity-pinned).
  • Also fixes a stale server.md line claiming external blob sizes are recorded at load time (removed by the canonical-shape fix in Replace read_blob with descriptor-first read_blob_at facade #367).

🤖 Generated with Claude Code

https://claude.ai/code/session_0149LrcotcFUeveBZBA4ww5T


Generated by Claude Code


Note

Medium Risk
Touches durable write orchestration (shared mutation publish path), admission-gated HTTP writes, and conditional concurrency via ETags; behavior is heavily tested but incorrect precondition or budget handling could affect data integrity.

Overview
Adds the blob upload surface alongside existing blob read: PUT /graphs/{id}/blob and omnigraph blob put accept raw octet-stream bodies (no base64/JSON) to replace one existing row’s Blob cell on a branch.

The engine exposes write_blob_at / write_blob_at_as, routing through a shared publish_staged_mutation tail so blob writes use the same Mutation-v9 commit/recovery path as one-row mutations. Uploads support optional BlobPrecondition / RFC 9110 If-Match (strong tags, *); mismatches return BlobWriteOutcome::PreconditionFailed (HTTP 412 / shared CLI error). blob_etag and parse_if_match move into the engine with omnigraph-api-types re-exports for server/CLI parity.

Scans can skip materializing the target blob column so the old payload isn’t read or charged against the 32 MiB budget. The server handler enforces policy, workload admission, and a 32 MiB body limit; CLI reads stdin/--file, supports --if-match, and mirrors remote vs embedded behavior in tests and the parity matrix. Docs and OpenAPI add the upload contract.

Reviewed by Cursor Bugbot for commit b92681d. Bugbot is set up for automated code reviews on this repo. Configure here.

Greptile Summary

This PR adds raw blob uploads across the engine, HTTP API, and CLI. The main changes are:

  • A Mutation-v9 blob replacement writer with conditional ETag checks.
  • A PUT /graphs/{id}/blob endpoint with authorization, admission, and body limits.
  • An embedded and remote omnigraph blob put command.
  • Shared API types, OpenAPI updates, documentation, and parity tests.

Confidence Score: 4/5

The post-commit ETag race and unbounded CLI input buffering need fixes before merging.

  • A concurrent write can make a successful upload return unrelated metadata or an error after committing.
  • Oversized CLI input can exhaust local memory before any configured limit is applied.
  • Authorization, conditional writes, update-only behavior, and atomic publication are otherwise consistently wired.

crates/omnigraph/src/exec/mutation.rs; crates/omnigraph-cli/src/main.rs

Important Files Changed

Filename Overview
crates/omnigraph/src/exec/mutation.rs Adds the blob writer and shared publication tail, but derives the response ETag through a racy branch-head read after commit.
crates/omnigraph/src/table_store.rs Adds target-column-skipping blob materialization while preserving and budgeting the other cells.
crates/omnigraph-server/src/handlers.rs Adds the authorized, admission-gated PUT handler and maps engine outcomes to 200 and 412 responses.
crates/omnigraph-server/src/lib.rs Registers PUT on the per-graph blob route with the ingest body limit.
crates/omnigraph-cli/src/main.rs Adds file and stdin upload dispatch but buffers input without applying the supported size limit.
crates/omnigraph-cli/src/client.rs Implements matching remote and embedded upload clients with shared precondition errors.
crates/omnigraph-api-types/src/lib.rs Adds the upload response type, shared If-Match parsing, and delegated ETag generation.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant C as Client
    participant H as Blob PUT handler
    participant E as Engine
    participant M as Mutation publisher
    C->>H: Raw bytes + optional If-Match
    H->>E: write_blob_at_as
    E->>E: Pin base and check precondition
    E->>M: Stage and publish row replacement
    M-->>E: Commit durable
    E->>E: Re-read mutable branch head for ETag
    Note over E: Another write can intervene here
    E-->>H: Written outcome or read error
    H-->>C: Upload response
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant C as Client
    participant H as Blob PUT handler
    participant E as Engine
    participant M as Mutation publisher
    C->>H: Raw bytes + optional If-Match
    H->>E: write_blob_at_as
    E->>E: Pin base and check precondition
    E->>M: Stage and publish row replacement
    M-->>E: Commit durable
    E->>E: Re-read mutable branch head for ETag
    Note over E: Another write can intervene here
    E-->>H: Written outcome or read error
    H-->>C: Upload response
Loading

Fix All in Claude Code

Reviews (1): Last reviewed commit: "Add blob put rows to the embedded/remote..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

Context used:

  • Context used - AGENTS.md (source)
  • Context used - CLAUDE.md (source)

claude added 4 commits July 19, 2026 00:27
New public Omnigraph::write_blob_at{,_as}(branch, type, id, prop, bytes,
precondition) replaces one existing node row's Blob cell. Update-only: a
missing row is a typed not-found. The write composes the existing
Mutation-v9 protocol exactly — mutate_one_attempt's publish tail
(validate, stage_all, lineage, commit_all, publish, RecoveryRequired
wrapping, sidecar cleanup) is extracted into one shared
publish_staged_mutation helper so the orchestration cannot drift and
every durable call keeps its single registered site.

The row is read via a new target-column-excluding variant of the
materializing scan: other blob cells are carried through (external refs
inline, the documented update behavior) while the replaced cell is
null-filled — its old payload is neither read nor charged against the
32 MiB batch budget. The payload enters as raw bytes through Lance's
BlobArrayBuilder; no base64 round-trip.

Preconditions are transport-neutral BlobPrecondition tokens evaluated
against the same pinned base each attempt prepares from; the write's
retry classification is deliberately retryable (unlike .gq updates)
because the whole read set is re-derived per attempt, so both
last-writer-wins and If-Match re-evaluation hold by construction. A
mismatch returns the must-use BlobWriteOutcome::PreconditionFailed
variant rather than widening the error taxonomy. blob_etag derivation
moves into the engine (api-types delegates, unchanged signature) and
api-types gains the shared RFC 9110 parse_if_match boundary parser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149LrcotcFUeveBZBA4ww5T
The request body IS the payload — no base64 inflation, no JSON envelope —
so the full 32 MiB ingest budget is usable (the inline-base64 route
effectively capped payloads near 24 MiB and died at the body limit).
Update-only (404 on a missing row); branch defaults to main; snapshot
addressing is rejected as read-only. The handler mirrors the load route's
shape: Cedar change action on the branch, then per-actor workload
admission sized by the body — a binary PUT does not bypass the 429
contract other write endpoints carry.

If-Match is parsed at the transport boundary by the shared api-types
parser (entity-tag lists, *, weak-tag rejection per RFC 9110) into the
engine's typed precondition; a mismatch answers 412 with the current
validator in ETag. Success answers 200 with the committed validator in
ETag plus a BlobPutOutput JSON body shared with the CLI. Documented
Hyrum-facing contracts: the validator is table-version-granular, and a
row's OTHER external-reference blob columns are inlined by the write.

Also corrects a stale server.md line claiming external blob sizes are
recorded at load time (removed by the canonical-shape fix; sizes are
unknown by design).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149LrcotcFUeveBZBA4ww5T
omnigraph blob put TYPE ID PROP uploads raw payload bytes from --file or
stdin into one existing row's Blob cell — no base64 step, so the full
32 MiB budget is usable from the command line. The remote arm PUTs the
raw body with an optional If-Match header; the embedded arm parses the
same --if-match value with the shared api-types parser and calls
write_blob_at_as under the --as actor. Both arms print the shared
BlobPutOutput object and surface a stale precondition through one
identical typed error carrying the current validator, so exit codes and
messages hold parity by construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149LrcotcFUeveBZBA4ww5T
parity_blob_put pins the strongest cross-arm contract: scrubbed-JSON
equality where etag and size are not in the volatile allowlist, so the
identity-derived validator must match exactly (both arms start from the
byte-mirrored graph and apply the identical operation), plus a
round-trip get. Stale If-Match, missing-row, and oversize rows pin
matching failure exit codes and the shared typed precondition error.
KNOWN_DIVERGENCES stays empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149LrcotcFUeveBZBA4ww5T
Comment on lines +1159 to +1166
let committed = self
.read_blob_at(
crate::db::ReadTarget::branch(requested.as_deref().unwrap_or("main")),
type_name,
id,
property,
)
.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.

P1 Post-Commit Read Returns Wrong ETag

After this upload is published, read_blob_at reads the mutable branch head rather than the version just committed. If another writer updates or deletes the row in that window, this request returns the later writer's ETag or reports an error even though its own upload is already durable, violating the Written outcome and HTTP response contract.

Context Used: AGENTS.md (source)

Fix in Claude Code

Comment on lines +737 to +744
let bytes = match &file {
Some(path) => std::fs::read(path)?,
None => {
let mut buffer = Vec::new();
std::io::Read::read_to_end(&mut std::io::stdin().lock(), &mut buffer)?;
buffer
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Oversized Input Is Buffered Unbounded

Both file and stdin input are fully loaded into a Vec before the 32 MiB limit is checked. A large file can exhaust the CLI process's memory before the embedded engine rejects it or the remote server returns 413; the reader should stop after observing one byte beyond the supported limit.

Context Used: AGENTS.md (source)

Fix in Claude Code

@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: b92681d8c1

ℹ️ 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 on lines +999 to +1002
let precondition = headers
.get(IF_MATCH)
.and_then(|value| value.to_str().ok())
.map(parse_if_match);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject malformed If-Match instead of dropping it

When a direct HTTP client sends an If-Match header that cannot be converted with to_str() (for example an obs-text/non-UTF8 value), this path turns the failed parse into None and the engine performs an unconditional last-writer-wins blob overwrite. Because If-Match is the safety precondition for this write endpoint, malformed values should be rejected or treated as a non-match (412), not silently ignored.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Closing this PR because its contract and implementation are a direct follow-up to #367, which was reverted by #373 to restore the RFC governance boundary.

This PR also introduces a new public engine, HTTP, and CLI write feature (PUT /graphs/{id}/blob / omnigraph blob put) without an accepted RFC. Rebasing it onto current main would either reintroduce the reverted #367 surface or require a new design.

The implementation can be revisited after a blob-delivery RFC establishes the read/write contract, concurrency semantics, resource limits, and API/CLI surface.

@aaltshuler aaltshuler closed this Jul 19, 2026
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.

3 participants