From 1f058beb4174f0cf0a37955c24e5a1babd4c42f0 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:39:07 -0600 Subject: [PATCH 01/77] fix: conceal CLI secrets and honor the requested bind address --- crates/schema-forge-cli/src/cli.rs | 26 +++++++++--- crates/schema-forge-cli/src/commands/serve.rs | 3 +- .../schema-forge-cli/tests/cli_integration.rs | 41 +++++++++++++++++++ 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/crates/schema-forge-cli/src/cli.rs b/crates/schema-forge-cli/src/cli.rs index b0a4c55a..4e977b32 100644 --- a/crates/schema-forge-cli/src/cli.rs +++ b/crates/schema-forge-cli/src/cli.rs @@ -1,3 +1,4 @@ +use std::net::IpAddr; use std::path::PathBuf; use clap::{ArgAction, Args, Parser, Subcommand}; @@ -57,7 +58,12 @@ pub struct GlobalOpts { pub no_color: bool, /// Database connection URL (auto-detects backend from scheme) [env: SCHEMA_FORGE_DB_URL] - #[arg(long = "db-url", global = true, env = "SCHEMA_FORGE_DB_URL")] + #[arg( + long = "db-url", + global = true, + env = "SCHEMA_FORGE_DB_URL", + hide_env_values = true + )] pub db_url: Option, /// Database namespace (SurrealDB only) [env: SCHEMA_FORGE_DB_NS] @@ -349,7 +355,11 @@ pub struct BootstrapAdminArgs { /// interactively — operators run this command from provisioning /// pipelines (init containers, ansible playbooks) where stdin isn't /// available. - #[arg(long = "password", env = "SCHEMA_FORGE_BOOTSTRAP_ADMIN_PASSWORD")] + #[arg( + long = "password", + env = "SCHEMA_FORGE_BOOTSTRAP_ADMIN_PASSWORD", + hide_env_values = true + )] pub password: String, /// Display name written into the user record. Cosmetic only. @@ -698,7 +708,7 @@ pub struct MigrateArgs { pub struct ServeArgs { /// Host address to bind #[arg(short = 'H', long = "host", default_value = "127.0.0.1")] - pub host: String, + pub host: IpAddr, /// Port to listen on #[arg(short = 'p', long = "port", default_value = "3000")] @@ -721,7 +731,11 @@ pub struct ServeArgs { pub admin_user: String, /// Admin password used to bootstrap the initial user on first run. - #[arg(long = "admin-password", env = "FORGE_ADMIN_PASSWORD")] + #[arg( + long = "admin-password", + env = "FORGE_ADMIN_PASSWORD", + hide_env_values = true + )] pub admin_password: Option, /// Seed the bundled SchemaForge demo personas (alice/bob/charlie/dana/eve) @@ -896,7 +910,7 @@ pub struct CompletionsArgs { pub struct EntityConnectionArgs { /// Base URL of the running instance (e.g. https://forge.agency.gov). The /// versioned API path is appended automatically. [env: SCHEMAFORGE_SERVER] - #[arg(long, env = "SCHEMAFORGE_SERVER")] + #[arg(long, env = "SCHEMAFORGE_SERVER", hide_env_values = true)] pub server: Option, /// API version path segment. @@ -1496,7 +1510,7 @@ mod tests { ]) .unwrap(); if let Commands::Serve(args) = cli.command { - assert_eq!(args.host, "0.0.0.0"); + assert_eq!(args.host, std::net::Ipv4Addr::UNSPECIFIED); assert_eq!(args.port, 8080); assert!(args.watch); } else { diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index a84081ea..10a6fca8 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -297,6 +297,7 @@ pub async fn run( // fields here. Database/SurrealDB sections are not touched here — they // were resolved up-front by `load_svc_config` so acton-service's pool // and the schema-forge backend pool see the same URL by construction. + svc_config.service.bind = args.host; svc_config.service.port = args.port; svc_config.service.name = "schemaforge".to_string(); @@ -442,7 +443,7 @@ pub async fn run( }; let console_served = cfg!(feature = "embedded-console") && !args.no_console; - let bind_addr = format!("{}:{}", args.host, args.port); + let bind_addr = std::net::SocketAddr::new(svc_config.service.bind, svc_config.service.port); output.success(&format!( "SchemaForge server listening on http://{bind_addr}" )); diff --git a/crates/schema-forge-cli/tests/cli_integration.rs b/crates/schema-forge-cli/tests/cli_integration.rs index 95756b0f..acccecd9 100644 --- a/crates/schema-forge-cli/tests/cli_integration.rs +++ b/crates/schema-forge-cli/tests/cli_integration.rs @@ -666,3 +666,44 @@ fn entity_file_clear_requires_yes_in_scripts() { .failure() .stderr(predicate::str::contains("requires --yes")); } + +#[test] +fn help_does_not_disclose_secret_environment_values() { + for args in [ + vec!["--help"], + vec!["parse", "--help"], + vec!["serve", "--help"], + vec!["bootstrap-admin", "--help"], + vec!["entity", "list", "--help"], + ] { + schema_forge() + .env( + "SCHEMA_FORGE_DB_URL", + "postgres://operator:HELP_SECRET_DB@localhost/test", + ) + .env("FORGE_ADMIN_PASSWORD", "HELP_SECRET_SERVE") + .env( + "SCHEMA_FORGE_BOOTSTRAP_ADMIN_PASSWORD", + "HELP_SECRET_BOOTSTRAP", + ) + .env( + "SCHEMAFORGE_SERVER", + "https://operator:HELP_SECRET_SERVER@localhost", + ) + .args(args) + .assert() + .success() + .stdout(predicate::str::contains("SCHEMA_FORGE_DB_URL")) + .stdout(predicate::str::contains("HELP_SECRET").not()) + .stderr(predicate::str::contains("HELP_SECRET").not()); + } +} + +#[test] +fn serve_rejects_invalid_host_before_connecting() { + schema_forge() + .args(["serve", "--host", "not-an-ip-address"]) + .assert() + .failure() + .stderr(predicate::str::contains("invalid IP address syntax")); +} From f636f0e51e3668e9c03c2149bf5f71e039102647 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:39:07 -0600 Subject: [PATCH 02/77] fix: route GraphQL mutations through the canonical write pipeline --- crates/schema-forge-acton/src/extension.rs | 29 ++- .../schema-forge-acton/src/graphql/context.rs | 2 + .../src/graphql/input_types.rs | 17 +- crates/schema-forge-acton/src/graphql/mod.rs | 5 +- .../src/graphql/resolvers.rs | 222 ++++++++---------- .../tests/graphql_writes.rs | 138 +++++++++++ 6 files changed, 275 insertions(+), 138 deletions(-) create mode 100644 crates/schema-forge-acton/tests/graphql_writes.rs diff --git a/crates/schema-forge-acton/src/extension.rs b/crates/schema-forge-acton/src/extension.rs index 8eaf786f..9a8b5c16 100644 --- a/crates/schema-forge-acton/src/extension.rs +++ b/crates/schema-forge-acton/src/extension.rs @@ -426,11 +426,11 @@ impl SchemaForgeExtension { // won't have `derived_from` set, so this pass recomputes it on // every daemon start — cheap and idempotent. let mut paired: Vec = registry.values().cloned().collect(); - schema_forge_core::inverse_relations::pair_inverse_relations(&mut paired).map_err( - |e| ForgeError::Internal { + schema_forge_core::inverse_relations::pair_inverse_relations(&mut paired).map_err(|e| { + ForgeError::Internal { message: format!("invalid inverse relation: {e}"), - }, - )?; + } + })?; for schema in paired { registry.insert(schema.name.as_str().to_string(), schema); } @@ -448,12 +448,11 @@ impl SchemaForgeExtension { }; // Initialize the S3 storage registry (empty if no backends configured). - let storage_registry = - StorageRegistry::from_config(storage_config) - .await - .map_err(|e| ForgeError::Internal { - message: format!("Failed to initialize storage registry: {e}"), - })?; + let storage_registry = StorageRegistry::from_config(storage_config) + .await + .map_err(|e| ForgeError::Internal { + message: format!("Failed to initialize storage registry: {e}"), + })?; validate_file_references(&all_schemas, &storage_registry)?; if storage_registry.is_enabled() { tracing::info!( @@ -544,17 +543,17 @@ impl SchemaForgeExtension { /// Adds `POST /forge/graphql` (handler) and `GET /forge/graphql` (GraphiQL playground). /// Claims are extracted from request extensions (injected by upstream token middleware). #[cfg(feature = "graphql")] - pub fn register_graphql_routes(&self, router: Router) -> Router - where - S: Clone + Send + Sync + 'static, - { + pub fn register_graphql_routes( + &self, + router: Router>, + ) -> Router> { let gql_router = Router::new() .route( "/graphql", axum::routing::get(crate::graphql::graphql_playground) .post(crate::graphql::graphql_handler), ) - .with_state(self.state.clone()); + .layer(axum::Extension(self.state.clone())); router.nest("/forge", gql_router) } diff --git a/crates/schema-forge-acton/src/graphql/context.rs b/crates/schema-forge-acton/src/graphql/context.rs index a19e9441..d5c657ed 100644 --- a/crates/schema-forge-acton/src/graphql/context.rs +++ b/crates/schema-forge-acton/src/graphql/context.rs @@ -7,5 +7,7 @@ use crate::state::ForgeState; /// Resolvers access it with `ctx.data::()`. pub struct ForgeGraphqlContext { pub state: ForgeState, + /// Shared actor-backed state used by the canonical entity write handlers. + pub app_state: acton_service::state::AppState, pub claims: Option, } diff --git a/crates/schema-forge-acton/src/graphql/input_types.rs b/crates/schema-forge-acton/src/graphql/input_types.rs index d27929ca..ad3df08f 100644 --- a/crates/schema-forge-acton/src/graphql/input_types.rs +++ b/crates/schema-forge-acton/src/graphql/input_types.rs @@ -24,7 +24,22 @@ pub fn build_create_input(schema: &SchemaDefinition) -> InputObject { for field in &schema.fields { let field_name = field.name.as_str(); - let required = field.is_required(); + // The write pipeline materializes defaults, computed values, and ownership. + // GraphQL must allow those fields to be omitted just as REST does. + let server_supplied = field.annotations.iter().any(|annotation| { + matches!( + annotation, + schema_forge_core::types::FieldAnnotation::Default { .. } + | schema_forge_core::types::FieldAnnotation::Compute { .. } + | schema_forge_core::types::FieldAnnotation::Owner + ) + }) || field.modifiers.iter().any(|modifier| { + matches!( + modifier, + schema_forge_core::types::FieldModifier::Default { .. } + ) + }); + let required = field.is_required() && !server_supplied; let type_ref = input_field_type_ref( schema.name.as_str(), field_name, diff --git a/crates/schema-forge-acton/src/graphql/mod.rs b/crates/schema-forge-acton/src/graphql/mod.rs index b8efa9e5..0e432738 100644 --- a/crates/schema-forge-acton/src/graphql/mod.rs +++ b/crates/schema-forge-acton/src/graphql/mod.rs @@ -10,6 +10,7 @@ use async_graphql::http::GraphiQLSource; use async_graphql_axum::{GraphQLRequest, GraphQLResponse}; use axum::extract::State; use axum::response::{Html, IntoResponse}; +use axum::Extension; use self::context::ForgeGraphqlContext; use self::schema_builder::build_graphql_schema; @@ -18,13 +19,15 @@ use crate::state::ForgeState; /// GraphQL POST handler. pub async fn graphql_handler( - State(state): State, + State(app_state): State>, + Extension(state): Extension, OptionalClaims(claims): OptionalClaims, req: GraphQLRequest, ) -> GraphQLResponse { let schema = state.graphql_schema.load(); let request = req.into_inner().data(ForgeGraphqlContext { state: state.clone(), + app_state, claims, }); schema.execute(request).await.into() diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index a88b7360..1cbdccc4 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -7,12 +7,10 @@ use schema_forge_core::query::{validate_filter, FieldPath, SortOrder}; use schema_forge_core::types::{DynamicValue, EntityId, SchemaDefinition, SchemaName}; use super::context::ForgeGraphqlContext; -use super::input_types::{ - filter_input_to_filter, gql_input_to_entity_fields, gql_input_to_partial_fields, -}; +use super::input_types::filter_input_to_filter; use crate::access::{ - check_schema_access, filter_entity_fields, inject_tenant_on_create, inject_tenant_scope, - AccessAction, FieldFilterDirection, + check_schema_access, filter_entity_fields, inject_tenant_scope, AccessAction, + FieldFilterDirection, }; use crate::error::ForgeError; @@ -28,6 +26,7 @@ pub struct EntityFields { /// Mirrors the REST-side `reject_hidden_fields_in_body` guard so a /// password_hash (or any other operator-marked secret) can't be supplied /// through the GraphQL mutation surface either. +#[cfg(test)] fn reject_hidden_input( schema_def: &SchemaDefinition, input: &async_graphql::indexmap::IndexMap, @@ -79,8 +78,13 @@ pub async fn resolve_get_entity<'a>( let gql_ctx = ctx.data::()?; let claims = gql_ctx.claims.as_ref(); - check_schema_access(&gql_ctx.state.policy_store, schema_def, claims, AccessAction::Read) - .map_err(forge_error_to_gql)?; + check_schema_access( + &gql_ctx.state.policy_store, + schema_def, + claims, + AccessAction::Read, + ) + .map_err(forge_error_to_gql)?; let id_arg = ctx.args.try_get("id")?.string()?.to_string(); @@ -132,8 +136,13 @@ pub async fn resolve_list_entities<'a>( let gql_ctx = ctx.data::()?; let claims = gql_ctx.claims.as_ref(); - check_schema_access(&gql_ctx.state.policy_store, schema_def, claims, AccessAction::Read) - .map_err(forge_error_to_gql)?; + check_schema_access( + &gql_ctx.state.policy_store, + schema_def, + claims, + AccessAction::Read, + ) + .map_err(forge_error_to_gql)?; let mut query = schema_forge_core::query::Query::new(schema_def.id.clone()); @@ -250,7 +259,8 @@ pub struct ConnectionData { pub total_count: Option, } -/// Resolve create entity mutation. +/// Resolve create through the canonical write pipeline, including authorization, +/// defaults, rules, hooks, tenant/owner injection, and audit events. pub async fn resolve_create_entity<'a>( ctx: &ResolverContext<'a>, schema_name: &str, @@ -258,131 +268,101 @@ pub async fn resolve_create_entity<'a>( type_name: &str, ) -> async_graphql::Result>> { let gql_ctx = ctx.data::()?; - let claims = gql_ctx.claims.as_ref(); - - check_schema_access(&gql_ctx.state.policy_store, schema_def, claims, AccessAction::Write) - .map_err(forge_error_to_gql)?; - - let input_accessor = ctx.args.try_get("input")?; - let input_obj = input_accessor.object()?; - - let input_map = input_obj.as_index_map(); - reject_hidden_input(schema_def, input_map).map_err(forge_error_to_gql)?; - - let mut fields = gql_input_to_entity_fields(input_map, schema_def) - .map_err(|errors| forge_error_to_gql(ForgeError::ValidationFailed { details: errors }))?; - - // Inject tenant - inject_tenant_on_create(&mut fields, claims, &gql_ctx.state.tenant_config); - - let schema = SchemaName::new(schema_name).map_err(|_| { - forge_error_to_gql(ForgeError::InvalidSchemaName { - name: schema_name.to_string(), - }) - })?; - - let mut entity = Entity::new(schema, fields); - filter_entity_fields( - &gql_ctx.state.policy_store, - &mut entity, - schema_def, - claims, - FieldFilterDirection::Write, - ); - - let mut created = gql_ctx - .state - .backend - .create(&entity) - .await - .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; - - created.strip_hidden(schema_def); - filter_entity_fields( - &gql_ctx.state.policy_store, - &mut created, - schema_def, - claims, - FieldFilterDirection::Read, - ); - - Ok(Some(entity_to_field_value(created, type_name))) + let response = crate::routes::entities::create_entity( + axum::extract::State(gql_ctx.app_state.clone()), + axum::extract::Path(schema_name.to_owned()), + crate::access::OptionalClaims(gql_ctx.claims.clone()), + axum::http::HeaderMap::new(), + axum::Json(mutation_request(ctx)?), + ) + .await + .map_err(forge_error_to_gql)?; + mutation_response(response, schema_def, type_name).await } -/// Resolve update entity mutation. +/// GraphQL updates have patch semantics and share the REST PATCH pipeline. pub async fn resolve_update_entity<'a>( ctx: &ResolverContext<'a>, schema_name: &str, schema_def: &SchemaDefinition, type_name: &str, ) -> async_graphql::Result>> { + use axum::response::IntoResponse; let gql_ctx = ctx.data::()?; - let claims = gql_ctx.claims.as_ref(); - - check_schema_access(&gql_ctx.state.policy_store, schema_def, claims, AccessAction::Write) - .map_err(forge_error_to_gql)?; + let id = ctx.args.try_get("id")?.string()?.to_owned(); + let response = crate::routes::entities::patch_entity( + axum::extract::State(gql_ctx.app_state.clone()), + axum::extract::Path((schema_name.to_owned(), id)), + crate::access::OptionalClaims(gql_ctx.claims.clone()), + axum::http::HeaderMap::new(), + axum::Json(mutation_request(ctx)?), + ) + .await + .map_err(forge_error_to_gql)? + .into_response(); + mutation_response(response, schema_def, type_name).await +} - let id_arg = ctx.args.try_get("id")?.string()?.to_string(); +fn mutation_request( + ctx: &ResolverContext<'_>, +) -> async_graphql::Result { + let input = ctx.args.try_get("input")?.object()?; + Ok(crate::routes::entities::EntityRequest { + fields: input + .as_index_map() + .iter() + .map(|(key, value)| { + ( + key.to_string(), + super::type_mapping::gql_value_to_json(value), + ) + }) + .collect(), + }) +} - let schema = SchemaName::new(schema_name).map_err(|_| { - forge_error_to_gql(ForgeError::InvalidSchemaName { - name: schema_name.to_string(), +/// Adapt the authorized REST projection, never reload the unfiltered stored row. +async fn mutation_response( + response: axum::response::Response, + schema_def: &SchemaDefinition, + type_name: &str, +) -> async_graphql::Result>> { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .map_err(|_| { + forge_error_to_gql(ForgeError::Internal { + message: "failed to read entity mutation response".into(), + }) + })?; + #[derive(serde::Deserialize)] + struct MutationProjection { + id: String, + fields: serde_json::Map, + } + let response: MutationProjection = serde_json::from_slice(&bytes).map_err(|_| { + forge_error_to_gql(ForgeError::Internal { + message: "invalid entity mutation response".into(), }) })?; - - let entity_id = EntityId::parse(&id_arg) - .map_err(|_| forge_error_to_gql(ForgeError::InvalidEntityId { id: id_arg.clone() }))?; - - // Record-level ownership check - if let (Some(ref policy), Some(c)) = (&gql_ctx.state.record_access_policy, claims) { - let existing = gql_ctx - .state - .backend - .get(&schema, &entity_id) - .await - .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; - if !policy.can_modify(schema_def, c, &existing).await { - return Err(forge_error_to_gql(ForgeError::Forbidden { - message: format!("not authorized to modify entity '{id_arg}'"), - })); - } - } - - let input_accessor = ctx.args.try_get("input")?; - let input_obj = input_accessor.object()?; - - let input_map = input_obj.as_index_map(); - reject_hidden_input(schema_def, input_map).map_err(forge_error_to_gql)?; - - let fields = gql_input_to_partial_fields(input_map, schema_def) - .map_err(|errors| forge_error_to_gql(ForgeError::ValidationFailed { details: errors }))?; - - let mut entity = Entity::with_id(entity_id, schema, fields); - filter_entity_fields( - &gql_ctx.state.policy_store, - &mut entity, + let fields = crate::routes::entities::json_to_entity_fields_with_mode( schema_def, - claims, - FieldFilterDirection::Write, - ); - - let mut updated = gql_ctx - .state - .backend - .update(&entity) - .await - .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; - - updated.strip_hidden(schema_def); - filter_entity_fields( - &gql_ctx.state.policy_store, - &mut updated, - schema_def, - claims, - FieldFilterDirection::Read, - ); - - Ok(Some(entity_to_field_value(updated, type_name))) + &response.fields, + crate::routes::entities::ConversionMode::Merge, + ) + .map_err(|_| { + forge_error_to_gql(ForgeError::Internal { + message: "invalid entity mutation projection".into(), + }) + })?; + let id = EntityId::parse(&response.id).map_err(|_| { + forge_error_to_gql(ForgeError::Internal { + message: "invalid entity mutation identifier".into(), + }) + })?; + Ok(Some(entity_to_field_value( + Entity::with_id(id, schema_def.name.clone(), fields), + type_name, + ))) } /// Resolve delete entity mutation. diff --git a/crates/schema-forge-acton/tests/graphql_writes.rs b/crates/schema-forge-acton/tests/graphql_writes.rs new file mode 100644 index 00000000..8cb44120 --- /dev/null +++ b/crates/schema-forge-acton/tests/graphql_writes.rs @@ -0,0 +1,138 @@ +#![cfg(feature = "graphql")] + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use acton_service::{ + config::Config, middleware::Claims, prelude::ActorHandleInterface, + service_builder::ServiceBuilder, +}; +use axum::{body::Body, http::Request, Router}; +use http_body_util::BodyExt; +use schema_forge_acton::{ + config::SchemaForgeConfig, + messages::{InitForge, ReplyChannel}, + ForgeActor, HookDispatchActor, SchemaForgeExtension, +}; +use schema_forge_backend::SchemaBackend; +use schema_forge_core::migration::DiffEngine; +use schema_forge_surrealdb::SurrealBackend; +use serde_json::{json, Value}; +use tokio::sync::oneshot; +use tower::ServiceExt; + +async fn app() -> Router { + let backend = SurrealBackend::connect_with_auth("mem://", "graphql", "writes", None, None) + .await + .unwrap(); + let schemas = schema_forge_dsl::parse( + r#" + @access(read: ["staff", "manager"], write: ["staff", "manager"]) + schema Line { + label: text required + number: text @field_access(read: ["staff", "manager"], write: ["manager"]) + status: text required @default("'pending'") + @require("status != 'live' || number != null", "live needs number") + has_number: boolean required @compute("number != null") + enabled: boolean required default(true) + } + "#, + ) + .unwrap(); + for schema in &schemas { + backend + .apply_migration(&schema.name, &DiffEngine::create_new(schema)) + .await + .unwrap(); + backend.store_schema_metadata(schema).await.unwrap(); + } + let extension = SchemaForgeExtension::builder() + .with_backend(backend) + .build() + .await + .unwrap(); + let forge_state = extension.state(); + let service = ServiceBuilder::new() + .with_config(Config::::default()) + .with_actor::() + .with_actor::() + .build(); + let (tx, rx) = oneshot::channel(); + service + .state() + .actor::() + .unwrap() + .send(InitForge { + registry: forge_state + .registry + .list() + .await + .into_iter() + .map(|s| (s.name.as_str().to_owned(), s)) + .collect(), + backend: forge_state.backend.clone(), + tenant_config: forge_state.tenant_config.clone(), + record_access_policy: forge_state.record_access_policy.clone(), + hook_dispatcher: None, + storage_registry: forge_state.storage_registry.clone(), + policy_store: Some(Arc::clone(&forge_state.policy_store)), + custom_policies_dir: None, + reply: ReplyChannel::new(tx), + }) + .await; + tokio::time::timeout(Duration::from_secs(10), rx) + .await + .unwrap() + .unwrap(); + extension + .register_graphql_routes(Router::new()) + .with_state(service.state().clone()) +} + +async fn query(app: &Router, query: &str, role: &str) -> Value { + let claims = Claims { + sub: "user:graphql-test".into(), + roles: vec![role.into()], + perms: vec![], + exp: 9_999_999_999, + iat: None, + jti: None, + iss: None, + aud: None, + email: None, + username: None, + custom: HashMap::new(), + }; + let mut request = Request::post("/forge/graphql") + .header("content-type", "application/json") + .body(Body::from(json!({"query": query}).to_string())) + .unwrap(); + request.extensions_mut().insert(claims); + let response = app.clone().oneshot(request).await.unwrap(); + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap() +} + +#[tokio::test] +async fn graphql_writes_share_defaults_authorization_rules_and_noop_semantics() { + let app = app().await; + let created = query(&app, r#"mutation { createLine(input: {label: "test", number: "denied"}) { id status has_number enabled number } }"#, "staff").await; + assert!(created.get("errors").is_none(), "{created}"); + let row = &created["data"]["createLine"]; + assert_eq!(row["status"], "pending"); + assert_eq!(row["has_number"], false); + assert_eq!(row["enabled"], true); + assert!(row["number"].is_null()); + let id = row["id"].as_str().unwrap(); + + let rejected = query(&app, r#"mutation { createLine(input: {label: "invalid", status: "live", number: "denied"}) { id } }"#, "staff").await; + assert!(rejected.get("errors").is_some(), "{rejected}"); + let noop = query(&app, &format!(r#"mutation {{ updateLine(id: "{id}", input: {{number: "denied"}}) {{ id number has_number }} }}"#), "staff").await; + assert!(noop.get("errors").is_none(), "{noop}"); + assert_eq!(noop["data"]["updateLine"]["has_number"], false); + let rejected = query(&app, &format!(r#"mutation {{ updateLine(id: "{id}", input: {{status: "live", number: "denied"}}) {{ id }} }}"#), "staff").await; + assert!(rejected.get("errors").is_some(), "{rejected}"); + + let authorized = query(&app, &format!(r#"mutation {{ updateLine(id: "{id}", input: {{status: "live", number: "allowed"}}) {{ number has_number status }} }}"#), "manager").await; + assert!(authorized.get("errors").is_none(), "{authorized}"); + assert_eq!(authorized["data"]["updateLine"]["has_number"], true); + assert_eq!(authorized["data"]["updateLine"]["number"], "allowed"); +} From b4c819b068085223253ea20b7e7a1c3cd5c7e533 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:34:40 -0600 Subject: [PATCH 03/77] fix: enforce schema-aware tenant boundaries and invitation scope --- crates/schema-forge-acton/src/access.rs | 67 +++++++++++++++---- .../schema-forge-acton/src/authz/adapters.rs | 8 ++- .../src/cedar/policy_gen.rs | 8 ++- .../src/graphql/resolvers.rs | 2 +- .../schema-forge-acton/src/routes/export.rs | 4 +- .../schema-forge-acton/src/routes/invites.rs | 41 ++++++++++++ .../schema-forge-postgres/src/cedar_read.rs | 19 +++--- 7 files changed, 119 insertions(+), 30 deletions(-) diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index 92c3f6f4..b1f4ade1 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -349,7 +349,11 @@ pub fn inject_tenant_scope( query: &mut Query, claims: Option<&Claims>, tenant_config: &Option, + schema: &SchemaDefinition, ) { + if !schema.is_tenanted() { + return; + } let _config = match tenant_config { Some(c) if c.is_enabled() => c, _ => return, @@ -377,13 +381,14 @@ pub fn inject_tenant_scope( .iter() .map(|t| DynamicValue::Text(t.entity_id.clone())) .collect(); + let tenant_field = if is_tenant_root(schema) { "id" } else { "_tenant" }; let tenant_filter = if tenant_values.len() == 1 { Filter::eq( - FieldPath::single("_tenant"), + FieldPath::single(tenant_field), tenant_values.into_iter().next().unwrap(), ) } else { - Filter::in_set(FieldPath::single("_tenant"), tenant_values) + Filter::in_set(FieldPath::single(tenant_field), tenant_values) }; query.filter = Some(match query.filter.take() { Some(existing) => Filter::and(vec![existing, tenant_filter]), @@ -400,7 +405,11 @@ pub fn inject_tenant_on_create( fields: &mut BTreeMap, claims: Option<&Claims>, tenant_config: &Option, + schema: &SchemaDefinition, ) { + if !schema.is_tenanted() { + return; + } let _config = match tenant_config { Some(c) if c.is_enabled() => c, _ => return, @@ -420,6 +429,32 @@ pub fn inject_tenant_on_create( } } +/// Whether a schema defines tenant identities rather than tenant-owned data. +pub fn is_tenant_root(schema: &SchemaDefinition) -> bool { + schema.annotations.iter().any(|annotation| matches!(annotation, + schema_forge_core::types::Annotation::Tenant(schema_forge_core::types::TenantKind::Root))) +} + +/// A tenant root always belongs to its own identity, including admin creates. +pub fn stamp_root_tenant(entity: &mut Entity, schema: &SchemaDefinition) { + if is_tenant_root(schema) { + entity.fields.insert("_tenant".into(), DynamicValue::Text(entity.id.to_string())); + } +} + +/// Keep tenant ownership server-controlled on updates. +/// Only platform administrators can reassign child records; roots are immutable. +pub fn strip_tenant_on_update( + fields: &mut BTreeMap, + schema: &SchemaDefinition, + claims: Option<&Claims>, +) { + if !schema.is_tenanted() || is_tenant_root(schema) + || !claims.is_some_and(|claims| claims.has_role(PLATFORM_ADMIN_ROLE)) { + fields.remove("_tenant"); + } +} + /// Force-set the schema's `@owner` field to the authenticated principal. /// /// The Cedar `owner_write` / `owner_restrict` policies decide per-record @@ -565,6 +600,12 @@ mod tests { use schema_forge_core::types::DynamicValue; + fn tenant_child_schema() -> SchemaDefinition { + let mut schema = SchemaDefinition::new(SchemaId::new(), SchemaName::new("Note").unwrap(), vec![FieldDefinition::new(FieldName::new("body").unwrap(), FieldType::Text(TextConstraints::default()))], vec![]).unwrap(); + schema.annotations.push(Annotation::Tenant(TenantKind::Child { parent: SchemaName::new("Organization").unwrap() })); + schema + } + fn make_claims(roles: &[&str]) -> Claims { Claims { sub: format!("user:{}", EntityId::new("user").as_str()), @@ -658,7 +699,7 @@ mod tests { let claims = make_claims_with_tenant(&["member"], tenant_id.as_str()); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + inject_tenant_scope(&mut query, Some(&claims), &tenant_config, &tenant_child_schema()); assert!(query.filter.is_some()); let filter = query.filter.unwrap(); @@ -681,7 +722,7 @@ mod tests { let claims = make_claims_with_tenant(&["member"], tenant_id.as_str()); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + inject_tenant_scope(&mut query, Some(&claims), &tenant_config, &tenant_child_schema()); assert!(query.filter.is_none()); } @@ -693,7 +734,7 @@ mod tests { let claims = make_claims_with_tenant(&["platform_admin"], tenant_id.as_str()); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + inject_tenant_scope(&mut query, Some(&claims), &tenant_config, &tenant_child_schema()); assert!(query.filter.is_none()); } @@ -703,7 +744,7 @@ mod tests { let tenant_config = make_enabled_tenant_config(); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, None, &tenant_config); + inject_tenant_scope(&mut query, None, &tenant_config, &tenant_child_schema()); assert!(query.filter.is_none()); } @@ -721,7 +762,7 @@ mod tests { ); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + inject_tenant_scope(&mut query, Some(&claims), &tenant_config, &tenant_child_schema()); let filter = query.filter.expect("filter set"); match filter { @@ -748,7 +789,7 @@ mod tests { let claims = make_claims(&["member"]); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + inject_tenant_scope(&mut query, Some(&claims), &tenant_config, &tenant_child_schema()); assert!(query.filter.is_none()); } @@ -765,7 +806,7 @@ mod tests { ); let mut query = Query::new(SchemaId::new()).with_filter(existing_filter); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config); + inject_tenant_scope(&mut query, Some(&claims), &tenant_config, &tenant_child_schema()); assert!(query.filter.is_some()); let filter = query.filter.unwrap(); @@ -802,7 +843,7 @@ mod tests { let mut fields = BTreeMap::new(); fields.insert("name".to_string(), DynamicValue::Text("Alice".to_string())); - inject_tenant_on_create(&mut fields, Some(&claims), &tenant_config); + inject_tenant_on_create(&mut fields, Some(&claims), &tenant_config, &tenant_child_schema()); assert!(fields.contains_key("_tenant")); assert_eq!( @@ -819,7 +860,7 @@ mod tests { let mut fields = BTreeMap::new(); fields.insert("name".to_string(), DynamicValue::Text("Alice".to_string())); - inject_tenant_on_create(&mut fields, Some(&claims), &tenant_config); + inject_tenant_on_create(&mut fields, Some(&claims), &tenant_config, &tenant_child_schema()); assert!(!fields.contains_key("_tenant")); } @@ -830,7 +871,7 @@ mod tests { let mut fields = BTreeMap::new(); fields.insert("name".to_string(), DynamicValue::Text("Alice".to_string())); - inject_tenant_on_create(&mut fields, None, &tenant_config); + inject_tenant_on_create(&mut fields, None, &tenant_config, &tenant_child_schema()); assert!(!fields.contains_key("_tenant")); } @@ -842,7 +883,7 @@ mod tests { let mut fields = BTreeMap::new(); fields.insert("name".to_string(), DynamicValue::Text("Alice".to_string())); - inject_tenant_on_create(&mut fields, Some(&claims), &tenant_config); + inject_tenant_on_create(&mut fields, Some(&claims), &tenant_config, &tenant_child_schema()); assert!(!fields.contains_key("_tenant")); } diff --git a/crates/schema-forge-acton/src/authz/adapters.rs b/crates/schema-forge-acton/src/authz/adapters.rs index 93b43fa4..dd58f293 100644 --- a/crates/schema-forge-acton/src/authz/adapters.rs +++ b/crates/schema-forge-acton/src/authz/adapters.rs @@ -260,7 +260,13 @@ pub fn build_resource_entity( // Resource carries _tenant as a Cedar entity reference when present so // tenant policies can do `resource._tenant in principal`. - if let Some(DynamicValue::Text(tenant_id)) = entity.fields.get("_tenant") { + let root_tenant = DynamicValue::Text(entity.id.to_string()); + let tenant = if crate::access::is_tenant_root(schema) { + Some(&root_tenant) + } else { + entity.fields.get("_tenant") + }; + if let Some(DynamicValue::Text(tenant_id)) = tenant { let raw_uid = format!("{TENANT_TYPE}::\"{tenant_id}\""); if let Ok(t_uid) = EntityUid::from_str(&raw_uid) { attrs.insert( diff --git a/crates/schema-forge-acton/src/cedar/policy_gen.rs b/crates/schema-forge-acton/src/cedar/policy_gen.rs index cbbf1c36..eff92450 100644 --- a/crates/schema-forge-acton/src/cedar/policy_gen.rs +++ b/crates/schema-forge-acton/src/cedar/policy_gen.rs @@ -228,6 +228,11 @@ forbid ( /// at the query layer). fn tenant_guard_forbid_policy(schema: &SchemaDefinition) -> CedarPolicy { let name = schema.name.as_str(); + let condition = if schema.is_tenanted() { + format!("resource != {name}::\"_any\" && (!(resource has \"_tenant\") || !(principal in resource[\"_tenant\"]))") + } else { + "resource has \"_tenant\" && !(principal in resource[\"_tenant\"])".to_string() + }; CedarPolicy { description: format!( "Forbid per-record actions on {name} when the principal is not a member of the resource's tenant" @@ -244,8 +249,7 @@ forbid ( ], resource is {name} ) when {{ - resource has "_tenant" - && !(principal in resource["_tenant"]) + {condition} && !(principal in Forge::Group::"platform_admin") }};"#, lname = name.to_ascii_lowercase() diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index 1cbdccc4..a403f882 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -202,7 +202,7 @@ pub async fn resolve_list_entities<'a>( } // Inject tenant scope - inject_tenant_scope(&mut query, claims, &gql_ctx.state.tenant_config); + inject_tenant_scope(&mut query, claims, &gql_ctx.state.tenant_config, schema_def); let result = gql_ctx .state diff --git a/crates/schema-forge-acton/src/routes/export.rs b/crates/schema-forge-acton/src/routes/export.rs index 7ea04643..578d9fb0 100644 --- a/crates/schema-forge-acton/src/routes/export.rs +++ b/crates/schema-forge-acton/src/routes/export.rs @@ -507,7 +507,7 @@ pub async fn prepare_export( query = query.with_limit(probe_limit); // Same tenant injection as the query path. - inject_tenant_scope(&mut query, claims, tenant_config); + inject_tenant_scope(&mut query, claims, tenant_config, schema_def); // Execute. let (tx, rx) = oneshot::channel(); @@ -1289,7 +1289,7 @@ async fn resolve_export_displays( }) .without_total_count(); display_query.projection = Some(vec!["id".to_string(), display_field.clone()]); - inject_tenant_scope(&mut display_query, claims, tenant_config); + inject_tenant_scope(&mut display_query, claims, tenant_config, &target_def); let (tx, rx) = oneshot::channel(); forge diff --git a/crates/schema-forge-acton/src/routes/invites.rs b/crates/schema-forge-acton/src/routes/invites.rs index 0df317d7..1806a4d9 100644 --- a/crates/schema-forge-acton/src/routes/invites.rs +++ b/crates/schema-forge-acton/src/routes/invites.rs @@ -36,6 +36,11 @@ use std::time::Duration; use acton_service::audit::AuditSeverity; use acton_service::auth::tokens::paseto_generator::PasetoGenerator; use acton_service::middleware::paseto::PasetoAuth; +use acton_service::middleware::Claims; +use schema_forge_backend::{tenant::TenantConfig, TenantRef}; +use tokio::sync::oneshot; +use crate::actor::ForgeActor; +use crate::messages::{GetTenantConfig, ReplyChannel}; use acton_service::state::AppState; use axum::extract::State; use axum::http::StatusCode; @@ -187,6 +192,33 @@ fn invite_email_subject(project_name: &str) -> String { format!("You've been invited to {project_name}") } +/// Validate the signed invitation's tenant target before minting or persistence. +fn validate_invite_tenant( + claims: &Claims, + config: Option<&TenantConfig>, + tenant_type: Option<&str>, + tenant_id: Option<&str>, +) -> Result<(), ForgeError> { + let (tenant_type, tenant_id) = match (tenant_type, tenant_id) { + (None, None) => return Ok(()), + (Some(kind), Some(id)) if !kind.is_empty() && !id.is_empty() => (kind, id), + _ => return Err(ForgeError::ValidationFailed { + details: vec!["tenant_type and tenant_id must be supplied together and must not be empty".into()], + }), + }; + if !config.is_some_and(|config| config.hierarchy.iter().any(|level| level.schema.as_str() == tenant_type)) { + return Err(ForgeError::ValidationFailed { + details: vec!["tenant_type must name a configured tenant schema".into()], + }); + } + let chain = claims.custom_claim_as::>("tenant_chain").unwrap_or_default(); + if claims.has_role("platform_admin") || chain.iter().any(|tenant| tenant.schema == tenant_type && tenant.entity_id == tenant_id) { + Ok(()) + } else { + Err(ForgeError::Forbidden { message: "invitation tenant is outside the caller's effective tenant scope".into() }) + } +} + // --------------------------------------------------------------------------- // Handlers // --------------------------------------------------------------------------- @@ -216,6 +248,15 @@ pub async fn create_invite( check_schema_access(&policy_store, &user_schema, Some(claims), AccessAction::Create)?; validate_email(&body.email)?; + let forge = state.actor::().ok_or_else(|| ForgeError::Internal { + message: "ForgeActor not registered".into(), + })?; + let (tx, rx) = oneshot::channel(); + forge.send(GetTenantConfig { reply: ReplyChannel::new(tx) }).await; + let tenant_config = rx.await.map_err(|_| ForgeError::Internal { + message: "ForgeActor reply channel dropped while fetching tenant configuration".into(), + })?; + validate_invite_tenant(claims, tenant_config.as_ref(), body.tenant_type.as_deref(), body.tenant_id.as_deref())?; // The invite grants a single role (or none). Apply the same role-grant // guards `create_user` applies to its `roles` list. diff --git a/crates/schema-forge-postgres/src/cedar_read.rs b/crates/schema-forge-postgres/src/cedar_read.rs index 579dfc0b..d3f2c59f 100644 --- a/crates/schema-forge-postgres/src/cedar_read.rs +++ b/crates/schema-forge-postgres/src/cedar_read.rs @@ -114,7 +114,7 @@ pub(crate) async fn query_compatible( } return Ok(None); } - let scoped = scoped_query(query, scope, proof.has_tenant); + let scoped = scoped_query(query, scope, proof.has_tenant, expected); let total = if scoped.include_total { let count = count_to_sql(&scoped, expected.name.as_str()); let total: i64 = sqlx::query_scalar_with(&count.sql, arguments(&count.params)?) @@ -290,19 +290,16 @@ fn supported_type(field: &FieldType) -> Option<&'static str> { } } -fn scoped_query(query: &Query, scope: &CedarReadScope, has_tenant: bool) -> Query { +fn scoped_query(query: &Query, scope: &CedarReadScope, has_tenant: bool, schema: &SchemaDefinition) -> Query { let mut query = query.clone(); if let CedarReadScope::TenantMembers(members) = scope { if has_tenant { - let tenant = Filter::Or { - filters: vec![ - Filter::eq(FieldPath::single("_tenant"), DynamicValue::Null), - Filter::in_set( - FieldPath::single("_tenant"), - members.iter().cloned().map(DynamicValue::Text).collect(), - ), - ], - }; + let root = schema.annotations.iter().any(|annotation| matches!(annotation, + schema_forge_core::types::Annotation::Tenant(schema_forge_core::types::TenantKind::Root))); + let tenant = Filter::in_set( + FieldPath::single(if root { "id" } else { "_tenant" }), + members.iter().cloned().map(DynamicValue::Text).collect(), + ); query.filter = Some(match query.filter.take() { Some(filter) => Filter::And { filters: vec![filter, tenant], From 6870c99c65e7047cfb2e35b04eb5648f91ea4541 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:37:52 -0600 Subject: [PATCH 04/77] fix: authorize write inputs before rules and validate final fields --- crates/schema-forge-acton/src/authz/engine.rs | 34 ++- .../schema-forge-acton/src/routes/entities.rs | 198 ++++++++++++++---- crates/schema-forge-acton/src/rules.rs | 157 +++++++------- .../tests/conditional_entities.rs | 146 ++++++++++++- 4 files changed, 405 insertions(+), 130 deletions(-) diff --git a/crates/schema-forge-acton/src/authz/engine.rs b/crates/schema-forge-acton/src/authz/engine.rs index 14771427..a101bb46 100644 --- a/crates/schema-forge-acton/src/authz/engine.rs +++ b/crates/schema-forge-acton/src/authz/engine.rs @@ -282,6 +282,38 @@ pub fn authorize_field( entity: &Entity, field_name: &str, direction: FieldDirection, +) -> Result { + authorize_field_resource(store, claims, schema, entity, field_name, direction, true) +} + +/// Authorize caller input before defaults can complete the new resource. +/// Policies reading unavailable attributes fail closed at the caller. +pub(crate) fn authorize_create_field( + store: &Arc, + claims: Option<&Claims>, + schema: &SchemaDefinition, + entity: &Entity, + field_name: &str, +) -> Result { + authorize_field_resource( + store, + claims, + schema, + entity, + field_name, + FieldDirection::Write, + false, + ) +} + +fn authorize_field_resource( + store: &Arc, + claims: Option<&Claims>, + schema: &SchemaDefinition, + entity: &Entity, + field_name: &str, + direction: FieldDirection, + complete: bool, ) -> Result { let snapshot = store.current(); @@ -314,7 +346,7 @@ pub fn authorize_field( let mut all_entities = principal_entities; all_entities.push(resource_entity); - let entities = Entities::from_entities(all_entities, Some(&snapshot.schema)) + let entities = Entities::from_entities(all_entities, complete.then_some(&snapshot.schema)) .map_err(|e| AuthzError::Request(render_error_chain(&e)))?; // `filter_entity_fields` only calls `authorize_field` for fields whose diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index f90b4843..c75306ab 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -23,8 +23,9 @@ use super::query_params::{parse_fields_param, parse_filter_params, parse_sort_pa use crate::access::{ check_schema_access, entity_permissions, filter_entity_fields, filter_patch_fields, inject_audit_columns_on_create, inject_audit_columns_on_update, inject_owner_on_create, - inject_tenant_on_create, inject_tenant_scope, schema_permissions, strip_owner_on_update, - AccessAction, EntityPermissions, FieldFilterDirection, OptionalClaims, SchemaPermissions, + inject_tenant_on_create, inject_tenant_scope, schema_permissions, stamp_root_tenant, + strip_owner_on_update, strip_tenant_on_update, AccessAction, EntityPermissions, + FieldFilterDirection, OptionalClaims, SchemaPermissions, }; use crate::actor::ForgeActor; use crate::authz::{authorize, namespace::ActionVerb}; @@ -51,13 +52,15 @@ use std::sync::Arc; /// Map a [`RuleError`] from CEL `@require` validation onto a [`ForgeError`]. /// /// A definite rejection becomes a 422 `ValidationFailed`; a predicate that -/// could not be evaluated (errored or non-bool) becomes a 500 `Internal`, +/// could not be evaluated (errored or non-bool) becomes a 422 `ValidationFailed`, /// preserving the fail-closed contract documented on [`crate::rules`]. fn rule_error_to_forge(err: RuleError) -> ForgeError { match err { RuleError::Rejected(details) => ForgeError::ValidationFailed { details }, - RuleError::Eval { field, detail } => ForgeError::Internal { - message: format!("@require on field '{field}' could not be evaluated: {detail}"), + RuleError::Eval { field, detail } => ForgeError::ValidationFailed { + details: vec![format!( + "rule on field '{field}' could not be evaluated: {detail}" + )], }, } } @@ -841,6 +844,34 @@ fn enforce_bytes_max_size(bytes: &[u8], max_size: Option) -> Result<(), S /// /// Values whose names are not in the schema (`_tenant` and friends) carry no /// declared constraints and are skipped. +pub(crate) fn validate_required_fields( + schema: &SchemaDefinition, + fields: &BTreeMap, +) -> Result<(), ForgeError> { + let details: Vec<_> = schema + .fields + .iter() + .filter(|field| !field.is_derived() && field.is_required()) + .filter(|field| { + matches!( + fields.get(field.name.as_str()), + None | Some(DynamicValue::Null) + ) + }) + .map(|field| { + format!( + "required field '{}' is missing or null", + field.name.as_str() + ) + }) + .collect(); + if details.is_empty() { + Ok(()) + } else { + Err(ForgeError::ValidationFailed { details }) + } +} + fn check_field_constraints( schema: &SchemaDefinition, fields: &BTreeMap, @@ -1467,7 +1498,7 @@ async fn execute_entity_query( }) .await; let tenant_config = ask_forge(rx).await?; - inject_tenant_scope(query, claims, &tenant_config); + inject_tenant_scope(query, claims, &tenant_config, schema_def); // NOTE: the client `fields` projection is deliberately NOT pushed into the // DB query as a column selection. Record-level authorization @@ -1707,7 +1738,7 @@ async fn resolve_relation_displays( } // Apply tenant scope so we never leak rows the caller couldn't // otherwise see through a direct list call. - inject_tenant_scope(&mut display_query, claims, tenant_config); + inject_tenant_scope(&mut display_query, claims, tenant_config, target_def); let source_fields: Vec = fields_pointing_at_target .iter() @@ -1860,7 +1891,7 @@ async fn check_requires_with_related( now: chrono::DateTime, tenant_config: &Option, ) -> Result<(), ForgeError> { - let mut bindings = build_bindings(fields, claims, now); + let mut bindings = build_bindings(schema, fields, claims, now); let related_map = resolve_related_bindings(forge, schema, fields, claims, tenant_config).await?; @@ -2055,7 +2086,7 @@ async fn load_related_row( values: vec![DynamicValue::Text(fk_id.to_string())], }) .without_total_count(); - inject_tenant_scope(&mut query, claims, tenant_config); + inject_tenant_scope(&mut query, claims, tenant_config, target_def); let (tx, rx) = oneshot::channel(); forge @@ -2189,7 +2220,7 @@ async fn populate_derived_collections( if claims.is_some() { child_query.projection = Some(vec!["id".to_string(), fk_field_name.clone()]); } - inject_tenant_scope(&mut child_query, claims, tenant_config); + inject_tenant_scope(&mut child_query, claims, tenant_config, target_def); jobs.push((target_def, parent_field_name, fk_field_name, child_query)); } @@ -2457,10 +2488,10 @@ pub async fn create_entity( reject_hidden_fields_in_body(&schema_def, &body.fields)?; // Convert JSON fields to DynamicValue fields - let mut fields = json_to_entity_fields(&schema_def, &body.fields) - .map_err(|errors| ForgeError::ValidationFailed { details: errors })?; + let mut fields = + json_to_entity_fields_with_mode(&schema_def, &body.fields, ConversionMode::Merge) + .map_err(|errors| ForgeError::ValidationFailed { details: errors })?; - // Get tenant config via actor let (tx, rx) = oneshot::channel(); forge .send(GetTenantConfig { @@ -2468,7 +2499,59 @@ pub async fn create_entity( }) .await; let tenant_config = ask_forge(rx).await?; - inject_tenant_on_create(&mut fields, claims.as_ref(), &tenant_config); + let mut supplied = Entity::new(schema_name.clone(), fields); + stamp_root_tenant(&mut supplied, &schema_def); + inject_tenant_on_create( + &mut supplied.fields, + claims.as_ref(), + &tenant_config, + &schema_def, + ); + inject_owner_on_create(&mut supplied.fields, &schema_def, claims.as_ref()); + let mut denied = Vec::new(); + for name in body.fields.keys() { + if schema_def + .field(name) + .is_none_or(|field| field.field_access().is_none()) + { + continue; + } + let decision = crate::authz::engine::authorize_create_field( + &policy_store, + claims.as_ref(), + &schema_def, + &supplied, + name, + ) + .map_err(|_| ForgeError::Forbidden { + message: "Could not authorize a supplied field.".into(), + })?; + if !decision.errors.is_empty() { + return Err(ForgeError::Forbidden { + message: "Could not authorize a supplied field.".into(), + }); + } + if !decision.is_allow() { + denied.push(name.clone()); + } + } + for name in denied { + supplied.fields.remove(&name); + } + // Server-owned values are injected again after caller input filtering. + stamp_root_tenant(&mut supplied, &schema_def); + fields = supplied.fields; + inject_tenant_on_create(&mut fields, claims.as_ref(), &tenant_config, &schema_def); + if schema_def.unique_scoped_by_tenant() + && !matches!(fields.get("_tenant"), Some(DynamicValue::Text(tenant)) if !tenant.is_empty()) + { + return Err(ForgeError::ValidationFailed { + details: vec![format!( + "tenanted schema '{}' requires _tenant; platform_admin must supply a tenant", + schema_def.name + )], + }); + } inject_owner_on_create(&mut fields, &schema_def, claims.as_ref()); // Single request-time instant: reused for audit columns and as the `now` // CEL binding so all rules in this write observe the same clock. @@ -2488,6 +2571,7 @@ pub async fn create_entity( // CEL @compute derived fields (#93) — evaluated before @require, stored. apply_computed(&schema_def, &mut fields, claims.as_ref(), rules_now) .map_err(rule_error_to_forge)?; + validate_required_fields(&schema_def, &fields)?; // CEL @require validation rules (#92) — fail-closed, in-transaction, // pre-persistence. Cross-entity reads (#95) are resolved here: any @@ -2544,14 +2628,8 @@ pub async fn create_entity( } // Create the entity, filtering write-restricted fields - let mut entity = Entity::new(schema_name, fields); - filter_entity_fields( - &policy_store, - &mut entity, - &schema_def, - claims.as_ref(), - FieldFilterDirection::Write, - ); + let entity = Entity::with_id(supplied.id, schema_name, fields); + validate_required_fields(&schema_def, &entity.fields)?; check_field_constraints(&schema_def, &entity.fields)?; if let Some(intent) = intent { @@ -3269,10 +3347,36 @@ pub async fn update_entity( reject_hidden_fields_in_body(&schema_def, &body.fields)?; // Convert JSON fields - let mut fields = json_to_entity_fields(&schema_def, &body.fields) - .map_err(|errors| ForgeError::ValidationFailed { details: errors })?; + let mut fields = + json_to_entity_fields_with_mode(&schema_def, &body.fields, ConversionMode::Merge) + .map_err(|errors| ForgeError::ValidationFailed { details: errors })?; + + let mut candidate = existing.clone(); + candidate.fields.extend(fields.clone()); + let mut supplied = Entity::with_id(entity_id.clone(), schema_name.clone(), fields); + filter_patch_fields( + &policy_store, + &mut supplied, + &candidate, + &schema_def, + claims.as_ref(), + )?; + fields = supplied.fields; + for name in body.fields.keys() { + if !fields.contains_key(name) { + if let Some(value) = existing.fields.get(name) { + fields.insert(name.clone(), value.clone()); + } + } + } strip_owner_on_update(&mut fields, &schema_def); + strip_tenant_on_update(&mut fields, &schema_def, claims.as_ref()); + if let Some(tenant) = existing.fields.get("_tenant") { + fields + .entry("_tenant".into()) + .or_insert_with(|| tenant.clone()); + } // PUT replaces supplied fields, but immutable ownership remains part of // the post-update rule context, even when an administrator is the caller. if let Some(owner_field) = schema_def.fields.iter().find(|field| field.has_owner()) { @@ -3294,6 +3398,14 @@ pub async fn update_entity( apply_computed(&schema_def, &mut fields, claims.as_ref(), rules_now) .map_err(rule_error_to_forge)?; + validate_required_fields(&schema_def, &fields).map_err(|error| match error { + ForgeError::ValidationFailed { mut details } => { + details.push("PUT requires a complete entity; use PATCH for partial updates".into()); + ForgeError::ValidationFailed { details } + } + error => error, + })?; + // Tenant config for cross-entity-read tenant scoping (#95). Fetched here so // a `related.` prefetch honors the caller's tenant boundary. let (tx, rx) = oneshot::channel(); @@ -3356,14 +3468,8 @@ pub async fn update_entity( } // Build entity with specific ID, filtering write-restricted fields - let mut entity = Entity::with_id(entity_id, schema_name, fields); - filter_entity_fields( - &policy_store, - &mut entity, - &schema_def, - claims.as_ref(), - FieldFilterDirection::Write, - ); + let entity = Entity::with_id(entity_id, schema_name, fields); + validate_required_fields(&schema_def, &entity.fields)?; check_field_constraints(&schema_def, &entity.fields)?; let (mut updated, revision) = persist_entity_update(&forge, entity, expected).await?; @@ -3554,9 +3660,22 @@ pub async fn patch_entity( json_to_entity_fields_with_mode(&schema_def, &body.fields, ConversionMode::Merge) .map_err(|errors| ForgeError::ValidationFailed { details: errors })?; + let mut candidate = existing.clone(); + candidate.fields.extend(patch_fields.clone()); + let mut supplied = Entity::with_id(entity_id.clone(), schema_name.clone(), patch_fields); + filter_patch_fields( + &policy_store, + &mut supplied, + &candidate, + &schema_def, + claims.as_ref(), + )?; + patch_fields = supplied.fields; + // Owner field is immutable post-create; refuse to transfer ownership // via PATCH the same way we refuse via PUT. strip_owner_on_update(&mut patch_fields, &schema_def); + strip_tenant_on_update(&mut patch_fields, &schema_def, claims.as_ref()); // Single request-time instant reused for audit columns and the `now` CEL binding. let rules_now = chrono::Utc::now(); inject_audit_columns_on_update(&mut patch_fields, &schema_def, claims.as_ref(), rules_now); @@ -3584,6 +3703,8 @@ pub async fn patch_entity( apply_computed(&schema_def, &mut merged, claims.as_ref(), rules_now) .map_err(rule_error_to_forge)?; + validate_required_fields(&schema_def, &merged)?; + // Tenant config for cross-entity-read tenant scoping (#95). let (tx, rx) = oneshot::channel(); forge @@ -3645,6 +3766,9 @@ pub async fn patch_entity( .await?; } + validate_required_fields(&schema_def, &merged)?; + check_field_constraints(&schema_def, &merged)?; + // Compute the delta: only keys whose final value differs from the // loaded baseline go to the backend. This keeps PATCH's SQL UPDATE // actually partial, which makes the whole class of "null column @@ -3667,15 +3791,7 @@ pub async fn patch_entity( let (mut updated, revision) = if delta.is_empty() && expected.is_none() { (existing, None) } else { - let resource = Entity::with_id(entity_id.clone(), schema_name.clone(), merged); - let mut entity = Entity::with_id(entity_id, schema_name, delta); - filter_patch_fields( - &policy_store, - &mut entity, - &resource, - &schema_def, - claims.as_ref(), - )?; + let entity = Entity::with_id(entity_id, schema_name, delta); check_field_constraints(&schema_def, &entity.fields)?; persist_entity_update(&forge, entity, expected).await? }; diff --git a/crates/schema-forge-acton/src/rules.rs b/crates/schema-forge-acton/src/rules.rs index b45057d1..af1f0e42 100644 --- a/crates/schema-forge-acton/src/rules.rs +++ b/crates/schema-forge-acton/src/rules.rs @@ -53,7 +53,7 @@ //! pass when it evaluates to exactly `Ok(CelValue::Bool(true))`. Any other //! outcome surfaces as either a rejection (the predicate definitively returned //! `false`) or a [`RuleError::Eval`] (the predicate could not yield a definite -//! boolean — treated as a schema-authoring/server fault, mapped to 500). +//! boolean — treated as a schema-authoring/server fault, mapped to 422). //! //! ## The `now` binding (request-time clock) //! @@ -74,7 +74,9 @@ use chrono::{DateTime, Utc}; use acton_service::middleware::Claims; use schema_forge_cel::{cel_to_dynamic, dynamic_to_cel, CelKey, CelValue}; -use schema_forge_core::types::{DynamicValue, FieldAnnotation, FieldType, SchemaDefinition}; +use schema_forge_core::types::{ + DefaultValue, DynamicValue, FieldAnnotation, FieldModifier, FieldType, SchemaDefinition, +}; /// The outcome of a failed rule evaluation. #[derive(Debug, Clone, PartialEq)] @@ -85,7 +87,7 @@ pub enum RuleError { Rejected(Vec), /// A `@require` predicate could not be evaluated to a definite boolean — /// it errored or returned a non-boolean. This is a schema-authoring or - /// server fault, so it fails closed and maps to HTTP 500. + /// server fault, so it fails closed and maps to HTTP 422. Eval { /// The field whose `@require` annotation could not be evaluated. field: String, @@ -128,11 +130,20 @@ impl std::error::Error for RuleError {} /// `now` (see the module docs); the caller passes a single instant so all rules /// in one write see the same clock. pub fn build_bindings( + schema: &SchemaDefinition, fields: &BTreeMap, claims: Option<&Claims>, now: DateTime, ) -> schema_forge_cel::Bindings { let mut bindings = schema_forge_cel::Bindings::new(); + for field in &schema.fields { + if !field.is_derived() { + bindings.insert(field.name.as_str().to_string(), CelValue::Null); + } + } + if schema.is_tenanted() { + bindings.insert("_tenant".into(), CelValue::Null); + } for (name, value) in fields { if let Ok(cel) = dynamic_to_cel(value) { @@ -192,7 +203,7 @@ fn principal_map(claims: Option<&Claims>) -> CelValue { /// Fail-closed (see the module docs): a predicate passes only on /// `Ok(CelValue::Bool(true))`. A definite `false` is collected as a rejection /// (→ [`RuleError::Rejected`], 422). A non-boolean result or an evaluation -/// error short-circuits immediately to [`RuleError::Eval`] (500) so a broken +/// error short-circuits immediately to [`RuleError::Eval`] (422) so a broken /// predicate can never let a write through. pub fn check_requires( schema: &SchemaDefinition, @@ -200,7 +211,7 @@ pub fn check_requires( claims: Option<&Claims>, now: DateTime, ) -> Result<(), RuleError> { - let bindings = build_bindings(fields, claims, now); + let bindings = build_bindings(schema, fields, claims, now); check_requires_with_bindings(schema, &bindings) } @@ -274,7 +285,7 @@ pub fn check_requires_with_bindings( /// chainable). /// /// Fail-closed: an evaluation error or a value that cannot be converted / -/// coerced to the field's declared type returns [`RuleError::Eval`] (500) and +/// coerced to the field's declared type returns [`RuleError::Eval`] (422) and /// stores nothing for that field — a half-evaluated value is never persisted. /// This runs *before* [`check_requires`] so `@require` predicates validate the /// computed values. @@ -292,15 +303,14 @@ pub fn apply_computed( // Rebuild bindings from the current fields so this compute sees the // results of any earlier computed fields (chaining). - let bindings = build_bindings(fields, claims, now); + let bindings = build_bindings(schema, fields, claims, now); let field_name = field.name.as_str(); - let cel_value = schema_forge_cel::evaluate(expr, &bindings).map_err(|e| { - RuleError::Eval { + let cel_value = + schema_forge_cel::evaluate(expr, &bindings).map_err(|e| RuleError::Eval { field: field_name.to_string(), detail: e.to_string(), - } - })?; + })?; let natural = cel_to_dynamic(&cel_value).map_err(|e| RuleError::Eval { field: field_name.to_string(), @@ -363,7 +373,7 @@ pub fn apply_computed( /// example is spelled `@default("now")`. /// /// Fail-closed: an evaluation error or a value that cannot be converted / -/// coerced to the field's declared type returns [`RuleError::Eval`] (500) and +/// coerced to the field's declared type returns [`RuleError::Eval`] (422) and /// stores nothing for that field. pub fn apply_defaults( schema: &SchemaDefinition, @@ -372,6 +382,26 @@ pub fn apply_defaults( now: DateTime, ) -> Result<(), RuleError> { for field in &schema.fields { + if !fields.contains_key(field.name.as_str()) { + for modifier in &field.modifiers { + if let FieldModifier::Default { value } = modifier { + let natural = match value { + DefaultValue::String(value) => DynamicValue::Text(value.clone()), + DefaultValue::Integer(value) => DynamicValue::Integer(*value), + DefaultValue::Boolean(value) => DynamicValue::Boolean(*value), + DefaultValue::Float(value) => { + DynamicValue::Float(value.parse().map_err(|_| RuleError::Eval { + field: field.name.as_str().into(), + detail: "invalid numeric default".into(), + })?) + } + }; + let value = + coerce_to_field_type(natural, &field.field_type, field.name.as_str())?; + fields.insert(field.name.as_str().into(), value); + } + } + } for annotation in &field.annotations { let FieldAnnotation::Default { expr } = annotation else { continue; @@ -392,14 +422,13 @@ pub fn apply_defaults( // Rebuild bindings from the current fields so this default can read // other fields, including an earlier-defaulted one (chaining). - let bindings = build_bindings(fields, claims, now); + let bindings = build_bindings(schema, fields, claims, now); - let cel_value = schema_forge_cel::evaluate(expr, &bindings).map_err(|e| { - RuleError::Eval { + let cel_value = + schema_forge_cel::evaluate(expr, &bindings).map_err(|e| RuleError::Eval { field: field_name.to_string(), detail: e.to_string(), - } - })?; + })?; let natural = cel_to_dynamic(&cel_value).map_err(|e| RuleError::Eval { field: field_name.to_string(), @@ -447,14 +476,12 @@ fn coerce_to_field_type( }) } } - (FieldType::DateTime, DynamicValue::Text(s)) => { - chrono::DateTime::parse_from_rfc3339(&s) - .map(|dt| DynamicValue::DateTime(dt.with_timezone(&chrono::Utc))) - .map_err(|e| RuleError::Eval { - field: field.to_string(), - detail: format!("computed value '{s}' is not a valid RFC 3339 datetime: {e}"), - }) - } + (FieldType::DateTime, DynamicValue::Text(s)) => chrono::DateTime::parse_from_rfc3339(&s) + .map(|dt| DynamicValue::DateTime(dt.with_timezone(&chrono::Utc))) + .map_err(|e| RuleError::Eval { + field: field.to_string(), + detail: format!("computed value '{s}' is not a valid RFC 3339 datetime: {e}"), + }), // No coercion applies; store the natural value as-is. (_, value) => Ok(value), } @@ -478,8 +505,13 @@ mod tests { } fn schema_with(fields: Vec) -> SchemaDefinition { - SchemaDefinition::new(SchemaId::new(), SchemaName::new("Thing").unwrap(), fields, vec![]) - .unwrap() + SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("Thing").unwrap(), + fields, + vec![], + ) + .unwrap() } fn require(expr: &str, message: &str) -> FieldAnnotation { @@ -545,10 +577,7 @@ mod tests { fn multiple_failing_requires_collected_in_order() { let schema = schema_with(vec![ text_field("age", vec![require("age >= 18", "too young")]), - text_field( - "name", - vec![require("size(name) > 0", "name required")], - ), + text_field("name", vec![require("size(name) > 0", "name required")]), ]); let f = fields(&[ ("age", DynamicValue::Integer(10)), @@ -598,7 +627,10 @@ mod tests { ("status", DynamicValue::Text("closed".to_string())), ("close_reason", DynamicValue::Text("done".to_string())), ]); - assert_eq!(check_requires(&schema, &closed_with_reason, None, fixed_now()), Ok(())); + assert_eq!( + check_requires(&schema, &closed_with_reason, None, fixed_now()), + Ok(()) + ); } #[test] @@ -686,10 +718,7 @@ mod tests { fn skipped_binding_fails_closed() { // A field that converts fine but an annotation references a field that // was never supplied → undeclared reference → Eval (fail-closed). - let schema = schema_with(vec![text_field( - "a", - vec![require("b == 1", "needs b")], - )]); + let schema = schema_with(vec![text_field("a", vec![require("b == 1", "needs b")])]); let f = fields(&[("a", DynamicValue::Integer(1))]); assert!(matches!( check_requires(&schema, &f, None, fixed_now()), @@ -748,16 +777,12 @@ mod tests { let schema = schema_with(vec![ typed_field( "quantity", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![], ), typed_field( "unit_price", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![], ), typed_field( @@ -818,23 +843,17 @@ mod tests { let schema = schema_with(vec![ typed_field( "base", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![], ), typed_field( "a", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![compute("base + 1")], ), typed_field( "b", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![compute("a * 10")], ), ]); @@ -864,10 +883,7 @@ mod tests { #[test] fn eval_error_compute_is_eval() { - let schema = schema_with(vec![text_field( - "x", - vec![compute("missing_field + 1")], - )]); + let schema = schema_with(vec![text_field("x", vec![compute("missing_field + 1")])]); let mut f = fields(&[]); match apply_computed(&schema, &mut f, None, fixed_now()) { Err(RuleError::Eval { field, detail }) => { @@ -882,15 +898,10 @@ mod tests { #[test] fn enum_coercion_success() { - let variants = - EnumVariants::new(vec!["low".to_string(), "high".to_string()]).unwrap(); + let variants = EnumVariants::new(vec!["low".to_string(), "high".to_string()]).unwrap(); let schema = schema_with(vec![ text_field("level", vec![]), - typed_field( - "tier", - FieldType::Enum(variants), - vec![compute("level")], - ), + typed_field("tier", FieldType::Enum(variants), vec![compute("level")]), ]); let mut f = fields(&[("level", DynamicValue::Text("high".to_string()))]); assert_eq!(apply_computed(&schema, &mut f, None, fixed_now()), Ok(())); @@ -899,15 +910,10 @@ mod tests { #[test] fn enum_coercion_invalid_variant_is_eval() { - let variants = - EnumVariants::new(vec!["low".to_string(), "high".to_string()]).unwrap(); + let variants = EnumVariants::new(vec!["low".to_string(), "high".to_string()]).unwrap(); let schema = schema_with(vec![ text_field("level", vec![]), - typed_field( - "tier", - FieldType::Enum(variants), - vec![compute("level")], - ), + typed_field("tier", FieldType::Enum(variants), vec![compute("level")]), ]); let mut f = fields(&[("level", DynamicValue::Text("medium".to_string()))]); match apply_computed(&schema, &mut f, None, fixed_now()) { @@ -984,10 +990,7 @@ mod tests { "created_by", vec![default_expr("principal.sub")], )]); - let mut f = fields(&[( - "created_by", - DynamicValue::Text("explicit".to_string()), - )]); + let mut f = fields(&[("created_by", DynamicValue::Text("explicit".to_string()))]); assert_eq!( apply_defaults(&schema, &mut f, Some(&claims(&[])), fixed_now()), Ok(()) @@ -1035,16 +1038,12 @@ mod tests { let schema = schema_with(vec![ typed_field( "a", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![default_expr("10")], ), typed_field( "b", - FieldType::Integer( - schema_forge_core::types::IntegerConstraints::unconstrained(), - ), + FieldType::Integer(schema_forge_core::types::IntegerConstraints::unconstrained()), vec![default_expr("a + 5")], ), ]); diff --git a/crates/schema-forge-acton/tests/conditional_entities.rs b/crates/schema-forge-acton/tests/conditional_entities.rs index f0d06940..5f39b0dd 100644 --- a/crates/schema-forge-acton/tests/conditional_entities.rs +++ b/crates/schema-forge-acton/tests/conditional_entities.rs @@ -431,17 +431,13 @@ async fn conditional_delete_preserves_owner_denial_without_hiding_the_record() { .await; assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); assert!(!headers.contains_key("entity-revision")); - assert!(!body.to_string().contains("conditional_mutation_unsupported")); + assert!(!body + .to_string() + .contains("conditional_mutation_unsupported")); // `@owner` governs the write, never the read: the editor role the schema grants read to // still sees a record it does not own. - let (status, headers, body) = request( - &app, - &path, - "GET", - Some("malformed"), - serde_json::json!({}), - ) - .await; + let (status, headers, body) = + request(&app, &path, "GET", Some("malformed"), serde_json::json!({})).await; assert_eq!(status, StatusCode::OK, "{body}"); assert_eq!(body["fields"]["title"], "Original"); assert!(!headers.contains_key("entity-revision")); @@ -1106,3 +1102,135 @@ async fn postgres_http_exact_counts_preserve_projection_and_malformed_row_fallba ); } } + +async fn write_pipeline_fixture() -> Router { + let backend = Arc::new( + SurrealBackend::connect_memory("writes", "writes") + .await + .unwrap(), + ); + let schema = schema_forge_dsl::parse(r#" + @access(read: ["editor", "manager"], write: ["editor", "manager"], delete: ["manager"]) + schema Line { + title: text required + stage: text required @default("'pending'") + literal: text required default("literal") + owner: text required @owner + optional: text @require("optional == null || size(optional) > 2", "optional too short") + number: text @field_access(read: ["editor", "manager"], write: ["manager"]) + status: text @default("'pending'") @require("status != 'live' || number != null", "live needs number") + has_number: boolean required @compute("number != null") @field_access(read: ["editor", "manager"], write: ["manager"]) + } + "#).unwrap().remove(0); + let plan = schema_forge_core::migration::DiffEngine::create_new(&schema); + backend + .apply_migration(&schema.name, &plan.steps) + .await + .unwrap(); + backend.store_schema_metadata(&schema).await.unwrap(); + app_with_backend(backend, schema, &["editor"]).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn write_rules_observe_only_authorized_input_and_keep_server_values() { + let app = write_pipeline_fixture().await; + let base = "/schemas/Line/entities"; + let (status, _, body) = request( + &app, + base, + "POST", + None, + serde_json::json!({"title":"line", "number":"forbidden", "status":"live"}), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + let (status, _, body) = request( + &app, + base, + "POST", + None, + serde_json::json!({"title":"line", "number":"forbidden"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert_eq!(body["fields"]["stage"], "pending"); + assert_eq!(body["fields"]["literal"], "literal"); + assert_eq!(body["fields"]["owner"], "user:editor"); + assert_eq!(body["fields"]["has_number"], false); + assert!(body["fields"]["number"].is_null()); + let path = format!("{base}/{}", body["id"].as_str().unwrap()); + let (status, _, body) = request( + &app, + &path, + "PATCH", + None, + serde_json::json!({"number":"forbidden", "status":"live"}), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + let (status, _, body) = request( + &app, + &path, + "PATCH", + None, + serde_json::json!({"number":"forbidden"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body["fields"]["number"].is_null()); + assert_eq!(body["fields"]["has_number"], false); + let (status, _, body) = request(&app, &path, "PUT", None, + serde_json::json!({"title":"updated", "stage":"pending", "literal":"literal", "status":"live", "number":"forbidden"})).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn required_fields_reject_null_and_put_does_not_apply_create_defaults() { + let app = write_pipeline_fixture().await; + let base = "/schemas/Line/entities"; + let (status, _, body) = + request(&app, base, "POST", None, serde_json::json!({"title":null})).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert!(!body.to_string().contains("PUT")); + let (status, _, body) = request( + &app, + base, + "POST", + None, + serde_json::json!({"title":"line"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + let path = format!("{base}/{}", body["id"].as_str().unwrap()); + for method in ["PATCH", "PUT"] { + let (status, _, body) = request( + &app, + &path, + method, + None, + serde_json::json!({"title":null, "stage":"pending", "literal":"literal"}), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{method}: {body}"); + } + let (status, _, body) = request( + &app, + &path, + "PUT", + None, + serde_json::json!({"title":"updated"}), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert!(body.to_string().contains("stage")); + let (status, _, body) = request( + &app, + &path, + "PUT", + None, + serde_json::json!({"title":"updated", "stage":"pending", "literal":"literal"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["fields"]["owner"], "user:editor"); +} From 4ef194598775e13da7b3620fe4e70356c525f5d1 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:38:07 -0600 Subject: [PATCH 05/77] fix: add tenant isolation regressions and warn on empty access grants --- crates/schema-forge-acton/src/access.rs | 61 ++++++++++++++++ .../schema-forge-acton/src/routes/invites.rs | 22 ++++++ crates/schema-forge-cli/src/commands/parse.rs | 7 ++ crates/schema-forge-dsl/src/lib.rs | 3 + crates/schema-forge-dsl/src/warnings.rs | 72 +++++++++++++++++++ .../schema-forge-postgres/src/cedar_read.rs | 7 ++ docs/invitations-reference.md | 4 ++ docs/public-reads.md | 4 ++ skills/schemaforge/dsl-reference.md | 6 ++ 9 files changed, 186 insertions(+) create mode 100644 crates/schema-forge-dsl/src/warnings.rs diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index b1f4ade1..e3b399fa 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -1174,6 +1174,67 @@ mod tests { Arc::new(PolicyStore::new(snapshot)) } + #[test] + fn shared_schema_never_receives_tenant_filter_or_stamp() { + let schema = schema_without_owner("Catalog"); + let claims = make_claims_with_tenant(&["member"], "organization_a"); + let config = make_enabled_tenant_config(); + let mut query = Query::new(schema.id.clone()); + inject_tenant_scope(&mut query, Some(&claims), &config, &schema); + assert!(query.filter.is_none()); + let mut fields = BTreeMap::new(); + inject_tenant_on_create(&mut fields, Some(&claims), &config, &schema); + assert!(!fields.contains_key("_tenant")); + } + + #[test] + fn tenant_updates_preserve_member_boundaries_and_root_identity() { + let child = tenant_child_schema(); + let mut root = child.clone(); + root.annotations = vec![Annotation::Tenant(TenantKind::Root)]; + for (schema, role, retained) in [(&child, "member", false), (&child, "platform_admin", true), (&root, "platform_admin", false)] { + let claims = make_claims(&[role]); + let mut fields = BTreeMap::from([("_tenant".into(), DynamicValue::Text("other".into()))]); + strip_tenant_on_update(&mut fields, schema, Some(&claims)); + assert_eq!(fields.contains_key("_tenant"), retained); + } + } + + #[test] + fn tenant_root_stamp_and_query_use_own_identity() { + let mut schema = tenant_child_schema(); + schema.annotations = vec![Annotation::Tenant(TenantKind::Root)]; + let mut entity = Entity::new(schema.name.clone(), BTreeMap::new()); + stamp_root_tenant(&mut entity, &schema); + assert_eq!(entity.fields["_tenant"], DynamicValue::Text(entity.id.to_string())); + let claims = make_claims_with_tenant(&["member"], entity.id.as_str()); + let mut query = Query::new(schema.id.clone()); + inject_tenant_scope(&mut query, Some(&claims), &make_enabled_tenant_config(), &schema); + assert!(matches!(query.filter, Some(Filter::Eq { path, .. }) if path.root() == "id")); + } + + #[test] + fn cedar_tenant_guard_denies_null_children_and_cross_tenant_legacy_roots() { + for annotation in ["@tenant(root)", "@tenant(parent: \"Organization\")"] { + let source = format!("{annotation}\n@access(read: [\"member\"], write: [\"member\"], delete: [\"member\"])\nschema Note {{ body: text }}"); + let schema = schema_forge_dsl::parse(&source).unwrap().remove(0); + let store = store_for(&schema, None); + let mut entity = Entity::new(schema.name.clone(), BTreeMap::from([("body".into(), DynamicValue::Text("secret".into()))])); + let outsider = make_claims_with_tenant(&["member"], "organization_other"); + let member = make_claims_with_tenant(&["member"], entity.id.as_str()); + let admin = make_claims(&["platform_admin"]); + for tenant in [None, Some(DynamicValue::Null)] { + if let Some(value) = tenant { entity.fields.insert("_tenant".into(), value); } + for action in [ActionVerb::Read, ActionVerb::List, ActionVerb::Update, ActionVerb::Delete] { + assert!(!authorize(&store, Some(&outsider), action, &schema, Some(&entity)).unwrap().is_allow()); + assert!(authorize(&store, Some(&admin), action, &schema, Some(&entity)).unwrap().is_allow()); + assert_eq!(authorize(&store, Some(&member), action, &schema, Some(&entity)).unwrap().is_allow(), is_tenant_root(&schema)); + } + } + assert!(check_schema_access(&store, &schema, Some(&member), AccessAction::Read).is_ok()); + } + } + #[test] fn patch_authorization_errors_abort_without_filtering_the_delta() { let schema = schema_forge_dsl::parse( diff --git a/crates/schema-forge-acton/src/routes/invites.rs b/crates/schema-forge-acton/src/routes/invites.rs index 1806a4d9..cd8a42cf 100644 --- a/crates/schema-forge-acton/src/routes/invites.rs +++ b/crates/schema-forge-acton/src/routes/invites.rs @@ -37,6 +37,7 @@ use acton_service::audit::AuditSeverity; use acton_service::auth::tokens::paseto_generator::PasetoGenerator; use acton_service::middleware::paseto::PasetoAuth; use acton_service::middleware::Claims; +use acton_service::prelude::ActorHandleInterface; use schema_forge_backend::{tenant::TenantConfig, TenantRef}; use tokio::sync::oneshot; use crate::actor::ForgeActor; @@ -520,6 +521,27 @@ pub async fn accept_invite( mod tests { use super::*; + #[test] + fn invitation_target_requires_configured_type_and_effective_membership() { + use schema_forge_core::types::{SchemaName, EntityId}; + use schema_forge_backend::tenant::TenantLevel; + let mut claims: Claims = serde_json::from_value(serde_json::json!({ + "sub": "user_inviter", "roles": ["owner"], "perms": [], "exp": 9999999999_u64, + "tenant_chain": [{"schema": "Org", "entity_id": "org_a"}] + })).unwrap(); + // Claims custom fields are explicitly populated to match middleware output. + claims.custom.insert("tenant_chain".into(), serde_json::json!([{"schema": "Org", "entity_id": "org_a"}])); + let config = TenantConfig { root_schema: Some(SchemaName::new("Org").unwrap()), hierarchy: vec![TenantLevel {schema: SchemaName::new("Org").unwrap(), parent: None, parent_field: None}] }; + assert!(validate_invite_tenant(&claims, Some(&config), Some("Org"), Some("org_a")).is_ok()); + assert!(matches!(validate_invite_tenant(&claims, Some(&config), Some("Org"), Some(EntityId::new("org").as_str())), Err(ForgeError::Forbidden { .. }))); + assert!(matches!(validate_invite_tenant(&claims, Some(&config), Some("Other"), Some("org_a")), Err(ForgeError::ValidationFailed { .. }))); + assert!(validate_invite_tenant(&claims, Some(&config), Some("Org"), None).is_err()); + assert!(validate_invite_tenant(&claims, Some(&config), None, None).is_ok()); + claims.roles = vec!["platform_admin".into()]; + assert!(validate_invite_tenant(&claims, Some(&config), Some("Org"), Some("org_b")).is_ok()); + assert!(validate_invite_tenant(&claims, Some(&config), Some("Unknown"), Some("org_b")).is_err()); + } + #[test] fn validate_email_accepts_plausible_addresses() { assert!(validate_email("invitee@example.gov").is_ok()); diff --git a/crates/schema-forge-cli/src/commands/parse.rs b/crates/schema-forge-cli/src/commands/parse.rs index ac7936b3..3677436d 100644 --- a/crates/schema-forge-cli/src/commands/parse.rs +++ b/crates/schema-forge-cli/src/commands/parse.rs @@ -34,6 +34,12 @@ pub async fn run( match schema_forge_dsl::parse(&source_text) { Ok(schemas) => { + let warnings = schema_forge_dsl::empty_access_grants(&source_text); + if output.mode != OutputMode::Json { + for warning in &warnings { + output.warn(&format!("{filename}: {warning}")); + } + } let count = schemas.len(); total_schemas += count; @@ -47,6 +53,7 @@ pub async fn run( "file": filename, "schemas": count, "errors": [], + "warnings": warnings.iter().map(ToString::to_string).collect::>(), })); } else { output.status(&format!(" {filename} .... {count} schemas")); diff --git a/crates/schema-forge-dsl/src/lib.rs b/crates/schema-forge-dsl/src/lib.rs index ccb1efbd..36ac522c 100644 --- a/crates/schema-forge-dsl/src/lib.rs +++ b/crates/schema-forge-dsl/src/lib.rs @@ -38,3 +38,6 @@ pub mod token; pub use error::{DslError, Span}; pub use parser::parse; pub use printer::{print, print_all}; + +mod warnings; +pub use warnings::{empty_access_grants, EmptyAccessGrant}; diff --git a/crates/schema-forge-dsl/src/warnings.rs b/crates/schema-forge-dsl/src/warnings.rs new file mode 100644 index 00000000..2eeb2961 --- /dev/null +++ b/crates/schema-forge-dsl/src/warnings.rs @@ -0,0 +1,72 @@ +//! Nonbreaking diagnostics for ambiguous authorization grants. + +use crate::lexer::tokenize; +use crate::token::Token; +use crate::Span; + +/// An explicit empty access grant, which permits every authenticated user. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmptyAccessGrant { + /// Annotation containing the grant (`access` or `field_access`). + pub annotation: String, + /// Direction with the explicit empty grant. + pub direction: String, + /// Location of the direction and list in the original source. + pub span: Span, +} + +impl std::fmt::Display for EmptyAccessGrant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "@{}({}: []) grants access to every authenticated user; use a nonempty role list such as [\"platform_admin\"] to restrict access", self.annotation, self.direction) + } +} + +/// Report explicit empty read/write/delete grants without changing semantics. +/// Comments, string contents, omitted grants and cross_tenant_read are ignored. +/// Invalid token streams are left to the parser's error diagnostics. +pub fn empty_access_grants(source: &str) -> Vec { + let Ok(tokens) = tokenize(source) else { return vec![] }; + let mut warnings = Vec::new(); + let mut annotation = None; + for (index, token) in tokens.iter().enumerate() { + if token.token == Token::At { + annotation = tokens.get(index + 1).and_then(|name| { + matches!(name.text.as_str(), "access" | "field_access").then_some(name.text.as_str()) + }); + } + if token.token == Token::RParen { + annotation = None; + } + let Some(annotation) = annotation else { continue }; + if !matches!(token.text.as_str(), "read" | "write" | "delete") { + continue; + } + let Some(rest) = tokens.get(index + 1..index + 4) else { continue }; + if rest[0].token == Token::Colon && rest[1].token == Token::LBracket && rest[2].token == Token::RBracket { + warnings.push(EmptyAccessGrant { annotation: annotation.into(), direction: token.text.clone(), span: Span::new(token.span.start, rest[2].span.end) }); + } + } + warnings +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reports_only_explicit_empty_access_directions() { + let source = r#" + // @access(read: []) + @access(read: ["staff"], write: [], cross_tenant_read: []) + schema Setting { + value: text @field_access(read: [], write: ["admin"]) + comment: text default("@access(delete: [])") + } + "#; + let warnings = empty_access_grants(source); + assert_eq!(warnings.len(), 2); + assert_eq!(warnings[0].direction, "write"); + assert_eq!(warnings[1].annotation, "field_access"); + assert_eq!(&source[warnings[0].span.start..warnings[0].span.end], "write: []"); + } +} diff --git a/crates/schema-forge-postgres/src/cedar_read.rs b/crates/schema-forge-postgres/src/cedar_read.rs index d3f2c59f..4ace663d 100644 --- a/crates/schema-forge-postgres/src/cedar_read.rs +++ b/crates/schema-forge-postgres/src/cedar_read.rs @@ -300,6 +300,13 @@ fn scoped_query(query: &Query, scope: &CedarReadScope, has_tenant: bool, schema: FieldPath::single(if root { "id" } else { "_tenant" }), members.iter().cloned().map(DynamicValue::Text).collect(), ); + let tenant = if schema.is_tenanted() { + tenant + } else { + // Legacy unannotated resources may carry tenant metadata. Their + // generated guard permits missing metadata, so retain exact parity. + Filter::Or { filters: vec![Filter::eq(FieldPath::single("_tenant"), DynamicValue::Null), tenant] } + }; query.filter = Some(match query.filter.take() { Some(filter) => Filter::And { filters: vec![filter, tenant], diff --git a/docs/invitations-reference.md b/docs/invitations-reference.md index 1c9da94e..042d4626 100644 --- a/docs/invitations-reference.md +++ b/docs/invitations-reference.md @@ -164,3 +164,7 @@ project_name = "Bob's Dog Scheduling" - **Signed claims authoritative.** On accept, role and tenant come from the cryptographically verified token, not from mutable DB columns. - **Fail-closed delivery.** A send failure is a `5xx` with the invite left `Pending`; it never reports success on undelivered mail. - **No secret on disk.** The SMTP password enters only through the environment. + +## Tenant delegation + +An invitation with a tenant target must supply both `tenant_type` and `tenant_id`. The type must be a configured tenant schema. Before creating the signed invitation or sending email, the server checks that the pair belongs to the caller's effective tenant chain, including any active-tenant narrowing. A cross-tenant target returns 403. Platform administrators may invite into any configured tenant type; incomplete or unknown targets return 422. diff --git a/docs/public-reads.md b/docs/public-reads.md index 9c52f782..e0bc763d 100644 --- a/docs/public-reads.md +++ b/docs/public-reads.md @@ -18,3 +18,7 @@ Public schema access does not override record ownership, tenant isolation, expli Custom `RecordAccessPolicy` implementations deny anonymous reads by default. To support public reads, override `filter_visible_optional` and explicitly handle absent claims. Authenticated requests continue through the existing `filter_visible` implementation. The built-in Cedar policy supports both callers without inventing authenticated claims. Embedding applications must opt selected GET/HEAD entity routes into acton-service's optional token authentication and retain schema authorization. Marking an entity prefix as `public_paths` skips token verification and is not an equivalent configuration. + +## Empty role lists + +For `@access` read/write/delete and `@field_access` read/write, explicit `[]` has the same meaning as omission: any authenticated principal, subject to the remaining Cedar policies. It does not deny access. Use a nonempty role list to restrict an action, for example `write: ["platform_admin"]`. `schemaforge parse` reports explicit empty grants as warnings while preserving existing schema semantics. diff --git a/skills/schemaforge/dsl-reference.md b/skills/schemaforge/dsl-reference.md index 99b5f8d0..49a28395 100644 --- a/skills/schemaforge/dsl-reference.md +++ b/skills/schemaforge/dsl-reference.md @@ -442,6 +442,10 @@ schema Department { ... } ### @access(read: [...], write: [...], delete: [...], cross_tenant_read: [...]) +For `read`, `write`, and `delete`, an omitted role list and an explicit `[]` both grant access to every authenticated principal. **An empty list does not mean nobody.** For example, `@access(read: ["staff"], write: [], delete: [])` lets any authenticated user create, update, and delete, subject to other Cedar forbids such as tenant and owner guards. Use a nonempty list, such as `write: ["platform_admin"], delete: ["platform_admin"]`, to restrict those actions. `schemaforge parse` warns about explicit empty grants. + +`cross_tenant_read: []` does not grant cross-tenant access. Anonymous access requires the explicit `"public"` role in `read`; empty grants require authentication. + Role-based access control. Generates Cedar authorization policies. ``` @@ -646,6 +650,8 @@ pipeline_stage: enum( ### @field_access(read: [...], write: [...]) +An omitted direction or an explicit empty list grants that direction to every authenticated user who can access the entity. For example, `@field_access(read: ["hr"], write: [])` allows any authenticated entity writer to write the field. To restrict writes, provide a nonempty role list such as `write: ["hr"]` or `write: ["platform_admin"]`. + Field-level access control — restricts who can read/write specific fields. ``` From 6a83608574f4a656c482919a38857894efb8438c Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:39:34 -0600 Subject: [PATCH 06/77] fix: enforce concrete Cedar authorization for GraphQL reads and deletes --- crates/schema-forge-acton/src/access.rs | 2 +- .../src/graphql/resolvers.rs | 38 +++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index e3b399fa..d5512ee0 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -407,7 +407,7 @@ pub fn inject_tenant_on_create( tenant_config: &Option, schema: &SchemaDefinition, ) { - if !schema.is_tenanted() { + if !schema.is_tenanted() || is_tenant_root(schema) { return; } let _config = match tenant_config { diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index a403f882..205d2500 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -13,6 +13,7 @@ use crate::access::{ FieldFilterDirection, }; use crate::error::ForgeError; +use crate::authz::{authorize, namespace::ActionVerb}; /// Entity data stored in resolver parent values. pub struct EntityFields { @@ -102,6 +103,7 @@ pub async fn resolve_get_entity<'a>( Err(e) => return Err(forge_error_to_gql(ForgeError::from(e))), }; + require_record_access(gql_ctx, schema_def, &entity, ActionVerb::Read)?; // Record-level visibility check if let (Some(ref policy), Some(c)) = (&gql_ctx.state.record_access_policy, claims) { let visible = policy @@ -211,16 +213,22 @@ pub async fn resolve_list_entities<'a>( .await .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; + // Canonical Cedar decisions apply even without a custom record policy. + let authorized = result.entities.into_iter().filter(|entity| { + require_record_access(gql_ctx, schema_def, entity, ActionVerb::Read).is_ok() + && require_record_access(gql_ctx, schema_def, entity, ActionVerb::List).is_ok() + }).collect(); // Record-level access filtering let visible_entities = if let (Some(ref policy), Some(c)) = (&gql_ctx.state.record_access_policy, claims) { - policy.filter_visible(schema_def, c, result.entities).await + policy.filter_visible(schema_def, c, authorized).await } else { - result.entities + authorized }; let count = visible_entities.len(); - let total_count = result.total_count; + // The raw storage count precedes authorization and could disclose hidden rows. + let total_count = None; let items: Vec = visible_entities .into_iter() @@ -393,6 +401,10 @@ pub async fn resolve_delete_entity( let entity_id = EntityId::parse(&id_arg) .map_err(|_| forge_error_to_gql(ForgeError::InvalidEntityId { id: id_arg.clone() }))?; + let entity = gql_ctx.state.backend.get(&schema, &entity_id).await + .map_err(|error| forge_error_to_gql(ForgeError::from(error)))?; + require_record_access(gql_ctx, schema_def, &entity, ActionVerb::Delete)?; + // Record-level ownership check if let (Some(ref policy), Some(c)) = (&gql_ctx.state.record_access_policy, claims) { let entity = gql_ctx @@ -455,6 +467,10 @@ pub async fn resolve_relation_one<'a>( Err(_) => return Ok(None), }; + if require_record_access(gql_ctx, target_schema_def, &entity, ActionVerb::Read).is_err() { + return Ok(None); + } + entity.strip_hidden(target_schema_def); filter_entity_fields( &gql_ctx.state.policy_store, &mut entity, @@ -503,6 +519,9 @@ pub async fn resolve_relation_many<'a>( let mut results = Vec::new(); for ref_id in ref_ids { if let Ok(mut entity) = gql_ctx.state.backend.get(&target_schema, &ref_id).await { + if require_record_access(gql_ctx, target_schema_def, &entity, ActionVerb::Read).is_err() { + continue; + } entity.strip_hidden(target_schema_def); filter_entity_fields( &gql_ctx.state.policy_store, @@ -518,6 +537,19 @@ pub async fn resolve_relation_many<'a>( Ok(Some(FieldValue::list(results))) } +fn require_record_access( + context: &ForgeGraphqlContext, + schema: &SchemaDefinition, + entity: &Entity, + action: ActionVerb, +) -> async_graphql::Result<()> { + let decision = authorize(&context.state.policy_store, context.claims.as_ref(), action, schema, Some(entity)) + .map_err(|_| forge_error_to_gql(ForgeError::Forbidden { message: "could not authorize entity".into() }))?; + if decision.is_allow() && decision.errors.is_empty() { Ok(()) } else { + Err(forge_error_to_gql(ForgeError::Forbidden { message: "not authorized for this entity".into() })) + } +} + /// Convert an Entity to a FieldValue wrapping EntityFields. fn entity_to_field_value(entity: Entity, type_name: &str) -> FieldValue<'static> { FieldValue::owned_any(EntityFields { From a075ce0c27b8bf2357a3d4d36b7b56b372d91f8f Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:41:07 -0600 Subject: [PATCH 07/77] test: cover incomplete create authorization and optional rule bindings --- .../schema-forge-acton/src/routes/entities.rs | 27 +++---- crates/schema-forge-acton/src/rules.rs | 18 +++++ .../tests/conditional_entities.rs | 78 ++++++++++++++++++- 3 files changed, 106 insertions(+), 17 deletions(-) diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index c75306ab..6d37a340 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -831,19 +831,7 @@ fn enforce_bytes_max_size(bytes: &[u8], max_size: Option) -> Result<(), S } } -/// Check every value in a write against the constraints declared on its -/// field's type, collecting all violations into a single 422. -/// -/// Runs at the last seam before the backend, which is what makes it -/// complete: by this point the field map holds client JSON, `@default` and -/// `@compute` rule output, server-injected columns, and anything a -/// `before_*` hook substituted. Checking earlier would leave the later -/// sources unguarded, and an unguarded violation reaches the database, whose -/// refusal arrives as an untyped driver error and surfaces as a 502 — a -/// retryable status for a request that can never succeed. See #133. -/// -/// Values whose names are not in the schema (`_tenant` and friends) carry no -/// declared constraints and are skipped. +/// Reject missing or null required fields after server values have been applied. pub(crate) fn validate_required_fields( schema: &SchemaDefinition, fields: &BTreeMap, @@ -872,6 +860,19 @@ pub(crate) fn validate_required_fields( } } +/// Check every value in a write against the constraints declared on its +/// field's type, collecting all violations into a single 422. +/// +/// Runs at the last seam before the backend, which is what makes it +/// complete: by this point the field map holds client JSON, `@default` and +/// `@compute` rule output, server-injected columns, and anything a +/// `before_*` hook substituted. Checking earlier would leave the later +/// sources unguarded, and an unguarded violation reaches the database, whose +/// refusal arrives as an untyped driver error and surfaces as a 502 — a +/// retryable status for a request that can never succeed. See #133. +/// +/// Values whose names are not in the schema (`_tenant` and friends) carry no +/// declared constraints and are skipped. fn check_field_constraints( schema: &SchemaDefinition, fields: &BTreeMap, diff --git a/crates/schema-forge-acton/src/rules.rs b/crates/schema-forge-acton/src/rules.rs index af1f0e42..37b76277 100644 --- a/crates/schema-forge-acton/src/rules.rs +++ b/crates/schema-forge-acton/src/rules.rs @@ -550,6 +550,24 @@ mod tests { Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap() } + #[test] + fn absent_declared_fields_and_tenant_are_bound_as_null() { + let schema = schema_forge_dsl::parse(r#" + @tenant(root) + schema Tenant { + optional: text @require("optional == null && _tenant == null", "expected null context") + copy: text @default("optional") + derived: boolean @compute("optional == null") + } + "#).unwrap().remove(0); + let mut values = BTreeMap::new(); + apply_defaults(&schema, &mut values, None, fixed_now()).unwrap(); + apply_computed(&schema, &mut values, None, fixed_now()).unwrap(); + assert_eq!(check_requires(&schema, &values, None, fixed_now()), Ok(())); + assert_eq!(values.get("copy"), Some(&DynamicValue::Null)); + assert_eq!(values.get("derived"), Some(&DynamicValue::Boolean(true))); + } + #[test] fn passing_require_ok() { let schema = schema_with(vec![text_field( diff --git a/crates/schema-forge-acton/tests/conditional_entities.rs b/crates/schema-forge-acton/tests/conditional_entities.rs index 5f39b0dd..e7c45f4e 100644 --- a/crates/schema-forge-acton/tests/conditional_entities.rs +++ b/crates/schema-forge-acton/tests/conditional_entities.rs @@ -123,6 +123,15 @@ async fn app_with_backend( backend: Arc, schema: SchemaDefinition, roles: &[&str], +) -> Router { + app_with_policies(backend, schema, roles, None).await +} + +async fn app_with_policies( + backend: Arc, + schema: SchemaDefinition, + roles: &[&str], + custom_policies_dir: Option, ) -> Router { let service = ServiceBuilder::new() .with_config(Config::::default()) @@ -141,7 +150,7 @@ async fn app_with_backend( hook_dispatcher: None, storage_registry: StorageRegistry::default(), policy_store: None, - custom_policies_dir: None, + custom_policies_dir, reply: ReplyChannel::new(tx), }) .await; @@ -1104,6 +1113,13 @@ async fn postgres_http_exact_counts_preserve_projection_and_malformed_row_fallba } async fn write_pipeline_fixture() -> Router { + write_pipeline_fixture_with_policy(&["editor"], None).await +} + +async fn write_pipeline_fixture_with_policy( + roles: &[&str], + custom_policies_dir: Option, +) -> Router { let backend = Arc::new( SurrealBackend::connect_memory("writes", "writes") .await @@ -1128,7 +1144,7 @@ async fn write_pipeline_fixture() -> Router { .await .unwrap(); backend.store_schema_metadata(&schema).await.unwrap(); - app_with_backend(backend, schema, &["editor"]).await + app_with_policies(backend, schema, roles, custom_policies_dir).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1155,7 +1171,7 @@ async fn write_rules_observe_only_authorized_input_and_keep_server_values() { assert_eq!(status, StatusCode::CREATED, "{body}"); assert_eq!(body["fields"]["stage"], "pending"); assert_eq!(body["fields"]["literal"], "literal"); - assert_eq!(body["fields"]["owner"], "user:editor"); + assert_eq!(body["fields"]["owner"], "editor"); assert_eq!(body["fields"]["has_number"], false); assert!(body["fields"]["number"].is_null()); let path = format!("{base}/{}", body["id"].as_str().unwrap()); @@ -1232,5 +1248,59 @@ async fn required_fields_reject_null_and_put_does_not_apply_create_defaults() { ) .await; assert_eq!(status, StatusCode::OK, "{body}"); - assert_eq!(body["fields"]["owner"], "user:editor"); + assert_eq!(body["fields"]["owner"], "editor"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_field_authorization_accepts_defaults_but_fails_closed_on_missing_policy_attributes() +{ + let base = "/schemas/Line/entities"; + let app = write_pipeline_fixture_with_policy(&["manager"], None).await; + let (status, _, body) = request( + &app, + base, + "POST", + None, + serde_json::json!({"title":"line", "number":"allowed", "status":"live"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert_eq!(body["fields"]["number"], "allowed"); + assert_eq!(body["fields"]["has_number"], true); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("custom.cedar"), + r#" + forbid(principal, action == Action::"WriteFieldLine_number", resource is Line) + when { resource.stage == "blocked" }; + "#, + ) + .unwrap(); + let app = + write_pipeline_fixture_with_policy(&["manager"], Some(dir.path().to_path_buf())).await; + for stage in [None, Some("blocked")] { + let mut fields = serde_json::json!({"title":"line", "number":"allowed"}); + if let Some(stage) = stage { + fields["stage"] = stage.into(); + } + let (status, _, body) = request(&app, base, "POST", None, fields).await; + if stage.is_none() { + // An errored forbid must not be bypassed by the generated role permit. + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + } else { + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert!(body["fields"]["number"].is_null()); + assert_eq!(body["fields"]["has_number"], false); + } + } + let (status, _, body) = request( + &app, + base, + "POST", + None, + serde_json::json!({"title":"line", "stage":"open", "number":"allowed"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert_eq!(body["fields"]["has_number"], true); } From 049d4fa1b1866498b263f8046c6baf0d69e085f8 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:42:17 -0600 Subject: [PATCH 08/77] test: cover tenant boundaries in PostgreSQL and GraphQL --- crates/schema-forge-acton/src/access.rs | 166 ++++++++++++++--- .../schema-forge-acton/src/authz/adapters.rs | 13 +- .../schema-forge-acton/src/routes/invites.rs | 124 +++++++++---- .../tests/graphql_tenants.rs | 169 ++++++++++++++++++ crates/schema-forge-dsl/src/warnings.rs | 31 +++- .../schema-forge-postgres/src/cedar_read.rs | 24 ++- .../schema-forge-postgres/tests/cedar_read.rs | 38 ++++ docs/tenant-isolation.md | 11 ++ 8 files changed, 500 insertions(+), 76 deletions(-) create mode 100644 crates/schema-forge-acton/tests/graphql_tenants.rs create mode 100644 docs/tenant-isolation.md diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index d5512ee0..51ee0fd3 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -381,7 +381,11 @@ pub fn inject_tenant_scope( .iter() .map(|t| DynamicValue::Text(t.entity_id.clone())) .collect(); - let tenant_field = if is_tenant_root(schema) { "id" } else { "_tenant" }; + let tenant_field = if is_tenant_root(schema) { + "id" + } else { + "_tenant" + }; let tenant_filter = if tenant_values.len() == 1 { Filter::eq( FieldPath::single(tenant_field), @@ -431,14 +435,22 @@ pub fn inject_tenant_on_create( /// Whether a schema defines tenant identities rather than tenant-owned data. pub fn is_tenant_root(schema: &SchemaDefinition) -> bool { - schema.annotations.iter().any(|annotation| matches!(annotation, - schema_forge_core::types::Annotation::Tenant(schema_forge_core::types::TenantKind::Root))) + schema.annotations.iter().any(|annotation| { + matches!( + annotation, + schema_forge_core::types::Annotation::Tenant( + schema_forge_core::types::TenantKind::Root + ) + ) + }) } /// A tenant root always belongs to its own identity, including admin creates. pub fn stamp_root_tenant(entity: &mut Entity, schema: &SchemaDefinition) { if is_tenant_root(schema) { - entity.fields.insert("_tenant".into(), DynamicValue::Text(entity.id.to_string())); + entity + .fields + .insert("_tenant".into(), DynamicValue::Text(entity.id.to_string())); } } @@ -449,8 +461,10 @@ pub fn strip_tenant_on_update( schema: &SchemaDefinition, claims: Option<&Claims>, ) { - if !schema.is_tenanted() || is_tenant_root(schema) - || !claims.is_some_and(|claims| claims.has_role(PLATFORM_ADMIN_ROLE)) { + if !schema.is_tenanted() + || is_tenant_root(schema) + || !claims.is_some_and(|claims| claims.has_role(PLATFORM_ADMIN_ROLE)) + { fields.remove("_tenant"); } } @@ -601,8 +615,21 @@ mod tests { use schema_forge_core::types::DynamicValue; fn tenant_child_schema() -> SchemaDefinition { - let mut schema = SchemaDefinition::new(SchemaId::new(), SchemaName::new("Note").unwrap(), vec![FieldDefinition::new(FieldName::new("body").unwrap(), FieldType::Text(TextConstraints::default()))], vec![]).unwrap(); - schema.annotations.push(Annotation::Tenant(TenantKind::Child { parent: SchemaName::new("Organization").unwrap() })); + let mut schema = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("Note").unwrap(), + vec![FieldDefinition::new( + FieldName::new("body").unwrap(), + FieldType::Text(TextConstraints::default()), + )], + vec![], + ) + .unwrap(); + schema + .annotations + .push(Annotation::Tenant(TenantKind::Child { + parent: SchemaName::new("Organization").unwrap(), + })); schema } @@ -699,7 +726,12 @@ mod tests { let claims = make_claims_with_tenant(&["member"], tenant_id.as_str()); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config, &tenant_child_schema()); + inject_tenant_scope( + &mut query, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(query.filter.is_some()); let filter = query.filter.unwrap(); @@ -722,7 +754,12 @@ mod tests { let claims = make_claims_with_tenant(&["member"], tenant_id.as_str()); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config, &tenant_child_schema()); + inject_tenant_scope( + &mut query, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(query.filter.is_none()); } @@ -734,7 +771,12 @@ mod tests { let claims = make_claims_with_tenant(&["platform_admin"], tenant_id.as_str()); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config, &tenant_child_schema()); + inject_tenant_scope( + &mut query, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(query.filter.is_none()); } @@ -762,7 +804,12 @@ mod tests { ); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config, &tenant_child_schema()); + inject_tenant_scope( + &mut query, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); let filter = query.filter.expect("filter set"); match filter { @@ -789,7 +836,12 @@ mod tests { let claims = make_claims(&["member"]); let mut query = Query::new(SchemaId::new()); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config, &tenant_child_schema()); + inject_tenant_scope( + &mut query, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(query.filter.is_none()); } @@ -806,7 +858,12 @@ mod tests { ); let mut query = Query::new(SchemaId::new()).with_filter(existing_filter); - inject_tenant_scope(&mut query, Some(&claims), &tenant_config, &tenant_child_schema()); + inject_tenant_scope( + &mut query, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(query.filter.is_some()); let filter = query.filter.unwrap(); @@ -843,7 +900,12 @@ mod tests { let mut fields = BTreeMap::new(); fields.insert("name".to_string(), DynamicValue::Text("Alice".to_string())); - inject_tenant_on_create(&mut fields, Some(&claims), &tenant_config, &tenant_child_schema()); + inject_tenant_on_create( + &mut fields, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(fields.contains_key("_tenant")); assert_eq!( @@ -860,7 +922,12 @@ mod tests { let mut fields = BTreeMap::new(); fields.insert("name".to_string(), DynamicValue::Text("Alice".to_string())); - inject_tenant_on_create(&mut fields, Some(&claims), &tenant_config, &tenant_child_schema()); + inject_tenant_on_create( + &mut fields, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(!fields.contains_key("_tenant")); } @@ -883,7 +950,12 @@ mod tests { let mut fields = BTreeMap::new(); fields.insert("name".to_string(), DynamicValue::Text("Alice".to_string())); - inject_tenant_on_create(&mut fields, Some(&claims), &tenant_config, &tenant_child_schema()); + inject_tenant_on_create( + &mut fields, + Some(&claims), + &tenant_config, + &tenant_child_schema(), + ); assert!(!fields.contains_key("_tenant")); } @@ -1192,9 +1264,14 @@ mod tests { let child = tenant_child_schema(); let mut root = child.clone(); root.annotations = vec![Annotation::Tenant(TenantKind::Root)]; - for (schema, role, retained) in [(&child, "member", false), (&child, "platform_admin", true), (&root, "platform_admin", false)] { + for (schema, role, retained) in [ + (&child, "member", false), + (&child, "platform_admin", true), + (&root, "platform_admin", false), + ] { let claims = make_claims(&[role]); - let mut fields = BTreeMap::from([("_tenant".into(), DynamicValue::Text("other".into()))]); + let mut fields = + BTreeMap::from([("_tenant".into(), DynamicValue::Text("other".into()))]); strip_tenant_on_update(&mut fields, schema, Some(&claims)); assert_eq!(fields.contains_key("_tenant"), retained); } @@ -1206,10 +1283,18 @@ mod tests { schema.annotations = vec![Annotation::Tenant(TenantKind::Root)]; let mut entity = Entity::new(schema.name.clone(), BTreeMap::new()); stamp_root_tenant(&mut entity, &schema); - assert_eq!(entity.fields["_tenant"], DynamicValue::Text(entity.id.to_string())); + assert_eq!( + entity.fields["_tenant"], + DynamicValue::Text(entity.id.to_string()) + ); let claims = make_claims_with_tenant(&["member"], entity.id.as_str()); let mut query = Query::new(schema.id.clone()); - inject_tenant_scope(&mut query, Some(&claims), &make_enabled_tenant_config(), &schema); + inject_tenant_scope( + &mut query, + Some(&claims), + &make_enabled_tenant_config(), + &schema, + ); assert!(matches!(query.filter, Some(Filter::Eq { path, .. }) if path.root() == "id")); } @@ -1219,19 +1304,44 @@ mod tests { let source = format!("{annotation}\n@access(read: [\"member\"], write: [\"member\"], delete: [\"member\"])\nschema Note {{ body: text }}"); let schema = schema_forge_dsl::parse(&source).unwrap().remove(0); let store = store_for(&schema, None); - let mut entity = Entity::new(schema.name.clone(), BTreeMap::from([("body".into(), DynamicValue::Text("secret".into()))])); + let mut entity = Entity::new( + schema.name.clone(), + BTreeMap::from([("body".into(), DynamicValue::Text("secret".into()))]), + ); let outsider = make_claims_with_tenant(&["member"], "organization_other"); let member = make_claims_with_tenant(&["member"], entity.id.as_str()); let admin = make_claims(&["platform_admin"]); for tenant in [None, Some(DynamicValue::Null)] { - if let Some(value) = tenant { entity.fields.insert("_tenant".into(), value); } - for action in [ActionVerb::Read, ActionVerb::List, ActionVerb::Update, ActionVerb::Delete] { - assert!(!authorize(&store, Some(&outsider), action, &schema, Some(&entity)).unwrap().is_allow()); - assert!(authorize(&store, Some(&admin), action, &schema, Some(&entity)).unwrap().is_allow()); - assert_eq!(authorize(&store, Some(&member), action, &schema, Some(&entity)).unwrap().is_allow(), is_tenant_root(&schema)); + if let Some(value) = tenant { + entity.fields.insert("_tenant".into(), value); + } + for action in [ + ActionVerb::Read, + ActionVerb::List, + ActionVerb::Update, + ActionVerb::Delete, + ] { + assert!( + !authorize(&store, Some(&outsider), action, &schema, Some(&entity)) + .unwrap() + .is_allow() + ); + assert!( + authorize(&store, Some(&admin), action, &schema, Some(&entity)) + .unwrap() + .is_allow() + ); + assert_eq!( + authorize(&store, Some(&member), action, &schema, Some(&entity)) + .unwrap() + .is_allow(), + is_tenant_root(&schema) + ); } } - assert!(check_schema_access(&store, &schema, Some(&member), AccessAction::Read).is_ok()); + assert!( + check_schema_access(&store, &schema, Some(&member), AccessAction::Read).is_ok() + ); } } diff --git a/crates/schema-forge-acton/src/authz/adapters.rs b/crates/schema-forge-acton/src/authz/adapters.rs index dd58f293..ffb949ce 100644 --- a/crates/schema-forge-acton/src/authz/adapters.rs +++ b/crates/schema-forge-acton/src/authz/adapters.rs @@ -189,13 +189,12 @@ pub fn build_principal_entities( parents.extend(group_uids); parents.extend(tenant_uids); - let principal_entity = - CedarEntity::new(principal_uid_value, attrs, parents).map_err(|e| { - AdapterError::UnrepresentableValue { - field: "principal".into(), - detail: e.to_string(), - } - })?; + let principal_entity = CedarEntity::new(principal_uid_value, attrs, parents).map_err(|e| { + AdapterError::UnrepresentableValue { + field: "principal".into(), + detail: e.to_string(), + } + })?; let mut all = Vec::with_capacity(1 + group_entities.len() + tenant_entities.len()); all.push(principal_entity); diff --git a/crates/schema-forge-acton/src/routes/invites.rs b/crates/schema-forge-acton/src/routes/invites.rs index cd8a42cf..d880d075 100644 --- a/crates/schema-forge-acton/src/routes/invites.rs +++ b/crates/schema-forge-acton/src/routes/invites.rs @@ -33,15 +33,13 @@ use std::sync::Arc; use std::time::Duration; +use crate::actor::ForgeActor; +use crate::messages::{GetTenantConfig, ReplyChannel}; use acton_service::audit::AuditSeverity; use acton_service::auth::tokens::paseto_generator::PasetoGenerator; use acton_service::middleware::paseto::PasetoAuth; use acton_service::middleware::Claims; use acton_service::prelude::ActorHandleInterface; -use schema_forge_backend::{tenant::TenantConfig, TenantRef}; -use tokio::sync::oneshot; -use crate::actor::ForgeActor; -use crate::messages::{GetTenantConfig, ReplyChannel}; use acton_service::state::AppState; use axum::extract::State; use axum::http::StatusCode; @@ -49,8 +47,10 @@ use axum::response::IntoResponse; use axum::{Extension, Json}; use chrono::Utc; use schema_forge_backend::user_store::ForgeUser; +use schema_forge_backend::{tenant::TenantConfig, TenantRef}; use schema_forge_backend::{InviteStore, NewInvitation}; use serde::{Deserialize, Serialize}; +use tokio::sync::oneshot; use tracing::instrument; use crate::access::{check_schema_access, AccessAction, OptionalClaims}; @@ -172,7 +172,10 @@ fn validate_email(email: &str) -> Result<(), ForgeError> { /// site-relative path when no public base URL is configured. fn build_accept_url(base: Option<&str>, invite_id: &str) -> String { match base { - Some(b) => format!("{}/invite/accept?invite={invite_id}", b.trim_end_matches('/')), + Some(b) => format!( + "{}/invite/accept?invite={invite_id}", + b.trim_end_matches('/') + ), None => format!("/invite/accept?invite={invite_id}"), } } @@ -203,20 +206,38 @@ fn validate_invite_tenant( let (tenant_type, tenant_id) = match (tenant_type, tenant_id) { (None, None) => return Ok(()), (Some(kind), Some(id)) if !kind.is_empty() && !id.is_empty() => (kind, id), - _ => return Err(ForgeError::ValidationFailed { - details: vec!["tenant_type and tenant_id must be supplied together and must not be empty".into()], - }), + _ => { + return Err(ForgeError::ValidationFailed { + details: vec![ + "tenant_type and tenant_id must be supplied together and must not be empty" + .into(), + ], + }) + } }; - if !config.is_some_and(|config| config.hierarchy.iter().any(|level| level.schema.as_str() == tenant_type)) { + if !config.is_some_and(|config| { + config + .hierarchy + .iter() + .any(|level| level.schema.as_str() == tenant_type) + }) { return Err(ForgeError::ValidationFailed { details: vec!["tenant_type must name a configured tenant schema".into()], }); } - let chain = claims.custom_claim_as::>("tenant_chain").unwrap_or_default(); - if claims.has_role("platform_admin") || chain.iter().any(|tenant| tenant.schema == tenant_type && tenant.entity_id == tenant_id) { + let chain = claims + .custom_claim_as::>("tenant_chain") + .unwrap_or_default(); + if claims.has_role("platform_admin") + || chain + .iter() + .any(|tenant| tenant.schema == tenant_type && tenant.entity_id == tenant_id) + { Ok(()) } else { - Err(ForgeError::Forbidden { message: "invitation tenant is outside the caller's effective tenant scope".into() }) + Err(ForgeError::Forbidden { + message: "invitation tenant is outside the caller's effective tenant scope".into(), + }) } } @@ -246,18 +267,34 @@ pub async fn create_invite( let user_schema = fetch_user_schema(&state).await?; let policy_store = fetch_policy_store(&state).await?; - check_schema_access(&policy_store, &user_schema, Some(claims), AccessAction::Create)?; + check_schema_access( + &policy_store, + &user_schema, + Some(claims), + AccessAction::Create, + )?; validate_email(&body.email)?; - let forge = state.actor::().ok_or_else(|| ForgeError::Internal { - message: "ForgeActor not registered".into(), - })?; + let forge = state + .actor::() + .ok_or_else(|| ForgeError::Internal { + message: "ForgeActor not registered".into(), + })?; let (tx, rx) = oneshot::channel(); - forge.send(GetTenantConfig { reply: ReplyChannel::new(tx) }).await; + forge + .send(GetTenantConfig { + reply: ReplyChannel::new(tx), + }) + .await; let tenant_config = rx.await.map_err(|_| ForgeError::Internal { message: "ForgeActor reply channel dropped while fetching tenant configuration".into(), })?; - validate_invite_tenant(claims, tenant_config.as_ref(), body.tenant_type.as_deref(), body.tenant_id.as_deref())?; + validate_invite_tenant( + claims, + tenant_config.as_ref(), + body.tenant_type.as_deref(), + body.tenant_id.as_deref(), + )?; // The invite grants a single role (or none). Apply the same role-grant // guards `create_user` applies to its `roles` list. @@ -445,10 +482,11 @@ pub async fn accept_invite( // Reconstruct + verify the full token from the stored column. Signed // claims are authoritative; the DB columns are a convenience mirror. - let verified = - verify_invite_token(validator.as_ref(), &invite.token).map_err(|e| ForgeError::Internal { + let verified = verify_invite_token(validator.as_ref(), &invite.token).map_err(|e| { + ForgeError::Internal { message: format!("stored invite token failed verification: {e}"), - })?; + } + })?; if verified.invite_id != invite.jti { return Err(ForgeError::Internal { message: "invite token does not match the invitation it was stored under".to_string(), @@ -480,8 +518,10 @@ pub async fn accept_invite( .create_user(&verified.email, &body.password, &roles, &display_name) .await?; - if let (Some(tt), Some(tid)) = (verified.tenant_type.as_deref(), verified.tenant_id.as_deref()) - { + if let (Some(tt), Some(tid)) = ( + verified.tenant_type.as_deref(), + verified.tenant_id.as_deref(), + ) { auth_store .add_tenant_membership(&verified.email, tt, tid, verified.role.as_deref()) .await?; @@ -523,23 +563,47 @@ mod tests { #[test] fn invitation_target_requires_configured_type_and_effective_membership() { - use schema_forge_core::types::{SchemaName, EntityId}; use schema_forge_backend::tenant::TenantLevel; + use schema_forge_core::types::{EntityId, SchemaName}; let mut claims: Claims = serde_json::from_value(serde_json::json!({ "sub": "user_inviter", "roles": ["owner"], "perms": [], "exp": 9999999999_u64, "tenant_chain": [{"schema": "Org", "entity_id": "org_a"}] - })).unwrap(); + })) + .unwrap(); // Claims custom fields are explicitly populated to match middleware output. - claims.custom.insert("tenant_chain".into(), serde_json::json!([{"schema": "Org", "entity_id": "org_a"}])); - let config = TenantConfig { root_schema: Some(SchemaName::new("Org").unwrap()), hierarchy: vec![TenantLevel {schema: SchemaName::new("Org").unwrap(), parent: None, parent_field: None}] }; + claims.custom.insert( + "tenant_chain".into(), + serde_json::json!([{"schema": "Org", "entity_id": "org_a"}]), + ); + let config = TenantConfig { + root_schema: Some(SchemaName::new("Org").unwrap()), + hierarchy: vec![TenantLevel { + schema: SchemaName::new("Org").unwrap(), + parent: None, + parent_field: None, + }], + }; assert!(validate_invite_tenant(&claims, Some(&config), Some("Org"), Some("org_a")).is_ok()); - assert!(matches!(validate_invite_tenant(&claims, Some(&config), Some("Org"), Some(EntityId::new("org").as_str())), Err(ForgeError::Forbidden { .. }))); - assert!(matches!(validate_invite_tenant(&claims, Some(&config), Some("Other"), Some("org_a")), Err(ForgeError::ValidationFailed { .. }))); + assert!(matches!( + validate_invite_tenant( + &claims, + Some(&config), + Some("Org"), + Some(EntityId::new("org").as_str()) + ), + Err(ForgeError::Forbidden { .. }) + )); + assert!(matches!( + validate_invite_tenant(&claims, Some(&config), Some("Other"), Some("org_a")), + Err(ForgeError::ValidationFailed { .. }) + )); assert!(validate_invite_tenant(&claims, Some(&config), Some("Org"), None).is_err()); assert!(validate_invite_tenant(&claims, Some(&config), None, None).is_ok()); claims.roles = vec!["platform_admin".into()]; assert!(validate_invite_tenant(&claims, Some(&config), Some("Org"), Some("org_b")).is_ok()); - assert!(validate_invite_tenant(&claims, Some(&config), Some("Unknown"), Some("org_b")).is_err()); + assert!( + validate_invite_tenant(&claims, Some(&config), Some("Unknown"), Some("org_b")).is_err() + ); } #[test] diff --git a/crates/schema-forge-acton/tests/graphql_tenants.rs b/crates/schema-forge-acton/tests/graphql_tenants.rs new file mode 100644 index 00000000..f3d3516a --- /dev/null +++ b/crates/schema-forge-acton/tests/graphql_tenants.rs @@ -0,0 +1,169 @@ +#![cfg(feature = "graphql")] + +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use acton_service::{ + config::Config, middleware::Claims, prelude::ActorHandleInterface, + service_builder::ServiceBuilder, +}; +use axum::{body::Body, http::Request, Router}; +use http_body_util::BodyExt; +use schema_forge_acton::{ + config::SchemaForgeConfig, + messages::{InitForge, ReplyChannel}, + ForgeActor, HookDispatchActor, SchemaForgeExtension, +}; +use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; +use schema_forge_core::migration::DiffEngine; +use schema_forge_core::types::{DynamicValue, SchemaName}; +use schema_forge_surrealdb::SurrealBackend; +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use tokio::sync::oneshot; +use tower::ServiceExt; + +async fn app() -> (Router, String, String) { + let backend = SurrealBackend::connect_with_auth("mem://", "graphql", "tenants", None, None) + .await + .unwrap(); + let schemas = schema_forge_dsl::parse( + r#" + @tenant(root) + @access(read: ["member"], write: ["member"], delete: ["member"]) + schema Org { name: text required } + @access(read: ["member"], write: ["member"]) + schema Catalog { name: text required } + "#, + ) + .unwrap(); + for schema in &schemas { + backend + .apply_migration(&schema.name, &DiffEngine::create_new(schema)) + .await + .unwrap(); + backend.store_schema_metadata(schema).await.unwrap(); + } + // Direct storage seeds reproduce pre-fix tenant roots with NULL metadata. + let mut roots = Vec::new(); + for name in ["a", "b"] { + let entity = Entity::new( + SchemaName::new("Org").unwrap(), + BTreeMap::from([("name".into(), DynamicValue::Text(name.into()))]), + ); + roots.push(entity.id.to_string()); + EntityStore::create(&backend, &entity).await.unwrap(); + } + let extension = SchemaForgeExtension::builder() + .with_backend(backend) + .build() + .await + .unwrap(); + let forge_state = extension.state(); + let service = ServiceBuilder::new() + .with_config(Config::::default()) + .with_actor::() + .with_actor::() + .build(); + let (tx, rx) = oneshot::channel(); + service + .state() + .actor::() + .unwrap() + .send(InitForge { + registry: forge_state + .registry + .list() + .await + .into_iter() + .map(|s| (s.name.as_str().to_owned(), s)) + .collect(), + backend: forge_state.backend.clone(), + tenant_config: forge_state.tenant_config.clone(), + record_access_policy: forge_state.record_access_policy.clone(), + hook_dispatcher: None, + storage_registry: forge_state.storage_registry.clone(), + policy_store: Some(Arc::clone(&forge_state.policy_store)), + custom_policies_dir: None, + reply: ReplyChannel::new(tx), + }) + .await; + tokio::time::timeout(Duration::from_secs(10), rx) + .await + .unwrap() + .unwrap(); + let app = extension + .register_graphql_routes(Router::new()) + .with_state(service.state().clone()); + (app, roots.remove(0), roots.remove(0)) +} + +async fn query(app: &Router, query: &str, tenant: &str) -> Value { + let claims = Claims { + sub: "user:graphql-test".into(), + roles: vec!["member".into()], + perms: vec![], + exp: 9_999_999_999, + iat: None, + jti: None, + iss: None, + aud: None, + email: None, + username: None, + custom: HashMap::from([( + "tenant_chain".into(), + json!([{"schema":"Org","entity_id":tenant}]), + )]), + }; + let mut request = Request::post("/forge/graphql") + .header("content-type", "application/json") + .body(Body::from(json!({"query": query}).to_string())) + .unwrap(); + request.extensions_mut().insert(claims); + let response = app.clone().oneshot(request).await.unwrap(); + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap() +} + +#[tokio::test] +async fn graphql_scopes_legacy_roots_and_keeps_shared_catalog_usable() { + let (app, own, foreign) = app().await; + let get = query( + &app, + &format!("{{ org(id: \"{own}\") {{ id name }} }}"), + &own, + ) + .await; + assert_eq!(get["data"]["org"]["id"], own, "{get}"); + let denied = query( + &app, + &format!("{{ org(id: \"{foreign}\") {{ id name }} }}"), + &own, + ) + .await; + assert!(denied.get("errors").is_some(), "{denied}"); + let list = query(&app, "{ orgs { items { id } totalCount } }", &own).await; + assert_eq!( + list["data"]["orgs"]["items"].as_array().unwrap().len(), + 1, + "{list}" + ); + assert!(list["data"]["orgs"]["totalCount"].is_null()); + let denied = query( + &app, + &format!("mutation {{ deleteOrg(id: \"{foreign}\") }}"), + &own, + ) + .await; + assert!(denied.get("errors").is_some(), "{denied}"); + let created = query( + &app, + "mutation { createCatalog(input: {name: \"shared\"}) { id name } }", + &own, + ) + .await; + assert!(created.get("errors").is_none(), "{created}"); + let catalog = query(&app, "{ catalogs { items { name } } }", &foreign).await; + assert_eq!( + catalog["data"]["catalogs"]["items"][0]["name"], "shared", + "{catalog}" + ); +} diff --git a/crates/schema-forge-dsl/src/warnings.rs b/crates/schema-forge-dsl/src/warnings.rs index 2eeb2961..f863362a 100644 --- a/crates/schema-forge-dsl/src/warnings.rs +++ b/crates/schema-forge-dsl/src/warnings.rs @@ -25,25 +25,39 @@ impl std::fmt::Display for EmptyAccessGrant { /// Comments, string contents, omitted grants and cross_tenant_read are ignored. /// Invalid token streams are left to the parser's error diagnostics. pub fn empty_access_grants(source: &str) -> Vec { - let Ok(tokens) = tokenize(source) else { return vec![] }; + let Ok(tokens) = tokenize(source) else { + return vec![]; + }; let mut warnings = Vec::new(); let mut annotation = None; for (index, token) in tokens.iter().enumerate() { if token.token == Token::At { annotation = tokens.get(index + 1).and_then(|name| { - matches!(name.text.as_str(), "access" | "field_access").then_some(name.text.as_str()) + matches!(name.text.as_str(), "access" | "field_access") + .then_some(name.text.as_str()) }); } if token.token == Token::RParen { annotation = None; } - let Some(annotation) = annotation else { continue }; + let Some(annotation) = annotation else { + continue; + }; if !matches!(token.text.as_str(), "read" | "write" | "delete") { continue; } - let Some(rest) = tokens.get(index + 1..index + 4) else { continue }; - if rest[0].token == Token::Colon && rest[1].token == Token::LBracket && rest[2].token == Token::RBracket { - warnings.push(EmptyAccessGrant { annotation: annotation.into(), direction: token.text.clone(), span: Span::new(token.span.start, rest[2].span.end) }); + let Some(rest) = tokens.get(index + 1..index + 4) else { + continue; + }; + if rest[0].token == Token::Colon + && rest[1].token == Token::LBracket + && rest[2].token == Token::RBracket + { + warnings.push(EmptyAccessGrant { + annotation: annotation.into(), + direction: token.text.clone(), + span: Span::new(token.span.start, rest[2].span.end), + }); } } warnings @@ -67,6 +81,9 @@ mod tests { assert_eq!(warnings.len(), 2); assert_eq!(warnings[0].direction, "write"); assert_eq!(warnings[1].annotation, "field_access"); - assert_eq!(&source[warnings[0].span.start..warnings[0].span.end], "write: []"); + assert_eq!( + &source[warnings[0].span.start..warnings[0].span.end], + "write: []" + ); } } diff --git a/crates/schema-forge-postgres/src/cedar_read.rs b/crates/schema-forge-postgres/src/cedar_read.rs index 4ace663d..e9fe006f 100644 --- a/crates/schema-forge-postgres/src/cedar_read.rs +++ b/crates/schema-forge-postgres/src/cedar_read.rs @@ -290,12 +290,23 @@ fn supported_type(field: &FieldType) -> Option<&'static str> { } } -fn scoped_query(query: &Query, scope: &CedarReadScope, has_tenant: bool, schema: &SchemaDefinition) -> Query { +fn scoped_query( + query: &Query, + scope: &CedarReadScope, + has_tenant: bool, + schema: &SchemaDefinition, +) -> Query { let mut query = query.clone(); if let CedarReadScope::TenantMembers(members) = scope { if has_tenant { - let root = schema.annotations.iter().any(|annotation| matches!(annotation, - schema_forge_core::types::Annotation::Tenant(schema_forge_core::types::TenantKind::Root))); + let root = schema.annotations.iter().any(|annotation| { + matches!( + annotation, + schema_forge_core::types::Annotation::Tenant( + schema_forge_core::types::TenantKind::Root + ) + ) + }); let tenant = Filter::in_set( FieldPath::single(if root { "id" } else { "_tenant" }), members.iter().cloned().map(DynamicValue::Text).collect(), @@ -305,7 +316,12 @@ fn scoped_query(query: &Query, scope: &CedarReadScope, has_tenant: bool, schema: } else { // Legacy unannotated resources may carry tenant metadata. Their // generated guard permits missing metadata, so retain exact parity. - Filter::Or { filters: vec![Filter::eq(FieldPath::single("_tenant"), DynamicValue::Null), tenant] } + Filter::Or { + filters: vec![ + Filter::eq(FieldPath::single("_tenant"), DynamicValue::Null), + tenant, + ], + } }; query.filter = Some(match query.filter.take() { Some(filter) => Filter::And { diff --git a/crates/schema-forge-postgres/tests/cedar_read.rs b/crates/schema-forge-postgres/tests/cedar_read.rs index 485cff8b..228bc971 100644 --- a/crates/schema-forge-postgres/tests/cedar_read.rs +++ b/crates/schema-forge-postgres/tests/cedar_read.rs @@ -460,6 +460,44 @@ async fn required_and_hidden_json_cannot_change_tenant_visibility() { }).await; } +#[tokio::test] +#[ignore = "requires SCHEMAFORGE_TEST_POSTGRES_URL with CREATE SCHEMA privilege"] +async fn tenanted_children_exclude_null_and_roots_scope_by_identity() { + use schema_forge_core::types::{Annotation, TenantKind}; + for kind in [ + TenantKind::Root, + TenantKind::Child { + parent: SchemaName::new("Org").unwrap(), + }, + ] { + with_database(|backend| async move { + let mut schema = definition(); + schema.annotations.push(Annotation::Tenant(kind.clone())); + install(&backend, &schema).await; + let own = insert(&backend, &schema, "own").await; + let _foreign = insert(&backend, &schema, "foreign").await; + let _unstamped = insert(&backend, &schema, "unstamped").await; + let scope = CedarReadScope::TenantMembers(vec![own.id.to_string()]); + if matches!(kind, TenantKind::Child { .. }) { + sqlx::query("UPDATE \"CountProof\" SET _tenant = id WHERE id = $1") + .bind(own.id.as_str()) + .execute(backend.pool()) + .await + .unwrap(); + } + let result = backend + .query_cedar_compatible(&schema, &Query::new(schema.id.clone()), &scope) + .await + .unwrap() + .unwrap(); + assert_eq!(result.total_count, Some(1)); + assert_eq!(result.entities.len(), 1); + assert_eq!(result.entities[0].id, own.id); + }) + .await; + } +} + async fn with_database(test: Test) where Test: FnOnce(Arc) -> Pending, diff --git a/docs/tenant-isolation.md b/docs/tenant-isolation.md new file mode 100644 index 00000000..30f20b65 --- /dev/null +++ b/docs/tenant-isolation.md @@ -0,0 +1,11 @@ +# Tenant isolation + +Only schemas with `@tenant(root)` or `@tenant(parent: "...")` receive automatic tenant filters and tenant stamps. Shared schemas remain accessible according to their Cedar policies, including when referenced from tenant-owned rows. + +A root row's identity defines its tenant. New root rows store `_tenant = id`; authorization and list scoping derive that value from `id` for existing roots too. Legacy roots with NULL or inconsistent `_tenant` metadata therefore remain accessible to their own members without exposing other roots or requiring a data rewrite. + +A tenant-owned child must carry valid `_tenant` metadata. Generated Cedar policies deny reads, updates and deletes of missing or NULL child tenants for non-platform administrators. Apply regenerated policies when upgrading a deployment that persists generated Cedar files. The canonical query filter and PostgreSQL authorized counts exclude these unstamped child rows. + +Tenant members cannot move rows by supplying `_tenant` in PUT or PATCH: the server removes that input and preserves stored ownership. Platform administrators can reassign child rows. Root identities remain immutable for every caller. + +Invitation tenant targets require a configured tenant schema and a type/id pair in the caller's effective tenant chain. Active-tenant narrowing applies before delegation; platform administrators may delegate across tenants. From aec363664b395f6c8609177e65b71b3b1c391d64 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:43:25 -0600 Subject: [PATCH 09/77] docs: describe canonical GraphQL mutation behavior and integration --- docs/graphql-writes.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/graphql-writes.md diff --git a/docs/graphql-writes.md b/docs/graphql-writes.md new file mode 100644 index 00000000..0661589d --- /dev/null +++ b/docs/graphql-writes.md @@ -0,0 +1,13 @@ +# GraphQL entity writes + +GraphQL `create{Schema}` and `update{Schema}` mutations use the same entity write pipeline as REST POST and PATCH. Field authorization runs before defaults, computed expressions, validation rules, and lifecycle hooks. Updates merge authorized input with stored fields before evaluating rules. A mutation containing only denied fields returns the unchanged entity, unless server-generated values change. + +Required fields supplied by a literal default, `@default`, `@compute`, or `@owner` may be omitted from create input. Required fields without a server-supplied value remain mandatory. The completed entity is validated before persistence. + +Mutation responses use the same field projection as REST, including hidden-field removal and field-level read authorization. A GraphQL update has partial-update semantics; it does not behave like REST PUT. + +## Rust integration migration + +`SchemaForgeExtension::register_graphql_routes` now accepts and returns `Router>`. Register GraphQL routes on the service router before supplying the initialized application state. The service must register and initialize `ForgeActor`, as required by the REST routes; register `HookDispatchActor` when lifecycle hooks are enabled. GraphQL no longer writes directly through a separate backend-only state. + +The request context carries both the schema/query state and the initialized actor-backed application state. Embedders constructing `ForgeGraphqlContext` directly must supply its new `app_state` field. From 955a1945647bb4d170c20e9e0ce8569de3828f2d Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:43:33 -0600 Subject: [PATCH 10/77] fix: refuse tenant count certification when metadata column is missing --- .../schema-forge-acton/src/cedar/policy_gen.rs | 17 +++++------------ crates/schema-forge-postgres/src/cedar_read.rs | 3 ++- .../schema-forge-postgres/tests/cedar_read.rs | 3 +++ 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/crates/schema-forge-acton/src/cedar/policy_gen.rs b/crates/schema-forge-acton/src/cedar/policy_gen.rs index eff92450..67c11bbc 100644 --- a/crates/schema-forge-acton/src/cedar/policy_gen.rs +++ b/crates/schema-forge-acton/src/cedar/policy_gen.rs @@ -214,18 +214,11 @@ forbid ( /// Generates the per-schema tenant-isolation forbid. /// -/// Cedar — not the query layer — is the authoritative gate on cross-tenant -/// access. The policy fires for any per-record action when: -/// -/// - the resource carries a `_tenant` reference (i.e., it's tenant-scoped), AND -/// - the principal is not a member of that tenant via parent chain, AND -/// - the principal is not `platform_admin`. -/// -/// The `resource has "_tenant"` precondition keeps the rule inert for -/// non-tenant resources and for the schema-level placeholder (which has no -/// attributes), so it composes cleanly with the schema-level `@access` -/// permits and with `inject_tenant_scope` (which stays as defense in depth -/// at the query layer). +/// Concrete tenanted records fail closed when `_tenant` is absent, or when +/// the caller is outside that tenant. Root adapters derive `_tenant` from +/// the root identity. Platform administrators and schema placeholders are +/// exempt. Legacy untenanted resources retain their metadata-based guard. +/// Query filters are defense in depth; Cedar remains authoritative. fn tenant_guard_forbid_policy(schema: &SchemaDefinition) -> CedarPolicy { let name = schema.name.as_str(); let condition = if schema.is_tenanted() { diff --git a/crates/schema-forge-postgres/src/cedar_read.rs b/crates/schema-forge-postgres/src/cedar_read.rs index e9fe006f..43b459c5 100644 --- a/crates/schema-forge-postgres/src/cedar_read.rs +++ b/crates/schema-forge-postgres/src/cedar_read.rs @@ -255,7 +255,8 @@ fn certify_columns( ))); } } - if !names.contains("id") + if (schema.is_tenanted() && !proof.has_tenant) + || !names.contains("id") || schema .fields .iter() diff --git a/crates/schema-forge-postgres/tests/cedar_read.rs b/crates/schema-forge-postgres/tests/cedar_read.rs index 228bc971..fbf396fb 100644 --- a/crates/schema-forge-postgres/tests/cedar_read.rs +++ b/crates/schema-forge-postgres/tests/cedar_read.rs @@ -493,6 +493,9 @@ async fn tenanted_children_exclude_null_and_roots_scope_by_identity() { assert_eq!(result.total_count, Some(1)); assert_eq!(result.entities.len(), 1); assert_eq!(result.entities[0].id, own.id); + execute(&backend, "ALTER TABLE \"CountProof\" DROP COLUMN _tenant").await; + assert!(backend.query_cedar_compatible(&schema, &Query::new(schema.id.clone()), &scope) + .await.unwrap().is_none()); }) .await; } From b7c5961ab700f99dd9af9805cc9373b1eb9650c3 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:42:25 -0600 Subject: [PATCH 11/77] fix: preserve fail-closed bindings on CEL conversion errors --- crates/schema-forge-acton/src/rules.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/schema-forge-acton/src/rules.rs b/crates/schema-forge-acton/src/rules.rs index 37b76277..71eaf3c4 100644 --- a/crates/schema-forge-acton/src/rules.rs +++ b/crates/schema-forge-acton/src/rules.rs @@ -122,6 +122,9 @@ impl std::error::Error for RuleError {} /// "undeclared reference" eval error, which [`check_requires`] handles /// fail-closed. /// +/// Absent non-derived schema fields and the tenant field on tenanted schemas +/// are bound as null, allowing explicit null guards in rules. +/// /// A `principal` map is always bound (even when `claims` is `None`, in which /// case it is an empty map) so that `has(principal.sub)` is a clean `false` /// rather than an undeclared-reference error. @@ -148,6 +151,8 @@ pub fn build_bindings( for (name, value) in fields { if let Ok(cel) = dynamic_to_cel(value) { bindings.insert(name.clone(), cel); + } else { + bindings.remove(name); } // On conversion failure we intentionally omit the binding; a predicate // referencing it will error and be handled fail-closed downstream. @@ -333,13 +338,9 @@ pub fn apply_computed( /// This is wired into entity **creation only** — never PUT/PATCH. A default /// seeds an initial value; it must not silently re-materialize on later writes. /// -/// ## Distinct from the static default -/// -/// `@default("")` (this annotation, [`FieldAnnotation::Default`]) is an -/// *expression-valued* default evaluated by the CEL engine at write time. It is -/// entirely separate from the literal [`FieldModifier::Default`](schema_forge_core::types::FieldModifier::Default) -/// (e.g. `default(5)`), which is applied as a storage-layer SQL `DEFAULT`. This -/// function does not touch the static-default path, whose behavior is unchanged. +/// Literal `default(...)` modifiers are materialized for absent fields before +/// their CEL `@default` annotations. This makes storage defaults visible to +/// required validation, computations, and hooks before persistence. /// /// ## Absent-vs-null /// @@ -351,13 +352,11 @@ pub fn apply_computed( /// /// 1. client-supplied non-null value /// 2. value stamped by `@owner` / tenant / audit injection (runs before hooks) -/// 3. value set by a before-hook +/// 3. literal `default(...)` for an absent field /// 4. expression `@default` /// -/// Because `apply_defaults` runs *after* owner/tenant/audit injection and after -/// the before-hooks, and only fills absent/null fields, any of those earlier -/// stages "wins" over `@default` for the same field — in particular `@owner` -/// always beats `@default`. +/// Owner/tenant/audit injection precedes defaults. Before-hooks run after all +/// rule phases and can replace their output before final validation. /// /// ## Order relative to the other rules /// From a99835172eb4cb6f998af03a7e39794bf50b6c14 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:44:25 -0600 Subject: [PATCH 12/77] fix: align replacement rules with persisted optional field values --- .../schema-forge-acton/src/routes/entities.rs | 70 ++++++++++++------- .../tests/conditional_entities.rs | 53 ++++++++++++++ 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index 6d37a340..5a3491ad 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -12,8 +12,8 @@ use schema_forge_backend::conditional::{ConditionalMutationError, EntityRevision use schema_forge_backend::entity::Entity; use schema_forge_core::query::{validate_filter, FieldPath, Filter, SortOrder}; use schema_forge_core::types::{ - Cardinality, ConstraintViolation, DynamicValue, EntityId, FieldType, SchemaDefinition, - SchemaName, + Cardinality, ConstraintViolation, DynamicValue, EntityId, FieldAnnotation, FieldType, + SchemaDefinition, SchemaName, }; use serde::{Deserialize, Serialize}; use tokio::sync::oneshot; @@ -3352,6 +3352,40 @@ pub async fn update_entity( json_to_entity_fields_with_mode(&schema_def, &body.fields, ConversionMode::Merge) .map_err(|errors| ForgeError::ValidationFailed { details: errors })?; + // Immutable input cannot influence field authorization. + strip_owner_on_update(&mut fields, &schema_def); + strip_tenant_on_update(&mut fields, &schema_def, claims.as_ref()); + if let Some(tenant) = existing.fields.get("_tenant") { + fields + .entry("_tenant".into()) + .or_insert_with(|| tenant.clone()); + } + // PUT removes omitted writable optional values. Hidden/server-owned + // fields remain present, and denied removals are restored below. + for field in &schema_def.fields { + let name = field.name.as_str(); + if field.is_derived() || fields.contains_key(name) { + continue; + } + let server_owned = field.is_hidden() + || field.has_owner() + || field + .annotations + .iter() + .any(|annotation| matches!(annotation, FieldAnnotation::Compute { .. })) + || matches!( + name, + "created_at" | "created_by" | "updated_at" | "updated_by" + ); + if server_owned { + if let Some(value) = existing.fields.get(name) { + fields.insert(name.into(), value.clone()); + } + } else if !field.is_required() || field.field_access().is_some() { + fields.insert(name.into(), DynamicValue::Null); + } + } + let proposed_names: Vec<_> = fields.keys().cloned().collect(); let mut candidate = existing.clone(); candidate.fields.extend(fields.clone()); let mut supplied = Entity::with_id(entity_id.clone(), schema_name.clone(), fields); @@ -3363,28 +3397,13 @@ pub async fn update_entity( claims.as_ref(), )?; fields = supplied.fields; - for name in body.fields.keys() { - if !fields.contains_key(name) { - if let Some(value) = existing.fields.get(name) { - fields.insert(name.clone(), value.clone()); + for name in proposed_names { + if !fields.contains_key(&name) { + if let Some(value) = existing.fields.get(&name) { + fields.insert(name, value.clone()); } } } - - strip_owner_on_update(&mut fields, &schema_def); - strip_tenant_on_update(&mut fields, &schema_def, claims.as_ref()); - if let Some(tenant) = existing.fields.get("_tenant") { - fields - .entry("_tenant".into()) - .or_insert_with(|| tenant.clone()); - } - // PUT replaces supplied fields, but immutable ownership remains part of - // the post-update rule context, even when an administrator is the caller. - if let Some(owner_field) = schema_def.fields.iter().find(|field| field.has_owner()) { - if let Some(owner) = existing.fields.get(owner_field.name.as_str()) { - fields.insert(owner_field.name.as_str().to_string(), owner.clone()); - } - } // Single request-time instant reused for audit columns and the `now` CEL binding. let rules_now = chrono::Utc::now(); inject_audit_columns_on_update(&mut fields, &schema_def, claims.as_ref(), rules_now); @@ -3661,6 +3680,11 @@ pub async fn patch_entity( json_to_entity_fields_with_mode(&schema_def, &body.fields, ConversionMode::Merge) .map_err(|errors| ForgeError::ValidationFailed { details: errors })?; + // Owner field is immutable post-create; refuse to transfer ownership + // via PATCH the same way we refuse via PUT. + strip_owner_on_update(&mut patch_fields, &schema_def); + strip_tenant_on_update(&mut patch_fields, &schema_def, claims.as_ref()); + let mut candidate = existing.clone(); candidate.fields.extend(patch_fields.clone()); let mut supplied = Entity::with_id(entity_id.clone(), schema_name.clone(), patch_fields); @@ -3673,10 +3697,6 @@ pub async fn patch_entity( )?; patch_fields = supplied.fields; - // Owner field is immutable post-create; refuse to transfer ownership - // via PATCH the same way we refuse via PUT. - strip_owner_on_update(&mut patch_fields, &schema_def); - strip_tenant_on_update(&mut patch_fields, &schema_def, claims.as_ref()); // Single request-time instant reused for audit columns and the `now` CEL binding. let rules_now = chrono::Utc::now(); inject_audit_columns_on_update(&mut patch_fields, &schema_def, claims.as_ref(), rules_now); diff --git a/crates/schema-forge-acton/tests/conditional_entities.rs b/crates/schema-forge-acton/tests/conditional_entities.rs index e7c45f4e..876644ee 100644 --- a/crates/schema-forge-acton/tests/conditional_entities.rs +++ b/crates/schema-forge-acton/tests/conditional_entities.rs @@ -1304,3 +1304,56 @@ async fn create_field_authorization_accepts_defaults_but_fails_closed_on_missing assert_eq!(status, StatusCode::CREATED, "{body}"); assert_eq!(body["fields"]["has_number"], true); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn put_omission_matches_persisted_values_and_preserves_denied_fields() { + for role in ["editor", "manager"] { + let backend = Arc::new(SurrealBackend::connect_memory("put", "put").await.unwrap()); + let schema = schema_forge_dsl::parse( + r#" + @access(read: ["editor", "manager"], write: ["editor", "manager"], delete: ["manager"]) + schema Contact { + title: text required + number: text @field_access(read: ["editor", "manager"], write: ["manager"]) + has_number: boolean @compute("number != null") + } + "#, + ) + .unwrap() + .remove(0); + let plan = schema_forge_core::migration::DiffEngine::create_new(&schema); + backend + .apply_migration(&schema.name, &plan.steps) + .await + .unwrap(); + backend.store_schema_metadata(&schema).await.unwrap(); + let seed = Entity::new( + schema.name.clone(), + BTreeMap::from([ + ("title".into(), DynamicValue::Text("original".into())), + ("number".into(), DynamicValue::Text("stored".into())), + ("has_number".into(), DynamicValue::Boolean(true)), + ]), + ); + DynEntityStore::create(backend.as_ref(), &seed) + .await + .unwrap(); + let app = app_with_backend(backend, schema, &[role]).await; + let path = format!("/schemas/Contact/entities/{}", seed.id); + let (status, _, body) = request( + &app, + &path, + "PUT", + None, + serde_json::json!({"title":"updated"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{role}: {body}"); + assert_eq!(body["fields"]["has_number"], role == "editor", "{body}"); + if role == "editor" { + assert_eq!(body["fields"]["number"], "stored"); + } else { + assert!(body["fields"]["number"].is_null()); + } + } +} From 73658edd593608426863fdbc1786a3c5c11c56a4 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:46:34 -0600 Subject: [PATCH 13/77] fix: reauthorize input after denied fields are removed --- .../schema-forge-acton/src/routes/entities.rs | 105 +++++++++++------- .../tests/conditional_entities.rs | 73 ++++++++++++ 2 files changed, 138 insertions(+), 40 deletions(-) diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index 5a3491ad..47e2feee 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -831,6 +831,64 @@ fn enforce_bytes_max_size(bytes: &[u8], max_size: Option) -> Result<(), S } } +/// Reevaluate retained input against the resource after every denied-field removal. +/// Each pass removes keys, so authorization reaches a stable result in finite time. +fn filter_update_input( + store: &Arc, + schema: &SchemaDefinition, + claims: Option<&Claims>, + existing: &Entity, + supplied: &mut Entity, +) -> Result<(), ForgeError> { + loop { + let mut candidate = existing.clone(); + candidate.fields.extend(supplied.fields.clone()); + let before = supplied.fields.len(); + filter_patch_fields(store, supplied, &candidate, schema, claims)?; + if supplied.fields.len() == before { + return Ok(()); + } + } +} + +fn filter_create_input( + store: &Arc, + schema: &SchemaDefinition, + claims: Option<&Claims>, + supplied: &mut Entity, +) -> Result<(), ForgeError> { + loop { + let mut denied = Vec::new(); + for name in supplied.fields.keys() { + if schema + .field(name) + .is_none_or(|field| field.has_owner() || field.field_access().is_none()) + { + continue; + } + let decision = + crate::authz::engine::authorize_create_field(store, claims, schema, supplied, name) + .map_err(|_| ForgeError::Forbidden { + message: "Could not authorize a supplied field.".into(), + })?; + if !decision.errors.is_empty() { + return Err(ForgeError::Forbidden { + message: "Could not authorize a supplied field.".into(), + }); + } + if !decision.is_allow() { + denied.push(name.clone()); + } + } + if denied.is_empty() { + return Ok(()); + } + for name in denied { + supplied.fields.remove(&name); + } + } +} + /// Reject missing or null required fields after server values have been applied. pub(crate) fn validate_required_fields( schema: &SchemaDefinition, @@ -2509,36 +2567,7 @@ pub async fn create_entity( &schema_def, ); inject_owner_on_create(&mut supplied.fields, &schema_def, claims.as_ref()); - let mut denied = Vec::new(); - for name in body.fields.keys() { - if schema_def - .field(name) - .is_none_or(|field| field.field_access().is_none()) - { - continue; - } - let decision = crate::authz::engine::authorize_create_field( - &policy_store, - claims.as_ref(), - &schema_def, - &supplied, - name, - ) - .map_err(|_| ForgeError::Forbidden { - message: "Could not authorize a supplied field.".into(), - })?; - if !decision.errors.is_empty() { - return Err(ForgeError::Forbidden { - message: "Could not authorize a supplied field.".into(), - }); - } - if !decision.is_allow() { - denied.push(name.clone()); - } - } - for name in denied { - supplied.fields.remove(&name); - } + filter_create_input(&policy_store, &schema_def, claims.as_ref(), &mut supplied)?; // Server-owned values are injected again after caller input filtering. stamp_root_tenant(&mut supplied, &schema_def); fields = supplied.fields; @@ -3386,15 +3415,13 @@ pub async fn update_entity( } } let proposed_names: Vec<_> = fields.keys().cloned().collect(); - let mut candidate = existing.clone(); - candidate.fields.extend(fields.clone()); let mut supplied = Entity::with_id(entity_id.clone(), schema_name.clone(), fields); - filter_patch_fields( + filter_update_input( &policy_store, - &mut supplied, - &candidate, &schema_def, claims.as_ref(), + &existing, + &mut supplied, )?; fields = supplied.fields; for name in proposed_names { @@ -3685,15 +3712,13 @@ pub async fn patch_entity( strip_owner_on_update(&mut patch_fields, &schema_def); strip_tenant_on_update(&mut patch_fields, &schema_def, claims.as_ref()); - let mut candidate = existing.clone(); - candidate.fields.extend(patch_fields.clone()); let mut supplied = Entity::with_id(entity_id.clone(), schema_name.clone(), patch_fields); - filter_patch_fields( + filter_update_input( &policy_store, - &mut supplied, - &candidate, &schema_def, claims.as_ref(), + &existing, + &mut supplied, )?; patch_fields = supplied.fields; diff --git a/crates/schema-forge-acton/tests/conditional_entities.rs b/crates/schema-forge-acton/tests/conditional_entities.rs index 876644ee..fba39f1d 100644 --- a/crates/schema-forge-acton/tests/conditional_entities.rs +++ b/crates/schema-forge-acton/tests/conditional_entities.rs @@ -1357,3 +1357,76 @@ async fn put_omission_matches_persisted_values_and_preserves_denied_fields() { } } } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn denied_inputs_cannot_authorize_other_fields() { + let backend = Arc::new( + SurrealBackend::connect_memory("field_auth", "field_auth") + .await + .unwrap(), + ); + let schema = schema_forge_dsl::parse( + r#" + @access(read: ["editor"], write: ["editor"], delete: ["editor"]) + schema Pair { + title: text required + value: text @field_access(read: ["editor"], write: ["editor"]) + gate: text @field_access(read: ["editor"], write: ["manager"]) + } + "#, + ) + .unwrap() + .remove(0); + let plan = schema_forge_core::migration::DiffEngine::create_new(&schema); + backend + .apply_migration(&schema.name, &plan.steps) + .await + .unwrap(); + backend.store_schema_metadata(&schema).await.unwrap(); + let seed = Entity::new( + schema.name.clone(), + BTreeMap::from([ + ("title".into(), DynamicValue::Text("original".into())), + ("value".into(), DynamicValue::Text("original".into())), + ("gate".into(), DynamicValue::Text("locked".into())), + ]), + ); + DynEntityStore::create(backend.as_ref(), &seed) + .await + .unwrap(); + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("custom.cedar"), + r#" + forbid(principal, action == Action::"WriteFieldPair_value", resource is Pair) + when { !(resource has gate && resource.gate == "unlocked") }; + "#, + ) + .unwrap(); + let app = app_with_policies(backend, schema, &["editor"], Some(dir.path().to_path_buf())).await; + let path = format!("/schemas/Pair/entities/{}", seed.id); + for method in ["PATCH", "PUT"] { + let (status, _, body) = request( + &app, + &path, + method, + None, + serde_json::json!({"title":"updated", "value":"attacker", "gate":"unlocked"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["fields"]["value"], "original"); + assert_eq!(body["fields"]["gate"], "locked"); + } + let (status, _, body) = request( + &app, + "/schemas/Pair/entities", + "POST", + None, + serde_json::json!({"title":"new", "value":"attacker", "gate":"unlocked"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert!(body["fields"]["value"].is_null()); + assert!(body["fields"]["gate"].is_null()); +} From 2de99bd8393c3d25d187903aef8daa12f7fa86ff Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:47:24 -0600 Subject: [PATCH 14/77] docs: clarify runtime tenant normalization and grant diagnostics --- crates/schema-forge-acton/src/access.rs | 7 ++++--- crates/schema-forge-dsl/src/warnings.rs | 4 +++- docs/tenant-isolation.md | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index 51ee0fd3..917c250f 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -340,8 +340,8 @@ pub(crate) fn filter_patch_fields( /// Inject tenant scoping filter into a query. /// -/// Adds `_tenant = ` filter based on the deepest tenant in the -/// claims' `tenant_chain` custom claim. No-ops when: +/// Filters child `_tenant` or root `id` by the effective tenant chain. +/// Shared schemas are not scoped. No-ops when: /// - `tenant_config` is `None` or disabled /// - `claims` is `None` /// - user is `platform_admin` (bypass) @@ -404,7 +404,8 @@ pub fn inject_tenant_scope( /// /// Sets `_tenant` to the deepest tenant entity ID in the claims' /// `tenant_chain` custom claim. No-ops when tenancy is disabled, -/// claims is `None`, or the tenant chain is empty. +/// claims is `None`, or the tenant chain is empty. Shared schemas and tenant +/// roots are excluded; roots are stamped by [`stamp_root_tenant`]. pub fn inject_tenant_on_create( fields: &mut BTreeMap, claims: Option<&Claims>, diff --git a/crates/schema-forge-dsl/src/warnings.rs b/crates/schema-forge-dsl/src/warnings.rs index f863362a..3679941e 100644 --- a/crates/schema-forge-dsl/src/warnings.rs +++ b/crates/schema-forge-dsl/src/warnings.rs @@ -43,7 +43,9 @@ pub fn empty_access_grants(source: &str) -> Vec { let Some(annotation) = annotation else { continue; }; - if !matches!(token.text.as_str(), "read" | "write" | "delete") { + if !matches!(token.text.as_str(), "read" | "write" | "delete") + || (annotation == "field_access" && token.text == "delete") + { continue; } let Some(rest) = tokens.get(index + 1..index + 4) else { diff --git a/docs/tenant-isolation.md b/docs/tenant-isolation.md index 30f20b65..9a5b9d44 100644 --- a/docs/tenant-isolation.md +++ b/docs/tenant-isolation.md @@ -4,7 +4,7 @@ Only schemas with `@tenant(root)` or `@tenant(parent: "...")` receive automatic A root row's identity defines its tenant. New root rows store `_tenant = id`; authorization and list scoping derive that value from `id` for existing roots too. Legacy roots with NULL or inconsistent `_tenant` metadata therefore remain accessible to their own members without exposing other roots or requiring a data rewrite. -A tenant-owned child must carry valid `_tenant` metadata. Generated Cedar policies deny reads, updates and deletes of missing or NULL child tenants for non-platform administrators. Apply regenerated policies when upgrading a deployment that persists generated Cedar files. The canonical query filter and PostgreSQL authorized counts exclude these unstamped child rows. +A tenant-owned child must carry valid `_tenant` metadata. Generated Cedar policies deny reads, updates and deletes of missing or NULL child tenants for non-platform administrators. The server regenerates these policies from registered schemas at startup. The canonical query filter and PostgreSQL authorized counts exclude these unstamped child rows. Tenant members cannot move rows by supplying `_tenant` in PUT or PATCH: the server removes that input and preserves stored ownership. Platform administrators can reassign child rows. Root identities remain immutable for every caller. From d703c1fca9409b03be440955d6deb0dc354ad050 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:49:55 -0600 Subject: [PATCH 15/77] feat: make schema migrations explicit and preserve renamed fields --- .Codex/plans/migration-safety.md | 9 + .github/workflows/mssql-integration.yml | 9 +- README.md | 2 + crates/schema-forge-acton/src/error.rs | 30 ++- .../schema-forge-acton/src/routes/schemas.rs | 75 ++++-- crates/schema-forge-acton/src/state.rs | 15 ++ crates/schema-forge-backend/src/error.rs | 6 + crates/schema-forge-backend/src/traits.rs | 6 + .../tests/support/migration_renames.rs | 70 ++++++ crates/schema-forge-cli/src/cli.rs | 4 + crates/schema-forge-cli/src/commands/apply.rs | 6 +- .../schema-forge-cli/src/commands/migrate.rs | 4 +- .../src/commands/schema_update.rs | 44 +++- crates/schema-forge-cli/src/commands/serve.rs | 17 ++ crates/schema-forge-cli/src/error.rs | 2 +- crates/schema-forge-core/src/migration.rs | 61 ++++- .../src/types/field_annotation.rs | 18 +- crates/schema-forge-dsl/src/parser.rs | 31 ++- crates/schema-forge-dsl/src/printer.rs | 6 +- .../schema-forge-dsl/tests/migration_hints.rs | 58 +++++ crates/schema-forge-mssql/src/backend.rs | 39 +++ crates/schema-forge-mssql/tests/sql_server.rs | 4 + crates/schema-forge-postgres/src/backend.rs | 93 ++++++++ crates/schema-forge-postgres/src/codegen.rs | 19 +- .../tests/migration_safety.rs | 223 ++++++++++++++++++ crates/schema-forge-surrealdb/src/backend.rs | 53 ++++- crates/schema-forge-surrealdb/src/codegen.rs | 29 +++ .../tests/migration_renames.rs | 10 + docs/migrations/safe-schema-changes.md | 142 +++++++++++ 29 files changed, 1002 insertions(+), 83 deletions(-) create mode 100644 .Codex/plans/migration-safety.md create mode 100644 crates/schema-forge-backend/tests/support/migration_renames.rs create mode 100644 crates/schema-forge-dsl/tests/migration_hints.rs create mode 100644 crates/schema-forge-postgres/tests/migration_safety.rs create mode 100644 crates/schema-forge-surrealdb/tests/migration_renames.rs create mode 100644 docs/migrations/safe-schema-changes.md diff --git a/.Codex/plans/migration-safety.md b/.Codex/plans/migration-safety.md new file mode 100644 index 00000000..d3422903 --- /dev/null +++ b/.Codex/plans/migration-safety.md @@ -0,0 +1,9 @@ +# Migration safety (#167, #174, #175, #176) + +1. Centralize validation of tenant transitions and rename hints in DiffEngine. Reject tenant annotation transitions before any DDL or metadata write, requiring a manual migration/backfill and matching metadata. Preserve backend integrity until automatic backfill is available. +2. Add typed FieldAnnotation::RenamedFrom with DSL parsing and display, retained in signed serialized schemas. Collect active hints centrally, validate missing/ambiguous/conflicting sources, and retain no-op hints on repeat apply. +3. Preflight serve migrations before executing user schema changes; require explicit allow-destructive-migrations opt-in. Gate runtime schema PUT with explicit body opt-in and preserve existing schema annotations. +4. Reconcile PostgreSQL relation foreign keys consistently at schema persistence/startup checkpoints, after tables exist, using explicit restrictive delete behavior and idempotent checks. Validate existing data and report failures. Translate 23503 into typed conflict errors. +5. Reject empty PostgreSQL UPDATE fields before emitting SQL. + +Tests: DSL roundtrip and invalid rename hints, repeat apply, tenant add/remove/root-parent refusal, startup/runtime destructive refusals, PostgreSQL fresh/add/backfill/cyclic FK behavior and deletion/write error mapping; nextest and clippy, cross-backend compilation. No dependencies required. Public annotation and error variants warrant minor release; root owns version bump. diff --git a/.github/workflows/mssql-integration.yml b/.github/workflows/mssql-integration.yml index fd732954..9fcd375a 100644 --- a/.github/workflows/mssql-integration.yml +++ b/.github/workflows/mssql-integration.yml @@ -4,6 +4,8 @@ on: pull_request: paths: - "crates/schema-forge-mssql/**" + - "crates/schema-forge-core/**" + - "crates/schema-forge-backend/tests/support/migration_renames.rs" - "crates/schema-forge-cli/Cargo.toml" - "crates/schema-forge-acton/Cargo.toml" - "Cargo.lock" @@ -84,9 +86,12 @@ jobs: - name: Rust cache uses: Swatinem/rust-cache@v2 + - name: Install nextest + run: cargo install cargo-nextest --locked + - name: Run SQL Server integration test env: TEST_NAME: ${{ matrix.test }} run: >- - cargo test --package schema-forge-mssql --test sql_server - "$TEST_NAME" -- --ignored --exact --nocapture + cargo nextest run --package schema-forge-mssql --test sql_server + --run-ignored only -E "test(=$TEST_NAME)" diff --git a/README.md b/README.md index 87fcb720..58215e50 100644 --- a/README.md +++ b/README.md @@ -1054,3 +1054,5 @@ See the project repository for license information. ### Audit browsing and verification Platform administrators can browse recorded audit events and verify bounded chain ranges through the [audit API](docs/audit-api-reference.md). Access is deployment-wide, uses the active framework audit store, and reports collection limits separately from local chain consistency. Available in v0.42.0. + +See [safe schema changes](docs/migrations/safe-schema-changes.md) for declared field renames, destructive migration opt-ins, PostgreSQL relation integrity, and explicit tenancy migrations. diff --git a/crates/schema-forge-acton/src/error.rs b/crates/schema-forge-acton/src/error.rs index d2c06d88..e7bcf871 100644 --- a/crates/schema-forge-acton/src/error.rs +++ b/crates/schema-forge-acton/src/error.rs @@ -30,6 +30,8 @@ pub enum ForgeError { /// with a structured body carrying the offending schema + field so the /// client can surface an inline form error. UniqueViolation { schema: String, field: String }, + /// A relation cannot be written or removed without violating integrity. + ForeignKeyViolation { schema: String, constraint: String }, /// Request body failed validation. Maps to 422. ValidationFailed { details: Vec }, /// Invalid schema name (not PascalCase). Maps to 400. @@ -79,6 +81,10 @@ impl fmt::Display for ForgeError { Self::Conflict { reason, message } => { write!(f, "conflict ({reason}): {message}") } + Self::ForeignKeyViolation { schema, constraint } => write!( + f, + "relation constraint '{constraint}' violated in schema '{schema}'" + ), Self::UniqueViolation { schema, field } => { write!( f, @@ -143,7 +149,8 @@ impl ForgeError { Self::SchemaNotFound { .. } | Self::EntityNotFound { .. } => StatusCode::NOT_FOUND, Self::SchemaAlreadyExists { .. } | Self::Conflict { .. } - | Self::UniqueViolation { .. } => StatusCode::CONFLICT, + | Self::UniqueViolation { .. } + | Self::ForeignKeyViolation { .. } => StatusCode::CONFLICT, Self::ValidationFailed { .. } => StatusCode::UNPROCESSABLE_ENTITY, Self::InvalidSchemaName { .. } | Self::InvalidEntityId { .. } @@ -167,6 +174,7 @@ impl ForgeError { Self::EntityNotFound { .. } => "entity_not_found", Self::SchemaAlreadyExists { .. } => "schema_already_exists", Self::Conflict { .. } => "conflict", + Self::ForeignKeyViolation { .. } => "foreign_key_violation", Self::UniqueViolation { .. } => "unique_violation", Self::ValidationFailed { .. } => "validation_failed", Self::InvalidSchemaName { .. } => "invalid_schema_name", @@ -194,6 +202,9 @@ impl IntoResponse for ForgeError { "reason": reason, "message": message, }), + Self::ForeignKeyViolation { schema, constraint } => { + serde_json::json!({ "schema": schema, "constraint": constraint }) + } Self::UniqueViolation { schema, field } => serde_json::json!({ "error": "unique_violation", "schema": schema, @@ -273,6 +284,9 @@ impl From for ForgeError { }, BackendError::ConnectionError { message } => Self::BackendUnavailable { message }, BackendError::QueryError { message } => Self::BackendUnavailable { message }, + BackendError::ForeignKeyViolation { schema, constraint } => { + Self::ForeignKeyViolation { schema, constraint } + } BackendError::UniqueViolation { schema, field } => { Self::UniqueViolation { schema, field } } @@ -603,12 +617,7 @@ mod tests { let response = err.into_response(); assert_eq!(response.status(), StatusCode::CONFLICT); - let bytes = response - .into_body() - .collect() - .await - .unwrap() - .to_bytes(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(json["error"], "conflict"); assert_eq!(json["reason"], "last_platform_admin"); @@ -624,12 +633,7 @@ mod tests { let response = err.into_response(); assert_eq!(response.status(), StatusCode::CONFLICT); - let bytes = response - .into_body() - .collect() - .await - .unwrap() - .to_bytes(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(json["error"], "unique_violation"); assert_eq!(json["schema"], "Contact"); diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index 1cd3ae92..2aa5ff5e 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -62,6 +62,22 @@ async fn pair_with_registry( batch.retain(|s| s.name.as_str() != target.name.as_str()); batch.push(target.clone()); + for field in &target.fields { + if let FieldType::Relation { + target: related, .. + } = &field.field_type + { + if !batch.iter().any(|schema| &schema.name == related) { + return Err(ForgeError::ValidationFailed { + details: vec![format!( + "relation '{}.{}' references missing schema '{related}'", + target.name, field.name + )], + }); + } + } + } + schema_forge_core::inverse_relations::pair_inverse_relations(&mut batch).map_err(|e| { ForgeError::ValidationFailed { details: vec![e.to_string()], @@ -113,7 +129,9 @@ async fn precheck_policy_bundle( principal_claims, ) .map_err(|e| ForgeError::ValidationFailed { - details: vec![format!("Cedar policy validation failed for proposed schema: {e}")], + details: vec![format!( + "Cedar policy validation failed for proposed schema: {e}" + )], })?; Ok(()) @@ -123,9 +141,11 @@ async fn precheck_policy_bundle( async fn fetch_policy_store( state: &AppState, ) -> Result, ForgeError> { - let forge = state.actor::().ok_or_else(|| ForgeError::Internal { - message: "ForgeActor not registered".into(), - })?; + let forge = state + .actor::() + .ok_or_else(|| ForgeError::Internal { + message: "ForgeActor not registered".into(), + })?; let (tx, rx) = oneshot::channel(); forge .send(crate::messages::GetPolicyStore { @@ -174,6 +194,9 @@ fn require_admin(claims: &Claims) -> Result<(), ForgeError> { /// Request body for creating a schema. #[derive(Debug, Deserialize)] pub struct CreateSchemaRequest { + /// Explicit acknowledgement that an update may drop stored data. + #[serde(default)] + pub allow_destructive_migrations: bool, /// The schema name (must be PascalCase). pub name: String, /// The field definitions. @@ -356,11 +379,11 @@ fn parse_map_field_type( obj: &serde_json::Map, ) -> Result { let data = obj.get("data").and_then(|d| d.as_object()); - let value_json = data - .and_then(|d| d.get("value")) - .ok_or_else(|| ForgeError::ValidationFailed { - details: vec!["Map field type requires a 'value' type in 'data'".to_string()], - })?; + let value_json = + data.and_then(|d| d.get("value")) + .ok_or_else(|| ForgeError::ValidationFailed { + details: vec!["Map field type requires a 'value' type in 'data'".to_string()], + })?; let value = parse_field_type(value_json)?; if let Some(key_json) = data.and_then(|d| d.get("key")) { @@ -577,11 +600,9 @@ pub async fn create_schema( reply: ReplyChannel::new(tx), }) .await; - ask_forge(rx) - .await? - .map_err(|err| ForgeError::Internal { - message: format!("Cedar policy recompile failed during schema insertion: {err}"), - })?; + ask_forge(rx).await?.map_err(|err| ForgeError::Internal { + message: format!("Cedar policy recompile failed during schema insertion: {err}"), + })?; // 9. Rebuild GraphQL schema // NOTE: GraphQL rebuild will be re-integrated when the graphql module @@ -761,7 +782,7 @@ pub async fn update_schema( old_schema.id.clone(), schema_name.clone(), fields, - Vec::::new(), + old_schema.annotations.clone(), ) .map_err(|e| ForgeError::ValidationFailed { details: vec![e.to_string()], @@ -777,7 +798,15 @@ pub async fn update_schema( precheck_policy_bundle(&state, &forge, &new_definition, false).await?; // 5. Compute diff and generate migration plan + DiffEngine::validate_transition(&old_schema, &new_definition).map_err(|error| { + ForgeError::ValidationFailed { + details: vec![error.to_string()], + } + })?; let plan = DiffEngine::diff(&old_schema, &new_definition); + if plan.has_destructive_steps() && !body.allow_destructive_migrations { + return Err(ForgeError::ValidationFailed { details: vec![format!("destructive schema update refused: {}; set allow_destructive_migrations=true to acknowledge data loss", plan.steps.iter().map(ToString::to_string).collect::>().join("; "))] }); + } // 6. Apply migration steps via actor let step_count = plan.steps.len(); @@ -812,11 +841,9 @@ pub async fn update_schema( reply: ReplyChannel::new(tx), }) .await; - ask_forge(rx) - .await? - .map_err(|err| ForgeError::Internal { - message: format!("Cedar policy recompile failed during schema update: {err}"), - })?; + ask_forge(rx).await?.map_err(|err| ForgeError::Internal { + message: format!("Cedar policy recompile failed during schema update: {err}"), + })?; // 9. Rebuild GraphQL schema // NOTE: GraphQL rebuild will be re-integrated when the graphql module @@ -890,11 +917,9 @@ pub async fn delete_schema( reply: ReplyChannel::new(tx), }) .await; - ask_forge(rx) - .await? - .map_err(|err| ForgeError::Internal { - message: format!("Cedar policy recompile failed during schema deletion: {err}"), - })?; + ask_forge(rx).await?.map_err(|err| ForgeError::Internal { + message: format!("Cedar policy recompile failed during schema deletion: {err}"), + })?; // 3. Rebuild GraphQL schema // NOTE: GraphQL rebuild will be re-integrated when the graphql module diff --git a/crates/schema-forge-acton/src/state.rs b/crates/schema-forge-acton/src/state.rs index 96cd3947..c135127e 100644 --- a/crates/schema-forge-acton/src/state.rs +++ b/crates/schema-forge-acton/src/state.rs @@ -27,6 +27,13 @@ use tokio::sync::RwLock; /// RPITIT traits cannot be used as `dyn Trait`. This wrapper uses boxed futures /// to enable dynamic dispatch for HTTP handler state. pub trait DynSchemaBackend: Send + Sync { + /// Finish cross-schema constraints after explicit schema administration. + fn finalize_schema_migrations( + &self, + ) -> Pin> + Send + Sync + '_>> { + Box::pin(async { Ok(()) }) + } + /// Whether this adapter can explicitly prepare record revisions. fn supports_record_revisions(&self) -> bool { false @@ -73,6 +80,14 @@ pub trait DynSchemaBackend: Send + Sync { /// Blanket impl: any concrete `SchemaBackend` automatically implements `DynSchemaBackend`. impl DynSchemaBackend for T { + fn finalize_schema_migrations( + &self, + ) -> Pin> + Send + Sync + '_>> { + Box::pin(SyncFuture::new(SchemaBackend::finalize_schema_migrations( + self, + ))) + } + fn supports_record_revisions(&self) -> bool { SchemaBackend::supports_record_revisions(self) } diff --git a/crates/schema-forge-backend/src/error.rs b/crates/schema-forge-backend/src/error.rs index 0675fe08..f9c7d35d 100644 --- a/crates/schema-forge-backend/src/error.rs +++ b/crates/schema-forge-backend/src/error.rs @@ -33,6 +33,8 @@ pub enum BackendError { /// field name when the backend reported a recognisable constraint; /// otherwise it falls back to the literal constraint name. UniqueViolation { schema: String, field: String }, + /// A relation references a missing record or prevents removal of a referenced record. + ForeignKeyViolation { schema: String, constraint: String }, /// Internal or unexpected error. Internal { message: String }, } @@ -74,6 +76,10 @@ impl fmt::Display for BackendError { Self::QueryError { message } => { write!(f, "query execution error: {message}") } + Self::ForeignKeyViolation { schema, constraint } => write!( + f, + "relation constraint '{constraint}' violated in schema '{schema}'" + ), Self::UniqueViolation { schema, field } => { write!( f, diff --git a/crates/schema-forge-backend/src/traits.rs b/crates/schema-forge-backend/src/traits.rs index 9d9a07da..464267fc 100644 --- a/crates/schema-forge-backend/src/traits.rs +++ b/crates/schema-forge-backend/src/traits.rs @@ -16,6 +16,12 @@ use crate::error::BackendError; /// Uses RPITIT (return position impl Trait in trait) for async methods, /// avoiding the `async-trait` crate. pub trait SchemaBackend: Send + Sync { + /// Finish cross-schema constraints after applying a batch, including legacy repair. + /// Called only during explicit schema administration, never on a read connection. + fn finalize_schema_migrations(&self) -> impl Future> + Send { + async { Ok(()) } + } + /// Whether explicit preparation of record revisions is available. fn supports_record_revisions(&self) -> bool { false diff --git a/crates/schema-forge-backend/tests/support/migration_renames.rs b/crates/schema-forge-backend/tests/support/migration_renames.rs new file mode 100644 index 00000000..4fd2f490 --- /dev/null +++ b/crates/schema-forge-backend/tests/support/migration_renames.rs @@ -0,0 +1,70 @@ +use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; +use schema_forge_core::{ + migration::DiffEngine, + types::{ + DynamicValue, FieldAnnotation, FieldDefinition, FieldModifier, FieldName, FieldType, + SchemaDefinition, SchemaId, SchemaName, TextConstraints, + }, +}; +use std::collections::BTreeMap; + +pub async fn exercise(backend: &B) { + let old = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("RenameProbe").unwrap(), + vec![ + FieldDefinition::with_modifiers( + FieldName::new("number").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + vec![ + FieldModifier::Required, + FieldModifier::Unique, + FieldModifier::Indexed, + ], + ), + FieldDefinition::new(FieldName::new("payload").unwrap(), FieldType::Json), + ], + vec![], + ) + .unwrap(); + backend + .apply_migration(&old.name, &DiffEngine::create_new(&old).steps) + .await + .unwrap(); + backend.store_schema_metadata(&old).await.unwrap(); + let nested = + DynamicValue::Json(serde_json::json!({"nested": [null, false, 12, {"quoted": "a\"b"}]})); + let row = backend + .create(&Entity::new( + old.name.clone(), + BTreeMap::from([ + ("number".into(), DynamicValue::Text("555-9999".into())), + ("payload".into(), nested.clone()), + ]), + )) + .await + .unwrap(); + let mut new = old.clone(); + for (field, target) in new.fields.iter_mut().zip(["business_number", "document"]) { + field.annotations.push(FieldAnnotation::RenamedFrom { + name: field.name.clone(), + }); + field.name = FieldName::new(target).unwrap(); + } + DiffEngine::validate_transition(&old, &new).unwrap(); + backend + .apply_migration(&new.name, &DiffEngine::diff(&old, &new).steps) + .await + .unwrap(); + backend.store_schema_metadata(&new).await.unwrap(); + let loaded = backend.get(&new.name, &row.id).await.unwrap(); + assert_eq!( + loaded.fields.get("business_number"), + Some(&DynamicValue::Text("555-9999".into())) + ); + assert_eq!(loaded.fields.get("document"), Some(&nested)); + assert!(!loaded.fields.contains_key("number")); + assert!(!loaded.fields.contains_key("payload")); + assert!(DiffEngine::diff(&new, &new).is_empty()); + backend.store_schema_metadata(&new).await.unwrap(); +} diff --git a/crates/schema-forge-cli/src/cli.rs b/crates/schema-forge-cli/src/cli.rs index 4e977b32..b38101fc 100644 --- a/crates/schema-forge-cli/src/cli.rs +++ b/crates/schema-forge-cli/src/cli.rs @@ -706,6 +706,10 @@ pub struct MigrateArgs { /// Arguments for `schema-forge serve`. #[derive(Args)] pub struct ServeArgs { + /// Allow startup migrations that drop fields or data; use @renamed_from for renames. + #[arg(long)] + pub allow_destructive_migrations: bool, + /// Host address to bind #[arg(short = 'H', long = "host", default_value = "127.0.0.1")] pub host: IpAddr, diff --git a/crates/schema-forge-cli/src/commands/apply.rs b/crates/schema-forge-cli/src/commands/apply.rs index 921696f6..08906566 100644 --- a/crates/schema-forge-cli/src/commands/apply.rs +++ b/crates/schema-forge-cli/src/commands/apply.rs @@ -45,7 +45,7 @@ pub(super) async fn apply_to_backend( for schema in schemas { let existing = backend.load_schema_metadata(&schema.name).await?; - let update = SchemaUpdate::plan(existing.as_ref(), schema); + let update = SchemaUpdate::plan(existing.as_ref(), schema)?; let plan = &update.migration; if update.is_empty() { output.status(&format!(" {} .... no changes", schema.name.as_str())); @@ -139,6 +139,10 @@ pub(super) async fn apply_to_backend( applied_schemas += 1; } + if !args.dry_run { + backend.finalize_schema_migrations().await?; + } + // Generate policies if requested if args.with_policies && !args.dry_run { for schema in schemas { diff --git a/crates/schema-forge-cli/src/commands/migrate.rs b/crates/schema-forge-cli/src/commands/migrate.rs index c8a5d25a..7c1e09fd 100644 --- a/crates/schema-forge-cli/src/commands/migrate.rs +++ b/crates/schema-forge-cli/src/commands/migrate.rs @@ -45,7 +45,7 @@ pub(super) async fn migrate_on_backend( } let existing = backend.load_schema_metadata(&schema.name).await?; - let update = SchemaUpdate::plan(existing.as_ref(), schema); + let update = SchemaUpdate::plan(existing.as_ref(), schema)?; let plan = &update.migration; if update.is_empty() { @@ -178,6 +178,8 @@ pub(super) async fn migrate_on_backend( update.persist(backend).await?; } + backend.finalize_schema_migrations().await?; + output.success(&format!( "Executed {total_steps} migration steps across {schemas_affected} schemas." )); diff --git a/crates/schema-forge-cli/src/commands/schema_update.rs b/crates/schema-forge-cli/src/commands/schema_update.rs index 95528b88..f75275db 100644 --- a/crates/schema-forge-cli/src/commands/schema_update.rs +++ b/crates/schema-forge-cli/src/commands/schema_update.rs @@ -16,9 +16,17 @@ pub(super) struct SchemaUpdate { impl SchemaUpdate { /// Preserve stored identity when comparing freshly parsed definitions. - pub fn plan(existing: Option<&SchemaDefinition>, desired: &SchemaDefinition) -> Self { + pub fn plan( + existing: Option<&SchemaDefinition>, + desired: &SchemaDefinition, + ) -> Result { let mut schema = desired.clone(); if let Some(existing) = existing { + DiffEngine::validate_transition(existing, desired).map_err(|error| { + CliError::Config { + message: error.to_string(), + } + })?; schema.id = existing.id.clone(); } let metadata_changed = existing != Some(&schema); @@ -26,11 +34,11 @@ impl SchemaUpdate { || DiffEngine::create_new(&schema), |existing| DiffEngine::diff(existing, &schema), ); - Self { + Ok(Self { schema, migration, metadata_changed, - } + }) } pub fn is_empty(&self) -> bool { @@ -222,6 +230,36 @@ mod tests { } } + #[tokio::test] + async fn tenancy_changes_never_write_even_with_force() { + for force in [false, true] { + for dry_run in [false, true] { + let original = schema("schema Contact { phone: text unique }"); + let backend = Backend::seeded(original.clone()); + let result = super::super::apply::apply_to_backend( + &ApplyArgs { + paths: vec![], + force, + dry_run, + with_policies: false, + prepare_record_revisions: false, + }, + &[schema( + r#"@tenant(parent: "Org") schema Contact { phone: text unique }"#, + )], + &backend, + &output(), + ) + .await; + assert!(matches!(result, Err(CliError::Config { .. }))); + let stored = backend.stored.lock().unwrap(); + assert_eq!(stored.migrations, 0); + assert_eq!(stored.writes, 0); + assert_eq!(stored.schema.as_ref(), Some(&original)); + } + } + } + const ORIGINAL: &str = "@version(1) schema Person { age: integer }"; const METADATA_CHANGES: [&str; 3] = [ "@version(2) schema Person { age: integer }", diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index 10a6fca8..904e0d61 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -146,6 +146,7 @@ pub async fn run( let mut registry = init_data.registry; if !schemas.is_empty() { output.status("Applying schemas..."); + let mut plans = Vec::new(); for schema in &schemas { let existing = backend_arc .load_schema_metadata(&schema.name) @@ -153,11 +154,22 @@ pub async fn run( .map_err(CliError::Backend)?; let plan = if let Some(old) = existing { + DiffEngine::validate_transition(&old, schema).map_err(|error| { + CliError::Config { + message: error.to_string(), + } + })?; DiffEngine::diff(&old, schema) } else { DiffEngine::create_new(schema) }; + if plan.has_destructive_steps() && !args.allow_destructive_migrations { + return Err(CliError::Config { message: format!("schema '{}': destructive startup migration refused: {}. Declare field renames with @renamed_from(\"old_name\") or explicitly pass --allow-destructive-migrations", schema.name, plan.steps.iter().map(ToString::to_string).collect::>().join("; ")) }); + } + plans.push(plan); + } + for (schema, plan) in schemas.iter().zip(plans) { if !plan.is_empty() { backend_arc .apply_migration(&schema.name, &plan.steps) @@ -178,6 +190,11 @@ pub async fn run( } } + backend_arc + .finalize_schema_migrations() + .await + .map_err(CliError::Backend)?; + // Rebuild tenant config after applying parsed schemas let all_schemas: Vec<_> = registry.values().cloned().collect(); let tenant_config = schema_forge_backend::tenant::TenantConfig::from_schemas(&all_schemas) diff --git a/crates/schema-forge-cli/src/error.rs b/crates/schema-forge-cli/src/error.rs index 03777348..c079a28d 100644 --- a/crates/schema-forge-cli/src/error.rs +++ b/crates/schema-forge-cli/src/error.rs @@ -81,7 +81,7 @@ pub enum CliError { DirectoryExists { path: PathBuf }, /// Non-TTY requires --force for destructive operations. - #[error("destructive changes require --force in non-interactive mode")] + #[error("destructive changes require --force in non-interactive mode; for field renames declare @renamed_from(\"old_name\") to preserve data")] RequiresForce, /// HTTP server errors. diff --git a/crates/schema-forge-core/src/migration.rs b/crates/schema-forge-core/src/migration.rs index 6ffa947e..0f6acd34 100644 --- a/crates/schema-forge-core/src/migration.rs +++ b/crates/schema-forge-core/src/migration.rs @@ -452,6 +452,44 @@ impl fmt::Display for MigrationPlan { pub struct DiffEngine; impl DiffEngine { + /// Validate transitions that cannot safely be inferred from field differences. + /// Tenant changes require a manual data backfill and constraint migration. + pub fn validate_transition( + old: &crate::types::SchemaDefinition, + new: &crate::types::SchemaDefinition, + ) -> Result<(), MigrationError> { + let tenancy = |schema: &crate::types::SchemaDefinition| { + schema.annotations.iter().find_map(|annotation| { + if let Annotation::Tenant(kind) = annotation { + Some(kind.clone()) + } else { + None + } + }) + }; + if tenancy(old) != tenancy(new) { + return Err(MigrationError::ManualMigrationRequired { reason: format!("schema '{}': changing @tenant requires a manual migration of _tenant, tenant backfill, unique constraints, and stored schema metadata; automatic migration refused", new.name) }); + } + let mut sources = std::collections::HashSet::new(); + for field in &new.fields { + for annotation in &field.annotations { + if let crate::types::FieldAnnotation::RenamedFrom { name } = annotation { + let source_exists = old.field(name.as_str()).is_some(); + let target_exists = old.field(field.name.as_str()).is_some(); + if name == &field.name + || !sources.insert(name) + || new.field(name.as_str()).is_some() + || (source_exists && target_exists) + || (!source_exists && !target_exists) + { + return Err(MigrationError::ManualMigrationRequired { reason: format!("invalid @renamed_from(\"{name}\") on '{}': source must identify one removed field and target one new field (or an already completed rename)", field.name) }); + } + } + } + } + Ok(()) + } + /// Compare two schema definitions and produce a migration plan. /// /// This is a pure function: no I/O, no side effects. @@ -460,7 +498,25 @@ impl DiffEngine { old: &crate::types::SchemaDefinition, new: &crate::types::SchemaDefinition, ) -> MigrationPlan { - Self::diff_with_renames(old, new, &[]) + let renames: Vec<_> = new + .fields + .iter() + .flat_map(|field| { + field + .annotations + .iter() + .filter_map(|annotation| match annotation { + crate::types::FieldAnnotation::RenamedFrom { name } + if old.field(name.as_str()).is_some() + && old.field(field.name.as_str()).is_none() => + { + Some((name.clone(), field.name.clone())) + } + _ => None, + }) + }) + .collect(); + Self::diff_with_renames(old, new, &renames) } /// Compare two schema definitions with explicit rename hints. @@ -900,6 +956,8 @@ impl DiffEngine { #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum MigrationError { + /// This transition requires an explicit manual migration. + ManualMigrationRequired { reason: String }, /// The migration ID string could not be parsed. InvalidMigrationId(String), /// Attempted to apply a destructive migration without confirmation. @@ -921,6 +979,7 @@ pub enum MigrationError { impl fmt::Display for MigrationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::ManualMigrationRequired { reason } => write!(f, "{reason}"), Self::InvalidMigrationId(s) => { write!(f, "invalid migration id: {s}") } diff --git a/crates/schema-forge-core/src/types/field_annotation.rs b/crates/schema-forge-core/src/types/field_annotation.rs index ef0a7d4f..2af3a860 100644 --- a/crates/schema-forge-core/src/types/field_annotation.rs +++ b/crates/schema-forge-core/src/types/field_annotation.rs @@ -526,6 +526,8 @@ impl fmt::Display for ExportFlatten { #[serde(tag = "annotation")] #[non_exhaustive] pub enum FieldAnnotation { + /// Previous physical field name, retained as an idempotent migration hint. + RenamedFrom { name: super::FieldName }, /// `@field_access(...)` -- role-based access control on a specific field. FieldAccess { read: Vec, @@ -544,9 +546,7 @@ pub enum FieldAnnotation { /// responsible for ensuring every key names a valid variant of the /// enum field the annotation is attached to. Missing keys render with /// the default neutral badge. - EnumColors { - colors: BTreeMap, - }, + EnumColors { colors: BTreeMap }, /// `@list(primary|column|hidden)` -- controls whether the field /// appears in the generated list view and, for `primary`, marks it /// as the headline cell rendered with display styling. @@ -593,6 +593,7 @@ impl FieldAnnotation { /// Returns the annotation kind as a string, for dedup checking. pub fn kind(&self) -> &'static str { match self { + Self::RenamedFrom { .. } => "renamed_from", Self::FieldAccess { .. } => "field_access", Self::Owner => "owner", Self::Widget { .. } => "widget", @@ -612,6 +613,7 @@ impl FieldAnnotation { impl fmt::Display for FieldAnnotation { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::RenamedFrom { name } => write!(f, "@renamed_from(\"{name}\")"), Self::FieldAccess { read, write } => { write!( f, @@ -1039,7 +1041,10 @@ mod tests { #[test] fn export_flatten_str_roundtrip() { - assert_eq!("json".parse::().unwrap(), ExportFlatten::Json); + assert_eq!( + "json".parse::().unwrap(), + ExportFlatten::Json + ); assert_eq!(ExportFlatten::Json.to_string(), "json"); assert!("xml".parse::().is_err()); } @@ -1262,7 +1267,10 @@ mod tests { expr: "age >= 18".to_string(), message: "must be 18 or older".to_string(), }; - assert_eq!(a.to_string(), "@require(\"age >= 18\", \"must be 18 or older\")"); + assert_eq!( + a.to_string(), + "@require(\"age >= 18\", \"must be 18 or older\")" + ); } #[test] diff --git a/crates/schema-forge-dsl/src/parser.rs b/crates/schema-forge-dsl/src/parser.rs index 294c33a4..204d45c6 100644 --- a/crates/schema-forge-dsl/src/parser.rs +++ b/crates/schema-forge-dsl/src/parser.rs @@ -807,6 +807,19 @@ impl Parser { self.expect(&Token::At)?; let name_tok = self.expect_ident("field annotation name")?; match name_tok.text.as_str() { + "renamed_from" => { + self.expect(&Token::LParen)?; + let value = self.expect_string_literal()?; + let name = FieldName::new(unquote_string(&value.text)).map_err(|error| { + DslError::UnexpectedToken { + expected: "valid previous field name".into(), + found: error.to_string(), + span: value.span, + } + })?; + self.expect(&Token::RParen)?; + Ok(FieldAnnotation::RenamedFrom { name }) + } "owner" => Ok(FieldAnnotation::Owner), "hidden" => Ok(FieldAnnotation::Hidden), "field_access" => { @@ -915,14 +928,13 @@ impl Parser { } self.expect(&Token::Colon)?; let hint_tok = self.expect_ident("flatten hint (json)")?; - let flatten = - ExportFlatten::from_str(&hint_tok.text).map_err(|()| { - DslError::UnknownExportFlatten { - value: hint_tok.text.clone(), - valid: VALID_EXPORT_FLATTEN, - span: hint_tok.span.clone(), - } - })?; + let flatten = ExportFlatten::from_str(&hint_tok.text).map_err(|()| { + DslError::UnknownExportFlatten { + value: hint_tok.text.clone(), + valid: VALID_EXPORT_FLATTEN, + span: hint_tok.span.clone(), + } + })?; self.expect(&Token::RParen)?; Ok(FieldAnnotation::Exportable { flatten: Some(flatten), @@ -1481,7 +1493,8 @@ impl Parser { // type-check pass keys on top-level schema field names). let mut composite_rule_sites: Vec = Vec::new(); let mut composite_exportable_sites: Vec = Vec::new(); - let fields = self.parse_fields(&mut composite_rule_sites, &mut composite_exportable_sites)?; + let fields = + self.parse_fields(&mut composite_rule_sites, &mut composite_exportable_sites)?; // `@exportable` is a top-level field opt-in; a composite sub-field cannot // carry it (the whole composite is exported as a single value). Reject it diff --git a/crates/schema-forge-dsl/src/printer.rs b/crates/schema-forge-dsl/src/printer.rs index baa1debd..cd298652 100644 --- a/crates/schema-forge-dsl/src/printer.rs +++ b/crates/schema-forge-dsl/src/printer.rs @@ -389,7 +389,8 @@ fn print_field_annotation(annotation: &FieldAnnotation, output: &mut String) { output.push(')'); } FieldAnnotation::Hidden => output.push_str("@hidden"), - FieldAnnotation::Require { .. } + FieldAnnotation::RenamedFrom { .. } + | FieldAnnotation::Require { .. } | FieldAnnotation::Compute { .. } | FieldAnnotation::Default { .. } | FieldAnnotation::Exportable { .. } => { @@ -1247,8 +1248,7 @@ schema S { let reparsed = crate::parser::parse(&printed).unwrap(); for i in 0..3 { assert_eq!( - parsed[0].fields[i].annotations, - reparsed[0].fields[i].annotations, + parsed[0].fields[i].annotations, reparsed[0].fields[i].annotations, "annotation mismatch on field {i}" ); } diff --git a/crates/schema-forge-dsl/tests/migration_hints.rs b/crates/schema-forge-dsl/tests/migration_hints.rs new file mode 100644 index 00000000..813aab98 --- /dev/null +++ b/crates/schema-forge-dsl/tests/migration_hints.rs @@ -0,0 +1,58 @@ +use schema_forge_core::migration::{DiffEngine, MigrationStep}; + +fn schema(source: &str) -> schema_forge_core::types::SchemaDefinition { + schema_forge_dsl::parse(source).unwrap().remove(0) +} + +#[test] +fn declared_rename_preserves_data_plan_and_becomes_noop() { + let old = schema("schema Line { number: text required unique }"); + let new = + schema(r#"schema Line { business_number: text required unique @renamed_from("number") }"#); + DiffEngine::validate_transition(&old, &new).unwrap(); + let plan = DiffEngine::diff(&old, &new); + assert!(matches!( + plan.steps.as_slice(), + [MigrationStep::RenameField { .. }] + )); + DiffEngine::validate_transition(&new, &new).unwrap(); + assert!(DiffEngine::diff(&new, &new).is_empty()); + let printed = schema_forge_dsl::print_all(std::slice::from_ref(&new)); + assert_eq!(schema(&printed).fields, new.fields); +} + +#[test] +fn invalid_rename_hints_fail_before_migration() { + let old = schema("schema Line { number: text other: text }"); + for source in [ + r#"schema Line { business_number: text @renamed_from("missing") }"#, + r#"schema Line { number: text @renamed_from("number") }"#, + r#"schema Line { number: text other: text @renamed_from("number") }"#, + r#"schema Line { first: text @renamed_from("number") second: text @renamed_from("number") }"#, + ] { + assert!( + DiffEngine::validate_transition(&old, &schema(source)).is_err(), + "{source}" + ); + } +} + +#[test] +fn all_tenant_transitions_require_explicit_manual_migration() { + let annotations = [ + "", + "@tenant(root)", + r#"@tenant(parent: "Org")"#, + r#"@tenant(parent: "Other")"#, + ]; + for old in annotations { + for new in annotations { + let old_schema = schema(&format!("{old} schema Contact {{ phone: text unique }}")); + let new_schema = schema(&format!("{new} schema Contact {{ phone: text unique }}")); + assert_eq!( + DiffEngine::validate_transition(&old_schema, &new_schema).is_ok(), + old == new + ); + } + } +} diff --git a/crates/schema-forge-mssql/src/backend.rs b/crates/schema-forge-mssql/src/backend.rs index 21fdb43c..3d10528e 100644 --- a/crates/schema-forge-mssql/src/backend.rs +++ b/crates/schema-forge-mssql/src/backend.rs @@ -102,6 +102,38 @@ impl SchemaBackend for MssqlBackend { } let mut connection = connection(&self.pool).await?; for step in steps { + if let MigrationStep::RenameField { old_name, new_name } = step { + // DynamicValue encodes every value as an object, including arrays and null. + // JSON_QUERY preserves that object rather than escaping it into a string. + let old_path = format!("$.\"{old_name}\""); + let new_path = format!("$.\"{new_name}\""); + let sql = format!( + r#"SET XACT_ABORT ON; + BEGIN TRY + BEGIN TRANSACTION; + IF EXISTS (SELECT 1 FROM [dbo].{table} WITH (UPDLOCK, HOLDLOCK) + WHERE JSON_QUERY([data], @P1) IS NOT NULL AND JSON_QUERY([data], @P2) IS NOT NULL) + THROW 50001, 'rename destination already contains data', 1; + UPDATE [dbo].{table} + SET [data] = JSON_MODIFY(JSON_MODIFY([data], @P2, JSON_QUERY([data], @P1)), @P1, NULL) + WHERE JSON_QUERY([data], @P1) IS NOT NULL; + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; + THROW; + END CATCH;"#, + table = quote(schema_name.as_str()) + ); + connection + .execute(&sql, &[&old_path.as_str(), &new_path.as_str()]) + .await + .map_err(|error| BackendError::MigrationFailed { + step: step.to_string(), + reason: error.to_string(), + })?; + continue; + } if let MigrationStep::ChangeType { name, transform: ValueTransform::NullRemovedEnumVariants { variants }, @@ -156,6 +188,13 @@ impl SchemaBackend for MssqlBackend { &self, definition: &SchemaDefinition, ) -> Result<(), BackendError> { + if let Some(existing) = self.load_schema_metadata(&definition.name).await? { + schema_forge_core::migration::DiffEngine::validate_transition(&existing, definition) + .map_err(|error| BackendError::MigrationFailed { + step: "validate schema transition".into(), + reason: error.to_string(), + })?; + } let json = serde_json::to_string(definition).map_err(json_error)?; let sql = format!( "MERGE [dbo].[{METADATA}] AS target \ diff --git a/crates/schema-forge-mssql/tests/sql_server.rs b/crates/schema-forge-mssql/tests/sql_server.rs index d59c578c..33a12082 100644 --- a/crates/schema-forge-mssql/tests/sql_server.rs +++ b/crates/schema-forge-mssql/tests/sql_server.rs @@ -1,3 +1,6 @@ +#[path = "../../schema-forge-backend/tests/support/migration_renames.rs"] +mod migration_renames; + #[path = "../../schema-forge-backend/tests/support/data_correctness.rs"] mod data_correctness; @@ -61,6 +64,7 @@ async fn connects_and_initializes_metadata(image_tag: &str) { exercises_backend_contract(&backend).await; data_correctness::exercise(&backend).await; + migration_renames::exercise(&backend).await; } async fn exercises_backend_contract(backend: &MssqlBackend) { diff --git a/crates/schema-forge-postgres/src/backend.rs b/crates/schema-forge-postgres/src/backend.rs index bce6c64d..1c63ba0a 100644 --- a/crates/schema-forge-postgres/src/backend.rs +++ b/crates/schema-forge-postgres/src/backend.rs @@ -38,6 +38,12 @@ const PG_UNIQUE_VIOLATION: &str = "23505"; /// client still has *something* actionable to display. pub(crate) fn map_write_error(err: sqlx::Error, schema: &str, context: &str) -> BackendError { if let sqlx::Error::Database(ref db_err) = err { + if db_err.code().as_deref() == Some("23503") { + return BackendError::ForeignKeyViolation { + schema: db_err.table().unwrap_or(schema).to_owned(), + constraint: db_err.constraint().unwrap_or("").to_owned(), + }; + } if db_err.code().as_deref() == Some(PG_UNIQUE_VIOLATION) { let constraint = db_err.constraint().unwrap_or(""); let field = extract_field_from_unique_constraint(constraint, schema) @@ -160,6 +166,74 @@ impl PgBackend { } } + /// Repair legacy missing relation constraints and finish deferred table creation. + /// All available tables are checked together so cycles and file order are harmless. + async fn reconcile_relation_constraints( + &self, + incoming: Option<&SchemaDefinition>, + ) -> Result<(), BackendError> { + let finalizing = incoming.is_none(); + let mut definitions = self.fetch_schema_metadata_from_db().await?; + if let Some(incoming) = incoming { + definitions.retain(|definition| definition.name != incoming.name); + definitions.push(incoming.clone()); + } + let mut tx = self + .pool + .begin() + .await + .map_err(|error| BackendError::ConnectionError { + message: error.to_string(), + })?; + for definition in &definitions { + for field in &definition.fields { + if let FieldType::Relation { + target, + cardinality: schema_forge_core::types::Cardinality::One, + } = &field.field_type + { + if field.is_derived() { + continue; + } + let table = definition.name.as_str(); + let column = field.name.as_str(); + // Names are validated SchemaName/FieldName values, never raw SQL input. + // Missing targets defer installation until that table's metadata is stored. + let statement = format!( + r#"DO $forge_fk$ + BEGIN + IF {finalizing} AND to_regclass('"{target}"') IS NULL THEN + RAISE EXCEPTION 'relation target {target} for {table}.{column} does not exist'; + END IF; + IF to_regclass('"{table}"') IS NOT NULL AND to_regclass('"{target}"') IS NOT NULL + AND EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = to_regclass('"{table}"') AND attname = '{column}' AND NOT attisdropped) + AND NOT EXISTS ( + SELECT 1 FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid + JOIN pg_attribute referenced ON referenced.attrelid = c.confrelid + WHERE c.contype = 'f' AND c.conrelid = to_regclass('"{table}"') + AND c.confrelid = to_regclass('"{target}"') AND a.attname = '{column}' + AND c.conkey = ARRAY[a.attnum] AND referenced.attname = 'id' + AND c.confkey = ARRAY[referenced.attnum] + ) THEN + ALTER TABLE "{table}" ADD FOREIGN KEY ("{column}") REFERENCES "{target}"("id") ON DELETE RESTRICT; + END IF; + END $forge_fk$;"# + ); + sqlx::query(&statement).execute(&mut *tx).await.map_err(|error| BackendError::MigrationFailed { + step: format!("ensure relation constraint {table}.{column} -> {target}"), + reason: format!("{error}; repair orphaned relation values before retrying the migration"), + })?; + } + } + } + tx.commit() + .await + .map_err(|error| BackendError::MigrationFailed { + step: "commit relation constraints".into(), + reason: error.to_string(), + }) + } + /// Drop the cached schema metadata so the next read refetches from /// the database. Called from every mutation path that changes the /// `_schema_metadata` table. @@ -253,6 +327,12 @@ impl PgBackend { entity: &Entity, schema_def: Option<&SchemaDefinition>, ) -> Result<(String, PgArguments), BackendError> { + if entity.fields.is_empty() { + return Err(BackendError::ValidationFailed { + field: "fields".into(), + reason: "update must contain at least one writable field".into(), + }); + } let table = entity.schema.as_str(); let mut args = PgArguments::default(); @@ -453,6 +533,10 @@ impl PgBackend { } impl SchemaBackend for PgBackend { + async fn finalize_schema_migrations(&self) -> Result<(), BackendError> { + self.reconcile_relation_constraints(None).await + } + fn supports_record_revisions(&self) -> bool { true } @@ -519,7 +603,16 @@ impl SchemaBackend for PgBackend { // float value. Detect and silently re-type any such columns to // DOUBLE PRECISION whenever the schema is (re)stored — this is // the canonical "schema is authoritative" checkpoint. See GH #37. + if let Some(existing) = self.load_schema_metadata(&definition.name).await? { + schema_forge_core::migration::DiffEngine::validate_transition(&existing, definition) + .map_err(|error| BackendError::MigrationFailed { + step: "validate schema transition".into(), + reason: error.to_string(), + })?; + } self.repair_float_columns(definition).await?; + self.reconcile_relation_constraints(Some(definition)) + .await?; let json = serde_json::to_value(definition).map_err(|e| BackendError::Internal { message: format!("failed to serialize schema metadata: {e}"), diff --git a/crates/schema-forge-postgres/src/codegen.rs b/crates/schema-forge-postgres/src/codegen.rs index 4aa349ec..7c260386 100644 --- a/crates/schema-forge-postgres/src/codegen.rs +++ b/crates/schema-forge-postgres/src/codegen.rs @@ -97,9 +97,11 @@ pub fn migration_step_to_sql(table: &str, step: &MigrationStep) -> Vec { )] } MigrationStep::RenameField { old_name, new_name } => { - vec![format!( - "ALTER TABLE \"{table}\" RENAME COLUMN \"{old_name}\" TO \"{new_name}\";" - )] + vec![ + format!("ALTER TABLE \"{table}\" RENAME COLUMN \"{old_name}\" TO \"{new_name}\";"), + format!("ALTER INDEX IF EXISTS \"uq_{table}_{old_name}\" RENAME TO \"uq_{table}_{new_name}\";"), + format!("ALTER INDEX IF EXISTS \"idx_{table}_{old_name}\" RENAME TO \"idx_{table}_{new_name}\";"), + ] } MigrationStep::ChangeType { name, @@ -162,12 +164,12 @@ pub fn migration_step_to_sql(table: &str, step: &MigrationStep) -> Vec { } MigrationStep::AddRelation { name, - target, + target: _, cardinality, } => match cardinality { Cardinality::One => { vec![format!( - "ALTER TABLE \"{table}\" ADD COLUMN IF NOT EXISTS \"{name}\" TEXT REFERENCES \"{target}\"(\"id\");" + "ALTER TABLE \"{table}\" ADD COLUMN IF NOT EXISTS \"{name}\" TEXT;" )] } Cardinality::Many => { @@ -177,7 +179,7 @@ pub fn migration_step_to_sql(table: &str, step: &MigrationStep) -> Vec { } _ => { vec![format!( - "ALTER TABLE \"{table}\" ADD COLUMN IF NOT EXISTS \"{name}\" TEXT REFERENCES \"{target}\"(\"id\");" + "ALTER TABLE \"{table}\" ADD COLUMN IF NOT EXISTS \"{name}\" TEXT;" )] } }, @@ -641,7 +643,7 @@ mod tests { new_name: FieldName::new("full_name").unwrap(), }; let stmts = migration_step_to_sql("Contact", &step); - assert_eq!(stmts.len(), 1); + assert_eq!(stmts.len(), 3); assert_eq!( stmts[0], "ALTER TABLE \"Contact\" RENAME COLUMN \"name\" TO \"full_name\";" @@ -678,7 +680,8 @@ mod tests { }; let stmts = migration_step_to_sql("Contact", &step); assert_eq!(stmts.len(), 1); - assert!(stmts[0].contains("\"company\" TEXT REFERENCES \"Company\"(\"id\")")); + assert!(stmts[0].contains("\"company\" TEXT;")); + assert!(!stmts[0].contains("REFERENCES")); } #[test] diff --git a/crates/schema-forge-postgres/tests/migration_safety.rs b/crates/schema-forge-postgres/tests/migration_safety.rs new file mode 100644 index 00000000..df7da8ae --- /dev/null +++ b/crates/schema-forge-postgres/tests/migration_safety.rs @@ -0,0 +1,223 @@ +//! Live checks for migration parity, legacy repair, and relation conflicts. +use schema_forge_backend::{BackendError, Entity, EntityStore, SchemaBackend}; +use schema_forge_core::{ + migration::DiffEngine, + types::{ + Cardinality, DynamicValue, EntityId, FieldDefinition, FieldName, FieldType, + SchemaDefinition, SchemaId, SchemaName, TextConstraints, + }, +}; +use schema_forge_postgres::PgBackend; +use sqlx::postgres::PgPoolOptions; +use std::{collections::BTreeMap, sync::Arc}; + +fn definition(name: &str, relation: Option<(&str, &str)>) -> SchemaDefinition { + let mut fields = vec![FieldDefinition::new( + FieldName::new("label").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + )]; + if let Some((field, target)) = relation { + fields.push(FieldDefinition::new( + FieldName::new(field).unwrap(), + FieldType::Relation { + target: SchemaName::new(target).unwrap(), + cardinality: Cardinality::One, + }, + )); + } + SchemaDefinition::new( + SchemaId::new(), + SchemaName::new(name).unwrap(), + fields, + vec![], + ) + .unwrap() +} + +async fn apply(backend: &PgBackend, definition: &SchemaDefinition) { + let old = backend + .load_schema_metadata(&definition.name) + .await + .unwrap(); + let plan = old.as_ref().map_or_else( + || DiffEngine::create_new(definition), + |old| DiffEngine::diff(old, definition), + ); + backend + .apply_migration(&definition.name, &plan.steps) + .await + .unwrap(); + backend.store_schema_metadata(definition).await.unwrap(); +} + +#[tokio::test] +#[ignore = "requires SCHEMAFORGE_TEST_POSTGRES_URL with CREATE SCHEMA privilege"] +async fn relations_have_consistent_integrity_and_legacy_constraints_are_repaired() { + let url = std::env::var("SCHEMAFORGE_TEST_POSTGRES_URL").unwrap(); + let admin = PgPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .unwrap(); + let namespace = EntityId::new("migrationtest").to_string(); + sqlx::query(&format!("CREATE SCHEMA \"{namespace}\"")) + .execute(&admin) + .await + .unwrap(); + let scope = namespace.clone(); + let pool = PgPoolOptions::new() + .max_connections(4) + .after_connect(move |connection, _| { + let scope = scope.clone(); + Box::pin(async move { + sqlx::query("SELECT set_config('search_path', $1, false)") + .bind(scope) + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect(&url) + .await + .unwrap(); + let backend = Arc::new(PgBackend::from_pool(pool.clone()).await.unwrap()); + let result = tokio::spawn(async move { exercise(&backend).await }).await; + pool.close().await; + sqlx::query(&format!("DROP SCHEMA \"{namespace}\" CASCADE")) + .execute(&admin) + .await + .unwrap(); + result.unwrap(); +} + +async fn exercise(backend: &PgBackend) { + // Pet precedes Owner, and both reference each other. + let mut pet = definition("Pet", Some(("owner", "Owner"))); + pet.fields[0] + .modifiers + .push(schema_forge_core::types::FieldModifier::Unique); + let owner = definition("Owner", Some(("pet", "Pet"))); + apply(backend, &pet).await; + apply(backend, &owner).await; + let owner_row = backend + .create(&Entity::new( + owner.name.clone(), + BTreeMap::from([("label".into(), DynamicValue::Text("owner".into()))]), + )) + .await + .unwrap(); + let pet_row = backend + .create(&Entity::new( + pet.name.clone(), + BTreeMap::from([ + ("owner".into(), DynamicValue::Ref(owner_row.id.clone())), + ("label".into(), DynamicValue::Text("kept".into())), + ]), + )) + .await + .unwrap(); + assert!(matches!( + backend.delete(&owner.name, &owner_row.id).await, + Err(BackendError::ForeignKeyViolation { .. }) + )); + let missing = Entity::new( + pet.name.clone(), + BTreeMap::from([("owner".into(), DynamicValue::Ref(EntityId::new("owner")))]), + ); + assert!(matches!( + backend.create(&missing).await, + Err(BackendError::ForeignKeyViolation { .. }) + )); + assert!(matches!( + backend + .update(&Entity::with_id( + pet_row.id.clone(), + pet.name.clone(), + BTreeMap::new() + )) + .await, + Err(BackendError::ValidationFailed { .. }) + )); + + // A later relation gets exactly the same integrity behavior. + let mut changed = pet.clone(); + changed.fields.push(FieldDefinition::new( + FieldName::new("vet").unwrap(), + FieldType::Relation { + target: owner.name.clone(), + cardinality: Cardinality::One, + }, + )); + apply(backend, &changed).await; + let patch = Entity::with_id( + pet_row.id.clone(), + pet.name.clone(), + BTreeMap::from([("vet".into(), DynamicValue::Ref(EntityId::new("owner")))]), + ); + assert!(matches!( + backend.update(&patch).await, + Err(BackendError::ForeignKeyViolation { .. }) + )); + + // Simulate legacy table without its original FK, then reconnect. + sqlx::query("ALTER TABLE \"Pet\" DROP CONSTRAINT \"Pet_owner_fkey\"") + .execute(backend.pool()) + .await + .unwrap(); + let repaired = PgBackend::from_pool(backend.pool().clone()).await.unwrap(); + repaired.finalize_schema_migrations().await.unwrap(); + assert!(matches!( + repaired.delete(&owner.name, &owner_row.id).await, + Err(BackendError::ForeignKeyViolation { .. }) + )); + // Reconciliation is idempotent. + apply(&repaired, &changed).await; + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_constraint WHERE conrelid='\"Pet\"'::regclass AND contype='f'", + ) + .fetch_one(backend.pool()) + .await + .unwrap(); + assert_eq!(count, 2); + + // Renaming a populated unique field preserves both its data and named constraints. + let mut renamed = changed.clone(); + renamed.fields[0].name = FieldName::new("title").unwrap(); + renamed.fields[0] + .annotations + .push(schema_forge_core::types::FieldAnnotation::RenamedFrom { + name: FieldName::new("label").unwrap(), + }); + apply(&repaired, &renamed).await; + let value: String = sqlx::query_scalar("SELECT title FROM \"Pet\" WHERE id = $1") + .bind(pet_row.id.as_str()) + .fetch_one(backend.pool()) + .await + .unwrap(); + assert_eq!(value, "kept"); + apply(&repaired, &renamed).await; + let mut without_unique = renamed.clone(); + without_unique.fields[0].modifiers.clear(); + apply(&repaired, &without_unique).await; + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_constraint WHERE conrelid='\"Pet\"'::regclass AND contype='u'", + ) + .fetch_one(backend.pool()) + .await + .unwrap(); + assert_eq!(count, 0); + + // Legacy orphan values must fail repair visibly rather than weakening integrity. + sqlx::query("ALTER TABLE \"Pet\" DROP CONSTRAINT \"Pet_owner_fkey\"") + .execute(backend.pool()) + .await + .unwrap(); + sqlx::query("UPDATE \"Pet\" SET owner = 'orphan'") + .execute(backend.pool()) + .await + .unwrap(); + assert!(matches!( + backend.finalize_schema_migrations().await, + Err(BackendError::MigrationFailed { .. }) + )); +} diff --git a/crates/schema-forge-surrealdb/src/backend.rs b/crates/schema-forge-surrealdb/src/backend.rs index 93d9b86a..e5fbd7c2 100644 --- a/crates/schema-forge-surrealdb/src/backend.rs +++ b/crates/schema-forge-surrealdb/src/backend.rs @@ -277,16 +277,19 @@ impl SchemaBackend for SurrealBackend { steps: &[MigrationStep], ) -> Result<(), BackendError> { let table = schema_name.as_str(); - let needs_enum_metadata = steps.iter().any(|step| { - matches!( - step, - MigrationStep::ChangeType { - old_type: FieldType::Enum(_), - new_type: FieldType::Enum(_), - .. - } - ) - }); + let needs_enum_metadata = steps + .iter() + .any(|step| matches!(step, MigrationStep::RenameField { .. })) + || steps.iter().any(|step| { + matches!( + step, + MigrationStep::ChangeType { + old_type: FieldType::Enum(_), + new_type: FieldType::Enum(_), + .. + } + ) + }); let metadata = if needs_enum_metadata { self.load_schema_metadata(schema_name).await? } else { @@ -294,7 +297,28 @@ impl SchemaBackend for SurrealBackend { }; let mut statements = Vec::new(); for step in steps { - let mut compiled = migration_step_to_surql(table, step); + let mut compiled = if let MigrationStep::RenameField { old_name, new_name } = step { + let schema = metadata + .as_ref() + .ok_or_else(|| BackendError::MigrationFailed { + step: step.to_string(), + reason: "rename requires stored schema metadata".into(), + })?; + let field = schema.field(old_name.as_str()).ok_or_else(|| { + BackendError::MigrationFailed { + step: step.to_string(), + reason: "rename source missing from stored metadata".into(), + } + })?; + crate::codegen::rename_field_stmts( + table, + field, + new_name, + schema.unique_scoped_by_tenant(), + ) + } else { + migration_step_to_surql(table, step) + }; if let MigrationStep::ChangeType { name, old_type: FieldType::Enum(_), @@ -343,6 +367,13 @@ impl SchemaBackend for SurrealBackend { &self, definition: &SchemaDefinition, ) -> Result<(), BackendError> { + if let Some(existing) = self.load_schema_metadata(&definition.name).await? { + schema_forge_core::migration::DiffEngine::validate_transition(&existing, definition) + .map_err(|error| BackendError::MigrationFailed { + step: "validate schema transition".into(), + reason: error.to_string(), + })?; + } let json = serde_json::to_string(definition).map_err(|e| BackendError::Internal { message: format!("failed to serialize schema metadata: {e}"), })?; diff --git a/crates/schema-forge-surrealdb/src/codegen.rs b/crates/schema-forge-surrealdb/src/codegen.rs index b7a8b745..79fc49de 100644 --- a/crates/schema-forge-surrealdb/src/codegen.rs +++ b/crates/schema-forge-surrealdb/src/codegen.rs @@ -274,6 +274,35 @@ pub fn field_assertions(field_type: &FieldType) -> Vec { } } +/// Preserve the field's physical type and constraints while copying its values. +pub(crate) fn rename_field_stmts( + table: &str, + source: &FieldDefinition, + new_name: &schema_forge_core::types::FieldName, + per_tenant: bool, +) -> Vec { + let old_name = &source.name; + let mut destination = source.clone(); + destination.name = new_name.clone(); + let mut statements = define_field_stmts(table, &destination); + statements.push(format!("UPDATE {table} SET {new_name} = {old_name};")); + if source.is_unique() { + statements.push(format!( + "REMOVE INDEX {} ON {table};", + unique_index_name(table, old_name.as_str()) + )); + } + if source.is_indexed() { + statements.push(format!("REMOVE INDEX idx_{table}_{old_name} ON {table};")); + } + statements.push(format!("REMOVE FIELD {old_name} ON {table};")); + statements.push(format!("UPDATE {table} UNSET {old_name};")); + if source.is_unique() { + statements.push(add_unique_surql(table, new_name.as_str(), per_tenant)); + } + statements +} + /// Generate a complete DEFINE FIELD statement (possibly multiple for composites). pub(crate) fn define_field_stmts(table: &str, field: &FieldDefinition) -> Vec { let name = &field.name; diff --git a/crates/schema-forge-surrealdb/tests/migration_renames.rs b/crates/schema-forge-surrealdb/tests/migration_renames.rs new file mode 100644 index 00000000..bbbb32bc --- /dev/null +++ b/crates/schema-forge-surrealdb/tests/migration_renames.rs @@ -0,0 +1,10 @@ +#[path = "../../schema-forge-backend/tests/support/migration_renames.rs"] +mod contract; + +#[tokio::test] +async fn declared_renames_preserve_values_and_schema_constraints() { + let backend = schema_forge_surrealdb::SurrealBackend::connect_memory("renames", "renames") + .await + .unwrap(); + contract::exercise(&backend).await; +} diff --git a/docs/migrations/safe-schema-changes.md b/docs/migrations/safe-schema-changes.md new file mode 100644 index 00000000..9f37d4cf --- /dev/null +++ b/docs/migrations/safe-schema-changes.md @@ -0,0 +1,142 @@ +# Safe schema changes + +## Rename a field without losing its values + +Declare the previous field name on the replacement field: + +```schema +schema Line { + business_number: text required @renamed_from("number") +} +``` + +`apply`, `migrate`, and `serve` use the hint to rename the existing column. +It remains valid after the rename and can stay in signed schema artifacts. +The old name must identify exactly one removed field. A source and destination +that both already exist, a missing source and destination, duplicate sources, +and self-renames are rejected before migration. PostgreSQL also renames the +associated generated unique/index objects, allowing later modifier changes. + +Without this declaration, changing a field name means removing one field and +adding another. `apply` and `migrate --execute` require confirmation for that +operation. `serve` refuses destructive startup plans before applying any of the +user's schema plans. After reviewing the plan, use +`serve --allow-destructive-migrations` to explicitly accept data loss. +Admin schema PUT requests must include `"allow_destructive_migrations": true` +for destructive changes. Omitted or false values refuse the operation. + +## PostgreSQL relation integrity + +To-one relations use foreign keys to the target table's `id`, with restrictive +deletion behavior. Table creation and later relation additions follow the same +policy. Constraint installation is deferred until the referenced table exists, +so file order and cyclic schemas work. Schema administration finishes with an +idempotent reconciliation pass over stored metadata. `apply`, +`migrate --execute`, and startup also repair legacy missing constraints even +when the schema text has no changes. Read commands and connections do not run +this repair. Dry runs do not install constraints; they do not inspect existing +rows for orphan references. + +Finalization fails if a target table is missing. An orphaned value makes +constraint installation fail with the source schema and field; repair or remove +that orphan and rerun the operation. Already completed migrations are not +rolled back across the whole batch. Run migrations during a maintenance window: +adding a validated foreign key scans existing rows and takes PostgreSQL locks. + +Writing a nonexistent related ID or deleting a referenced record returns HTTP +409 `foreign_key_violation`, with schema and constraint names. It does not +return raw SQL or row values. Existing foreign keys with default `NO ACTION` +remain valid: these are nondeferrable and prevent referenced deletions as well. + +## Changing tenancy requires an explicit data migration + +Adding, removing, or changing `@tenant` on a stored schema is rejected before +automatic DDL or metadata replacement. `--force` and the destructive startup +opt-in do not bypass this check. A schema annotation cannot establish which +tenant owns each existing row. Leaving old rows unattributed or guessing an +owner would change authorization and unique constraints without a sound data +mapping. + +For PostgreSQL, use this procedure with the service and other writers offline. +If a systemd restart policy is configured, mask the service for the maintenance +window. Take a database backup and test the procedure against a restored copy. +Perform tenancy changes separately from field changes. + +1. Apply the complete desired schema set to an empty staging database. This + validates the desired hierarchy and provides canonical metadata and DDL. + Compare the staging table's columns, indexes, and constraints with production. +2. Prepare an explicit mapping from every affected row ID to a valid tenant ID + in the desired hierarchy. Verify completeness and tenant existence. `_tenant` + stores the tenant entity ID, without a schema-name prefix. Update token tenant + chains and related tables when changing the hierarchy. +3. In one production transaction, lock affected tables, change the physical + columns/constraints, backfill ownership, verify all invariants, and copy only + the desired annotation array into the existing metadata row. Preserve the + production schema ID and fields. Roll back on any failed check. +4. Restart using the matching desired schema files, verify tenant-isolated reads + and writes with real tenant principals, and only then restore normal service. + +The following example converts a global `Contact.phone unique` schema to +`@tenant(parent: "Org")`. It assumes Org already exists as a tenant root and +all Contact rows belong to **one explicitly chosen Org**. For multiple tenants, +replace the UPDATE with a join against your reviewed row-to-tenant mapping. + +First export the canonical annotations from the staging database that has the +desired definition: + +```sh +psql "$STAGING_DATABASE_URL" -X -Atc \ + "SELECT definition::jsonb->'annotations' FROM \"_schema_metadata\" WHERE name = 'Contact'" \ + > contact-annotations.json +psql "$PRODUCTION_DATABASE_URL" -X -v ON_ERROR_STOP=1 \ + --set=annotations="$(cat contact-annotations.json)" \ + --set=tenant_id='REPLACE_WITH_VERIFIED_ORG_ID' -f contact-tenancy.sql +``` + +Contents of `contact-tenancy.sql`: + +```sql +BEGIN; +LOCK TABLE "Contact", "Org", "_schema_metadata" IN ACCESS EXCLUSIVE MODE; +ALTER TABLE "Contact" ADD COLUMN "_tenant" TEXT; +UPDATE "Contact" SET "_tenant" = :'tenant_id'; +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM "Contact" c LEFT JOIN "Org" o ON c."_tenant" = o.id + WHERE c."_tenant" IS NULL OR o.id IS NULL + ) THEN RAISE EXCEPTION 'tenant attribution incomplete or invalid'; END IF; +END $$; +ALTER TABLE "Contact" DROP CONSTRAINT "uq_Contact_phone"; +CREATE INDEX "idx_Contact_tenant" ON "Contact" ("_tenant"); +CREATE UNIQUE INDEX "uq_Contact_phone" ON "Contact" ("_tenant", "phone"); +UPDATE "_schema_metadata" +SET definition = jsonb_set(definition::jsonb, '{annotations}', :'annotations'::jsonb) +WHERE name = 'Contact'; +COMMIT; +``` + +Check that exactly one metadata row was updated. The annotation file must come +from the validated desired staging schema, not a hand-invented serialization. +Repeat the unique-constraint conversion for every unique field. + +Other transitions have different requirements: + +- **Adding a root:** add `_tenant` and its index. Root unique fields stay global. + Establish a valid single-root hierarchy and reviewed ownership values; root + rows represent tenant boundaries and must not be assigned to arbitrary roots. +- **Removing tenancy from a child:** verify and resolve duplicate values across + tenants, drop each composite unique index, create global unique constraints, + then drop `_tenant` (its supporting index is dropped with the column). Removing + isolation changes who can read records; review the resulting policies before + accepting the change. Removing a root also requires updating its descendants. +- **Changing child parent:** keep `_tenant` and composite unique indexes, but + remap every affected row to a valid entity in the new hierarchy. Update + descendants and principal tenant chains together. +- **Root to child:** establish a different valid root, backfill parent ownership, + and replace global unique constraints with composite indexes. +- **Child to root:** establish a valid single-root hierarchy, resolve cross-tenant + duplicates, and replace composite indexes with global unique constraints. + +For every case, commit physical changes, validated ownership, and the canonical +annotation update together. Other database backends require equivalent native +DDL and metadata changes; the PostgreSQL SQL above is not portable. From 9cdb1af6e7450b2e891452ebea694d4d7361cdb2 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:50:44 -0600 Subject: [PATCH 16/77] fix: classify GraphQL integrity violations as conflicts --- .../src/graphql/resolvers.rs | 60 +++++++++++++++---- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index 205d2500..ea39a95e 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -12,8 +12,8 @@ use crate::access::{ check_schema_access, filter_entity_fields, inject_tenant_scope, AccessAction, FieldFilterDirection, }; -use crate::error::ForgeError; use crate::authz::{authorize, namespace::ActionVerb}; +use crate::error::ForgeError; /// Entity data stored in resolver parent values. pub struct EntityFields { @@ -60,7 +60,11 @@ pub fn forge_error_to_gql(err: ForgeError) -> async_graphql::Error { ForgeError::SchemaNotFound { .. } | ForgeError::EntityNotFound { .. } => "NOT_FOUND", ForgeError::Forbidden { .. } => "FORBIDDEN", ForgeError::Unauthorized { .. } => "UNAUTHORIZED", - ForgeError::ValidationFailed { .. } => "VALIDATION_ERROR", + ForgeError::ValidationFailed { .. } | ForgeError::HookAborted { .. } => "VALIDATION_ERROR", + ForgeError::Conflict { .. } + | ForgeError::UniqueViolation { .. } + | ForgeError::ForeignKeyViolation { .. } + | ForgeError::SchemaAlreadyExists { .. } => "CONFLICT", ForgeError::InvalidQuery { .. } | ForgeError::InvalidSchemaName { .. } | ForgeError::InvalidEntityId { .. } => "BAD_REQUEST", @@ -214,10 +218,14 @@ pub async fn resolve_list_entities<'a>( .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; // Canonical Cedar decisions apply even without a custom record policy. - let authorized = result.entities.into_iter().filter(|entity| { - require_record_access(gql_ctx, schema_def, entity, ActionVerb::Read).is_ok() - && require_record_access(gql_ctx, schema_def, entity, ActionVerb::List).is_ok() - }).collect(); + let authorized = result + .entities + .into_iter() + .filter(|entity| { + require_record_access(gql_ctx, schema_def, entity, ActionVerb::Read).is_ok() + && require_record_access(gql_ctx, schema_def, entity, ActionVerb::List).is_ok() + }) + .collect(); // Record-level access filtering let visible_entities = if let (Some(ref policy), Some(c)) = (&gql_ctx.state.record_access_policy, claims) { @@ -401,7 +409,11 @@ pub async fn resolve_delete_entity( let entity_id = EntityId::parse(&id_arg) .map_err(|_| forge_error_to_gql(ForgeError::InvalidEntityId { id: id_arg.clone() }))?; - let entity = gql_ctx.state.backend.get(&schema, &entity_id).await + let entity = gql_ctx + .state + .backend + .get(&schema, &entity_id) + .await .map_err(|error| forge_error_to_gql(ForgeError::from(error)))?; require_record_access(gql_ctx, schema_def, &entity, ActionVerb::Delete)?; @@ -519,7 +531,8 @@ pub async fn resolve_relation_many<'a>( let mut results = Vec::new(); for ref_id in ref_ids { if let Ok(mut entity) = gql_ctx.state.backend.get(&target_schema, &ref_id).await { - if require_record_access(gql_ctx, target_schema_def, &entity, ActionVerb::Read).is_err() { + if require_record_access(gql_ctx, target_schema_def, &entity, ActionVerb::Read).is_err() + { continue; } entity.strip_hidden(target_schema_def); @@ -543,10 +556,24 @@ fn require_record_access( entity: &Entity, action: ActionVerb, ) -> async_graphql::Result<()> { - let decision = authorize(&context.state.policy_store, context.claims.as_ref(), action, schema, Some(entity)) - .map_err(|_| forge_error_to_gql(ForgeError::Forbidden { message: "could not authorize entity".into() }))?; - if decision.is_allow() && decision.errors.is_empty() { Ok(()) } else { - Err(forge_error_to_gql(ForgeError::Forbidden { message: "not authorized for this entity".into() })) + let decision = authorize( + &context.state.policy_store, + context.claims.as_ref(), + action, + schema, + Some(entity), + ) + .map_err(|_| { + forge_error_to_gql(ForgeError::Forbidden { + message: "could not authorize entity".into(), + }) + })?; + if decision.is_allow() && decision.errors.is_empty() { + Ok(()) + } else { + Err(forge_error_to_gql(ForgeError::Forbidden { + message: "not authorized for this entity".into(), + })) } } @@ -564,6 +591,15 @@ fn entity_to_field_value(entity: Entity, type_name: &str) -> FieldValue<'static> mod tests { use super::*; + #[test] + fn foreign_key_conflict_is_not_an_internal_graphql_error() { + let error = forge_error_to_gql(ForgeError::ForeignKeyViolation { + schema: "Pet".into(), + constraint: "Pet_owner_fkey".into(), + }); + assert_eq!(extension_code(&error).as_deref(), Some("CONFLICT")); + } + fn extension_code(err: &async_graphql::Error) -> Option { err.extensions .as_ref() From 1aac552c5b0f9784460bd1d7b3e427e29582cb98 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:51:54 -0600 Subject: [PATCH 17/77] ci: exercise migration and GraphQL security regressions --- .github/workflows/postgres-conditional.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/postgres-conditional.yml b/.github/workflows/postgres-conditional.yml index b328de0e..9f6fb3e8 100644 --- a/.github/workflows/postgres-conditional.yml +++ b/.github/workflows/postgres-conditional.yml @@ -3,6 +3,9 @@ name: PostgreSQL conditional mutations on: pull_request: paths: + - "crates/schema-forge-core/**" + - "crates/schema-forge-dsl/**" + - "crates/schema-forge-surrealdb/**" - "crates/schema-forge-backend/**" - "crates/schema-forge-postgres/**" - "crates/schema-forge-acton/**" @@ -60,8 +63,14 @@ jobs: run: | cargo nextest run -p schema-forge-acton --features postgres --lib --test authorization_context -E 'test(authz::read_scope) | binary(authorization_context)' cargo nextest run -p schema-forge-acton --features postgres --test auth_demo -E 'test(readable_paging)' - - name: Check CLI preparation behavior - run: cargo nextest run -p schema-forge-cli --no-default-features --features postgres -E 'test(explicit_revision_preparation)' + - name: Run GraphQL write and tenant regressions + run: cargo nextest run -p schema-forge-acton --features postgres,graphql --test graphql_writes --test graphql_tenants + - name: Run schema planning and rename regressions + run: | + cargo nextest run -p schema-forge-core -p schema-forge-dsl + cargo nextest run -p schema-forge-surrealdb --test migration_renames + - name: Check CLI preparation and security behavior + run: cargo nextest run -p schema-forge-cli --no-default-features --features postgres -E 'test(explicit_revision_preparation) | test(help_does_not_disclose_secret_environment_values) | test(serve_rejects_invalid_host_before_connecting)' - name: Clean disposable HTTP namespace if: always() run: psql -X -v ON_ERROR_STOP=1 -c 'DROP SCHEMA IF EXISTS conditional_http CASCADE' From 2aa0131e0a511b002c6c307f454440810910d8f8 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:55:31 -0600 Subject: [PATCH 18/77] docs: document v0.45.0 security fixes and migration requirements --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e1ba7b..9941c373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,61 @@ is pre-1.0; breaking changes bump the **minor** version per ## [Unreleased] +## [0.45.0] - 2026-09-24 + +### Security and correctness + +- Tenant scope applies only to tenanted schemas. Tenant roots are authorized by + their own identity, including legacy roots with NULL metadata. Unattributed + child records fail closed for tenant users, and PUT/PATCH cannot move a record + to another tenant unless the caller is a platform administrator. Invitations + validate both the configured tenant type and the caller's effective scope. +- Field write authorization precedes defaults, computed expressions, validation + rules, and hooks. Retained caller input is reauthorized after denied fields + are removed. Rules see optional absent fields as null; required fields are + checked after server-supplied values are available. Filtered empty PATCH + requests no longer emit invalid SQL. PUT rules and persisted optional values + now agree, including when a field is omitted or write-restricted. +- GraphQL creates and updates share the REST write pipeline. GraphQL reads, + relations, and deletes enforce concrete Cedar decisions. Unproven raw + GraphQL totals are withheld instead of disclosing counts of inaccessible rows. +- CLI help hides secret environment values, including database and server URLs. + `serve --host` controls the actual listener and accepts validated IPv4/IPv6 + addresses. The default listener is now the documented `127.0.0.1`. + +### Schema migrations + +- Declare field renames with `@renamed_from("old_name")` to preserve data instead + of dropping and recreating a field. Rename hints are validated before migration + and remain valid after the rename completes. +- `serve` preflights startup plans and refuses destructive changes unless + `--allow-destructive-migrations` is supplied. Runtime schema PUT requires + `allow_destructive_migrations: true` for destructive plans. +- PostgreSQL to-one relation foreign keys are installed consistently for fresh, + altered, and legacy schemas. Missing targets or orphan references fail schema + administration clearly. Integrity violations return HTTP 409 + `foreign_key_violation` rather than 502, without exposing SQL or row values. +- Automatic changes to an existing schema's tenancy are refused because row + ownership cannot be inferred safely. The + [migration guide](docs/migrations/safe-schema-changes.md) documents explicit + ownership backfill, constraint changes, and metadata updates. +- Explicit empty access lists produce diagnostics; documentation clarifies that + within access annotations, empty and omitted role lists grant access to every + authenticated user. + +### Upgrade notes + +Review pending schema changes before restarting. Destructive startup migrations +now require deliberate opt-in. PostgreSQL schema administration repairs missing +foreign keys and can require cleanup of existing orphan references. Tenanted +children created by a platform administrator require an explicit tenant. + +Rust embedders must update the schema-aware tenant helper and rule-binding call +signatures. GraphQL registration now requires initialized +`AppState`; see [GraphQL writes](docs/graphql-writes.md). +The backend trait adds a defaulted `finalize_schema_migrations` operation for +completing batch migration integrity checks. + ## [0.44.2] - 2026-09-10 ### Fixed From a52c95571b8d743a3dc25e5f0c63fdc9c3e7f732 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:51:08 -0600 Subject: [PATCH 19/77] refactor: validate schema transitions in the migration planner --- crates/schema-forge-acton/src/routes/schemas.rs | 3 +-- .../src/commands/schema_update.rs | 17 ++++++++--------- crates/schema-forge-cli/src/commands/serve.rs | 9 +++------ crates/schema-forge-core/src/migration.rs | 14 +++++++++++++- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index 2aa5ff5e..f15005c2 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -798,12 +798,11 @@ pub async fn update_schema( precheck_policy_bundle(&state, &forge, &new_definition, false).await?; // 5. Compute diff and generate migration plan - DiffEngine::validate_transition(&old_schema, &new_definition).map_err(|error| { + let plan = DiffEngine::plan_update(&old_schema, &new_definition).map_err(|error| { ForgeError::ValidationFailed { details: vec![error.to_string()], } })?; - let plan = DiffEngine::diff(&old_schema, &new_definition); if plan.has_destructive_steps() && !body.allow_destructive_migrations { return Err(ForgeError::ValidationFailed { details: vec![format!("destructive schema update refused: {}; set allow_destructive_migrations=true to acknowledge data loss", plan.steps.iter().map(ToString::to_string).collect::>().join("; "))] }); } diff --git a/crates/schema-forge-cli/src/commands/schema_update.rs b/crates/schema-forge-cli/src/commands/schema_update.rs index f75275db..b9198884 100644 --- a/crates/schema-forge-cli/src/commands/schema_update.rs +++ b/crates/schema-forge-cli/src/commands/schema_update.rs @@ -22,18 +22,17 @@ impl SchemaUpdate { ) -> Result { let mut schema = desired.clone(); if let Some(existing) = existing { - DiffEngine::validate_transition(existing, desired).map_err(|error| { - CliError::Config { - message: error.to_string(), - } - })?; schema.id = existing.id.clone(); } let metadata_changed = existing != Some(&schema); - let migration = existing.map_or_else( - || DiffEngine::create_new(&schema), - |existing| DiffEngine::diff(existing, &schema), - ); + let migration = existing + .map_or_else( + || Ok(DiffEngine::create_new(&schema)), + |existing| DiffEngine::plan_update(existing, &schema), + ) + .map_err(|error| CliError::Config { + message: error.to_string(), + })?; Ok(Self { schema, migration, diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index 904e0d61..d41c9990 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -154,12 +154,9 @@ pub async fn run( .map_err(CliError::Backend)?; let plan = if let Some(old) = existing { - DiffEngine::validate_transition(&old, schema).map_err(|error| { - CliError::Config { - message: error.to_string(), - } - })?; - DiffEngine::diff(&old, schema) + DiffEngine::plan_update(&old, schema).map_err(|error| CliError::Config { + message: error.to_string(), + })? } else { DiffEngine::create_new(schema) }; diff --git a/crates/schema-forge-core/src/migration.rs b/crates/schema-forge-core/src/migration.rs index 0f6acd34..ecc6e571 100644 --- a/crates/schema-forge-core/src/migration.rs +++ b/crates/schema-forge-core/src/migration.rs @@ -452,6 +452,16 @@ impl fmt::Display for MigrationPlan { pub struct DiffEngine; impl DiffEngine { + /// Plan an existing schema update, rejecting transitions that need manual migration. + /// Production callers should use this checked entry point before any DDL or metadata write. + pub fn plan_update( + old: &crate::types::SchemaDefinition, + new: &crate::types::SchemaDefinition, + ) -> Result { + Self::validate_transition(old, new)?; + Ok(Self::diff(old, new)) + } + /// Validate transitions that cannot safely be inferred from field differences. /// Tenant changes require a manual data backfill and constraint migration. pub fn validate_transition( @@ -490,8 +500,10 @@ impl DiffEngine { Ok(()) } - /// Compare two schema definitions and produce a migration plan. + /// Compute an unchecked structural diff of two schema definitions. /// + /// This compatibility API does not validate tenancy transitions or rename hints. + /// Use [`Self::plan_update`] for executable migration plans. /// This is a pure function: no I/O, no side effects. #[instrument(skip(old, new), fields(old_schema = %old.name.as_str(), new_schema = %new.name.as_str()))] pub fn diff( From 322385c5f632faec31929353a01aadd33d3dac82 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:52:12 -0600 Subject: [PATCH 20/77] test: verify schema update refusal preserves stored data --- .../schema-forge-acton/tests/integration.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/schema-forge-acton/tests/integration.rs b/crates/schema-forge-acton/tests/integration.rs index 8cdd641d..c2f13a07 100644 --- a/crates/schema-forge-acton/tests/integration.rs +++ b/crates/schema-forge-acton/tests/integration.rs @@ -281,6 +281,46 @@ async fn update_schema_triggers_migration() { assert_eq!(json["fields"].as_array().unwrap().len(), 2); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn destructive_schema_updates_require_explicit_opt_in_and_preserve_data_on_refusal() { + let app = test_app().await; + let create = + serde_json::json!({"name": "Line", "fields": [{"name": "number", "field_type": "Text"}]}); + assert_eq!( + json_request(&app, Method::POST, "/schemas", Some(create)) + .await + .0, + StatusCode::CREATED + ); + let (status, row) = json_request( + &app, + Method::POST, + "/schemas/Line/entities", + Some(serde_json::json!({"fields": {"number": "555-9999"}})), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let entity_path = format!("/schemas/Line/entities/{}", row["id"].as_str().unwrap()); + let mut update = serde_json::json!({"name": "Line", "fields": [{"name": "business_number", "field_type": "Text"}]}); + for explicit_false in [false, true] { + if explicit_false { + update["allow_destructive_migrations"] = serde_json::json!(false); + } + let (status, error) = + json_request(&app, Method::PUT, "/schemas/Line", Some(update.clone())).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{error}"); + let (status, unchanged) = json_request(&app, Method::GET, &entity_path, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(unchanged["fields"]["number"], "555-9999"); + let (_, schema) = json_request(&app, Method::GET, "/schemas/Line", None).await; + assert_eq!(schema["fields"][0]["name"], "number"); + } + update["allow_destructive_migrations"] = serde_json::json!(true); + let (status, result) = json_request(&app, Method::PUT, "/schemas/Line", Some(update)).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert_eq!(result["fields"][0]["name"], "business_number"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn delete_schema_removes_from_registry() { let app = test_app().await; From c2a92a1850a782c38a50318acd07d41b96c8ff48 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:52:04 -0600 Subject: [PATCH 21/77] test: import migration backend trait for write fixtures --- crates/schema-forge-acton/tests/conditional_entities.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/schema-forge-acton/tests/conditional_entities.rs b/crates/schema-forge-acton/tests/conditional_entities.rs index fba39f1d..f36e031f 100644 --- a/crates/schema-forge-acton/tests/conditional_entities.rs +++ b/crates/schema-forge-acton/tests/conditional_entities.rs @@ -18,7 +18,9 @@ use schema_forge_acton::{ storage::StorageRegistry, ForgeActor, }; -use schema_forge_backend::{conditional::EntityRevision, entity::Entity, tenant::TenantConfig}; +use schema_forge_backend::{ + conditional::EntityRevision, entity::Entity, tenant::TenantConfig, SchemaBackend, +}; use schema_forge_core::types::{ Annotation, DynamicValue, EntityId, FieldAnnotation, FieldDefinition, FieldModifier, FieldName, FieldType, SchemaDefinition, SchemaId, SchemaName, TenantKind, TextConstraints, From c9b7376c90618c991794c8272565b3786e089a5b Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 15:58:05 -0600 Subject: [PATCH 22/77] fix: preserve rename constraints and atomic migration batches --- crates/schema-forge-mssql/src/backend.rs | 131 +++++++----------- crates/schema-forge-mssql/tests/sql_server.rs | 50 +++++++ crates/schema-forge-postgres/src/codegen.rs | 30 +++- .../tests/migration_safety.rs | 37 +++++ crates/schema-forge-surrealdb/src/codegen.rs | 23 ++- docs/migrations/safe-schema-changes.md | 12 +- 6 files changed, 183 insertions(+), 100 deletions(-) diff --git a/crates/schema-forge-mssql/src/backend.rs b/crates/schema-forge-mssql/src/backend.rs index 3d10528e..31cc65ed 100644 --- a/crates/schema-forge-mssql/src/backend.rs +++ b/crates/schema-forge-mssql/src/backend.rs @@ -67,6 +67,45 @@ impl MssqlBackend { } } +/// Compile one atomic batch while keeping arbitrary variant text in bound parameters. +fn migration_batch(schema_name: &SchemaName, steps: &[MigrationStep]) -> (String, Vec) { + let table = quote(schema_name.as_str()); + let mut statements = Vec::new(); + let mut parameters = Vec::new(); + for step in steps { + match step { + MigrationStep::RenameField { old_name, new_name } => { + let old_parameter = parameters.len() + 1; + let new_parameter = old_parameter + 1; + parameters.extend([format!("$.\"{old_name}\""), format!("$.\"{new_name}\"")]); + statements.push(format!(r#"IF EXISTS (SELECT 1 FROM [dbo].{table} WITH (UPDLOCK, HOLDLOCK) + WHERE JSON_QUERY([data], @P{old_parameter}) IS NOT NULL AND JSON_QUERY([data], @P{new_parameter}) IS NOT NULL) + THROW 50001, 'rename destination already contains data', 1; + UPDATE [dbo].{table} + SET [data] = JSON_MODIFY(JSON_MODIFY([data], @P{new_parameter}, JSON_QUERY([data], @P{old_parameter})), @P{old_parameter}, NULL) + WHERE JSON_QUERY([data], @P{old_parameter}) IS NOT NULL;"#)); + } + MigrationStep::ChangeType { name, transform: ValueTransform::NullRemovedEnumVariants { variants }, .. } => { + for variant in variants { + let path_parameter = parameters.len() + 1; + let value_parameter = path_parameter + 1; + let variant_parameter = path_parameter + 2; + parameters.extend([format!("$.\"{name}\""), format!("$.\"{name}\".value"), variant.clone()]); + statements.push(format!("UPDATE [dbo].{table} SET [data] = JSON_MODIFY([data], @P{path_parameter}, JSON_QUERY(N'{{\"type\":\"Null\"}}')) WHERE JSON_VALUE([data], @P{value_parameter}) COLLATE Latin1_General_100_BIN2 = @P{variant_parameter};")); + } + } + MigrationStep::CreateSchema { name, .. } => statements.push(format!("IF OBJECT_ID(N'[dbo].{table}', N'U') IS NULL CREATE TABLE [dbo].{table} ([id] NVARCHAR(255) NOT NULL PRIMARY KEY, [data] NVARCHAR(MAX) NOT NULL CHECK (ISJSON([data]) = 1));", table = quote(name.as_str()))), + MigrationStep::DropSchema { name } => statements.push(format!("IF OBJECT_ID(N'[dbo].{table}', N'U') IS NOT NULL DROP TABLE [dbo].{table};", table = quote(name.as_str()))), + _ => {} + } + } + if statements.is_empty() { + return (String::new(), parameters); + } + let sql = format!("SET XACT_ABORT ON; BEGIN TRY BEGIN TRANSACTION; {} COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; THROW; END CATCH;", statements.join("\n")); + (sql, parameters) +} + impl SchemaBackend for MssqlBackend { async fn apply_migration( &self, @@ -100,87 +139,21 @@ impl SchemaBackend for MssqlBackend { } } } + let (sql, parameters) = migration_batch(schema_name, steps); + if sql.is_empty() { + return Ok(()); + } + let bindings: Vec<&dyn tiberius::ToSql> = parameters + .iter() + .map(|value| value as &dyn tiberius::ToSql) + .collect(); let mut connection = connection(&self.pool).await?; - for step in steps { - if let MigrationStep::RenameField { old_name, new_name } = step { - // DynamicValue encodes every value as an object, including arrays and null. - // JSON_QUERY preserves that object rather than escaping it into a string. - let old_path = format!("$.\"{old_name}\""); - let new_path = format!("$.\"{new_name}\""); - let sql = format!( - r#"SET XACT_ABORT ON; - BEGIN TRY - BEGIN TRANSACTION; - IF EXISTS (SELECT 1 FROM [dbo].{table} WITH (UPDLOCK, HOLDLOCK) - WHERE JSON_QUERY([data], @P1) IS NOT NULL AND JSON_QUERY([data], @P2) IS NOT NULL) - THROW 50001, 'rename destination already contains data', 1; - UPDATE [dbo].{table} - SET [data] = JSON_MODIFY(JSON_MODIFY([data], @P2, JSON_QUERY([data], @P1)), @P1, NULL) - WHERE JSON_QUERY([data], @P1) IS NOT NULL; - COMMIT TRANSACTION; - END TRY - BEGIN CATCH - IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; - THROW; - END CATCH;"#, - table = quote(schema_name.as_str()) - ); - connection - .execute(&sql, &[&old_path.as_str(), &new_path.as_str()]) - .await - .map_err(|error| BackendError::MigrationFailed { - step: step.to_string(), - reason: error.to_string(), - })?; - continue; + connection.execute(sql, &bindings).await.map_err(|error| { + BackendError::MigrationFailed { + step: "apply migration transaction".into(), + reason: error.to_string(), } - if let MigrationStep::ChangeType { - name, - transform: ValueTransform::NullRemovedEnumVariants { variants }, - .. - } = step - { - // JSON payloads store tagged DynamicValue objects. Bind both the - // path and variant so enum strings cannot become SQL syntax. - let path = format!("$.\"{}\"", name.as_str()); - let value_path = format!("{path}.value"); - let sql = format!("UPDATE [dbo].{} SET [data] = JSON_MODIFY([data], @P1, JSON_QUERY(N'{{\"type\":\"Null\"}}')) WHERE JSON_VALUE([data], @P2) COLLATE Latin1_General_100_BIN2 = @P3;", quote(schema_name.as_str())); - for variant in variants { - connection - .execute( - &sql, - &[&path.as_str(), &value_path.as_str(), &variant.as_str()], - ) - .await - .map_err(query_error)?; - } - continue; - } - let sql = match step { - MigrationStep::CreateSchema { name, .. } => Some(format!( - "IF OBJECT_ID(N'[dbo].{}', N'U') IS NULL CREATE TABLE [dbo].{} \ - ([id] NVARCHAR(255) NOT NULL PRIMARY KEY, \ - [data] NVARCHAR(MAX) NOT NULL CHECK (ISJSON([data]) = 1));", - quote(name.as_str()), - quote(name.as_str()) - )), - MigrationStep::DropSchema { name } => Some(format!( - "IF OBJECT_ID(N'[dbo].{}', N'U') IS NOT NULL DROP TABLE [dbo].{};", - quote(name.as_str()), - quote(name.as_str()) - )), - _ => None, - }; - if let Some(sql) = sql { - connection.execute(sql, &[]).await.map_err(|error| { - BackendError::MigrationFailed { - step: step.to_string(), - reason: error.to_string(), - } - })?; - } - } - let _ = schema_name; + })?; Ok(()) } diff --git a/crates/schema-forge-mssql/tests/sql_server.rs b/crates/schema-forge-mssql/tests/sql_server.rs index 33a12082..e55efc0a 100644 --- a/crates/schema-forge-mssql/tests/sql_server.rs +++ b/crates/schema-forge-mssql/tests/sql_server.rs @@ -65,6 +65,56 @@ async fn connects_and_initializes_metadata(image_tag: &str) { exercises_backend_contract(&backend).await; data_correctness::exercise(&backend).await; migration_renames::exercise(&backend).await; + rename_collision_rolls_back_entire_plan(&backend).await; +} + +async fn rename_collision_rolls_back_entire_plan(backend: &MssqlBackend) { + let schema = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("RenameRollback").unwrap(), + ["first", "second"] + .map(|name| { + FieldDefinition::new( + FieldName::new(name).unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + ) + }) + .to_vec(), + vec![], + ) + .unwrap(); + backend + .apply_migration(&schema.name, &DiffEngine::create_new(&schema).steps) + .await + .unwrap(); + backend.store_schema_metadata(&schema).await.unwrap(); + let row = backend + .create(&Entity::new( + schema.name.clone(), + BTreeMap::from([ + ("first".into(), DynamicValue::Text("keep".into())), + ("second".into(), DynamicValue::Text("other".into())), + ("destination".into(), DynamicValue::Text("occupied".into())), + ]), + )) + .await + .unwrap(); + let steps = [ + MigrationStep::RenameField { + old_name: FieldName::new("first").unwrap(), + new_name: FieldName::new("third").unwrap(), + }, + MigrationStep::RenameField { + old_name: FieldName::new("second").unwrap(), + new_name: FieldName::new("destination").unwrap(), + }, + ]; + assert!(backend.apply_migration(&schema.name, &steps).await.is_err()); + assert_eq!(backend.get(&schema.name, &row.id).await.unwrap(), row); + assert_eq!( + backend.load_schema_metadata(&schema.name).await.unwrap(), + Some(schema) + ); } async fn exercises_backend_contract(backend: &MssqlBackend) { diff --git a/crates/schema-forge-postgres/src/codegen.rs b/crates/schema-forge-postgres/src/codegen.rs index 7c260386..e4402563 100644 --- a/crates/schema-forge-postgres/src/codegen.rs +++ b/crates/schema-forge-postgres/src/codegen.rs @@ -97,11 +97,24 @@ pub fn migration_step_to_sql(table: &str, step: &MigrationStep) -> Vec { )] } MigrationStep::RenameField { old_name, new_name } => { - vec![ + let mut statements = vec![ format!("ALTER TABLE \"{table}\" RENAME COLUMN \"{old_name}\" TO \"{new_name}\";"), format!("ALTER INDEX IF EXISTS \"uq_{table}_{old_name}\" RENAME TO \"uq_{table}_{new_name}\";"), format!("ALTER INDEX IF EXISTS \"idx_{table}_{old_name}\" RENAME TO \"idx_{table}_{new_name}\";"), - ] + ]; + for suffix in ["enum", "range", "size", "file"] { + statements.push(rename_constraint_sql( + table, + &format!("chk_{table}_{old_name}_{suffix}"), + &format!("chk_{table}_{new_name}_{suffix}"), + )); + } + statements.push(rename_constraint_sql( + table, + &format!("{table}_{old_name}_fkey"), + &format!("{table}_{new_name}_fkey"), + )); + statements } MigrationStep::ChangeType { name, @@ -447,6 +460,17 @@ pub fn tenant_ddl_statements(table: &str) -> Vec { ] } +/// Rename generated constraints only when the source object exists on this table. +fn rename_constraint_sql(table: &str, old_name: &str, new_name: &str) -> String { + format!( + r#"DO $forge_rename$ BEGIN + IF EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = to_regclass('"{table}"') AND conname = left('{old_name}', 63)) THEN + ALTER TABLE "{table}" RENAME CONSTRAINT "{old_name}" TO "{new_name}"; + END IF; + END $forge_rename$;"# + ) +} + /// Escape single quotes in strings for PostgreSQL string literals. fn escape_sql_string(s: &str) -> String { s.replace('\'', "''") @@ -643,7 +667,7 @@ mod tests { new_name: FieldName::new("full_name").unwrap(), }; let stmts = migration_step_to_sql("Contact", &step); - assert_eq!(stmts.len(), 3); + assert_eq!(stmts.len(), 8); assert_eq!( stmts[0], "ALTER TABLE \"Contact\" RENAME COLUMN \"name\" TO \"full_name\";" diff --git a/crates/schema-forge-postgres/tests/migration_safety.rs b/crates/schema-forge-postgres/tests/migration_safety.rs index df7da8ae..30b681af 100644 --- a/crates/schema-forge-postgres/tests/migration_safety.rs +++ b/crates/schema-forge-postgres/tests/migration_safety.rs @@ -207,6 +207,43 @@ async fn exercise(backend: &PgBackend) { .unwrap(); assert_eq!(count, 0); + // A renamed enum must also rename its CHECK, so widening removes the old restriction. + let mut enum_schema = definition("EnumProbe", None); + enum_schema.fields[0].field_type = + FieldType::Enum(schema_forge_core::types::EnumVariants::new(vec!["old".into()]).unwrap()); + apply(backend, &enum_schema).await; + let enum_row = backend + .create(&Entity::new( + enum_schema.name.clone(), + BTreeMap::from([("label".into(), DynamicValue::Enum("old".into()))]), + )) + .await + .unwrap(); + let mut renamed_enum = enum_schema.clone(); + renamed_enum.fields[0].name = FieldName::new("status").unwrap(); + renamed_enum.fields[0].annotations.push( + schema_forge_core::types::FieldAnnotation::RenamedFrom { + name: FieldName::new("label").unwrap(), + }, + ); + apply(backend, &renamed_enum).await; + renamed_enum.fields[0].field_type = FieldType::Enum( + schema_forge_core::types::EnumVariants::new(vec!["old".into(), "new".into()]).unwrap(), + ); + apply(backend, &renamed_enum).await; + let updated = backend + .update(&Entity::with_id( + enum_row.id, + renamed_enum.name, + BTreeMap::from([("status".into(), DynamicValue::Enum("new".into()))]), + )) + .await + .unwrap(); + assert_eq!( + updated.fields.get("status"), + Some(&DynamicValue::Enum("new".into())) + ); + // Legacy orphan values must fail repair visibly rather than weakening integrity. sqlx::query("ALTER TABLE \"Pet\" DROP CONSTRAINT \"Pet_owner_fkey\"") .execute(backend.pool()) diff --git a/crates/schema-forge-surrealdb/src/codegen.rs b/crates/schema-forge-surrealdb/src/codegen.rs index 79fc49de..a23b6594 100644 --- a/crates/schema-forge-surrealdb/src/codegen.rs +++ b/crates/schema-forge-surrealdb/src/codegen.rs @@ -43,14 +43,10 @@ pub fn migration_step_to_surql(table: &str, step: &MigrationStep) -> Vec MigrationStep::RemoveField { name } => { vec![format!("REMOVE FIELD {name} ON {table};")] } - MigrationStep::RenameField { old_name, new_name } => { - // SurrealDB does not have a native RENAME FIELD command. - // We define the new field, copy data, then remove the old one. - vec![ - format!("DEFINE FIELD {new_name} ON {table} TYPE any;"), - format!("UPDATE {table} SET {new_name} = {old_name};"), - format!("REMOVE FIELD {old_name} ON {table};"), - ] + MigrationStep::RenameField { .. } => { + // A context-free generator cannot preserve type/modifier/index metadata. + // The backend compiles renames with rename_field_stmts and stored schema context. + vec!["THROW 'field rename requires stored schema metadata; execute through SchemaBackend';".into()] } MigrationStep::ChangeType { name, @@ -845,16 +841,15 @@ mod tests { } #[test] - fn rename_field_produces_three_statements() { + fn rename_field() { let step = MigrationStep::RenameField { old_name: FieldName::new("name").unwrap(), new_name: FieldName::new("full_name").unwrap(), }; - let stmts = migration_step_to_surql("Contact", &step); - assert_eq!(stmts.len(), 3); - assert!(stmts[0].contains("DEFINE FIELD full_name")); - assert!(stmts[1].contains("UPDATE Contact SET full_name = name")); - assert!(stmts[2].contains("REMOVE FIELD name")); + let statements = migration_step_to_surql("Contact", &step); + assert_eq!(statements.len(), 1); + assert!(statements[0].starts_with("THROW")); + assert!(!statements[0].contains("TYPE any")); } #[test] diff --git a/docs/migrations/safe-schema-changes.md b/docs/migrations/safe-schema-changes.md index 9f37d4cf..82b0e521 100644 --- a/docs/migrations/safe-schema-changes.md +++ b/docs/migrations/safe-schema-changes.md @@ -14,8 +14,12 @@ schema Line { It remains valid after the rename and can stay in signed schema artifacts. The old name must identify exactly one removed field. A source and destination that both already exist, a missing source and destination, duplicate sources, -and self-renames are rejected before migration. PostgreSQL also renames the -associated generated unique/index objects, allowing later modifier changes. +and self-renames are rejected before migration. PostgreSQL also renames generated indexes and CHECK/foreign-key constraints, +allowing later modifier and enum changes. SurrealDB preserves the full field +definition during its transactional copy. SQL Server renames keys in its tagged +JSON payloads; all steps execute in one transaction and destination collisions +roll back the whole plan. Context-free SurrealDB SQL generation refuses renames +because preserving constraints requires stored schema metadata. Without this declaration, changing a field name means removing one field and adding another. `apply` and `migrate --execute` require confirmation for that @@ -62,8 +66,8 @@ If a systemd restart policy is configured, mask the service for the maintenance window. Take a database backup and test the procedure against a restored copy. Perform tenancy changes separately from field changes. -1. Apply the complete desired schema set to an empty staging database. This - validates the desired hierarchy and provides canonical metadata and DDL. +1. Apply the complete desired schema set to an empty staging database. Start the staging server once to validate the desired hierarchy. The staging + database provides canonical metadata and DDL. Compare the staging table's columns, indexes, and constraints with production. 2. Prepare an explicit mapping from every affected row ID to a valid tenant ID in the desired hierarchy. Verify completeness and tenant existence. `_tenant` From def12d88d9442ca29d16ceca4de6608ec8be92b2 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:02:06 -0600 Subject: [PATCH 23/77] chore(release): prepare v0.45.0 and align library versions --- CHANGELOG.md | 3 ++- Cargo.lock | 18 ++++++++-------- crates/schema-forge-acton/Cargo.toml | 4 ++-- crates/schema-forge-backend/Cargo.toml | 2 +- crates/schema-forge-cel/Cargo.toml | 4 ++-- crates/schema-forge-cli/Cargo.toml | 4 ++-- crates/schema-forge-core/Cargo.toml | 2 +- crates/schema-forge-dsl/Cargo.toml | 4 ++-- crates/schema-forge-mssql/Cargo.toml | 6 +++--- crates/schema-forge-postgres/Cargo.toml | 2 +- crates/schema-forge-surrealdb/Cargo.toml | 2 +- skills/schemaforge/SKILL.md | 27 +++++++++++++++--------- 12 files changed, 43 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9941c373..8f569ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,8 @@ is pre-1.0; breaking changes bump the **minor** version per are removed. Rules see optional absent fields as null; required fields are checked after server-supplied values are available. Filtered empty PATCH requests no longer emit invalid SQL. PUT rules and persisted optional values - now agree, including when a field is omitted or write-restricted. + now agree: PUT clears omitted writable optional fields and preserves denied + or server-managed fields. PATCH remains a partial update. - GraphQL creates and updates share the REST write pipeline. GraphQL reads, relations, and deletes enforce concrete Cedar decisions. Unproven raw GraphQL totals are withheld instead of disclosing counts of inaccessible rows. diff --git a/Cargo.lock b/Cargo.lock index 1144b764..38879877 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8099,7 +8099,7 @@ dependencies = [ [[package]] name = "schema-forge-acton" -version = "0.43.1" +version = "0.44.0" dependencies = [ "acton-service", "arc-swap", @@ -8156,7 +8156,7 @@ dependencies = [ [[package]] name = "schema-forge-backend" -version = "0.17.1" +version = "0.18.0" dependencies = [ "acton-service", "argon2", @@ -8171,7 +8171,7 @@ dependencies = [ [[package]] name = "schema-forge-cel" -version = "0.10.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "chrono", @@ -8187,7 +8187,7 @@ dependencies = [ [[package]] name = "schema-forge-cli" -version = "0.44.2" +version = "0.45.0" dependencies = [ "acton-service", "assert_cmd", @@ -8229,7 +8229,7 @@ dependencies = [ [[package]] name = "schema-forge-core" -version = "0.17.0" +version = "0.18.0" dependencies = [ "base64 0.22.1", "chrono", @@ -8243,7 +8243,7 @@ dependencies = [ [[package]] name = "schema-forge-dsl" -version = "0.13.0" +version = "0.14.0" dependencies = [ "logos 0.15.1", "proptest", @@ -8254,7 +8254,7 @@ dependencies = [ [[package]] name = "schema-forge-mssql" -version = "0.4.0" +version = "0.5.0" dependencies = [ "acton-service", "bb8", @@ -8270,7 +8270,7 @@ dependencies = [ [[package]] name = "schema-forge-postgres" -version = "0.12.2" +version = "0.13.0" dependencies = [ "arc-swap", "argon2", @@ -8308,7 +8308,7 @@ dependencies = [ [[package]] name = "schema-forge-surrealdb" -version = "0.12.0" +version = "0.13.0" dependencies = [ "chrono", "schema-forge-backend", diff --git a/crates/schema-forge-acton/Cargo.toml b/crates/schema-forge-acton/Cargo.toml index cc1d9a9a..0cfdd659 100644 --- a/crates/schema-forge-acton/Cargo.toml +++ b/crates/schema-forge-acton/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-acton" -version = "0.43.1" +version = "0.44.0" edition = "2021" [dependencies] @@ -44,7 +44,7 @@ aws-lc-rs = { version = "1", features = ["fips"], optional = true } rustls = { version = "0.23", default-features = false, features = ["std", "aws_lc_rs", "logging"] } schema-forge-signing = { version = "0.1.0", path = "../schema-forge-signing" } lettre = { version = "0.11.22", default-features = false, features = ["tokio1-rustls", "aws-lc-rs", "webpki-roots", "smtp-transport", "builder", "pool", "hostname"] } -schema-forge-cel = { version = "0.10.0", path = "../schema-forge-cel" } +schema-forge-cel = { version = "0.11.0", path = "../schema-forge-cel" } rust_xlsxwriter = { version = "0.95.0", features = ["chrono"] } zip = "8.6.0" diff --git a/crates/schema-forge-backend/Cargo.toml b/crates/schema-forge-backend/Cargo.toml index 27786b3e..f59c493d 100644 --- a/crates/schema-forge-backend/Cargo.toml +++ b/crates/schema-forge-backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-backend" -version = "0.17.1" +version = "0.18.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-cel/Cargo.toml b/crates/schema-forge-cel/Cargo.toml index 9c604fe4..d1c4e141 100644 --- a/crates/schema-forge-cel/Cargo.toml +++ b/crates/schema-forge-cel/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-cel" -version = "0.10.0" +version = "0.11.0" edition = "2021" description = "Minimal, owned CEL (Common Expression Language) evaluator over SchemaForge DynamicValue." @@ -17,7 +17,7 @@ chrono-tz = "0.10.4" # catastrophic backtracking (no ReDoS), matching the evaluator's # guaranteed-terminating / DoS-hardened posture. regex = "1.12.3" -schema-forge-core = { version = "0.17.0", path = "../schema-forge-core" } +schema-forge-core = { version = "0.18.0", path = "../schema-forge-core" } serde_json = "1.0.150" tracing = "0.1" diff --git a/crates/schema-forge-cli/Cargo.toml b/crates/schema-forge-cli/Cargo.toml index b2aa6254..53a75695 100644 --- a/crates/schema-forge-cli/Cargo.toml +++ b/crates/schema-forge-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-cli" -version = "0.44.2" +version = "0.45.0" edition = "2021" [[bin]] @@ -38,7 +38,7 @@ rust-embed = { version = "8.11.0", features = ["interpolate-folder-path"], optio mime_guess = { version = "2.0.5" } sha2 = "0.11.0" tokio-util = { version = "0.7.18", features = ["io"] } -schema-forge-mssql = { version = "0.4.0", path = "../schema-forge-mssql", optional = true } +schema-forge-mssql = { version = "0.5.0", path = "../schema-forge-mssql", optional = true } [features] default = ["surrealdb"] diff --git a/crates/schema-forge-core/Cargo.toml b/crates/schema-forge-core/Cargo.toml index cf5489a1..c549cc88 100644 --- a/crates/schema-forge-core/Cargo.toml +++ b/crates/schema-forge-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-core" -version = "0.17.0" +version = "0.18.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-dsl/Cargo.toml b/crates/schema-forge-dsl/Cargo.toml index e06a412b..77f024bc 100644 --- a/crates/schema-forge-dsl/Cargo.toml +++ b/crates/schema-forge-dsl/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "schema-forge-dsl" -version = "0.13.0" +version = "0.14.0" edition = "2021" [dependencies] schema-forge-core = { path = "../schema-forge-core" } -schema-forge-cel = { version = "0.10.0", path = "../schema-forge-cel" } +schema-forge-cel = { version = "0.11.0", path = "../schema-forge-cel" } logos = "0.15" tracing = "0.1" diff --git a/crates/schema-forge-mssql/Cargo.toml b/crates/schema-forge-mssql/Cargo.toml index 8b62ebd3..4ee2252a 100644 --- a/crates/schema-forge-mssql/Cargo.toml +++ b/crates/schema-forge-mssql/Cargo.toml @@ -1,14 +1,14 @@ [package] name = "schema-forge-mssql" -version = "0.4.0" +version = "0.5.0" edition = "2021" [dependencies] acton-service = { version = "0.43.1", features = ["mssql", "crypto-aws-lc-rs"] } bb8 = "0.9.1" bb8-tiberius = "0.16.0" -schema-forge-backend = { version = "0.17.0", path = "../schema-forge-backend" } -schema-forge-core = { version = "0.17.0", path = "../schema-forge-core" } +schema-forge-backend = { version = "0.18.0", path = "../schema-forge-backend" } +schema-forge-core = { version = "0.18.0", path = "../schema-forge-core" } serde_json = "1.0.151" tiberius = "0.12.3" diff --git a/crates/schema-forge-postgres/Cargo.toml b/crates/schema-forge-postgres/Cargo.toml index 820b95b5..0ff24227 100644 --- a/crates/schema-forge-postgres/Cargo.toml +++ b/crates/schema-forge-postgres/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-postgres" -version = "0.12.2" +version = "0.13.0" edition = "2021" [dependencies] diff --git a/crates/schema-forge-surrealdb/Cargo.toml b/crates/schema-forge-surrealdb/Cargo.toml index acdd197b..2a7cc510 100644 --- a/crates/schema-forge-surrealdb/Cargo.toml +++ b/crates/schema-forge-surrealdb/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "schema-forge-surrealdb" -version = "0.12.0" +version = "0.13.0" edition = "2021" [dependencies] diff --git a/skills/schemaforge/SKILL.md b/skills/schemaforge/SKILL.md index 956c7d92..ecdd8027 100644 --- a/skills/schemaforge/SKILL.md +++ b/skills/schemaforge/SKILL.md @@ -9,7 +9,7 @@ description: Use when writing, creating, editing, or reviewing SchemaForge .sche SchemaForge is an Adaptive Object Model runtime with a human-readable DSL. One `.schema` file produces database tables, REST API endpoints, migrations, Cedar authorization policies, and OpenAPI specs — no recompilation required. -**Version:** 0.39.1 +**Version:** 0.45.0 **Core principle:** Schemas are the single source of truth for the entire entity lifecycle. Authorization is **Cedar-canonical**: every read/write/delete decision flows through the embedded Cedar engine — there are no parallel custom guards. @@ -25,15 +25,15 @@ integrated SSPI/Kerberos authentication. | Crate | Version | Purpose | |-------|---------|---------| -| `schema-forge-core` | 0.17.0 | Core types: schemas, fields (incl. `FieldType::File`, `Duration`, `Bytes`, `Map`), annotations (incl. `@hidden` and the CEL rule annotations `@require`/`@compute`/`@default`), modifiers (incl. `unique`), migrations (incl. `AddUnique`/`RemoveUnique` with per-tenant scope), queries, hook events | -| `schema-forge-cel` | 0.10.0 | First-party CEL evaluator over `DynamicValue` (ADR-0002): lexer/parser, tree-walking evaluator, stdlib (incl. `base64.encode`/`decode`), apply-time type-checker, and the `related_paths` cross-entity-read AST walker. No upstream `cel` dependency; verified against the cel-spec conformance corpus. | -| `schema-forge-dsl` | 0.13.0 | Lexer/parser for `.schema` DSL (logos-based) incl. `file(...)` syntax, size literals, `duration`/`bytes(max)`/`map` types, the `@hidden` field annotation, the `unique` modifier with parse-time type-guard, and `@require`/`@compute`/`@default` rule annotations (CEL syntax-validated at parse, type-checked at apply) | -| `schema-forge-backend` | 0.17.0 | Backend trait abstraction (depends on acton-service); owns the `PLATFORM_ADMIN_ROLE` constant, `EntityAuthStore` (the user-mgmt impl over the system `User` schema), and the typed `BackendError::UniqueViolation` discriminator | -| `schema-forge-surrealdb` | 0.12.0 | SurrealDB backend implementation (incl. `DEFINE INDEX ... UNIQUE` codegen, unique-violation reclassification, native `duration`/`bytes` storage, and fail-closed rejection of negative durations) | -| `schema-forge-postgres` | 0.12.0 | PostgreSQL backend implementation (via sqlx), incl. JSONB-backed file/map columns, `BIGINT`-nanosecond durations, `BYTEA` bytes with octet-length CHECK, and SQLSTATE 23505 → typed `UniqueViolation` mapping | -| `schema-forge-mssql` | 0.4.0 | Microsoft SQL Server backend via Tiberius and acton-service pools, including integrated SSPI/Kerberos authentication, JSON document storage, CRUD, filtering, sorting, pagination, and aggregates | -| `schema-forge-acton` | 0.41.0 | Axum/acton-service integration: REST API, unified bearer/mTLS/Windows claims, the write-time rule phases (`@default`→`@compute`→`@require`, incl. tenant-scoped cross-entity reads), Cedar policy store, auth, hook dispatch, S3 storage, and typed HTTP errors | -| `schema-forge-cli` | 0.42.0 | CLI binary (`schemaforge`) with SurrealDB, PostgreSQL, and SQL Server release flavors; routes configuration through `acton_service::Config` and supports trusted-proxy Windows authentication | +| `schema-forge-core` | 0.18.0 | Core types: schemas, fields (incl. `FieldType::File`, `Duration`, `Bytes`, `Map`), annotations (incl. `@hidden` and the CEL rule annotations `@require`/`@compute`/`@default`), modifiers (incl. `unique`), migrations (incl. `AddUnique`/`RemoveUnique` with per-tenant scope), queries, hook events | +| `schema-forge-cel` | 0.11.0 | First-party CEL evaluator over `DynamicValue` (ADR-0002): lexer/parser, tree-walking evaluator, stdlib (incl. `base64.encode`/`decode`), apply-time type-checker, and the `related_paths` cross-entity-read AST walker. No upstream `cel` dependency; verified against the cel-spec conformance corpus. | +| `schema-forge-dsl` | 0.14.0 | Lexer/parser for `.schema` DSL (logos-based) incl. `file(...)` syntax, size literals, `duration`/`bytes(max)`/`map` types, the `@hidden` field annotation, the `unique` modifier with parse-time type-guard, and `@require`/`@compute`/`@default` rule annotations (CEL syntax-validated at parse, type-checked at apply) | +| `schema-forge-backend` | 0.18.0 | Backend trait abstraction (depends on acton-service); owns the `PLATFORM_ADMIN_ROLE` constant, `EntityAuthStore` (the user-mgmt impl over the system `User` schema), and the typed `BackendError::UniqueViolation` discriminator | +| `schema-forge-surrealdb` | 0.13.0 | SurrealDB backend implementation (incl. `DEFINE INDEX ... UNIQUE` codegen, unique-violation reclassification, native `duration`/`bytes` storage, and fail-closed rejection of negative durations) | +| `schema-forge-postgres` | 0.13.0 | PostgreSQL backend implementation (via sqlx), incl. JSONB-backed file/map columns, `BIGINT`-nanosecond durations, `BYTEA` bytes with octet-length CHECK, and SQLSTATE 23505 → typed `UniqueViolation` mapping | +| `schema-forge-mssql` | 0.5.0 | Microsoft SQL Server backend via Tiberius and acton-service pools, including integrated SSPI/Kerberos authentication, JSON document storage, CRUD, filtering, sorting, pagination, and aggregates | +| `schema-forge-acton` | 0.44.0 | Axum/acton-service integration: REST API, unified bearer/mTLS/Windows claims, the write-time rule phases (`@default`→`@compute`→`@require`, incl. tenant-scoped cross-entity reads), Cedar policy store, auth, hook dispatch, S3 storage, and typed HTTP errors | +| `schema-forge-cli` | 0.45.0 | CLI binary (`schemaforge`) with SurrealDB, PostgreSQL, and SQL Server release flavors; routes configuration through `acton_service::Config` and supports trusted-proxy Windows authentication | ## Before You Build: acton-service Owns the Platform Layer @@ -883,3 +883,10 @@ For complete details, load these supporting files: ### Audit access (v0.42.0) Platform administrators can use `/api/v1/forge/audit/status`, `/audit/events` and `/audit/verify` for deployment-wide audit browsing and bounded chain checks. Reuses acton-service 0.42.0 storage, including the 0.40.1 suffix-verification fix. Tenant administrators cannot access these routes. See [audit API reference](../../docs/audit-api-reference.md) for the field projection, fixed upper sequence pagination, limits, permission flags and verification trust boundaries. + + +### Safe schema changes (v0.45.0) + +Use `@renamed_from("old_name")` on a replacement field to preserve values across a rename. Startup refuses destructive migration plans unless `serve --allow-destructive-migrations` is explicit; runtime schema PUT requires `allow_destructive_migrations: true`. Existing tenancy annotations cannot change through automatic migration because ownership and unique constraints need an explicit data migration. PostgreSQL schema administration reconciles legacy missing relation foreign keys and refuses orphaned references. See [safe schema changes](../../docs/migrations/safe-schema-changes.md). + +Field write authorization precedes defaults, computed expressions, rules, and hooks. Required fields are validated after server-supplied values; omitted optional fields bind to null in rules. PUT clears omitted writable optional fields, while PATCH retains partial-update semantics. GraphQL mutations share the same write pipeline. See [rule ordering](../../docs/rule-ordering-reference.md) and [GraphQL writes](../../docs/graphql-writes.md). From e7ce38004ab101caea52091b6894de12b89b96f7 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:02:16 -0600 Subject: [PATCH 24/77] test: pass migration steps to GraphQL fixture backends --- crates/schema-forge-acton/tests/graphql_tenants.rs | 2 +- crates/schema-forge-acton/tests/graphql_writes.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/schema-forge-acton/tests/graphql_tenants.rs b/crates/schema-forge-acton/tests/graphql_tenants.rs index f3d3516a..a66967c9 100644 --- a/crates/schema-forge-acton/tests/graphql_tenants.rs +++ b/crates/schema-forge-acton/tests/graphql_tenants.rs @@ -38,7 +38,7 @@ async fn app() -> (Router, String, String) { .unwrap(); for schema in &schemas { backend - .apply_migration(&schema.name, &DiffEngine::create_new(schema)) + .apply_migration(&schema.name, &DiffEngine::create_new(schema).steps) .await .unwrap(); backend.store_schema_metadata(schema).await.unwrap(); diff --git a/crates/schema-forge-acton/tests/graphql_writes.rs b/crates/schema-forge-acton/tests/graphql_writes.rs index 8cb44120..7bbf3098 100644 --- a/crates/schema-forge-acton/tests/graphql_writes.rs +++ b/crates/schema-forge-acton/tests/graphql_writes.rs @@ -40,7 +40,7 @@ async fn app() -> Router { .unwrap(); for schema in &schemas { backend - .apply_migration(&schema.name, &DiffEngine::create_new(schema)) + .apply_migration(&schema.name, &DiffEngine::create_new(schema).steps) .await .unwrap(); backend.store_schema_metadata(schema).await.unwrap(); From a06168511db5389bff24f7aa18cee6eba5c7b452 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:04:47 -0600 Subject: [PATCH 25/77] docs: define field rename hints in the DSL reference --- skills/schemaforge/dsl-reference.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/skills/schemaforge/dsl-reference.md b/skills/schemaforge/dsl-reference.md index 49a28395..ba8f2033 100644 --- a/skills/schemaforge/dsl-reference.md +++ b/skills/schemaforge/dsl-reference.md @@ -529,6 +529,16 @@ Export is gated by a **distinct Cedar `Export{Entity}` action**, not `Read` — Field-level annotations appear after modifiers on the field line. +### @renamed_from("old_name") + +Declare a field rename during migration: + +```schema +business_number: text required @renamed_from("number") +``` + +The old field must be removed from the desired schema. A source may name only one replacement, and a rename cannot overwrite another declared field. The hint becomes a no-op after migration and may remain in a signed schema file. Removing it is necessary before reusing the old name for a different field. PostgreSQL renames its column and generated constraints; SurrealDB and SQL Server preserve the stored values through their backend migration implementations. See [safe schema changes](../../docs/migrations/safe-schema-changes.md). + ### @owner Marks a field as the record ownership tracker. From cfc2bb21de9decf3703cfa7e1840070df5147677 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:05:17 -0600 Subject: [PATCH 26/77] fix: restore denied replacement fields through map entries --- crates/schema-forge-acton/src/routes/entities.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index 47e2feee..4398aa76 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -3425,9 +3425,9 @@ pub async fn update_entity( )?; fields = supplied.fields; for name in proposed_names { - if !fields.contains_key(&name) { - if let Some(value) = existing.fields.get(&name) { - fields.insert(name, value.clone()); + if let std::collections::btree_map::Entry::Vacant(entry) = fields.entry(name) { + if let Some(value) = existing.fields.get(entry.key()) { + entry.insert(value.clone()); } } } From ceccc19e05b1593ccc345d39c79a7accafbc1cde Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:04:10 -0600 Subject: [PATCH 27/77] fix: preflight tenant topology and classify lossy migrations --- .../schema-forge-acton/src/routes/schemas.rs | 68 +++++++++++++++--- crates/schema-forge-cli/src/commands/apply.rs | 1 + .../schema-forge-cli/src/commands/migrate.rs | 10 +++ .../src/commands/schema_update.rs | 70 +++++++++++++++++++ crates/schema-forge-cli/src/commands/serve.rs | 3 + crates/schema-forge-core/src/migration.rs | 42 ++++++++++- crates/schema-forge-postgres/src/backend.rs | 2 +- .../tests/migration_safety.rs | 9 +-- docs/migrations/safe-schema-changes.md | 10 ++- 9 files changed, 196 insertions(+), 19 deletions(-) diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index f15005c2..471f13b5 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -9,8 +9,8 @@ use axum::response::IntoResponse; use axum::Json; use schema_forge_core::migration::DiffEngine; use schema_forge_core::types::{ - Annotation, BytesConstraints, FieldDefinition, FieldModifier, FieldName, FieldType, - SchemaDefinition, SchemaId, SchemaName, TextConstraints, + Annotation, BytesConstraints, FieldAnnotation, FieldDefinition, FieldModifier, FieldName, + FieldType, SchemaDefinition, SchemaId, SchemaName, TextConstraints, }; use serde::{Deserialize, Serialize}; use tokio::sync::oneshot; @@ -112,11 +112,16 @@ async fn precheck_policy_bundle( .await; let mut proposed = ask_forge(rx).await?; + let current_tenant_structure = tenant_structure(&proposed)?; proposed.retain(|s| s.name.as_str() != target.name.as_str()); if !removing { proposed.push(target.clone()); } + if tenant_structure(&proposed)? != current_tenant_structure { + return Err(ForgeError::ValidationFailed { details: vec!["tenant hierarchy changes require applying schema files and restarting serve so actor and middleware configuration change together".into()] }); + } + let policy_store = fetch_policy_store(state).await?; let snapshot = policy_store.current(); let role_ranks = snapshot.role_ranks.clone(); @@ -137,6 +142,26 @@ async fn precheck_policy_bundle( Ok(()) } +/// Compare the effective tenant topology independently of registry iteration order. +fn tenant_structure( + schemas: &[SchemaDefinition], +) -> Result< + std::collections::BTreeMap, Option)>, + ForgeError, +> { + let config = + schema_forge_backend::tenant::TenantConfig::from_schemas(schemas).map_err(|error| { + ForgeError::ValidationFailed { + details: vec![format!("invalid proposed tenant hierarchy: {error}")], + } + })?; + Ok(config + .hierarchy + .into_iter() + .map(|level| (level.schema, (level.parent, level.parent_field))) + .collect()) +} + /// Fetch the current Cedar [`PolicyStore`] from the actor. async fn fetch_policy_store( state: &AppState, @@ -203,12 +228,15 @@ pub struct CreateSchemaRequest { pub fields: Vec, /// Optional annotations. #[serde(default)] - pub annotations: Vec, + pub annotations: Option>, } /// A field in a create/update schema request. #[derive(Debug, Deserialize)] pub struct FieldDefinitionRequest { + /// Optional field annotations; omitted annotations are preserved on existing fields. + #[serde(default)] + pub annotations: Option>, /// The field name. pub name: String, /// The field type specification as a JSON value. @@ -293,11 +321,12 @@ fn request_field_to_definition( } } - if modifiers.is_empty() { - Ok(FieldDefinition::new(name, field_type)) - } else { - Ok(FieldDefinition::with_modifiers(name, field_type, modifiers)) - } + Ok(FieldDefinition::with_annotations( + name, + field_type, + modifiers, + req.annotations.clone().unwrap_or_default(), + )) } /// Parse a JSON value into a `FieldType`. @@ -547,12 +576,16 @@ pub async fn create_schema( schema_id, schema_name.clone(), fields, - Vec::::new(), + body.annotations.clone().unwrap_or_default(), ) .map_err(|e| ForgeError::ValidationFailed { details: vec![e.to_string()], })?; + if definition.is_tenanted() { + return Err(ForgeError::ValidationFailed { details: vec!["creating a tenanted schema at runtime requires applying the schema files and restarting serve so actor and middleware tenant configuration change together".into()] }); + } + // 4a. Run the inverse-relation pairing pass across the full registry so // any `-> X[]` field paired with an FK from an existing schema is marked // as derived before the migration plan is generated. @@ -771,18 +804,28 @@ pub async fn update_schema( }); } - let fields: Vec = body + let mut fields: Vec = body .fields .iter() .map(request_field_to_definition) .collect::, _>>()?; + for (field, request) in fields.iter_mut().zip(&body.fields) { + if request.annotations.is_none() { + if let Some(existing) = old_schema.field(field.name.as_str()) { + field.annotations = existing.annotations.clone(); + } + } + } + // 4. Build new SchemaDefinition (preserving the original ID) let mut new_definition = SchemaDefinition::new( old_schema.id.clone(), schema_name.clone(), fields, - old_schema.annotations.clone(), + body.annotations + .clone() + .unwrap_or_else(|| old_schema.annotations.clone()), ) .map_err(|e| ForgeError::ValidationFailed { details: vec![e.to_string()], @@ -1040,6 +1083,7 @@ mod tests { #[test] fn request_field_to_definition_simple() { let req = FieldDefinitionRequest { + annotations: None, name: "email".into(), field_type: serde_json::json!("Text"), modifiers: vec![], @@ -1052,6 +1096,7 @@ mod tests { #[test] fn request_field_to_definition_with_modifiers() { let req = FieldDefinitionRequest { + annotations: None, name: "email".into(), field_type: serde_json::json!("Text"), modifiers: vec!["required".into(), "indexed".into()], @@ -1064,6 +1109,7 @@ mod tests { #[test] fn request_field_to_definition_unknown_modifier() { let req = FieldDefinitionRequest { + annotations: None, name: "email".into(), field_type: serde_json::json!("Text"), modifiers: vec!["unknown".into()], diff --git a/crates/schema-forge-cli/src/commands/apply.rs b/crates/schema-forge-cli/src/commands/apply.rs index 08906566..ee15fa61 100644 --- a/crates/schema-forge-cli/src/commands/apply.rs +++ b/crates/schema-forge-cli/src/commands/apply.rs @@ -34,6 +34,7 @@ pub(super) async fn apply_to_backend( backend: &dyn DynSchemaBackend, output: &OutputContext, ) -> Result<(), CliError> { + super::schema_update::preflight_schema_batch(backend, schemas).await?; if args.prepare_record_revisions && !backend.supports_record_revisions() { return Err(CliError::Config { message: "--prepare-record-revisions requires a PostgreSQL backend with record revision support".into() }); } diff --git a/crates/schema-forge-cli/src/commands/migrate.rs b/crates/schema-forge-cli/src/commands/migrate.rs index 7c1e09fd..db1e5d9d 100644 --- a/crates/schema-forge-cli/src/commands/migrate.rs +++ b/crates/schema-forge-cli/src/commands/migrate.rs @@ -32,6 +32,16 @@ pub(super) async fn migrate_on_backend( backend: &dyn DynSchemaBackend, output: &OutputContext, ) -> Result<(), CliError> { + let desired: Vec<_> = schemas + .iter() + .filter(|schema| { + args.schema + .as_ref() + .is_none_or(|filter| schema.name.as_str() == filter) + }) + .cloned() + .collect(); + super::schema_update::preflight_schema_batch(backend, &desired).await?; let mut plans = Vec::new(); let mut total_steps = 0usize; let mut schemas_affected = 0usize; diff --git a/crates/schema-forge-cli/src/commands/schema_update.rs b/crates/schema-forge-cli/src/commands/schema_update.rs index b9198884..f05bd305 100644 --- a/crates/schema-forge-cli/src/commands/schema_update.rs +++ b/crates/schema-forge-cli/src/commands/schema_update.rs @@ -8,6 +8,41 @@ use schema_forge_core::{ use crate::error::CliError; +/// Construct the complete desired registry before performing any migration. +pub(super) fn merge_schema_definitions( + existing: impl IntoIterator, + desired: &[SchemaDefinition], +) -> Vec { + let mut registry: std::collections::BTreeMap<_, _> = existing + .into_iter() + .map(|schema| (schema.name.clone(), schema)) + .collect(); + registry.extend( + desired + .iter() + .cloned() + .map(|schema| (schema.name.clone(), schema)), + ); + registry.into_values().collect() +} + +pub(super) fn validate_tenant_hierarchy(proposed: &[SchemaDefinition]) -> Result<(), CliError> { + schema_forge_backend::tenant::TenantConfig::from_schemas(proposed).map_err(|error| { + CliError::Config { + message: format!("invalid proposed tenant hierarchy: {error}"), + } + })?; + Ok(()) +} + +pub(super) async fn preflight_schema_batch( + backend: &dyn DynSchemaBackend, + desired: &[SchemaDefinition], +) -> Result<(), CliError> { + let existing = backend.list_schema_metadata().await?; + validate_tenant_hierarchy(&merge_schema_definitions(existing, desired)) +} + pub(super) struct SchemaUpdate { pub schema: SchemaDefinition, pub migration: MigrationPlan, @@ -259,6 +294,41 @@ mod tests { } } + #[tokio::test] + async fn invalid_combined_hierarchy_is_rejected_before_all_writes() { + for command in [Command::Apply, Command::Migrate] { + let original = schema("@tenant(root) schema Org { name: text }"); + for source in [ + "@tenant(root) schema Other { name: text }", + r#"@tenant(parent: "Missing") schema Child { name: text }"#, + ] { + let backend = Backend::seeded(original.clone()); + assert!(command.run(&backend, schema(source), true).await.is_err()); + let stored = backend.stored.lock().unwrap(); + assert_eq!(stored.migrations, 0); + assert_eq!(stored.writes, 0); + assert_eq!(stored.schema.as_ref(), Some(&original)); + } + } + } + + #[test] + fn proposed_hierarchy_replaces_old_definitions_and_accepts_valid_batches() { + let old = schema("schema Contact { phone: text }"); + let desired = [ + schema("@tenant(root) schema Org { name: text }"), + schema(r#"@tenant(parent: "Org") schema Contact { phone: text }"#), + ]; + let proposed = merge_schema_definitions([old], &desired); + assert_eq!(proposed.len(), 2); + validate_tenant_hierarchy(&proposed).unwrap(); + assert!(validate_tenant_hierarchy(&[ + schema(r#"@tenant(parent: "Other") schema Org { name: text }"#), + schema(r#"@tenant(parent: "Org") schema Other { name: text }"#) + ]) + .is_err()); + } + const ORIGINAL: &str = "@version(1) schema Person { age: integer }"; const METADATA_CHANGES: [&str; 3] = [ "@version(2) schema Person { age: integer }", diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index d41c9990..bbee1a23 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -144,6 +144,9 @@ pub async fn run( // 5. Apply parsed schemas (using the backend directly, before actor spawning) let mut registry = init_data.registry; + let proposed_schemas = + super::schema_update::merge_schema_definitions(registry.values().cloned(), &schemas); + super::schema_update::validate_tenant_hierarchy(&proposed_schemas)?; if !schemas.is_empty() { output.status("Applying schemas..."); let mut plans = Vec::new(); diff --git a/crates/schema-forge-core/src/migration.rs b/crates/schema-forge-core/src/migration.rs index ecc6e571..14727cd9 100644 --- a/crates/schema-forge-core/src/migration.rs +++ b/crates/schema-forge-core/src/migration.rs @@ -242,6 +242,15 @@ impl MigrationStep { /// Classify the safety level of this migration step. pub fn safety(&self) -> MigrationSafety { match self { + Self::ChangeType { + transform: + ValueTransform::SetNull + | ValueTransform::SetDefault { .. } + | ValueTransform::NullRemovedEnumVariants { .. } + | ValueTransform::FloatToInteger + | ValueTransform::IntegerToFloat, + .. + } => MigrationSafety::Destructive, Self::ChangeType { transform: ValueTransform::Identity, .. @@ -1229,8 +1238,8 @@ mod tests { MigrationStep::ChangeType { name: FieldName::new("score").unwrap(), old_type: FieldType::Integer(IntegerConstraints::unconstrained()), - new_type: FieldType::Float(FloatConstraints::unconstrained()), - transform: ValueTransform::IntegerToFloat, + new_type: FieldType::Text(TextConstraints::unconstrained()), + transform: ValueTransform::ToString, }, MigrationStep::AddRequired { field: FieldName::new("email").unwrap(), @@ -1894,6 +1903,35 @@ mod tests { .any(|s| matches!(s, MigrationStep::RenameField { .. }))); } + #[test] + fn lossy_transforms_are_destructive() { + for transform in [ + ValueTransform::SetNull, + ValueTransform::SetDefault { + value: DefaultValue::Text("replacement".into()), + }, + ValueTransform::NullRemovedEnumVariants { + variants: vec!["old".into()], + }, + ValueTransform::FloatToInteger, + ValueTransform::IntegerToFloat, + ] { + let step = MigrationStep::ChangeType { + name: FieldName::new("value").unwrap(), + old_type: FieldType::Float(FloatConstraints::unconstrained()), + new_type: FieldType::Integer(IntegerConstraints::unconstrained()), + transform, + }; + assert_eq!(step.safety(), MigrationSafety::Destructive); + let plan = MigrationPlan::new( + SchemaId::new(), + SchemaName::new("Sample").unwrap(), + vec![step], + ); + assert!(plan.has_destructive_steps()); + } + } + // -- MigrationError tests -- #[test] diff --git a/crates/schema-forge-postgres/src/backend.rs b/crates/schema-forge-postgres/src/backend.rs index 1c63ba0a..65c48c88 100644 --- a/crates/schema-forge-postgres/src/backend.rs +++ b/crates/schema-forge-postgres/src/backend.rs @@ -38,7 +38,7 @@ const PG_UNIQUE_VIOLATION: &str = "23505"; /// client still has *something* actionable to display. pub(crate) fn map_write_error(err: sqlx::Error, schema: &str, context: &str) -> BackendError { if let sqlx::Error::Database(ref db_err) = err { - if db_err.code().as_deref() == Some("23503") { + if matches!(db_err.code().as_deref(), Some("23503" | "23001")) { return BackendError::ForeignKeyViolation { schema: db_err.table().unwrap_or(schema).to_owned(), constraint: db_err.constraint().unwrap_or("").to_owned(), diff --git a/crates/schema-forge-postgres/tests/migration_safety.rs b/crates/schema-forge-postgres/tests/migration_safety.rs index 30b681af..7f2feed9 100644 --- a/crates/schema-forge-postgres/tests/migration_safety.rs +++ b/crates/schema-forge-postgres/tests/migration_safety.rs @@ -116,10 +116,11 @@ async fn exercise(backend: &PgBackend) { )) .await .unwrap(); - assert!(matches!( - backend.delete(&owner.name, &owner_row.id).await, - Err(BackendError::ForeignKeyViolation { .. }) - )); + let deletion = backend.delete(&owner.name, &owner_row.id).await; + assert!( + matches!(deletion, Err(BackendError::ForeignKeyViolation { .. })), + "unexpected deletion outcome: {deletion:?}" + ); let missing = Entity::new( pet.name.clone(), BTreeMap::from([("owner".into(), DynamicValue::Ref(EntityId::new("owner")))]), diff --git a/docs/migrations/safe-schema-changes.md b/docs/migrations/safe-schema-changes.md index 82b0e521..644f16cc 100644 --- a/docs/migrations/safe-schema-changes.md +++ b/docs/migrations/safe-schema-changes.md @@ -27,7 +27,15 @@ operation. `serve` refuses destructive startup plans before applying any of the user's schema plans. After reviewing the plan, use `serve --allow-destructive-migrations` to explicitly accept data loss. Admin schema PUT requests must include `"allow_destructive_migrations": true` -for destructive changes. Omitted or false values refuse the operation. +for destructive changes. Omitted or false values refuse the operation. Lossy +type conversions and enum variant removal also count as destructive. + +Runtime requests preserve omitted schema and field annotations. Explicit +annotation arrays replace them; declared tenancy transitions are rejected. +Creating or deleting tenanted schemas and changing their parent-reference +structure require offline apply and restart, because the actor and HTTP +middleware must activate the same tenant configuration together. Invalid +combined hierarchies are rejected before schema writes. ## PostgreSQL relation integrity From 896518782035bb3a94350c563f1cbe2a6a25a63a Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:04:47 -0600 Subject: [PATCH 28/77] test: use the string default variant in safety regression --- crates/schema-forge-core/src/migration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/schema-forge-core/src/migration.rs b/crates/schema-forge-core/src/migration.rs index 14727cd9..dda69d86 100644 --- a/crates/schema-forge-core/src/migration.rs +++ b/crates/schema-forge-core/src/migration.rs @@ -1908,7 +1908,7 @@ mod tests { for transform in [ ValueTransform::SetNull, ValueTransform::SetDefault { - value: DefaultValue::Text("replacement".into()), + value: DefaultValue::String("replacement".into()), }, ValueTransform::NullRemovedEnumVariants { variants: vec!["old".into()], From d2294b06ce0798b13a645fa508c9e601f0fb1aa5 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:06:16 -0600 Subject: [PATCH 29/77] test: scope backend trait imports to write fixtures --- crates/schema-forge-acton/tests/conditional_entities.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/schema-forge-acton/tests/conditional_entities.rs b/crates/schema-forge-acton/tests/conditional_entities.rs index f36e031f..d1ea36e9 100644 --- a/crates/schema-forge-acton/tests/conditional_entities.rs +++ b/crates/schema-forge-acton/tests/conditional_entities.rs @@ -18,9 +18,7 @@ use schema_forge_acton::{ storage::StorageRegistry, ForgeActor, }; -use schema_forge_backend::{ - conditional::EntityRevision, entity::Entity, tenant::TenantConfig, SchemaBackend, -}; +use schema_forge_backend::{conditional::EntityRevision, entity::Entity, tenant::TenantConfig}; use schema_forge_core::types::{ Annotation, DynamicValue, EntityId, FieldAnnotation, FieldDefinition, FieldModifier, FieldName, FieldType, SchemaDefinition, SchemaId, SchemaName, TenantKind, TextConstraints, @@ -1122,6 +1120,7 @@ async fn write_pipeline_fixture_with_policy( roles: &[&str], custom_policies_dir: Option, ) -> Router { + use schema_forge_backend::SchemaBackend; let backend = Arc::new( SurrealBackend::connect_memory("writes", "writes") .await @@ -1309,6 +1308,7 @@ async fn create_field_authorization_accepts_defaults_but_fails_closed_on_missing #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn put_omission_matches_persisted_values_and_preserves_denied_fields() { + use schema_forge_backend::SchemaBackend; for role in ["editor", "manager"] { let backend = Arc::new(SurrealBackend::connect_memory("put", "put").await.unwrap()); let schema = schema_forge_dsl::parse( @@ -1362,6 +1362,7 @@ async fn put_omission_matches_persisted_values_and_preserves_denied_fields() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn denied_inputs_cannot_authorize_other_fields() { + use schema_forge_backend::SchemaBackend; let backend = Arc::new( SurrealBackend::connect_memory("field_auth", "field_auth") .await From 1effc00c0328b7d8a418f98881e73b77f094d39a Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:07:05 -0600 Subject: [PATCH 30/77] fix: validate exact custom policy bundles before schema mutations --- crates/schema-forge-acton/src/actor.rs | 8 + crates/schema-forge-acton/src/messages.rs | 7 + .../schema-forge-acton/src/routes/schemas.rs | 12 +- .../tests/policy_preflight.rs | 164 ++++++++++++++++++ crates/schema-forge-cli/src/commands/mod.rs | 1 + .../src/commands/policy_preflight.rs | 64 +++++++ crates/schema-forge-cli/src/commands/serve.rs | 22 ++- docs/migrations/safe-schema-changes.md | 11 +- 8 files changed, 271 insertions(+), 18 deletions(-) create mode 100644 crates/schema-forge-acton/tests/policy_preflight.rs create mode 100644 crates/schema-forge-cli/src/commands/policy_preflight.rs diff --git a/crates/schema-forge-acton/src/actor.rs b/crates/schema-forge-acton/src/actor.rs index 5c3e870d..3d454b99 100644 --- a/crates/schema-forge-acton/src/actor.rs +++ b/crates/schema-forge-acton/src/actor.rs @@ -264,6 +264,14 @@ fn configure_registry_reads(actor: &mut ManagedActor) { }) }); + actor.act_on::(|actor, ctx| { + let directory = actor.model.custom_policies_dir.clone(); + let reply = ctx.message().reply.clone(); + Reply::pending(async move { + reply.send(directory).await; + }) + }); + actor.act_on::(|actor, ctx| { let registry = actor.model.storage_registry.clone(); let reply = ctx.message().reply.clone(); diff --git a/crates/schema-forge-acton/src/messages.rs b/crates/schema-forge-acton/src/messages.rs index 857606dd..7b256c53 100644 --- a/crates/schema-forge-acton/src/messages.rs +++ b/crates/schema-forge-acton/src/messages.rs @@ -127,6 +127,13 @@ pub struct GetPolicyStore { pub reply: ReplyChannel>>, } +/// Retrieve the custom policy source selected when the actor was initialized. +/// This includes CLI overrides and must be used for schema preflight checks. +#[derive(Clone, Debug)] +pub struct GetCustomPoliciesDir { + pub reply: ReplyChannel>, +} + // --------------------------------------------------------------------------- // Registry mutations // --------------------------------------------------------------------------- diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index 471f13b5..6d3075b6 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -127,9 +127,15 @@ async fn precheck_policy_bundle( let role_ranks = snapshot.role_ranks.clone(); let principal_claims = snapshot.principal_claims.clone(); + let (tx, rx) = oneshot::channel(); + forge.send(crate::messages::GetCustomPoliciesDir { + reply: ReplyChannel::new(tx), + }).await; + let custom_dir = ask_forge(rx).await?; + crate::authz::store::PolicyStoreSnapshot::from_schemas( &proposed, - None, + custom_dir.as_deref(), role_ranks, principal_claims, ) @@ -945,10 +951,12 @@ pub async fn delete_schema( reply: ReplyChannel::new(tx), }) .await; - let _schema = ask_forge(rx) + let schema = ask_forge(rx) .await? .ok_or(ForgeError::SchemaNotFound { name: name.clone() })?; + precheck_policy_bundle(&state, &forge, &schema, true).await?; + // 2. Remove from registry cache + recompile Cedar bundle. The actor // reverts the registry mutation if the recompile fails so the running // bundle and registry never drift. diff --git a/crates/schema-forge-acton/tests/policy_preflight.rs b/crates/schema-forge-acton/tests/policy_preflight.rs new file mode 100644 index 00000000..f0bbba11 --- /dev/null +++ b/crates/schema-forge-acton/tests/policy_preflight.rs @@ -0,0 +1,164 @@ +//! Schema mutations must honor the actor's custom policy contract before DDL. + +use std::{ + collections::{BTreeMap, HashMap}, + sync::Arc, + time::Duration, +}; + +use acton_service::{ + config::Config, middleware::Claims, prelude::ActorHandleInterface, + service_builder::ServiceBuilder, +}; +use axum::{ + body::Body, + http::{Method, Request, StatusCode}, + Router, +}; +use http_body_util::BodyExt; +use schema_forge_acton::{ + authz::{PolicyStore, PolicyStoreSnapshot, PrincipalClaimMappings, RoleRanks}, + config::SchemaForgeConfig, + messages::{InitForge, ReplyChannel}, + routes::forge_routes, + ForgeActor, +}; +use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; +use schema_forge_core::{migration::DiffEngine, types::DynamicValue}; +use schema_forge_surrealdb::SurrealBackend; +use serde_json::{json, Value}; +use tokio::sync::oneshot; +use tower::ServiceExt; + +async fn request(app: &Router, method: Method, path: &str, body: Value) -> (StatusCode, Value) { + let claims = Claims { + sub: "user:policy-admin".into(), + roles: vec!["platform_admin".into()], + perms: vec![], + exp: 9_999_999_999, + iat: None, + jti: None, + iss: None, + aud: None, + email: None, + username: None, + custom: HashMap::new(), + }; + let mut request = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + request.extensions_mut().insert(claims); + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn custom_policy_field_contract_refuses_mutations_before_storage_changes() { + let schema = schema_forge_dsl::parse("schema Thing { code: text required label: text }") + .unwrap() + .remove(0); + let backend = Arc::new( + SurrealBackend::connect_memory("policy", "preflight") + .await + .unwrap(), + ); + backend + .apply_migration(&schema.name, &DiffEngine::create_new(&schema).steps) + .await + .unwrap(); + backend.store_schema_metadata(&schema).await.unwrap(); + let entity = Entity::new( + schema.name.clone(), + BTreeMap::from([ + ("code".into(), DynamicValue::Text("keep".into())), + ("label".into(), DynamicValue::Text("untouched".into())), + ]), + ); + backend.create(&entity).await.unwrap(); + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("contract.cedar"), + r#"forbid (principal is Forge::Principal, action == Action::"ReadThing", resource is Thing) when { resource.code == "secret" };"#).unwrap(); + let snapshot = PolicyStoreSnapshot::from_schemas( + std::slice::from_ref(&schema), + Some(directory.path()), + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(); + let policy_hash = snapshot.policy_hash.clone(); + let policies = Arc::new(PolicyStore::new(snapshot)); + // Config has no custom directory: the actor path models a CLI override. + let service = ServiceBuilder::new() + .with_config(Config::::default()) + .with_actor::() + .build(); + let (tx, rx) = oneshot::channel(); + service + .state() + .actor::() + .unwrap() + .send(InitForge { + registry: HashMap::from([("Thing".into(), schema.clone())]), + backend: backend.clone(), + tenant_config: None, + record_access_policy: None, + hook_dispatcher: None, + storage_registry: Default::default(), + policy_store: Some(policies.clone()), + custom_policies_dir: Some(directory.path().to_path_buf()), + reply: ReplyChannel::new(tx), + }) + .await; + tokio::time::timeout(Duration::from_secs(5), rx) + .await + .unwrap() + .unwrap(); + let app = forge_routes().with_state(service.state().clone()); + let drop_field = json!({"name":"Thing", "allow_destructive_migrations":true, "fields":[{"name":"label","field_type":"Text"}]}); + let rename_field = json!({"name":"Thing", "allow_destructive_migrations":true, "fields":[ + {"name":"renamed","field_type":"Text","modifiers":["required"],"annotations":[{"annotation":"RenamedFrom","name":"code"}]}, + {"name":"label","field_type":"Text"} + ]}); + for (method, body) in [ + (Method::PUT, drop_field), + (Method::PUT, rename_field), + (Method::DELETE, Value::Null), + ] { + let (status, result) = request(&app, method, "/schemas/Thing", body).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{result}"); + assert!( + result + .to_string() + .contains("Cedar policy validation failed"), + "{result}" + ); + assert_eq!( + backend + .load_schema_metadata(&schema.name) + .await + .unwrap() + .unwrap(), + schema + ); + assert_eq!( + backend.get(&schema.name, &entity.id).await.unwrap().fields, + entity.fields + ); + assert_eq!(policies.current().policy_hash, policy_hash); + let (status, result) = request(&app, Method::GET, "/schemas/Thing", Value::Null).await; + assert_eq!(status, StatusCode::OK, "{result}"); + assert!(result["fields"] + .as_array() + .unwrap() + .iter() + .any(|field| field["name"] == "code")); + } +} diff --git a/crates/schema-forge-cli/src/commands/mod.rs b/crates/schema-forge-cli/src/commands/mod.rs index 91c8195f..19015436 100644 --- a/crates/schema-forge-cli/src/commands/mod.rs +++ b/crates/schema-forge-cli/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod migrate; pub mod parse; pub mod policies; mod schema_update; +mod policy_preflight; pub mod serve; #[cfg(feature = "embedded-console")] pub mod serve_console; diff --git a/crates/schema-forge-cli/src/commands/policy_preflight.rs b/crates/schema-forge-cli/src/commands/policy_preflight.rs new file mode 100644 index 00000000..ddfe11c7 --- /dev/null +++ b/crates/schema-forge-cli/src/commands/policy_preflight.rs @@ -0,0 +1,64 @@ +//! Compile the proposed startup bundle before any application schema writes. + +use std::path::Path; + +use schema_forge_acton::authz::PolicyStoreSnapshot; +use schema_forge_core::types::SchemaDefinition; + +use crate::error::CliError; + +pub(super) fn compile( + schemas: &[SchemaDefinition], + current: &PolicyStoreSnapshot, + custom_dir: Option<&Path>, +) -> Result { + PolicyStoreSnapshot::from_schemas( + schemas, + custom_dir, + current.role_ranks.clone(), + current.principal_claims.clone(), + ) + .map_err(|error| CliError::Server { + message: format!("Cedar policy preflight failed before schema changes: {error}"), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use schema_forge_acton::authz::{PrincipalClaimMappings, RoleRanks}; + + #[test] + fn proposed_bundle_keeps_custom_policy_contract_and_is_ready_for_installation() { + let old = + schema_forge_dsl::parse("schema Thing { code: text required label: text }").unwrap(); + let new = + schema_forge_dsl::parse("schema Thing { renamed: text required label: text }").unwrap(); + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("contract.cedar"), + r#"forbid (principal is Forge::Principal, action == Action::"ReadThing", resource is Thing) when { resource.code == "secret" };"#).unwrap(); + let current = PolicyStoreSnapshot::from_schemas( + &old, + Some(directory.path()), + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(); + assert!(compile(&new, ¤t, Some(directory.path())).is_err()); + // A coordinated policy/schema rename is valid even though the new + // policy would not compile against the old stored registry. + std::fs::write(directory.path().join("contract.cedar"), + r#"forbid (principal is Forge::Principal, action == Action::"ReadThing", resource is Thing) when { resource.renamed == "secret" };"#).unwrap(); + assert!(compile(&new, ¤t, Some(directory.path())).is_ok()); + std::fs::write(directory.path().join("contract.cedar"), + r#"forbid (principal is Forge::Principal, action == Action::"ReadThing", resource is Thing) when { resource.code == "secret" };"#).unwrap(); + let prepared = compile(&old, ¤t, Some(directory.path())).unwrap(); + assert_eq!(prepared.policy_hash, current.policy_hash); + // The caller installs this exact snapshot after migration; a subsequent + // disk edit cannot silently change the bundle it already validated. + std::fs::write(directory.path().join("contract.cedar"), "invalid policy").unwrap(); + let store = schema_forge_acton::authz::PolicyStore::new(current); + store.swap(prepared); + assert!(store.current().policy_count > 0); + } +} diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index bbee1a23..0cc10039 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -135,7 +135,9 @@ pub async fn run( &storage_config, role_ranks, principal_claims, - custom_dir.as_deref(), + // Validate custom policies against the proposed registry below, not the + // old registry: a coordinated field/policy rename must be deployable. + None, ) .await .map_err(|e| CliError::Server { @@ -147,6 +149,9 @@ pub async fn run( let proposed_schemas = super::schema_update::merge_schema_definitions(registry.values().cloned(), &schemas); super::schema_update::validate_tenant_hierarchy(&proposed_schemas)?; + let prepared_policy = init_data.policy_store.as_ref().map(|store| { + super::policy_preflight::compile(&proposed_schemas, &store.current(), custom_dir.as_deref()) + }).transpose()?; if !schemas.is_empty() { output.status("Applying schemas..."); let mut plans = Vec::new(); @@ -207,17 +212,10 @@ pub async fn run( None }; - // Recompile the Cedar policy bundle now that --schemas have been merged - // into the registry. `build_init` ran before the parsed schemas were - // applied, so its initial PolicyStore covers only the system schemas; - // without this step the runtime would reject every authz check against - // an app schema with "type X is not declared in the schema". - if let Some(policy_store) = &init_data.policy_store { - policy_store - .recompile_from_schemas(&all_schemas, custom_dir.as_deref()) - .map_err(|e| CliError::Server { - message: format!("Cedar policy recompile failed after schema apply: {e}"), - })?; + // Install exactly the bundle validated before DDL. Do not reread policy + // files after storage changes and risk discovering a late compile failure. + if let (Some(policy_store), Some(prepared_policy)) = (&init_data.policy_store, prepared_policy) { + policy_store.swap(prepared_policy); // Log the final bundle posture so misconfiguration (missing custom // policies, wrong directory) is obvious at startup. Mirrors the diff --git a/docs/migrations/safe-schema-changes.md b/docs/migrations/safe-schema-changes.md index 644f16cc..b5de0ba6 100644 --- a/docs/migrations/safe-schema-changes.md +++ b/docs/migrations/safe-schema-changes.md @@ -85,7 +85,8 @@ Perform tenancy changes separately from field changes. columns/constraints, backfill ownership, verify all invariants, and copy only the desired annotation array into the existing metadata row. Preserve the production schema ID and fields. Roll back on any failed check. -4. Restart using the matching desired schema files, verify tenant-isolated reads +4. Unmask any service masked for maintenance, then restart using the matching + desired schema files. Verify tenant-isolated reads and writes with real tenant principals, and only then restore normal service. The following example converts a global `Contact.phone unique` schema to @@ -133,7 +134,8 @@ Repeat the unique-constraint conversion for every unique field. Other transitions have different requirements: -- **Adding a root:** add `_tenant` and its index. Root unique fields stay global. +- **Adding a root:** add `_tenant` and its index, then set `_tenant = id` on + every root row. Root unique fields stay global. Establish a valid single-root hierarchy and reviewed ownership values; root rows represent tenant boundaries and must not be assigned to arbitrary roots. - **Removing tenancy from a child:** verify and resolve duplicate values across @@ -146,8 +148,9 @@ Other transitions have different requirements: descendants and principal tenant chains together. - **Root to child:** establish a different valid root, backfill parent ownership, and replace global unique constraints with composite indexes. -- **Child to root:** establish a valid single-root hierarchy, resolve cross-tenant - duplicates, and replace composite indexes with global unique constraints. +- **Child to root:** establish a valid single-root hierarchy, set `_tenant = id` + on every root row, resolve cross-tenant duplicates, and replace composite + indexes with global unique constraints. For every case, commit physical changes, validated ownership, and the canonical annotation update together. Other database backends require equivalent native From 41351457c437a6ebda631a68c77f56bb2f03a75a Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:06:50 -0600 Subject: [PATCH 31/77] fix: validate runtime schema annotations before migrations --- .../schema-forge-acton/src/routes/schemas.rs | 11 ++++ .../schema-forge-acton/tests/integration.rs | 56 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index 6d3075b6..ebe59836 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -49,6 +49,7 @@ async fn pair_with_registry( forge: &acton_service::prelude::ActorHandle, target: &mut SchemaDefinition, ) -> Result<(), ForgeError> { + validate_schema_definition(target)?; let (tx, rx) = oneshot::channel(); forge .send(ListSchemas { @@ -148,6 +149,16 @@ async fn precheck_policy_bundle( Ok(()) } +/// Reuse canonical DSL validation for annotations supplied through the JSON schema API. +fn validate_schema_definition(definition: &SchemaDefinition) -> Result<(), ForgeError> { + schema_forge_dsl::parse(&schema_forge_dsl::print(definition)).map_err(|errors| { + ForgeError::ValidationFailed { + details: errors.iter().map(ToString::to_string).collect(), + } + })?; + Ok(()) +} + /// Compare the effective tenant topology independently of registry iteration order. fn tenant_structure( schemas: &[SchemaDefinition], diff --git a/crates/schema-forge-acton/tests/integration.rs b/crates/schema-forge-acton/tests/integration.rs index c2f13a07..95b35114 100644 --- a/crates/schema-forge-acton/tests/integration.rs +++ b/crates/schema-forge-acton/tests/integration.rs @@ -321,6 +321,62 @@ async fn destructive_schema_updates_require_explicit_opt_in_and_preserve_data_on assert_eq!(result["fields"][0]["name"], "business_number"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn runtime_annotations_are_validated_and_rename_hints_preserve_values() { + let app = test_app().await; + let root = serde_json::json!({"name": "Org", "annotations": [schema_forge_core::types::Annotation::Tenant(schema_forge_core::types::TenantKind::Root)], "fields": [{"name": "name", "field_type": "Text"}]}); + assert_eq!( + json_request(&app, Method::POST, "/schemas", Some(root)) + .await + .0, + StatusCode::UNPROCESSABLE_ENTITY + ); + assert_eq!( + json_request(&app, Method::GET, "/schemas/Org", None) + .await + .0, + StatusCode::NOT_FOUND + ); + let invalid_rule = serde_json::json!({"name": "InvalidRule", "fields": [{"name": "value", "field_type": "Text", "annotations": [{"annotation": "Require", "expr": "value", "message": "invalid return type"}]}]}); + assert_eq!( + json_request(&app, Method::POST, "/schemas", Some(invalid_rule)) + .await + .0, + StatusCode::UNPROCESSABLE_ENTITY + ); + assert_eq!( + json_request(&app, Method::GET, "/schemas/InvalidRule", None) + .await + .0, + StatusCode::NOT_FOUND + ); + let create = + serde_json::json!({"name": "Line", "fields": [{"name": "number", "field_type": "Text"}]}); + assert_eq!( + json_request(&app, Method::POST, "/schemas", Some(create)) + .await + .0, + StatusCode::CREATED + ); + let (_, row) = json_request( + &app, + Method::POST, + "/schemas/Line/entities", + Some(serde_json::json!({"fields": {"number": "555-9999"}})), + ) + .await; + let rename = serde_json::json!({"name": "Line", "fields": [{"name": "business_number", "field_type": "Text", "annotations": [{"annotation": "RenamedFrom", "name": "number"}]}]}); + for _ in 0..2 { + let (status, body) = + json_request(&app, Method::PUT, "/schemas/Line", Some(rename.clone())).await; + assert_eq!(status, StatusCode::OK, "{body}"); + } + let entity_path = format!("/schemas/Line/entities/{}", row["id"].as_str().unwrap()); + let (status, loaded) = json_request(&app, Method::GET, &entity_path, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(loaded["fields"]["business_number"], "555-9999"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn delete_schema_removes_from_registry() { let app = test_app().await; From 520835a110d20f4c62373eda9c3461eeb87065ca Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:07:31 -0600 Subject: [PATCH 32/77] fix: require lossless runtime schema validation --- crates/schema-forge-acton/src/routes/schemas.rs | 15 ++++++++++----- .../tests/migration_integration.rs | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index ebe59836..4cc91ee5 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -151,11 +151,16 @@ async fn precheck_policy_bundle( /// Reuse canonical DSL validation for annotations supplied through the JSON schema API. fn validate_schema_definition(definition: &SchemaDefinition) -> Result<(), ForgeError> { - schema_forge_dsl::parse(&schema_forge_dsl::print(definition)).map_err(|errors| { - ForgeError::ValidationFailed { - details: errors.iter().map(ToString::to_string).collect(), - } - })?; + let parsed = + schema_forge_dsl::parse(&schema_forge_dsl::print(definition)).map_err(|errors| { + ForgeError::ValidationFailed { + details: errors.iter().map(ToString::to_string).collect(), + } + })?; + if !matches!(parsed.as_slice(), [validated] if validated.fields == definition.fields && validated.annotations == definition.annotations) + { + return Err(ForgeError::ValidationFailed { details: vec!["schema annotations and fields must roundtrip through the canonical DSL without semantic changes".into()] }); + } Ok(()) } diff --git a/crates/schema-forge-core/tests/migration_integration.rs b/crates/schema-forge-core/tests/migration_integration.rs index 9977c5c8..4d0132c6 100644 --- a/crates/schema-forge-core/tests/migration_integration.rs +++ b/crates/schema-forge-core/tests/migration_integration.rs @@ -170,7 +170,7 @@ fn scenario_change_field_type() { let plan = DiffEngine::diff(&v1, &v2); assert_eq!(plan.len(), 1); - assert_eq!(plan.overall_safety(), MigrationSafety::RequiresConfirmation); + assert_eq!(plan.overall_safety(), MigrationSafety::Destructive); assert!(matches!( &plan.steps[0], MigrationStep::ChangeType { From 144c254ae3bc6c6e73167fe226aba27c35a25a9b Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:08:49 -0600 Subject: [PATCH 33/77] test: cover preflight refusal before schema writes --- .../src/commands/schema_update.rs | 36 +++++++++++++++++++ .../schema-forge-dsl/tests/migration_hints.rs | 6 ++-- .../tests/migration_safety.rs | 4 +++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/schema-forge-cli/src/commands/schema_update.rs b/crates/schema-forge-cli/src/commands/schema_update.rs index f05bd305..34253c17 100644 --- a/crates/schema-forge-cli/src/commands/schema_update.rs +++ b/crates/schema-forge-cli/src/commands/schema_update.rs @@ -329,6 +329,42 @@ mod tests { .is_err()); } + #[tokio::test] + async fn lossy_transforms_require_force_before_any_writes() { + for command in [Command::Apply, Command::Migrate] { + for (old, new) in [ + ( + "schema Sample { value: float }", + "schema Sample { value: integer }", + ), + ( + "schema Sample { value: integer }", + "schema Sample { value: float }", + ), + ( + r#"schema Sample { value: enum("old", "stay") }"#, + r#"schema Sample { value: enum("stay") }"#, + ), + ( + "schema Sample { value: boolean }", + "schema Sample { value: datetime }", + ), + ] { + let original = schema(old); + let backend = Backend::seeded(original.clone()); + let result = command.run(&backend, schema(new), true).await; + assert!( + matches!(result, Err(CliError::RequiresForce)), + "{command:?}: {result:?}" + ); + let stored = backend.stored.lock().unwrap(); + assert_eq!(stored.migrations, 0); + assert_eq!(stored.writes, 0); + assert_eq!(stored.schema.as_ref(), Some(&original)); + } + } + } + const ORIGINAL: &str = "@version(1) schema Person { age: integer }"; const METADATA_CHANGES: [&str; 3] = [ "@version(2) schema Person { age: integer }", diff --git a/crates/schema-forge-dsl/tests/migration_hints.rs b/crates/schema-forge-dsl/tests/migration_hints.rs index 813aab98..13666948 100644 --- a/crates/schema-forge-dsl/tests/migration_hints.rs +++ b/crates/schema-forge-dsl/tests/migration_hints.rs @@ -10,7 +10,7 @@ fn declared_rename_preserves_data_plan_and_becomes_noop() { let new = schema(r#"schema Line { business_number: text required unique @renamed_from("number") }"#); DiffEngine::validate_transition(&old, &new).unwrap(); - let plan = DiffEngine::diff(&old, &new); + let plan = DiffEngine::plan_update(&old, &new).unwrap(); assert!(matches!( plan.steps.as_slice(), [MigrationStep::RenameField { .. }] @@ -31,7 +31,7 @@ fn invalid_rename_hints_fail_before_migration() { r#"schema Line { first: text @renamed_from("number") second: text @renamed_from("number") }"#, ] { assert!( - DiffEngine::validate_transition(&old, &schema(source)).is_err(), + DiffEngine::plan_update(&old, &schema(source)).is_err(), "{source}" ); } @@ -50,7 +50,7 @@ fn all_tenant_transitions_require_explicit_manual_migration() { let old_schema = schema(&format!("{old} schema Contact {{ phone: text unique }}")); let new_schema = schema(&format!("{new} schema Contact {{ phone: text unique }}")); assert_eq!( - DiffEngine::validate_transition(&old_schema, &new_schema).is_ok(), + DiffEngine::plan_update(&old_schema, &new_schema).is_ok(), old == new ); } diff --git a/crates/schema-forge-postgres/tests/migration_safety.rs b/crates/schema-forge-postgres/tests/migration_safety.rs index 7f2feed9..815ba596 100644 --- a/crates/schema-forge-postgres/tests/migration_safety.rs +++ b/crates/schema-forge-postgres/tests/migration_safety.rs @@ -98,6 +98,10 @@ async fn exercise(backend: &PgBackend) { .push(schema_forge_core::types::FieldModifier::Unique); let owner = definition("Owner", Some(("pet", "Pet"))); apply(backend, &pet).await; + assert!(matches!( + backend.finalize_schema_migrations().await, + Err(BackendError::MigrationFailed { .. }) + )); apply(backend, &owner).await; let owner_row = backend .create(&Entity::new( From 79e244dcf136e7126465c43f3b431c4d62c39e8d Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:11:04 -0600 Subject: [PATCH 34/77] ci: exercise runtime migration and policy preflight guards --- .github/workflows/postgres-conditional.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/postgres-conditional.yml b/.github/workflows/postgres-conditional.yml index 9f6fb3e8..85186bc5 100644 --- a/.github/workflows/postgres-conditional.yml +++ b/.github/workflows/postgres-conditional.yml @@ -65,12 +65,14 @@ jobs: cargo nextest run -p schema-forge-acton --features postgres --test auth_demo -E 'test(readable_paging)' - name: Run GraphQL write and tenant regressions run: cargo nextest run -p schema-forge-acton --features postgres,graphql --test graphql_writes --test graphql_tenants + - name: Run runtime schema and custom policy preflight regressions + run: cargo nextest run -p schema-forge-acton --features postgres --test integration --test policy_preflight - name: Run schema planning and rename regressions run: | cargo nextest run -p schema-forge-core -p schema-forge-dsl cargo nextest run -p schema-forge-surrealdb --test migration_renames - name: Check CLI preparation and security behavior - run: cargo nextest run -p schema-forge-cli --no-default-features --features postgres -E 'test(explicit_revision_preparation) | test(help_does_not_disclose_secret_environment_values) | test(serve_rejects_invalid_host_before_connecting)' + run: cargo nextest run -p schema-forge-cli --no-default-features --features postgres - name: Clean disposable HTTP namespace if: always() run: psql -X -v ON_ERROR_STOP=1 -c 'DROP SCHEMA IF EXISTS conditional_http CASCADE' From eeb66fd5e55bfe96d1ae42330398287c0a22f5c8 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:13:55 -0600 Subject: [PATCH 35/77] docs: explain policy preflight and runtime tenancy boundaries --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f569ef9..986fd230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,9 @@ is pre-1.0; breaking changes bump the **minor** version per and remain valid after the rename completes. - `serve` preflights startup plans and refuses destructive changes unless `--allow-destructive-migrations` is supplied. Runtime schema PUT requires - `allow_destructive_migrations: true` for destructive plans. + `allow_destructive_migrations: true` for destructive plans, including lossy + type conversions. Proposed tenant hierarchies and custom Cedar policies are + validated before application schema changes. - PostgreSQL to-one relation foreign keys are installed consistently for fresh, altered, and legacy schemas. Missing targets or orphan references fail schema administration clearly. Integrity violations return HTTP 409 @@ -56,6 +58,8 @@ Review pending schema changes before restarting. Destructive startup migrations now require deliberate opt-in. PostgreSQL schema administration repairs missing foreign keys and can require cleanup of existing orphan references. Tenanted children created by a platform administrator require an explicit tenant. +Runtime changes to tenant topology require offline schema application and a +restart so the actor and HTTP middleware activate the same configuration. Rust embedders must update the schema-aware tenant helper and rule-binding call signatures. GraphQL registration now requires initialized From 575d21b4b140c534224f3f2fe14d481c28951bc1 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:11:01 -0600 Subject: [PATCH 36/77] fix: express tenant presence guard safely for Cedar validation --- crates/schema-forge-acton/src/cedar/policy_gen.rs | 2 +- crates/schema-forge-acton/tests/graphql_tenants.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/schema-forge-acton/src/cedar/policy_gen.rs b/crates/schema-forge-acton/src/cedar/policy_gen.rs index 67c11bbc..ba407085 100644 --- a/crates/schema-forge-acton/src/cedar/policy_gen.rs +++ b/crates/schema-forge-acton/src/cedar/policy_gen.rs @@ -222,7 +222,7 @@ forbid ( fn tenant_guard_forbid_policy(schema: &SchemaDefinition) -> CedarPolicy { let name = schema.name.as_str(); let condition = if schema.is_tenanted() { - format!("resource != {name}::\"_any\" && (!(resource has \"_tenant\") || !(principal in resource[\"_tenant\"]))") + format!("resource != {name}::\"_any\" && !(resource has \"_tenant\" && principal in resource[\"_tenant\"])") } else { "resource has \"_tenant\" && !(principal in resource[\"_tenant\"])".to_string() }; diff --git a/crates/schema-forge-acton/tests/graphql_tenants.rs b/crates/schema-forge-acton/tests/graphql_tenants.rs index a66967c9..8ca5ec9a 100644 --- a/crates/schema-forge-acton/tests/graphql_tenants.rs +++ b/crates/schema-forge-acton/tests/graphql_tenants.rs @@ -123,7 +123,7 @@ async fn query(app: &Router, query: &str, tenant: &str) -> Value { serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap() } -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn graphql_scopes_legacy_roots_and_keeps_shared_catalog_usable() { let (app, own, foreign) = app().await; let get = query( From ed28f30716da71b1764f92d6f0dd9294b7b57032 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:11:18 -0600 Subject: [PATCH 37/77] refactor: name the runtime tenant topology model --- .../schema-forge-acton/src/routes/schemas.rs | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index 4cc91ee5..3f083af8 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -164,24 +164,39 @@ fn validate_schema_definition(definition: &SchemaDefinition) -> Result<(), Forge Ok(()) } -/// Compare the effective tenant topology independently of registry iteration order. -fn tenant_structure( - schemas: &[SchemaDefinition], -) -> Result< - std::collections::BTreeMap, Option)>, - ForgeError, -> { +/// One schema's place in the effective runtime tenant hierarchy. +#[derive(PartialEq, Eq)] +struct TenantParent { + schema: Option, + field: Option, +} + +/// Tenant topology normalized independently of registry iteration order. +#[derive(PartialEq, Eq)] +struct TenantStructure(std::collections::BTreeMap); + +fn tenant_structure(schemas: &[SchemaDefinition]) -> Result { let config = schema_forge_backend::tenant::TenantConfig::from_schemas(schemas).map_err(|error| { ForgeError::ValidationFailed { details: vec![format!("invalid proposed tenant hierarchy: {error}")], } })?; - Ok(config - .hierarchy - .into_iter() - .map(|level| (level.schema, (level.parent, level.parent_field))) - .collect()) + Ok(TenantStructure( + config + .hierarchy + .into_iter() + .map(|level| { + ( + level.schema, + TenantParent { + schema: level.parent, + field: level.parent_field, + }, + ) + }) + .collect(), + )) } /// Fetch the current Cedar [`PolicyStore`] from the actor. From eca5b5ed45ea9622f590a833a09cea199de4a0bf Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:10:45 -0600 Subject: [PATCH 38/77] test: verify transactional manual tenant migration workflow --- .../tests/migration_safety.rs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/crates/schema-forge-postgres/tests/migration_safety.rs b/crates/schema-forge-postgres/tests/migration_safety.rs index 815ba596..978a04a9 100644 --- a/crates/schema-forge-postgres/tests/migration_safety.rs +++ b/crates/schema-forge-postgres/tests/migration_safety.rs @@ -249,6 +249,8 @@ async fn exercise(backend: &PgBackend) { Some(&DynamicValue::Enum("new".into())) ); + exercise_manual_tenancy(backend).await; + // Legacy orphan values must fail repair visibly rather than weakening integrity. sqlx::query("ALTER TABLE \"Pet\" DROP CONSTRAINT \"Pet_owner_fkey\"") .execute(backend.pool()) @@ -263,3 +265,118 @@ async fn exercise(backend: &PgBackend) { Err(BackendError::MigrationFailed { .. }) )); } + +async fn exercise_manual_tenancy(backend: &PgBackend) { + use schema_forge_core::types::{Annotation, FieldModifier, TenantKind}; + let mut root = definition("TenantOrg", None); + root.annotations.push(Annotation::Tenant(TenantKind::Root)); + apply(backend, &root).await; + let tenant = backend + .create(&Entity::new( + root.name.clone(), + BTreeMap::from([("label".into(), DynamicValue::Text("first".into()))]), + )) + .await + .unwrap(); + let mut old = definition("TenantContact", None); + old.fields[0].modifiers.push(FieldModifier::Unique); + apply(backend, &old).await; + let original = backend + .create(&Entity::new( + old.name.clone(), + BTreeMap::from([("label".into(), DynamicValue::Text("retained".into()))]), + )) + .await + .unwrap(); + let mut child = old.clone(); + child + .annotations + .push(Annotation::Tenant(TenantKind::Child { + parent: root.name.clone(), + })); + assert!(DiffEngine::plan_update(&old, &child).is_err()); + assert!(backend.store_schema_metadata(&child).await.is_err()); + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM pg_attribute WHERE attrelid='\"TenantContact\"'::regclass AND attname='_tenant'").fetch_one(backend.pool()).await.unwrap(); + assert_eq!(count, 0); + + // The documented manual path commits ownership, physical uniqueness and canonical metadata together. + let mut tx = backend.pool().begin().await.unwrap(); + for statement in [ + "LOCK TABLE \"TenantContact\", \"TenantOrg\", \"_schema_metadata\" IN ACCESS EXCLUSIVE MODE", + "ALTER TABLE \"TenantContact\" ADD COLUMN _tenant TEXT", + "ALTER TABLE \"TenantContact\" DROP CONSTRAINT \"uq_TenantContact_label\"", + "CREATE INDEX \"idx_TenantContact_tenant\" ON \"TenantContact\" (_tenant)", + "CREATE UNIQUE INDEX \"uq_TenantContact_label\" ON \"TenantContact\" (_tenant, label)", + ] { sqlx::query(statement).execute(&mut *tx).await.unwrap(); } + sqlx::query("UPDATE \"TenantContact\" SET _tenant = $1") + .bind(tenant.id.as_str()) + .execute(&mut *tx) + .await + .unwrap(); + let invalid: i64 = sqlx::query_scalar("SELECT count(*) FROM \"TenantContact\" c LEFT JOIN \"TenantOrg\" o ON c._tenant=o.id WHERE c._tenant IS NULL OR o.id IS NULL").fetch_one(&mut *tx).await.unwrap(); + assert_eq!(invalid, 0); + let updated = sqlx::query("UPDATE \"_schema_metadata\" SET definition=jsonb_set(definition, '{annotations}', $1) WHERE name='TenantContact'").bind(serde_json::to_value(&child.annotations).unwrap()).execute(&mut *tx).await.unwrap(); + assert_eq!(updated.rows_affected(), 1); + tx.commit().await.unwrap(); + backend.store_schema_metadata(&child).await.unwrap(); + let stored = backend + .load_schema_metadata(&child.name) + .await + .unwrap() + .unwrap(); + assert!(DiffEngine::plan_update(&stored, &child).unwrap().is_empty()); + assert_eq!( + backend + .get(&child.name, &original.id) + .await + .unwrap() + .fields + .get("label"), + Some(&DynamicValue::Text("retained".into())) + ); + let other = backend + .create(&Entity::new( + root.name.clone(), + BTreeMap::from([("label".into(), DynamicValue::Text("second".into()))]), + )) + .await + .unwrap(); + backend + .create(&Entity::new( + child.name.clone(), + BTreeMap::from([ + ("label".into(), DynamicValue::Text("retained".into())), + ("_tenant".into(), DynamicValue::Text(other.id.to_string())), + ]), + )) + .await + .unwrap(); + let duplicate = Entity::new( + child.name.clone(), + BTreeMap::from([ + ("label".into(), DynamicValue::Text("retained".into())), + ("_tenant".into(), DynamicValue::Text(tenant.id.to_string())), + ]), + ); + assert!(matches!( + backend.create(&duplicate).await, + Err(BackendError::UniqueViolation { .. }) + )); + // Disabling tenant scoping cannot silently discard cross-tenant duplicate values. + let mut tx = backend.pool().begin().await.unwrap(); + sqlx::query("DROP INDEX \"uq_TenantContact_label\"") + .execute(&mut *tx) + .await + .unwrap(); + assert!(sqlx::query( + "ALTER TABLE \"TenantContact\" ADD CONSTRAINT \"uq_TenantContact_label\" UNIQUE(label)" + ) + .execute(&mut *tx) + .await + .is_err()); + tx.rollback().await.unwrap(); + assert!(matches!( + backend.create(&duplicate).await, + Err(BackendError::UniqueViolation { .. }) + )); +} From 4f5bba8b4371d26ed22040b7ebe3849d349692b8 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:11:24 -0600 Subject: [PATCH 39/77] fix: authorize provisional updates before final required validation --- crates/schema-forge-acton/src/access.rs | 2 +- crates/schema-forge-acton/src/authz/engine.rs | 4 ++-- crates/schema-forge-acton/src/routes/entities.rs | 2 +- .../tests/conditional_entities.rs | 16 +++++++++++++++- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index 917c250f..47ad4428 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -318,7 +318,7 @@ pub(crate) fn filter_patch_fields( continue; } let decision = - authorize_field(store, claims, schema, resource, name, FieldDirection::Write).map_err( + crate::authz::engine::authorize_input_field(store, claims, schema, resource, name).map_err( |_| ForgeError::Forbidden { message: "Could not authorize a patched field.".into(), }, diff --git a/crates/schema-forge-acton/src/authz/engine.rs b/crates/schema-forge-acton/src/authz/engine.rs index a101bb46..7da3a3ec 100644 --- a/crates/schema-forge-acton/src/authz/engine.rs +++ b/crates/schema-forge-acton/src/authz/engine.rs @@ -286,9 +286,9 @@ pub fn authorize_field( authorize_field_resource(store, claims, schema, entity, field_name, direction, true) } -/// Authorize caller input before defaults can complete the new resource. +/// Authorize provisional caller input before server rules and final validation. /// Policies reading unavailable attributes fail closed at the caller. -pub(crate) fn authorize_create_field( +pub(crate) fn authorize_input_field( store: &Arc, claims: Option<&Claims>, schema: &SchemaDefinition, diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index 4398aa76..80669cab 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -867,7 +867,7 @@ fn filter_create_input( continue; } let decision = - crate::authz::engine::authorize_create_field(store, claims, schema, supplied, name) + crate::authz::engine::authorize_input_field(store, claims, schema, supplied, name) .map_err(|_| ForgeError::Forbidden { message: "Could not authorize a supplied field.".into(), })?; diff --git a/crates/schema-forge-acton/tests/conditional_entities.rs b/crates/schema-forge-acton/tests/conditional_entities.rs index d1ea36e9..23b108e9 100644 --- a/crates/schema-forge-acton/tests/conditional_entities.rs +++ b/crates/schema-forge-acton/tests/conditional_entities.rs @@ -133,6 +133,20 @@ async fn app_with_policies( roles: &[&str], custom_policies_dir: Option, ) -> Router { + let policy_store = custom_policies_dir.as_ref().map(|directory| { + use schema_forge_acton::authz::{ + PolicyStore, PolicyStoreSnapshot, PrincipalClaimMappings, RoleRanks, + }; + Arc::new(PolicyStore::new( + PolicyStoreSnapshot::from_schemas( + std::slice::from_ref(&schema), + Some(directory), + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(), + )) + }); let service = ServiceBuilder::new() .with_config(Config::::default()) .with_actor::() @@ -149,7 +163,7 @@ async fn app_with_policies( record_access_policy: None, hook_dispatcher: None, storage_registry: StorageRegistry::default(), - policy_store: None, + policy_store, custom_policies_dir, reply: ReplyChannel::new(tx), }) From 88f2306849b91ad47b083ffcf38ee6499ea14e8d Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:12:59 -0600 Subject: [PATCH 40/77] fix: expose tenant and server-managed audit inputs in GraphQL --- .../src/graphql/input_types.rs | 18 +++- .../src/graphql/resolvers.rs | 32 ------ .../tests/graphql_writes.rs | 102 +++++++++++++++++- 3 files changed, 116 insertions(+), 36 deletions(-) diff --git a/crates/schema-forge-acton/src/graphql/input_types.rs b/crates/schema-forge-acton/src/graphql/input_types.rs index ad3df08f..b6e8b7b8 100644 --- a/crates/schema-forge-acton/src/graphql/input_types.rs +++ b/crates/schema-forge-acton/src/graphql/input_types.rs @@ -39,7 +39,15 @@ pub fn build_create_input(schema: &SchemaDefinition) -> InputObject { schema_forge_core::types::FieldModifier::Default { .. } ) }); - let required = field.is_required() && !server_supplied; + let audit_supplied = matches!( + (field_name, &field.field_type), + ("created_at" | "updated_at", FieldType::DateTime) + | ( + "created_by" | "updated_by", + FieldType::Text(_) | FieldType::RichText + ) + ); + let required = field.is_required() && !server_supplied && !audit_supplied; let type_ref = input_field_type_ref( schema.name.as_str(), field_name, @@ -49,6 +57,10 @@ pub fn build_create_input(schema: &SchemaDefinition) -> InputObject { input = input.field(InputValue::new(field_name, type_ref)); } + if schema.unique_scoped_by_tenant() && schema.field("_tenant").is_none() { + input = input.field(InputValue::new("_tenant", TypeRef::named(TypeRef::STRING))); + } + input } @@ -64,6 +76,10 @@ pub fn build_update_input(schema: &SchemaDefinition) -> InputObject { input = input.field(InputValue::new(field_name, type_ref)); } + if schema.unique_scoped_by_tenant() && schema.field("_tenant").is_none() { + input = input.field(InputValue::new("_tenant", TypeRef::named(TypeRef::STRING))); + } + input } diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index ea39a95e..4c498249 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -22,38 +22,6 @@ pub struct EntityFields { pub fields: BTreeMap, } -/// Reject any GraphQL input that names a `@hidden` schema field. -/// -/// Mirrors the REST-side `reject_hidden_fields_in_body` guard so a -/// password_hash (or any other operator-marked secret) can't be supplied -/// through the GraphQL mutation surface either. -#[cfg(test)] -fn reject_hidden_input( - schema_def: &SchemaDefinition, - input: &async_graphql::indexmap::IndexMap, -) -> Result<(), ForgeError> { - let offenders: Vec = input - .keys() - .filter_map(|key| { - let name = key.as_str(); - schema_def - .field(name) - .filter(|f| f.is_hidden()) - .map(|_| name.to_string()) - }) - .collect(); - if offenders.is_empty() { - Ok(()) - } else { - Err(ForgeError::ValidationFailed { - details: vec![format!( - "fields cannot be set via the GraphQL API (marked @hidden): {}", - offenders.join(", ") - )], - }) - } -} - /// Convert ForgeError to async_graphql::Error with extension codes. pub fn forge_error_to_gql(err: ForgeError) -> async_graphql::Error { let code = match &err { diff --git a/crates/schema-forge-acton/tests/graphql_writes.rs b/crates/schema-forge-acton/tests/graphql_writes.rs index 7bbf3098..5da47910 100644 --- a/crates/schema-forge-acton/tests/graphql_writes.rs +++ b/crates/schema-forge-acton/tests/graphql_writes.rs @@ -34,7 +34,17 @@ async fn app() -> Router { @require("status != 'live' || number != null", "live needs number") has_number: boolean required @compute("number != null") enabled: boolean required default(true) + created_at: datetime required + updated_at: datetime required + created_by: text required + updated_by: text required } + @tenant(root) + @access(read: ["staff"], write: ["staff"]) + schema Org { name: text required } + @tenant(parent: "Org") + @access(read: ["staff"], write: ["staff"]) + schema Ticket { title: text required } "#, ) .unwrap(); @@ -89,6 +99,10 @@ async fn app() -> Router { } async fn query(app: &Router, query: &str, role: &str) -> Value { + query_with_tenant(app, query, role, None).await +} + +async fn query_with_tenant(app: &Router, query: &str, role: &str, tenant: Option<&str>) -> Value { let claims = Claims { sub: "user:graphql-test".into(), roles: vec![role.into()], @@ -100,7 +114,14 @@ async fn query(app: &Router, query: &str, role: &str) -> Value { aud: None, email: None, username: None, - custom: HashMap::new(), + custom: tenant + .map(|tenant| { + HashMap::from([( + "tenant_chain".into(), + json!([{"schema":"Org", "entity_id":tenant}]), + )]) + }) + .unwrap_or_default(), }; let mut request = Request::post("/forge/graphql") .header("content-type", "application/json") @@ -111,15 +132,19 @@ async fn query(app: &Router, query: &str, role: &str) -> Value { serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap() } -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn graphql_writes_share_defaults_authorization_rules_and_noop_semantics() { let app = app().await; - let created = query(&app, r#"mutation { createLine(input: {label: "test", number: "denied"}) { id status has_number enabled number } }"#, "staff").await; + let created = query(&app, r#"mutation { createLine(input: {label: "test", number: "denied"}) { id status has_number enabled number created_at updated_at created_by updated_by } }"#, "staff").await; assert!(created.get("errors").is_none(), "{created}"); let row = &created["data"]["createLine"]; assert_eq!(row["status"], "pending"); assert_eq!(row["has_number"], false); assert_eq!(row["enabled"], true); + assert_eq!(row["created_by"], "graphql-test"); + assert_eq!(row["updated_by"], "graphql-test"); + assert!(row["created_at"].as_str().is_some()); + assert!(row["updated_at"].as_str().is_some()); assert!(row["number"].is_null()); let id = row["id"].as_str().unwrap(); @@ -136,3 +161,74 @@ async fn graphql_writes_share_defaults_authorization_rules_and_noop_semantics() assert_eq!(authorized["data"]["updateLine"]["has_number"], true); assert_eq!(authorized["data"]["updateLine"]["number"], "allowed"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graphql_tenant_inputs_use_canonical_admin_and_member_rules() { + let app = app().await; + let mut roots = Vec::new(); + for name in ["one", "two"] { + let created = query( + &app, + &format!(r#"mutation {{ createOrg(input: {{name: "{name}"}}) {{ id }} }}"#), + "platform_admin", + ) + .await; + assert!(created.get("errors").is_none(), "{created}"); + roots.push( + created["data"]["createOrg"]["id"] + .as_str() + .unwrap() + .to_string(), + ); + } + let missing = query( + &app, + r#"mutation { createTicket(input: {title:"missing tenant"}) { id } }"#, + "platform_admin", + ) + .await; + assert_eq!( + missing["errors"][0]["extensions"]["code"], "VALIDATION_ERROR", + "{missing}" + ); + let created = query( + &app, + &format!( + r#"mutation {{ createTicket(input: {{title: "ticket", _tenant: "{}"}}) {{ id }} }}"#, + roots[0] + ), + "platform_admin", + ) + .await; + assert!(created.get("errors").is_none(), "{created}"); + let id = created["data"]["createTicket"]["id"].as_str().unwrap(); + let spoof = query_with_tenant(&app, &format!(r#"mutation {{ updateTicket(id: "{id}", input: {{title:"changed", _tenant:"{}"}}) {{ id }} }}"#, roots[1]), "staff", Some(&roots[0])).await; + assert!(spoof.get("errors").is_none(), "{spoof}"); + let lookup = format!(r#"{{ ticket(id:"{id}") {{ title }} }}"#); + let own = query_with_tenant(&app, &lookup, "staff", Some(&roots[0])).await; + assert!(own.get("errors").is_none(), "{own}"); + let foreign = query_with_tenant(&app, &lookup, "staff", Some(&roots[1])).await; + assert!(foreign.get("errors").is_some(), "{foreign}"); + let moved = query( + &app, + &format!( + r#"mutation {{ updateTicket(id: "{id}", input: {{_tenant:"{}"}}) {{ id }} }}"#, + roots[1] + ), + "platform_admin", + ) + .await; + assert!(moved.get("errors").is_none(), "{moved}"); + let own = query_with_tenant(&app, &lookup, "staff", Some(&roots[1])).await; + assert!(own.get("errors").is_none(), "{own}"); + let missing_required = query( + &app, + "mutation { createLine(input: {}) { id } }", + "platform_admin", + ) + .await; + assert!( + missing_required.get("errors").is_some(), + "{missing_required}" + ); +} From 624c8ef48f328c1d9a423268de51067ebbb848c4 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:13:53 -0600 Subject: [PATCH 41/77] test: reject null only after denied write inputs are removed --- .../tests/conditional_entities.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/schema-forge-acton/tests/conditional_entities.rs b/crates/schema-forge-acton/tests/conditional_entities.rs index 23b108e9..c577b684 100644 --- a/crates/schema-forge-acton/tests/conditional_entities.rs +++ b/crates/schema-forge-acton/tests/conditional_entities.rs @@ -1147,6 +1147,7 @@ async fn write_pipeline_fixture_with_policy( stage: text required @default("'pending'") literal: text required default("literal") owner: text required @owner + guarded: text required @default("'locked'") @field_access(read: ["editor", "manager"], write: ["manager"]) optional: text @require("optional == null || size(optional) > 2", "optional too short") number: text @field_access(read: ["editor", "manager"], write: ["manager"]) status: text @default("'pending'") @require("status != 'live' || number != null", "live needs number") @@ -1233,6 +1234,20 @@ async fn required_fields_reject_null_and_put_does_not_apply_create_defaults() { .await; assert_eq!(status, StatusCode::CREATED, "{body}"); let path = format!("{base}/{}", body["id"].as_str().unwrap()); + let (status, _, body) = request( + &app, + &path, + "PATCH", + None, + serde_json::json!({"guarded":null}), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "denied null must not be validated as accepted input: {body}" + ); + assert_eq!(body["fields"]["guarded"], "locked"); for method in ["PATCH", "PUT"] { let (status, _, body) = request( &app, From 0abe8a7ffdeb0d279bc428022bc1d1ee324769fd Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:18:35 -0600 Subject: [PATCH 42/77] fix: return concrete GraphQL values without abstract type wrappers --- crates/schema-forge-acton/src/graphql/resolvers.rs | 3 +-- crates/schema-forge-acton/src/graphql/schema_builder.rs | 4 ---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index 4c498249..caaaedfa 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -546,13 +546,12 @@ fn require_record_access( } /// Convert an Entity to a FieldValue wrapping EntityFields. -fn entity_to_field_value(entity: Entity, type_name: &str) -> FieldValue<'static> { +fn entity_to_field_value(entity: Entity, _type_name: &str) -> FieldValue<'static> { FieldValue::owned_any(EntityFields { id: entity.id.clone(), schema: entity.schema.clone(), fields: entity.fields, }) - .with_type(type_name.to_string()) } #[cfg(test)] diff --git a/crates/schema-forge-acton/src/graphql/schema_builder.rs b/crates/schema-forge-acton/src/graphql/schema_builder.rs index 8143433f..0e89f7ab 100644 --- a/crates/schema-forge-acton/src/graphql/schema_builder.rs +++ b/crates/schema-forge-acton/src/graphql/schema_builder.rs @@ -389,14 +389,11 @@ fn build_output_type( /// Build a `{Schema}Connection` type. fn build_connection_type(connection_name: &str, item_type_name: &str) -> Object { - let item_tn = item_type_name.to_string(); - Object::new(connection_name) .field(Field::new( "items", TypeRef::named_nn_list(item_type_name), move |ctx| { - let item_tn = item_tn.clone(); FieldFuture::new(async move { let conn = ctx.parent_value.try_downcast_ref::()?; let items: Vec = conn @@ -408,7 +405,6 @@ fn build_connection_type(connection_name: &str, item_type_name: &str) -> Object schema: ef.schema.clone(), fields: ef.fields.clone(), }) - .with_type(item_tn.clone()) }) .collect(); Ok(Some(FieldValue::list(items))) From 5d0dcc930ccf576a2fbc74e5a5f376a27c777267 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:17:52 -0600 Subject: [PATCH 43/77] fix: serialize schema commits with immutable validated policies --- crates/schema-forge-acton/src/actor.rs | 3 + .../src/actor/schema_changes.rs | 107 ++++++++++++ crates/schema-forge-acton/src/authz/store.rs | 5 + crates/schema-forge-acton/src/messages.rs | 13 ++ .../schema-forge-acton/src/routes/schemas.rs | 143 ++++++---------- .../tests/policy_preflight.rs | 154 +++++++++++++++++- 6 files changed, 326 insertions(+), 99 deletions(-) create mode 100644 crates/schema-forge-acton/src/actor/schema_changes.rs diff --git a/crates/schema-forge-acton/src/actor.rs b/crates/schema-forge-acton/src/actor.rs index 3d454b99..f91ae21f 100644 --- a/crates/schema-forge-acton/src/actor.rs +++ b/crates/schema-forge-acton/src/actor.rs @@ -28,6 +28,8 @@ use crate::messages::{ use crate::state::DynForgeBackend; use crate::storage::StorageRegistry; +mod schema_changes; + // --------------------------------------------------------------------------- // ForgeActor // --------------------------------------------------------------------------- @@ -286,6 +288,7 @@ fn configure_registry_reads(actor: &mut ManagedActor) { // --------------------------------------------------------------------------- fn configure_registry_mutations(actor: &mut ManagedActor) { + schema_changes::configure(actor); actor.mutate_on::(|actor, ctx| { let msg = ctx.message(); let name = msg.name.clone(); diff --git a/crates/schema-forge-acton/src/actor/schema_changes.rs b/crates/schema-forge-acton/src/actor/schema_changes.rs new file mode 100644 index 00000000..5fdb725a --- /dev/null +++ b/crates/schema-forge-acton/src/actor/schema_changes.rs @@ -0,0 +1,107 @@ +//! Serialized storage/registry commits using immutable preflight policy bundles. + +use std::sync::Arc; + +use acton_service::prelude::{Idle, ManagedActor, Reply}; +use schema_forge_core::types::{SchemaDefinition, SchemaName}; + +use super::ForgeActor; +use crate::{error::ForgeError, messages::ApplyPreparedSchemaChange}; + +#[derive(Debug)] +struct SchemaChangeFailure { + name: SchemaName, + previous: Option, + cause: ForgeError, +} + +impl std::fmt::Display for SchemaChangeFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.cause, f) + } +} + +impl std::error::Error for SchemaChangeFailure {} + +pub(super) fn configure(actor: &mut ManagedActor) { + actor.try_mutate_on::(|actor, context| { + let change = context.message().clone(); + let store = actor.model.policy_store.clone(); + let unchanged = actor.model.registry == change.expected_registry + && store + .as_ref() + .is_some_and(|store| Arc::ptr_eq(&store.current(), &change.expected_policy)); + if !unchanged { + return Reply::try_pending(async move { + change + .reply + .send(Err(ForgeError::Conflict { + reason: "schema_preflight_stale", + message: + "schemas or policies changed after validation; retry the schema change" + .into(), + })) + .await; + Ok(()) + }); + } + let (Some(backend), Some(store)) = (actor.model.backend.clone(), store) else { + return Reply::try_pending(async move { + change + .reply + .send(Err(ForgeError::Internal { + message: "schema backend is not initialized".into(), + })) + .await; + Ok(()) + }); + }; + let name = change.definition.name.clone(); + // Mutable handler futures are awaited inline. No subsequent actor + // message observes this provisional registry until storage succeeds, + // or the on_error handler below has restored it. + let previous = if change.remove { + actor.model.registry.remove(name.as_str()) + } else { + actor + .model + .registry + .insert(name.to_string(), change.definition.clone()) + }; + Reply::try_pending(async move { + let result = async { + if !change.steps.is_empty() { + backend.apply_migration(&name, &change.steps).await?; + } + if !change.remove { + backend.store_schema_metadata(&change.definition).await?; + } + Ok::<(), schema_forge_backend::BackendError>(()) + } + .await; + if let Err(error) = result { + return Err(SchemaChangeFailure { + name, + previous, + cause: error.into(), + }); + } + store.swap_prepared(change.next_policy); + change.reply.send(Ok(())).await; + Ok(()) + }) + }); + actor.on_error::(|actor, context, failure| { + if let Some(previous) = &failure.previous { + actor + .model + .registry + .insert(failure.name.to_string(), previous.clone()); + } else { + actor.model.registry.remove(failure.name.as_str()); + } + let reply = context.message().reply.clone(); + let error = failure.cause.clone(); + Reply::pending(async move { reply.send(Err(error)).await }) + }); +} diff --git a/crates/schema-forge-acton/src/authz/store.rs b/crates/schema-forge-acton/src/authz/store.rs index a3059de9..38980303 100644 --- a/crates/schema-forge-acton/src/authz/store.rs +++ b/crates/schema-forge-acton/src/authz/store.rs @@ -87,6 +87,11 @@ impl PolicyStore { self.inner.store(Arc::new(next)); } + /// Install an immutable, already-validated snapshot without recompilation. + pub fn swap_prepared(&self, next: Arc) { + self.inner.store(next); + } + /// Compiles a fresh snapshot from `schemas` (reusing the current /// snapshot's [`RoleRanks`]) and atomically installs it. /// diff --git a/crates/schema-forge-acton/src/messages.rs b/crates/schema-forge-acton/src/messages.rs index 7b256c53..5f28a389 100644 --- a/crates/schema-forge-acton/src/messages.rs +++ b/crates/schema-forge-acton/src/messages.rs @@ -138,6 +138,19 @@ pub struct GetCustomPoliciesDir { // Registry mutations // --------------------------------------------------------------------------- +/// Commit a prevalidated schema change under the actor's mutation barrier. +/// The expected registry and policy identity guard against stale preflight plans. +#[derive(Clone, Debug)] +pub struct ApplyPreparedSchemaChange { + pub expected_registry: HashMap, + pub expected_policy: Arc, + pub next_policy: Arc, + pub definition: SchemaDefinition, + pub remove: bool, + pub steps: Vec, + pub reply: ReplyChannel>, +} + /// Insert or update a schema definition in the in-memory registry. /// /// On success, the actor recompiles the Cedar policy bundle from the new diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index 3f083af8..15d412b4 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -24,8 +24,7 @@ use crate::actor::ForgeActor; use crate::config::SchemaForgeConfig; use crate::error::ForgeError; use crate::messages::{ - ApplyMigration, GetSchema, InsertSchema, ListSchemas, RemoveSchema, ReplyChannel, - StoreSchemaMetadata, + ApplyPreparedSchemaChange, GetSchema, ListSchemas, ReplyChannel, }; // --------------------------------------------------------------------------- @@ -97,14 +96,15 @@ async fn pair_with_registry( /// /// Surfacing the validation error here turns it into a 400-class response — /// the caller's request is rejected before any DB migration runs. The actor -/// will recompile and atomically swap on the subsequent `InsertSchema` / -/// `RemoveSchema` regardless; this is purely a fail-closed pre-check. +/// commits the prepared snapshot under its mutation barrier, guarded by the +/// exact registry and policy identities this preflight observed. async fn precheck_policy_bundle( state: &AppState, forge: &acton_service::prelude::ActorHandle, target: &SchemaDefinition, removing: bool, -) -> Result<(), ForgeError> { + expected_target: Option<&SchemaDefinition>, +) -> Result { let (tx, rx) = oneshot::channel(); forge .send(ListSchemas { @@ -112,6 +112,14 @@ async fn precheck_policy_bundle( }) .await; let mut proposed = ask_forge(rx).await?; + let expected_registry: std::collections::HashMap<_, _> = proposed.iter() + .map(|schema| (schema.name.to_string(), schema.clone())).collect(); + if expected_registry.get(target.name.as_str()) != expected_target { + return Err(ForgeError::Conflict { + reason: "schema_preflight_stale", + message: "schema changed while preparing the update; retry the schema change".into(), + }); + } let current_tenant_structure = tenant_structure(&proposed)?; proposed.retain(|s| s.name.as_str() != target.name.as_str()); @@ -134,7 +142,7 @@ async fn precheck_policy_bundle( }).await; let custom_dir = ask_forge(rx).await?; - crate::authz::store::PolicyStoreSnapshot::from_schemas( + let next_policy = crate::authz::store::PolicyStoreSnapshot::from_schemas( &proposed, custom_dir.as_deref(), role_ranks, @@ -146,7 +154,7 @@ async fn precheck_policy_bundle( )], })?; - Ok(()) + Ok(PreparedSchemaPolicies { expected_registry, expected_policy: snapshot, next_policy: std::sync::Arc::new(next_policy) }) } /// Reuse canonical DSL validation for annotations supplied through the JSON schema API. @@ -199,6 +207,29 @@ fn tenant_structure(schemas: &[SchemaDefinition]) -> Result, + expected_policy: std::sync::Arc, + next_policy: std::sync::Arc, +} + +async fn apply_prepared_schema_change( + forge: &acton_service::prelude::ActorHandle, + prepared: PreparedSchemaPolicies, + definition: SchemaDefinition, + remove: bool, + steps: Vec, +) -> Result<(), ForgeError> { + let (tx, rx) = oneshot::channel(); + forge.send(ApplyPreparedSchemaChange { + expected_registry: prepared.expected_registry, + expected_policy: prepared.expected_policy, + next_policy: prepared.next_policy, + definition, remove, steps, reply: ReplyChannel::new(tx), + }).await; + ask_forge(rx).await? +} + /// Fetch the current Cedar [`PolicyStore`] from the actor. async fn fetch_policy_store( state: &AppState, @@ -629,50 +660,14 @@ pub async fn create_schema( pair_with_registry(&forge, &mut definition).await?; // 4b. Pre-validate the proposed Cedar bundle BEFORE running any DB - // migration. The actor will recompile and atomically swap on InsertSchema - // anyway, but doing the dry-run here means a malformed schema is rejected - // with a 400 instead of leaving the database in a state the running - // policy bundle can't reason about. - precheck_policy_bundle(&state, &forge, &definition, false).await?; + // migration. The actor commits this immutable bundle with the storage + // change; no policy files are read after DDL. + let prepared = precheck_policy_bundle(&state, &forge, &definition, false, None).await?; // 5. Generate migration plan let plan = DiffEngine::create_new(&definition); - // 6. Apply migration to backend via actor - let (tx, rx) = oneshot::channel(); - forge - .send(ApplyMigration { - schema_name: schema_name.clone(), - steps: plan.steps, - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx).await?.map_err(ForgeError::from)?; - - // 7. Store schema metadata in backend via actor - let (tx, rx) = oneshot::channel(); - forge - .send(StoreSchemaMetadata { - definition: definition.clone(), - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx).await?.map_err(ForgeError::from)?; - - // 8. Update registry cache + recompile Cedar bundle. The actor swap is - // the source of truth: if the recompile fails here despite the dry-run - // above, the actor reverts the registry mutation and returns the error. - let (tx, rx) = oneshot::channel(); - forge - .send(InsertSchema { - name: schema_name.as_str().to_string(), - definition: definition.clone(), - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx).await?.map_err(|err| ForgeError::Internal { - message: format!("Cedar policy recompile failed during schema insertion: {err}"), - })?; + apply_prepared_schema_change(&forge, prepared, definition.clone(), false, plan.steps).await?; // 9. Rebuild GraphQL schema // NOTE: GraphQL rebuild will be re-integrated when the graphql module @@ -875,7 +870,7 @@ pub async fn update_schema( // 4b. Dry-run the Cedar bundle for the proposed registry state so an // invalid schema fails fast — before any DB migration. - precheck_policy_bundle(&state, &forge, &new_definition, false).await?; + let prepared = precheck_policy_bundle(&state, &forge, &new_definition, false, Some(&old_schema)).await?; // 5. Compute diff and generate migration plan let plan = DiffEngine::plan_update(&old_schema, &new_definition).map_err(|error| { @@ -887,42 +882,8 @@ pub async fn update_schema( return Err(ForgeError::ValidationFailed { details: vec![format!("destructive schema update refused: {}; set allow_destructive_migrations=true to acknowledge data loss", plan.steps.iter().map(ToString::to_string).collect::>().join("; "))] }); } - // 6. Apply migration steps via actor let step_count = plan.steps.len(); - if !plan.is_empty() { - let (tx, rx) = oneshot::channel(); - forge - .send(ApplyMigration { - schema_name: schema_name.clone(), - steps: plan.steps, - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx).await?.map_err(ForgeError::from)?; - } - - // 7. Store updated metadata via actor - let (tx, rx) = oneshot::channel(); - forge - .send(StoreSchemaMetadata { - definition: new_definition.clone(), - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx).await?.map_err(ForgeError::from)?; - - // 8. Update registry cache + recompile Cedar bundle. - let (tx, rx) = oneshot::channel(); - forge - .send(InsertSchema { - name: schema_name.as_str().to_string(), - definition: new_definition.clone(), - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx).await?.map_err(|err| ForgeError::Internal { - message: format!("Cedar policy recompile failed during schema update: {err}"), - })?; + apply_prepared_schema_change(&forge, prepared, new_definition.clone(), false, plan.steps).await?; // 9. Rebuild GraphQL schema // NOTE: GraphQL rebuild will be re-integrated when the graphql module @@ -986,21 +947,9 @@ pub async fn delete_schema( .await? .ok_or(ForgeError::SchemaNotFound { name: name.clone() })?; - precheck_policy_bundle(&state, &forge, &schema, true).await?; + let prepared = precheck_policy_bundle(&state, &forge, &schema, true, Some(&schema)).await?; - // 2. Remove from registry cache + recompile Cedar bundle. The actor - // reverts the registry mutation if the recompile fails so the running - // bundle and registry never drift. - let (tx, rx) = oneshot::channel(); - forge - .send(RemoveSchema { - name: name.clone(), - reply: ReplyChannel::new(tx), - }) - .await; - ask_forge(rx).await?.map_err(|err| ForgeError::Internal { - message: format!("Cedar policy recompile failed during schema deletion: {err}"), - })?; + apply_prepared_schema_change(&forge, prepared, schema, true, vec![]).await?; // 3. Rebuild GraphQL schema // NOTE: GraphQL rebuild will be re-integrated when the graphql module diff --git a/crates/schema-forge-acton/tests/policy_preflight.rs b/crates/schema-forge-acton/tests/policy_preflight.rs index f0bbba11..024c84b9 100644 --- a/crates/schema-forge-acton/tests/policy_preflight.rs +++ b/crates/schema-forge-acton/tests/policy_preflight.rs @@ -19,12 +19,15 @@ use http_body_util::BodyExt; use schema_forge_acton::{ authz::{PolicyStore, PolicyStoreSnapshot, PrincipalClaimMappings, RoleRanks}, config::SchemaForgeConfig, - messages::{InitForge, ReplyChannel}, + messages::{ApplyPreparedSchemaChange, GetSchema, InitForge, ReplyChannel}, routes::forge_routes, ForgeActor, }; use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; -use schema_forge_core::{migration::DiffEngine, types::DynamicValue}; +use schema_forge_core::{ + migration::DiffEngine, + types::{DynamicValue, FieldDefinition, FieldModifier, FieldName, FieldType, TextConstraints}, +}; use schema_forge_surrealdb::SurrealBackend; use serde_json::{json, Value}; use tokio::sync::oneshot; @@ -161,4 +164,151 @@ async fn custom_policy_field_contract_refuses_mutations_before_storage_changes() .iter() .any(|field| field["name"] == "code")); } + // Prepare two competing changes from the same immutable registry/bundle. + let mut desired = schema.clone(); + desired.fields.push(FieldDefinition::new( + FieldName::new("extra").unwrap(), + FieldType::Text(TextConstraints::default()), + )); + let mut competing = schema.clone(); + competing + .fields + .retain(|field| field.name.as_str() != "label"); + let compile = |definition: &schema_forge_core::types::SchemaDefinition| { + Arc::new( + PolicyStoreSnapshot::from_schemas( + std::slice::from_ref(definition), + Some(directory.path()), + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(), + ) + }; + let desired_policy = compile(&desired); + let competing_policy = compile(&competing); + let expected_policy = policies.current(); + let expected_registry = HashMap::from([("Thing".into(), schema.clone())]); + // If commit rereads the source, this would fail after already changing DDL. + std::fs::write(directory.path().join("contract.cedar"), "invalid policy").unwrap(); + let forge = service.state().actor::().unwrap(); + let (tx, rx) = oneshot::channel(); + forge + .send(ApplyPreparedSchemaChange { + expected_registry: expected_registry.clone(), + expected_policy: expected_policy.clone(), + next_policy: desired_policy.clone(), + definition: desired.clone(), + remove: false, + steps: DiffEngine::plan_update(&schema, &desired).unwrap().steps, + reply: ReplyChannel::new(tx), + }) + .await; + tokio::time::timeout(Duration::from_secs(5), rx) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(Arc::ptr_eq(&policies.current(), &desired_policy)); + assert_eq!( + backend + .load_schema_metadata(&schema.name) + .await + .unwrap() + .unwrap(), + desired + ); + let (tx, rx) = oneshot::channel(); + forge + .send(ApplyPreparedSchemaChange { + expected_registry, + expected_policy, + next_policy: competing_policy, + definition: competing.clone(), + remove: false, + steps: DiffEngine::plan_update(&schema, &competing).unwrap().steps, + reply: ReplyChannel::new(tx), + }) + .await; + let error = tokio::time::timeout(Duration::from_secs(5), rx) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(matches!( + error, + schema_forge_acton::error::ForgeError::Conflict { + reason: "schema_preflight_stale", + .. + } + )); + assert_eq!( + backend.get(&schema.name, &entity.id).await.unwrap().fields["label"], + DynamicValue::Text("untouched".into()) + ); + assert_eq!( + backend + .load_schema_metadata(&schema.name) + .await + .unwrap() + .unwrap(), + desired + ); + + // A storage rejection restores the provisional registry before replying. + std::fs::write(directory.path().join("contract.cedar"), + r#"forbid (principal is Forge::Principal, action == Action::"ReadThing", resource is Thing) when { resource.code == "secret" };"#).unwrap(); + backend + .create(&Entity::new(schema.name.clone(), entity.fields.clone())) + .await + .unwrap(); + let mut invalid = desired.clone(); + invalid + .fields + .iter_mut() + .find(|field| field.name.as_str() == "code") + .unwrap() + .modifiers + .push(FieldModifier::Unique); + let (tx, rx) = oneshot::channel(); + forge + .send(ApplyPreparedSchemaChange { + expected_registry: HashMap::from([("Thing".into(), desired.clone())]), + expected_policy: policies.current(), + next_policy: compile(&invalid), + definition: invalid.clone(), + remove: false, + steps: DiffEngine::plan_update(&desired, &invalid).unwrap().steps, + reply: ReplyChannel::new(tx), + }) + .await; + assert!(tokio::time::timeout(Duration::from_secs(5), rx) + .await + .unwrap() + .unwrap() + .is_err()); + assert!(Arc::ptr_eq(&policies.current(), &desired_policy)); + let (tx, rx) = oneshot::channel(); + forge + .send(GetSchema { + name: "Thing".into(), + reply: ReplyChannel::new(tx), + }) + .await; + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), rx) + .await + .unwrap() + .unwrap() + .unwrap(), + desired + ); + assert_eq!( + backend + .load_schema_metadata(&schema.name) + .await + .unwrap() + .unwrap(), + desired + ); } From a6c90613484d83b67826b5017b2a423e5576f283 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:21:32 -0600 Subject: [PATCH 44/77] fix: finalize runtime relation constraints before policy publication --- crates/schema-forge-acton/src/actor/schema_changes.rs | 1 + docs/migrations/safe-schema-changes.md | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/crates/schema-forge-acton/src/actor/schema_changes.rs b/crates/schema-forge-acton/src/actor/schema_changes.rs index 5fdb725a..34be2f2e 100644 --- a/crates/schema-forge-acton/src/actor/schema_changes.rs +++ b/crates/schema-forge-acton/src/actor/schema_changes.rs @@ -75,6 +75,7 @@ pub(super) fn configure(actor: &mut ManagedActor) { } if !change.remove { backend.store_schema_metadata(&change.definition).await?; + backend.finalize_schema_migrations().await?; } Ok::<(), schema_forge_backend::BackendError>(()) } diff --git a/docs/migrations/safe-schema-changes.md b/docs/migrations/safe-schema-changes.md index b5de0ba6..215cd487 100644 --- a/docs/migrations/safe-schema-changes.md +++ b/docs/migrations/safe-schema-changes.md @@ -37,6 +37,14 @@ structure require offline apply and restart, because the actor and HTTP middleware must activate the same tenant configuration together. Invalid combined hierarchies are rejected before schema writes. +Runtime schema changes validate the complete Cedar bundle before storage and +install that exact immutable bundle after storage succeeds. Concurrent changes +with an outdated preflight return HTTP 409 and must be retried. Storage failures +preserve the active registry and policy bundle, but DDL, metadata, and final +constraint reconciliation can use separate backend transactions. A later failure +does not undo earlier committed database changes. Inspect the reported failure +and stored schema before retrying; use a maintenance window for migrations. + ## PostgreSQL relation integrity To-one relations use foreign keys to the target table's `id`, with restrictive From edc1cc70406429aa035838ff7931817c8218d06c Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:23:31 -0600 Subject: [PATCH 45/77] fix: guard relation pairing against concurrent schema changes --- .../schema-forge-acton/src/routes/schemas.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index 15d412b4..8a01b867 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -47,7 +47,7 @@ const ACTOR_TIMEOUT: Duration = Duration::from_secs(5); async fn pair_with_registry( forge: &acton_service::prelude::ActorHandle, target: &mut SchemaDefinition, -) -> Result<(), ForgeError> { +) -> Result, ForgeError> { validate_schema_definition(target)?; let (tx, rx) = oneshot::channel(); forge @@ -56,6 +56,7 @@ async fn pair_with_registry( }) .await; let mut batch = ask_forge(rx).await?; + let registry = batch.iter().map(|schema| (schema.name.to_string(), schema.clone())).collect(); // Replace any existing entry with the same name so we pair against // the incoming definition — not the stale one. @@ -88,7 +89,7 @@ async fn pair_with_registry( if let Some(paired) = batch.pop() { *target = paired; } - Ok(()) + Ok(registry) } /// Dry-run the Cedar policy bundle that would result from inserting (or @@ -104,6 +105,7 @@ async fn precheck_policy_bundle( target: &SchemaDefinition, removing: bool, expected_target: Option<&SchemaDefinition>, + paired_registry: Option<&std::collections::HashMap>, ) -> Result { let (tx, rx) = oneshot::channel(); forge @@ -114,7 +116,8 @@ async fn precheck_policy_bundle( let mut proposed = ask_forge(rx).await?; let expected_registry: std::collections::HashMap<_, _> = proposed.iter() .map(|schema| (schema.name.to_string(), schema.clone())).collect(); - if expected_registry.get(target.name.as_str()) != expected_target { + if expected_registry.get(target.name.as_str()) != expected_target + || paired_registry.is_some_and(|registry| registry != &expected_registry) { return Err(ForgeError::Conflict { reason: "schema_preflight_stale", message: "schema changed while preparing the update; retry the schema change".into(), @@ -657,12 +660,12 @@ pub async fn create_schema( // 4a. Run the inverse-relation pairing pass across the full registry so // any `-> X[]` field paired with an FK from an existing schema is marked // as derived before the migration plan is generated. - pair_with_registry(&forge, &mut definition).await?; + let paired_registry = pair_with_registry(&forge, &mut definition).await?; // 4b. Pre-validate the proposed Cedar bundle BEFORE running any DB // migration. The actor commits this immutable bundle with the storage // change; no policy files are read after DDL. - let prepared = precheck_policy_bundle(&state, &forge, &definition, false, None).await?; + let prepared = precheck_policy_bundle(&state, &forge, &definition, false, None, Some(&paired_registry)).await?; // 5. Generate migration plan let plan = DiffEngine::create_new(&definition); @@ -866,11 +869,11 @@ pub async fn update_schema( // 4a. Run the inverse-relation pairing pass before diffing, so newly // added `-> X[]` fields are classified as derived (and therefore // produce no AddRelation step for a physical column). - pair_with_registry(&forge, &mut new_definition).await?; + let paired_registry = pair_with_registry(&forge, &mut new_definition).await?; // 4b. Dry-run the Cedar bundle for the proposed registry state so an // invalid schema fails fast — before any DB migration. - let prepared = precheck_policy_bundle(&state, &forge, &new_definition, false, Some(&old_schema)).await?; + let prepared = precheck_policy_bundle(&state, &forge, &new_definition, false, Some(&old_schema), Some(&paired_registry)).await?; // 5. Compute diff and generate migration plan let plan = DiffEngine::plan_update(&old_schema, &new_definition).map_err(|error| { @@ -947,7 +950,7 @@ pub async fn delete_schema( .await? .ok_or(ForgeError::SchemaNotFound { name: name.clone() })?; - let prepared = precheck_policy_bundle(&state, &forge, &schema, true, Some(&schema)).await?; + let prepared = precheck_policy_bundle(&state, &forge, &schema, true, Some(&schema), None).await?; apply_prepared_schema_change(&forge, prepared, schema, true, vec![]).await?; From 1536fe5b88d1e3efbde260324bb451cf47f715e3 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:26:28 -0600 Subject: [PATCH 46/77] ci: run complete regression and lint gates on runners --- .github/workflows/mssql-integration.yml | 5 ++++- .github/workflows/postgres-conditional.yml | 24 ++++++++-------------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/.github/workflows/mssql-integration.yml b/.github/workflows/mssql-integration.yml index 9fcd375a..b4f23332 100644 --- a/.github/workflows/mssql-integration.yml +++ b/.github/workflows/mssql-integration.yml @@ -5,7 +5,7 @@ on: paths: - "crates/schema-forge-mssql/**" - "crates/schema-forge-core/**" - - "crates/schema-forge-backend/tests/support/migration_renames.rs" + - "crates/schema-forge-backend/**" - "crates/schema-forge-cli/Cargo.toml" - "crates/schema-forge-acton/Cargo.toml" - "Cargo.lock" @@ -95,3 +95,6 @@ jobs: run: >- cargo nextest run --package schema-forge-mssql --test sql_server --run-ignored only -E "test(=$TEST_NAME)" + - name: Deny SQL Server lints + if: matrix.version == 2019 + run: cargo clippy -p schema-forge-mssql --all-targets -- -D warnings diff --git a/.github/workflows/postgres-conditional.yml b/.github/workflows/postgres-conditional.yml index 85186bc5..c48ce674 100644 --- a/.github/workflows/postgres-conditional.yml +++ b/.github/workflows/postgres-conditional.yml @@ -20,7 +20,7 @@ permissions: jobs: conditional: runs-on: ubuntu-24.04 - timeout-minutes: 40 + timeout-minutes: 60 services: postgres: image: postgres:16 @@ -52,27 +52,19 @@ jobs: run: cargo nextest run -p schema-forge-backend -p schema-forge-postgres --run-ignored all - name: Prepare disposable HTTP namespace run: psql -X -v ON_ERROR_STOP=1 -c 'CREATE SCHEMA conditional_http' + - name: Run complete runtime and schema suites + run: cargo nextest run -p schema-forge-acton -p schema-forge-surrealdb -p schema-forge-core -p schema-forge-dsl --features schema-forge-acton/postgres,schema-forge-acton/graphql - name: Run HTTP authorization and concurrency cases env: SCHEMAFORGE_TEST_POSTGRES_DISPOSABLE: "1" SCHEMAFORGE_TEST_POSTGRES_URL: postgres://schemaforge:schemaforge-test@localhost:5432/schemaforge_test?options=-csearch_path%3Dconditional_http - run: cargo nextest run -p schema-forge-acton --features postgres --test conditional_entities --run-ignored all - - name: Run audit authorization and bounded verification regressions - run: cargo nextest run -p schema-forge-acton --features postgres --test audit_api - - name: Run exact authorized count policy and paging regressions - run: | - cargo nextest run -p schema-forge-acton --features postgres --lib --test authorization_context -E 'test(authz::read_scope) | binary(authorization_context)' - cargo nextest run -p schema-forge-acton --features postgres --test auth_demo -E 'test(readable_paging)' - - name: Run GraphQL write and tenant regressions - run: cargo nextest run -p schema-forge-acton --features postgres,graphql --test graphql_writes --test graphql_tenants - - name: Run runtime schema and custom policy preflight regressions - run: cargo nextest run -p schema-forge-acton --features postgres --test integration --test policy_preflight - - name: Run schema planning and rename regressions - run: | - cargo nextest run -p schema-forge-core -p schema-forge-dsl - cargo nextest run -p schema-forge-surrealdb --test migration_renames + run: cargo nextest run -p schema-forge-acton --features postgres,graphql --test conditional_entities --run-ignored only - name: Check CLI preparation and security behavior run: cargo nextest run -p schema-forge-cli --no-default-features --features postgres + - name: Deny runtime and backend lints + run: cargo clippy -p schema-forge-acton -p schema-forge-surrealdb -p schema-forge-core -p schema-forge-dsl -p schema-forge-backend -p schema-forge-postgres --features schema-forge-acton/postgres,schema-forge-acton/graphql --all-targets -- -D warnings + - name: Deny CLI lints + run: cargo clippy -p schema-forge-cli --no-default-features --features postgres --all-targets -- -D warnings - name: Clean disposable HTTP namespace if: always() run: psql -X -v ON_ERROR_STOP=1 -c 'DROP SCHEMA IF EXISTS conditional_http CASCADE' From b776ee23ae7b68fa8f39fee67f484123068b35de Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:27:52 -0600 Subject: [PATCH 47/77] docs: describe atomic schema changes for custom backends --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 986fd230..a4642e3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,7 +65,10 @@ Rust embedders must update the schema-aware tenant helper and rule-binding call signatures. GraphQL registration now requires initialized `AppState`; see [GraphQL writes](docs/graphql-writes.md). The backend trait adds a defaulted `finalize_schema_migrations` operation for -completing batch migration integrity checks. +completing batch migration integrity checks. Custom backends must implement +the atomic `apply_schema_change` operation to support runtime schema creation +and updates; the default refuses these operations rather than applying a +partial migration. ## [0.44.2] - 2026-09-10 From 9ed0d6d7d29e284bf62fe6ffa626806f0e0d6abd Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:29:20 -0600 Subject: [PATCH 48/77] ci: publish upgrade notes and verify release version --- .github/workflows/release.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80ce501d..0f7b8941 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,6 +76,13 @@ jobs: - name: Verify Rust toolchain run: rustc --version && cargo --version + - name: Verify release tag matches CLI version + shell: bash + run: | + PACKAGE_ID="$(cargo pkgid --locked -p schema-forge-cli)" + PACKAGE_VERSION="${PACKAGE_ID##*#}" + test "$TAG_NAME" = "v${PACKAGE_VERSION##*@}" + - name: Install cosign uses: sigstore/cosign-installer@v3 @@ -119,7 +126,7 @@ jobs: # Points rust-embed at the verified console bundle (build.rs re-exports # it for the `#[folder = "$SCHEMAFORGE_CONSOLE_DIST"]` interpolation). SCHEMAFORGE_CONSOLE_DIST: ${{ github.workspace }}/console-dist - run: cargo build --release --package schema-forge-cli --no-default-features --features ${{ matrix.backend }},embedded-console + run: cargo build --locked --release --package schema-forge-cli --no-default-features --features ${{ matrix.backend }},embedded-console - name: Sign and verify Windows binary (Sigstore keyless) if: runner.os == 'Windows' @@ -170,6 +177,19 @@ jobs: contents: write id-token: write steps: + - uses: actions/checkout@v6 + + - name: Prepare release notes from changelog + env: + TAG_NAME: ${{ github.ref_name }} + run: | + awk -v version="${TAG_NAME#v}" ' + index($0, "## [" version "]") == 1 { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md > RELEASE_NOTES.md + test -s RELEASE_NOTES.md + - uses: actions/download-artifact@v8 with: merge-multiple: true @@ -192,6 +212,7 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v3 with: + body_path: RELEASE_NOTES.md generate_release_notes: true files: | schemaforge-*.tar.gz From abc5d4b3b7cde04b484ba89afa11af11351b754a Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:30:36 -0600 Subject: [PATCH 49/77] fix: persist runtime schema changes atomically across backends --- .Codex/plans/migration-safety.md | 4 + crates/schema-forge-acton/src/state.rs | 26 ++++ crates/schema-forge-backend/src/traits.rs | 17 +++ crates/schema-forge-mssql/src/backend.rs | 92 +++++++++-- crates/schema-forge-mssql/tests/sql_server.rs | 70 +++++++++ crates/schema-forge-postgres/src/backend.rs | 144 +++++++++++++++--- .../tests/migration_safety.rs | 46 ++++++ crates/schema-forge-surrealdb/src/backend.rs | 79 +++++++++- .../tests/migration_renames.rs | 52 +++++++ 9 files changed, 492 insertions(+), 38 deletions(-) diff --git a/.Codex/plans/migration-safety.md b/.Codex/plans/migration-safety.md index d3422903..a7794d77 100644 --- a/.Codex/plans/migration-safety.md +++ b/.Codex/plans/migration-safety.md @@ -7,3 +7,7 @@ 5. Reject empty PostgreSQL UPDATE fields before emitting SQL. Tests: DSL roundtrip and invalid rename hints, repeat apply, tenant add/remove/root-parent refusal, startup/runtime destructive refusals, PostgreSQL fresh/add/backfill/cyclic FK behavior and deletion/write error mapping; nextest and clippy, cross-backend compilation. No dependencies required. Public annotation and error variants warrant minor release; root owns version bump. + +## Atomic runtime schema persistence followup + +Add apply_schema_change(name, steps, optional definition) to SchemaBackend and dynamic adapter with unsupported default. Each supported backend executes schema DDL, required validation, metadata upsert/delete, and integrity reconciliation in a single transaction. Extract existing statement/connection helpers to preserve rename behavior. PostgreSQL uses transaction-local metadata and strict FK finalization; cache invalidates only after commit. Runtime DELETE keeps its existing registry-only semantics (parent decision). Add focused rollback contracts exercising failure after DDL and metadata-only/deletion paths, run focused nextest and Clippy only; CI owns broad gates. diff --git a/crates/schema-forge-acton/src/state.rs b/crates/schema-forge-acton/src/state.rs index c135127e..0bc1f2e9 100644 --- a/crates/schema-forge-acton/src/state.rs +++ b/crates/schema-forge-acton/src/state.rs @@ -27,6 +27,21 @@ use tokio::sync::RwLock; /// RPITIT traits cannot be used as `dyn Trait`. This wrapper uses boxed futures /// to enable dynamic dispatch for HTTP handler state. pub trait DynSchemaBackend: Send + Sync { + /// Atomically persist a complete schema change or refuse without writes. + fn apply_schema_change<'a>( + &'a self, + _name: &'a SchemaName, + _steps: &'a [MigrationStep], + _definition: Option<&'a SchemaDefinition>, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(async { + Err(BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: "backend does not support atomic schema changes".into(), + }) + }) + } + /// Finish cross-schema constraints after explicit schema administration. fn finalize_schema_migrations( &self, @@ -80,6 +95,17 @@ pub trait DynSchemaBackend: Send + Sync { /// Blanket impl: any concrete `SchemaBackend` automatically implements `DynSchemaBackend`. impl DynSchemaBackend for T { + fn apply_schema_change<'a>( + &'a self, + name: &'a SchemaName, + steps: &'a [MigrationStep], + definition: Option<&'a SchemaDefinition>, + ) -> Pin> + Send + Sync + 'a>> { + Box::pin(SyncFuture::new(SchemaBackend::apply_schema_change( + self, name, steps, definition, + ))) + } + fn finalize_schema_migrations( &self, ) -> Pin> + Send + Sync + '_>> { diff --git a/crates/schema-forge-backend/src/traits.rs b/crates/schema-forge-backend/src/traits.rs index 464267fc..b76ab791 100644 --- a/crates/schema-forge-backend/src/traits.rs +++ b/crates/schema-forge-backend/src/traits.rs @@ -16,6 +16,23 @@ use crate::error::BackendError; /// Uses RPITIT (return position impl Trait in trait) for async methods, /// avoiding the `async-trait` crate. pub trait SchemaBackend: Send + Sync { + /// Atomically apply DDL and persist the resulting metadata, including integrity checks. + /// `None` removes metadata; physical deletion requires explicit migration steps. + /// Unsupported adapters must refuse before making any changes. + fn apply_schema_change( + &self, + _name: &SchemaName, + _steps: &[MigrationStep], + _definition: Option<&SchemaDefinition>, + ) -> impl Future> + Send { + async { + Err(BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: "backend does not support atomic schema changes".into(), + }) + } + } + /// Finish cross-schema constraints after applying a batch, including legacy repair. /// Called only during explicit schema administration, never on a read connection. fn finalize_schema_migrations(&self) -> impl Future> + Send { diff --git a/crates/schema-forge-mssql/src/backend.rs b/crates/schema-forge-mssql/src/backend.rs index 31cc65ed..1938d2f8 100644 --- a/crates/schema-forge-mssql/src/backend.rs +++ b/crates/schema-forge-mssql/src/backend.rs @@ -68,7 +68,10 @@ impl MssqlBackend { } /// Compile one atomic batch while keeping arbitrary variant text in bound parameters. -fn migration_batch(schema_name: &SchemaName, steps: &[MigrationStep]) -> (String, Vec) { +fn migration_statements( + schema_name: &SchemaName, + steps: &[MigrationStep], +) -> (String, Vec) { let table = quote(schema_name.as_str()); let mut statements = Vec::new(); let mut parameters = Vec::new(); @@ -99,15 +102,15 @@ fn migration_batch(schema_name: &SchemaName, steps: &[MigrationStep]) -> (String _ => {} } } - if statements.is_empty() { - return (String::new(), parameters); - } - let sql = format!("SET XACT_ABORT ON; BEGIN TRY BEGIN TRANSACTION; {} COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; THROW; END CATCH;", statements.join("\n")); - (sql, parameters) + (statements.join("\n"), parameters) } -impl SchemaBackend for MssqlBackend { - async fn apply_migration( +fn transaction_batch(statements: &str) -> String { + format!("SET XACT_ABORT ON; BEGIN TRY BEGIN TRANSACTION; {statements} COMMIT TRANSACTION; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION; THROW; END CATCH;") +} + +impl MssqlBackend { + async fn validate_migration( &self, schema_name: &SchemaName, steps: &[MigrationStep], @@ -139,7 +142,69 @@ impl SchemaBackend for MssqlBackend { } } } - let (sql, parameters) = migration_batch(schema_name, steps); + Ok(()) + } +} + +impl SchemaBackend for MssqlBackend { + async fn apply_schema_change( + &self, + name: &SchemaName, + steps: &[MigrationStep], + definition: Option<&SchemaDefinition>, + ) -> Result<(), BackendError> { + if let Some(definition) = definition { + if &definition.name != name { + return Err(BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: "schema name does not match metadata".into(), + }); + } + if let Some(existing) = self.load_schema_metadata(name).await? { + schema_forge_core::migration::DiffEngine::validate_transition( + &existing, definition, + ) + .map_err(|error| BackendError::MigrationFailed { + step: "validate schema transition".into(), + reason: error.to_string(), + })?; + } + } + self.validate_migration(name, steps).await?; + let (mut sql, mut parameters) = migration_statements(name, steps); + let name_parameter = parameters.len() + 1; + parameters.push(name.to_string()); + if let Some(definition) = definition { + let definition_parameter = parameters.len() + 1; + parameters.push(serde_json::to_string(definition).map_err(json_error)?); + sql.push_str(&format!(" MERGE [dbo].[{METADATA}] AS target USING (SELECT @P{name_parameter} AS [name], @P{definition_parameter} AS [definition]) source ON target.[name] = source.[name] WHEN MATCHED THEN UPDATE SET [definition] = source.[definition] WHEN NOT MATCHED THEN INSERT ([name], [definition]) VALUES (source.[name], source.[definition]);")); + } else { + sql.push_str(&format!( + " DELETE FROM [dbo].[{METADATA}] WHERE [name] = @P{name_parameter};" + )); + } + let bindings: Vec<&dyn tiberius::ToSql> = parameters + .iter() + .map(|value| value as &dyn tiberius::ToSql) + .collect(); + let mut connection = connection(&self.pool).await?; + connection + .execute(transaction_batch(&sql), &bindings) + .await + .map_err(|error| BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: error.to_string(), + })?; + Ok(()) + } + + async fn apply_migration( + &self, + schema_name: &SchemaName, + steps: &[MigrationStep], + ) -> Result<(), BackendError> { + self.validate_migration(schema_name, steps).await?; + let (sql, parameters) = migration_statements(schema_name, steps); if sql.is_empty() { return Ok(()); } @@ -148,12 +213,13 @@ impl SchemaBackend for MssqlBackend { .map(|value| value as &dyn tiberius::ToSql) .collect(); let mut connection = connection(&self.pool).await?; - connection.execute(sql, &bindings).await.map_err(|error| { - BackendError::MigrationFailed { + connection + .execute(transaction_batch(&sql), &bindings) + .await + .map_err(|error| BackendError::MigrationFailed { step: "apply migration transaction".into(), reason: error.to_string(), - } - })?; + })?; Ok(()) } diff --git a/crates/schema-forge-mssql/tests/sql_server.rs b/crates/schema-forge-mssql/tests/sql_server.rs index e55efc0a..db368033 100644 --- a/crates/schema-forge-mssql/tests/sql_server.rs +++ b/crates/schema-forge-mssql/tests/sql_server.rs @@ -62,6 +62,7 @@ async fn connects_and_initializes_metadata(image_tag: &str) { .expect("list initialized metadata") .is_empty()); + atomic_metadata_failure_rolls_back_rename(&backend).await; exercises_backend_contract(&backend).await; data_correctness::exercise(&backend).await; migration_renames::exercise(&backend).await; @@ -266,3 +267,72 @@ fn product(schema: &SchemaName, name: &str, price: i64, active: bool) -> Entity ]), ) } + +async fn atomic_metadata_failure_rolls_back_rename(backend: &MssqlBackend) { + let original = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("AtomicSchema").unwrap(), + vec![FieldDefinition::new( + FieldName::new("label").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + )], + vec![], + ) + .unwrap(); + backend + .apply_schema_change( + &original.name, + &DiffEngine::create_new(&original).steps, + Some(&original), + ) + .await + .unwrap(); + let row = Entity { + id: schema_forge_core::types::EntityId::new("atomic"), + schema: original.name.clone(), + fields: BTreeMap::from([("label".into(), DynamicValue::Text("retained".into()))]), + }; + backend.create(&row).await.unwrap(); + let mut proposed = original.clone(); + proposed.fields[0].name = FieldName::new("replacement").unwrap(); + proposed.fields[0] + .annotations + .push(schema_forge_core::types::FieldAnnotation::RenamedFrom { + name: FieldName::new("label").unwrap(), + }); + { + let mut connection = backend.pool().get().await.unwrap(); + connection.simple_query("CREATE TRIGGER [reject_atomic_metadata] ON [dbo].[_schema_metadata] AFTER UPDATE AS BEGIN THROW 50001, 'test metadata failure', 1; END;").await.unwrap().into_results().await.unwrap(); + } + let plan = DiffEngine::plan_update(&original, &proposed).unwrap(); + assert!(backend + .apply_schema_change(&original.name, &plan.steps, Some(&proposed)) + .await + .is_err()); + assert_eq!( + backend.load_schema_metadata(&original.name).await.unwrap(), + Some(original.clone()) + ); + assert_eq!( + backend.get(&original.name, &row.id).await.unwrap(), + Some(row) + ); + { + let mut connection = backend.pool().get().await.unwrap(); + connection + .simple_query("DROP TRIGGER [reject_atomic_metadata]") + .await + .unwrap() + .into_results() + .await + .unwrap(); + } + backend + .apply_schema_change(&original.name, &plan.steps, Some(&proposed)) + .await + .unwrap(); + assert_eq!( + backend.load_schema_metadata(&original.name).await.unwrap(), + Some(proposed) + ); +} diff --git a/crates/schema-forge-postgres/src/backend.rs b/crates/schema-forge-postgres/src/backend.rs index 65c48c88..1293fe32 100644 --- a/crates/schema-forge-postgres/src/backend.rs +++ b/crates/schema-forge-postgres/src/backend.rs @@ -185,7 +185,21 @@ impl PgBackend { .map_err(|error| BackendError::ConnectionError { message: error.to_string(), })?; - for definition in &definitions { + Self::reconcile_constraints_on(&mut tx, &definitions, finalizing).await?; + tx.commit() + .await + .map_err(|error| BackendError::MigrationFailed { + step: "commit relation constraints".into(), + reason: error.to_string(), + }) + } + + async fn reconcile_constraints_on( + connection: &mut sqlx::PgConnection, + definitions: &[SchemaDefinition], + finalizing: bool, + ) -> Result<(), BackendError> { + for definition in definitions { for field in &definition.fields { if let FieldType::Relation { target, @@ -219,19 +233,14 @@ impl PgBackend { END IF; END $forge_fk$;"# ); - sqlx::query(&statement).execute(&mut *tx).await.map_err(|error| BackendError::MigrationFailed { + sqlx::query(&statement).execute(&mut *connection).await.map_err(|error| BackendError::MigrationFailed { step: format!("ensure relation constraint {table}.{column} -> {target}"), reason: format!("{error}; repair orphaned relation values before retrying the migration"), })?; } } } - tx.commit() - .await - .map_err(|error| BackendError::MigrationFailed { - step: "commit relation constraints".into(), - reason: error.to_string(), - }) + Ok(()) } /// Drop the cached schema metadata so the next read refetches from @@ -396,10 +405,23 @@ impl PgBackend { } async fn fetch_schema_metadata_from_db(&self) -> Result, BackendError> { + let mut connection = + self.pool + .acquire() + .await + .map_err(|e| BackendError::ConnectionError { + message: e.to_string(), + })?; + Self::fetch_metadata_on(&mut connection).await + } + + async fn fetch_metadata_on( + connection: &mut sqlx::PgConnection, + ) -> Result, BackendError> { let rows: Vec = sqlx::query(&format!( "SELECT \"definition\" FROM \"{SCHEMA_META_TABLE}\";" )) - .fetch_all(&self.pool) + .fetch_all(&mut *connection) .await .map_err(|e| BackendError::QueryError { message: format!("failed to list schema metadata: {e}"), @@ -451,6 +473,21 @@ impl PgBackend { async fn repair_float_columns( &self, definition: &SchemaDefinition, + ) -> Result<(), BackendError> { + let mut tx = self + .pool + .begin() + .await + .map_err(|e| map_write_error(e, definition.name.as_str(), "begin float repair"))?; + Self::repair_float_columns_on(&mut tx, definition).await?; + tx.commit() + .await + .map_err(|e| map_write_error(e, definition.name.as_str(), "commit float repair")) + } + + async fn repair_float_columns_on( + connection: &mut sqlx::PgConnection, + definition: &SchemaDefinition, ) -> Result<(), BackendError> { let float_columns: Vec<&str> = definition .fields @@ -472,7 +509,7 @@ impl PgBackend { WHERE table_schema = current_schema() AND table_name = $1;", ) .bind(table) - .fetch_all(&self.pool) + .fetch_all(&mut *connection) .await .map_err(|e| BackendError::QueryError { message: format!("failed to introspect columns for '{table}': {e}"), @@ -503,21 +540,14 @@ impl PgBackend { let alter = format!( "ALTER TABLE \"{table}\" ALTER COLUMN \"{col}\" TYPE DOUBLE PRECISION USING \"{col}\"::double precision;" ); - let mut tx = self - .pool - .begin() + sqlx::query(&alter) + .execute(&mut *connection) .await - .map_err(|e| map_write_error(e, table, "begin float repair"))?; - sqlx::query(&alter).execute(&mut *tx).await.map_err(|e| { - BackendError::MigrationFailed { + .map_err(|e| BackendError::MigrationFailed { step: format!("repair_float_column({table}.{col})"), reason: e.to_string(), - } - })?; - Self::invalidate_schema_revisions(&mut tx, &definition.name).await?; - tx.commit() - .await - .map_err(|e| map_write_error(e, table, "commit float repair"))?; + })?; + Self::invalidate_schema_revisions(connection, &definition.name).await?; } Ok(()) @@ -533,6 +563,76 @@ impl PgBackend { } impl SchemaBackend for PgBackend { + async fn apply_schema_change( + &self, + name: &SchemaName, + steps: &[MigrationStep], + definition: Option<&SchemaDefinition>, + ) -> Result<(), BackendError> { + let fail = |error: sqlx::Error| BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: error.to_string(), + }; + let mut tx = self.pool.begin().await.map_err(fail)?; + // Serialize administrative metadata changes while retaining transactional DDL visibility. + sqlx::query(&format!( + "LOCK TABLE \"{SCHEMA_META_TABLE}\" IN SHARE ROW EXCLUSIVE MODE" + )) + .execute(&mut *tx) + .await + .map_err(fail)?; + let mut definitions = Self::fetch_metadata_on(&mut tx).await?; + if let Some(definition) = definition { + if &definition.name != name { + return Err(BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: "schema name does not match metadata".into(), + }); + } + if let Some(existing) = definitions.iter().find(|schema| &schema.name == name) { + schema_forge_core::migration::DiffEngine::validate_transition(existing, definition) + .map_err(|error| BackendError::MigrationFailed { + step: "validate schema transition".into(), + reason: error.to_string(), + })?; + } + } + for step in steps { + for statement in migration_step_to_sql(name.as_str(), step) { + sqlx::query(&statement) + .execute(&mut *tx) + .await + .map_err(fail)?; + } + } + if !steps.is_empty() { + Self::invalidate_schema_revisions(&mut tx, name).await?; + } + definitions.retain(|schema| &schema.name != name); + if let Some(definition) = definition { + Self::repair_float_columns_on(&mut tx, definition).await?; + let json = + serde_json::to_value(definition).map_err(|error| BackendError::Internal { + message: error.to_string(), + })?; + sqlx::query(&format!("INSERT INTO \"{SCHEMA_META_TABLE}\" (name, definition) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET definition = $2")) + .bind(name.as_str()).bind(json).execute(&mut *tx).await.map_err(fail)?; + definitions.push(definition.clone()); + } else { + sqlx::query(&format!( + "DELETE FROM \"{SCHEMA_META_TABLE}\" WHERE name = $1" + )) + .bind(name.as_str()) + .execute(&mut *tx) + .await + .map_err(fail)?; + } + Self::reconcile_constraints_on(&mut tx, &definitions, true).await?; + tx.commit().await.map_err(fail)?; + self.invalidate_schema_cache(); + Ok(()) + } + async fn finalize_schema_migrations(&self) -> Result<(), BackendError> { self.reconcile_relation_constraints(None).await } diff --git a/crates/schema-forge-postgres/tests/migration_safety.rs b/crates/schema-forge-postgres/tests/migration_safety.rs index 978a04a9..18642224 100644 --- a/crates/schema-forge-postgres/tests/migration_safety.rs +++ b/crates/schema-forge-postgres/tests/migration_safety.rs @@ -91,6 +91,7 @@ async fn relations_have_consistent_integrity_and_legacy_constraints_are_repaired } async fn exercise(backend: &PgBackend) { + atomic_schema_failure_preserves_data_and_metadata(backend).await; // Pet precedes Owner, and both reference each other. let mut pet = definition("Pet", Some(("owner", "Owner"))); pet.fields[0] @@ -380,3 +381,48 @@ async fn exercise_manual_tenancy(backend: &PgBackend) { Err(BackendError::UniqueViolation { .. }) )); } + +async fn atomic_schema_failure_preserves_data_and_metadata(backend: &PgBackend) { + let original = definition("AtomicSchema", None); + backend + .apply_schema_change( + &original.name, + &DiffEngine::create_new(&original).steps, + Some(&original), + ) + .await + .unwrap(); + let row = Entity { + id: EntityId::new("atomic"), + schema: original.name.clone(), + fields: BTreeMap::from([("label".into(), DynamicValue::Text("retained".into()))]), + }; + backend.create(&row).await.unwrap(); + // Dropping label succeeds, then strict FK finalization fails. Every earlier + // DDL, metadata write, and revision invalidation must be rolled back. + let mut proposed = definition("AtomicSchema", Some(("owner", "AbsentAtomicOwner"))); + proposed.id = original.id.clone(); + proposed.fields.remove(0); + let plan = DiffEngine::plan_update(&original, &proposed).unwrap(); + assert!(backend + .apply_schema_change(&original.name, &plan.steps, Some(&proposed)) + .await + .is_err()); + assert_eq!( + backend.load_schema_metadata(&original.name).await.unwrap(), + Some(original.clone()) + ); + assert_eq!( + backend.get(&original.name, &row.id).await.unwrap(), + Some(row) + ); + backend + .apply_schema_change(&original.name, &[], None) + .await + .unwrap(); + assert!(backend + .load_schema_metadata(&original.name) + .await + .unwrap() + .is_none()); +} diff --git a/crates/schema-forge-surrealdb/src/backend.rs b/crates/schema-forge-surrealdb/src/backend.rs index e5fbd7c2..60181312 100644 --- a/crates/schema-forge-surrealdb/src/backend.rs +++ b/crates/schema-forge-surrealdb/src/backend.rs @@ -270,12 +270,12 @@ impl SurrealBackend { } } -impl SchemaBackend for SurrealBackend { - async fn apply_migration( +impl SurrealBackend { + async fn compile_migration( &self, schema_name: &SchemaName, steps: &[MigrationStep], - ) -> Result<(), BackendError> { + ) -> Result, BackendError> { let table = schema_name.as_str(); let needs_enum_metadata = steps .iter() @@ -348,6 +348,79 @@ impl SchemaBackend for SurrealBackend { } statements.extend(compiled); } + Ok(statements) + } +} + +impl SchemaBackend for SurrealBackend { + async fn apply_schema_change( + &self, + name: &SchemaName, + steps: &[MigrationStep], + definition: Option<&SchemaDefinition>, + ) -> Result<(), BackendError> { + if let Some(definition) = definition { + if &definition.name != name { + return Err(BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: "schema name does not match metadata".into(), + }); + } + if let Some(existing) = self.load_schema_metadata(name).await? { + schema_forge_core::migration::DiffEngine::validate_transition( + &existing, definition, + ) + .map_err(|error| BackendError::MigrationFailed { + step: "validate schema transition".into(), + reason: error.to_string(), + })?; + } + } + let mut statements = self.compile_migration(name, steps).await?; + if let Some(definition) = definition { + let json = + serde_json::to_string(definition).map_err(|error| BackendError::Internal { + message: error.to_string(), + })?; + statements.push(format!("UPSERT {SCHEMA_META_TABLE}:`{name}` CONTENT {{ name: '{name}', definition: $schema_definition }};")); + self.db + .query(format!( + "BEGIN TRANSACTION;\n{}\nCOMMIT TRANSACTION;", + statements.join("\n") + )) + .bind(("schema_definition", json)) + .await + .map_err(|error| BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: error.to_string(), + })? + .check() + .map_err(|error| BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: error.to_string(), + })?; + } else { + statements.push(format!("DELETE {SCHEMA_META_TABLE}:`{name}`;")); + self.execute_raw(&format!( + "BEGIN TRANSACTION;\n{}\nCOMMIT TRANSACTION;", + statements.join("\n") + )) + .await? + .check() + .map_err(|error| BackendError::MigrationFailed { + step: "atomic schema change".into(), + reason: error.to_string(), + })?; + } + Ok(()) + } + + async fn apply_migration( + &self, + schema_name: &SchemaName, + steps: &[MigrationStep], + ) -> Result<(), BackendError> { + let statements = self.compile_migration(schema_name, steps).await?; if !statements.is_empty() { let sql = format!( "BEGIN TRANSACTION;\n{}\nCOMMIT TRANSACTION;", diff --git a/crates/schema-forge-surrealdb/tests/migration_renames.rs b/crates/schema-forge-surrealdb/tests/migration_renames.rs index bbbb32bc..38826974 100644 --- a/crates/schema-forge-surrealdb/tests/migration_renames.rs +++ b/crates/schema-forge-surrealdb/tests/migration_renames.rs @@ -8,3 +8,55 @@ async fn declared_renames_preserve_values_and_schema_constraints() { .unwrap(); contract::exercise(&backend).await; } + +#[tokio::test] +async fn metadata_failure_rolls_back_destructive_schema_steps() { + use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; + use schema_forge_core::{migration::DiffEngine, types::*}; + use std::collections::BTreeMap; + let backend = schema_forge_surrealdb::SurrealBackend::connect_memory("atomic", "atomic") + .await + .unwrap(); + let original = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("AtomicSchema").unwrap(), + vec![FieldDefinition::new( + FieldName::new("label").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + )], + vec![], + ) + .unwrap(); + backend + .apply_schema_change( + &original.name, + &DiffEngine::create_new(&original).steps, + Some(&original), + ) + .await + .unwrap(); + let row = Entity { + id: EntityId::new("atomic"), + schema: original.name.clone(), + fields: BTreeMap::from([("label".into(), DynamicValue::Text("retained".into()))]), + }; + backend.create(&row).await.unwrap(); + let mut proposed = original.clone(); + proposed.fields[0].name = FieldName::new("replacement").unwrap(); + let old_json = serde_json::to_string(&original).unwrap(); + backend.client().query("DEFINE FIELD definition ON _schema_metadata TYPE string ASSERT $value = $original_definition;") + .bind(("original_definition", old_json)).await.unwrap().check().unwrap(); + let plan = DiffEngine::plan_update(&original, &proposed).unwrap(); + assert!(backend + .apply_schema_change(&original.name, &plan.steps, Some(&proposed)) + .await + .is_err()); + assert_eq!( + backend.load_schema_metadata(&original.name).await.unwrap(), + Some(original.clone()) + ); + assert_eq!( + backend.get(&original.name, &row.id).await.unwrap(), + Some(row) + ); +} From 6ed21ebfbac52894c42fb692f2c22da04c06793c Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:25:44 -0600 Subject: [PATCH 50/77] fix: commit prepared schema storage through atomic backend operation --- .../src/actor/schema_changes.rs | 20 +++++++++---------- .../schema-forge-acton/src/routes/schemas.rs | 3 ++- docs/migrations/safe-schema-changes.md | 11 +++++----- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/crates/schema-forge-acton/src/actor/schema_changes.rs b/crates/schema-forge-acton/src/actor/schema_changes.rs index 34be2f2e..fd329f91 100644 --- a/crates/schema-forge-acton/src/actor/schema_changes.rs +++ b/crates/schema-forge-acton/src/actor/schema_changes.rs @@ -69,17 +69,15 @@ pub(super) fn configure(actor: &mut ManagedActor) { .insert(name.to_string(), change.definition.clone()) }; Reply::try_pending(async move { - let result = async { - if !change.steps.is_empty() { - backend.apply_migration(&name, &change.steps).await?; - } - if !change.remove { - backend.store_schema_metadata(&change.definition).await?; - backend.finalize_schema_migrations().await?; - } - Ok::<(), schema_forge_backend::BackendError>(()) - } - .await; + // HTTP DELETE unregisters the schema for this process. Preserve its + // existing storage lifecycle: no implicit metadata or table deletion. + let result = if change.remove { + Ok(()) + } else { + backend + .apply_schema_change(&name, &change.steps, Some(&change.definition)) + .await + }; if let Err(error) = result { return Err(SchemaChangeFailure { name, diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index 8a01b867..69a641a9 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -910,7 +910,8 @@ pub async fn update_schema( Ok(Json(schema_to_response(&new_definition))) } -/// DELETE /schemas/{name} -- Remove a schema. Requires platform_admin role. +/// DELETE /schemas/{name} -- Unregister a schema from the running process. +/// Requires platform_admin. Stored metadata and entity data remain unchanged. #[instrument(skip_all)] pub async fn delete_schema( State(state): State>, diff --git a/docs/migrations/safe-schema-changes.md b/docs/migrations/safe-schema-changes.md index 215cd487..c06b3de9 100644 --- a/docs/migrations/safe-schema-changes.md +++ b/docs/migrations/safe-schema-changes.md @@ -39,11 +39,12 @@ combined hierarchies are rejected before schema writes. Runtime schema changes validate the complete Cedar bundle before storage and install that exact immutable bundle after storage succeeds. Concurrent changes -with an outdated preflight return HTTP 409 and must be retried. Storage failures -preserve the active registry and policy bundle, but DDL, metadata, and final -constraint reconciliation can use separate backend transactions. A later failure -does not undo earlier committed database changes. Inspect the reported failure -and stored schema before retrying; use a maintenance window for migrations. +with an outdated preflight return HTTP 409 and must be retried. Runtime changes +commit DDL, metadata, and constraint reconciliation in one backend transaction. +Storage failures preserve the prior database state, active registry, and policy +bundle. Backends without atomic schema-change support refuse the operation. +CLI batches can still commit each schema separately; use a maintenance window +for migrations. ## PostgreSQL relation integrity From cb7066652d4dd8f23fae06b3d04787ecb113003b Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:26:34 -0600 Subject: [PATCH 51/77] test: verify failed schema commit rolls back prior destructive steps --- crates/schema-forge-acton/tests/policy_preflight.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/schema-forge-acton/tests/policy_preflight.rs b/crates/schema-forge-acton/tests/policy_preflight.rs index 024c84b9..8a33b181 100644 --- a/crates/schema-forge-acton/tests/policy_preflight.rs +++ b/crates/schema-forge-acton/tests/policy_preflight.rs @@ -255,7 +255,8 @@ async fn custom_policy_field_contract_refuses_mutations_before_storage_changes() desired ); - // A storage rejection restores the provisional registry before replying. + // A later storage rejection rolls back earlier DDL and restores the + // provisional registry before replying. Field removal precedes the index. std::fs::write(directory.path().join("contract.cedar"), r#"forbid (principal is Forge::Principal, action == Action::"ReadThing", resource is Thing) when { resource.code == "secret" };"#).unwrap(); backend @@ -263,6 +264,9 @@ async fn custom_policy_field_contract_refuses_mutations_before_storage_changes() .await .unwrap(); let mut invalid = desired.clone(); + invalid + .fields + .retain(|field| field.name.as_str() != "label"); invalid .fields .iter_mut() @@ -288,6 +292,10 @@ async fn custom_policy_field_contract_refuses_mutations_before_storage_changes() .unwrap() .is_err()); assert!(Arc::ptr_eq(&policies.current(), &desired_policy)); + assert_eq!( + backend.get(&schema.name, &entity.id).await.unwrap().fields["label"], + DynamicValue::Text("untouched".into()) + ); let (tx, rx) = oneshot::channel(); forge .send(GetSchema { From 54a844c603929cbb86822d40c56fde619f0b7dfe Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:29:03 -0600 Subject: [PATCH 52/77] refactor: keep schema change failure payload compact --- crates/schema-forge-acton/src/actor/schema_changes.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/schema-forge-acton/src/actor/schema_changes.rs b/crates/schema-forge-acton/src/actor/schema_changes.rs index fd329f91..1c64a88f 100644 --- a/crates/schema-forge-acton/src/actor/schema_changes.rs +++ b/crates/schema-forge-acton/src/actor/schema_changes.rs @@ -11,7 +11,7 @@ use crate::{error::ForgeError, messages::ApplyPreparedSchemaChange}; #[derive(Debug)] struct SchemaChangeFailure { name: SchemaName, - previous: Option, + previous: Option>, cause: ForgeError, } @@ -81,7 +81,7 @@ pub(super) fn configure(actor: &mut ManagedActor) { if let Err(error) = result { return Err(SchemaChangeFailure { name, - previous, + previous: previous.map(Box::new), cause: error.into(), }); } @@ -95,7 +95,7 @@ pub(super) fn configure(actor: &mut ManagedActor) { actor .model .registry - .insert(failure.name.to_string(), previous.clone()); + .insert(failure.name.to_string(), previous.as_ref().clone()); } else { actor.model.registry.remove(failure.name.as_str()); } From 2b0bfaf390a4f8f4f96997d3a650407a4cb5af30 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:31:27 -0600 Subject: [PATCH 53/77] test: compare loaded atomic records directly --- crates/schema-forge-mssql/tests/sql_server.rs | 2 +- crates/schema-forge-postgres/tests/migration_safety.rs | 2 +- crates/schema-forge-surrealdb/tests/migration_renames.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/schema-forge-mssql/tests/sql_server.rs b/crates/schema-forge-mssql/tests/sql_server.rs index db368033..660c3220 100644 --- a/crates/schema-forge-mssql/tests/sql_server.rs +++ b/crates/schema-forge-mssql/tests/sql_server.rs @@ -315,7 +315,7 @@ async fn atomic_metadata_failure_rolls_back_rename(backend: &MssqlBackend) { ); assert_eq!( backend.get(&original.name, &row.id).await.unwrap(), - Some(row) + row ); { let mut connection = backend.pool().get().await.unwrap(); diff --git a/crates/schema-forge-postgres/tests/migration_safety.rs b/crates/schema-forge-postgres/tests/migration_safety.rs index 18642224..eaed0f76 100644 --- a/crates/schema-forge-postgres/tests/migration_safety.rs +++ b/crates/schema-forge-postgres/tests/migration_safety.rs @@ -414,7 +414,7 @@ async fn atomic_schema_failure_preserves_data_and_metadata(backend: &PgBackend) ); assert_eq!( backend.get(&original.name, &row.id).await.unwrap(), - Some(row) + row ); backend .apply_schema_change(&original.name, &[], None) diff --git a/crates/schema-forge-surrealdb/tests/migration_renames.rs b/crates/schema-forge-surrealdb/tests/migration_renames.rs index 38826974..2b8ae95e 100644 --- a/crates/schema-forge-surrealdb/tests/migration_renames.rs +++ b/crates/schema-forge-surrealdb/tests/migration_renames.rs @@ -57,6 +57,6 @@ async fn metadata_failure_rolls_back_destructive_schema_steps() { ); assert_eq!( backend.get(&original.name, &row.id).await.unwrap(), - Some(row) + row ); } From 1c8899330801f2182fc4f06b52fc2aa98283a3a7 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:45:50 -0600 Subject: [PATCH 54/77] fix: preserve object data during transactional SurrealDB renames --- .Codex/plans/migration-safety.md | 4 ++++ .../tests/support/migration_renames.rs | 20 ++++++++++++++++++- crates/schema-forge-surrealdb/src/backend.rs | 6 ++++++ crates/schema-forge-surrealdb/src/codegen.rs | 17 +++++++++++++++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/.Codex/plans/migration-safety.md b/.Codex/plans/migration-safety.md index a7794d77..d48ebb79 100644 --- a/.Codex/plans/migration-safety.md +++ b/.Codex/plans/migration-safety.md @@ -11,3 +11,7 @@ Tests: DSL roundtrip and invalid rename hints, repeat apply, tenant add/remove/r ## Atomic runtime schema persistence followup Add apply_schema_change(name, steps, optional definition) to SchemaBackend and dynamic adapter with unsupported default. Each supported backend executes schema DDL, required validation, metadata upsert/delete, and integrity reconciliation in a single transaction. Extract existing statement/connection helpers to preserve rename behavior. PostgreSQL uses transaction-local metadata and strict FK finalization; cache invalidates only after commit. Runtime DELETE keeps its existing registry-only semantics (parent decision). Add focused rollback contracts exercising failure after DDL and metadata-only/deletion paths, run focused nextest and Clippy only; CI owns broad gates. + +## SurrealDB transactional rename visibility + +Measured SELECT snapshots show REMOVE FIELD followed by UPDATE in the same transaction discards unrelated FLEXIBLE object data. Individual statements in separate transactions do not. Preserve one transaction: copy to fully defined destination, temporarily relax source required/default constraints, unset old values while source definition still exists, defer only rename-source definition removals until all data writes finish. Regression must retain unrelated nested data and verify a later write still works, besides renamed nested data and metadata rollback. diff --git a/crates/schema-forge-backend/tests/support/migration_renames.rs b/crates/schema-forge-backend/tests/support/migration_renames.rs index 4fd2f490..0817948c 100644 --- a/crates/schema-forge-backend/tests/support/migration_renames.rs +++ b/crates/schema-forge-backend/tests/support/migration_renames.rs @@ -23,6 +23,7 @@ pub async fn exercise(backend: &B) { ], ), FieldDefinition::new(FieldName::new("payload").unwrap(), FieldType::Json), + FieldDefinition::new(FieldName::new("unrelated").unwrap(), FieldType::Json), ], vec![], ) @@ -40,10 +41,12 @@ pub async fn exercise(backend: &B) { BTreeMap::from([ ("number".into(), DynamicValue::Text("555-9999".into())), ("payload".into(), nested.clone()), + ("unrelated".into(), nested.clone()), ]), )) .await .unwrap(); + let stored_nested = row.fields.get("payload").cloned().unwrap(); let mut new = old.clone(); for (field, target) in new.fields.iter_mut().zip(["business_number", "document"]) { field.annotations.push(FieldAnnotation::RenamedFrom { @@ -62,9 +65,24 @@ pub async fn exercise(backend: &B) { loaded.fields.get("business_number"), Some(&DynamicValue::Text("555-9999".into())) ); - assert_eq!(loaded.fields.get("document"), Some(&nested)); + assert_eq!(loaded.fields.get("document"), Some(&stored_nested)); + assert_eq!(loaded.fields.get("unrelated"), row.fields.get("unrelated")); assert!(!loaded.fields.contains_key("number")); assert!(!loaded.fields.contains_key("payload")); assert!(DiffEngine::diff(&new, &new).is_empty()); backend.store_schema_metadata(&new).await.unwrap(); + // A later ordinary write must retain both renamed and unrelated objects. + let updated = backend + .update(&Entity { + id: row.id.clone(), + schema: new.name.clone(), + fields: BTreeMap::from([( + "business_number".into(), + DynamicValue::Text("555-0000".into()), + )]), + }) + .await + .unwrap(); + assert_eq!(updated.fields.get("document"), Some(&stored_nested)); + assert_eq!(updated.fields.get("unrelated"), row.fields.get("unrelated")); } diff --git a/crates/schema-forge-surrealdb/src/backend.rs b/crates/schema-forge-surrealdb/src/backend.rs index 60181312..409aa8ff 100644 --- a/crates/schema-forge-surrealdb/src/backend.rs +++ b/crates/schema-forge-surrealdb/src/backend.rs @@ -296,6 +296,7 @@ impl SurrealBackend { None }; let mut statements = Vec::new(); + let mut rename_cleanup = Vec::new(); for step in steps { let mut compiled = if let MigrationStep::RenameField { old_name, new_name } = step { let schema = metadata @@ -310,6 +311,10 @@ impl SurrealBackend { reason: "rename source missing from stored metadata".into(), } })?; + // SurrealDB's transaction-local field refresh after REMOVE FIELD + // can strip unrelated object data on a later UPDATE. Complete + // every data write before removing renamed source definitions. + rename_cleanup.push(format!("REMOVE FIELD {old_name} ON {table};")); crate::codegen::rename_field_stmts( table, field, @@ -348,6 +353,7 @@ impl SurrealBackend { } statements.extend(compiled); } + statements.extend(rename_cleanup); Ok(statements) } } diff --git a/crates/schema-forge-surrealdb/src/codegen.rs b/crates/schema-forge-surrealdb/src/codegen.rs index a23b6594..94e75ad4 100644 --- a/crates/schema-forge-surrealdb/src/codegen.rs +++ b/crates/schema-forge-surrealdb/src/codegen.rs @@ -291,7 +291,22 @@ pub(crate) fn rename_field_stmts( if source.is_indexed() { statements.push(format!("REMOVE INDEX idx_{table}_{old_name} ON {table};")); } - statements.push(format!("REMOVE FIELD {old_name} ON {table};")); + // Remove required/default rules before unsetting the old value. Its + // definition stays present until the backend finishes all data writes. + let original_type = field_type_to_surql(&source.field_type); + let optional_type = if original_type == "any" || original_type.starts_with("option<") { + original_type + } else { + format!("option<{original_type}>") + }; + let flexible = if needs_flexible(&source.field_type) { + "FLEXIBLE " + } else { + "" + }; + statements.push(format!( + "DEFINE FIELD OVERWRITE {old_name} ON {table} {flexible}TYPE {optional_type};" + )); statements.push(format!("UPDATE {table} UNSET {old_name};")); if source.is_unique() { statements.push(add_unique_surql(table, new_name.as_str(), per_tenant)); From a1b3065e7e24fc619a5e12d70253190d3d1529f2 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:47:22 -0600 Subject: [PATCH 55/77] ci: collect all regression failures and retain build caches --- .github/workflows/postgres-conditional.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/postgres-conditional.yml b/.github/workflows/postgres-conditional.yml index c48ce674..74d2d440 100644 --- a/.github/workflows/postgres-conditional.yml +++ b/.github/workflows/postgres-conditional.yml @@ -46,24 +46,30 @@ jobs: run: sudo apt-get update && sudo apt-get install -y protobuf-compiler libkrb5-dev postgresql-client - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - name: Install nextest run: cargo install cargo-nextest --locked - name: Run storage concurrency cases - run: cargo nextest run -p schema-forge-backend -p schema-forge-postgres --run-ignored all + run: cargo nextest run -p schema-forge-backend -p schema-forge-postgres --run-ignored all --no-fail-fast - name: Prepare disposable HTTP namespace run: psql -X -v ON_ERROR_STOP=1 -c 'CREATE SCHEMA conditional_http' - name: Run complete runtime and schema suites - run: cargo nextest run -p schema-forge-acton -p schema-forge-surrealdb -p schema-forge-core -p schema-forge-dsl --features schema-forge-acton/postgres,schema-forge-acton/graphql + run: cargo nextest run -p schema-forge-acton -p schema-forge-surrealdb -p schema-forge-core -p schema-forge-dsl --features schema-forge-acton/postgres,schema-forge-acton/graphql --no-fail-fast - name: Run HTTP authorization and concurrency cases + if: ${{ !cancelled() }} env: SCHEMAFORGE_TEST_POSTGRES_DISPOSABLE: "1" SCHEMAFORGE_TEST_POSTGRES_URL: postgres://schemaforge:schemaforge-test@localhost:5432/schemaforge_test?options=-csearch_path%3Dconditional_http - run: cargo nextest run -p schema-forge-acton --features postgres,graphql --test conditional_entities --run-ignored only + run: cargo nextest run -p schema-forge-acton --features postgres,graphql --test conditional_entities --run-ignored only --no-fail-fast - name: Check CLI preparation and security behavior - run: cargo nextest run -p schema-forge-cli --no-default-features --features postgres + if: ${{ !cancelled() }} + run: cargo nextest run -p schema-forge-cli --no-default-features --features postgres --no-fail-fast - name: Deny runtime and backend lints + if: ${{ !cancelled() }} run: cargo clippy -p schema-forge-acton -p schema-forge-surrealdb -p schema-forge-core -p schema-forge-dsl -p schema-forge-backend -p schema-forge-postgres --features schema-forge-acton/postgres,schema-forge-acton/graphql --all-targets -- -D warnings - name: Deny CLI lints + if: ${{ !cancelled() }} run: cargo clippy -p schema-forge-cli --no-default-features --features postgres --all-targets -- -D warnings - name: Clean disposable HTTP namespace if: always() From 56c31e7a012786b3392b8c4114a0f6cb95aa6615 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:50:57 -0600 Subject: [PATCH 56/77] test(authz): exercise actual provisional policy diagnostics --- crates/schema-forge-acton/src/access.rs | 33 +++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index 47ad4428..5d0409ba 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -1365,9 +1365,38 @@ mod tests { schema.name.clone(), BTreeMap::from([("draft".into(), DynamicValue::Text("changed".into()))]), ); - // A malformed full resource must never turn authorization failure - // into a successful empty update, even for a permitted role. + // Required values may still be supplied by defaults after input + // authorization. Absence alone is not a Cedar evaluation error. let incomplete = delta.clone(); + filter_patch_fields(&store, &mut delta, &incomplete, &schema, Some(&claims)).unwrap(); + assert_eq!(delta.fields, incomplete.fields); + + // A forbid that actually reads the absent attribute must fail closed, + // even though the role-based permit would otherwise allow the write. + let store = store_for( + &schema, + Some( + r#" + forbid ( + principal, + action == Action::"WriteFieldSettings_draft", + resource is Settings + ) when { resource.key == "locked" }; + "#, + ), + ); + let decision = crate::authz::engine::authorize_input_field( + &store, + Some(&claims), + &schema, + &incomplete, + "draft", + ) + .unwrap(); + assert!( + !decision.errors.is_empty(), + "reading the absent key must be a Cedar evaluation error" + ); let error = filter_patch_fields(&store, &mut delta, &incomplete, &schema, Some(&claims)) .unwrap_err(); assert!(matches!(error, ForgeError::Forbidden { .. })); From 5595230e610ed538c7567c3136bbfd576b4d2805 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:58:10 -0600 Subject: [PATCH 57/77] fix: preserve omitted SQL Server fields during entity updates --- .Codex/plans/migration-safety.md | 4 ++ crates/schema-forge-backend/src/traits.rs | 3 +- crates/schema-forge-mssql/src/backend.rs | 39 ++++++++--- crates/schema-forge-mssql/tests/sql_server.rs | 70 +++++++++++++++++-- 4 files changed, 101 insertions(+), 15 deletions(-) diff --git a/.Codex/plans/migration-safety.md b/.Codex/plans/migration-safety.md index d48ebb79..4e993870 100644 --- a/.Codex/plans/migration-safety.md +++ b/.Codex/plans/migration-safety.md @@ -15,3 +15,7 @@ Add apply_schema_change(name, steps, optional definition) to SchemaBackend and d ## SurrealDB transactional rename visibility Measured SELECT snapshots show REMOVE FIELD followed by UPDATE in the same transaction discards unrelated FLEXIBLE object data. Individual statements in separate transactions do not. Preserve one transaction: copy to fully defined destination, temporarily relax source required/default constraints, unset old values while source definition still exists, defer only rename-source definition removals until all data writes finish. Regression must retain unrelated nested data and verify a later write still works, besides renamed nested data and metadata rollback. + +## SQL Server sparse update parity + +CI exposed that SQL Server replaces its entire JSON payload on EntityStore::update, unlike PostgreSQL/SurrealDB and the runtime's field-filtered partial update path. Implement one bound JSON_MODIFY UPDATE with OUTPUT inserted row, preserving omitted values and explicit tagged nulls without a read/modify/write race. Clarify trait semantics, retain rename regression, and add SQL Server direct null/empty-update checks. Run focused SQL Server integration tests only. diff --git a/crates/schema-forge-backend/src/traits.rs b/crates/schema-forge-backend/src/traits.rs index b76ab791..4cdebb83 100644 --- a/crates/schema-forge-backend/src/traits.rs +++ b/crates/schema-forge-backend/src/traits.rs @@ -167,7 +167,8 @@ pub trait EntityStore: Send + Sync { /// Update an existing entity. /// /// The entity's `id` and `schema` determine which record to update. - /// All fields in `entity.fields` replace the existing fields. + /// Supplied fields replace their corresponding values; omitted fields remain unchanged. + /// An explicit `DynamicValue::Null` writes null rather than omitting the field. /// Returns the updated entity. fn update(&self, entity: &Entity) -> impl Future> + Send; diff --git a/crates/schema-forge-mssql/src/backend.rs b/crates/schema-forge-mssql/src/backend.rs index 1938d2f8..2be7fcba 100644 --- a/crates/schema-forge-mssql/src/backend.rs +++ b/crates/schema-forge-mssql/src/backend.rs @@ -319,24 +319,43 @@ impl EntityStore for MssqlBackend { } async fn update(&self, entity: &Entity) -> Result { - let data = serde_json::to_string(&entity.fields).map_err(json_error)?; + if entity.fields.is_empty() { + return self.get(&entity.schema, &entity.id).await; + } + let mut parameters = vec![entity.id.to_string()]; + let mut expression = "[data]".to_string(); + for (field, value) in &entity.fields { + let path_parameter = parameters.len() + 1; + let value_parameter = path_parameter + 1; + parameters.push(format!("$.\"{field}\"")); + parameters.push(serde_json::to_string(value).map_err(json_error)?); + expression = format!( + "JSON_MODIFY({expression}, @P{path_parameter}, JSON_QUERY(@P{value_parameter}))" + ); + } let sql = format!( - "UPDATE {} SET [data] = @P2 WHERE [id] = @P1;", + "UPDATE {} SET [data] = {expression} OUTPUT INSERTED.[id], INSERTED.[data] WHERE [id] = @P1;", quote(entity.schema.as_str()) ); + let bindings: Vec<&dyn tiberius::ToSql> = parameters + .iter() + .map(|value| value as &dyn tiberius::ToSql) + .collect(); let mut connection = connection(&self.pool).await?; - let affected = connection - .execute(sql, &[&entity.id.as_str(), &data.as_str()]) + let rows = connection + .query(sql, &bindings) .await .map_err(query_error)? - .total(); - if affected == 0 { - return Err(BackendError::EntityNotFound { + .into_first_result() + .await + .map_err(query_error)?; + rows.first() + .map(|row| entity_from_row(row, &entity.schema)) + .transpose()? + .ok_or_else(|| BackendError::EntityNotFound { schema: entity.schema.to_string(), entity_id: entity.id.to_string(), - }); - } - Ok(entity.clone()) + }) } async fn update_field_if_matches( diff --git a/crates/schema-forge-mssql/tests/sql_server.rs b/crates/schema-forge-mssql/tests/sql_server.rs index 660c3220..3d7922be 100644 --- a/crates/schema-forge-mssql/tests/sql_server.rs +++ b/crates/schema-forge-mssql/tests/sql_server.rs @@ -62,6 +62,7 @@ async fn connects_and_initializes_metadata(image_tag: &str) { .expect("list initialized metadata") .is_empty()); + sparse_updates_preserve_other_fields_and_explicit_null(&backend).await; atomic_metadata_failure_rolls_back_rename(&backend).await; exercises_backend_contract(&backend).await; data_correctness::exercise(&backend).await; @@ -313,10 +314,7 @@ async fn atomic_metadata_failure_rolls_back_rename(backend: &MssqlBackend) { backend.load_schema_metadata(&original.name).await.unwrap(), Some(original.clone()) ); - assert_eq!( - backend.get(&original.name, &row.id).await.unwrap(), - row - ); + assert_eq!(backend.get(&original.name, &row.id).await.unwrap(), row); { let mut connection = backend.pool().get().await.unwrap(); connection @@ -336,3 +334,67 @@ async fn atomic_metadata_failure_rolls_back_rename(backend: &MssqlBackend) { Some(proposed) ); } + +async fn sparse_updates_preserve_other_fields_and_explicit_null(backend: &MssqlBackend) { + let schema = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("SparseUpdate").unwrap(), + ["first", "second", "nullable"] + .into_iter() + .map(|name| { + FieldDefinition::new( + FieldName::new(name).unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + ) + }) + .collect(), + vec![], + ) + .unwrap(); + backend + .apply_schema_change( + &schema.name, + &DiffEngine::create_new(&schema).steps, + Some(&schema), + ) + .await + .unwrap(); + let initial = Entity::new( + schema.name.clone(), + BTreeMap::from([ + ("first".into(), DynamicValue::Text("before".into())), + ("second".into(), DynamicValue::Text("before".into())), + ("nullable".into(), DynamicValue::Text("clear me".into())), + ]), + ); + backend.create(&initial).await.unwrap(); + let first = Entity::with_id( + initial.id.clone(), + schema.name.clone(), + BTreeMap::from([("first".into(), DynamicValue::Text("first changed".into()))]), + ); + let second = Entity::with_id( + initial.id.clone(), + schema.name.clone(), + BTreeMap::from([("second".into(), DynamicValue::Text("second changed".into()))]), + ); + let (first_result, second_result) = + tokio::join!(backend.update(&first), backend.update(&second)); + assert_eq!(first_result.unwrap().fields.len(), 3); + assert_eq!(second_result.unwrap().fields.len(), 3); + let null_update = Entity::with_id( + initial.id.clone(), + schema.name.clone(), + BTreeMap::from([("nullable".into(), DynamicValue::Null)]), + ); + let updated = backend.update(&null_update).await.unwrap(); + assert_eq!(updated.fields.get("first"), first.fields.get("first")); + assert_eq!(updated.fields.get("second"), second.fields.get("second")); + assert_eq!(updated.fields.get("nullable"), Some(&DynamicValue::Null)); + assert_eq!( + backend.get(&schema.name, &initial.id).await.unwrap(), + updated + ); + let empty = Entity::with_id(initial.id, schema.name, BTreeMap::new()); + assert_eq!(backend.update(&empty).await.unwrap(), updated); +} From 48a283f190a4d02a8bfc54c1aaaadbc08977a96e Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 16:59:04 -0600 Subject: [PATCH 58/77] docs: document SQL Server partial update preservation --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4642e3d..97a40393 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ is pre-1.0; breaking changes bump the **minor** version per requests no longer emit invalid SQL. PUT rules and persisted optional values now agree: PUT clears omitted writable optional fields and preserves denied or server-managed fields. PATCH remains a partial update. +- SQL Server updates merge supplied fields atomically, preserving omitted fields + and explicit nulls. Concurrent updates to different fields no longer replace + each other's stored values. - GraphQL creates and updates share the REST write pipeline. GraphQL reads, relations, and deletes enforce concrete Cedar decisions. Unproven raw GraphQL totals are withheld instead of disclosing counts of inaccessible rows. From a50e9a511b21461a5baeb204327370e2273af199 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:16:16 -0600 Subject: [PATCH 59/77] test: declare tenant ownership in isolation fixtures --- crates/schema-forge-acton/tests/auth_demo.rs | 30 ++++++++--------- .../tests/cross_entity_reads.rs | 32 ++++++++++++++----- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/crates/schema-forge-acton/tests/auth_demo.rs b/crates/schema-forge-acton/tests/auth_demo.rs index 90be70a8..a57d2877 100644 --- a/crates/schema-forge-acton/tests/auth_demo.rs +++ b/crates/schema-forge-acton/tests/auth_demo.rs @@ -584,7 +584,6 @@ async fn demo_multi_tenancy_isolation() { let surreal = SurrealBackend::connect_memory("test", "demo_tenant") .await .unwrap(); - let db_client = surreal.client().clone(); let backend: Arc = Arc::new(surreal); let mut registry = HashMap::new(); @@ -610,7 +609,7 @@ async fn demo_multi_tenancy_isolation() { .unwrap(); register_schema(&org_schema, &backend, &mut registry).await; - // Create Project schema (regular, will be tenant-scoped) + // Declare Project as a tenant child; unannotated schemas are shared. // @access with empty lists = all authenticated users permitted (testing tenancy, not schema-level) let project_schema = SchemaDefinition::new( SchemaId::new(), @@ -619,22 +618,21 @@ async fn demo_multi_tenancy_isolation() { FieldName::new("title").unwrap(), FieldType::Text(TextConstraints::unconstrained()), )], - vec![Annotation::Access { - read: vec![], - write: vec![], - delete: vec![], - cross_tenant_read: vec![], - }], + vec![ + Annotation::Tenant(TenantKind::Child { + parent: SchemaName::new("Organization").unwrap(), + }), + Annotation::Access { + read: vec![], + write: vec![], + delete: vec![], + cross_tenant_read: vec![], + }, + ], ) .unwrap(); register_schema(&project_schema, &backend, &mut registry).await; - // Define _tenant field on tenant-scoped tables (SCHEMAFULL requires explicit field definition) - db_client - .query("DEFINE FIELD _tenant ON Project TYPE option;") - .await - .expect("define _tenant field"); - // Build tenant config from schemas let all_schemas: Vec = registry.values().cloned().collect(); let tenant_config = TenantConfig::from_schemas(&all_schemas).expect("valid tenant config"); @@ -668,7 +666,7 @@ async fn demo_multi_tenancy_isolation() { Some(serde_json::json!({"fields": {"title": "Tenant A Project"}})), ) .await; - assert_eq!(status, StatusCode::CREATED); + assert_eq!(status, StatusCode::CREATED, "{json}"); println!( " Created: {} (title={})", json["id"], json["fields"]["title"] @@ -701,7 +699,7 @@ async fn demo_multi_tenancy_isolation() { Some(serde_json::json!({"fields": {"title": "Tenant B Project"}})), ) .await; - assert_eq!(status, StatusCode::CREATED); + assert_eq!(status, StatusCode::CREATED, "{json}"); println!( " Created: {} (title={})", json["id"], json["fields"]["title"] diff --git a/crates/schema-forge-acton/tests/cross_entity_reads.rs b/crates/schema-forge-acton/tests/cross_entity_reads.rs index 8fdd09b7..a99ad775 100644 --- a/crates/schema-forge-acton/tests/cross_entity_reads.rs +++ b/crates/schema-forge-acton/tests/cross_entity_reads.rs @@ -447,13 +447,15 @@ async fn cross_tenant_related_row_is_not_readable() { .unwrap(), ); let mut registry = HashMap::new(); - apply_and_register(&backend, &mut registry, approval_schema(vec![])).await; - apply_and_register( - &backend, - &mut registry, - document_schema(REQUIRE_GRANTED, Cardinality::One), - ) - .await; + let tenant_child = Annotation::Tenant(TenantKind::Child { + parent: SchemaName::new("Organization").unwrap(), + }); + let mut approval = approval_schema(vec![]); + approval.annotations.push(tenant_child.clone()); + apply_and_register(&backend, &mut registry, approval).await; + let mut document = document_schema(REQUIRE_GRANTED, Cardinality::One); + document.annotations.push(tenant_child); + apply_and_register(&backend, &mut registry, document).await; // Tenancy enabled with an Organization root. let org = SchemaDefinition::new( @@ -463,7 +465,9 @@ async fn cross_tenant_related_row_is_not_readable() { vec![Annotation::Tenant(TenantKind::Root)], ) .unwrap(); - let tenant_config = TenantConfig::from_schemas(&[org]).unwrap(); + apply_and_register(&backend, &mut registry, org).await; + let schemas: Vec<_> = registry.values().cloned().collect(); + let tenant_config = TenantConfig::from_schemas(&schemas).unwrap(); let state = build_state(backend, registry, Some(tenant_config)).await; @@ -479,6 +483,18 @@ async fn cross_tenant_related_row_is_not_readable() { assert_eq!(status, StatusCode::CREATED, "{approval}"); let approval_id = approval["id"].as_str().unwrap().to_string(); + // The same rule succeeds when the caller owns the related tenant row. + let (status, body) = json_request( + &app_a, + Method::POST, + "/schemas/Document/entities", + Some(serde_json::json!({ + "fields": { "title": "own", "status": "closed", "approval": approval_id } + })), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "same-tenant rule read: {body}"); + // Tenant B references tenant A's approval id in a closed document. Because // the related read is tenant-scoped to org-b, the row is invisible → the // related.approval binding is absent → fail-closed (422), proving a rule From 0dc4ef543d5eed2b14eb846b614f6d5763619bbe Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:36:17 -0600 Subject: [PATCH 60/77] build: align release toolchain and documentation with SurrealDB 3.3 --- .github/workflows/cel-kani.yml | 2 +- .github/workflows/mssql-integration.yml | 4 ++-- .github/workflows/postgres-conditional.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/site-e2e.yml | 2 +- CHANGELOG.md | 5 +++++ README.md | 6 +++--- crates/schema-forge-acton/src/routes/meta.rs | 6 +++--- crates/schema-forge-cli/src/commands/serve.rs | 4 ++-- .../templates/site/src/generated/api-client.ts.jinja | 2 +- rust-toolchain.toml | 2 +- 11 files changed, 21 insertions(+), 16 deletions(-) diff --git a/.github/workflows/cel-kani.yml b/.github/workflows/cel-kani.yml index 41d9e87a..3f7a7ce0 100644 --- a/.github/workflows/cel-kani.yml +++ b/.github/workflows/cel-kani.yml @@ -27,7 +27,7 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 - name: Install build prerequisites run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - name: Install pinned integer solver diff --git a/.github/workflows/mssql-integration.yml b/.github/workflows/mssql-integration.yml index b4f23332..f032783b 100644 --- a/.github/workflows/mssql-integration.yml +++ b/.github/workflows/mssql-integration.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.97.1 - name: Install Protocol Buffers compiler shell: pwsh @@ -81,7 +81,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y protobuf-compiler libkrb5-dev - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.97.1 - name: Rust cache uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/postgres-conditional.yml b/.github/workflows/postgres-conditional.yml index 74d2d440..419fa535 100644 --- a/.github/workflows/postgres-conditional.yml +++ b/.github/workflows/postgres-conditional.yml @@ -44,7 +44,7 @@ jobs: - uses: actions/checkout@v6 - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y protobuf-compiler libkrb5-dev postgresql-client - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.97.1 - uses: Swatinem/rust-cache@v2 with: cache-on-failure: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f7b8941..97df79e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: - uses: actions/checkout@v6 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.97.1 - name: Install Linux system build dependencies if: runner.os == 'Linux' diff --git a/.github/workflows/site-e2e.yml b/.github/workflows/site-e2e.yml index e0ad279a..8020de9e 100644 --- a/.github/workflows/site-e2e.yml +++ b/.github/workflows/site-e2e.yml @@ -25,7 +25,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y protobuf-compiler libkrb5-dev - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@1.97.1 - name: Rust cache uses: Swatinem/rust-cache@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 97a40393..76634d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ is pre-1.0; breaking changes bump the **minor** version per requests no longer emit invalid SQL. PUT rules and persisted optional values now agree: PUT clears omitted writable optional fields and preserves denied or server-managed fields. PATCH remains a partial update. +- SurrealDB uses the supported 3.3 storage engine, correcting a concurrency race + that could let two conditional writes both succeed. - SQL Server updates merge supplied fields atomically, preserving omitted fields and explicit nulls. Concurrent updates to different fields no longer replace each other's stored values. @@ -57,6 +59,9 @@ is pre-1.0; breaking changes bump the **minor** version per ### Upgrade notes +Source builds use Rust 1.97.1. SurrealDB deployments require server 3.3 or newer. Upgrade remote servers before +connecting this release; embedded development databases use the bundled engine. + Review pending schema changes before restarting. Destructive startup migrations now require deliberate opt-in. PostgreSQL schema administration repairs missing foreign keys and can require cleanup of existing orphan references. Tenanted diff --git a/README.md b/README.md index 58215e50..2c77e341 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The repo ships a self-contained demo: an in-memory backend with twelve seeded en | Tool | Why it's needed | |---|---| -| [Rust 1.75+](https://rustup.rs) | The demo builds the CLI from source | +| [Rust 1.97.1+](https://rustup.rs) | The demo builds the CLI from source | | [Task](https://taskfile.dev/installation/) | Runs the bundled demo recipes | | [pnpm](https://pnpm.io/installation) + Node 20+ | Builds and serves the React admin | @@ -88,8 +88,8 @@ project, define a schema, and serve it. If you only want to kick the tires, the ### Prerequisites -- A running SurrealDB 2.x, PostgreSQL 14+, or Microsoft SQL Server instance (SurrealDB embedded mode works for development) -- Rust 1.75+ only if you intend to build from source +- A running SurrealDB 3.3+, PostgreSQL 14+, or Microsoft SQL Server instance (SurrealDB embedded mode works for development) +- Rust 1.97.1+ only if you intend to build from source ### Install the Prebuilt Binary diff --git a/crates/schema-forge-acton/src/routes/meta.rs b/crates/schema-forge-acton/src/routes/meta.rs index a1bf4cf1..9ebfe118 100644 --- a/crates/schema-forge-acton/src/routes/meta.rs +++ b/crates/schema-forge-acton/src/routes/meta.rs @@ -26,7 +26,7 @@ pub struct MetaInfo { /// Lowercase, stable: API clients should match on this token, not the /// human label below. pub backend: &'static str, - /// Backend label suitable for human display (`"SurrealDB 2.x"`). + /// Backend label suitable for human display (`"SurrealDB 3.3+"`). pub backend_label: String, /// Auth posture (always PASETO V4 today). pub auth: MetaAuth, @@ -148,9 +148,9 @@ mod tests { #[test] fn meta_info_carries_compile_time_version() { - let info = MetaInfo::new("surrealdb", "SurrealDB 2.x", 3600); + let info = MetaInfo::new("surrealdb", "SurrealDB 3.3+", 3600); assert_eq!(info.backend, "surrealdb"); - assert_eq!(info.backend_label, "SurrealDB 2.x"); + assert_eq!(info.backend_label, "SurrealDB 3.3+"); assert_eq!(info.auth.kind, "paseto"); assert_eq!(info.auth.ttl_seconds, 3600); // The build version is whatever Cargo stamped on this crate. diff --git a/crates/schema-forge-cli/src/commands/serve.rs b/crates/schema-forge-cli/src/commands/serve.rs index 0cc10039..c8775503 100644 --- a/crates/schema-forge-cli/src/commands/serve.rs +++ b/crates/schema-forge-cli/src/commands/serve.rs @@ -975,7 +975,7 @@ fn resolve_custom_policies_dir( fn build_meta_info(db_params: &DbParams) -> Arc { let (backend, label) = match db_params { #[cfg(feature = "surrealdb")] - DbParams::Surrealdb(_) => ("surrealdb", "SurrealDB 2.x"), + DbParams::Surrealdb(_) => ("surrealdb", "SurrealDB 3.3+"), #[cfg(feature = "postgres")] DbParams::Postgres(_) => ("postgres", "PostgreSQL"), #[cfg(feature = "mssql")] @@ -1190,7 +1190,7 @@ mod tests { let meta = Arc::new(schema_forge_acton::MetaInfo::new( "surrealdb", - "SurrealDB 2.x", + "SurrealDB 3.3+", 3600, )); let principal_claims = diff --git a/crates/schema-forge-cli/templates/site/src/generated/api-client.ts.jinja b/crates/schema-forge-cli/templates/site/src/generated/api-client.ts.jinja index c62ce27a..ba06a01d 100644 --- a/crates/schema-forge-cli/templates/site/src/generated/api-client.ts.jinja +++ b/crates/schema-forge-cli/templates/site/src/generated/api-client.ts.jinja @@ -273,7 +273,7 @@ export function isSystemSchema(schema: SchemaResponse): boolean { export type MetaResponse = { /** Stable lowercase backend kind (`"surrealdb"`, `"postgres"`, `"turso"`). */ backend: string - /** Human-readable backend label (`"SurrealDB 2.x"`). */ + /** Human-readable backend label (`"SurrealDB 3.3+"`). */ backend_label: string auth: { /** Token scheme — `"paseto"` today. */ diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 03199c42..5ec58696 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.91.1" +channel = "1.97.1" components = ["rustfmt", "clippy"] profile = "minimal" From bda51e46cccc721a575c597fe5d7d1557d467528 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:36:52 -0600 Subject: [PATCH 61/77] ci: validate the SurrealDB CLI before release --- .github/workflows/postgres-conditional.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/postgres-conditional.yml b/.github/workflows/postgres-conditional.yml index 419fa535..f4903f54 100644 --- a/.github/workflows/postgres-conditional.yml +++ b/.github/workflows/postgres-conditional.yml @@ -65,12 +65,18 @@ jobs: - name: Check CLI preparation and security behavior if: ${{ !cancelled() }} run: cargo nextest run -p schema-forge-cli --no-default-features --features postgres --no-fail-fast + - name: Check SurrealDB CLI integration + if: ${{ !cancelled() }} + run: cargo nextest run -p schema-forge-cli --no-fail-fast - name: Deny runtime and backend lints if: ${{ !cancelled() }} run: cargo clippy -p schema-forge-acton -p schema-forge-surrealdb -p schema-forge-core -p schema-forge-dsl -p schema-forge-backend -p schema-forge-postgres --features schema-forge-acton/postgres,schema-forge-acton/graphql --all-targets -- -D warnings - name: Deny CLI lints if: ${{ !cancelled() }} run: cargo clippy -p schema-forge-cli --no-default-features --features postgres --all-targets -- -D warnings + - name: Deny SurrealDB CLI lints + if: ${{ !cancelled() }} + run: cargo clippy -p schema-forge-cli --all-targets -- -D warnings - name: Clean disposable HTTP namespace if: always() run: psql -X -v ON_ERROR_STOP=1 -c 'DROP SCHEMA IF EXISTS conditional_http CASCADE' From 32faad6b6e01d3fe4b3937b932066420e6a625ab Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:37:08 -0600 Subject: [PATCH 62/77] fix: pin live schema authorization for GraphQL reads --- crates/schema-forge-acton/src/actor.rs | 11 ++ crates/schema-forge-acton/src/authz/store.rs | 6 + crates/schema-forge-acton/src/graphql/mod.rs | 41 ++++++- .../src/graphql/resolvers.rs | 31 +++++- crates/schema-forge-acton/src/messages.rs | 13 +++ .../tests/graphql_tenants.rs | 103 ++++++++++++++++-- docs/graphql-writes.md | 7 ++ 7 files changed, 198 insertions(+), 14 deletions(-) diff --git a/crates/schema-forge-acton/src/actor.rs b/crates/schema-forge-acton/src/actor.rs index f91ae21f..e5acf50d 100644 --- a/crates/schema-forge-acton/src/actor.rs +++ b/crates/schema-forge-acton/src/actor.rs @@ -266,6 +266,17 @@ fn configure_registry_reads(actor: &mut ManagedActor) { }) }); + actor.act_on::(|actor, ctx| { + let snapshot = actor.model.policy_store.as_ref().map(|store| { + crate::messages::AuthorizationSnapshot { + registry: actor.model.registry.clone(), + policy: store.current(), + } + }); + let reply = ctx.message().reply.clone(); + Reply::pending(async move { reply.send(snapshot).await }) + }); + actor.act_on::(|actor, ctx| { let directory = actor.model.custom_policies_dir.clone(); let reply = ctx.message().reply.clone(); diff --git a/crates/schema-forge-acton/src/authz/store.rs b/crates/schema-forge-acton/src/authz/store.rs index 38980303..00a3e07e 100644 --- a/crates/schema-forge-acton/src/authz/store.rs +++ b/crates/schema-forge-acton/src/authz/store.rs @@ -77,6 +77,12 @@ impl PolicyStore { } } + /// Pin an existing immutable snapshot for a single request. + #[cfg(feature = "graphql")] + pub(crate) fn from_snapshot(snapshot: Arc) -> Self { + Self { inner: ArcSwap::from(snapshot) } + } + /// Returns the current snapshot. Cheap pointer-clone. pub fn current(&self) -> Arc { self.inner.load_full() diff --git a/crates/schema-forge-acton/src/graphql/mod.rs b/crates/schema-forge-acton/src/graphql/mod.rs index 0e432738..d99304c9 100644 --- a/crates/schema-forge-acton/src/graphql/mod.rs +++ b/crates/schema-forge-acton/src/graphql/mod.rs @@ -20,10 +20,17 @@ use crate::state::ForgeState; /// GraphQL POST handler. pub async fn graphql_handler( State(app_state): State>, - Extension(state): Extension, + Extension(mut state): Extension, OptionalClaims(claims): OptionalClaims, req: GraphQLRequest, ) -> GraphQLResponse { + if let Err(error) = pin_authorization_snapshot(&app_state, &mut state).await { + return async_graphql::Response::from_errors(vec![async_graphql::ServerError::new( + error.to_string(), + None, + )]) + .into(); + } let schema = state.graphql_schema.load(); let request = req.into_inner().data(ForgeGraphqlContext { state: state.clone(), @@ -33,6 +40,38 @@ pub async fn graphql_handler( schema.execute(request).await.into() } +/// GraphQL reads authorize and project against one coherent request snapshot. +/// Mutations continue to use the live actor-backed REST handlers. +async fn pin_authorization_snapshot( + app_state: &acton_service::state::AppState, + state: &mut ForgeState, +) -> Result<(), crate::error::ForgeError> { + use acton_service::prelude::ActorHandleInterface; + let unavailable = || crate::error::ForgeError::Forbidden { + message: "GraphQL authorization state unavailable".into(), + }; + let actor = app_state + .actor::() + .ok_or_else(unavailable)?; + let (tx, rx) = tokio::sync::oneshot::channel(); + actor + .send(crate::messages::GetAuthorizationSnapshot { + reply: crate::messages::ReplyChannel::new(tx), + }) + .await; + let snapshot = tokio::time::timeout(std::time::Duration::from_secs(5), rx) + .await + .map_err(|_| unavailable())? + .map_err(|_| unavailable())? + .ok_or_else(unavailable)?; + state.registry = crate::state::SchemaRegistry::new(); + for (name, definition) in snapshot.registry { + state.registry.insert(name, definition).await; + } + state.policy_store = Arc::new(crate::authz::PolicyStore::from_snapshot(snapshot.policy)); + Ok(()) +} + /// GraphiQL playground GET handler. pub async fn graphql_playground() -> impl IntoResponse { Html(GraphiQLSource::build().endpoint("/forge/graphql").finish()) diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index caaaedfa..44c7f896 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -45,10 +45,12 @@ pub fn forge_error_to_gql(err: ForgeError) -> async_graphql::Error { pub async fn resolve_get_entity<'a>( ctx: &ResolverContext<'a>, schema_name: &str, - schema_def: &SchemaDefinition, + _schema_def: &SchemaDefinition, type_name: &str, ) -> async_graphql::Result>> { let gql_ctx = ctx.data::()?; + let live_definition = request_schema(gql_ctx, schema_name).await?; + let schema_def = &live_definition; let claims = gql_ctx.claims.as_ref(); check_schema_access( @@ -103,11 +105,13 @@ pub async fn resolve_get_entity<'a>( /// Resolve a list of entities with filter/sort/pagination. pub async fn resolve_list_entities<'a>( ctx: &ResolverContext<'a>, - _schema_name: &str, - schema_def: &SchemaDefinition, + schema_name: &str, + _schema_def: &SchemaDefinition, type_name: &str, ) -> async_graphql::Result>> { let gql_ctx = ctx.data::()?; + let live_definition = request_schema(gql_ctx, schema_name).await?; + let schema_def = &live_definition; let claims = gql_ctx.claims.as_ref(); check_schema_access( @@ -353,9 +357,11 @@ async fn mutation_response( pub async fn resolve_delete_entity( ctx: &ResolverContext<'_>, schema_name: &str, - schema_def: &SchemaDefinition, + _schema_def: &SchemaDefinition, ) -> async_graphql::Result { let gql_ctx = ctx.data::()?; + let live_definition = request_schema(gql_ctx, schema_name).await?; + let schema_def = &live_definition; let claims = gql_ctx.claims.as_ref(); check_schema_access( @@ -416,7 +422,7 @@ pub async fn resolve_relation_one<'a>( parent: &EntityFields, field_name: &str, target_schema_name: &str, - target_schema_def: &SchemaDefinition, + _target_schema_def: &SchemaDefinition, target_type_name: &str, ) -> async_graphql::Result>> { let ref_id = match parent.fields.get(field_name) { @@ -426,6 +432,8 @@ pub async fn resolve_relation_one<'a>( }; let gql_ctx = ctx.data::()?; + let live_definition = request_schema(gql_ctx, target_schema_name).await?; + let target_schema_def = &live_definition; let claims = gql_ctx.claims.as_ref(); check_schema_access( @@ -468,7 +476,7 @@ pub async fn resolve_relation_many<'a>( parent: &EntityFields, field_name: &str, target_schema_name: &str, - target_schema_def: &SchemaDefinition, + _target_schema_def: &SchemaDefinition, target_type_name: &str, ) -> async_graphql::Result>> { let ref_ids = match parent.fields.get(field_name) { @@ -480,6 +488,8 @@ pub async fn resolve_relation_many<'a>( }; let gql_ctx = ctx.data::()?; + let live_definition = request_schema(gql_ctx, target_schema_name).await?; + let target_schema_def = &live_definition; let claims = gql_ctx.claims.as_ref(); check_schema_access( @@ -518,6 +528,15 @@ pub async fn resolve_relation_many<'a>( Ok(Some(FieldValue::list(results))) } +async fn request_schema( + context: &ForgeGraphqlContext, + name: &str, +) -> async_graphql::Result { + context.state.registry.get(name).await.ok_or_else(|| { + forge_error_to_gql(ForgeError::SchemaNotFound { name: name.into() }) + }) +} + fn require_record_access( context: &ForgeGraphqlContext, schema: &SchemaDefinition, diff --git a/crates/schema-forge-acton/src/messages.rs b/crates/schema-forge-acton/src/messages.rs index 5f28a389..cef2b3e2 100644 --- a/crates/schema-forge-acton/src/messages.rs +++ b/crates/schema-forge-acton/src/messages.rs @@ -127,6 +127,19 @@ pub struct GetPolicyStore { pub reply: ReplyChannel>>, } +/// Coherent definitions and policies captured under the actor message barrier. +#[derive(Clone, Debug)] +pub struct AuthorizationSnapshot { + pub registry: HashMap, + pub policy: Arc, +} + +/// Capture a request's authorization context without mixing registry versions. +#[derive(Clone, Debug)] +pub struct GetAuthorizationSnapshot { + pub reply: ReplyChannel>, +} + /// Retrieve the custom policy source selected when the actor was initialized. /// This includes CLI overrides and must be used for schema preflight checks. #[derive(Clone, Debug)] diff --git a/crates/schema-forge-acton/tests/graphql_tenants.rs b/crates/schema-forge-acton/tests/graphql_tenants.rs index 8ca5ec9a..144edb00 100644 --- a/crates/schema-forge-acton/tests/graphql_tenants.rs +++ b/crates/schema-forge-acton/tests/graphql_tenants.rs @@ -6,7 +6,11 @@ use acton_service::{ config::Config, middleware::Claims, prelude::ActorHandleInterface, service_builder::ServiceBuilder, }; -use axum::{body::Body, http::Request, Router}; +use axum::{ + body::Body, + http::{Method, Request}, + Router, +}; use http_body_util::BodyExt; use schema_forge_acton::{ config::SchemaForgeConfig, @@ -32,7 +36,7 @@ async fn app() -> (Router, String, String) { @access(read: ["member"], write: ["member"], delete: ["member"]) schema Org { name: text required } @access(read: ["member"], write: ["member"]) - schema Catalog { name: text required } + schema Catalog { name: text required secret: text hidden_value: text } "#, ) .unwrap(); @@ -92,15 +96,15 @@ async fn app() -> (Router, String, String) { .unwrap() .unwrap(); let app = extension - .register_graphql_routes(Router::new()) + .register_graphql_routes(schema_forge_acton::routes::forge_routes()) .with_state(service.state().clone()); (app, roots.remove(0), roots.remove(0)) } -async fn query(app: &Router, query: &str, tenant: &str) -> Value { - let claims = Claims { +fn test_claims(tenant: &str, roles: &[&str]) -> Claims { + Claims { sub: "user:graphql-test".into(), - roles: vec!["member".into()], + roles: roles.iter().map(|role| (*role).into()).collect(), perms: vec![], exp: 9_999_999_999, iat: None, @@ -113,7 +117,11 @@ async fn query(app: &Router, query: &str, tenant: &str) -> Value { "tenant_chain".into(), json!([{"schema":"Org","entity_id":tenant}]), )]), - }; + } +} + +async fn query(app: &Router, query: &str, tenant: &str) -> Value { + let claims = test_claims(tenant, &["member"]); let mut request = Request::post("/forge/graphql") .header("content-type", "application/json") .body(Body::from(json!({"query": query}).to_string())) @@ -167,3 +175,84 @@ async fn graphql_scopes_legacy_roots_and_keeps_shared_catalog_usable() { "{catalog}" ); } + +async fn administer_catalog(app: &Router, method: Method, body: Value) { + let mut request = Request::builder() + .method(method) + .uri("/schemas/Catalog") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + request + .extensions_mut() + .insert(test_claims("", &["platform_admin"])); + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + assert!( + status.is_success(), + "{status}: {}", + String::from_utf8_lossy(&bytes) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graphql_uses_live_field_security_and_refuses_removed_schemas() { + let (app, tenant, _) = app().await; + let created = query(&app, + "mutation { createCatalog(input: {name: \"visible\", secret: \"restricted\", hidden_value: \"private\"}) { id } }", + &tenant).await; + let id = created["data"]["createCatalog"]["id"].as_str().unwrap(); + let get = format!("{{ catalog(id: \"{id}\") {{ name secret hidden_value }} }}"); + let before = query(&app, &get, &tenant).await; + assert_eq!( + before["data"]["catalog"]["secret"], "restricted", + "{before}" + ); + + let fields = json!([ + {"name":"name", "field_type":"Text", "modifiers":["required"]}, + {"name":"secret", "field_type":"Text", "annotations":[{"annotation":"FieldAccess", "read":["admin"], "write":["admin"]}]}, + {"name":"hidden_value", "field_type":"Text", "modifiers":["hidden"]} + ]); + administer_catalog( + &app, + Method::PUT, + json!({"name":"Catalog", "fields":fields}), + ) + .await; + let after = query(&app, &get, &tenant).await; + assert_eq!(after["data"]["catalog"]["name"], "visible", "{after}"); + assert!(after["data"]["catalog"]["secret"].is_null(), "{after}"); + assert!( + after["data"]["catalog"]["hidden_value"].is_null(), + "{after}" + ); + let list = query( + &app, + "{ catalogs { items { name secret hidden_value } } }", + &tenant, + ) + .await; + assert_eq!( + list["data"]["catalogs"]["items"][0]["name"], "visible", + "{list}" + ); + assert!( + list["data"]["catalogs"]["items"][0]["secret"].is_null(), + "{list}" + ); + assert!( + list["data"]["catalogs"]["items"][0]["hidden_value"].is_null(), + "{list}" + ); + + administer_catalog(&app, Method::PUT, json!({"name":"Catalog", "fields":fields, + "annotations":[{"annotation":"Access", "read":["admin"], "write":["admin"], "delete":["admin"], "cross_tenant_read":[]}] + })).await; + let revoked = query(&app, &get, &tenant).await; + assert!(revoked.get("errors").is_some(), "{revoked}"); + administer_catalog(&app, Method::DELETE, Value::Null).await; + let removed = query(&app, &get, &tenant).await; + assert!(removed.get("errors").is_some(), "{removed}"); +} diff --git a/docs/graphql-writes.md b/docs/graphql-writes.md index 0661589d..c3df501e 100644 --- a/docs/graphql-writes.md +++ b/docs/graphql-writes.md @@ -11,3 +11,10 @@ Mutation responses use the same field projection as REST, including hidden-field `SchemaForgeExtension::register_graphql_routes` now accepts and returns `Router>`. Register GraphQL routes on the service router before supplying the initialized application state. The service must register and initialize `ForgeActor`, as required by the REST routes; register `HookDispatchActor` when lifecycle hooks are enabled. GraphQL no longer writes directly through a separate backend-only state. The request context carries both the schema/query state and the initialized actor-backed application state. Embedders constructing `ForgeGraphqlContext` directly must supply its new `app_state` field. + +GraphQL reads capture the actor's current schema definitions and Cedar policy +bundle together at request start. Record checks, field permissions, and hidden +field projection use that immutable request snapshot, including runtime schema +annotation changes. Removed schemas fail closed even when their names remain in +the structural GraphQL schema until restart. Create and update mutations still +use the live actor-backed REST pipeline for each operation. From 4da9479dcba1822ec4715bcb8f648e3933fd480c Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:37:43 -0600 Subject: [PATCH 63/77] docs: describe live GraphQL authorization snapshots --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76634d27..dede6f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,9 @@ is pre-1.0; breaking changes bump the **minor** version per and explicit nulls. Concurrent updates to different fields no longer replace each other's stored values. - GraphQL creates and updates share the REST write pipeline. GraphQL reads, - relations, and deletes enforce concrete Cedar decisions. Unproven raw + relations, and deletes enforce concrete Cedar decisions. Each request captures + matching live definitions and policies, so runtime field restrictions also + govern existing GraphQL fields. Unproven raw GraphQL totals are withheld instead of disclosing counts of inaccessible rows. - CLI help hides secret environment values, including database and server URLs. `serve --host` controls the actual listener and accepts validated IPv4/IPv6 From b790446186759b403884cbeac3df4b8d1f027dcc Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:38:51 -0600 Subject: [PATCH 64/77] refactor: remove stale GraphQL authorization definition captures --- .../src/graphql/resolvers.rs | 5 ---- .../src/graphql/schema_builder.rs | 30 ++++--------------- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index 44c7f896..480a5551 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -45,7 +45,6 @@ pub fn forge_error_to_gql(err: ForgeError) -> async_graphql::Error { pub async fn resolve_get_entity<'a>( ctx: &ResolverContext<'a>, schema_name: &str, - _schema_def: &SchemaDefinition, type_name: &str, ) -> async_graphql::Result>> { let gql_ctx = ctx.data::()?; @@ -106,7 +105,6 @@ pub async fn resolve_get_entity<'a>( pub async fn resolve_list_entities<'a>( ctx: &ResolverContext<'a>, schema_name: &str, - _schema_def: &SchemaDefinition, type_name: &str, ) -> async_graphql::Result>> { let gql_ctx = ctx.data::()?; @@ -357,7 +355,6 @@ async fn mutation_response( pub async fn resolve_delete_entity( ctx: &ResolverContext<'_>, schema_name: &str, - _schema_def: &SchemaDefinition, ) -> async_graphql::Result { let gql_ctx = ctx.data::()?; let live_definition = request_schema(gql_ctx, schema_name).await?; @@ -422,7 +419,6 @@ pub async fn resolve_relation_one<'a>( parent: &EntityFields, field_name: &str, target_schema_name: &str, - _target_schema_def: &SchemaDefinition, target_type_name: &str, ) -> async_graphql::Result>> { let ref_id = match parent.fields.get(field_name) { @@ -476,7 +472,6 @@ pub async fn resolve_relation_many<'a>( parent: &EntityFields, field_name: &str, target_schema_name: &str, - _target_schema_def: &SchemaDefinition, target_type_name: &str, ) -> async_graphql::Result>> { let ref_ids = match parent.fields.get(field_name) { diff --git a/crates/schema-forge-acton/src/graphql/schema_builder.rs b/crates/schema-forge-acton/src/graphql/schema_builder.rs index 0e89f7ab..d81ba3e3 100644 --- a/crates/schema-forge-acton/src/graphql/schema_builder.rs +++ b/crates/schema-forge-acton/src/graphql/schema_builder.rs @@ -25,12 +25,6 @@ use super::type_mapping::{ pub fn build_graphql_schema(schemas: &[SchemaDefinition]) -> Result { let non_system: Vec<&SchemaDefinition> = schemas.iter().filter(|s| !s.is_system()).collect(); - // Build a lookup map for relation resolvers - let schema_map: HashMap = schemas - .iter() - .map(|s| (s.name.as_str().to_string(), s)) - .collect(); - let mut query = Object::new("Query"); let mut mutation = Object::new("Mutation"); @@ -59,7 +53,7 @@ pub fn build_graphql_schema(schemas: &[SchemaDefinition]) -> Result Result() @@ -131,14 +125,12 @@ pub fn build_graphql_schema(schemas: &[SchemaDefinition]) -> Result Result Result Result Result Result, type_name: &str, ) -> Result { let schema_name = schema_def.name.as_str().to_string(); @@ -315,7 +302,6 @@ fn build_output_type( // Relation type ref is always nullable let type_ref = field_type_to_type_ref(&schema_name, &field_name, field_type, false); - let target_def = schema_map.get(&target_name).map(|s| Arc::new((*s).clone())); let card = *cardinality; let fn_clone = field_name.clone(); @@ -323,13 +309,9 @@ fn build_output_type( let fn_clone = fn_clone.clone(); let target_name = target_name.clone(); let target_type = target_type.clone(); - let target_def = target_def.clone(); let card = card; FieldFuture::new(async move { let parent = ctx.parent_value.try_downcast_ref::()?; - let Some(td) = target_def else { - return Ok(None); - }; match card { Cardinality::One => { resolve_relation_one( @@ -337,7 +319,6 @@ fn build_output_type( parent, &fn_clone, &target_name, - &td, &target_type, ) .await @@ -348,7 +329,6 @@ fn build_output_type( parent, &fn_clone, &target_name, - &td, &target_type, ) .await From a9878d607f5bcce4fc5b70069882dc3bab1eadd8 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:39:31 -0600 Subject: [PATCH 65/77] fix: route GraphQL deletion through canonical entity pipeline --- .../src/graphql/resolvers.rs | 60 +++---------------- .../tests/graphql_tenants.rs | 14 +++++ docs/graphql-writes.md | 5 +- 3 files changed, 24 insertions(+), 55 deletions(-) diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index 480a5551..ffd1b6a0 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -351,65 +351,21 @@ async fn mutation_response( ))) } -/// Resolve delete entity mutation. +/// Resolve deletion through the canonical REST authorization, hooks, and storage pipeline. pub async fn resolve_delete_entity( ctx: &ResolverContext<'_>, schema_name: &str, ) -> async_graphql::Result { let gql_ctx = ctx.data::()?; - let live_definition = request_schema(gql_ctx, schema_name).await?; - let schema_def = &live_definition; - let claims = gql_ctx.claims.as_ref(); - - check_schema_access( - &gql_ctx.state.policy_store, - schema_def, - claims, - AccessAction::Delete, + let id = ctx.args.try_get("id")?.string()?.to_owned(); + crate::routes::entities::delete_entity( + axum::extract::State(gql_ctx.app_state.clone()), + axum::extract::Path((schema_name.to_owned(), id)), + crate::access::OptionalClaims(gql_ctx.claims.clone()), + axum::http::HeaderMap::new(), ) + .await .map_err(forge_error_to_gql)?; - - let id_arg = ctx.args.try_get("id")?.string()?.to_string(); - - let schema = SchemaName::new(schema_name).map_err(|_| { - forge_error_to_gql(ForgeError::InvalidSchemaName { - name: schema_name.to_string(), - }) - })?; - - let entity_id = EntityId::parse(&id_arg) - .map_err(|_| forge_error_to_gql(ForgeError::InvalidEntityId { id: id_arg.clone() }))?; - - let entity = gql_ctx - .state - .backend - .get(&schema, &entity_id) - .await - .map_err(|error| forge_error_to_gql(ForgeError::from(error)))?; - require_record_access(gql_ctx, schema_def, &entity, ActionVerb::Delete)?; - - // Record-level ownership check - if let (Some(ref policy), Some(c)) = (&gql_ctx.state.record_access_policy, claims) { - let entity = gql_ctx - .state - .backend - .get(&schema, &entity_id) - .await - .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; - if !policy.can_delete(schema_def, c, &entity).await { - return Err(forge_error_to_gql(ForgeError::Forbidden { - message: format!("not authorized to delete entity '{id_arg}'"), - })); - } - } - - gql_ctx - .state - .backend - .delete(&schema, &entity_id) - .await - .map_err(|e| forge_error_to_gql(ForgeError::from(e)))?; - Ok(GqlValue::Boolean(true)) } diff --git a/crates/schema-forge-acton/tests/graphql_tenants.rs b/crates/schema-forge-acton/tests/graphql_tenants.rs index 144edb00..02931391 100644 --- a/crates/schema-forge-acton/tests/graphql_tenants.rs +++ b/crates/schema-forge-acton/tests/graphql_tenants.rs @@ -174,6 +174,20 @@ async fn graphql_scopes_legacy_roots_and_keeps_shared_catalog_usable() { catalog["data"]["catalogs"]["items"][0]["name"], "shared", "{catalog}" ); + let deleted = query( + &app, + &format!("mutation {{ deleteOrg(id: \"{own}\") }}"), + &own, + ) + .await; + assert_eq!(deleted["data"]["deleteOrg"], true, "{deleted}"); + let remaining = query( + &app, + &format!("{{ org(id: \"{foreign}\") {{ id }} }}"), + &foreign, + ) + .await; + assert_eq!(remaining["data"]["org"]["id"], foreign, "{remaining}"); } async fn administer_catalog(app: &Router, method: Method, body: Value) { diff --git a/docs/graphql-writes.md b/docs/graphql-writes.md index c3df501e..9fb9816b 100644 --- a/docs/graphql-writes.md +++ b/docs/graphql-writes.md @@ -1,6 +1,6 @@ # GraphQL entity writes -GraphQL `create{Schema}` and `update{Schema}` mutations use the same entity write pipeline as REST POST and PATCH. Field authorization runs before defaults, computed expressions, validation rules, and lifecycle hooks. Updates merge authorized input with stored fields before evaluating rules. A mutation containing only denied fields returns the unchanged entity, unless server-generated values change. +GraphQL `create{Schema}`, `update{Schema}`, and `delete{Schema}` mutations use the same entity write pipelines as REST POST, PATCH, and DELETE. Field authorization runs before defaults, computed expressions, validation rules, and lifecycle hooks. Updates merge authorized input with stored fields before evaluating rules. A mutation containing only denied fields returns the unchanged entity, unless server-generated values change. Required fields supplied by a literal default, `@default`, `@compute`, or `@owner` may be omitted from create input. Required fields without a server-supplied value remain mandatory. The completed entity is validated before persistence. @@ -16,5 +16,4 @@ GraphQL reads capture the actor's current schema definitions and Cedar policy bundle together at request start. Record checks, field permissions, and hidden field projection use that immutable request snapshot, including runtime schema annotation changes. Removed schemas fail closed even when their names remain in -the structural GraphQL schema until restart. Create and update mutations still -use the live actor-backed REST pipeline for each operation. +the structural GraphQL schema until restart. All entity mutations use the live actor-backed REST pipeline for each operation. From a0f75e8eb1f543ecabad0d1be90be378b1994d7d Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:39:56 -0600 Subject: [PATCH 66/77] docs: finalize mutation and server compatibility notes --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dede6f33..3f66eec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ is pre-1.0; breaking changes bump the **minor** version per - SQL Server updates merge supplied fields atomically, preserving omitted fields and explicit nulls. Concurrent updates to different fields no longer replace each other's stored values. -- GraphQL creates and updates share the REST write pipeline. GraphQL reads, +- GraphQL creates, updates, and deletes share the REST mutation pipeline. GraphQL reads, relations, and deletes enforce concrete Cedar decisions. Each request captures matching live definitions and policies, so runtime field restrictions also govern existing GraphQL fields. Unproven raw @@ -61,7 +61,7 @@ is pre-1.0; breaking changes bump the **minor** version per ### Upgrade notes -Source builds use Rust 1.97.1. SurrealDB deployments require server 3.3 or newer. Upgrade remote servers before +Source builds use Rust 1.97.1. SurrealDB deployments require a stable 3.x server at version 3.3 or newer. Upgrade remote servers before connecting this release; embedded development databases use the bundled engine. Review pending schema changes before restarting. Destructive startup migrations From 4b05f02950e2b34a3c59353fd2d8cd34bf0b4bd7 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:42:14 -0600 Subject: [PATCH 67/77] fix: use SurrealDB 3.3 transaction-safe storage engine --- .Codex/plans/migration-safety.md | 4 + Cargo.lock | 2511 +++++++---------- crates/schema-forge-surrealdb/Cargo.toml | 3 +- crates/schema-forge-surrealdb/src/backend.rs | 176 +- crates/schema-forge-surrealdb/src/value.rs | 142 +- .../tests/data_correctness.rs | 93 + 6 files changed, 1336 insertions(+), 1593 deletions(-) diff --git a/.Codex/plans/migration-safety.md b/.Codex/plans/migration-safety.md index 4e993870..89b2ec2b 100644 --- a/.Codex/plans/migration-safety.md +++ b/.Codex/plans/migration-safety.md @@ -19,3 +19,7 @@ Measured SELECT snapshots show REMOVE FIELD followed by UPDATE in the same trans ## SQL Server sparse update parity CI exposed that SQL Server replaces its entire JSON payload on EntityStore::update, unlike PostgreSQL/SurrealDB and the runtime's field-filtered partial update path. Implement one bound JSON_MODIFY UPDATE with OUTPUT inserted row, preserving omitted values and explicit tagged nulls without a read/modify/write race. Clarify trait semantics, retain rename regression, and add SQL Server direct null/empty-update checks. Run focused SQL Server integration tests only. + +## Supported SurrealDB engine correction + +Reproduced simultaneous successful attachment clears on the 2.6 Mem engine even with explicit transactions. SurrealKV0.9.3 advances commit oracle before index publication; snapshots can mix old data with the newer conflict watermark. Latest2.6.4 uses identical engine; no compatible SurrealKV0.9 patch exists. Upgrade the backend SDK to stable3.3.0, whose Mem engine uses SurrealMX0.27 completed-commit watermark. Adapt native value/error APIs without changing entity semantics, add multi-thread competing clear/replace regression, run focused CAS and migration tests. Root approved supported upgrade over local mutex or vendored engine fork. diff --git a/Cargo.lock b/Cargo.lock index 38879877..02767051 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,7 +17,7 @@ dependencies = [ "derive-new", "derive_more", "mti", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -40,7 +40,7 @@ dependencies = [ "acton-macro", "anyhow", "async-trait", - "dashmap 6.1.0", + "dashmap", "derive-new", "dyn-clone", "futures", @@ -68,12 +68,12 @@ dependencies = [ "argon2", "async-trait", "axum", - "base64 0.22.1", + "base64", "bb8", "bb8-tiberius", "blake3", "chrono", - "dashmap 6.1.0", + "dashmap", "figment", "futures", "governor", @@ -96,8 +96,8 @@ dependencies = [ "serde", "serde_json", "sqlx", - "surrealdb 3.0.5", - "thiserror 2.0.18", + "surrealdb", + "thiserror 2.0.21", "tiberius", "tokio", "tokio-rustls 0.26.4", @@ -162,40 +162,17 @@ dependencies = [ [[package]] name = "affinitypool" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dde2a385b82232b559baeec740c37809051c596f9b56e7da0d0da2c8e8f54f6" -dependencies = [ - "async-channel", - "num_cpus", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "affinitypool" -version = "0.4.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a58b64a64aecad4ba7f2ccf0f79115f5d2d184b1e55307f78c20be07adc6633" +checksum = "a487a26c23775316bc010e9425fd7cf046c384ab37efbb2cfd4ba87a58f68c6d" dependencies = [ - "crossbeam", - "libc", + "arc-swap", + "async-task", + "crossbeam-deque", + "crossbeam-utils", "num_cpus", "parking_lot 0.12.5", - "thiserror 2.0.18", - "tokio", - "winapi", -] - -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", + "thiserror 2.0.21", ] [[package]] @@ -228,14 +205,13 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "ammonia" -version = "4.1.2" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +checksum = "3f7ed3ac4252be4d3e36fb589ed5659356842587b21a31596a9dc89d3b2f5e2a" dependencies = [ "cssparser", "html5ever", "maplit", - "tendril", "url", ] @@ -307,17 +283,11 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "any_ascii" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90c6333e01ba7235575b6ab53e5af10f1c327927fd97c36462917e289557ea64" - [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -367,12 +337,6 @@ dependencies = [ "password-hash 0.5.0", ] -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" version = "0.5.2" @@ -397,22 +361,13 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "ascii-canvas" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" -dependencies = [ - "term 0.7.0", -] - [[package]] name = "ascii-canvas" version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" dependencies = [ - "term 1.2.1", + "term", ] [[package]] @@ -433,7 +388,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", ] @@ -515,20 +470,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-executor" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - [[package]] name = "async-graphql" version = "7.2.1" @@ -541,14 +482,14 @@ dependencies = [ "async-io", "async-trait", "asynk-strim", - "base64 0.22.1", + "base64", "bytes", "fast_chemail", "fnv", "futures-util", "handlebars", "http 1.4.0", - "indexmap 2.13.0", + "indexmap 2.14.2", "mime", "multer", "num-traits", @@ -559,7 +500,7 @@ dependencies = [ "serde_urlencoded", "static_assertions_next", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -591,9 +532,9 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "strum", + "strum 0.27.2", "syn 2.0.119", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -615,7 +556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e3ef112905abea9dea592fc868a6873b10ebd3f983e83308f995d6284e9ba41" dependencies = [ "bytes", - "indexmap 2.13.0", + "indexmap 2.14.2", "serde", "serde_json", ] @@ -700,17 +641,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "async_io_stream" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" -dependencies = [ - "futures", - "pharos", - "rustc_version", -] - [[package]] name = "asynchronous-codec" version = "0.6.2" @@ -928,7 +858,7 @@ dependencies = [ "http 0.2.12", "http 1.4.0", "http-body 1.0.1", - "lru 0.16.4", + "lru", "percent-encoding", "regex-lite", "sha2 0.10.9", @@ -1271,7 +1201,7 @@ checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "axum-macros", - "base64 0.22.1", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -1293,7 +1223,7 @@ dependencies = [ "sha1 0.10.6", "sync_wrapper", "tokio", - "tokio-tungstenite 0.28.0", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -1366,12 +1296,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -1419,26 +1343,13 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "bcrypt" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" -dependencies = [ - "base64 0.22.1", - "blowfish", - "getrandom 0.2.17", - "subtle", - "zeroize", -] - [[package]] name = "bcrypt" version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a0f5948f30df5f43ac29d310b7476793be97c50787e6ef4a63d960a0d0be827" dependencies = [ - "base64 0.22.1", + "base64", "blowfish", "getrandom 0.3.4", "subtle", @@ -1451,15 +1362,6 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - [[package]] name = "bincode" version = "2.0.1" @@ -1523,30 +1425,15 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec 0.6.3", -] - [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec 0.8.0", + "bit-vec", ] -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bit-vec" version = "0.8.0" @@ -1568,18 +1455,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - [[package]] name = "blake2" version = "0.10.6" @@ -1591,16 +1466,15 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec 0.7.6", "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.2.17", + "cpufeatures 0.3.0", ] [[package]] @@ -1645,7 +1519,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220" dependencies = [ "async-stream", - "base64 0.22.1", + "base64", "bitflags 2.11.0", "bollard-buildkit-proto", "bollard-stubs", @@ -1672,7 +1546,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_urlencoded", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "tokio", "tokio-stream", @@ -1701,7 +1575,7 @@ version = "1.53.1-rc.29.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" dependencies = [ - "base64 0.22.1", + "base64", "bollard-buildkit-proto", "bytes", "prost", @@ -1759,24 +1633,25 @@ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytecheck" -version = "0.6.12" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" dependencies = [ "bytecheck_derive", "ptr_meta", + "rancor", "simdutf8", ] [[package]] name = "bytecheck_derive" -version = "0.6.12" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 3.0.3", ] [[package]] @@ -1784,6 +1659,20 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] [[package]] name = "byteorder" @@ -1793,9 +1682,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -1857,64 +1746,24 @@ dependencies = [ "shlex", ] -[[package]] -name = "cedar-policy" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d91e3b10a0f7f2911774d5e49713c4d25753466f9e11d1cd2ec627f8a2dc857" -dependencies = [ - "cedar-policy-core 2.4.2", - "cedar-policy-validator", - "itertools 0.10.5", - "lalrpop-util 0.20.2", - "ref-cast", - "serde", - "serde_json", - "smol_str 0.2.2", - "thiserror 1.0.69", -] - [[package]] name = "cedar-policy" version = "4.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50368b44367cd7664627bbee9bfe5721d10ab2433daf77645833645e8eb746da" dependencies = [ - "cedar-policy-core 4.9.1", + "cedar-policy-core", "cedar-policy-formatter", "itertools 0.14.0", "linked-hash-map", - "miette 7.6.0", + "miette", "ref-cast", "semver", "serde", "serde_json", "serde_with", - "smol_str 0.3.6", - "thiserror 2.0.18", -] - -[[package]] -name = "cedar-policy-core" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd2315591c6b7e18f8038f0a0529f254235fd902b6c217aabc04f2459b0d9995" -dependencies = [ - "either", - "ipnet", - "itertools 0.10.5", - "lalrpop 0.20.2", - "lalrpop-util 0.20.2", - "lazy_static", - "miette 5.10.0", - "regex", - "rustc_lexer", - "serde", - "serde_json", - "serde_with", - "smol_str 0.2.2", - "stacker", - "thiserror 1.0.69", + "smol_str", + "thiserror 2.0.21", ] [[package]] @@ -1927,11 +1776,11 @@ dependencies = [ "educe", "either", "itertools 0.14.0", - "lalrpop 0.22.2", - "lalrpop-util 0.22.2", + "lalrpop", + "lalrpop-util", "linked-hash-map", "linked_hash_set", - "miette 7.6.0", + "miette", "nonempty", "ref-cast", "regex", @@ -1939,9 +1788,9 @@ dependencies = [ "serde", "serde_json", "serde_with", - "smol_str 0.3.6", + "smol_str", "stacker", - "thiserror 2.0.18", + "thiserror 2.0.21", "unicode-security", ] @@ -1951,30 +1800,13 @@ version = "4.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18c03e1d143e1c222d2ea48453ab4f4b11e545ac5a268a15bb163769fe568b90" dependencies = [ - "cedar-policy-core 4.9.1", + "cedar-policy-core", "itertools 0.14.0", "logos 0.16.1", - "miette 7.6.0", + "miette", "pretty", "regex", - "smol_str 0.3.6", -] - -[[package]] -name = "cedar-policy-validator" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e756e1b2a5da742ed97e65199ad6d0893e9aa4bd6b34be1de9e70bd1e6adc7df" -dependencies = [ - "cedar-policy-core 2.4.2", - "itertools 0.10.5", - "serde", - "serde_json", - "serde_with", - "smol_str 0.2.2", - "stacker", - "thiserror 1.0.69", - "unicode-security", + "smol_str", ] [[package]] @@ -2160,7 +1992,7 @@ version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -2358,6 +2190,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crawdad" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abed0ad19907fc8472dae05f0418dfa82fdf0eaef18427c7c7cc4e42db41534b" + [[package]] name = "crc" version = "3.3.0" @@ -2412,19 +2250,6 @@ dependencies = [ "x509-cert", ] -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -2436,9 +2261,9 @@ dependencies = [ [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -2455,9 +2280,9 @@ dependencies = [ [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] @@ -2529,27 +2354,15 @@ dependencies = [ [[package]] name = "cssparser" -version = "0.35.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59" dependencies = [ - "cssparser-macros", "dtoa-short", "itoa", - "phf 0.11.3", "smallvec", ] -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.119", -] - [[package]] name = "csv" version = "1.4.0" @@ -2607,6 +2420,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "daachorse" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd10668980c9e7ba8aa2e616207d9ec52f7db66ebb47db857ed1f3c342530dad" + [[package]] name = "darling" version = "0.20.11" @@ -2711,19 +2530,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core 0.9.12", -] - [[package]] name = "dashmap" version = "6.1.0" @@ -2958,16 +2764,6 @@ dependencies = [ "dirs-sys", ] -[[package]] -name = "dirs-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - [[package]] name = "dirs-sys" version = "0.5.0" @@ -2976,19 +2772,66 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] [[package]] -name = "dirs-sys-next" -version = "0.1.2" +name = "diskann" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +checksum = "3ead115d8a4f3917c1ae9c3db0aa4dd56251841864ef8637329969f8e5145dd3" dependencies = [ - "libc", - "redox_users 0.4.6", - "winapi", + "anyhow", + "bytemuck", + "diskann-utils", + "diskann-vector", + "diskann-wide", + "futures-util", + "half", + "hashbrown 0.16.1", + "num-traits", + "rand 0.9.4", + "thiserror 2.0.21", + "tokio", +] + +[[package]] +name = "diskann-utils" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac627181d402b5dd63318e434fc2e7107843be0a10cf0a6a99a24666453239bc" +dependencies = [ + "bytemuck", + "cfg-if", + "diskann-vector", + "diskann-wide", + "half", + "rand 0.9.4", + "rand_distr", + "rayon", + "thiserror 2.0.21", +] + +[[package]] +name = "diskann-vector" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee9ba401eba2a12cea01aba7ee2ba7c755019f2b73c2417fecc49a2da26dd4c" +dependencies = [ + "cfg-if", + "diskann-wide", + "half", +] + +[[package]] +name = "diskann-wide" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343f4ea154bad7f8a9c6936746379faa880899165740a20092775bec97c9a141" +dependencies = [ + "cfg-if", + "half", ] [[package]] @@ -3018,7 +2861,7 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ - "base64 0.22.1", + "base64", "serde", "serde_json", ] @@ -3029,12 +2872,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "double-ended-peekable" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0d05e1c0dbad51b52c38bda7adceef61b9efc2baf04acfe8726a8c4630a6f57" - [[package]] name = "dtoa" version = "1.0.11" @@ -3062,16 +2899,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "earcutr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" -dependencies = [ - "itertools 0.11.0", - "num-traits", -] - [[package]] name = "ecdsa" version = "0.14.8" @@ -3190,7 +3017,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" dependencies = [ - "base64 0.22.1", + "base64", "memchr", ] @@ -3225,16 +3052,13 @@ dependencies = [ ] [[package]] -name = "endian-type" -version = "0.1.2" +name = "encoding_rs_io" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" - -[[package]] -name = "endian-type" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] [[package]] name = "enum-ordinalize" @@ -3461,12 +3285,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "fixedbitset" version = "0.5.7" @@ -3581,22 +3399,6 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.32" @@ -3662,10 +3464,7 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand", "futures-core", - "futures-io", - "parking", "pin-project-lite", ] @@ -3753,79 +3552,41 @@ dependencies = [ "zeroize", ] -[[package]] -name = "geo" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f811f663912a69249fa620dcd2a005db7254529da2d8a0b23942e81f47084501" -dependencies = [ - "earcutr", - "float_next_after", - "geo-types", - "geographiclib-rs", - "log", - "num-traits", - "robust", - "rstar 0.12.2", - "serde", - "spade", -] - -[[package]] -name = "geo" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc1a1678e54befc9b4bcab6cd43b8e7f834ae8ea121118b0fd8c42747675b4a" -dependencies = [ - "earcutr", - "float_next_after", - "geo-types", - "geographiclib-rs", - "i_overlay", - "log", - "num-traits", - "robust", - "rstar 0.12.2", - "serde", - "spade", -] - [[package]] name = "geo" version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3901269ec6d4f6068d3f09e5f02f995bd076398dcd1dfec407cd230b02d11b" dependencies = [ - "earcutr", "float_next_after", "geo-types", "geographiclib-rs", "i_overlay", "log", "num-traits", - "rand 0.8.5", + "rand 0.8.8", "robust", "rstar 0.12.2", "serde", "sif-itree", - "spade", ] [[package]] name = "geo-types" -version = "0.7.18" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24f8647af4005fa11da47cd56252c6ef030be8fa97bdbf355e7dfb6348f0a82c" +checksum = "777d18aa0f12f8b285331cd867133ee14422b3f023f6d388034c47d43e28786a" dependencies = [ "approx", "num-traits", - "rayon", "rstar 0.10.0", "rstar 0.11.0", "rstar 0.12.2", + "rstar 0.13.0", "rstar 0.8.4", "rstar 0.9.3", "serde", + "thiserror 2.0.21", ] [[package]] @@ -3899,9 +3660,9 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" @@ -3923,7 +3684,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" dependencies = [ "cfg-if", - "dashmap 6.1.0", + "dashmap", "futures-sink", "futures-timer", "futures-util", @@ -3973,7 +3734,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.13.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -3992,7 +3753,7 @@ dependencies = [ "futures-core", "futures-sink", "http 1.4.0", - "indexmap 2.13.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -4005,8 +3766,12 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ + "bytemuck", "cfg-if", "crunchy", + "num-traits", + "rand 0.9.4", + "rand_distr", "zerocopy", ] @@ -4023,7 +3788,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -4058,19 +3823,12 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash 0.7.8", -] [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash 0.8.12", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -4094,6 +3852,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.10.0" @@ -4109,7 +3873,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "headers-core", "http 1.4.0", @@ -4162,6 +3926,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -4238,13 +4008,13 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.35.0" +version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130" dependencies = [ "log", "markup5ever", - "match_token", + "memchr", ] [[package]] @@ -4442,7 +4212,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", @@ -4501,7 +4271,6 @@ dependencies = [ "i_key_sort", "i_shape", "i_tree", - "rayon", ] [[package]] @@ -4531,7 +4300,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -4670,12 +4439,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -4728,9 +4497,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "iri-string" @@ -4763,24 +4532,6 @@ dependencies = [ "nom 8.0.0", ] -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.13.0" @@ -4869,21 +4620,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "jsonwebtoken" version = "10.4.0" @@ -4891,7 +4627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "aws-lc-rs", - "base64 0.22.1", + "base64", "ed25519-dalek", "getrandom 0.2.17", "hmac 0.12.1", @@ -4899,7 +4635,7 @@ dependencies = [ "p256 0.13.2", "p384", "pem", - "rand 0.8.5", + "rand 0.8.8", "rsa", "serde", "serde_json", @@ -4918,59 +4654,28 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "lalrpop" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" -dependencies = [ - "ascii-canvas 3.0.0", - "bit-set 0.5.3", - "ena", - "itertools 0.11.0", - "lalrpop-util 0.20.2", - "petgraph 0.6.5", - "pico-args", - "regex", - "regex-syntax", - "string_cache", - "term 0.7.0", - "tiny-keccak", - "unicode-xid", - "walkdir", -] - [[package]] name = "lalrpop" version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" dependencies = [ - "ascii-canvas 4.0.0", - "bit-set 0.8.0", + "ascii-canvas", + "bit-set", "ena", "itertools 0.14.0", - "lalrpop-util 0.22.2", + "lalrpop-util", "petgraph 0.7.1", "pico-args", "regex", "regex-syntax", "sha3", - "string_cache", - "term 1.2.1", + "string_cache 0.8.9", + "term", "unicode-xid", "walkdir", ] -[[package]] -name = "lalrpop-util" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" -dependencies = [ - "regex-automata", -] - [[package]] name = "lalrpop-util" version = "0.22.2" @@ -5009,7 +4714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" dependencies = [ "async-trait", - "base64 0.22.1", + "base64", "email-encoding", "email_address", "fastrand", @@ -5030,15 +4735,6 @@ dependencies = [ "webpki-roots 1.0.9", ] -[[package]] -name = "lexicmp" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7378d131ddf24063b32cbd7e91668d183140c4b3906270635a4d633d1068ea5d" -dependencies = [ - "any_ascii", -] - [[package]] name = "lexicmp" version = "0.2.0" @@ -5120,15 +4816,47 @@ dependencies = [ ] [[package]] -name = "linfa-linalg" -version = "0.1.0" +name = "lindera" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e7562b41c8876d3367897067013bb2884cc78e6893f092ecd26b305176ac82" +checksum = "2ba619c086410a8d3b82f0208e45a8d496d895a8b883fe8975713abd340957db" dependencies = [ - "ndarray 0.15.6", - "num-traits", - "rand 0.8.5", - "thiserror 1.0.69", + "anyhow", + "lindera-dictionary", + "log", + "percent-encoding", + "serde", + "serde_json", + "strum 0.28.0", + "strum_macros 0.28.0", + "url", +] + +[[package]] +name = "lindera-dictionary" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1b3280d38586314078049deb66f332f544765cc23b683c56c28e335cabcb20" +dependencies = [ + "anyhow", + "byteorder", + "crawdad", + "csv", + "daachorse", + "encoding_rs", + "encoding_rs_io", + "glob", + "log", + "memchr", + "memmap2", + "once_cell", + "rayon", + "rkyv", + "serde", + "serde_json", + "strum 0.28.0", + "strum_macros 0.28.0", + "thiserror 2.0.21", ] [[package]] @@ -5178,9 +4906,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "logos" @@ -5248,15 +4976,6 @@ dependencies = [ "logos-codegen 0.16.1", ] -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "lru" version = "0.16.4" @@ -5300,12 +5019,6 @@ dependencies = [ "sha2 0.11.0", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "maplit" version = "1.0.2" @@ -5314,26 +5027,15 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "markup5ever" -version = "0.35.0" +version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842" dependencies = [ "log", "tendril", "web_atoms", ] -[[package]] -name = "match_token" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "matchers" version = "0.2.0" @@ -5387,27 +5089,24 @@ checksum = "7e6bcd6433cff03a4bfc3d9834d504467db1f1cf6d0ea765d37d330249ed629d" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "memo-map" -version = "0.3.3" +name = "memmap2" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] [[package]] -name = "miette" -version = "5.10.0" +name = "memo-map" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" -dependencies = [ - "miette-derive 5.10.0", - "once_cell", - "thiserror 1.0.69", - "unicode-width 0.1.14", -] +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" [[package]] name = "miette" @@ -5418,7 +5117,7 @@ dependencies = [ "backtrace", "backtrace-ext", "cfg-if", - "miette-derive 7.6.0", + "miette-derive", "owo-colors", "serde", "supports-color", @@ -5429,17 +5128,6 @@ dependencies = [ "unicode-width 0.1.14", ] -[[package]] -name = "miette-derive" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "miette-derive" version = "7.6.0" @@ -5539,12 +5227,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] -name = "nanoid" -version = "0.4.0" +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ - "rand 0.8.5", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -5564,20 +5263,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "ndarray" -version = "0.15.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32" -dependencies = [ - "approx", - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "rawpointer", -] - [[package]] name = "ndarray" version = "0.17.2" @@ -5593,34 +5278,19 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "ndarray-stats" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af5a8477ac96877b5bd1fd67e0c28736c12943aba24eda92b127e036b0c8f400" -dependencies = [ - "indexmap 1.9.3", - "itertools 0.10.5", - "ndarray 0.15.6", - "noisy_float", - "num-integer", - "num-traits", - "rand 0.8.5", -] - [[package]] name = "ndarray-stats" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6e54a8b65764f71827a90ca1d56965ec0c67f069f996477bd493402a901d1f" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "itertools 0.13.0", - "ndarray 0.17.2", + "ndarray", "noisy_float", "num-integer", "num-traits", - "rand 0.8.5", + "rand 0.8.8", ] [[package]] @@ -5629,15 +5299,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nibble_vec" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" -dependencies = [ - "smallvec", -] - [[package]] name = "noisy_float" version = "0.2.1" @@ -5740,7 +5401,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -5862,46 +5523,36 @@ dependencies = [ [[package]] name = "object_store" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00" -dependencies = [ - "async-trait", - "bytes", - "chrono", - "futures", - "http 1.4.0", - "humantime", - "itertools 0.14.0", - "parking_lot 0.12.5", - "percent-encoding", - "thiserror 2.0.18", - "tokio", - "tracing", - "url", - "walkdir", - "wasm-bindgen-futures", - "web-time", -] - -[[package]] -name = "object_store" -version = "0.13.2" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49" dependencies = [ "async-trait", + "base64", "bytes", "chrono", + "form_urlencoded", "futures-channel", "futures-core", "futures-util", "http 1.4.0", + "http-body-util", + "httparse", "humantime", + "hyper 1.8.1", "itertools 0.14.0", + "md-5 0.10.6", "parking_lot 0.12.5", "percent-encoding", - "thiserror 2.0.18", + "quick-xml", + "rand 0.10.0", + "reqwest 0.12.28", + "ring", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.21", "tokio", "tracing", "url", @@ -5932,9 +5583,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -5995,7 +5646,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", ] @@ -6042,7 +5693,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest 0.12.28", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tonic", "tracing", @@ -6079,7 +5730,7 @@ dependencies = [ "opentelemetry", "percent-encoding", "rand 0.9.4", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -6151,9 +5802,9 @@ dependencies = [ [[package]] name = "papaya" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +checksum = "da2442474a9404698c42509b8967f437249dbc7b50493e83020333d3943ec0ae" dependencies = [ "equivalent", "seize", @@ -6324,7 +5975,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64 0.22.1", + "base64", "serde_core", ] @@ -6386,24 +6037,14 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "petgraph" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" -dependencies = [ - "fixedbitset 0.4.2", - "indexmap 2.13.0", -] - [[package]] name = "petgraph" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ - "fixedbitset 0.5.7", - "indexmap 2.13.0", + "fixedbitset", + "indexmap 2.14.2", ] [[package]] @@ -6412,29 +6053,9 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset 0.5.7", + "fixedbitset", "hashbrown 0.15.5", - "indexmap 2.13.0", -] - -[[package]] -name = "pharos" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" -dependencies = [ - "futures", - "rustc_version", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", + "indexmap 2.14.2", ] [[package]] @@ -6452,29 +6073,29 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros 0.13.1", + "phf_macros", "phf_shared 0.13.1", "serde", ] [[package]] -name = "phf_codegen" -version = "0.11.3" +name = "phf" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "phf_shared 0.14.0", + "serde", ] [[package]] -name = "phf_generator" -version = "0.11.3" +name = "phf_codegen" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe" dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", + "phf_generator 0.14.0", + "phf_shared 0.14.0", ] [[package]] @@ -6488,17 +6109,13 @@ dependencies = [ ] [[package]] -name = "phf_macros" -version = "0.11.3" +name = "phf_generator" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.119", - "unicase", + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -6522,7 +6139,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ "siphasher", - "unicase", ] [[package]] @@ -6544,6 +6160,15 @@ dependencies = [ "unicase", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -6572,9 +6197,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -6787,8 +6412,8 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set", + "bit-vec", "bitflags 2.11.0", "num-traits", "rand 0.9.4", @@ -6816,7 +6441,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", @@ -6881,22 +6506,22 @@ dependencies = [ [[package]] name = "ptr_meta" -version = "0.1.4" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" dependencies = [ "ptr_meta_derive", ] [[package]] name = "ptr_meta_derive" -version = "0.1.4" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 3.0.3", ] [[package]] @@ -6948,27 +6573,16 @@ checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "encoding_rs", "memchr", + "serde", ] [[package]] name = "quick_cache" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb55a1aa7668676bb93926cd4e9cdfe60f03bb866553bcca9112554911b6d3dc" -dependencies = [ - "ahash 0.8.12", - "equivalent", - "hashbrown 0.14.5", - "parking_lot 0.12.5", -] - -[[package]] -name = "quick_cache" -version = "0.6.18" +version = "0.6.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3" +checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" dependencies = [ - "ahash 0.8.12", + "ahash", "equivalent", "hashbrown 0.16.1", "parking_lot 0.12.5", @@ -6988,7 +6602,7 @@ dependencies = [ "rustc-hash 2.1.1", "rustls 0.23.40", "socket2 0.6.5", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -7010,7 +6624,7 @@ dependencies = [ "rustls 0.23.40", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -7052,31 +6666,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "radix_trie" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" -dependencies = [ - "endian-type 0.1.2", - "nibble_vec", - "serde", -] - -[[package]] -name = "radix_trie" -version = "0.3.0" +name = "rancor" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" dependencies = [ - "endian-type 0.2.0", - "nibble_vec", - "serde", + "ptr_meta", ] [[package]] @@ -7094,9 +6689,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -7187,6 +6782,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -7222,9 +6827,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -7273,17 +6878,6 @@ dependencies = [ "bitflags 2.11.0", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -7292,7 +6886,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -7350,17 +6944,11 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "relative-path" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" - [[package]] name = "rend" -version = "0.4.2" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" dependencies = [ "bytecheck", ] @@ -7371,11 +6959,12 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", "futures-util", + "h2 0.4.13", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -7384,11 +6973,11 @@ dependencies = [ "hyper-util", "js-sys", "log", - "mime_guess", "percent-encoding", "pin-project-lite", "quinn", "rustls 0.23.40", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -7414,7 +7003,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-core", @@ -7455,71 +7044,25 @@ dependencies = [ [[package]] name = "revision" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f53179a035f881adad8c4d58a2c599c6b4a8325b989c68d178d7a34d1b1e4c" -dependencies = [ - "revision-derive 0.10.0", -] - -[[package]] -name = "revision" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b8ee532f15b2f0811eb1a50adf10d036e14a6cdae8d99893e7f3b921cb227d" -dependencies = [ - "chrono", - "geo 0.28.0", - "regex", - "revision-derive 0.11.0", - "roaring 0.10.12", - "rust_decimal", - "uuid", -] - -[[package]] -name = "revision" -version = "0.17.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b66f44139d1fe8e1b6c21bf1a855f12df38517aab94e21cea2a077e9753f216" +checksum = "13b48b66c1cd4bf814516ea48f727d4b3697e3fa8841be99a7d9cbb8c9ac1666" dependencies = [ "bytes", "chrono", - "geo 0.31.0", + "geo", "regex", - "revision-derive 0.17.1", - "roaring 0.11.4", + "revision-derive", + "roaring", "rust_decimal", "uuid", ] [[package]] name = "revision-derive" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0ec466e5d8dca9965eb6871879677bef5590cf7525ad96cae14376efb75073" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "revision-derive" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3415e1bc838c36f9a0a2ac60c0fa0851c72297685e66592c44870d82834dfa2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "revision-derive" -version = "0.17.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "696cbf6f9d0bdeb7d75ef3a037c8295ea9fb665c89c6b70c23022f5918713353" +checksum = "3d266d798eaaa2921e14188dfe998bb7cc0528ec26e894b4be0e35fe2b19c89d" dependencies = [ "proc-macro2", "quote", @@ -7563,31 +7106,32 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.7.46" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" dependencies = [ - "bitvec", "bytecheck", "bytes", - "hashbrown 0.12.3", + "hashbrown 0.17.1", + "indexmap 2.14.2", + "munge", "ptr_meta", + "rancor", "rend", "rkyv_derive", - "seahash", "tinyvec", "uuid", ] [[package]] name = "rkyv_derive" -version = "0.7.46" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 3.0.3", ] [[package]] @@ -7609,26 +7153,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rmpv" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" -dependencies = [ - "rmp", -] - -[[package]] -name = "roaring" -version = "0.10.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e8d2cfa184d94d0726d650a9f4a1be7f9b76ac9fdb954219878dc00c1c1e7b" -dependencies = [ - "bytemuck", - "byteorder", - "serde", -] - [[package]] name = "roaring" version = "0.11.4" @@ -7729,32 +7253,15 @@ dependencies = [ ] [[package]] -name = "rstest" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" -dependencies = [ - "futures-timer", - "futures-util", - "rstest_macros", -] - -[[package]] -name = "rstest_macros" -version = "0.26.1" +name = "rstar" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe" dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn 2.0.119", - "unicode-ident", + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", ] [[package]] @@ -7804,18 +7311,19 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.40.0" +version = "1.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" dependencies = [ "arrayvec 0.7.6", "borsh", "bytes", "num-traits", - "rand 0.8.5", - "rkyv", + "rand 0.8.8", + "rand 0.9.4", "serde", "serde_json", + "wasm-bindgen", ] [[package]] @@ -7852,15 +7360,6 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be87abb9e40db7466e0681dc8ecd9dcfd40360cb10b4c8fe24a7c4c3669b198" -[[package]] -name = "rustc_lexer" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86aae0c77166108c01305ee1a36a1e77289d7dc6ca0a3cd91ff4992de2d16a5" -dependencies = [ - "unicode-xid", -] - [[package]] name = "rustc_version" version = "0.4.1" @@ -8040,7 +7539,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b23be9a89285428532551a6b46d303bb00321afceac9919058b995394553eece" dependencies = [ - "base64 0.22.1", + "base64", "blake2", "chacha20 0.9.1", "digest 0.10.7", @@ -8053,7 +7552,7 @@ dependencies = [ "serde", "serde_json", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "zeroize", ] @@ -8113,7 +7612,7 @@ dependencies = [ "axum", "bytes", "calamine", - "cedar-policy 4.9.1", + "cedar-policy", "chrono", "futures", "hex", @@ -8140,7 +7639,7 @@ dependencies = [ "sha2 0.11.0", "sync_wrapper", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-stream", "toml 1.1.4+spec-1.1.0", @@ -8173,7 +7672,7 @@ dependencies = [ name = "schema-forge-cel" version = "0.11.0" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "chrono-tz", "prost", @@ -8197,9 +7696,9 @@ dependencies = [ "console", "dialoguer", "glob", - "heck", + "heck 0.5.0", "indicatif", - "miette 7.6.0", + "miette", "mime_guess", "minijinja", "predicates", @@ -8219,7 +7718,7 @@ dependencies = [ "sigstore-trust-root", "ssh-key", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-util", "toml 0.8.23", @@ -8231,7 +7730,7 @@ dependencies = [ name = "schema-forge-core" version = "0.18.0" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "csv", "mti", @@ -8290,7 +7789,7 @@ dependencies = [ name = "schema-forge-signing" version = "0.1.0" dependencies = [ - "base64 0.22.1", + "base64", "ed25519-dalek", "globset", "hex", @@ -8301,7 +7800,7 @@ dependencies = [ "sigstore-verify", "ssh-key", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "toml 1.1.4+spec-1.1.0", ] @@ -8315,7 +7814,7 @@ dependencies = [ "schema-forge-core", "serde", "serde_json", - "surrealdb 2.6.0", + "surrealdb", "tokio", "tracing", ] @@ -8372,12 +7871,6 @@ dependencies = [ "untrusted 0.9.0", ] -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "sec1" version = "0.3.0" @@ -8441,57 +7934,42 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", ] -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", ] [[package]] -name = "serde-content" -version = "0.1.2" +name = "serde_core" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3753ca04f350fa92d00b6146a3555e63c55388c9ef2e11e09bce2ff1c0b509c6" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ - "serde", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", + "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -8500,7 +7978,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "itoa", "memchr", "serde", @@ -8586,11 +8064,11 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.2", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -8743,7 +8221,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb2255028e90ba8e7abe7cc49fb04e6f824a8f7a061fc6922f67a48e84ab052" dependencies = [ - "base64 0.22.1", + "base64", "hex", "serde", "serde_json", @@ -8752,7 +8230,7 @@ dependencies = [ "sigstore-rekor", "sigstore-tsa", "sigstore-types", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -8762,7 +8240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac7172898e15789d69d12469bb3d33397aab0771fb4f8d32cd56972e97808bf3" dependencies = [ "aws-lc-rs", - "base64 0.22.1", + "base64", "const-oid 0.9.6", "der 0.7.10", "digest 0.10.7", @@ -8772,7 +8250,7 @@ dependencies = [ "signature 2.2.0", "sigstore-types", "spki 0.7.3", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "x509-cert", ] @@ -8783,11 +8261,11 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e86ee272adfcfa21a2248b2fbf05a79b6e39a1fb699ef70c578c814af16e4db" dependencies = [ - "base64 0.22.1", + "base64", "hex", "sigstore-crypto", "sigstore-types", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -8796,7 +8274,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2448f8a13b91c615c23badca4d7e51b0376539b8723a2a2d43d27c016f9cce08" dependencies = [ - "base64 0.22.1", + "base64", "hex", "reqwest 0.13.3", "serde", @@ -8804,7 +8282,7 @@ dependencies = [ "sigstore-crypto", "sigstore-merkle", "sigstore-types", - "thiserror 2.0.18", + "thiserror 2.0.21", "url", ] @@ -8814,7 +8292,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bf02c1ab8f7a10db78dbf37cc80bb0f93d2afd636d5c37a572c815cb418b925" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "directories", "futures", @@ -8825,7 +8303,7 @@ dependencies = [ "serde_json", "sigstore-crypto", "sigstore-types", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tough", "tracing", @@ -8840,7 +8318,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31ea02ad335b7386c9a72455aecd2be964bef1d7118199858ee4c6d679af48ed" dependencies = [ "aws-lc-rs", - "base64 0.22.1", + "base64", "chrono", "cmpv2", "cms", @@ -8853,7 +8331,7 @@ dependencies = [ "rustls-webpki 0.102.8", "sigstore-crypto", "sigstore-types", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "x509-cert", "x509-tsp", @@ -8865,13 +8343,13 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce83bc56c60bbfb197a054d541839ff248c7e1aabf7016f704f8f3e6552fd13c" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "hex", "pem", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -8880,7 +8358,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "080e191482c7e040b9bdfd5bd60032915bb0052e5a0944c4881055ef41b6c2cb" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "cms", "const-oid 0.9.6", @@ -8898,7 +8376,7 @@ dependencies = [ "sigstore-trust-root", "sigstore-tsa", "sigstore-types", - "thiserror 2.0.18", + "thiserror 2.0.21", "tls_codec", "tracing", "x509-cert", @@ -8924,7 +8402,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", ] @@ -8942,18 +8420,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[package]] -name = "smol_str" -version = "0.2.2" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" dependencies = [ "serde", ] @@ -8985,18 +8454,12 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", ] -[[package]] -name = "snap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" - [[package]] name = "socket2" version = "0.5.10" @@ -9017,18 +8480,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spade" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb313e1c8afee5b5647e00ee0fe6855e3d529eb863a0fdae1d60006c4d1e9990" -dependencies = [ - "hashbrown 0.15.5", - "num-traits", - "robust", - "smallvec", -] - [[package]] name = "spin" version = "0.9.8" @@ -9092,7 +8543,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "chrono", "crc", @@ -9105,7 +8556,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap 2.13.0", + "indexmap 2.14.2", "log", "memchr", "once_cell", @@ -9115,7 +8566,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -9145,7 +8596,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", - "heck", + "heck 0.5.0", "hex", "once_cell", "proc-macro2", @@ -9169,7 +8620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64 0.22.1", + "base64", "bitflags 2.11.0", "byteorder", "bytes", @@ -9192,7 +8643,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.5", + "rand 0.8.8", "rsa", "serde", "sha1 0.10.6", @@ -9200,7 +8651,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "uuid", "whoami", @@ -9213,7 +8664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64 0.22.1", + "base64", "bitflags 2.11.0", "byteorder", "chrono", @@ -9232,14 +8683,14 @@ dependencies = [ "md-5 0.10.6", "memchr", "once_cell", - "rand 0.8.5", + "rand 0.8.8", "serde", "serde_json", "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "uuid", "whoami", @@ -9265,7 +8716,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "url", "uuid", @@ -9344,18 +8795,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766" -[[package]] -name = "storekey" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c42833834a5d23b344f71d87114e0cc9994766a5c42938f4b50e7b2aef85b2" -dependencies = [ - "byteorder", - "memchr", - "serde", - "thiserror 1.0.69", -] - [[package]] name = "storekey" version = "0.11.0" @@ -9388,17 +8827,28 @@ dependencies = [ "parking_lot 0.12.5", "phf_shared 0.11.3", "precomputed-hash", - "serde", +] + +[[package]] +name = "string_cache" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6" +dependencies = [ + "new_debug_unreachable", + "parking_lot 0.12.5", + "phf_shared 0.14.0", + "precomputed-hash", ] [[package]] name = "string_cache_codegen" -version = "0.5.4" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc" dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", ] @@ -9455,7 +8905,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -9464,7 +8923,19 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -9499,355 +8970,660 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "surrealdb" -version = "2.6.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b7720b39ce2985efbfa10858b7397ffd95655a9bab6d9dfaa03622bbdc3bc2" +checksum = "f04b42991e6a01e2d01f1ea6183714cf364d3c40fa2197bd8993ec7834e0b4c4" dependencies = [ - "arrayvec 0.7.6", + "anyhow", "async-channel", - "bincode 1.3.3", + "boxcar", "chrono", - "dmp", "futures", - "geo 0.28.0", "getrandom 0.3.4", - "indexmap 2.13.0", + "indexmap 2.14.2", + "js-sys", "path-clean", - "pharos", - "reblessive", - "reqwest 0.12.28", - "revision 0.11.0", + "reqwest 0.13.3", "ring", - "rust_decimal", "rustls 0.23.40", "rustls-pki-types", "semver", "serde", - "serde-content", "serde_json", - "surrealdb-core 2.6.0", - "thiserror 1.0.69", + "surrealdb-engine-api", + "surrealdb-engine-local", + "surrealdb-iam", + "surrealdb-kvs", + "surrealdb-rpc", + "surrealdb-syn", + "surrealdb-types", + "surrealdb-types-derive", "tokio", - "tokio-tungstenite 0.23.1", + "tokio-tungstenite", + "tokio-tungstenite-wasm", "tokio-util", + "tonic", "tracing", - "trice", "url", "uuid", + "wasm-bindgen", "wasm-bindgen-futures", - "wasmtimer 0.2.1", - "ws_stream_wasm", + "wasmtimer", + "web-sys", ] [[package]] -name = "surrealdb" -version = "3.0.5" +name = "surrealdb-catalog" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "504a96b55e86ef8653a03b6b97e771f49c954e26bcc0308160b0134d94f334fd" +checksum = "467526a3b49854f7ea1fb1d5948eee35973700791018a093b62ae366de87df4e" dependencies = [ + "ahash", "anyhow", - "async-channel", - "boxcar", "chrono", - "futures", - "getrandom 0.3.4", - "indexmap 2.13.0", - "js-sys", - "path-clean", - "reqwest 0.13.3", - "ring", - "rustls 0.23.40", - "rustls-pki-types", - "semver", + "md-5 0.10.6", + "revision", "serde", - "serde_json", - "surrealdb-core 3.0.5", + "sha2 0.10.9", + "storekey", + "surrealdb-collections", + "surrealdb-common", + "surrealdb-expr", + "surrealdb-iam", + "surrealdb-kvs", + "surrealdb-sql", + "surrealdb-strand", + "surrealdb-syn", "surrealdb-types", - "surrealdb-types-derive", - "tokio", - "tokio-tungstenite 0.28.0", - "tokio-tungstenite-wasm", - "tokio-util", + "thiserror 2.0.21", "tracing", - "url", "uuid", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasmtimer 0.4.3", - "web-sys", +] + +[[package]] +name = "surrealdb-cnf" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcdec4b7e7287d812c48b58e3495f7204524a2b31d51c20dbca6eff2d32d36e" +dependencies = [ + "num-traits", + "path-clean", + "surrealdb-common", + "surrealdb-parse-common", + "tracing", +] + +[[package]] +name = "surrealdb-collections" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90aed29a6512c5974634412cb3ad07c91a5b99353ec9bc7d96f4ae7491f243cf" +dependencies = [ + "revision", + "storekey", +] + +[[package]] +name = "surrealdb-common" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a1273ef6215f5b40c78a3e43a4ada9c0cb7199d839f25745b71fee57dae0ca0" +dependencies = [ + "chrono", + "futures", + "hashbrown 0.16.1", + "parking_lot 0.12.5", + "phf 0.13.1", + "rust_decimal", + "surrealdb-types", + "thiserror 2.0.21", + "tokio", + "unicase", + "wasmtimer", ] [[package]] name = "surrealdb-core" -version = "2.6.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c48e42c81713be2f9b3dae64328999eafe8b8060dd584059445a908748b39787" +checksum = "1490b831b5090b9bdbdacec5e8060d9dbc1944574809d157fabb4de25e53b1aa" dependencies = [ - "addr", - "affinitypool 0.3.1", - "ahash 0.8.12", - "ammonia", - "any_ascii", + "ahash", + "anyhow", + "arc-swap", "argon2", "async-channel", - "async-executor", - "async-graphql", - "base64 0.21.7", - "bcrypt 0.15.1", - "bincode 1.3.3", - "blake3", + "base64", + "bcrypt", "bytes", - "castaway", - "cedar-policy 2.4.2", "chrono", "ciborium", - "dashmap 5.5.3", - "deunicode", - "dmp", + "dashmap", "ext-sort", - "fst", "futures", - "fuzzy-matcher", - "geo 0.28.0", + "geo", "geo-types", "getrandom 0.3.4", - "hashbrown 0.14.5", + "headers", "hex", "http 1.4.0", "ipnet", - "jsonwebtoken 9.3.1", - "lexicmp 0.1.0", - "linfa-linalg", + "jsonwebtoken", "md-5 0.10.6", - "nanoid", - "ndarray 0.15.6", - "ndarray-stats 0.5.1", - "num-traits", + "memchr", + "mime", "num_cpus", - "object_store 0.12.5", + "object_store", "parking_lot 0.12.5", + "path-clean", "pbkdf2 0.12.2", - "pharos", - "phf 0.11.3", "pin-project-lite", - "quick_cache 0.5.2", - "radix_trie 0.2.1", - "rand 0.8.5", + "quick_cache", + "rand 0.9.4", + "rand_core 0.6.4", "rayon", "reblessive", "regex", - "revision 0.11.0", + "revision", "ring", - "rmpv", - "roaring 0.10.12", - "rust-stemmers", + "roaring", "rust_decimal", "scrypt", "semver", "serde", - "serde-content", "serde_json", "sha1 0.10.6", "sha2 0.10.9", - "snap", - "storekey 0.5.0", - "strsim 0.11.1", + "storekey", "subtle", - "surrealkv", - "sysinfo 0.33.1", + "surrealdb-catalog", + "surrealdb-cnf", + "surrealdb-collections", + "surrealdb-common", + "surrealdb-datastore", + "surrealdb-expr", + "surrealdb-iam", + "surrealdb-idx", + "surrealdb-kvs", + "surrealdb-kvs-any", + "surrealdb-observe", + "surrealdb-rpc", + "surrealdb-runtime", + "surrealdb-sql", + "surrealdb-strand", + "surrealdb-syn", + "surrealdb-types", + "sysinfo", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.21", "tokio", + "tokio-util", "tracing", - "trice", - "ulid", - "unicase", "url", "uuid", - "vart 0.8.1", "wasm-bindgen-futures", - "wasmtimer 0.2.1", - "ws_stream_wasm", + "wasmtimer", + "web-time", ] [[package]] -name = "surrealdb-core" -version = "3.0.5" +name = "surrealdb-datastore" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e6a7f248c958fd5000c4fab5759503663bf93c622be10a6d7bf7d2d676b8fc" +checksum = "c36f4accb0bf3f90f4072dc4e3ada965c2fe2a5adbde1b18be55ddbd62703f7c" dependencies = [ - "addr", - "affinitypool 0.4.0", - "ahash 0.8.12", - "ammonia", "anyhow", - "argon2", - "async-channel", - "async-stream", - "async-trait", - "base64 0.22.1", - "bcrypt 0.18.0", "blake3", "bytes", "chrono", - "ciborium", - "dashmap 6.1.0", - "deunicode", + "parking_lot 0.12.5", + "quick_cache", + "rand 0.9.4", + "revision", + "roaring", + "semver", + "serde", + "storekey", + "surrealdb-catalog", + "surrealdb-cnf", + "surrealdb-collections", + "surrealdb-common", + "surrealdb-expr", + "surrealdb-iam", + "surrealdb-keyspace-macro", + "surrealdb-kvs", + "surrealdb-observe", + "surrealdb-rpc", + "surrealdb-strand", + "surrealdb-types", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "tracing", + "uuid", + "web-time", +] + +[[package]] +name = "surrealdb-engine-api" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff233f2d7c1b5b0eac9f54abb01aaaa42dc466c381095d6b16588ae95482383" +dependencies = [ + "async-channel", + "surrealdb-rpc", + "surrealdb-types", + "tokio", + "uuid", +] + +[[package]] +name = "surrealdb-engine-local" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a354808ead8cdaead6ba4bc00318405739d869cf249911e16013f35cd927cbfd" +dependencies = [ + "anyhow", + "async-channel", + "futures", + "surrealdb-cnf", + "surrealdb-core", + "surrealdb-datastore", + "surrealdb-engine-api", + "surrealdb-iam", + "surrealdb-kvs", + "surrealdb-rpc", + "surrealdb-types", + "tokio", + "tokio-util", + "tracing", + "uuid", + "wasm-bindgen-futures", + "wasmtimer", +] + +[[package]] +name = "surrealdb-expr" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e494e4696ac372ead8c0a8b38f830db056a34c8f4192be4cfadeea3089c8687" +dependencies = [ + "anyhow", + "bytes", + "chrono", "dmp", - "ext-sort", "fastnum", + "geo", + "geo-types", + "half", + "hex", + "http 1.4.0", + "jsonwebtoken", + "lexicmp", + "quick_cache", + "rand 0.9.4", + "reblessive", + "regex", + "revision", + "rust_decimal", + "serde", + "serde_json", + "storekey", + "surrealdb-cnf", + "surrealdb-collections", + "surrealdb-common", + "surrealdb-iam", + "surrealdb-kvs", + "surrealdb-sql", + "surrealdb-strand", + "surrealdb-syn", + "surrealdb-types", + "thiserror 2.0.21", + "tracing", + "ulid", + "uuid", +] + +[[package]] +name = "surrealdb-iam" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ebf02070f99ebcdeb672b8ca8ca1ebe6b7f287c399ff868c1ac15a9e9702bc" +dependencies = [ + "anyhow", + "argon2", + "base64", + "hmac 0.12.1", + "pbkdf2 0.12.2", + "rand_core 0.6.4", + "revision", + "serde", + "sha2 0.10.9", + "stringprep", + "subtle", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "surrealdb-idx" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1871d2912896d5ae39d04e3989a20f3a69b8286b95a86a9c8ba2831fbb938e49" +dependencies = [ + "ahash", + "anyhow", + "bytes", + "dashmap", + "deunicode", + "diskann", + "diskann-utils", + "diskann-vector", "fst", - "futures", + "half", + "lindera", + "ndarray", + "ndarray-stats", + "parking_lot 0.12.5", + "quick_cache", + "rand 0.9.4", + "reblessive", + "roaring", + "rust-stemmers", + "storekey", + "surrealdb-catalog", + "surrealdb-cnf", + "surrealdb-common", + "surrealdb-datastore", + "surrealdb-expr", + "surrealdb-kvs", + "surrealdb-runtime", + "surrealdb-strand", + "surrealdb-types", + "thiserror 2.0.21", + "tokio", + "tracing", + "uuid", + "vart", + "web-time", +] + +[[package]] +name = "surrealdb-keyspace-macro" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911cd40a40f9ed6372c6b462157a4249a1fde0a0648b713805a5884018afdda2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "surrealdb-kvs" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b95075a800cc6c566506440ec2febde31e0ee21f48bc5fc1f2bc7c3bb6ea6e" +dependencies = [ + "affinitypool", + "anyhow", + "chrono", + "num_cpus", + "revision", + "roaring", + "surrealdb-cnf", + "surrealdb-common", + "surrealdb-types", + "thiserror 2.0.21", + "tracing", + "web-time", +] + +[[package]] +name = "surrealdb-kvs-any" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d40f18b879bce7ce4c1017dfdbd84feba9fc4a46a15e68084239ba498cb24b" +dependencies = [ + "surrealdb-cnf", + "surrealdb-kvs", + "surrealdb-kvs-mem", + "tokio-util", + "tracing", +] + +[[package]] +name = "surrealdb-kvs-mem" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a9c2f9830dcb58a208c9ee18b2720b80e614821ea370b386f706bb1df336c1" +dependencies = [ + "affinitypool", + "chrono", + "surrealdb-cnf", + "surrealdb-kvs", + "surrealmx", + "tokio", + "tracing", +] + +[[package]] +name = "surrealdb-observe" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09954bd399347da4ac4edaa9462f5074838648eac032c9b815edb1a2dee91815" +dependencies = [ + "surrealdb-iam", + "surrealdb-rpc", + "surrealdb-types", + "uuid", +] + +[[package]] +name = "surrealdb-parse-common" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b6b07e5e179c8993ddcf3bcddd34d8c781846df0b37157a4f37cfddf7c9257" +dependencies = [ + "chrono", + "logos 0.16.1", + "uuid", +] + +[[package]] +name = "surrealdb-protocol" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53501c853fe88d5ead8e50612d310b43389f3c66fda7ef323a1af6c393809c6" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "flatbuffers", + "geo", + "rust_decimal", + "semver", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "surrealdb-rpc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6d64c1a7783562566122795d56da73f83647fc7229f944b0558817bbe897b0" +dependencies = [ + "anyhow", + "humantime", + "ipnet", + "revision", + "serde", + "surrealdb-cnf", + "surrealdb-common", + "surrealdb-iam", + "surrealdb-types", + "thiserror 2.0.21", + "tracing", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "surrealdb-runtime" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e9d6ee0def941387d16dbf3730e1fc8faa38d477102035e6d8ba7f3f112676c" +dependencies = [ + "addr", + "ammonia", + "anyhow", + "argon2", + "bcrypt", + "blake3", + "chrono", + "deunicode", "fuzzy-matcher", - "geo 0.32.0", - "geo-types", - "getrandom 0.3.4", - "headers", - "hex", - "http 1.4.0", - "humantime", - "ipnet", - "jsonwebtoken 10.4.0", - "lexicmp 0.2.0", + "geo", "md-5 0.10.6", - "mime", - "ndarray 0.17.2", - "ndarray-stats 0.7.0", - "num-traits", - "num_cpus", - "object_store 0.13.2", - "parking_lot 0.12.5", - "path-clean", "pbkdf2 0.12.2", - "phf 0.13.1", - "pin-project-lite", - "quick_cache 0.6.18", - "radix_trie 0.3.0", - "rand 0.8.5", - "rayon", + "rand 0.9.4", + "rand_core 0.6.4", "reblessive", "regex", - "revision 0.17.1", - "ring", - "roaring 0.11.4", - "rust-stemmers", "rust_decimal", "scrypt", "semver", - "serde", - "serde_json", "sha1 0.10.6", "sha2 0.10.9", - "storekey 0.11.0", "strsim 0.11.1", - "subtle", - "surrealdb-protocol", + "surrealdb-cnf", + "surrealdb-common", + "surrealdb-expr", + "surrealdb-strand", + "surrealdb-syn", "surrealdb-types", - "surrealmx", - "sysinfo 0.37.2", - "tempfile", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", "ulid", - "unicase", "url", "uuid", - "vart 0.9.3", - "wasm-bindgen-futures", - "wasmtimer 0.4.3", - "web-time", ] [[package]] -name = "surrealdb-protocol" -version = "0.8.3" +name = "surrealdb-sql" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb37698e0493bcfac3229ecb6ec6894a3ad705a3a2087b1562eeb881b3db19d4" +checksum = "6397f3b9141dbceca638116045b525c9f31bea3a6b374ab3546fa052bfb07e33" dependencies = [ - "anyhow", - "async-trait", "bytes", "chrono", - "flatbuffers", - "futures", - "geo 0.32.0", - "prost", - "prost-types", + "geo", + "getrandom 0.3.4", + "hex", + "phf 0.13.1", + "rand 0.9.4", + "regex", "rust_decimal", - "semver", "serde", - "serde_json", - "tonic", - "tonic-prost", + "surrealdb-common", + "surrealdb-iam", + "surrealdb-strand", + "surrealdb-types", + "unicase", + "uuid", +] + +[[package]] +name = "surrealdb-strand" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc99fc8f56f55b3bcadd4ee4fe6ec4811dc04a3e686099fd0c69af7ee4edde0" +dependencies = [ + "revision", + "serde", + "storekey", +] + +[[package]] +name = "surrealdb-syn" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab01f470fa61bc45a4c46286f7ba94297d6ec7c46708845d9e164311fb8e443" +dependencies = [ + "bytes", + "chrono", + "geo", + "phf 0.13.1", + "reblessive", + "regex", + "rust_decimal", + "surrealdb-cnf", + "surrealdb-common", + "surrealdb-iam", + "surrealdb-sql", + "surrealdb-strand", + "surrealdb-types", + "thiserror 2.0.21", + "tracing", + "unicase", "uuid", ] [[package]] name = "surrealdb-types" -version = "3.0.5" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c79e71d035367b933cf528c09b7ed186bc17dea58c66a1bca84d22f9abf167db" +checksum = "18cd057ad378914c5c32701d8e2298fc0b717dd07b087d895627832d7c834051" dependencies = [ "anyhow", + "async-channel", "bytes", + "castaway", "chrono", "flatbuffers", - "geo 0.32.0", + "geo", + "getrandom 0.2.17", + "getrandom 0.3.4", "hex", "http 1.4.0", "papaya", - "rand 0.8.5", + "rand 0.9.4", "regex", - "rstest", + "reqwest 0.13.3", "rust_decimal", + "semver", "serde", "serde_json", + "surrealdb-parse-common", "surrealdb-protocol", "surrealdb-types-derive", + "tracing", "ulid", + "url", "uuid", ] [[package]] name = "surrealdb-types-derive" -version = "3.0.5" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a76abdbfc597e062daae5269251e18a84553f9090cfff423591f57c8c6765aa8" +checksum = "f53d89700a64a5c249a23a3436e4b2091099d9074ace28effa757421ea37f0b9" dependencies = [ + "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.119", ] -[[package]] -name = "surrealkv" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08a5041979bdff8599a1d5f6cb7365acb9a79664e2a84e5c4fddac2b3969f7d1" -dependencies = [ - "ahash 0.8.12", - "bytes", - "chrono", - "crc32fast", - "double-ended-peekable", - "getrandom 0.2.17", - "lru 0.12.5", - "parking_lot 0.12.5", - "quick_cache 0.6.18", - "revision 0.10.0", - "vart 0.9.3", -] - [[package]] name = "surrealmx" -version = "0.18.0" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6508449a7d1379a92a51ba49391b48ccab0b60dd11a4277c0dda965d8c99dbff" +checksum = "1b5f1002984c1c3918014d924435a7280a6de620dadd51d4db59de4f56e7202c" dependencies = [ "arc-swap", - "bincode 2.0.1", + "bincode", "bytes", "crossbeam-deque", "crossbeam-queue", @@ -9857,22 +9633,11 @@ dependencies = [ "parking_lot 0.12.5", "serde", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "web-time", ] -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -9915,20 +9680,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "sysinfo" -version = "0.33.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" -dependencies = [ - "core-foundation-sys", - "libc", - "memchr", - "ntapi", - "rayon", - "windows 0.57.0", -] - [[package]] name = "sysinfo" version = "0.37.2" @@ -9940,7 +9691,7 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows 0.61.3", + "windows", ] [[package]] @@ -9964,12 +9715,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - [[package]] name = "tempfile" version = "3.27.0" @@ -9985,24 +9730,11 @@ dependencies = [ [[package]] name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "term" -version = "0.7.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ - "dirs-next", - "rustversion", - "winapi", + "new_debug_unreachable", ] [[package]] @@ -10063,7 +9795,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -10100,11 +9832,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.21", ] [[package]] @@ -10120,13 +9852,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -10199,15 +9931,6 @@ dependencies = [ "time-core", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinystr" version = "0.8.2" @@ -10314,22 +10037,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "tokio-tungstenite" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" -dependencies = [ - "futures-util", - "log", - "rustls 0.23.40", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.26.4", - "tungstenite 0.23.0", - "webpki-roots 0.26.11", -] - [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -10342,7 +10049,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", - "tungstenite 0.28.0", + "tungstenite", "webpki-roots 0.26.11", ] @@ -10358,9 +10065,9 @@ dependencies = [ "http 1.4.0", "httparse", "js-sys", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", - "tokio-tungstenite 0.28.0", + "tokio-tungstenite", "wasm-bindgen", "web-sys", ] @@ -10398,7 +10105,7 @@ version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -10440,7 +10147,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -10454,7 +10161,7 @@ version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.14", @@ -10489,7 +10196,7 @@ checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", "axum", - "base64 0.22.1", + "base64", "bytes", "h2 0.4.13", "http 1.4.0", @@ -10621,7 +10328,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.13.0", + "indexmap 2.14.2", "pin-project-lite", "slab", "sync_wrapper", @@ -10671,7 +10378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "954ebc262351e20f2cbd76b916ce59d887fb32ce0e579061c8c24f17e5a68411" dependencies = [ "futures", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tower", "tower-layer", @@ -10686,7 +10393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62dfa8324283dd6ce8102ae60be686e0849ff2a0bd182c80d8ccb0f5fd55f118" dependencies = [ "futures", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tower", "tower-resilience-core", @@ -10698,7 +10405,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756171904b6fe320288e48b3741a5ea9490006f17734091b883f340d1e1e28af" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", ] @@ -10727,7 +10434,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "tracing-subscriber", ] @@ -10839,27 +10546,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http 1.4.0", - "httparse", - "log", - "rand 0.8.5", - "rustls 0.23.40", - "rustls-pki-types", - "sha1 0.10.6", - "thiserror 1.0.69", - "url", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.28.0" @@ -10875,7 +10561,7 @@ dependencies = [ "rustls 0.23.40", "rustls-pki-types", "sha1 0.10.6", - "thiserror 2.0.18", + "thiserror 2.0.21", "url", "utf-8", ] @@ -11101,7 +10787,7 @@ version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.2", "serde", "serde_json", "utoipa-gen", @@ -11126,7 +10812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" dependencies = [ "axum", - "base64 0.22.1", + "base64", "mime_guess", "regex", "rust-embed", @@ -11139,9 +10825,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "atomic", "getrandom 0.4.1", @@ -11158,12 +10844,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vart" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87782b74f898179396e93c0efabb38de0d58d50bbd47eae00c71b3a1144dbbae" - [[package]] name = "vart" version = "0.9.3" @@ -11273,6 +10953,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] @@ -11340,7 +11021,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.2", "wasm-encoder", "wasmparser", ] @@ -11379,23 +11060,10 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.2", "semver", ] -[[package]] -name = "wasmtimer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7ed9d8b15c7fb594d72bfb4b5a276f3d2029333cd93a932f376f5937f6f80ee" -dependencies = [ - "futures", - "js-sys", - "parking_lot 0.12.5", - "pin-utils", - "wasm-bindgen", -] - [[package]] name = "wasmtimer" version = "0.4.3" @@ -11431,13 +11099,13 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.1.3" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada" dependencies = [ - "phf 0.11.3", + "phf 0.14.0", "phf_codegen", - "string_cache", + "string_cache 0.11.0", "string_cache_codegen", ] @@ -11534,16 +11202,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "windows" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" -dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.3" @@ -11551,7 +11209,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -11563,19 +11221,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" -dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -11584,8 +11230,8 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -11597,22 +11243,11 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", "windows-threading", ] -[[package]] -name = "windows-implement" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -11624,17 +11259,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -11664,7 +11288,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", ] @@ -11679,15 +11303,6 @@ dependencies = [ "windows-strings 0.5.1", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.3.4" @@ -12061,7 +11676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -12072,8 +11687,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", - "indexmap 2.13.0", + "heck 0.5.0", + "indexmap 2.14.2", "prettyplease", "syn 2.0.119", "wasm-metadata", @@ -12104,7 +11719,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap 2.13.0", + "indexmap 2.14.2", "log", "serde", "serde_derive", @@ -12123,7 +11738,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.2", "log", "semver", "serde", @@ -12139,34 +11754,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" -[[package]] -name = "ws_stream_wasm" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" -dependencies = [ - "async_io_stream", - "futures", - "js-sys", - "log", - "pharos", - "rustc_version", - "send_wrapper", - "thiserror 2.0.18", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - [[package]] name = "x509-cert" version = "0.2.5" @@ -12194,7 +11781,7 @@ dependencies = [ "nom 7.1.3", "oid-registry", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", ] @@ -12363,7 +11950,7 @@ dependencies = [ "arbitrary", "crc32fast", "flate2", - "indexmap 2.13.0", + "indexmap 2.14.2", "memchr", "zopfli", ] @@ -12376,7 +11963,7 @@ checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" dependencies = [ "crc32fast", "flate2", - "indexmap 2.13.0", + "indexmap 2.14.2", "memchr", "typed-path 0.12.3", "zopfli", @@ -12396,7 +11983,7 @@ dependencies = [ "flate2", "getrandom 0.4.1", "hmac 0.13.0", - "indexmap 2.13.0", + "indexmap 2.14.2", "lzma-rust2", "memchr", "pbkdf2 0.13.0", diff --git a/crates/schema-forge-surrealdb/Cargo.toml b/crates/schema-forge-surrealdb/Cargo.toml index 2a7cc510..f0775b5f 100644 --- a/crates/schema-forge-surrealdb/Cargo.toml +++ b/crates/schema-forge-surrealdb/Cargo.toml @@ -1,4 +1,5 @@ [package] +rust-version = "1.97.1" name = "schema-forge-surrealdb" version = "0.13.0" edition = "2021" @@ -6,7 +7,7 @@ edition = "2021" [dependencies] schema-forge-core = { path = "../schema-forge-core" } schema-forge-backend = { path = "../schema-forge-backend" } -surrealdb = { version = "2", features = ["kv-mem", "protocol-ws", "rustls"] } +surrealdb = { version = "3.3.0", features = ["kv-mem", "protocol-ws", "rustls"] } serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/schema-forge-surrealdb/src/backend.rs b/crates/schema-forge-surrealdb/src/backend.rs index 409aa8ff..05dce826 100644 --- a/crates/schema-forge-surrealdb/src/backend.rs +++ b/crates/schema-forge-surrealdb/src/backend.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; +use crate::value::record_key_string; use schema_forge_backend::entity::{Entity, QueryResult}; use schema_forge_backend::error::BackendError; use schema_forge_backend::traits::{EntityStore, SchemaBackend}; @@ -12,6 +13,7 @@ use schema_forge_core::migration::MigrationStep; use schema_forge_core::query::{AggregateQuery, AggregateResult, Query}; use schema_forge_core::types::{DynamicValue, EntityId, FieldType, SchemaDefinition, SchemaName}; use surrealdb::engine::any::Any; +use surrealdb::types::ToSql; use surrealdb::Surreal; use crate::codegen::migration_step_to_surql; @@ -20,6 +22,10 @@ use crate::value::{ entity_to_surreal_map, first_negative_duration, first_oversized_bytes, surreal_to_dynamic, }; +fn supported_server_version(major: u64, minor: u64, stable: bool) -> bool { + major == 3 && minor >= 3 && stable +} + /// The schema metadata table name used to store `SchemaDefinition` records. const SCHEMA_META_TABLE: &str = "_schema_metadata"; @@ -63,6 +69,7 @@ fn reclassify_unique_violation(err: BackendError, table: &str) -> BackendError { /// `SchemaBackend` (DDL/metadata) and `EntityStore` (CRUD/query). pub struct SurrealBackend { db: Surreal, + version_checked: std::sync::OnceLock<()>, } impl SurrealBackend { @@ -72,7 +79,10 @@ impl SurrealBackend { /// connection pooling). The caller is responsible for ensuring the client /// has the correct namespace and database selected. pub fn from_client(db: Surreal) -> Self { - Self { db } + Self { + db, + version_checked: std::sync::OnceLock::new(), + } } /// Get a reference to the underlying SurrealDB client. @@ -101,7 +111,9 @@ impl SurrealBackend { message: e.to_string(), })?; - Ok(Self { db }) + let backend = Self::from_client(db); + backend.ensure_supported_version().await?; + Ok(backend) } /// Connect to a remote SurrealDB instance. @@ -132,8 +144,8 @@ impl SurrealBackend { if let (Some(user), Some(pass)) = (username, password) { db.signin(surrealdb::opt::auth::Root { - username: user, - password: pass, + username: user.to_owned(), + password: pass.to_owned(), }) .await .map_err(|e| BackendError::ConnectionError { @@ -148,11 +160,32 @@ impl SurrealBackend { message: format!("failed to select namespace/database: {e}"), })?; - Ok(Self { db }) + let backend = Self::from_client(db); + backend.ensure_supported_version().await?; + Ok(backend) + } + + async fn ensure_supported_version(&self) -> Result<(), BackendError> { + if self.version_checked.get().is_some() { + return Ok(()); + } + let version = self + .db + .version() + .await + .map_err(|error| BackendError::ConnectionError { + message: format!("cannot verify SurrealDB server version: {error}"), + })?; + if !supported_server_version(version.major, version.minor, version.pre.is_empty()) { + return Err(BackendError::ConnectionError { message: format!("SurrealDB {version} is unsupported; upgrade the server to stable 3.3 or newer within the 3.x series before using SchemaForge. Older embedded engines can lose concurrent conditional updates.") }); + } + let _ = self.version_checked.set(()); + Ok(()) } /// Execute a raw SurrealQL statement, returning the response. - async fn execute_raw(&self, sql: &str) -> Result { + async fn execute_raw(&self, sql: &str) -> Result { + self.ensure_supported_version().await?; self.db .query(sql) .await @@ -162,24 +195,25 @@ impl SurrealBackend { } /// Execute a raw SurrealQL statement and extract the result as a list of - /// `surrealdb::sql::Value` objects. + /// `surrealdb::types::Value` objects. /// - /// Uses `response.take::(0)` which bypasses serde + /// Uses `response.take::(0)` which bypasses serde /// deserialization (the SDK has a special `QueryResult` impl that /// wraps the core value directly). We then unwrap the `Array` variant /// manually to get individual row values. async fn execute_and_take_rows( &self, sql: &str, - ) -> Result, BackendError> { + ) -> Result, BackendError> { let mut response = self.execute_raw(sql).await?; - let value: surrealdb::Value = response.take(0).map_err(|e| BackendError::QueryError { - message: e.to_string(), - })?; - let core_val = value.into_inner(); + let value: surrealdb::types::Value = + response.take(0).map_err(|e| BackendError::QueryError { + message: e.to_string(), + })?; + let core_val = value; match core_val { - surrealdb::sql::Value::Array(arr) => Ok(arr.0), - surrealdb::sql::Value::None | surrealdb::sql::Value::Null => Ok(Vec::new()), + surrealdb::types::Value::Array(arr) => Ok(arr.into_inner()), + surrealdb::types::Value::None | surrealdb::types::Value::Null => Ok(Vec::new()), // Single object result (e.g. from CREATE) other => Ok(vec![other]), } @@ -365,6 +399,7 @@ impl SchemaBackend for SurrealBackend { steps: &[MigrationStep], definition: Option<&SchemaDefinition>, ) -> Result<(), BackendError> { + self.ensure_supported_version().await?; if let Some(definition) = definition { if &definition.name != name { return Err(BackendError::MigrationFailed { @@ -636,15 +671,15 @@ impl EntityStore for SurrealBackend { "UPDATE `{schema}`:`{id}` SET `{field}` = {new_value} WHERE `{field}` = {literal} RETURN AFTER;" ); let mut response = self.execute_raw(&sql).await?; - match response.take::(0) { - Ok(value) => Ok(match value.into_inner() { - surrealdb::sql::Value::Array(rows) => !rows.0.is_empty(), - surrealdb::sql::Value::None | surrealdb::sql::Value::Null => false, + match response.take::(0) { + Ok(value) => Ok(match value { + surrealdb::types::Value::Array(rows) => !rows.is_empty(), + surrealdb::types::Value::None | surrealdb::types::Value::Null => false, _ => true, }), - Err(surrealdb::Error::Db(surrealdb::error::Db::TxRetryable)) => Ok(false), - Err(surrealdb::Error::Db(surrealdb::error::Db::QueryNotExecutedDetail { message })) - if message == surrealdb::error::Db::TxRetryable.to_string() => + Err(error) + if error.query_details() + == Some(&surrealdb::types::QueryError::TransactionConflict) => { Ok(false) } @@ -719,9 +754,12 @@ impl EntityStore for SurrealBackend { let rows = self.execute_and_take_rows(&sql).await?; // SurrealDB returns [{ "count": N }] for GROUP ALL, or [] if no rows. - if let Some(surrealdb::sql::Value::Object(obj)) = rows.first() { - if let Some(surrealdb::sql::Value::Number(n)) = obj.get("count") { - return Ok(n.as_usize()); + if let Some(surrealdb::types::Value::Object(obj)) = rows.first() { + if let Some(surrealdb::types::Value::Number(n)) = obj.get("count") { + return Ok(n + .to_int() + .and_then(|n| usize::try_from(n).ok()) + .unwrap_or(0)); } } Ok(0) @@ -745,12 +783,12 @@ impl EntityStore for SurrealBackend { let mut results = Vec::with_capacity(query.ops.len()); if let Some(row) = rows.first() { - if let surrealdb::sql::Value::Object(obj) = row { + if let surrealdb::types::Value::Object(obj) = row { for (i, op) in query.ops.iter().enumerate() { let key = format!("agg_{i}"); let value = match obj.get(&key) { - Some(surrealdb::sql::Value::Number(n)) => { - let v = n.as_float(); + Some(surrealdb::types::Value::Number(n)) => { + let v = n.to_f64().unwrap_or(f64::NAN); if v.is_nan() { 0.0 } else { @@ -779,18 +817,18 @@ impl EntityStore for SurrealBackend { } } -/// Convert a `surrealdb::sql::Value` response row to an `Entity`. +/// Convert a `surrealdb::types::Value` response row to an `Entity`. /// /// This is the primary deserialization path. It works directly with -/// `surrealdb::sql::Value` (the core value type) which handles `Thing` +/// `surrealdb::types::Value` (the core value type) which handles `Thing` /// record IDs natively, avoiding the serialization errors that occur /// when trying to deserialize SurrealDB internal types through serde. fn surreal_row_to_entity( schema: &SchemaName, - row: &surrealdb::sql::Value, + row: &surrealdb::types::Value, ) -> Result { match row { - surrealdb::sql::Value::Object(obj) => { + surrealdb::types::Value::Object(obj) => { // Extract ID let id_value = obj.get("id").ok_or_else(|| BackendError::Internal { message: "SurrealDB record missing 'id' field".to_string(), @@ -813,69 +851,81 @@ fn surreal_row_to_entity( Ok(Entity::with_id(entity_id, schema.clone(), fields)) } other => Err(BackendError::Internal { - message: format!("expected Object in query result, got: {other}"), + message: format!("expected Object in query result, got: {other:?}"), }), } } -/// Extract entity ID string from a `surrealdb::sql::Value`. +/// Extract entity ID string from a `surrealdb::types::Value`. /// -/// SurrealDB returns IDs as `Thing` (table:id), `Strand` (string), or other formats. -fn extract_id_from_surreal(value: &surrealdb::sql::Value) -> String { +/// SurrealDB returns IDs as native record identifiers or strings. +fn extract_id_from_surreal(value: &surrealdb::types::Value) -> String { match value { - surrealdb::sql::Value::Thing(thing) => { - // thing.id is the record's unique part - thing.id.to_raw() + surrealdb::types::Value::RecordId(thing) => { + // Preserve the raw record key without SQL quoting. + record_key_string(&thing.key) } - surrealdb::sql::Value::Strand(s) => s.0.clone(), - other => other.to_string(), + surrealdb::types::Value::String(s) => s.clone(), + other => other.to_sql(), } } -/// Convert a surrealdb::sql::Value to a SurrealQL literal string for use in SET clauses. -fn field_surreal_value_to_literal(value: &surrealdb::sql::Value) -> String { +/// Convert a surrealdb::types::Value to a SurrealQL literal string for use in SET clauses. +fn field_surreal_value_to_literal(value: &surrealdb::types::Value) -> String { match value { - surrealdb::sql::Value::None | surrealdb::sql::Value::Null => "NONE".to_string(), - surrealdb::sql::Value::Bool(b) => b.to_string(), - surrealdb::sql::Value::Number(n) => n.to_string(), - surrealdb::sql::Value::Strand(s) => { + surrealdb::types::Value::None | surrealdb::types::Value::Null => "NONE".to_string(), + surrealdb::types::Value::Bool(b) => b.to_string(), + surrealdb::types::Value::Number(n) => n.to_sql(), + surrealdb::types::Value::String(s) => { // Detect ISO 8601 datetime strings and use SurrealQL d'...' literal if chrono::DateTime::parse_from_rfc3339(s.as_str()).is_ok() { format!("d'{}'", s.as_str()) } else { - value.to_string() + value.to_sql() } } - surrealdb::sql::Value::Datetime(dt) => format!("d'{}'", dt.0.to_rfc3339()), + surrealdb::types::Value::Datetime(dt) => { + format!("d'{}'", dt.clone().into_inner().to_rfc3339()) + } // Duration literals are bare in SurrealQL (e.g. `2w3d`); the Display impl // produces a parseable form. - surrealdb::sql::Value::Duration(dur) => dur.to_string(), + surrealdb::types::Value::Duration(dur) => dur.to_sql(), // The `Bytes` Display impl emits a parseable SurrealQL literal of the form // `encoding::base64::decode("...")`, round-tripping to native bytes. - surrealdb::sql::Value::Bytes(b) => b.to_string(), - surrealdb::sql::Value::Array(arr) => { + surrealdb::types::Value::Bytes(b) => b.to_sql(), + surrealdb::types::Value::Array(arr) => { let items: Vec = arr.iter().map(field_surreal_value_to_literal).collect(); format!("[{}]", items.join(", ")) } - surrealdb::sql::Value::Object(obj) => { + surrealdb::types::Value::Object(obj) => { let entries: Vec = obj .iter() .map(|(k, v)| { format!( "{}: {}", - surrealdb::sql::Value::from(k.as_str()), + surrealdb::types::Value::String(k.clone()).to_sql(), field_surreal_value_to_literal(v) ) }) .collect(); format!("{{ {} }}", entries.join(", ")) } - other => format!("'{}'", other.to_string().replace('\'', "\\'")), + other => format!("'{}'", other.to_sql().replace('\'', "\\'")), } } #[cfg(test)] mod tests { + #[test] + fn rejects_engines_without_supported_transaction_guarantees() { + assert!(!super::supported_server_version(2, 6, true)); + assert!(!super::supported_server_version(3, 2, true)); + assert!(!super::supported_server_version(3, 3, false)); + assert!(super::supported_server_version(3, 3, true)); + assert!(super::supported_server_version(3, 4, true)); + assert!(!super::supported_server_version(4, 0, true)); + } + use super::*; #[tokio::test] @@ -900,16 +950,16 @@ mod tests { #[test] fn extract_id_from_thing() { - use surrealdb::sql::{Id, Thing}; - let thing = Thing::from(("Contact", Id::String("entity_abc123".into()))); - let thing_val = surrealdb::sql::Value::Thing(thing); + use surrealdb::types::{RecordId, RecordIdKey}; + let thing = RecordId::new("Contact", RecordIdKey::String("entity_abc123".into())); + let thing_val = surrealdb::types::Value::RecordId(thing); assert_eq!(extract_id_from_surreal(&thing_val), "entity_abc123"); } #[test] fn extract_id_from_strand() { - let strand = surrealdb::sql::Strand::from("entity_abc123"); - let strand_val = surrealdb::sql::Value::Strand(strand); + let strand = String::from("entity_abc123"); + let strand_val = surrealdb::types::Value::String(strand); assert_eq!(extract_id_from_surreal(&strand_val), "entity_abc123"); } @@ -943,15 +993,15 @@ mod tests { #[test] fn duration_literal_is_bare() { - let dur = surrealdb::sql::Duration::from(std::time::Duration::from_secs(3600)); - let val = surrealdb::sql::Value::Duration(dur); + let dur = surrealdb::types::Duration::from(std::time::Duration::from_secs(3600)); + let val = surrealdb::types::Value::Duration(dur); // Bare SurrealQL duration literal, no quotes. assert_eq!(field_surreal_value_to_literal(&val), "1h"); } #[test] fn bytes_literal_is_base64_decode_call() { - let val = surrealdb::sql::Value::Bytes(surrealdb::sql::Bytes::from(b"hello".to_vec())); + let val = surrealdb::types::Value::Bytes(surrealdb::types::Bytes::from(b"hello".to_vec())); // Parseable SurrealQL: decodes back to the same bytes. assert_eq!( field_surreal_value_to_literal(&val), diff --git a/crates/schema-forge-surrealdb/src/value.rs b/crates/schema-forge-surrealdb/src/value.rs index 49b72960..1102cc87 100644 --- a/crates/schema-forge-surrealdb/src/value.rs +++ b/crates/schema-forge-surrealdb/src/value.rs @@ -1,10 +1,9 @@ -//! Pure functions for converting between `DynamicValue` and `surrealdb::sql::Value`. +//! Pure functions for converting between `DynamicValue` and `surrealdb::types::Value`. //! //! These conversions are used when reading from and writing to SurrealDB. //! -//! We use the `surrealdb::sql` module types (re-exported from `surrealdb_core`) -//! for pattern matching on query results. Construction of composite values -//! goes through the public `surrealdb::Object` wrapper which exposes `insert`. +//! The SDK exposes native values through `surrealdb::types`, preserving +//! records, durations, bytes, and nested objects without JSON coercion. //! //! A SchemaForge `duration` is a signed [`chrono::TimeDelta`], but SurrealDB's //! native `duration` type is unsigned. A negative duration therefore cannot be @@ -16,20 +15,27 @@ use std::collections::BTreeMap; use schema_forge_backend::entity::Entity; use schema_forge_backend::error::BackendError; use schema_forge_core::types::{DynamicValue, EntityId, FieldType, SchemaName}; -use surrealdb::sql::Value as SurrealValue; +use surrealdb::types::{SurrealValue as IntoSurrealValue, ToSql, Value as SurrealValue}; -/// Convert a `DynamicValue` to a `surrealdb::sql::Value`. +pub(crate) fn record_key_string(key: &surrealdb::types::RecordIdKey) -> String { + match key { + surrealdb::types::RecordIdKey::String(value) => value.clone(), + other => other.to_sql(), + } +} + +/// Convert a `DynamicValue` to a `surrealdb::types::Value`. pub fn dynamic_to_surreal(value: &DynamicValue) -> SurrealValue { match value { DynamicValue::Null => SurrealValue::None, - DynamicValue::Text(s) => SurrealValue::from(s.as_str()), - DynamicValue::Integer(i) => SurrealValue::from(*i), - DynamicValue::Float(f) => SurrealValue::from(*f), - DynamicValue::Boolean(b) => SurrealValue::from(*b), + DynamicValue::Text(s) => (s.as_str()).into_value(), + DynamicValue::Integer(i) => (*i).into_value(), + DynamicValue::Float(f) => (*f).into_value(), + DynamicValue::Boolean(b) => (*b).into_value(), DynamicValue::DateTime(dt) => { // Store as ISO 8601 string — the literal serializer in backend.rs // will wrap it with d'...' for SurrealQL datetime fields. - SurrealValue::from(dt.to_rfc3339()) + (dt.to_rfc3339()).into_value() } DynamicValue::Duration(d) => { timedelta_to_surreal_duration(d).map_or(SurrealValue::None, SurrealValue::Duration) @@ -37,42 +43,37 @@ pub fn dynamic_to_surreal(value: &DynamicValue) -> SurrealValue { DynamicValue::Bytes(b) => { // SurrealDB has a native (unsigned-length) `bytes` type; store the // bytes verbatim. - SurrealValue::Bytes(surrealdb::sql::Bytes::from(b.clone())) + SurrealValue::Bytes(surrealdb::types::Bytes::from(b.clone())) } - DynamicValue::Enum(s) => SurrealValue::from(s.as_str()), + DynamicValue::Enum(s) => (s.as_str()).into_value(), DynamicValue::Json(v) => json_to_surreal(v), DynamicValue::Array(arr) => { let items: Vec = arr.iter().map(dynamic_to_surreal).collect(); - SurrealValue::from(items) + (items).into_value() } DynamicValue::Composite(map) | DynamicValue::Map(map) => { // A fixed-field `Composite` and a typed open-key `Map` are both // stored as a native string-keyed SurrealDB object. - let mut obj = surrealdb::Object::new(); + let mut obj = surrealdb::types::Object::new(); for (k, v) in map { - obj.insert( - k.clone(), - surrealdb::Value::from_inner(dynamic_to_surreal(v)), - ); + obj.insert(k.clone(), dynamic_to_surreal(v)); } - SurrealValue::Object(obj.into_inner()) + SurrealValue::Object(obj) } - DynamicValue::Ref(id) => SurrealValue::from(id.as_str()), + DynamicValue::Ref(id) => (id.as_str()).into_value(), DynamicValue::RefArray(ids) => { - let items: Vec = ids - .iter() - .map(|id| SurrealValue::from(id.as_str())) - .collect(); - SurrealValue::from(items) + let items: Vec = + ids.iter().map(|id| (id.as_str()).into_value()).collect(); + (items).into_value() } _ => { // Future DynamicValue variants -- store as string fallback. - SurrealValue::from(format!("{value:?}").as_str()) + (format!("{value:?}").as_str()).into_value() } } } -/// Convert a `surrealdb::sql::Value` back to a `DynamicValue`. +/// Convert a `surrealdb::types::Value` back to a `DynamicValue`. /// /// This is a best-effort conversion. SurrealDB values that do not have /// a corresponding `DynamicValue` variant are stored as JSON. @@ -83,24 +84,25 @@ pub fn surreal_to_dynamic(value: &SurrealValue) -> Result { // Match on the Number enum variants directly. match n { - surrealdb::sql::Number::Int(i) => Ok(DynamicValue::Integer(*i)), - surrealdb::sql::Number::Float(f) => Ok(DynamicValue::Float(*f)), + surrealdb::types::Number::Int(i) => Ok(DynamicValue::Integer(*i)), + surrealdb::types::Number::Float(f) => Ok(DynamicValue::Float(*f)), _ => { // Decimal or future variants -- convert to float. - Ok(DynamicValue::Float((*n).as_float())) + Ok(DynamicValue::Float(n.to_f64().unwrap_or(f64::NAN))) } } } - SurrealValue::Strand(s) => Ok(DynamicValue::Text(s.0.clone())), + SurrealValue::String(s) => Ok(DynamicValue::Text(s.clone())), SurrealValue::Datetime(dt) => { - // surrealdb_core::sql::Datetime wraps chrono::DateTime as pub field .0 - let chrono_dt: chrono::DateTime = dt.0; + let chrono_dt: chrono::DateTime = dt.clone().into_inner(); Ok(DynamicValue::DateTime(chrono_dt)) } SurrealValue::Duration(dur) => { - // surrealdb::sql::Duration wraps an unsigned std::time::Duration. - let delta = chrono::TimeDelta::from_std(dur.0).map_err(|e| BackendError::Internal { - message: format!("duration out of representable range: {e}"), + // surrealdb::types::Duration wraps an unsigned std::time::Duration. + let delta = chrono::TimeDelta::from_std(dur.into_inner()).map_err(|e| { + BackendError::Internal { + message: format!("duration out of representable range: {e}"), + } })?; Ok(DynamicValue::Duration(delta)) } @@ -117,17 +119,17 @@ pub fn surreal_to_dynamic(value: &SurrealValue) -> Result { + SurrealValue::RecordId(thing) => { // Record reference from a relation field - let id_str = thing.id.to_raw(); + let id_str = record_key_string(&thing.key); match EntityId::parse(&id_str) { Ok(entity_id) => Ok(DynamicValue::Ref(entity_id)), - Err(_) => Ok(DynamicValue::Text(format!("{}:{}", thing.tb, id_str))), + Err(_) => Ok(DynamicValue::Text(format!("{}:{}", thing.table, id_str))), } } _ => { // Fallback: convert to JSON representation - let json_str = value.to_string(); + let json_str = value.to_sql(); match serde_json::from_str::(&json_str) { Ok(json_val) => Ok(DynamicValue::Json(json_val)), Err(_) => Ok(DynamicValue::Text(json_str)), @@ -141,7 +143,7 @@ pub fn surreal_to_dynamic(value: &SurrealValue) -> Result BTreeMap { let mut map = BTreeMap::new(); - map.insert("id".to_string(), SurrealValue::from(entity.id.as_str())); + map.insert("id".to_string(), (entity.id.as_str()).into_value()); for (k, v) in &entity.fields { map.insert(k.clone(), dynamic_to_surreal(v)); } @@ -153,7 +155,7 @@ pub fn entity_to_surreal_map(entity: &Entity) -> BTreeMap /// Expects an `"id"` field containing the entity's identifier. pub fn surreal_object_to_entity( schema: &SchemaName, - obj: &surrealdb::sql::Object, + obj: &surrealdb::types::Object, ) -> Result { // Extract ID let id_value = obj.get("id").ok_or_else(|| BackendError::Internal { @@ -182,17 +184,17 @@ pub fn surreal_object_to_entity( /// SurrealDB may return IDs as `Thing` (table:id), `Strand`, or other formats. fn extract_id_string(value: &SurrealValue) -> Result { match value { - SurrealValue::Strand(s) => Ok(s.0.clone()), - SurrealValue::Thing(thing) => { - // thing.id is the record's unique part; thing.tb is the table name - Ok(thing.id.to_raw()) + SurrealValue::String(s) => Ok(s.clone()), + SurrealValue::RecordId(thing) => { + // thing.id is the record's unique part; thing.table is the table name + Ok(record_key_string(&thing.key)) } - other => Ok(other.to_string()), + other => Ok(other.to_sql()), } } /// Convert a signed `chrono::TimeDelta` to SurrealDB's native (unsigned) -/// `surrealdb::sql::Duration`. +/// `surrealdb::types::Duration`. /// /// SurrealDB durations wrap an unsigned `std::time::Duration`, so a negative /// `TimeDelta` has no native representation and yields `None`. A negative value @@ -202,8 +204,8 @@ fn extract_id_string(value: &SurrealValue) -> Result { /// `duration` field uses on a records platform (retention windows, TTLs, SLA /// timers) are non-negative, so `None` here is only ever the unreachable /// belt-and-braces case for an already-validated value. -fn timedelta_to_surreal_duration(d: &chrono::TimeDelta) -> Option { - d.to_std().ok().map(surrealdb::sql::Duration::from) +fn timedelta_to_surreal_duration(d: &chrono::TimeDelta) -> Option { + d.to_std().ok().map(surrealdb::types::Duration::from) } /// Find the first negative `duration` anywhere in a value tree. @@ -262,31 +264,31 @@ pub(crate) fn first_oversized_bytes( } } -/// Convert a `serde_json::Value` to a `surrealdb::sql::Value`. +/// Convert a `serde_json::Value` to a `surrealdb::types::Value`. fn json_to_surreal(json: &serde_json::Value) -> SurrealValue { match json { serde_json::Value::Null => SurrealValue::None, - serde_json::Value::Bool(b) => SurrealValue::from(*b), + serde_json::Value::Bool(b) => (*b).into_value(), serde_json::Value::Number(n) => { if let Some(i) = n.as_i64() { - SurrealValue::from(i) + (i).into_value() } else if let Some(f) = n.as_f64() { - SurrealValue::from(f) + (f).into_value() } else { - SurrealValue::from(n.to_string().as_str()) + (n.to_string().as_str()).into_value() } } - serde_json::Value::String(s) => SurrealValue::from(s.as_str()), + serde_json::Value::String(s) => (s.as_str()).into_value(), serde_json::Value::Array(arr) => { let items: Vec = arr.iter().map(json_to_surreal).collect(); - SurrealValue::from(items) + (items).into_value() } serde_json::Value::Object(map) => { - let mut obj = surrealdb::Object::new(); + let mut obj = surrealdb::types::Object::new(); for (k, v) in map { - obj.insert(k.clone(), surrealdb::Value::from_inner(json_to_surreal(v))); + obj.insert(k.clone(), json_to_surreal(v)); } - SurrealValue::Object(obj.into_inner()) + SurrealValue::Object(obj) } } } @@ -532,19 +534,25 @@ mod tests { #[test] fn thing_converts_to_ref() { - use surrealdb::sql::{Id, Thing}; + use surrealdb::types::{RecordId, RecordIdKey}; let entity_id = EntityId::new("project"); - let thing = Thing::from(("Project", Id::String(entity_id.as_str().to_string()))); - let sv = SurrealValue::Thing(thing); + let thing = RecordId::new( + "Project", + RecordIdKey::String(entity_id.as_str().to_string()), + ); + let sv = SurrealValue::RecordId(thing); let back = surreal_to_dynamic(&sv).unwrap(); assert!(matches!(back, DynamicValue::Ref(ref id) if id.as_str() == entity_id.as_str())); } #[test] fn thing_non_entity_id_converts_to_text() { - use surrealdb::sql::{Id, Thing}; - let thing = Thing::from(("SomeTable", Id::String("not_an_entity_id".to_string()))); - let sv = SurrealValue::Thing(thing); + use surrealdb::types::{RecordId, RecordIdKey}; + let thing = RecordId::new( + "SomeTable", + RecordIdKey::String("not_an_entity_id".to_string()), + ); + let sv = SurrealValue::RecordId(thing); let back = surreal_to_dynamic(&sv).unwrap(); assert_eq!( back, diff --git a/crates/schema-forge-surrealdb/tests/data_correctness.rs b/crates/schema-forge-surrealdb/tests/data_correctness.rs index a0761295..707f968f 100644 --- a/crates/schema-forge-surrealdb/tests/data_correctness.rs +++ b/crates/schema-forge-surrealdb/tests/data_correctness.rs @@ -9,3 +9,96 @@ async fn null_filters_stable_pages_and_enum_migrations() { .unwrap(); contract::exercise(&backend).await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn competing_clients_cannot_both_replace_the_same_snapshot() { + use schema_forge_backend::{Entity, EntityStore, SchemaBackend}; + use schema_forge_core::{migration::DiffEngine, types::*}; + use schema_forge_surrealdb::SurrealBackend; + use std::collections::BTreeMap; + + let backend = SurrealBackend::connect_memory("cas", "cas").await.unwrap(); + let schema = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("ConcurrentCas").unwrap(), + vec![FieldDefinition::new( + FieldName::new("value").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + )], + vec![], + ) + .unwrap(); + backend + .apply_schema_change( + &schema.name, + &DiffEngine::create_new(&schema).steps, + Some(&schema), + ) + .await + .unwrap(); + let original = DynamicValue::Text("snapshot".into()); + let replacement = DynamicValue::Text("replacement".into()); + let row = backend + .create(&Entity::new( + schema.name.clone(), + BTreeMap::from([("value".into(), original.clone())]), + )) + .await + .unwrap(); + // Independent backend wrappers share the database client, never an + // application mutex. The engine must enforce the conditional write. + let first = SurrealBackend::from_client(backend.client().clone()); + let second = SurrealBackend::from_client(backend.client().clone()); + let field = FieldName::new("value").unwrap(); + for _ in 0..256 { + backend.update(&row).await.unwrap(); + let (replaced, cleared) = tokio::join!( + first.update_field_if_matches(&schema.name, &row.id, &field, &original, &replacement), + second.update_field_if_matches( + &schema.name, + &row.id, + &field, + &original, + &DynamicValue::Null + ), + ); + let replaced = replaced.unwrap(); + let cleared = cleared.unwrap(); + assert_ne!(replaced, cleared, "exactly one competing write commits"); + let stored = backend.get(&schema.name, &row.id).await.unwrap(); + if replaced { + assert_eq!( + stored.field("value"), + Some(&replacement), + "a losing clear must preserve the replacement" + ); + } else { + assert!(matches!( + stored.field("value"), + None | Some(DynamicValue::Null) + )); + } + backend.update(&row).await.unwrap(); + let (first_clear, second_clear) = tokio::join!( + first.update_field_if_matches( + &schema.name, + &row.id, + &field, + &original, + &DynamicValue::Null + ), + second.update_field_if_matches( + &schema.name, + &row.id, + &field, + &original, + &DynamicValue::Null + ), + ); + assert_ne!( + first_clear.unwrap(), + second_clear.unwrap(), + "exactly one clear commits" + ); + } +} From 5f1770365257bad034b2a405f804a66717550e8d Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:42:23 -0600 Subject: [PATCH 68/77] fix: honor operator visibility in GraphQL relation reads --- .../src/graphql/resolvers.rs | 27 ++++- .../tests/graphql_tenants.rs | 100 +++++++++++++++++- 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index ffd1b6a0..2c8153c7 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -78,9 +78,9 @@ pub async fn resolve_get_entity<'a>( require_record_access(gql_ctx, schema_def, &entity, ActionVerb::Read)?; // Record-level visibility check - if let (Some(ref policy), Some(c)) = (&gql_ctx.state.record_access_policy, claims) { + if let Some(policy) = &gql_ctx.state.record_access_policy { let visible = policy - .filter_visible(schema_def, c, vec![entity.clone()]) + .filter_visible_optional(schema_def, claims, vec![entity.clone()]) .await; if visible.is_empty() { return Err(forge_error_to_gql(ForgeError::Forbidden { @@ -198,8 +198,8 @@ pub async fn resolve_list_entities<'a>( .collect(); // Record-level access filtering let visible_entities = - if let (Some(ref policy), Some(c)) = (&gql_ctx.state.record_access_policy, claims) { - policy.filter_visible(schema_def, c, authorized).await + if let Some(policy) = &gql_ctx.state.record_access_policy { + policy.filter_visible_optional(schema_def, claims, authorized).await } else { authorized }; @@ -410,6 +410,9 @@ pub async fn resolve_relation_one<'a>( if require_record_access(gql_ctx, target_schema_def, &entity, ActionVerb::Read).is_err() { return Ok(None); } + if !operator_visible(gql_ctx, target_schema_def, &entity).await { + return Ok(None); + } entity.strip_hidden(target_schema_def); filter_entity_fields( &gql_ctx.state.policy_store, @@ -464,6 +467,9 @@ pub async fn resolve_relation_many<'a>( { continue; } + if !operator_visible(gql_ctx, target_schema_def, &entity).await { + continue; + } entity.strip_hidden(target_schema_def); filter_entity_fields( &gql_ctx.state.policy_store, @@ -479,6 +485,19 @@ pub async fn resolve_relation_many<'a>( Ok(Some(FieldValue::list(results))) } +async fn operator_visible( + context: &ForgeGraphqlContext, + schema: &SchemaDefinition, + entity: &Entity, +) -> bool { + match &context.state.record_access_policy { + Some(policy) => !policy.filter_visible_optional( + schema, context.claims.as_ref(), vec![entity.clone()], + ).await.is_empty(), + None => true, + } +} + async fn request_schema( context: &ForgeGraphqlContext, name: &str, diff --git a/crates/schema-forge-acton/tests/graphql_tenants.rs b/crates/schema-forge-acton/tests/graphql_tenants.rs index 02931391..7deff80b 100644 --- a/crates/schema-forge-acton/tests/graphql_tenants.rs +++ b/crates/schema-forge-acton/tests/graphql_tenants.rs @@ -27,10 +27,14 @@ use tokio::sync::oneshot; use tower::ServiceExt; async fn app() -> (Router, String, String) { + app_with_relations(false).await +} + +async fn app_with_relations(relations: bool) -> (Router, String, String) { let backend = SurrealBackend::connect_with_auth("mem://", "graphql", "tenants", None, None) .await .unwrap(); - let schemas = schema_forge_dsl::parse( + let mut schemas = schema_forge_dsl::parse( r#" @tenant(root) @access(read: ["member"], write: ["member"], delete: ["member"]) @@ -40,6 +44,17 @@ async fn app() -> (Router, String, String) { "#, ) .unwrap(); + if relations { + schemas.extend( + schema_forge_dsl::parse( + r#" + @access(read: ["member"], write: ["member"]) + schema Link { one: -> Catalog many: -> Catalog[] } + "#, + ) + .unwrap(), + ); + } for schema in &schemas { backend .apply_migration(&schema.name, &DiffEngine::create_new(schema).steps) @@ -59,6 +74,7 @@ async fn app() -> (Router, String, String) { } let extension = SchemaForgeExtension::builder() .with_backend(backend) + .with_record_access_policy(OperatorVisibility) .build() .await .unwrap(); @@ -270,3 +286,85 @@ async fn graphql_uses_live_field_security_and_refuses_removed_schemas() { let removed = query(&app, &get, &tenant).await; assert!(removed.get("errors").is_some(), "{removed}"); } + +struct OperatorVisibility; +impl schema_forge_backend::auth::RecordAccessPolicy for OperatorVisibility { + fn filter_visible<'a>( + &'a self, + _schema: &'a schema_forge_core::types::SchemaDefinition, + _claims: &'a Claims, + entities: Vec, + ) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + entities + .into_iter() + .filter(|entity| { + entity.fields.get("name") != Some(&DynamicValue::Text("operator-denied".into())) + }) + .collect() + }) + } + fn can_modify<'a>( + &'a self, + _schema: &'a schema_forge_core::types::SchemaDefinition, + _claims: &'a Claims, + _entity: &'a Entity, + ) -> std::pin::Pin + Send + 'a>> { + Box::pin(async { true }) + } + fn can_delete<'a>( + &'a self, + _schema: &'a schema_forge_core::types::SchemaDefinition, + _claims: &'a Claims, + _entity: &'a Entity, + ) -> std::pin::Pin + Send + 'a>> { + Box::pin(async { true }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn graphql_relation_reads_honor_operator_visibility_veto() { + let (app, tenant, _) = app_with_relations(true).await; + let denied = query( + &app, + "mutation { createCatalog(input: {name: \"operator-denied\"}) { id } }", + &tenant, + ) + .await; + let allowed = query( + &app, + "mutation { createCatalog(input: {name: \"operator-visible\"}) { id } }", + &tenant, + ) + .await; + let denied_id = denied["data"]["createCatalog"]["id"].as_str().unwrap(); + let allowed_id = allowed["data"]["createCatalog"]["id"].as_str().unwrap(); + let direct = query( + &app, + &format!("{{ catalog(id: \"{denied_id}\") {{ id name }} }}"), + &tenant, + ) + .await; + assert!(direct.get("errors").is_some(), "{direct}"); + let list = query(&app, "{ catalogs { items { id } } }", &tenant).await; + assert_eq!( + list["data"]["catalogs"]["items"], + json!([{"id":allowed_id}]), + "{list}" + ); + let link = query(&app, &format!("mutation {{ createLink(input: {{one: \"{denied_id}\", many: [\"{denied_id}\", \"{allowed_id}\"]}}) {{ id }} }}"), &tenant).await; + let id = link["data"]["createLink"]["id"].as_str().unwrap(); + let nested = query( + &app, + &format!("{{ link(id: \"{id}\") {{ id one {{ id name }} many {{ id name }} }} }}"), + &tenant, + ) + .await; + assert_eq!(nested["data"]["link"]["id"], id, "{nested}"); + assert!(nested["data"]["link"]["one"].is_null(), "{nested}"); + assert_eq!( + nested["data"]["link"]["many"], + json!([{"id":allowed_id, "name":"operator-visible"}]), + "{nested}" + ); +} From 59bcebb37b2941de1a60f13a047ea2bac1d66699 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:43:54 -0600 Subject: [PATCH 69/77] style: format changed Rust modules with the release toolchain --- crates/schema-forge-acton/src/access.rs | 7 +- crates/schema-forge-acton/src/actor.rs | 15 ++-- crates/schema-forge-acton/src/authz/store.rs | 67 +++++++++-------- .../src/graphql/resolvers.rs | 29 ++++--- .../src/graphql/schema_builder.rs | 11 +-- .../schema-forge-acton/src/routes/export.rs | 62 ++++++++------- .../schema-forge-acton/src/routes/schemas.rs | 75 +++++++++++++------ crates/schema-forge-cli/src/commands/mod.rs | 2 +- crates/schema-forge-cli/src/commands/parse.rs | 3 +- crates/schema-forge-cli/src/commands/serve.rs | 17 ++++- .../schema-forge-postgres/tests/cedar_read.rs | 7 +- .../tests/migration_safety.rs | 5 +- .../tests/migration_renames.rs | 5 +- 13 files changed, 177 insertions(+), 128 deletions(-) diff --git a/crates/schema-forge-acton/src/access.rs b/crates/schema-forge-acton/src/access.rs index 5d0409ba..493c6f6a 100644 --- a/crates/schema-forge-acton/src/access.rs +++ b/crates/schema-forge-acton/src/access.rs @@ -318,11 +318,10 @@ pub(crate) fn filter_patch_fields( continue; } let decision = - crate::authz::engine::authorize_input_field(store, claims, schema, resource, name).map_err( - |_| ForgeError::Forbidden { + crate::authz::engine::authorize_input_field(store, claims, schema, resource, name) + .map_err(|_| ForgeError::Forbidden { message: "Could not authorize a patched field.".into(), - }, - )?; + })?; if !decision.errors.is_empty() { return Err(ForgeError::Forbidden { message: "Could not authorize a patched field.".into(), diff --git a/crates/schema-forge-acton/src/actor.rs b/crates/schema-forge-acton/src/actor.rs index e5acf50d..380bf05f 100644 --- a/crates/schema-forge-acton/src/actor.rs +++ b/crates/schema-forge-acton/src/actor.rs @@ -267,12 +267,15 @@ fn configure_registry_reads(actor: &mut ManagedActor) { }); actor.act_on::(|actor, ctx| { - let snapshot = actor.model.policy_store.as_ref().map(|store| { - crate::messages::AuthorizationSnapshot { - registry: actor.model.registry.clone(), - policy: store.current(), - } - }); + let snapshot = + actor + .model + .policy_store + .as_ref() + .map(|store| crate::messages::AuthorizationSnapshot { + registry: actor.model.registry.clone(), + policy: store.current(), + }); let reply = ctx.message().reply.clone(); Reply::pending(async move { reply.send(snapshot).await }) }); diff --git a/crates/schema-forge-acton/src/authz/store.rs b/crates/schema-forge-acton/src/authz/store.rs index 00a3e07e..ad9a2262 100644 --- a/crates/schema-forge-acton/src/authz/store.rs +++ b/crates/schema-forge-acton/src/authz/store.rs @@ -80,7 +80,9 @@ impl PolicyStore { /// Pin an existing immutable snapshot for a single request. #[cfg(feature = "graphql")] pub(crate) fn from_snapshot(snapshot: Arc) -> Self { - Self { inner: ArcSwap::from(snapshot) } + Self { + inner: ArcSwap::from(snapshot), + } } /// Returns the current snapshot. Cheap pointer-clone. @@ -114,12 +116,8 @@ impl PolicyStore { let current = self.current(); let role_ranks = current.role_ranks.clone(); let principal_claims = current.principal_claims.clone(); - let next = PolicyStoreSnapshot::from_schemas( - schemas, - custom_dir, - role_ranks, - principal_claims, - )?; + let next = + PolicyStoreSnapshot::from_schemas(schemas, custom_dir, role_ranks, principal_claims)?; self.swap(next); Ok(()) } @@ -196,7 +194,10 @@ impl PolicyStoreSnapshot { let result = validator.validate(&policy_set, ValidationMode::Strict); if !result.validation_passed() { let errors: Vec = result.validation_errors().map(|e| e.to_string()).collect(); - let warns: Vec = result.validation_warnings().map(|w| w.to_string()).collect(); + let warns: Vec = result + .validation_warnings() + .map(|w| w.to_string()) + .collect(); let mut combined = errors; combined.extend(warns); return Err(PolicyStoreError::Validation(combined.join("\n"))); @@ -256,14 +257,13 @@ mod tests { #[test] fn compile_succeeds_for_validated_bundle() { - let snap = - PolicyStoreSnapshot::compile( - MINIMAL_SCHEMA, - MINIMAL_POLICY, - RoleRanks::empty(), - PrincipalClaimMappings::default(), - ) - .unwrap(); + let snap = PolicyStoreSnapshot::compile( + MINIMAL_SCHEMA, + MINIMAL_POLICY, + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(); assert_eq!(snap.policy_count, 1); assert_eq!(snap.policy_hash.len(), 64); } @@ -283,24 +283,22 @@ mod tests { #[test] fn store_swap_makes_new_snapshot_visible() { - let s1 = - PolicyStoreSnapshot::compile( - MINIMAL_SCHEMA, - MINIMAL_POLICY, - RoleRanks::empty(), - PrincipalClaimMappings::default(), - ) - .unwrap(); + let s1 = PolicyStoreSnapshot::compile( + MINIMAL_SCHEMA, + MINIMAL_POLICY, + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(); let store = PolicyStore::new(s1); let h1 = store.current().policy_hash.clone(); - let s2 = - PolicyStoreSnapshot::compile( - MINIMAL_SCHEMA, - MINIMAL_POLICY, - RoleRanks::empty(), - PrincipalClaimMappings::default(), - ) - .unwrap(); + let s2 = PolicyStoreSnapshot::compile( + MINIMAL_SCHEMA, + MINIMAL_POLICY, + RoleRanks::empty(), + PrincipalClaimMappings::default(), + ) + .unwrap(); store.swap(s2); assert_eq!(store.current().policy_hash, h1); } @@ -493,7 +491,10 @@ when { principal_claims_with_org(), ) .expect("strict-mode validation should accept guarded reads of mapped attributes"); - assert!(snap.principal_claims.iter().any(|m| m.attribute_name == "client_org_id")); + assert!(snap + .principal_claims + .iter() + .any(|m| m.attribute_name == "client_org_id")); } #[test] diff --git a/crates/schema-forge-acton/src/graphql/resolvers.rs b/crates/schema-forge-acton/src/graphql/resolvers.rs index 2c8153c7..db8f4e31 100644 --- a/crates/schema-forge-acton/src/graphql/resolvers.rs +++ b/crates/schema-forge-acton/src/graphql/resolvers.rs @@ -197,12 +197,13 @@ pub async fn resolve_list_entities<'a>( }) .collect(); // Record-level access filtering - let visible_entities = - if let Some(policy) = &gql_ctx.state.record_access_policy { - policy.filter_visible_optional(schema_def, claims, authorized).await - } else { - authorized - }; + let visible_entities = if let Some(policy) = &gql_ctx.state.record_access_policy { + policy + .filter_visible_optional(schema_def, claims, authorized) + .await + } else { + authorized + }; let count = visible_entities.len(); // The raw storage count precedes authorization and could disclose hidden rows. @@ -491,9 +492,10 @@ async fn operator_visible( entity: &Entity, ) -> bool { match &context.state.record_access_policy { - Some(policy) => !policy.filter_visible_optional( - schema, context.claims.as_ref(), vec![entity.clone()], - ).await.is_empty(), + Some(policy) => !policy + .filter_visible_optional(schema, context.claims.as_ref(), vec![entity.clone()]) + .await + .is_empty(), None => true, } } @@ -502,9 +504,12 @@ async fn request_schema( context: &ForgeGraphqlContext, name: &str, ) -> async_graphql::Result { - context.state.registry.get(name).await.ok_or_else(|| { - forge_error_to_gql(ForgeError::SchemaNotFound { name: name.into() }) - }) + context + .state + .registry + .get(name) + .await + .ok_or_else(|| forge_error_to_gql(ForgeError::SchemaNotFound { name: name.into() })) } fn require_record_access( diff --git a/crates/schema-forge-acton/src/graphql/schema_builder.rs b/crates/schema-forge-acton/src/graphql/schema_builder.rs index d81ba3e3..b92a2b9a 100644 --- a/crates/schema-forge-acton/src/graphql/schema_builder.rs +++ b/crates/schema-forge-acton/src/graphql/schema_builder.rs @@ -97,7 +97,7 @@ pub fn build_graphql_schema(schemas: &[SchemaDefinition]) -> Result() @@ -147,9 +147,7 @@ pub fn build_graphql_schema(schemas: &[SchemaDefinition]) -> Result Result Result { +fn build_output_type(schema_def: &SchemaDefinition, type_name: &str) -> Result { let schema_name = schema_def.name.as_str().to_string(); let mut obj = Object::new(type_name); diff --git a/crates/schema-forge-acton/src/routes/export.rs b/crates/schema-forge-acton/src/routes/export.rs index 578d9fb0..6574c564 100644 --- a/crates/schema-forge-acton/src/routes/export.rs +++ b/crates/schema-forge-acton/src/routes/export.rs @@ -167,8 +167,7 @@ struct FieldDisplay<'a> { impl RelationDisplay for FieldDisplay<'_> { fn display_for(&self, id: &EntityId) -> Option { - self.id_to_display - .and_then(|m| m.get(id.as_str()).cloned()) + self.id_to_display.and_then(|m| m.get(id.as_str()).cloned()) } } @@ -488,9 +487,11 @@ pub async fn prepare_export( // max_rows + 1 so an over-cap result is detectable without draining the table. let mut query = Query::new(schema_def.id.clone()).without_total_count(); if let Some(filter_json) = filter_json { - let filter = crate::routes::entities::json_to_filter(filter_json, schema_def) - .map_err(|errors| ForgeError::InvalidQuery { - message: errors.join("; "), + let filter = + crate::routes::entities::json_to_filter(filter_json, schema_def).map_err(|errors| { + ForgeError::InvalidQuery { + message: errors.join("; "), + } })?; validate_filter(&filter, schema_def).map_err(|errors| ForgeError::InvalidQuery { message: errors @@ -615,9 +616,12 @@ pub async fn materialize_zip_bundle( if bundle_files { let blob_refs = collect_file_blob_refs(&prepared.entities, schema_def, &prepared.columns); for blob in blob_refs { - let bytes = store.get(&blob.key).await.map_err(|e| ForgeError::Internal { - message: format!("failed to read file blob '{}' for bundle: {e}", blob.key), - })?; + let bytes = store + .get(&blob.key) + .await + .map_err(|e| ForgeError::Internal { + message: format!("failed to read file blob '{}' for bundle: {e}", blob.key), + })?; entries.push(BundleEntry::new(blob.member_name, bytes)); } } @@ -669,11 +673,7 @@ async fn enforce_export_rate_limit( })?; let key = rate_limit_key(claims.map(|c| c.sub.as_str())); - let window_ms = settings - .rate_limit - .window_secs - .saturating_mul(1000) - .max(1); + let window_ms = settings.rate_limit.window_secs.saturating_mul(1000).max(1); let (tx, rx) = oneshot::channel(); limiter @@ -811,7 +811,8 @@ pub async fn export_entities( // id-only file (which would mask the attempt). Narrowing a partially-valid // request is still allowed — only a wholly-non-exportable request is denied. if let Some(requested) = body.fields.as_deref() { - if !requested.is_empty() && resolve_export_columns(&schema_def, Some(requested)).is_empty() { + if !requested.is_empty() && resolve_export_columns(&schema_def, Some(requested)).is_empty() + { audit_export( &state, "forge.export.denied", @@ -858,7 +859,8 @@ pub async fn export_entities( // The effective row cap is the schema's `@export(max_rows)` intersected with // the server-wide ceiling: a schema may declare a tighter cap, never one // above what the operator permits (fail-closed). - let max_rows = crate::export_config::resolve_max_rows(max_rows, export_settings.default_max_rows); + let max_rows = + crate::export_config::resolve_max_rows(max_rows, export_settings.default_max_rows); // Initiated: the request passed the entity-level and authz gates. Record // the requested filter/fields/format for the exfiltration trail. @@ -945,9 +947,7 @@ pub async fn export_entities( .await; return Err(ForgeError::ExportTooLarge { max_rows, - message: format!( - "{message}; use the async job endpoint (POST with async:true)" - ), + message: format!("{message}; use the async job endpoint (POST with async:true)"), }); } Err(e) => return Err(e), @@ -1396,11 +1396,7 @@ mod tests { for (k, v) in fields { map.insert((*k).to_string(), DynamicValue::Text((*v).to_string())); } - Entity::with_id( - EntityId::new(id), - SchemaName::new("Subject").unwrap(), - map, - ) + Entity::with_id(EntityId::new(id), SchemaName::new("Subject").unwrap(), map) } #[test] @@ -1455,8 +1451,14 @@ mod tests { let schema = export_schema(); let cols = resolve_export_columns(&schema, None); let rows = vec![ - row("a", &[("name", "Ada"), ("ssn", "111-22-3333"), ("notes", "vip")]), - row("b", &[("name", "Bob"), ("ssn", "999-88-7777"), ("notes", "x,y")]), + row( + "a", + &[("name", "Ada"), ("ssn", "111-22-3333"), ("notes", "vip")], + ), + row( + "b", + &[("name", "Bob"), ("ssn", "999-88-7777"), ("notes", "x,y")], + ), ]; let bytes = entities_to_xlsx(&rows, &cols, &HashMap::new()).unwrap(); @@ -1582,8 +1584,14 @@ mod tests { let schema = export_schema(); let cols = resolve_export_columns(&schema, None); let rows = vec![ - row("a", &[("name", "Ada"), ("ssn", "111-22-3333"), ("notes", "vip")]), - row("b", &[("name", "Bob"), ("ssn", "999-88-7777"), ("notes", "x,y")]), + row( + "a", + &[("name", "Ada"), ("ssn", "111-22-3333"), ("notes", "vip")], + ), + row( + "b", + &[("name", "Bob"), ("ssn", "999-88-7777"), ("notes", "x,y")], + ), ]; let csv = entities_to_csv(&rows, &cols, &HashMap::new()).unwrap(); let mut lines = csv.lines(); diff --git a/crates/schema-forge-acton/src/routes/schemas.rs b/crates/schema-forge-acton/src/routes/schemas.rs index 69a641a9..935a5481 100644 --- a/crates/schema-forge-acton/src/routes/schemas.rs +++ b/crates/schema-forge-acton/src/routes/schemas.rs @@ -23,9 +23,7 @@ use crate::access::{ use crate::actor::ForgeActor; use crate::config::SchemaForgeConfig; use crate::error::ForgeError; -use crate::messages::{ - ApplyPreparedSchemaChange, GetSchema, ListSchemas, ReplyChannel, -}; +use crate::messages::{ApplyPreparedSchemaChange, GetSchema, ListSchemas, ReplyChannel}; // --------------------------------------------------------------------------- // Actor request helper @@ -56,7 +54,10 @@ async fn pair_with_registry( }) .await; let mut batch = ask_forge(rx).await?; - let registry = batch.iter().map(|schema| (schema.name.to_string(), schema.clone())).collect(); + let registry = batch + .iter() + .map(|schema| (schema.name.to_string(), schema.clone())) + .collect(); // Replace any existing entry with the same name so we pair against // the incoming definition — not the stale one. @@ -114,10 +115,13 @@ async fn precheck_policy_bundle( }) .await; let mut proposed = ask_forge(rx).await?; - let expected_registry: std::collections::HashMap<_, _> = proposed.iter() - .map(|schema| (schema.name.to_string(), schema.clone())).collect(); + let expected_registry: std::collections::HashMap<_, _> = proposed + .iter() + .map(|schema| (schema.name.to_string(), schema.clone())) + .collect(); if expected_registry.get(target.name.as_str()) != expected_target - || paired_registry.is_some_and(|registry| registry != &expected_registry) { + || paired_registry.is_some_and(|registry| registry != &expected_registry) + { return Err(ForgeError::Conflict { reason: "schema_preflight_stale", message: "schema changed while preparing the update; retry the schema change".into(), @@ -140,9 +144,11 @@ async fn precheck_policy_bundle( let principal_claims = snapshot.principal_claims.clone(); let (tx, rx) = oneshot::channel(); - forge.send(crate::messages::GetCustomPoliciesDir { - reply: ReplyChannel::new(tx), - }).await; + forge + .send(crate::messages::GetCustomPoliciesDir { + reply: ReplyChannel::new(tx), + }) + .await; let custom_dir = ask_forge(rx).await?; let next_policy = crate::authz::store::PolicyStoreSnapshot::from_schemas( @@ -157,7 +163,11 @@ async fn precheck_policy_bundle( )], })?; - Ok(PreparedSchemaPolicies { expected_registry, expected_policy: snapshot, next_policy: std::sync::Arc::new(next_policy) }) + Ok(PreparedSchemaPolicies { + expected_registry, + expected_policy: snapshot, + next_policy: std::sync::Arc::new(next_policy), + }) } /// Reuse canonical DSL validation for annotations supplied through the JSON schema API. @@ -224,12 +234,17 @@ async fn apply_prepared_schema_change( steps: Vec, ) -> Result<(), ForgeError> { let (tx, rx) = oneshot::channel(); - forge.send(ApplyPreparedSchemaChange { - expected_registry: prepared.expected_registry, - expected_policy: prepared.expected_policy, - next_policy: prepared.next_policy, - definition, remove, steps, reply: ReplyChannel::new(tx), - }).await; + forge + .send(ApplyPreparedSchemaChange { + expected_registry: prepared.expected_registry, + expected_policy: prepared.expected_policy, + next_policy: prepared.next_policy, + definition, + remove, + steps, + reply: ReplyChannel::new(tx), + }) + .await; ask_forge(rx).await? } @@ -665,7 +680,15 @@ pub async fn create_schema( // 4b. Pre-validate the proposed Cedar bundle BEFORE running any DB // migration. The actor commits this immutable bundle with the storage // change; no policy files are read after DDL. - let prepared = precheck_policy_bundle(&state, &forge, &definition, false, None, Some(&paired_registry)).await?; + let prepared = precheck_policy_bundle( + &state, + &forge, + &definition, + false, + None, + Some(&paired_registry), + ) + .await?; // 5. Generate migration plan let plan = DiffEngine::create_new(&definition); @@ -873,7 +896,15 @@ pub async fn update_schema( // 4b. Dry-run the Cedar bundle for the proposed registry state so an // invalid schema fails fast — before any DB migration. - let prepared = precheck_policy_bundle(&state, &forge, &new_definition, false, Some(&old_schema), Some(&paired_registry)).await?; + let prepared = precheck_policy_bundle( + &state, + &forge, + &new_definition, + false, + Some(&old_schema), + Some(&paired_registry), + ) + .await?; // 5. Compute diff and generate migration plan let plan = DiffEngine::plan_update(&old_schema, &new_definition).map_err(|error| { @@ -886,7 +917,8 @@ pub async fn update_schema( } let step_count = plan.steps.len(); - apply_prepared_schema_change(&forge, prepared, new_definition.clone(), false, plan.steps).await?; + apply_prepared_schema_change(&forge, prepared, new_definition.clone(), false, plan.steps) + .await?; // 9. Rebuild GraphQL schema // NOTE: GraphQL rebuild will be re-integrated when the graphql module @@ -951,7 +983,8 @@ pub async fn delete_schema( .await? .ok_or(ForgeError::SchemaNotFound { name: name.clone() })?; - let prepared = precheck_policy_bundle(&state, &forge, &schema, true, Some(&schema), None).await?; + let prepared = + precheck_policy_bundle(&state, &forge, &schema, true, Some(&schema), None).await?; apply_prepared_schema_change(&forge, prepared, schema, true, vec![]).await?; diff --git a/crates/schema-forge-cli/src/commands/mod.rs b/crates/schema-forge-cli/src/commands/mod.rs index 19015436..d435718e 100644 --- a/crates/schema-forge-cli/src/commands/mod.rs +++ b/crates/schema-forge-cli/src/commands/mod.rs @@ -11,8 +11,8 @@ pub mod login; pub mod migrate; pub mod parse; pub mod policies; -mod schema_update; mod policy_preflight; +mod schema_update; pub mod serve; #[cfg(feature = "embedded-console")] pub mod serve_console; diff --git a/crates/schema-forge-cli/src/commands/parse.rs b/crates/schema-forge-cli/src/commands/parse.rs index 3677436d..7463a981 100644 --- a/crates/schema-forge-cli/src/commands/parse.rs +++ b/crates/schema-forge-cli/src/commands/parse.rs @@ -242,8 +242,7 @@ fn manifest_root_for(input_paths: &[PathBuf], files: &[PathBuf]) -> Result Date: Thu, 24 Sep 2026 17:44:57 -0600 Subject: [PATCH 70/77] refactor: use native SurrealDB value conversions directly --- crates/schema-forge-surrealdb/src/backend.rs | 2 +- crates/schema-forge-surrealdb/src/value.rs | 27 ++++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/crates/schema-forge-surrealdb/src/backend.rs b/crates/schema-forge-surrealdb/src/backend.rs index 05dce826..ecaf22d6 100644 --- a/crates/schema-forge-surrealdb/src/backend.rs +++ b/crates/schema-forge-surrealdb/src/backend.rs @@ -885,7 +885,7 @@ fn field_surreal_value_to_literal(value: &surrealdb::types::Value) -> String { } } surrealdb::types::Value::Datetime(dt) => { - format!("d'{}'", dt.clone().into_inner().to_rfc3339()) + format!("d'{}'", (*dt).into_inner().to_rfc3339()) } // Duration literals are bare in SurrealQL (e.g. `2w3d`); the Display impl // produces a parseable form. diff --git a/crates/schema-forge-surrealdb/src/value.rs b/crates/schema-forge-surrealdb/src/value.rs index 1102cc87..4283bebe 100644 --- a/crates/schema-forge-surrealdb/src/value.rs +++ b/crates/schema-forge-surrealdb/src/value.rs @@ -28,14 +28,14 @@ pub(crate) fn record_key_string(key: &surrealdb::types::RecordIdKey) -> String { pub fn dynamic_to_surreal(value: &DynamicValue) -> SurrealValue { match value { DynamicValue::Null => SurrealValue::None, - DynamicValue::Text(s) => (s.as_str()).into_value(), + DynamicValue::Text(s) => s.as_str().into_value(), DynamicValue::Integer(i) => (*i).into_value(), DynamicValue::Float(f) => (*f).into_value(), DynamicValue::Boolean(b) => (*b).into_value(), DynamicValue::DateTime(dt) => { // Store as ISO 8601 string — the literal serializer in backend.rs // will wrap it with d'...' for SurrealQL datetime fields. - (dt.to_rfc3339()).into_value() + dt.to_rfc3339().into_value() } DynamicValue::Duration(d) => { timedelta_to_surreal_duration(d).map_or(SurrealValue::None, SurrealValue::Duration) @@ -45,11 +45,11 @@ pub fn dynamic_to_surreal(value: &DynamicValue) -> SurrealValue { // bytes verbatim. SurrealValue::Bytes(surrealdb::types::Bytes::from(b.clone())) } - DynamicValue::Enum(s) => (s.as_str()).into_value(), + DynamicValue::Enum(s) => s.as_str().into_value(), DynamicValue::Json(v) => json_to_surreal(v), DynamicValue::Array(arr) => { let items: Vec = arr.iter().map(dynamic_to_surreal).collect(); - (items).into_value() + items.into_value() } DynamicValue::Composite(map) | DynamicValue::Map(map) => { // A fixed-field `Composite` and a typed open-key `Map` are both @@ -60,15 +60,14 @@ pub fn dynamic_to_surreal(value: &DynamicValue) -> SurrealValue { } SurrealValue::Object(obj) } - DynamicValue::Ref(id) => (id.as_str()).into_value(), + DynamicValue::Ref(id) => id.as_str().into_value(), DynamicValue::RefArray(ids) => { - let items: Vec = - ids.iter().map(|id| (id.as_str()).into_value()).collect(); - (items).into_value() + let items: Vec = ids.iter().map(|id| id.as_str().into_value()).collect(); + items.into_value() } _ => { // Future DynamicValue variants -- store as string fallback. - (format!("{value:?}").as_str()).into_value() + format!("{value:?}").into_value() } } } @@ -94,7 +93,7 @@ pub fn surreal_to_dynamic(value: &SurrealValue) -> Result Ok(DynamicValue::Text(s.clone())), SurrealValue::Datetime(dt) => { - let chrono_dt: chrono::DateTime = dt.clone().into_inner(); + let chrono_dt: chrono::DateTime = (*dt).into_inner(); Ok(DynamicValue::DateTime(chrono_dt)) } SurrealValue::Duration(dur) => { @@ -143,7 +142,7 @@ pub fn surreal_to_dynamic(value: &SurrealValue) -> Result BTreeMap { let mut map = BTreeMap::new(); - map.insert("id".to_string(), (entity.id.as_str()).into_value()); + map.insert("id".to_string(), entity.id.as_str().into_value()); for (k, v) in &entity.fields { map.insert(k.clone(), dynamic_to_surreal(v)); } @@ -275,13 +274,13 @@ fn json_to_surreal(json: &serde_json::Value) -> SurrealValue { } else if let Some(f) = n.as_f64() { (f).into_value() } else { - (n.to_string().as_str()).into_value() + n.to_string().into_value() } } - serde_json::Value::String(s) => (s.as_str()).into_value(), + serde_json::Value::String(s) => s.as_str().into_value(), serde_json::Value::Array(arr) => { let items: Vec = arr.iter().map(json_to_surreal).collect(); - (items).into_value() + items.into_value() } serde_json::Value::Object(map) => { let mut obj = surrealdb::types::Object::new(); From 7ce1eb1dc8d5047ff67de15dffdac406ae2c4f96 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:47:18 -0600 Subject: [PATCH 71/77] fix: validate remote SurrealDB version before session setup --- crates/schema-forge-surrealdb/src/backend.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/schema-forge-surrealdb/src/backend.rs b/crates/schema-forge-surrealdb/src/backend.rs index ecaf22d6..8a40dd4f 100644 --- a/crates/schema-forge-surrealdb/src/backend.rs +++ b/crates/schema-forge-surrealdb/src/backend.rs @@ -142,6 +142,10 @@ impl SurrealBackend { } })?; + let backend = Self::from_client(db); + backend.ensure_supported_version().await?; + let db = backend.client(); + if let (Some(user), Some(pass)) = (username, password) { db.signin(surrealdb::opt::auth::Root { username: user.to_owned(), @@ -160,8 +164,6 @@ impl SurrealBackend { message: format!("failed to select namespace/database: {e}"), })?; - let backend = Self::from_client(db); - backend.ensure_supported_version().await?; Ok(backend) } From 797ef37ad2e25716d9fc395ede3e8cba4e37f303 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:53:44 -0600 Subject: [PATCH 72/77] fix: read absent SurrealDB metadata as an empty registry --- crates/schema-forge-surrealdb/src/backend.rs | 32 ++++++++++++++----- .../tests/migration_renames.rs | 28 ++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/crates/schema-forge-surrealdb/src/backend.rs b/crates/schema-forge-surrealdb/src/backend.rs index 8a40dd4f..b88df96e 100644 --- a/crates/schema-forge-surrealdb/src/backend.rs +++ b/crates/schema-forge-surrealdb/src/backend.rs @@ -511,10 +511,7 @@ impl SchemaBackend for SurrealBackend { let sql = format!("SELECT definition FROM {SCHEMA_META_TABLE}:`{name_str}`;"); let mut response = self.execute_raw(&sql).await?; - let rows: Vec = - response.take(0).map_err(|e| BackendError::QueryError { - message: e.to_string(), - })?; + let rows = take_metadata_rows(&mut response)?; if rows.is_empty() { return Ok(None); @@ -535,10 +532,7 @@ impl SchemaBackend for SurrealBackend { let sql = format!("SELECT definition FROM {SCHEMA_META_TABLE};"); let mut response = self.execute_raw(&sql).await?; - let rows: Vec = - response.take(0).map_err(|e| BackendError::QueryError { - message: e.to_string(), - })?; + let rows = take_metadata_rows(&mut response)?; let mut definitions = Vec::new(); for row in &rows { @@ -556,6 +550,28 @@ impl SchemaBackend for SurrealBackend { } } +/// An absent metadata table represents a fresh database. Other missing resources +/// and malformed metadata remain errors; reads never initialize database state. +fn take_metadata_rows( + response: &mut surrealdb::IndexedResults, +) -> Result, BackendError> { + match response.take(0) { + Ok(rows) => Ok(rows), + Err(error) + if matches!( + error.not_found_details(), + Some(surrealdb::types::NotFoundError::Table { name }) + if name == SCHEMA_META_TABLE + ) => + { + Ok(Vec::new()) + } + Err(error) => Err(BackendError::QueryError { + message: error.to_string(), + }), + } +} + /// Parse a stored schema metadata JSON blob and migrate any legacy /// `@widget("...")` annotations on the fly. Removed widget tokens (e.g. /// `currency`, `relative_time`) are dropped from the field's annotation list diff --git a/crates/schema-forge-surrealdb/tests/migration_renames.rs b/crates/schema-forge-surrealdb/tests/migration_renames.rs index c3a6b06f..f13af143 100644 --- a/crates/schema-forge-surrealdb/tests/migration_renames.rs +++ b/crates/schema-forge-surrealdb/tests/migration_renames.rs @@ -57,3 +57,31 @@ async fn metadata_failure_rolls_back_destructive_schema_steps() { ); assert_eq!(backend.get(&original.name, &row.id).await.unwrap(), row); } + +#[tokio::test] +async fn fresh_metadata_reads_are_empty_without_creating_tables() { + use schema_forge_backend::SchemaBackend; + use schema_forge_core::types::SchemaName; + let backend = schema_forge_surrealdb::SurrealBackend::connect_memory("fresh", "fresh") + .await + .unwrap(); + assert!(backend.list_schema_metadata().await.unwrap().is_empty()); + assert!(backend + .load_schema_metadata(&SchemaName::new("Missing").unwrap()) + .await + .unwrap() + .is_none()); + for table in ["_schema_metadata", "Missing"] { + let error = backend + .client() + .query(format!("SELECT * FROM {table};")) + .await + .unwrap() + .check() + .unwrap_err(); + assert!(matches!( + error.not_found_details(), + Some(surrealdb::types::NotFoundError::Table { name }) if name == table + )); + } +} From c9e1b86be7bd0949b8de945747a346cc2b9318fd Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 17:56:26 -0600 Subject: [PATCH 73/77] fix: emit SurrealDB 3.3 flexible field syntax --- crates/schema-forge-surrealdb/src/codegen.rs | 28 ++++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/schema-forge-surrealdb/src/codegen.rs b/crates/schema-forge-surrealdb/src/codegen.rs index 94e75ad4..745165fa 100644 --- a/crates/schema-forge-surrealdb/src/codegen.rs +++ b/crates/schema-forge-surrealdb/src/codegen.rs @@ -56,13 +56,13 @@ pub fn migration_step_to_surql(table: &str, step: &MigrationStep) -> Vec } => { let surql_type = field_type_to_surql(new_type); let assertions = field_assertions(new_type); - let flex_prefix = if needs_flexible(new_type) { - "FLEXIBLE " + let flex_suffix = if needs_flexible(new_type) { + " FLEXIBLE" } else { "" }; let mut stmt = - format!("DEFINE FIELD OVERWRITE {name} ON {table} {flex_prefix}TYPE {surql_type}"); + format!("DEFINE FIELD OVERWRITE {name} ON {table} TYPE {surql_type}{flex_suffix}"); if !assertions.is_empty() { stmt.push_str(&format!(" ASSERT {}", assertions.join(" AND "))); } @@ -300,12 +300,12 @@ pub(crate) fn rename_field_stmts( format!("option<{original_type}>") }; let flexible = if needs_flexible(&source.field_type) { - "FLEXIBLE " + " FLEXIBLE" } else { "" }; statements.push(format!( - "DEFINE FIELD OVERWRITE {old_name} ON {table} {flexible}TYPE {optional_type};" + "DEFINE FIELD OVERWRITE {old_name} ON {table} TYPE {optional_type}{flexible};" )); statements.push(format!("UPDATE {table} UNSET {old_name};")); if source.is_unique() { @@ -329,8 +329,8 @@ pub(crate) fn define_field_stmts(table: &str, field: &FieldDefinition) -> Vec Vec Vec;" + "DEFINE FIELD metadata ON Employee TYPE option FLEXIBLE;" ); } @@ -893,7 +893,7 @@ mod tests { assert_eq!(stmts.len(), 1); assert_eq!( stmts[0], - "DEFINE FIELD config ON Workflow FLEXIBLE TYPE object ASSERT $value != NONE;" + "DEFINE FIELD config ON Workflow TYPE object FLEXIBLE ASSERT $value != NONE;" ); } @@ -922,7 +922,7 @@ mod tests { // object (and any extra keys — though only declared sub-fields // are enforced). assert!( - stmts[0].contains("FLEXIBLE TYPE option"), + stmts[0].contains("TYPE option FLEXIBLE"), "parent: {}", stmts[0] ); @@ -958,7 +958,7 @@ mod tests { assert_eq!(stmts.len(), 1); assert_eq!( stmts[0], - "DEFINE FIELD OVERWRITE metadata ON Employee FLEXIBLE TYPE object;" + "DEFINE FIELD OVERWRITE metadata ON Employee TYPE object FLEXIBLE;" ); } From 55b177a548648d1eea8dba16933180fde98a6b05 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 18:10:49 -0600 Subject: [PATCH 74/77] fix: bind metadata writes and verify SurrealDB statement results --- crates/schema-forge-surrealdb/src/backend.rs | 42 ++++++++++++------ .../tests/migration_renames.rs | 43 +++++++++++++++++++ 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/crates/schema-forge-surrealdb/src/backend.rs b/crates/schema-forge-surrealdb/src/backend.rs index b88df96e..6dd5d421 100644 --- a/crates/schema-forge-surrealdb/src/backend.rs +++ b/crates/schema-forge-surrealdb/src/backend.rs @@ -495,11 +495,20 @@ impl SchemaBackend for SurrealBackend { })?; let name = definition.name.as_str(); - let sql = format!( - "UPSERT {SCHEMA_META_TABLE}:`{name}` CONTENT {{ name: '{name}', definition: '{json_escaped}' }};", - json_escaped = json.replace('\'', "\\'") - ); - self.execute_raw(&sql).await?; + self.db + .query(format!( + "UPSERT {SCHEMA_META_TABLE}:`{name}` CONTENT {{ name: $schema_name, definition: $schema_definition }};" + )) + .bind(("schema_name", name.to_owned())) + .bind(("schema_definition", json)) + .await + .map_err(|error| BackendError::QueryError { + message: error.to_string(), + })? + .check() + .map_err(|error| BackendError::QueryError { + message: error.to_string(), + })?; Ok(()) } @@ -1017,14 +1026,21 @@ mod tests { assert_eq!(field_surreal_value_to_literal(&val), "1h"); } - #[test] - fn bytes_literal_is_base64_decode_call() { - let val = surrealdb::types::Value::Bytes(surrealdb::types::Bytes::from(b"hello".to_vec())); - // Parseable SurrealQL: decodes back to the same bytes. - assert_eq!( - field_surreal_value_to_literal(&val), - "encoding::base64::decode(\"aGVsbG8\")" - ); + #[tokio::test] + async fn bytes_literal_round_trips_through_surrealql() { + let backend = SurrealBackend::connect_memory("bytes", "bytes") + .await + .unwrap(); + for bytes in [vec![], b"hello".to_vec(), vec![0, 255, 128, 34, 39, 92]] { + let value = surrealdb::types::Value::Bytes(surrealdb::types::Bytes::from(bytes)); + let literal = field_surreal_value_to_literal(&value); + let mut response = backend + .execute_raw(&format!("RETURN {literal};")) + .await + .unwrap(); + let decoded: surrealdb::types::Value = response.take(0).unwrap(); + assert_eq!(decoded, value); + } } #[tokio::test] diff --git a/crates/schema-forge-surrealdb/tests/migration_renames.rs b/crates/schema-forge-surrealdb/tests/migration_renames.rs index f13af143..4ec6be5c 100644 --- a/crates/schema-forge-surrealdb/tests/migration_renames.rs +++ b/crates/schema-forge-surrealdb/tests/migration_renames.rs @@ -85,3 +85,46 @@ async fn fresh_metadata_reads_are_empty_without_creating_tables() { )); } } + +#[tokio::test] +async fn metadata_write_checks_statement_failures() { + use schema_forge_backend::SchemaBackend; + use schema_forge_core::types::*; + let backend = schema_forge_surrealdb::SurrealBackend::connect_memory("metadata", "metadata") + .await + .unwrap(); + let mut schema = SchemaDefinition::new( + SchemaId::new(), + SchemaName::new("MetadataProbe").unwrap(), + vec![FieldDefinition::new( + FieldName::new("label").unwrap(), + FieldType::Text(TextConstraints::unconstrained()), + )], + vec![], + ) + .unwrap(); + schema.fields[0].annotations.push(FieldAnnotation::Require { + expr: "true".into(), + message: "Apostrophe ' and quote \" and backslash \\ and newline\nretained".into(), + }); + backend.store_schema_metadata(&schema).await.unwrap(); + assert_eq!( + backend.load_schema_metadata(&schema.name).await.unwrap(), + Some(schema.clone()) + ); + backend + .client() + .query("DEFINE FIELD definition ON _schema_metadata TYPE string ASSERT $value = $original;") + .bind(("original", serde_json::to_string(&schema).unwrap())) + .await + .unwrap() + .check() + .unwrap(); + let mut changed = schema.clone(); + changed.fields[0].name = FieldName::new("changed").unwrap(); + assert!(backend.store_schema_metadata(&changed).await.is_err()); + assert_eq!( + backend.load_schema_metadata(&schema.name).await.unwrap(), + Some(schema) + ); +} From a5c77c55905b79374992b5d183fb602b62e10761 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 18:27:37 -0600 Subject: [PATCH 75/77] fix: require concrete Cedar authorization for canonical mutations --- .../schema-forge-acton/src/routes/entities.rs | 34 +++++++++++++++++++ .../tests/graphql_tenants.rs | 22 ++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index 80669cab..3f4f7793 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -831,6 +831,26 @@ fn enforce_bytes_max_size(bytes: &[u8], max_size: Option) -> Result<(), S } } +/// Concrete Cedar authorization is mandatory even when an operator supplies +/// an additional record policy. Transport middleware is defense in depth. +fn require_entity_action( + store: &Arc, + schema: &SchemaDefinition, + claims: Option<&Claims>, + entity: &Entity, + action: ActionVerb, +) -> Result<(), ForgeError> { + let permitted = authorize(store, claims, action, schema, Some(entity)) + .is_ok_and(|decision| decision.is_allow() && decision.errors.is_empty()); + if permitted { + Ok(()) + } else { + Err(ForgeError::Forbidden { + message: "not authorized for this entity".into(), + }) + } +} + /// Reevaluate retained input against the resource after every denied-field removal. /// Each pass removes keys, so authorization reaches a stable result in finite time. fn filter_update_input( @@ -2602,6 +2622,12 @@ pub async fn create_entity( apply_computed(&schema_def, &mut fields, claims.as_ref(), rules_now) .map_err(rule_error_to_forge)?; validate_required_fields(&schema_def, &fields)?; + require_entity_action( + &policy_store, &schema_def, claims.as_ref(), + &Entity::with_id(supplied.id.clone(), schema_name.clone(), fields.clone()), + ActionVerb::Create, + )?; + // CEL @require validation rules (#92) — fail-closed, in-transaction, // pre-persistence. Cross-entity reads (#95) are resolved here: any @@ -2661,6 +2687,8 @@ pub async fn create_entity( let entity = Entity::with_id(supplied.id, schema_name, fields); validate_required_fields(&schema_def, &entity.fields)?; check_field_constraints(&schema_def, &entity.fields)?; + require_entity_action(&policy_store, &schema_def, claims.as_ref(), &entity, ActionVerb::Create)?; + if let Some(intent) = intent { let changed_fields: Vec<_> = entity.fields.keys().cloned().collect(); @@ -3329,6 +3357,8 @@ pub async fn update_entity( conditional_requested(&headers), ) .await?; + require_entity_action(&policy_store, &schema_def, claims.as_ref(), &existing, ActionVerb::Update)?; + // Record-level ownership check: fetch existing entity and verify ownership let (tx, rx) = oneshot::channel(); @@ -3654,6 +3684,8 @@ pub async fn patch_entity( conditional_requested(&headers), ) .await?; + require_entity_action(&policy_store, &schema_def, claims.as_ref(), &existing, ActionVerb::Update)?; + // Record-level ownership check let (tx, rx) = oneshot::channel(); @@ -3963,6 +3995,8 @@ pub async fn delete_entity( conditional_requested(&headers), ) .await?; + require_entity_action(&policy_store, &schema_def, claims.as_ref(), &existing, ActionVerb::Delete)?; + // Record-level ownership check: fetch entity first and verify ownership let (tx, rx) = oneshot::channel(); diff --git a/crates/schema-forge-acton/tests/graphql_tenants.rs b/crates/schema-forge-acton/tests/graphql_tenants.rs index 7deff80b..43e985ca 100644 --- a/crates/schema-forge-acton/tests/graphql_tenants.rs +++ b/crates/schema-forge-acton/tests/graphql_tenants.rs @@ -190,6 +190,23 @@ async fn graphql_scopes_legacy_roots_and_keeps_shared_catalog_usable() { catalog["data"]["catalogs"]["items"][0]["name"], "shared", "{catalog}" ); + // These routes intentionally have no tenant middleware. A permissive + // operator policy must never replace concrete Cedar tenant authorization. + for method in [Method::PUT, Method::PATCH, Method::DELETE] { + let mut request = Request::builder() + .method(method) + .uri(format!("/schemas/Org/entities/{foreign}")) + .header("content-type", "application/json") + .body(Body::from( + json!({"fields":{"name":"foreign overwrite"}}).to_string(), + )) + .unwrap(); + request + .extensions_mut() + .insert(test_claims(&own, &["member"])); + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN); + } let deleted = query( &app, &format!("mutation {{ deleteOrg(id: \"{own}\") }}"), @@ -199,11 +216,12 @@ async fn graphql_scopes_legacy_roots_and_keeps_shared_catalog_usable() { assert_eq!(deleted["data"]["deleteOrg"], true, "{deleted}"); let remaining = query( &app, - &format!("{{ org(id: \"{foreign}\") {{ id }} }}"), + &format!("{{ org(id: \"{foreign}\") {{ id name }} }}"), &foreign, ) .await; assert_eq!(remaining["data"]["org"]["id"], foreign, "{remaining}"); + assert_eq!(remaining["data"]["org"]["name"], "b", "{remaining}"); } async fn administer_catalog(app: &Router, method: Method, body: Value) { @@ -243,7 +261,7 @@ async fn graphql_uses_live_field_security_and_refuses_removed_schemas() { let fields = json!([ {"name":"name", "field_type":"Text", "modifiers":["required"]}, {"name":"secret", "field_type":"Text", "annotations":[{"annotation":"FieldAccess", "read":["admin"], "write":["admin"]}]}, - {"name":"hidden_value", "field_type":"Text", "modifiers":["hidden"]} + {"name":"hidden_value", "field_type":"Text", "annotations":[{"annotation":"Hidden"}]} ]); administer_catalog( &app, From 146bcc8dfa1da5784be21941d172282f205c412c Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 18:27:45 -0600 Subject: [PATCH 76/77] fix: preserve to-many relation values when decoding SurrealDB records --- crates/schema-forge-surrealdb/src/value.rs | 51 ++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/schema-forge-surrealdb/src/value.rs b/crates/schema-forge-surrealdb/src/value.rs index 4283bebe..554441f8 100644 --- a/crates/schema-forge-surrealdb/src/value.rs +++ b/crates/schema-forge-surrealdb/src/value.rs @@ -107,9 +107,23 @@ pub fn surreal_to_dynamic(value: &SurrealValue) -> Result Ok(DynamicValue::Bytes(b.to_vec())), SurrealValue::Array(arr) => { - let items: Result, BackendError> = - arr.iter().map(surreal_to_dynamic).collect(); - Ok(DynamicValue::Array(items?)) + let items: Vec = arr + .iter() + .map(surreal_to_dynamic) + .collect::>()?; + // Native record-reference arrays are the persisted representation + // of to-many relations. Preserve that distinction for consumers. + let references: Option> = items + .iter() + .map(|item| match item { + DynamicValue::Ref(id) => Some(id.clone()), + _ => None, + }) + .collect(); + match references { + Some(ids) if !ids.is_empty() => Ok(DynamicValue::RefArray(ids)), + _ => Ok(DynamicValue::Array(items)), + } } SurrealValue::Object(obj) => { let mut map = BTreeMap::new(); @@ -461,6 +475,37 @@ mod tests { ); } + #[test] + fn record_arrays_preserve_relation_values() { + use surrealdb::types::{RecordId, RecordIdKey}; + let first = EntityId::new("target"); + let second = EntityId::new("target"); + let record = |id: &EntityId| { + SurrealValue::RecordId(RecordId::new( + "Target", + RecordIdKey::String(id.as_str().into()), + )) + }; + let records = vec![record(&first), record(&second)].into_value(); + assert_eq!( + surreal_to_dynamic(&records).unwrap(), + DynamicValue::RefArray(vec![first.clone(), second]) + ); + let mixed = vec![record(&first), "ordinary text".into_value()].into_value(); + assert_eq!( + surreal_to_dynamic(&mixed).unwrap(), + DynamicValue::Array(vec![ + DynamicValue::Ref(first), + DynamicValue::Text("ordinary text".into()), + ]) + ); + let empty = Vec::::new().into_value(); + assert_eq!( + surreal_to_dynamic(&empty).unwrap(), + DynamicValue::Array(vec![]) + ); + } + #[test] fn array_round_trip() { let dv = DynamicValue::Array(vec![DynamicValue::Integer(1), DynamicValue::Integer(2)]); From efe2fed6ec3f7934ac5f136b5e379cc1887b860d Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Thu, 24 Sep 2026 18:28:43 -0600 Subject: [PATCH 77/77] docs: clarify canonical authorization and relation guarantees --- CHANGELOG.md | 7 +++- .../schema-forge-acton/src/routes/entities.rs | 41 ++++++++++++++----- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f66eec6..a18fc5b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,10 +28,15 @@ is pre-1.0; breaking changes bump the **minor** version per - SQL Server updates merge supplied fields atomically, preserving omitted fields and explicit nulls. Concurrent updates to different fields no longer replace each other's stored values. +- Canonical mutation handlers enforce concrete Cedar authorization before + operator policies and hooks. An operator policy can further restrict access + but cannot bypass tenant or Cedar checks. - GraphQL creates, updates, and deletes share the REST mutation pipeline. GraphQL reads, relations, and deletes enforce concrete Cedar decisions. Each request captures matching live definitions and policies, so runtime field restrictions also - govern existing GraphQL fields. Unproven raw + govern existing GraphQL fields. Nested relations honor operator visibility + restrictions, and SurrealDB to-many relations retain their record references. + Unproven raw GraphQL totals are withheld instead of disclosing counts of inaccessible rows. - CLI help hides secret environment values, including database and server URLs. `serve --host` controls the actual listener and accepts validated IPv4/IPv6 diff --git a/crates/schema-forge-acton/src/routes/entities.rs b/crates/schema-forge-acton/src/routes/entities.rs index 3f4f7793..95f5ea1b 100644 --- a/crates/schema-forge-acton/src/routes/entities.rs +++ b/crates/schema-forge-acton/src/routes/entities.rs @@ -2623,12 +2623,13 @@ pub async fn create_entity( .map_err(rule_error_to_forge)?; validate_required_fields(&schema_def, &fields)?; require_entity_action( - &policy_store, &schema_def, claims.as_ref(), + &policy_store, + &schema_def, + claims.as_ref(), &Entity::with_id(supplied.id.clone(), schema_name.clone(), fields.clone()), ActionVerb::Create, )?; - // CEL @require validation rules (#92) — fail-closed, in-transaction, // pre-persistence. Cross-entity reads (#95) are resolved here: any // `related..` is dereferenced to its tenant-scoped related row and @@ -2687,8 +2688,13 @@ pub async fn create_entity( let entity = Entity::with_id(supplied.id, schema_name, fields); validate_required_fields(&schema_def, &entity.fields)?; check_field_constraints(&schema_def, &entity.fields)?; - require_entity_action(&policy_store, &schema_def, claims.as_ref(), &entity, ActionVerb::Create)?; - + require_entity_action( + &policy_store, + &schema_def, + claims.as_ref(), + &entity, + ActionVerb::Create, + )?; if let Some(intent) = intent { let changed_fields: Vec<_> = entity.fields.keys().cloned().collect(); @@ -3357,8 +3363,13 @@ pub async fn update_entity( conditional_requested(&headers), ) .await?; - require_entity_action(&policy_store, &schema_def, claims.as_ref(), &existing, ActionVerb::Update)?; - + require_entity_action( + &policy_store, + &schema_def, + claims.as_ref(), + &existing, + ActionVerb::Update, + )?; // Record-level ownership check: fetch existing entity and verify ownership let (tx, rx) = oneshot::channel(); @@ -3684,8 +3695,13 @@ pub async fn patch_entity( conditional_requested(&headers), ) .await?; - require_entity_action(&policy_store, &schema_def, claims.as_ref(), &existing, ActionVerb::Update)?; - + require_entity_action( + &policy_store, + &schema_def, + claims.as_ref(), + &existing, + ActionVerb::Update, + )?; // Record-level ownership check let (tx, rx) = oneshot::channel(); @@ -3995,8 +4011,13 @@ pub async fn delete_entity( conditional_requested(&headers), ) .await?; - require_entity_action(&policy_store, &schema_def, claims.as_ref(), &existing, ActionVerb::Delete)?; - + require_entity_action( + &policy_store, + &schema_def, + claims.as_ref(), + &existing, + ActionVerb::Delete, + )?; // Record-level ownership check: fetch entity first and verify ownership let (tx, rx) = oneshot::channel();