2026-06-11 15:08:25 +03:00
//! HTTP route handlers, the bearer-auth middleware, per-request
//! authorization, and the cluster-prefix OpenAPI rewrite (moved
//! verbatim from lib.rs in the modularization).
use super ::* ;
/// Liveness probe.
///
/// Returns server status and version. Unauthenticated; safe to call from any
/// caller. Use this to confirm the server is reachable before invoking other
/// endpoints.
#[ utoipa::path(
get ,
path = " /healthz " ,
tag = " health " ,
operation_id = " health " ,
responses (
( status = 200 , description = " Server is healthy " , body = HealthOutput ) ,
) ,
) ]
pub ( crate ) async fn server_health ( ) -> Json < HealthOutput > {
Json ( HealthOutput {
status : " ok " . to_string ( ) ,
version : SERVER_VERSION . to_string ( ) ,
source_version : SERVER_SOURCE_VERSION . map ( str ::to_string ) ,
} )
}
#[ utoipa::path(
get ,
path = " /graphs " ,
tag = " management " ,
operation_id = " listGraphs " ,
responses (
( status = 200 , description = " List of registered graphs " , body = GraphListResponse ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
( status = 405 , description = " Method not allowed (single-graph mode) " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// List every graph currently registered with this server (MR-668).
///
/// Multi-graph mode only. In single mode, the route returns 405 — there's
/// no registry to enumerate. Cedar-gated by the server-level policy via
/// the `graph_list` action against `Omnigraph::Server::"root"`.
///
/// Order: alphabetical by `graph_id` (server-sorted so clients see
/// deterministic output across requests).
pub ( crate ) async fn server_graphs_list (
State ( state ) : State < AppState > ,
actor : Option < Extension < ResolvedActor > > ,
) -> std ::result ::Result < Json < GraphListResponse > , ApiError > {
2026-06-15 20:17:25 +03:00
let registry = & state . routing ( ) . registry ;
2026-06-11 15:08:25 +03:00
2026-06-16 03:11:43 +03:00
// Server-level Cedar gate. `state.server_policy` is loaded from the
// cluster-scoped policy bundle at startup. When no server policy is
// configured, `authorize_request_server` falls through to the MR-723
// default-deny semantics (every non-Read action denied for an
// authenticated actor). `GraphList` is not `Read`, so without a server
// policy the request gets 403 — which is the right default (don't leak
// the registry until the operator explicitly authorizes it).
2026-06-11 15:08:25 +03:00
authorize_request (
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
state . server_policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::GraphList ,
branch : None ,
target_branch : None ,
} ,
) ? ;
let mut graphs : Vec < GraphInfo > = registry
. list ( )
. into_iter ( )
. map ( | handle | GraphInfo {
graph_id : handle . key . graph_id . as_str ( ) . to_string ( ) ,
uri : handle . uri . clone ( ) ,
} )
. collect ( ) ;
graphs . sort_by ( | a , b | a . graph_id . cmp ( & b . graph_id ) ) ;
Ok ( Json ( GraphListResponse { graphs } ) )
}
pub ( crate ) async fn server_openapi ( State ( state ) : State < AppState > ) -> Json < utoipa ::openapi ::OpenApi > {
2026-06-15 20:17:25 +03:00
// `served_openapi` is the single nesting source — the protected
// routes always live under `/graphs/{graph_id}/...` (public/management
// paths `/healthz`, `/graphs` stay flat). Building from it here means
// the runtime spec and the committed `openapi.json` share one nesting
// pass and can't drift.
let mut doc = crate ::served_openapi ( ) ;
2026-06-11 15:08:25 +03:00
if ! state . requires_bearer_auth ( ) {
strip_security ( & mut doc ) ;
}
Json ( doc )
}
/// Path prefix used to namespace per-graph routes in multi mode.
/// Kept in sync with the `Router::nest(...)` invocation in `build_app`.
const CLUSTER_PATH_PREFIX : & str = " /graphs/{graph_id} " ;
/// Operation-id prefix applied to every cloned cluster operation.
/// Decision 7 in the implementation plan — keeps operation IDs unique
/// across the spec when both flat and nested variants ever appear in
/// the same generation pass.
const CLUSTER_OPERATION_ID_PREFIX : & str = " cluster_ " ;
/// Paths that stay flat in every server mode (public or server-level,
/// no per-graph dependency). Update this list when adding new
/// always-flat endpoints. `/graphs` is the management enumeration —
/// it lives at the root in both single mode (405) and multi mode, and
/// must never be rewritten to `/graphs/{graph_id}/graphs`.
const ALWAYS_FLAT_PATHS : & [ & str ] = & [ " /healthz " , " /graphs " ] ;
/// In multi-mode `server_openapi`, every protected path-item is
/// reattached under the cluster prefix. Operation IDs gain the
/// `cluster_` prefix so SDK generators don't collide if/when both
/// surfaces are merged. Every rewritten operation also declares the
/// required `{graph_id}` path parameter so the served OpenAPI document
/// remains internally valid.
///
/// Removing the flat protected paths matches the runtime router —
/// in multi mode, requests to `/snapshot` etc. return 404, so the
/// spec must agree.
pub ( crate ) fn nest_paths_under_cluster_prefix ( doc : & mut utoipa ::openapi ::OpenApi ) {
let original = std ::mem ::take ( & mut doc . paths . paths ) ;
let mut rewritten = std ::collections ::BTreeMap ::new ( ) ;
for ( path , mut item ) in original {
if ALWAYS_FLAT_PATHS . contains ( & path . as_str ( ) ) {
rewritten . insert ( path , item ) ;
continue ;
}
rename_operation_ids ( & mut item , CLUSTER_OPERATION_ID_PREFIX ) ;
add_cluster_graph_id_parameter ( & mut item ) ;
let new_path = format! ( " {CLUSTER_PATH_PREFIX} {path} " ) ;
rewritten . insert ( new_path , item ) ;
}
doc . paths . paths = rewritten ;
}
pub ( crate ) fn add_cluster_graph_id_parameter ( item : & mut utoipa ::openapi ::PathItem ) {
for op in path_item_operations_mut ( item ) {
let parameters = op . parameters . get_or_insert_with ( Vec ::new ) ;
let has_graph_id = parameters
. iter ( )
. any ( | param | param . name = = " graph_id " & & param . parameter_in = = ParameterIn ::Path ) ;
if ! has_graph_id {
parameters . insert ( 0 , graph_id_path_parameter ( ) ) ;
}
}
}
pub ( crate ) fn graph_id_path_parameter ( ) -> Parameter {
let mut parameter = Parameter ::new ( " graph_id " ) ;
parameter . parameter_in = ParameterIn ::Path ;
parameter . description = Some ( " Graph id to route the request to. " . to_string ( ) ) ;
parameter . schema = Some ( Object ::with_type ( Type ::String ) . into ( ) ) ;
parameter
}
/// Prefix every operation_id in this PathItem with `prefix`.
pub ( crate ) fn rename_operation_ids ( item : & mut utoipa ::openapi ::PathItem , prefix : & str ) {
for op in path_item_operations_mut ( item ) {
if let Some ( id ) = op . operation_id . as_deref ( ) {
op . operation_id = Some ( format! ( " {prefix} {id} " ) ) ;
}
}
}
pub ( crate ) fn path_item_operations_mut (
item : & mut utoipa ::openapi ::PathItem ,
) -> impl Iterator < Item = & mut utoipa ::openapi ::path ::Operation > {
[
item . get . as_mut ( ) ,
item . post . as_mut ( ) ,
item . put . as_mut ( ) ,
item . delete . as_mut ( ) ,
item . options . as_mut ( ) ,
item . head . as_mut ( ) ,
item . patch . as_mut ( ) ,
item . trace . as_mut ( ) ,
]
. into_iter ( )
. flatten ( )
}
pub ( crate ) fn strip_security ( doc : & mut utoipa ::openapi ::OpenApi ) {
if let Some ( components ) = doc . components . as_mut ( ) {
components . security_schemes . clear ( ) ;
}
for path_item in doc . paths . paths . values_mut ( ) {
for op in [
path_item . get . as_mut ( ) ,
path_item . post . as_mut ( ) ,
path_item . put . as_mut ( ) ,
path_item . delete . as_mut ( ) ,
path_item . options . as_mut ( ) ,
path_item . head . as_mut ( ) ,
path_item . patch . as_mut ( ) ,
path_item . trace . as_mut ( ) ,
]
. into_iter ( )
. flatten ( )
{
op . security = None ;
}
}
}
pub ( crate ) async fn require_bearer_auth (
State ( state ) : State < AppState > ,
mut request : Request ,
next : Next ,
) -> std ::result ::Result < Response , ApiError > {
if ! state . requires_bearer_auth ( ) {
return Ok ( next . run ( request ) . await ) ;
}
let Some ( header ) = request
. headers ( )
. get ( AUTHORIZATION )
. and_then ( | value | value . to_str ( ) . ok ( ) )
else {
return Err ( ApiError ::unauthorized ( " missing bearer token " ) ) ;
} ;
let Some ( provided_token ) = header . strip_prefix ( " Bearer " ) else {
return Err ( ApiError ::unauthorized ( " missing bearer token " ) ) ;
} ;
let Some ( actor ) = state . authenticate_bearer_token ( provided_token ) else {
return Err ( ApiError ::unauthorized ( " invalid bearer token " ) ) ;
} ;
request . extensions_mut ( ) . insert ( actor ) ;
Ok ( next . run ( request ) . await )
}
2026-06-15 20:17:25 +03:00
/// Routing middleware (RFC-011 cluster-only). Resolves the active graph
/// for the request and injects `Arc<GraphHandle>` as an extension so
/// handlers can extract it via `Extension<Arc<GraphHandle>>`.
2026-06-11 15:08:25 +03:00
///
2026-06-15 20:17:25 +03:00
/// Routes are always nested under `/graphs/{graph_id}/...`. The
2026-06-11 15:08:25 +03:00
/// middleware extracts `{graph_id}` from the URI path and looks it up in
/// the registry. Returns 404 if the graph is not registered.
///
/// The middleware fires AFTER `require_bearer_auth`, so the actor is
/// already in the request extensions (or auth was off entirely).
pub ( crate ) async fn resolve_graph_handle (
State ( state ) : State < AppState > ,
mut request : Request ,
next : Next ,
) -> std ::result ::Result < Response , ApiError > {
2026-06-15 20:17:25 +03:00
let registry = & state . routing . registry ;
// `Router::nest("/graphs/{graph_id}", inner)` rewrites
// `request.uri().path()` to the inner suffix (e.g. `/snapshot`).
// The pre-rewrite URI is preserved in the `OriginalUri`
// request extension by axum's router; we read from there to
// extract `{graph_id}`. Fall back to the current URI only if
// the extension is missing, which shouldn't happen for
// nested routes but is safe defensive code.
let original_path : String = request
. extensions ( )
. get ::< OriginalUri > ( )
. map ( | OriginalUri ( uri ) | uri . path ( ) . to_string ( ) )
. unwrap_or_else ( | | request . uri ( ) . path ( ) . to_string ( ) ) ;
let graph_id_str = original_path
. strip_prefix ( " /graphs/ " )
. and_then ( | rest | rest . split ( '/' ) . next ( ) )
. filter ( | s | ! s . is_empty ( ) )
. ok_or_else ( | | {
ApiError ::bad_request ( " cluster route missing /graphs/{graph_id} prefix " . to_string ( ) )
} ) ? ;
let graph_id = GraphId ::try_from ( graph_id_str . to_string ( ) )
. map_err ( | err | ApiError ::bad_request ( err . to_string ( ) ) ) ? ;
let key = GraphKey ::cluster ( graph_id . clone ( ) ) ;
let handle = match registry . get ( & key ) {
RegistryLookup ::Ready ( handle ) = > handle ,
RegistryLookup ::Gone = > {
return Err ( ApiError ::not_found ( format! ( " graph ' {graph_id} ' not found " ) ) ) ;
2026-06-11 15:08:25 +03:00
}
} ;
// Per-request observability. `Span::current().record` would silently
// no-op here because no upstream `#[tracing::instrument(...)]` macro
// declares a `graph_id` field; emit an explicit event instead so the
// routing decision actually lands in logs.
info! ( graph_id = % handle . key . graph_id , " graph routed " ) ;
request . extensions_mut ( ) . insert ( handle ) ;
Ok ( next . run ( request ) . await )
}
pub ( crate ) fn log_policy_decision ( actor_id : & str , request : & PolicyRequest , decision : & PolicyDecision ) {
info! (
actor_id = actor_id ,
action = % request . action ,
branch = request . branch . as_deref ( ) . unwrap_or ( " " ) ,
target_branch = request . target_branch . as_deref ( ) . unwrap_or ( " " ) ,
allowed = decision . allowed ,
matched_rule_id = decision . matched_rule_id . as_deref ( ) . unwrap_or ( " " ) ,
" policy decision "
) ;
}
/// The allow/deny **decision** an authorization check produces, kept
/// separate from the operational failures (`Err`) that can occur while
/// computing it. [`authorize_request`] collapses `Denied` to a 403; a caller
/// that needs to remap a denial without also remapping operational failures
/// (the stored-query invoke handler hides a denial as a 404) matches on this
/// directly, so a real 401 (missing bearer) or 500 (policy-evaluation error)
/// keeps its true status instead of being masked as the denial's response.
pub ( crate ) enum Authz {
Allowed ,
Denied ( String ) ,
}
/// HTTP-layer Cedar policy gate, returning the allow/deny [`Authz`] decision
/// and reserving `Err` for operational failures (401 missing bearer, 500
/// policy-evaluation error). Two sources of the policy engine:
/// * Per-graph handler — passes `handle.policy.as_deref()` so the
/// graph's Cedar rules govern read/change/branch_*/schema_apply.
/// * Management handler — passes `state.server_policy.as_deref()` so
/// server-level Cedar rules govern `graph_list` (the only shipped
/// server-scoped action; runtime `graph_create` / `graph_delete`
/// are deferred until a managed cluster catalog lands).
///
/// The MR-731 invariant lives inside this function: actor identity is
/// supplied as a separate argument from the resolved bearer match. The
/// `PolicyRequest` struct itself does not carry identity (the field was
/// dropped from the type), so handlers cannot smuggle it through the
/// request. See `actor_id_resolves_from_bearer_token_ignoring_client_supplied_headers`
/// at `tests/server.rs`.
pub ( crate ) fn authorize (
actor : Option < & ResolvedActor > ,
policy : Option < & PolicyEngine > ,
request : PolicyRequest ,
) -> std ::result ::Result < Authz , ApiError > {
let Some ( engine ) = policy else {
// No PolicyEngine installed. Three runtime states can reach this:
//
// * **Open mode** (`--unauthenticated`): no tokens, no policy.
// Per-graph operations are open by operator opt-in (they
// accepted "trust the network" for graph data).
// * **DefaultDeny mode**: tokens configured but no policy. The
// request went through bearer auth, so `actor` is Some. Only
// per-graph `Read` is permitted; other per-graph actions
// return 403. Closes the "configured auth but forgot the
// policy file" trap from MR-723.
// * Either of the above with a **server-scoped** action
// (`graph_list`, future `graph_create`/`graph_delete`).
//
// Server-scoped actions are always denied here, regardless of
// mode or actor presence. The management surface leaks server
// topology (graph IDs + URIs that may contain S3 bucket paths
// or internal hostnames) — operators who opted into Open mode
// accepted exposure of graph DATA, not exposure of server
// topology. Closing the management surface by default in every
// runtime state means the docstring contract on
// `server_graphs_list` ("don't leak the registry until the
// operator explicitly authorizes it") holds uniformly; the
2026-06-16 03:11:43 +03:00
// operator's only path to enabling it is configuring a
// cluster-scoped policy bundle, applying the cluster, and
// restarting the server.
2026-06-11 15:08:25 +03:00
if request . action . resource_kind ( ) = = PolicyResourceKind ::Server {
return Ok ( Authz ::Denied (
2026-06-16 03:11:43 +03:00
" server-scoped actions require an explicit cluster policy bundle \
applied with ` omnigraph cluster apply ` and served after restart — \
the management surface is closed by default in every runtime state , \
including - - unauthenticated , so that server topology is never exposed \
without operator opt - in . "
2026-06-11 15:08:25 +03:00
. to_string ( ) ,
) ) ;
}
if actor . is_some ( ) & & request . action ! = PolicyAction ::Read {
return Ok ( Authz ::Denied (
" server runs in default-deny mode (bearer tokens configured but no \
2026-06-16 03:11:43 +03:00
applied policy bundle ) . Only ` read ` actions are permitted ; configure \
a graph or cluster policy bundle in the cluster config , run \
` omnigraph cluster apply ` , and restart the server to enable other actions . "
2026-06-11 15:08:25 +03:00
. to_string ( ) ,
) ) ;
}
return Ok ( Authz ::Allowed ) ;
} ;
let Some ( actor ) = actor else {
return Err ( ApiError ::unauthorized ( " missing bearer token " ) ) ;
} ;
// SECURITY INVARIANT (MR-731): actor identity is supplied to the
// policy engine here as a separate argument, sourced from the
// bearer-token match resolved by `require_bearer_auth`. The
// `PolicyRequest` struct itself no longer carries `actor_id` (it
// was dropped from the type), so handlers cannot smuggle identity
// through the request body and there is no overwrite step that
// could be skipped. The principle is codified in
// `docs/dev/invariants.md` Hard Invariant 11 ("clients cannot set
// actor identity directly") and pinned by the regression test
// `actor_id_resolves_from_bearer_token_ignoring_client_supplied_headers`
// in `crates/omnigraph-server/tests/server.rs`.
let actor_id = actor . actor_id . as_ref ( ) ;
let decision = engine
. authorize ( actor_id , & request )
. map_err ( | err | ApiError ::internal ( format! ( " policy: {err} " ) ) ) ? ;
log_policy_decision ( actor_id , & request , & decision ) ;
if decision . allowed {
Ok ( Authz ::Allowed )
} else {
Ok ( Authz ::Denied ( decision . message ) )
}
}
/// Thin wrapper over [`authorize`] for the handlers that treat any denial as a
/// 403: a denial becomes `ApiError::forbidden`, and operational failures
/// (401 missing bearer, 500 policy-evaluation error) propagate unchanged. The
/// stored-query invoke handler does **not** use this — it consumes the
/// [`Authz`] decision directly to hide a denial as a 404 while letting an
/// operational failure keep its true status.
pub ( crate ) fn authorize_request (
actor : Option < & ResolvedActor > ,
policy : Option < & PolicyEngine > ,
request : PolicyRequest ,
) -> std ::result ::Result < ( ) , ApiError > {
match authorize ( actor , policy , request ) ? {
Authz ::Allowed = > Ok ( ( ) ) ,
Authz ::Denied ( message ) = > Err ( ApiError ::forbidden ( message ) ) ,
}
}
#[ utoipa::path(
get ,
path = " /snapshot " ,
tag = " snapshots " ,
operation_id = " getSnapshot " ,
params ( SnapshotQuery ) ,
responses (
( status = 200 , description = " Database snapshot " , body = api ::SnapshotOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// Read the current snapshot of a branch.
///
/// Returns the manifest version plus per-table metadata (path, version, row
/// count) for every table on the branch. Defaults to `main` when `branch` is
/// omitted. Read-only.
pub ( crate ) async fn server_snapshot (
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Query ( query ) : Query < SnapshotQuery > ,
) -> std ::result ::Result < Json < api ::SnapshotOutput > , ApiError > {
let branch = query . branch . unwrap_or_else ( | | " main " . to_string ( ) ) ;
authorize_request (
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::Read ,
branch : Some ( branch . clone ( ) ) ,
target_branch : None ,
} ,
) ? ;
let snapshot = {
let db = & handle . engine ;
db . snapshot_of ( ReadTarget ::branch ( branch . as_str ( ) ) )
. await
. map_err ( ApiError ::from_omni ) ?
} ;
Ok ( Json ( snapshot_payload ( & branch , & snapshot ) ) )
}
/// Header values that flag a response as coming from a deprecated route
/// (RFC 9745 / RFC 8288) and point at the canonical successor.
pub ( crate ) fn deprecation_headers ( successor_link : & 'static str ) -> [ ( HeaderName , HeaderValue ) ; 2 ] {
[
(
HeaderName ::from_static ( " deprecation " ) ,
HeaderValue ::from_static ( " true " ) ,
) ,
(
HeaderName ::from_static ( " link " ) ,
HeaderValue ::from_static ( successor_link ) ,
) ,
]
}
#[ utoipa::path(
post ,
path = " /read " ,
tag = " queries " ,
operation_id = " read " ,
request_body = ReadRequest ,
responses (
2026-06-16 03:11:43 +03:00
( status = 200 , description = " Query results (response includes `Deprecation: true` + `Link: <query>; rel= \" successor-version \" `) " , body = ReadOutput ) ,
2026-06-11 15:08:25 +03:00
( status = 400 , description = " Bad request " , body = ErrorOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
#[ deprecated(note = " use POST /query instead; /read is kept indefinitely for byte-stable back-compat " ) ]
/// **Deprecated** — use [`POST /query`](#tag/queries/operation/query) instead.
///
/// Execute a GQ read query. Behavior is unchanged from prior releases; the
/// route is kept indefinitely for byte-stable back-compat. New integrations
/// should target `POST /query`, which has clean field names (`query` /
/// `name`) and a 400-on-mutation guard. Responses from this route include
2026-06-16 03:11:43 +03:00
/// `Deprecation: true` and `Link: <query>; rel="successor-version"`
2026-06-11 15:08:25 +03:00
/// headers per RFC 9745 / RFC 8288 so SDKs and proxies can surface the
/// signal.
pub ( crate ) async fn server_read (
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Json ( request ) : Json < ReadRequest > ,
) -> std ::result ::Result < ( [ ( HeaderName , HeaderValue ) ; 2 ] , Json < ReadOutput > ) , ApiError > {
let ( selected_name , target , result ) = run_query (
handle ,
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
& request . query_source ,
request . query_name . as_deref ( ) ,
request . params . as_ref ( ) ,
request . branch ,
request . snapshot ,
false , // /read predates the D2 rule; legacy callers may submit mutating queries here
)
. await ? ;
Ok ( (
2026-06-16 03:11:43 +03:00
deprecation_headers ( " <query>; rel= \" successor-version \" " ) ,
2026-06-11 15:08:25 +03:00
Json ( api ::read_output ( selected_name , & target , result ) ) ,
) )
}
#[ utoipa::path(
post ,
path = " /query " ,
tag = " queries " ,
operation_id = " query " ,
request_body = QueryRequest ,
responses (
( status = 200 , description = " Query results " , body = ReadOutput ) ,
( status = 400 , description = " Bad request - also returned when the query body contains mutations; use POST /mutate (or its deprecated alias POST /change) for write queries " , body = ErrorOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// Execute an inline read query (friendlier-named alternative to `POST /read`).
///
/// Designed for ad-hoc exploration and AI-agent tool-use: short field
/// names (`query`, `name`) match the CLI `-e` flag and the GQ `query`
/// keyword. Mutations (`insert`/`update`/`delete`) are rejected with 400
/// -- use `POST /mutate` (or its deprecated alias `POST /change`) for
/// write queries. Otherwise behaves identically to `POST /read`: same
/// target semantics (branch xor snapshot), same Cedar action (Read),
/// same response shape.
pub ( crate ) async fn server_query (
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Json ( request ) : Json < QueryRequest > ,
) -> std ::result ::Result < Json < ReadOutput > , ApiError > {
let ( selected_name , target , result ) = run_query (
handle ,
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
& request . query ,
request . name . as_deref ( ) ,
request . params . as_ref ( ) ,
request . branch ,
request . snapshot ,
true , // /query is read-only; reject mutations
)
. await ? ;
Ok ( Json ( api ::read_output ( selected_name , & target , result ) ) )
}
#[ utoipa::path(
post ,
path = " /export " ,
tag = " queries " ,
operation_id = " export " ,
request_body = ExportRequest ,
responses (
( status = 200 , description = " Exported data as NDJSON " , content_type = " application/x-ndjson " ) ,
( status = 400 , description = " Bad request " , body = ErrorOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// Stream the contents of a branch as NDJSON.
///
/// Emits one JSON object per line (`application/x-ndjson`). Filter with
/// `type_names` (node/edge type names) and/or `table_keys`; both empty
/// streams the entire branch. Suitable for large exports — the response is
/// streamed, not buffered. Read-only.
pub ( crate ) async fn server_export (
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Json ( request ) : Json < ExportRequest > ,
) -> std ::result ::Result < Response , ApiError > {
let branch = request . branch . unwrap_or_else ( | | " main " . to_string ( ) ) ;
authorize_request (
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::Export ,
branch : Some ( branch . clone ( ) ) ,
target_branch : None ,
} ,
) ? ;
let engine = Arc ::clone ( & handle . engine ) ;
let type_names = request . type_names . clone ( ) ;
let table_keys = request . table_keys . clone ( ) ;
let ( tx , rx ) = mpsc ::unbounded_channel ::< std ::result ::Result < Bytes , io ::Error > > ( ) ;
tokio ::spawn ( async move {
let result = {
let mut writer = ExportStreamWriter { sender : tx . clone ( ) } ;
engine
. export_jsonl_to_writer ( & branch , & type_names , & table_keys , & mut writer )
. await
} ;
if let Err ( err ) = result {
let _ = tx . send ( Err ( io ::Error ::other ( err . to_string ( ) ) ) ) ;
}
} ) ;
let body = Body ::from_stream ( stream ::unfold ( rx , | mut rx | async move {
rx . recv ( ) . await . map ( | item | ( item , rx ) )
} ) ) ;
Ok ( (
StatusCode ::OK ,
[ ( CONTENT_TYPE , " application/x-ndjson; charset=utf-8 " ) ] ,
body ,
)
. into_response ( ) )
}
/// Shared implementation behind `POST /mutate` (canonical) and
/// `POST /change` (deprecated alias). Returns the bare `ChangeOutput`;
/// each route handler wraps it (the alias also attaches Deprecation
/// headers).
/// Shared backend for `/mutate` (canonical) and `/change` (deprecated alias).
///
/// Decoupled from `ChangeRequest` so MR-969's `/queries/{name}` stored-query
/// handler can call this directly with registry-supplied fields without
/// rebuilding the request body. Today's HTTP handlers unpack the request and
/// call here; the registry would do the same.
pub ( crate ) async fn run_mutate (
state : AppState ,
handle : Arc < GraphHandle > ,
actor : Option < & ResolvedActor > ,
query : & str ,
name : Option < & str > ,
params_json : Option < & Value > ,
branch : String ,
) -> std ::result ::Result < ChangeOutput , ApiError > {
let actor_arc = actor
. map ( | a | Arc ::clone ( & a . actor_id ) )
. unwrap_or_else ( | | Arc ::< str > ::from ( " anonymous " ) ) ;
let actor_id = actor . map ( | a | a . actor_id . as_ref ( ) ) ;
authorize_request (
actor ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::Change ,
branch : Some ( branch . clone ( ) ) ,
target_branch : None ,
} ,
) ? ;
// Per-actor admission: bound concurrent in-flight mutations and
// estimated bytes per actor. Cedar runs FIRST so denied requests
// don't consume admission slots. Estimate uses the request body
// size as a coarse proxy; engine memory pressure can run higher.
let est_bytes = query . len ( ) as u64
+ params_json
. map ( | p | p . to_string ( ) . len ( ) as u64 )
. unwrap_or ( 0 ) ;
let _admission = state
. workload
. try_admit ( & actor_arc , est_bytes )
. map_err ( ApiError ::from_workload_reject ) ? ;
let ( selected_name , query_params ) =
select_named_query ( query , name ) . map_err ( | err | ApiError ::bad_request ( err . to_string ( ) ) ) ? ;
let params = query_params_from_json ( & query_params , params_json )
. map_err ( | err | ApiError ::bad_request ( err . to_string ( ) ) ) ? ;
let result = {
let db = & handle . engine ;
db . mutate_as ( & branch , query , & selected_name , & params , actor_id )
. await
. map_err ( ApiError ::from_omni ) ?
} ;
Ok ( ChangeOutput {
branch ,
query_name : selected_name ,
affected_nodes : result . affected_nodes ,
affected_edges : result . affected_edges ,
actor_id : actor_id . map ( str ::to_string ) ,
} )
}
/// Shared backend for `/query` (canonical) and `/read` (deprecated alias).
///
/// Mirrors [`run_mutate`]'s decoupled shape so MR-969's stored-query handler
/// can call here with registry-supplied fields. Rejects inline source that
/// contains mutations (D2 rule); callers wanting writes go through
/// [`run_mutate`] instead.
///
/// Intentionally does **not** take [`AppState`] (unlike [`run_mutate`]):
/// reads are not admission-gated today, so there is no `state.workload`
/// consumer. The signature grows the parameter when Phase 1 (MR-976) adds
/// the request envelope's `expect: { max_rows_scanned: N }` budget, or
/// MR-969 extends per-actor admission to stored-read invocations.
pub ( crate ) async fn run_query (
handle : Arc < GraphHandle > ,
actor : Option < & ResolvedActor > ,
query : & str ,
name : Option < & str > ,
params_json : Option < & Value > ,
branch : Option < String > ,
snapshot : Option < String > ,
reject_mutations : bool ,
) -> std ::result ::Result < ( String , ReadTarget , omnigraph_compiler ::result ::QueryResult ) , ApiError > {
if branch . is_some ( ) & & snapshot . is_some ( ) {
return Err ( ApiError ::bad_request (
" request may specify branch or snapshot, not both " ,
) ) ;
}
let target = read_target_from_request ( branch , snapshot ) ;
let policy_branch = match & target {
ReadTarget ::Branch ( branch ) = > Some ( branch . clone ( ) ) ,
ReadTarget ::Snapshot ( _ ) if handle . policy . is_some ( ) & & actor . is_some ( ) = > {
let db = & handle . engine ;
db . resolved_branch_of ( target . clone ( ) )
. await
. map ( | branch | branch . or_else ( | | Some ( " main " . to_string ( ) ) ) )
. map_err ( ApiError ::from_omni ) ?
}
ReadTarget ::Snapshot ( _ ) = > None ,
} ;
authorize_request (
actor ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::Read ,
branch : policy_branch ,
target_branch : None ,
} ,
) ? ;
let query_decl =
select_named_query_decl ( query , name ) . map_err ( | err | ApiError ::bad_request ( err . to_string ( ) ) ) ? ;
if reject_mutations & & ! query_decl . mutations . is_empty ( ) {
return Err ( ApiError ::bad_request ( format! (
" query '{}' contains mutations (insert/update/delete); use POST /mutate for write queries " ,
query_decl . name
) ) ) ;
}
let selected_name = query_decl . name . clone ( ) ;
let params = query_params_from_json ( & query_decl . params , params_json )
. map_err ( | err | ApiError ::bad_request ( err . to_string ( ) ) ) ? ;
let result = {
let db = & handle . engine ;
db . query ( target . clone ( ) , query , & selected_name , & params )
. await
. map_err ( ApiError ::from_omni ) ?
} ;
Ok ( ( selected_name , target , result ) )
}
#[ utoipa::path(
post ,
path = " /change " ,
tag = " mutations " ,
operation_id = " change " ,
request_body = ChangeRequest ,
responses (
2026-06-16 03:11:43 +03:00
( status = 200 , description = " Mutation results (response includes `Deprecation: true` + `Link: <mutate>; rel= \" successor-version \" `) " , body = ChangeOutput ) ,
2026-06-11 15:08:25 +03:00
( status = 400 , description = " Bad request " , body = ErrorOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
( status = 409 , description = " Merge conflict " , body = ErrorOutput ) ,
( status = 429 , description = " Per-actor admission cap exceeded; honor `Retry-After` header " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
#[ deprecated(note = " use POST /mutate instead; /change is kept indefinitely for back-compat " ) ]
/// **Deprecated** — use [`POST /mutate`](#tag/mutations/operation/mutate) instead.
///
/// Apply a GQ mutation to a branch. Behavior is unchanged; the route is
/// kept indefinitely for back-compat. New integrations should target
/// `POST /mutate`, which has identical semantics and a name that pairs
/// cleanly with `POST /query`. Responses from this route include
2026-06-16 03:11:43 +03:00
/// `Deprecation: true` and `Link: <mutate>; rel="successor-version"`
2026-06-11 15:08:25 +03:00
/// headers per RFC 9745 / RFC 8288 so SDKs and proxies can surface the
/// signal.
pub ( crate ) async fn server_change (
State ( state ) : State < AppState > ,
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Json ( request ) : Json < ChangeRequest > ,
) -> std ::result ::Result < ( [ ( HeaderName , HeaderValue ) ; 2 ] , Json < ChangeOutput > ) , ApiError > {
let branch = request . branch . unwrap_or_else ( | | " main " . to_string ( ) ) ;
let output = run_mutate (
state ,
handle ,
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
& request . query ,
request . name . as_deref ( ) ,
request . params . as_ref ( ) ,
branch ,
)
. await ? ;
Ok ( (
2026-06-16 03:11:43 +03:00
deprecation_headers ( " <mutate>; rel= \" successor-version \" " ) ,
2026-06-11 15:08:25 +03:00
Json ( output ) ,
) )
}
#[ utoipa::path(
post ,
path = " /mutate " ,
tag = " mutations " ,
operation_id = " mutate " ,
request_body = ChangeRequest ,
responses (
( status = 200 , description = " Mutation results " , body = ChangeOutput ) ,
( status = 400 , description = " Bad request " , body = ErrorOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
( status = 409 , description = " Merge conflict " , body = ErrorOutput ) ,
( status = 429 , description = " Per-actor admission cap exceeded; honor `Retry-After` header " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// Apply a GQ mutation to a branch (canonical mutation endpoint).
///
/// Writes to the named `branch` (defaults to `main`). Mutations are atomic
/// per call and produce a new commit. Returns counts of nodes and edges
/// affected. **Destructive**: on success the branch is updated; rejected
/// mutations may still acquire locks briefly. Returns 409 on merge conflict.
///
/// Pairs with `POST /query` (read-only). The legacy `POST /change` route
/// has identical semantics and is kept as a deprecated alias.
pub ( crate ) async fn server_mutate (
State ( state ) : State < AppState > ,
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Json ( request ) : Json < ChangeRequest > ,
) -> std ::result ::Result < Json < ChangeOutput > , ApiError > {
let branch = request . branch . unwrap_or_else ( | | " main " . to_string ( ) ) ;
Ok ( Json (
run_mutate (
state ,
handle ,
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
& request . query ,
request . name . as_deref ( ) ,
request . params . as_ref ( ) ,
branch ,
)
. await ? ,
) )
}
/// Path parameter for `POST /queries/{name}`.
#[ derive(Deserialize) ]
pub ( crate ) struct QueryNamePath {
name : String ,
}
pub ( crate ) fn parse_optional_invoke_body (
body : Bytes ,
) -> std ::result ::Result < InvokeStoredQueryRequest , ApiError > {
if body . is_empty ( ) {
return Ok ( InvokeStoredQueryRequest ::default ( ) ) ;
}
serde_json ::from_slice ::< Option < InvokeStoredQueryRequest > > ( & body )
. map ( | request | request . unwrap_or_default ( ) )
. map_err ( | err | {
ApiError ::bad_request ( format! ( " invalid stored-query invocation body: {err} " ) )
} )
}
#[ utoipa::path(
post ,
path = " /queries/{name} " ,
tag = " queries " ,
operation_id = " invoke_query " ,
params ( ( " name " = String , Path , description = " Stored query name (the registry key) " ) ) ,
request_body = Option < InvokeStoredQueryRequest > ,
responses (
( status = 200 , description = " Read envelope (ReadOutput) or mutation envelope (ChangeOutput), serialized untagged " , body = InvokeStoredQueryResponse ) ,
( status = 400 , description = " Bad request (param type error; snapshot on a stored mutation) " , body = ErrorOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden (the inner `change` gate for a stored mutation) " , body = ErrorOutput ) ,
( status = 404 , description = " Unknown stored query, or `invoke_query` denied — indistinguishable to a caller without the grant " , body = ErrorOutput ) ,
( status = 409 , description = " Merge conflict " , body = ErrorOutput ) ,
( status = 429 , description = " Per-actor admission cap exceeded; honor `Retry-After` header " , body = ErrorOutput ) ,
( status = 500 , description = " Policy evaluation error (a denial is reported as 404, not 500) " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// Invoke a curated, server-side stored query by name.
///
/// The query source comes from the graph's `queries:` registry, not the
/// request body — callers send only runtime inputs (`params`, `branch`,
/// `snapshot`). Gated by the `invoke_query` Cedar action at the boundary;
/// a stored *mutation* additionally passes the engine's `change` gate
/// (double-gated). An actor **without** `invoke_query` cannot tell a denied
/// query from a missing one — both return the same 404, so the catalog
/// can't be probed without the grant. Once `invoke_query` is held, the
/// inner `read`/`change` gate may surface a 403 for an existing query the
/// actor can't run (the intended double-gate signal).
pub ( crate ) async fn server_invoke_query (
State ( state ) : State < AppState > ,
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Path ( QueryNamePath { name } ) : Path < QueryNamePath > ,
body : Bytes ,
) -> std ::result ::Result < Json < InvokeStoredQueryResponse > , ApiError > {
let req = parse_optional_invoke_body ( body ) ? ;
// A caller without `invoke_query` can't tell a denial from a missing
// query: both 404 with this exact message, so the catalog can't be
// probed without the grant. (A caller that holds invoke_query may still
// see the inner gate's 403 for an existing query it can't run — intended.)
const NOT_FOUND : & str = " stored query not found " ;
let actor_ref = actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ;
// Boundary gate (authentication already ran in `require_bearer_auth`).
// A denial is hidden as 404 (deny == missing, so the catalog can't be
// probed without the grant), but operational failures (401 missing bearer,
// 500 policy-evaluation error) propagate with their true status via `?`
// rather than being masked as a missing query.
match authorize (
actor_ref ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::InvokeQuery ,
// Graph-scoped: no branch dimension. The per-branch/snapshot
// access is enforced by the inner read/change gate in the
// runner, so the outer gate must not resolve a branch (doing so
// was wrong for snapshot reads).
branch : None ,
target_branch : None ,
} ,
) ? {
Authz ::Allowed = > { }
Authz ::Denied ( _ ) = > return Err ( ApiError ::not_found ( NOT_FOUND ) ) ,
}
// Resolve against the per-graph registry (same 404 on a miss).
let stored = handle
. queries
. as_ref ( )
. and_then ( | registry | registry . lookup ( & name ) )
. ok_or_else ( | | ApiError ::not_found ( NOT_FOUND ) ) ? ;
// Detach what we need before `handle` moves into the runner — the
// registry borrow lives inside `handle`.
let source = Arc ::clone ( & stored . source ) ;
let query_name = stored . name . clone ( ) ;
let is_mutation = stored . is_mutation ( ) ;
2026-06-15 16:52:58 +03:00
// RFC-011 D3: the CLI verb asserts the stored query's kind. `query <name>`
// sends `expect_mutation: false`, `mutate <name>` sends `true`; a mismatch
// is rejected here so the wrong verb errors instead of silently running.
if let Some ( expected ) = req . expect_mutation {
if expected ! = is_mutation {
let ( actual , verb ) = if is_mutation {
( " mutation " , " mutate " )
} else {
( " read " , " query " )
} ;
return Err ( ApiError ::bad_request ( format! (
" '{query_name}' is a {actual} — use omnigraph {verb} {query_name} "
) ) ) ;
}
}
2026-06-11 15:08:25 +03:00
info! (
graph = % handle . uri ,
actor = ? actor_ref . map ( | a | a . actor_id . as_ref ( ) ) ,
query = % query_name ,
kind = if is_mutation { " mutate " } else { " read " } ,
" stored query invoked "
) ;
if is_mutation {
if req . snapshot . is_some ( ) {
return Err ( ApiError ::bad_request (
" stored mutation cannot target a snapshot " ,
) ) ;
}
let branch = req . branch . unwrap_or_else ( | | " main " . to_string ( ) ) ;
let output = run_mutate (
state ,
handle ,
actor_ref ,
& source ,
Some ( & query_name ) ,
req . params . as_ref ( ) ,
branch ,
)
. await ? ;
Ok ( Json ( InvokeStoredQueryResponse ::Change ( output ) ) )
} else {
let ( selected , target , result ) = run_query (
handle ,
actor_ref ,
& source ,
Some ( & query_name ) ,
req . params . as_ref ( ) ,
req . branch ,
req . snapshot ,
true ,
)
. await ? ;
Ok ( Json ( InvokeStoredQueryResponse ::Read ( api ::read_output (
selected , & target , result ,
) ) ) )
}
}
#[ utoipa::path(
get ,
path = " /queries " ,
tag = " queries " ,
operation_id = " list_queries " ,
responses (
( status = 200 , description = " Stored-query catalog (the mcp.expose subset, with typed params) " , body = QueriesCatalogOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// List the graph's exposed stored queries as a typed tool catalog.
///
/// Returns the `mcp.expose == true` subset of the `queries:` registry, each
/// with its MCP tool name, read/mutate flag, description/instruction, and
/// typed parameters — enough for a client to register them as tools without
/// fetching `.gq` source. Read-gated; the catalog is graph-wide (branch
/// independent — `read` is authorized against `main`). **Not** Cedar-filtered
/// per query yet, so it can list a query whose `invoke_query` the caller
/// lacks (a known gap until per-query authorization lands).
pub ( crate ) async fn server_list_queries (
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
) -> std ::result ::Result < Json < QueriesCatalogOutput > , ApiError > {
authorize_request (
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::Read ,
branch : Some ( " main " . to_string ( ) ) ,
target_branch : None ,
} ,
) ? ;
let queries = match handle . queries . as_ref ( ) {
Some ( registry ) = > registry
. iter ( )
. filter ( | q | q . expose )
. map ( api ::query_catalog_entry )
. collect ( ) ,
None = > Vec ::new ( ) ,
} ;
Ok ( Json ( QueriesCatalogOutput { queries } ) )
}
#[ utoipa::path(
get ,
path = " /schema " ,
tag = " schema " ,
operation_id = " getSchema " ,
responses (
( status = 200 , description = " Current schema source " , body = SchemaOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// Read the current schema source.
///
/// Returns the project's schema as a single string in `.pg` source form.
/// Useful for clients that want to introspect available types and tables
/// before constructing GQ queries. Read-only.
pub ( crate ) async fn server_schema_get (
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
) -> std ::result ::Result < Json < SchemaOutput > , ApiError > {
authorize_request (
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::Read ,
branch : None ,
target_branch : None ,
} ,
) ? ;
let schema_source = {
let db = & handle . engine ;
db . schema_source ( ) . to_string ( )
} ;
Ok ( Json ( SchemaOutput { schema_source } ) )
}
#[ utoipa::path(
post ,
path = " /schema/apply " ,
tag = " mutations " ,
operation_id = " applySchema " ,
request_body = SchemaApplyRequest ,
responses (
( status = 200 , description = " Schema apply results " , body = SchemaApplyOutput ) ,
( status = 400 , description = " Bad request " , body = ErrorOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
2026-06-16 03:11:43 +03:00
( status = 409 , description = " Schema apply is disabled for cluster-backed serving; use `omnigraph cluster apply` and restart " , body = ErrorOutput ) ,
2026-06-11 15:08:25 +03:00
( status = 429 , description = " Per-actor admission cap exceeded; honor `Retry-After` header " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// Apply a schema migration.
///
2026-06-16 03:11:43 +03:00
/// Cluster-backed servers reject this route with `409 Conflict`; operators
/// must apply schema changes through `omnigraph cluster apply` and restart.
///
2026-06-11 15:08:25 +03:00
/// Diffs `schema_source` against the current schema and applies the resulting
/// migration steps (add/drop type, add/drop column, etc.). **Destructive**:
/// some steps drop data. Returns the list of steps applied; if `applied` is
/// false the diff was unsupported and no changes were made.
pub ( crate ) async fn server_schema_apply (
State ( state ) : State < AppState > ,
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Json ( request ) : Json < SchemaApplyRequest > ,
) -> std ::result ::Result < Json < SchemaApplyOutput > , ApiError > {
let actor_arc = actor
. as_ref ( )
. map ( | Extension ( actor ) | Arc ::clone ( & actor . actor_id ) )
. unwrap_or_else ( | | Arc ::< str > ::from ( " anonymous " ) ) ;
let actor_id = actor
. as_ref ( )
. map ( | Extension ( actor ) | actor . actor_id . as_ref ( ) ) ;
authorize_request (
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::SchemaApply ,
branch : None ,
target_branch : Some ( " main " . to_string ( ) ) ,
} ,
) ? ;
2026-06-16 03:11:43 +03:00
// Disable HTTP schema apply on cluster-backed serving AFTER the Cedar gate,
// so an unauthorized actor gets a 403 (not a 409 that would disclose the
// server is cluster-backed): 401 → 403 → 409, never leak topology before
// authorization. An authorized actor gets the actionable 409 signpost.
if state . routing ( ) . config_path . is_some ( ) {
return Err ( ApiError ::conflict (
" server-side schema apply is disabled for cluster-backed serving; \
update the cluster config , run ` omnigraph cluster apply ` , and restart \
the server . " ,
) ) ;
}
2026-06-11 15:08:25 +03:00
let est_bytes = request . schema_source . len ( ) as u64 ;
let _admission = state
. workload
. try_admit ( & actor_arc , est_bytes )
. map_err ( ApiError ::from_workload_reject ) ? ;
let result = {
let db = & handle . engine ;
let registry = handle . queries . as_deref ( ) ;
let label = handle . key . graph_id . as_str ( ) . to_string ( ) ;
// Engine-layer policy enforcement (MR-722): pass the resolved
// actor through so apply_schema_as can call enforce() with the
// authoritative identity. With a policy installed in AppState,
// engine-side enforcement re-checks the same decision the
// HTTP-layer authorize_request just made above. PR #3 collapses
// the redundancy.
db . apply_schema_as_with_catalog_check (
& request . schema_source ,
omnigraph ::db ::SchemaApplyOptions {
allow_data_loss : request . allow_data_loss ,
} ,
actor_id ,
| catalog | {
if let Some ( registry ) = registry {
validate_registry_against_catalog ( registry , catalog , & label ) ? ;
}
Ok ( ( ) )
} ,
)
. await
. map_err ( ApiError ::from_omni ) ?
} ;
Index materialization is derived state: defer off the write path, reconcile via optimize (iss-848) (#246)
* test(engine): reproduce empty-table Vector @index aborting schema apply
A Vector (IVF) index trains k-means centroids over the column, so Lance
cannot build it on 0 vectors ("Creating empty vector indices with
train=False is not yet implemented"). schema apply reconciles a table's
whole index set whenever any @index on it changes, so adding an unrelated
scalar @index materializes the dormant empty vector index and aborts the
entire migration (all-or-nothing).
This regression test inits a 0-row Doc with a Vector @index, adds a scalar
@index, and asserts the apply succeeds (then loads one embedded row and
asserts the deferred index materializes). It fails today at the apply step
with the vector-index abort; the fix lands in the next commit.
Refs dev-graph iss-empty-vector-index-schema-apply, iss-848.
* fix(engine): defer Vector @index on an empty table instead of aborting schema apply
build_indices_on_dataset_for_catalog materialized a declared Vector @index
unconditionally. On a 0-row table Lance cannot train the IVF index
("Creating empty vector indices with train=False is not yet implemented"),
so any later migration that touches the table (e.g. adding an unrelated
scalar @index, which reconciles the table's whole index set) aborted the
entire migration on the dormant vector index — all-or-nothing.
Guard the vector arm with a row-count check, matching the guard
ensure_indices_for_branch and the branch-merge rebuild already use: an
untrainable column becomes a pending index that a later ensure_indices /
optimize materializes once the table has rows. Reads stay correct meanwhile
(vector search degrades to a brute-force scan).
Stop-gap: the residual rows-present-but-vectors-null window and the full
decoupling (intent recorded at apply, an idempotent coverage reconciler)
are dev-graph iss-848. Turns the green half of the regression test added in
the previous commit.
Refs dev-graph iss-empty-vector-index-schema-apply, iss-848, iss-687.
* docs(invariants): record the logical-contract-over-physical-state principle
The bug class behind the empty-table vector-index abort (and the schema-apply
vs optimize version drift) is one shape: a physical operation allowed to fail
a logical one. Several hard invariants (2, 5, 7, 13) and deny-list items are
already instances of this, but the unifying rule was never written down.
Add it to docs/dev/invariants.md as a "Governing principle" section above the
hard invariants, naming which invariants and deny-list items instantiate it
and the smell to watch for (a logical operation gated on a physical fact).
Add a one-line always-on rule (7) in AGENTS.md so it stays in working memory,
with the qualifier that genuine logical conflicts still fail loudly — the
licence to lag covers physical convergence, not correctness.
Audience-neutral: no private ticket refs. check-agents-md.sh passes.
* test(engine): index build must tolerate rows with null vectors (load-before-embed)
Loading rows whose vector column is null into a `Vector @index` table fails
today: build_indices (reached via the loader's prepare_updates_for_commit)
calls create_vector_index, and Lance's IVF KMeans errors "cannot train 1
centroids with 0 vectors". The same abort hits ensure_indices/optimize/schema
apply/merge, since they all funnel through build_indices_on_dataset_for_catalog.
This test loads two null-embedding rows and calls ensure_indices; it must not
abort (the untrainable vector column is deferred, sibling indexes still build).
Fails today at the load step; fixed in the next commit.
Refs dev-graph iss-848, iss-empty-vector-index-schema-apply.
* fix(engine): defer unbuildable index columns instead of aborting the write path
build_indices_on_dataset_for_catalog is the chokepoint every write path funnels
through (load/mutate via prepare_updates_for_commit, schema apply, ensure_indices,
optimize, branch merge). Its vector arm called create_vector_index
unconditionally, so a column with no trainable vectors yet — an empty table, or
rows loaded before `embed` populates them — aborted the whole operation with
Lance's IVF KMeans error.
Fault-isolate the vector build: on failure, record the column as a PendingIndex
(table, column, reason), log it, and continue building the sibling indexes; a
later ensure_indices/optimize materializes it once the column is trainable, and
reads use brute-force meanwhile. Manifest/CAS/IO errors at the publish boundary
still propagate. Isolating at the single chokepoint realizes the governing
principle (physical index state never fails a logical operation) for every write
path, and supersedes the earlier symptomatic count_rows==0 stop-gap (removed) —
closing the residual rows-present-but-vectors-null window it left open.
Surfacing pending index status rather than failing is the database norm
(Postgres indisvalid, LanceDB list_indices). ensure_indices and the build_indices
wrappers now return Vec<PendingIndex>; optimize surfaces it in a later commit.
Refs dev-graph iss-848, iss-951 (vector index stays inline-commit until lance#6666).
* test(engine): index-only schema apply must not touch table data
Adding an @index to an existing column should be a pure metadata change once
index materialization moves to the reconciler (iss-848): the apply records the
intent in the catalog/IR but builds nothing inline, so the table's manifest
version is unchanged. Today the indexed_tables block builds the index inline
and bumps the version (4 -> 5). Fixed in the next commit.
Refs dev-graph iss-848.
* fix(engine): schema apply records index intent only; index-only apply is metadata
Schema apply no longer builds indexes inline. The four build_indices calls
(added/renamed/rewritten/index-only tables) are removed; the @index/@key intent
is already persisted in the catalog/IR the apply writes, and the physical index
is materialized off the critical path by ensure_indices/optimize (iss-848).
Concretely:
- AddConstraint (an @index addition — every other added constraint plans as
UnsupportedChange) becomes a pure metadata step alongside the metadata-only
steps: it touches no table data, so the table version is unchanged.
- added/renamed/rewritten tables still write their data; only the trailing
index build is gone. The rewritten table's coverage is restored later by
optimize_indices.
- recovery_pins drops index-only tables (they no longer advance Lance HEAD) and
keeps rewritten tables; their post_commit_pin = expected+1 is now exact (one
rewrite commit), strengthening recovery classification.
- the now-orphaned Omnigraph::build_indices_on_dataset_for_catalog wrapper is
removed.
A migration can no longer abort on an index build, for any index type at any
cardinality. Turns the green half of index_only_constraint_apply_touches_no_table_data.
Refs dev-graph iss-848.
* test(engine): optimize must converge a declared-but-unbuilt index
After iss-848, adding an @index post-data is a metadata-only apply that defers
the physical build, so the column is declared-indexed but unbuilt (reads scan).
`optimize` — the operator's cron reconciler — must materialize it. Today optimize
only maintains coverage of EXISTING indexes (optimize_indices) and never creates
missing ones, so the rank BTREE stays Degraded after optimize. Fixed next commit.
Refs dev-graph iss-848.
* fix(engine): optimize materializes declared-but-unbuilt indexes (the reconciler)
`omnigraph optimize` is the operator's cron reconciler. It already compacts and
folds new fragments into EXISTING indexes (optimize_indices); now it also builds
declared-but-missing indexes, so the indexes schema apply / load defer (iss-848)
converge on the next optimize.
Done inside optimize_one_table (not by composing the all-tables ensure_indices,
which is drift-blind and would re-publish the uncovered HEAD>manifest drift that
optimize deliberately skips): after the per-table drift/blob skips and under the
queue + Optimize sidecar already held, a needs_index_create gate (reusing
needs_index_work_node/edge — "declared index missing AND row_count > 0", so empty
tables stay no-ops) admits index-only work, and Phase B builds the missing index
over the just-compacted layout via the build chokepoint. An untrainable vector
column fault-isolates into the new TableOptimizeStats.pending_indexes (the
list_indices/indisvalid analog operators read), not a failure. committed now
reflects index commits, so the existing post-publish cache invalidation covers
them. LanceDB's optimize only maintains existing indexes; creating
declared-but-missing ones is the L2 behavior omnigraph's declarative @index needs.
Turns the green half of optimize_materializes_index_declared_but_unbuilt.
Refs dev-graph iss-848.
* docs: index materialization is deferred to the reconciler (iss-848)
Update the index-lifecycle docs to reflect the new contract: @index/@key
declares intent and the physical index is derived state that never fails a
logical operation. Schema apply builds nothing (records intent only);
load/mutate build inline through one chokepoint that defers an untrainable
Vector column as pending; optimize/ensure_indices is the reconciler that
creates declared-but-missing indexes and maintains coverage, reporting
still-pending columns.
Touches: dev/invariants.md (truth-matrix Index-lifecycle row), AGENTS.md
(capability matrix), user/search/indexes.md (L2 orchestration), user/operations/
maintenance.md (optimize reconciler bullet), dev/testing.md (new tests).
* test(server): schema_apply_route_can_add_index reflects deferred index build
iss-848 made schema apply record @index intent without building the physical
index inline. The route test asserted the index count increased after apply;
on an empty graph it now stays unchanged (the build is deferred to
ensure_indices/optimize). Assert the new contract: apply succeeds and the
physical index count is unchanged.
* fix(engine): precheck vector trainability — don't pin or swallow (PR review)
Two issues Cursor Bugbot caught in the chokepoint fault-isolation:
1. (HIGH) Pending vector pins roll back siblings. needs_index_work_node counted
a missing vector index as work whenever the table had rows, so a column with
no trainable vectors got pinned in the EnsureIndices recovery sidecar — but
the build deferred it (zero commit). On a crash before manifest publish the
classifier sees NoMovement and the all-or-nothing decision (recovery.rs
decide()) rolls back the WHOLE sidecar, undoing a sibling table's committed
index work.
2. (MED) Vector build swallowed fatal errors. The match arm converted every
create_vector_index error into a deferred PendingIndex, hiding genuine
I/O/manifest/Lance failures as "pending".
Fix both with one trainability precheck (vector_column_trainable: >=1 non-null
vector, the ivf_flat(1) minimum) used identically by needs_index_work_node and
the build arm: an untrainable column is never counted as work (so never pinned —
no zero-commit pin) and never attempted (so it can't fail); only a trainable
column is built, and then any error PROPAGATES (stays fatal). The deferred
column is still recorded as a PendingIndex with a clear reason.
Refs dev-graph iss-848.
* feat(cli): surface pending index column + reason in optimize output (PR review)
Codex (P2): pending_indexes was documented as visible in `optimize --json` but
the CLI projection never emitted it — operators would lose the only signal that
optimize has deferred index work. Greptile (P2): the stat dropped the reason, so
operators saw which column was stuck, not why.
Carry the reason: TableOptimizeStats.pending_indexes is now Vec<PendingIndex>
(column + reason), and `omnigraph optimize --json` emits {column, reason} per
pending index; human output prints a "↳ index pending on '<col>': <reason>" line.
Refs dev-graph iss-848.
* test: align CLI index-add test with deferred build; cover post-rename reconcile
- schema_apply_json_adds_index_for_existing_property (cli_schema_config.rs): the
CLI analog of the server test — asserted the index count grew after apply;
under iss-848 the apply defers the build, so the count is unchanged on an
empty graph. Assert the deferred contract. (The only full-suite failure.)
- optimize_materializes_index_after_type_rename (maintenance.rs, new): covers
the gap Greptile flagged — a RenameType writes the renamed table with rows but
no indexes (inline build removed in Commit B); assert the rank index is
Degraded post-rename and Indexed after optimize reconciles it.
Refs dev-graph iss-848.
* test(engine): in-source apply tests reflect deferred index materialization
The two db::omnigraph in-source unit tests asserted the old "schema apply builds
/ preserves indexes inline" behavior (the only remaining full-suite failures):
- test_apply_schema_defers_index_then_reconciler_builds_it (was
test_apply_schema_adds_index_for_existing_property): apply records the @index
intent but builds nothing; assert the BTREE on `age` is absent after apply and
present after ensure_indices. (Uses `age`, unindexed in TEST_SCHEMA — `name
@key` is already FTS-indexed at seed.)
- test_apply_schema_rewrite_defers_index_then_reconciler_restores (was
test_apply_schema_rewrite_preserves_existing_indices): an AddProperty rewrite
no longer rebuilds indexes inline; assert ensure_indices restores id BTREE +
name FTS after the rewrite.
Verified by grep that these + the server/CLI tests are the complete set of
"apply builds an index" assertions; all other index-presence tests run after
load/ensure_indices/primitives, which still build.
Refs dev-graph iss-848.
* fix(engine): optimize always reports pending indexes, not only on create-work (PR review)
Cursor Bugbot (MED): pending_indexes was filled only when needs_index_create was
true, but the vector trainability precheck makes needs_index_work_node exclude an
untrainable Vector column. So a table whose sole missing index is untrainable, but
which optimize still compacts or reindexes, returned an empty pending_indexes —
contradicting the documented operator contract for deferred columns.
Run the (idempotent) build chokepoint unconditionally once past the no-op gate,
rather than gating it on needs_index_create. It skips existing indexes, builds
any buildable missing one, and reports an untrainable column as pending whether
the table entered for compaction, reindex, or index creation. needs_index_create
still gates the no-op decision (so an index-only table still enters the path).
Refs dev-graph iss-848.
* test(engine): reframe staged-BTREE-failure failpoint onto the reconciler path
ensure_indices_stage_btree_failure_leaves_existing_tables_writable fired
`ensure_indices.post_stage_pre_commit_btree` and expected `apply_schema` (adding
a type) to fail mid-BTREE-build. iss-848 removed apply's inline index build, so
that apply now succeeds and the test's unwrap_err panicked — it exercised a
removed code path.
Reframe onto where BTREE builds happen now: seed Person, add an `@index` on
`age` (apply records intent, defers the build), then `ensure_indices` builds the
deferred BTREE and the failpoint fires between stage and commit. Person's HEAD
is unchanged (no drift) and its EnsureIndices sidecar pins NoMovement; a write to
a different, unpinned table (Company) is unaffected (mutations/loads heal
roll-forward and proceed, unlike optimize/repair which refuse on a pending
sidecar). Preserves the original coverage (staged-index stage failure leaves
other tables writable, no drift) in the new architecture.
Refs dev-graph iss-848.
* feat(server): converge deferred indexes promptly after schema apply (iss-848)
Schema apply records @index intent but defers the physical build. On a
long-lived server, spawn a detached best-effort ensure_indices after a
successful apply so the indexes converge promptly instead of waiting for the
operator's next optimize. Fire-and-forget: it never blocks or fails the apply
response, and a failure is logged (the index still converges on the next
optimize). Guarded on result.applied. The CLI is one-shot, so it has no
equivalent; its convergence path is the optimize cadence.
handle.engine is already an Arc, so the spawn takes an owned clone. Convergence
itself is covered by the engine ensure_indices/optimize tests; the existing
empty-graph schema-apply route tests confirm the response is unaffected (the
spawn is a read-only no-op on an empty table).
Refs dev-graph iss-848.
* docs(maintenance): list pending_indexes in optimize per-table stats (consistency)
2026-06-15 18:48:43 +02:00
// Prompt index convergence (iss-848): schema apply records `@index` intent
// but defers the physical build. On a long-lived server, materialize it
// promptly rather than waiting for the next `optimize` cron — spawned
// detached so it never blocks or fails the apply response. Best-effort: a
// failure is logged and the index still converges on the next optimize.
// The CLI is one-shot, so it has no equivalent; its convergence path is the
// operator's optimize cadence.
if result . applied {
let engine = Arc ::clone ( & handle . engine ) ;
tokio ::spawn ( async move {
if let Err ( err ) = engine . ensure_indices ( ) . await {
tracing ::warn! (
target : " omnigraph::server " ,
error = % err ,
" post-apply ensure_indices failed; indexes will converge on the next optimize " ,
) ;
}
} ) ;
}
2026-06-11 15:08:25 +03:00
Ok ( Json ( schema_apply_output ( handle . uri . as_str ( ) , result ) ) )
}
feat: canonical POST /load, deprecate /ingest (RFC-009 Phase 5) (#222)
* feat(server): canonical POST /load, deprecate /ingest (RFC-009 Phase 5)
The CLI's non-deprecated `load` verb rode the deprecated `/ingest` route, so
`/ingest`'s eventual removal would silently break it. Add a canonical `/load`,
mirroring the shipped `/mutate`↔`/change` and `/query`↔`/read` pattern.
- Extract `server_ingest`'s body into a shared `run_ingest` (branch-exists /
fork-if-`from`, Cedar auth, admission, `load_as`, `IngestOutput` mapping).
- `server_load` (canonical) → `run_ingest`, `Json<IngestOutput>`.
- `server_ingest` (deprecated) → `run_ingest` + `#[deprecated]` + RFC 9745/8288
`Deprecation: true` / `Link: </load>; rel="successor-version"` headers.
- Router mounts `/load` (same 32 MB body limit) beside `/ingest`; OpenAPI
`paths(...)` gains `server_load` and flags `server_ingest` deprecated.
`/load` reuses `IngestRequest`/`IngestOutput`, exactly as canonical `/mutate`
reuses `Change*` — a DTO rename is a separate, larger change (out of scope).
openapi.json regenerated. Tests: openapi `/load` present + not deprecated,
`/ingest` deprecated, `/load` bearer-secured; data_routes `/load` happy path +
`/ingest` deprecation headers. Existing `/ingest` route tests stay green (the
shim is unchanged). Docs: server.md endpoint table; RFC-009 Phase 5 marked
landed (incl. the hand-mount-vs-utoipa-axum registration finding).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): point remote load at /load (RFC-009 Phase 5)
`GraphClient::load`'s remote arm now POSTs to the canonical `/load` route
instead of the deprecated `/ingest`; the deprecated `ingest` verb keeps
riding `/ingest`. `parity_load` exercises `/load` on the remote arm (its
documented flip); the matrix exclusions comment is updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:32:16 +03:00
/// Shared body for `POST /load` (canonical) and `POST /ingest` (deprecated):
/// branch-exists / fork-if-`from` check, Cedar authorization, admission, the
/// bulk `load_as`, and the `IngestOutput` mapping.
async fn run_ingest (
state : AppState ,
handle : Arc < GraphHandle > ,
actor : Option < & ResolvedActor > ,
request : IngestRequest ,
) -> std ::result ::Result < IngestOutput , ApiError > {
2026-06-11 15:08:25 +03:00
let branch = request . branch . unwrap_or_else ( | | " main " . to_string ( ) ) ;
let from = request . from ;
let mode = request . mode . unwrap_or ( omnigraph ::loader ::LoadMode ::Merge ) ;
let actor_arc = actor
feat: canonical POST /load, deprecate /ingest (RFC-009 Phase 5) (#222)
* feat(server): canonical POST /load, deprecate /ingest (RFC-009 Phase 5)
The CLI's non-deprecated `load` verb rode the deprecated `/ingest` route, so
`/ingest`'s eventual removal would silently break it. Add a canonical `/load`,
mirroring the shipped `/mutate`↔`/change` and `/query`↔`/read` pattern.
- Extract `server_ingest`'s body into a shared `run_ingest` (branch-exists /
fork-if-`from`, Cedar auth, admission, `load_as`, `IngestOutput` mapping).
- `server_load` (canonical) → `run_ingest`, `Json<IngestOutput>`.
- `server_ingest` (deprecated) → `run_ingest` + `#[deprecated]` + RFC 9745/8288
`Deprecation: true` / `Link: </load>; rel="successor-version"` headers.
- Router mounts `/load` (same 32 MB body limit) beside `/ingest`; OpenAPI
`paths(...)` gains `server_load` and flags `server_ingest` deprecated.
`/load` reuses `IngestRequest`/`IngestOutput`, exactly as canonical `/mutate`
reuses `Change*` — a DTO rename is a separate, larger change (out of scope).
openapi.json regenerated. Tests: openapi `/load` present + not deprecated,
`/ingest` deprecated, `/load` bearer-secured; data_routes `/load` happy path +
`/ingest` deprecation headers. Existing `/ingest` route tests stay green (the
shim is unchanged). Docs: server.md endpoint table; RFC-009 Phase 5 marked
landed (incl. the hand-mount-vs-utoipa-axum registration finding).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): point remote load at /load (RFC-009 Phase 5)
`GraphClient::load`'s remote arm now POSTs to the canonical `/load` route
instead of the deprecated `/ingest`; the deprecated `ingest` verb keeps
riding `/ingest`. `parity_load` exercises `/load` on the remote arm (its
documented flip); the matrix exclusions comment is updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:32:16 +03:00
. map ( | actor | Arc ::clone ( & actor . actor_id ) )
2026-06-11 15:08:25 +03:00
. unwrap_or_else ( | | Arc ::< str > ::from ( " anonymous " ) ) ;
feat: canonical POST /load, deprecate /ingest (RFC-009 Phase 5) (#222)
* feat(server): canonical POST /load, deprecate /ingest (RFC-009 Phase 5)
The CLI's non-deprecated `load` verb rode the deprecated `/ingest` route, so
`/ingest`'s eventual removal would silently break it. Add a canonical `/load`,
mirroring the shipped `/mutate`↔`/change` and `/query`↔`/read` pattern.
- Extract `server_ingest`'s body into a shared `run_ingest` (branch-exists /
fork-if-`from`, Cedar auth, admission, `load_as`, `IngestOutput` mapping).
- `server_load` (canonical) → `run_ingest`, `Json<IngestOutput>`.
- `server_ingest` (deprecated) → `run_ingest` + `#[deprecated]` + RFC 9745/8288
`Deprecation: true` / `Link: </load>; rel="successor-version"` headers.
- Router mounts `/load` (same 32 MB body limit) beside `/ingest`; OpenAPI
`paths(...)` gains `server_load` and flags `server_ingest` deprecated.
`/load` reuses `IngestRequest`/`IngestOutput`, exactly as canonical `/mutate`
reuses `Change*` — a DTO rename is a separate, larger change (out of scope).
openapi.json regenerated. Tests: openapi `/load` present + not deprecated,
`/ingest` deprecated, `/load` bearer-secured; data_routes `/load` happy path +
`/ingest` deprecation headers. Existing `/ingest` route tests stay green (the
shim is unchanged). Docs: server.md endpoint table; RFC-009 Phase 5 marked
landed (incl. the hand-mount-vs-utoipa-axum registration finding).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): point remote load at /load (RFC-009 Phase 5)
`GraphClient::load`'s remote arm now POSTs to the canonical `/load` route
instead of the deprecated `/ingest`; the deprecated `ingest` verb keeps
riding `/ingest`. `parity_load` exercises `/load` on the remote arm (its
documented flip); the matrix exclusions comment is updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:32:16 +03:00
let actor_id = actor . map ( | actor | actor . actor_id . as_ref ( ) ) ;
2026-06-11 15:08:25 +03:00
let branch_exists = {
let db = & handle . engine ;
db . branch_list ( )
. await
. map_err ( ApiError ::from_omni ) ?
. into_iter ( )
. any ( | name | name = = branch )
} ;
if ! branch_exists {
match from . as_deref ( ) {
// Fork-if-missing is opt-in by presence of `from`; without it a
// typo'd branch name must surface as an error, not silently
// create a fork and land the data there.
None = > {
return Err ( ApiError ::not_found ( format! (
" branch '{branch}' not found; pass `from` to create it "
) ) ) ;
}
Some ( from ) = > authorize_request (
feat: canonical POST /load, deprecate /ingest (RFC-009 Phase 5) (#222)
* feat(server): canonical POST /load, deprecate /ingest (RFC-009 Phase 5)
The CLI's non-deprecated `load` verb rode the deprecated `/ingest` route, so
`/ingest`'s eventual removal would silently break it. Add a canonical `/load`,
mirroring the shipped `/mutate`↔`/change` and `/query`↔`/read` pattern.
- Extract `server_ingest`'s body into a shared `run_ingest` (branch-exists /
fork-if-`from`, Cedar auth, admission, `load_as`, `IngestOutput` mapping).
- `server_load` (canonical) → `run_ingest`, `Json<IngestOutput>`.
- `server_ingest` (deprecated) → `run_ingest` + `#[deprecated]` + RFC 9745/8288
`Deprecation: true` / `Link: </load>; rel="successor-version"` headers.
- Router mounts `/load` (same 32 MB body limit) beside `/ingest`; OpenAPI
`paths(...)` gains `server_load` and flags `server_ingest` deprecated.
`/load` reuses `IngestRequest`/`IngestOutput`, exactly as canonical `/mutate`
reuses `Change*` — a DTO rename is a separate, larger change (out of scope).
openapi.json regenerated. Tests: openapi `/load` present + not deprecated,
`/ingest` deprecated, `/load` bearer-secured; data_routes `/load` happy path +
`/ingest` deprecation headers. Existing `/ingest` route tests stay green (the
shim is unchanged). Docs: server.md endpoint table; RFC-009 Phase 5 marked
landed (incl. the hand-mount-vs-utoipa-axum registration finding).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): point remote load at /load (RFC-009 Phase 5)
`GraphClient::load`'s remote arm now POSTs to the canonical `/load` route
instead of the deprecated `/ingest`; the deprecated `ingest` verb keeps
riding `/ingest`. `parity_load` exercises `/load` on the remote arm (its
documented flip); the matrix exclusions comment is updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:32:16 +03:00
actor ,
2026-06-11 15:08:25 +03:00
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::BranchCreate ,
branch : Some ( from . to_string ( ) ) ,
target_branch : Some ( branch . clone ( ) ) ,
} ,
) ? ,
}
}
authorize_request (
feat: canonical POST /load, deprecate /ingest (RFC-009 Phase 5) (#222)
* feat(server): canonical POST /load, deprecate /ingest (RFC-009 Phase 5)
The CLI's non-deprecated `load` verb rode the deprecated `/ingest` route, so
`/ingest`'s eventual removal would silently break it. Add a canonical `/load`,
mirroring the shipped `/mutate`↔`/change` and `/query`↔`/read` pattern.
- Extract `server_ingest`'s body into a shared `run_ingest` (branch-exists /
fork-if-`from`, Cedar auth, admission, `load_as`, `IngestOutput` mapping).
- `server_load` (canonical) → `run_ingest`, `Json<IngestOutput>`.
- `server_ingest` (deprecated) → `run_ingest` + `#[deprecated]` + RFC 9745/8288
`Deprecation: true` / `Link: </load>; rel="successor-version"` headers.
- Router mounts `/load` (same 32 MB body limit) beside `/ingest`; OpenAPI
`paths(...)` gains `server_load` and flags `server_ingest` deprecated.
`/load` reuses `IngestRequest`/`IngestOutput`, exactly as canonical `/mutate`
reuses `Change*` — a DTO rename is a separate, larger change (out of scope).
openapi.json regenerated. Tests: openapi `/load` present + not deprecated,
`/ingest` deprecated, `/load` bearer-secured; data_routes `/load` happy path +
`/ingest` deprecation headers. Existing `/ingest` route tests stay green (the
shim is unchanged). Docs: server.md endpoint table; RFC-009 Phase 5 marked
landed (incl. the hand-mount-vs-utoipa-axum registration finding).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): point remote load at /load (RFC-009 Phase 5)
`GraphClient::load`'s remote arm now POSTs to the canonical `/load` route
instead of the deprecated `/ingest`; the deprecated `ingest` verb keeps
riding `/ingest`. `parity_load` exercises `/load` on the remote arm (its
documented flip); the matrix exclusions comment is updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:32:16 +03:00
actor ,
2026-06-11 15:08:25 +03:00
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::Change ,
branch : Some ( branch . clone ( ) ) ,
target_branch : None ,
} ,
) ? ;
let est_bytes = request . data . len ( ) as u64 ;
let _admission = state
. workload
. try_admit ( & actor_arc , est_bytes )
. map_err ( ApiError ::from_workload_reject ) ? ;
let result = {
let db = & handle . engine ;
db . load_as ( & branch , from . as_deref ( ) , & request . data , mode , actor_id )
. await
. map_err ( ApiError ::from_omni ) ?
} ;
feat: canonical POST /load, deprecate /ingest (RFC-009 Phase 5) (#222)
* feat(server): canonical POST /load, deprecate /ingest (RFC-009 Phase 5)
The CLI's non-deprecated `load` verb rode the deprecated `/ingest` route, so
`/ingest`'s eventual removal would silently break it. Add a canonical `/load`,
mirroring the shipped `/mutate`↔`/change` and `/query`↔`/read` pattern.
- Extract `server_ingest`'s body into a shared `run_ingest` (branch-exists /
fork-if-`from`, Cedar auth, admission, `load_as`, `IngestOutput` mapping).
- `server_load` (canonical) → `run_ingest`, `Json<IngestOutput>`.
- `server_ingest` (deprecated) → `run_ingest` + `#[deprecated]` + RFC 9745/8288
`Deprecation: true` / `Link: </load>; rel="successor-version"` headers.
- Router mounts `/load` (same 32 MB body limit) beside `/ingest`; OpenAPI
`paths(...)` gains `server_load` and flags `server_ingest` deprecated.
`/load` reuses `IngestRequest`/`IngestOutput`, exactly as canonical `/mutate`
reuses `Change*` — a DTO rename is a separate, larger change (out of scope).
openapi.json regenerated. Tests: openapi `/load` present + not deprecated,
`/ingest` deprecated, `/load` bearer-secured; data_routes `/load` happy path +
`/ingest` deprecation headers. Existing `/ingest` route tests stay green (the
shim is unchanged). Docs: server.md endpoint table; RFC-009 Phase 5 marked
landed (incl. the hand-mount-vs-utoipa-axum registration finding).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): point remote load at /load (RFC-009 Phase 5)
`GraphClient::load`'s remote arm now POSTs to the canonical `/load` route
instead of the deprecated `/ingest`; the deprecated `ingest` verb keeps
riding `/ingest`. `parity_load` exercises `/load` on the remote arm (its
documented flip); the matrix exclusions comment is updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:32:16 +03:00
Ok ( ingest_output (
2026-06-11 15:08:25 +03:00
handle . uri . as_str ( ) ,
& result ,
mode ,
actor_id . map ( str ::to_string ) ,
feat: canonical POST /load, deprecate /ingest (RFC-009 Phase 5) (#222)
* feat(server): canonical POST /load, deprecate /ingest (RFC-009 Phase 5)
The CLI's non-deprecated `load` verb rode the deprecated `/ingest` route, so
`/ingest`'s eventual removal would silently break it. Add a canonical `/load`,
mirroring the shipped `/mutate`↔`/change` and `/query`↔`/read` pattern.
- Extract `server_ingest`'s body into a shared `run_ingest` (branch-exists /
fork-if-`from`, Cedar auth, admission, `load_as`, `IngestOutput` mapping).
- `server_load` (canonical) → `run_ingest`, `Json<IngestOutput>`.
- `server_ingest` (deprecated) → `run_ingest` + `#[deprecated]` + RFC 9745/8288
`Deprecation: true` / `Link: </load>; rel="successor-version"` headers.
- Router mounts `/load` (same 32 MB body limit) beside `/ingest`; OpenAPI
`paths(...)` gains `server_load` and flags `server_ingest` deprecated.
`/load` reuses `IngestRequest`/`IngestOutput`, exactly as canonical `/mutate`
reuses `Change*` — a DTO rename is a separate, larger change (out of scope).
openapi.json regenerated. Tests: openapi `/load` present + not deprecated,
`/ingest` deprecated, `/load` bearer-secured; data_routes `/load` happy path +
`/ingest` deprecation headers. Existing `/ingest` route tests stay green (the
shim is unchanged). Docs: server.md endpoint table; RFC-009 Phase 5 marked
landed (incl. the hand-mount-vs-utoipa-axum registration finding).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): point remote load at /load (RFC-009 Phase 5)
`GraphClient::load`'s remote arm now POSTs to the canonical `/load` route
instead of the deprecated `/ingest`; the deprecated `ingest` verb keeps
riding `/ingest`. `parity_load` exercises `/load` on the remote arm (its
documented flip); the matrix exclusions comment is updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:32:16 +03:00
) )
}
#[ utoipa::path(
post ,
path = " /load " ,
tag = " mutations " ,
operation_id = " load " ,
request_body = IngestRequest ,
responses (
( status = 200 , description = " Load results " , body = IngestOutput ) ,
( status = 400 , description = " Bad request " , body = ErrorOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
( status = 429 , description = " Per-actor admission cap exceeded; honor `Retry-After` header " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// Bulk-load NDJSON data into a branch (canonical load endpoint).
///
/// `data` is NDJSON with one record per line. `mode` controls behavior on
/// existing rows: `merge` upserts by id (default), `append` blindly inserts,
/// `overwrite` replaces table contents. Branch creation is opt-in by
/// presence of `from`: with `from` set, a missing `branch` is created from
/// it; without `from`, `branch` must already exist — a missing branch is a
/// 404, never an implicit fork. **Destructive** when `mode` is `overwrite`
/// or when the load produces conflicting writes.
///
/// The legacy `POST /ingest` route has identical semantics and is kept as a
/// deprecated alias.
pub ( crate ) async fn server_load (
State ( state ) : State < AppState > ,
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Json ( request ) : Json < IngestRequest > ,
) -> std ::result ::Result < Json < IngestOutput > , ApiError > {
Ok ( Json (
run_ingest (
state ,
handle ,
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
request ,
)
. await ? ,
) )
}
#[ utoipa::path(
post ,
path = " /ingest " ,
tag = " mutations " ,
operation_id = " ingest " ,
request_body = IngestRequest ,
responses (
2026-06-16 03:11:43 +03:00
( status = 200 , description = " Load results (response includes `Deprecation: true` + `Link: <load>; rel= \" successor-version \" `) " , body = IngestOutput ) ,
feat: canonical POST /load, deprecate /ingest (RFC-009 Phase 5) (#222)
* feat(server): canonical POST /load, deprecate /ingest (RFC-009 Phase 5)
The CLI's non-deprecated `load` verb rode the deprecated `/ingest` route, so
`/ingest`'s eventual removal would silently break it. Add a canonical `/load`,
mirroring the shipped `/mutate`↔`/change` and `/query`↔`/read` pattern.
- Extract `server_ingest`'s body into a shared `run_ingest` (branch-exists /
fork-if-`from`, Cedar auth, admission, `load_as`, `IngestOutput` mapping).
- `server_load` (canonical) → `run_ingest`, `Json<IngestOutput>`.
- `server_ingest` (deprecated) → `run_ingest` + `#[deprecated]` + RFC 9745/8288
`Deprecation: true` / `Link: </load>; rel="successor-version"` headers.
- Router mounts `/load` (same 32 MB body limit) beside `/ingest`; OpenAPI
`paths(...)` gains `server_load` and flags `server_ingest` deprecated.
`/load` reuses `IngestRequest`/`IngestOutput`, exactly as canonical `/mutate`
reuses `Change*` — a DTO rename is a separate, larger change (out of scope).
openapi.json regenerated. Tests: openapi `/load` present + not deprecated,
`/ingest` deprecated, `/load` bearer-secured; data_routes `/load` happy path +
`/ingest` deprecation headers. Existing `/ingest` route tests stay green (the
shim is unchanged). Docs: server.md endpoint table; RFC-009 Phase 5 marked
landed (incl. the hand-mount-vs-utoipa-axum registration finding).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): point remote load at /load (RFC-009 Phase 5)
`GraphClient::load`'s remote arm now POSTs to the canonical `/load` route
instead of the deprecated `/ingest`; the deprecated `ingest` verb keeps
riding `/ingest`. `parity_load` exercises `/load` on the remote arm (its
documented flip); the matrix exclusions comment is updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:32:16 +03:00
( status = 400 , description = " Bad request " , body = ErrorOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
( status = 429 , description = " Per-actor admission cap exceeded; honor `Retry-After` header " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
#[ deprecated(note = " use POST /load instead; /ingest is kept indefinitely for back-compat " ) ]
/// **Deprecated** — use [`POST /load`](#tag/mutations/operation/load) instead.
///
/// Bulk-load NDJSON data into a branch. Behavior is unchanged; the route is
/// kept indefinitely for back-compat. New integrations should target
/// `POST /load`, which has identical semantics. Responses from this route
2026-06-16 03:11:43 +03:00
/// include `Deprecation: true` and `Link: <load>; rel="successor-version"`
feat: canonical POST /load, deprecate /ingest (RFC-009 Phase 5) (#222)
* feat(server): canonical POST /load, deprecate /ingest (RFC-009 Phase 5)
The CLI's non-deprecated `load` verb rode the deprecated `/ingest` route, so
`/ingest`'s eventual removal would silently break it. Add a canonical `/load`,
mirroring the shipped `/mutate`↔`/change` and `/query`↔`/read` pattern.
- Extract `server_ingest`'s body into a shared `run_ingest` (branch-exists /
fork-if-`from`, Cedar auth, admission, `load_as`, `IngestOutput` mapping).
- `server_load` (canonical) → `run_ingest`, `Json<IngestOutput>`.
- `server_ingest` (deprecated) → `run_ingest` + `#[deprecated]` + RFC 9745/8288
`Deprecation: true` / `Link: </load>; rel="successor-version"` headers.
- Router mounts `/load` (same 32 MB body limit) beside `/ingest`; OpenAPI
`paths(...)` gains `server_load` and flags `server_ingest` deprecated.
`/load` reuses `IngestRequest`/`IngestOutput`, exactly as canonical `/mutate`
reuses `Change*` — a DTO rename is a separate, larger change (out of scope).
openapi.json regenerated. Tests: openapi `/load` present + not deprecated,
`/ingest` deprecated, `/load` bearer-secured; data_routes `/load` happy path +
`/ingest` deprecation headers. Existing `/ingest` route tests stay green (the
shim is unchanged). Docs: server.md endpoint table; RFC-009 Phase 5 marked
landed (incl. the hand-mount-vs-utoipa-axum registration finding).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): point remote load at /load (RFC-009 Phase 5)
`GraphClient::load`'s remote arm now POSTs to the canonical `/load` route
instead of the deprecated `/ingest`; the deprecated `ingest` verb keeps
riding `/ingest`. `parity_load` exercises `/load` on the remote arm (its
documented flip); the matrix exclusions comment is updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:32:16 +03:00
/// headers per RFC 9745 / RFC 8288 so SDKs and proxies can surface the signal.
pub ( crate ) async fn server_ingest (
State ( state ) : State < AppState > ,
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Json ( request ) : Json < IngestRequest > ,
) -> std ::result ::Result < ( [ ( HeaderName , HeaderValue ) ; 2 ] , Json < IngestOutput > ) , ApiError > {
let output = run_ingest (
state ,
handle ,
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
request ,
)
. await ? ;
Ok ( (
2026-06-16 03:11:43 +03:00
deprecation_headers ( " <load>; rel= \" successor-version \" " ) ,
feat: canonical POST /load, deprecate /ingest (RFC-009 Phase 5) (#222)
* feat(server): canonical POST /load, deprecate /ingest (RFC-009 Phase 5)
The CLI's non-deprecated `load` verb rode the deprecated `/ingest` route, so
`/ingest`'s eventual removal would silently break it. Add a canonical `/load`,
mirroring the shipped `/mutate`↔`/change` and `/query`↔`/read` pattern.
- Extract `server_ingest`'s body into a shared `run_ingest` (branch-exists /
fork-if-`from`, Cedar auth, admission, `load_as`, `IngestOutput` mapping).
- `server_load` (canonical) → `run_ingest`, `Json<IngestOutput>`.
- `server_ingest` (deprecated) → `run_ingest` + `#[deprecated]` + RFC 9745/8288
`Deprecation: true` / `Link: </load>; rel="successor-version"` headers.
- Router mounts `/load` (same 32 MB body limit) beside `/ingest`; OpenAPI
`paths(...)` gains `server_load` and flags `server_ingest` deprecated.
`/load` reuses `IngestRequest`/`IngestOutput`, exactly as canonical `/mutate`
reuses `Change*` — a DTO rename is a separate, larger change (out of scope).
openapi.json regenerated. Tests: openapi `/load` present + not deprecated,
`/ingest` deprecated, `/load` bearer-secured; data_routes `/load` happy path +
`/ingest` deprecation headers. Existing `/ingest` route tests stay green (the
shim is unchanged). Docs: server.md endpoint table; RFC-009 Phase 5 marked
landed (incl. the hand-mount-vs-utoipa-axum registration finding).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat(cli): point remote load at /load (RFC-009 Phase 5)
`GraphClient::load`'s remote arm now POSTs to the canonical `/load` route
instead of the deprecated `/ingest`; the deprecated `ingest` verb keeps
riding `/ingest`. `parity_load` exercises `/load` on the remote arm (its
documented flip); the matrix exclusions comment is updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:32:16 +03:00
Json ( output ) ,
) )
2026-06-11 15:08:25 +03:00
}
#[ utoipa::path(
get ,
path = " /branches " ,
tag = " branches " ,
operation_id = " listBranches " ,
responses (
( status = 200 , description = " List of branches " , body = BranchListOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// List all branches.
///
/// Returns branch names sorted alphabetically. Read-only.
pub ( crate ) async fn server_branch_list (
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
) -> std ::result ::Result < Json < BranchListOutput > , ApiError > {
authorize_request (
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::Read ,
branch : None ,
target_branch : None ,
} ,
) ? ;
let mut branches = {
let db = & handle . engine ;
db . branch_list ( ) . await . map_err ( ApiError ::from_omni ) ?
} ;
branches . sort ( ) ;
Ok ( Json ( BranchListOutput { branches } ) )
}
#[ utoipa::path(
post ,
path = " /branches " ,
tag = " branches " ,
operation_id = " createBranch " ,
request_body = BranchCreateRequest ,
responses (
( status = 200 , description = " Branch created " , body = BranchCreateOutput ) ,
( status = 400 , description = " Bad request " , body = ErrorOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
( status = 409 , description = " Branch already exists " , body = ErrorOutput ) ,
( status = 429 , description = " Per-actor admission cap exceeded; honor `Retry-After` header " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// Create a new branch.
///
/// Forks `name` off of `from` (defaults to `main`). The new branch shares
/// table data with its parent until it is mutated. Returns 409 if `name`
/// already exists.
pub ( crate ) async fn server_branch_create (
State ( state ) : State < AppState > ,
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Json ( request ) : Json < BranchCreateRequest > ,
) -> std ::result ::Result < Json < BranchCreateOutput > , ApiError > {
let from = request . from . unwrap_or_else ( | | " main " . to_string ( ) ) ;
let actor_arc = actor
. as_ref ( )
. map ( | Extension ( actor ) | Arc ::clone ( & actor . actor_id ) )
. unwrap_or_else ( | | Arc ::< str > ::from ( " anonymous " ) ) ;
authorize_request (
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::BranchCreate ,
branch : Some ( from . clone ( ) ) ,
target_branch : Some ( request . name . clone ( ) ) ,
} ,
) ? ;
// Branch metadata only — small constant bytes estimate. The Lance
// shallow-clone work is bounded by the parent's manifest size, not
// the request body.
let _admission = state
. workload
. try_admit ( & actor_arc , 256 )
. map_err ( ApiError ::from_workload_reject ) ? ;
{
let db = & handle . engine ;
db . branch_create_from_as (
ReadTarget ::branch ( & from ) ,
& request . name ,
actor . as_ref ( ) . map ( | Extension ( a ) | a . actor_id . as_ref ( ) ) ,
)
. await
. map_err ( ApiError ::from_omni ) ? ;
}
Ok ( Json ( BranchCreateOutput {
uri : handle . uri . clone ( ) ,
from ,
name : request . name ,
actor_id : actor . map ( | Extension ( actor ) | actor . actor_id . as_ref ( ) . to_string ( ) ) ,
} ) )
}
/// Path-param shape for [`server_branch_delete`]. Named-field
/// deserialization (rather than `Path<String>` or `Path<(String,)>`)
/// keeps the extractor stable across single-mode flat routes and
/// multi-mode nested routes: the `{branch}` capture is picked by
/// name and any other captures in scope (e.g. `{graph_id}` in
/// multi-mode) are ignored without breaking deserialization.
///
/// Closes the "handler path-extractor type is positional and breaks
/// when route nesting changes" class.
#[ derive(Deserialize) ]
pub ( crate ) struct BranchPath {
branch : String ,
}
#[ utoipa::path(
delete ,
path = " /branches/{branch} " ,
tag = " branches " ,
operation_id = " deleteBranch " ,
params (
( " branch " = String , Path , description = " Branch name to delete " ) ,
) ,
responses (
( status = 200 , description = " Branch deleted " , body = BranchDeleteOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
( status = 404 , description = " Branch not found " , body = ErrorOutput ) ,
( status = 429 , description = " Per-actor admission cap exceeded; honor `Retry-After` header " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// Delete a branch.
///
/// **Irreversible.** Removes the branch pointer; commits remain reachable
/// only if referenced by another branch. Returns 404 if the branch does not
/// exist.
pub ( crate ) async fn server_branch_delete (
State ( state ) : State < AppState > ,
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Path ( BranchPath { branch } ) : Path < BranchPath > ,
) -> std ::result ::Result < Json < BranchDeleteOutput > , ApiError > {
let actor_arc = actor
. as_ref ( )
. map ( | Extension ( actor ) | Arc ::clone ( & actor . actor_id ) )
. unwrap_or_else ( | | Arc ::< str > ::from ( " anonymous " ) ) ;
let actor_id = actor
. as_ref ( )
. map ( | Extension ( actor ) | actor . actor_id . as_ref ( ) ) ;
authorize_request (
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::BranchDelete ,
branch : None ,
target_branch : Some ( branch . clone ( ) ) ,
} ,
) ? ;
// Metadata-only manifest tombstone — small constant estimate.
let _admission = state
. workload
. try_admit ( & actor_arc , 256 )
. map_err ( ApiError ::from_workload_reject ) ? ;
{
let db = & handle . engine ;
db . branch_delete_as ( & branch , actor_id )
. await
. map_err ( ApiError ::from_omni ) ? ;
}
Ok ( Json ( BranchDeleteOutput {
uri : handle . uri . clone ( ) ,
name : branch ,
actor_id : actor_id . map ( str ::to_string ) ,
} ) )
}
#[ utoipa::path(
post ,
path = " /branches/merge " ,
tag = " branches " ,
operation_id = " mergeBranches " ,
request_body = BranchMergeRequest ,
responses (
( status = 200 , description = " Branches merged " , body = BranchMergeOutput ) ,
( status = 400 , description = " Bad request " , body = ErrorOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
( status = 409 , description = " Merge conflict " , body = ErrorOutput ) ,
( status = 429 , description = " Per-actor admission cap exceeded; honor `Retry-After` header " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// Merge one branch into another.
///
/// Merges `source` into `target` (defaults to `main`). Outcome is one of
/// `already_up_to_date`, `fast_forward`, or `merged`. Returns 409 with the
/// list of conflicts if the merge cannot be completed; the target is left
/// unchanged in that case. **Destructive** to `target` on success.
pub ( crate ) async fn server_branch_merge (
State ( state ) : State < AppState > ,
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Json ( request ) : Json < BranchMergeRequest > ,
) -> std ::result ::Result < Json < BranchMergeOutput > , ApiError > {
let target = request . target . unwrap_or_else ( | | " main " . to_string ( ) ) ;
let actor_arc = actor
. as_ref ( )
. map ( | Extension ( actor ) | Arc ::clone ( & actor . actor_id ) )
. unwrap_or_else ( | | Arc ::< str > ::from ( " anonymous " ) ) ;
let actor_id = actor
. as_ref ( )
. map ( | Extension ( actor ) | actor . actor_id . as_ref ( ) ) ;
authorize_request (
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::BranchMerge ,
branch : Some ( request . source . clone ( ) ) ,
target_branch : Some ( target . clone ( ) ) ,
} ,
) ? ;
// Merge body is small JSON; the heavy work is in the engine but is
// bounded per-(table, branch) by the writer queue. Small constant
// estimate suffices for the actor in-flight count.
let _admission = state
. workload
. try_admit ( & actor_arc , 256 )
. map_err ( ApiError ::from_workload_reject ) ? ;
let outcome = {
let db = & handle . engine ;
db . branch_merge_as ( & request . source , & target , actor_id )
. await
. map_err ( ApiError ::from_omni ) ?
} ;
Ok ( Json ( BranchMergeOutput {
source : request . source ,
target ,
outcome : outcome . into ( ) ,
actor_id : actor_id . map ( str ::to_string ) ,
} ) )
}
#[ utoipa::path(
get ,
path = " /commits " ,
tag = " commits " ,
operation_id = " listCommits " ,
params ( CommitListQuery ) ,
responses (
( status = 200 , description = " List of commits " , body = CommitListOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// List commits.
///
/// Filter by `branch` to get the commits on a single branch (most recent
/// first); omit to list across all branches. Read-only.
pub ( crate ) async fn server_commit_list (
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Query ( query ) : Query < CommitListQuery > ,
) -> std ::result ::Result < Json < CommitListOutput > , ApiError > {
authorize_request (
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::Read ,
branch : query . branch . clone ( ) ,
target_branch : None ,
} ,
) ? ;
let commits = {
let db = & handle . engine ;
db . list_commits ( query . branch . as_deref ( ) )
. await
. map_err ( ApiError ::from_omni ) ?
} ;
Ok ( Json ( CommitListOutput {
commits : commits . iter ( ) . map ( api ::commit_output ) . collect ( ) ,
} ) )
}
/// Path-param shape for [`server_commit_show`]. See [`BranchPath`]
/// for the design rationale — same pattern, different field name.
#[ derive(Deserialize) ]
pub ( crate ) struct CommitPath {
commit_id : String ,
}
#[ utoipa::path(
get ,
path = " /commits/{commit_id} " ,
tag = " commits " ,
operation_id = " getCommit " ,
params (
( " commit_id " = String , Path , description = " Commit identifier " ) ,
) ,
responses (
( status = 200 , description = " Commit details " , body = api ::CommitOutput ) ,
( status = 401 , description = " Unauthorized " , body = ErrorOutput ) ,
( status = 403 , description = " Forbidden " , body = ErrorOutput ) ,
( status = 404 , description = " Commit not found " , body = ErrorOutput ) ,
) ,
security ( ( " bearer_token " = [ ] ) ) ,
) ]
/// Get a single commit.
///
/// Returns the commit's manifest version, parent commit(s), and creation
/// metadata. Read-only.
pub ( crate ) async fn server_commit_show (
Extension ( handle ) : Extension < Arc < GraphHandle > > ,
actor : Option < Extension < ResolvedActor > > ,
Path ( CommitPath { commit_id } ) : Path < CommitPath > ,
) -> std ::result ::Result < Json < api ::CommitOutput > , ApiError > {
authorize_request (
actor . as_ref ( ) . map ( | Extension ( actor ) | actor ) ,
handle . policy . as_deref ( ) ,
PolicyRequest {
action : PolicyAction ::Read ,
branch : None ,
target_branch : None ,
} ,
) ? ;
let commit = {
let db = & handle . engine ;
db . get_commit ( & commit_id )
. await
. map_err ( ApiError ::from_omni ) ?
} ;
Ok ( Json ( api ::commit_output ( & commit ) ) )
}
pub ( crate ) fn read_target_from_request ( branch : Option < String > , snapshot : Option < String > ) -> ReadTarget {
if let Some ( snapshot ) = snapshot {
ReadTarget ::snapshot ( omnigraph ::db ::SnapshotId ::new ( snapshot ) )
} else {
ReadTarget ::branch ( branch . unwrap_or_else ( | | " main " . to_string ( ) ) )
}
}
pub ( crate ) fn select_named_query_decl (
query_source : & str ,
requested_name : Option < & str > ,
) -> Result < omnigraph_compiler ::query ::ast ::QueryDecl > {
let parsed = parse_query ( query_source ) ? ;
let query = if let Some ( name ) = requested_name {
parsed
. queries
. into_iter ( )
. find ( | query | query . name = = name )
. ok_or_else ( | | color_eyre ::eyre ::eyre! ( " query '{}' not found " , name ) ) ?
} else if parsed . queries . len ( ) = = 1 {
parsed . queries . into_iter ( ) . next ( ) . unwrap ( )
} else {
bail! ( " query file contains multiple queries; pass --name " ) ;
} ;
Ok ( query )
}
pub ( crate ) fn select_named_query (
query_source : & str ,
requested_name : Option < & str > ,
) -> Result < ( String , Vec < omnigraph_compiler ::query ::ast ::Param > ) > {
let query = select_named_query_decl ( query_source , requested_name ) ? ;
Ok ( ( query . name , query . params ) )
}
pub ( crate ) fn query_params_from_json (
query_params : & [ omnigraph_compiler ::query ::ast ::Param ] ,
params_json : Option < & Value > ,
) -> Result < ParamMap > {
json_params_to_param_map ( params_json , query_params , JsonParamMode ::Standard )
. map_err ( | err | color_eyre ::eyre ::eyre! ( err . to_string ( ) ) )
}