schema-lint v1 commit 2: DropMode + dormant Drop* variants

Second commit of the chassis v1 branch. Lands the type-level shape
of soft/hard drops without wiring them up. Variants are reachable
from emitters but the planner doesn't produce them yet; the apply
path returns an explicit not-yet-implemented error if one shows up
via deserialization.

Added:

- `DropMode { Soft, Hard }` — orthogonal to `SafetyTier`. Tier
  classifies the rule's risk class; mode is the operator's intent
  for data treatment.
    - `Soft` → catalog tombstone, data retained. Tier: safe.
    - `Hard` → Lance-level removal. Tier: destructive; will require
      --allow-data-loss to apply (commit 7).

- `SchemaMigrationStep::DropType { type_kind, name, mode }` and
  `SchemaMigrationStep::DropProperty { type_kind, type_name,
  property_name, mode }` variants.

- Re-export `DropMode` from `omnigraph_compiler::DropMode` so
  downstream crates don't reach into the catalog submodule.

- CLI `render_schema_plan_step` arms for both variants, surfacing
  the mode in plan output: `drop property 'Person.age' of node
  'Person' (soft mode)`.

- `apply_schema_with_lock` exhaustive match arm for the two new
  variants that returns `manifest_internal` with a clear
  not-yet-implemented message. If a SchemaIR JSON containing
  Drop{Type,Property} arrives (e.g. from a future tool or hand-
  written), the apply path fails explicitly rather than silently
  misclassifying.

- Two new in-source tests:
    - `drop_steps_round_trip_through_serde` — pins the wire shape
      for all four (variant × mode) combinations.
    - `drop_mode_serde_uses_snake_case` — pins external-tool-
      friendly serialization (`"soft"` / `"hard"`).

Build: clean, only pre-existing warnings.
Tests:
- omnigraph-compiler schema_plan: 6/6 (4 existing + 2 new).
- omnigraph-engine schema_apply: 11/11 (unchanged — planner still
  emits UnsupportedChange for removal paths).

Next commit (commit 3 per docs/schema-lint-v1-plan.md): add the
`tombstoned: bool` fields to NodeIR / EdgeIR / PropertyIR for the
catalog representation of soft-mode tombstones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
andrew 2026-05-13 18:10:14 +03:00
parent babacb41fd
commit 7ecbda6b4e
4 changed files with 139 additions and 1 deletions

View file

@ -1035,6 +1035,29 @@ fn render_schema_plan_step(step: &SchemaMigrationStep) -> String {
type_name,
render_annotations(annotations)
),
SchemaMigrationStep::DropType {
type_kind,
name,
mode,
} => format!(
"drop {} type '{}' ({} mode)",
schema_type_kind_label(*type_kind),
name,
drop_mode_label(*mode),
),
SchemaMigrationStep::DropProperty {
type_kind,
type_name,
property_name,
mode,
} => format!(
"drop property '{}.{}' of {} '{}' ({} mode)",
type_name,
property_name,
schema_type_kind_label(*type_kind),
type_name,
drop_mode_label(*mode),
),
SchemaMigrationStep::UnsupportedChange {
entity, reason, ..
} => {
@ -1073,6 +1096,13 @@ fn schema_lint_tier_label(tier: omnigraph_compiler::SafetyTier) -> &'static str
}
}
fn drop_mode_label(mode: omnigraph_compiler::DropMode) -> &'static str {
match mode {
omnigraph_compiler::DropMode::Soft => "soft",
omnigraph_compiler::DropMode::Hard => "hard",
}
}
fn render_prop_type(prop_type: &omnigraph_compiler::PropType) -> String {
let base = if let Some(values) = &prop_type.enum_values {
format!("Enum({})", values.join("|"))

View file

@ -16,6 +16,29 @@ pub enum SchemaTypeKind {
Edge,
}
/// How a drop step interacts with data.
///
/// - **`Soft`** — catalog tombstone only. The type / property is hidden
/// from queries but the underlying Lance column / dataset is retained
/// on disk. Reversible via `omnigraph schema unhide` (forthcoming).
/// Tier: `safe`.
/// - **`Hard`** — actual data removal. The Lance column is rewritten
/// without the property, or the Lance dataset is dropped. Irreversible
/// short of branch / snapshot restore. Tier: `destructive`; requires
/// `--allow-data-loss` to apply.
///
/// The planner emits `Soft` by default; `--allow-data-loss` on the apply
/// CLI promotes drops to `Hard`. This is the dimension orthogonal to
/// `SafetyTier` from the schema-lint chassis (`crate::lint`): tier
/// describes the rule's class; mode describes the operator's intent for
/// data treatment.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DropMode {
Soft,
Hard,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SchemaMigrationPlan {
pub supported: bool,
@ -62,6 +85,28 @@ pub enum SchemaMigrationStep {
property_name: String,
annotations: Vec<Annotation>,
},
/// Remove a node or edge type. Soft mode tombstones in the catalog
/// and retains data on disk; Hard mode drops the Lance dataset and
/// requires `--allow-data-loss`.
///
/// Dormant in this commit — emitted by the planner in a later
/// commit (see `docs/schema-lint-v1-plan.md`).
DropType {
type_kind: SchemaTypeKind,
name: String,
mode: DropMode,
},
/// Remove a property from an existing type. Soft mode tombstones
/// the property in the catalog and retains the Lance column; Hard
/// mode rewrites the column out and requires `--allow-data-loss`.
///
/// Dormant in this commit.
DropProperty {
type_kind: SchemaTypeKind,
type_name: String,
property_name: String,
mode: DropMode,
},
UnsupportedChange {
entity: String,
reason: String,
@ -953,4 +998,56 @@ node Person @description("new") {
}],
}));
}
#[test]
fn drop_steps_round_trip_through_serde() {
// The DropType / DropProperty variants are dormant in this
// commit — the planner doesn't emit them yet — but their
// serde shape needs to be stable from day one. A future
// SchemaIR JSON containing one of these must deserialize
// back to the same value. This test pins the wire format
// so a v0 schema-ir consumer never sees a surprise variant
// shape after v1 ships.
let steps = vec![
SchemaMigrationStep::DropType {
type_kind: SchemaTypeKind::Node,
name: "Person".to_string(),
mode: DropMode::Soft,
},
SchemaMigrationStep::DropType {
type_kind: SchemaTypeKind::Edge,
name: "Knows".to_string(),
mode: DropMode::Hard,
},
SchemaMigrationStep::DropProperty {
type_kind: SchemaTypeKind::Node,
type_name: "Person".to_string(),
property_name: "age".to_string(),
mode: DropMode::Soft,
},
SchemaMigrationStep::DropProperty {
type_kind: SchemaTypeKind::Interface,
type_name: "Named".to_string(),
property_name: "alias".to_string(),
mode: DropMode::Hard,
},
];
for step in steps {
let json = serde_json::to_string(&step).expect("serialize");
let round_trip: SchemaMigrationStep =
serde_json::from_str(&json).expect("deserialize");
assert_eq!(step, round_trip, "round-trip mismatch on {json}");
}
}
#[test]
fn drop_mode_serde_uses_snake_case() {
// External tools may write SchemaIR JSON by hand. Pin the
// wire form so we don't silently break them later.
assert_eq!(serde_json::to_string(&DropMode::Soft).unwrap(), "\"soft\"");
assert_eq!(serde_json::to_string(&DropMode::Hard).unwrap(), "\"hard\"");
let soft: DropMode = serde_json::from_str("\"soft\"").unwrap();
assert_eq!(soft, DropMode::Soft);
}
}

View file

@ -16,7 +16,7 @@ pub use catalog::schema_ir::{
schema_ir_pretty_json,
};
pub use catalog::schema_plan::{
SchemaMigrationPlan, SchemaMigrationStep, SchemaTypeKind, plan_schema_migration,
DropMode, SchemaMigrationPlan, SchemaMigrationStep, SchemaTypeKind, plan_schema_migration,
};
pub use lint::{DiagnosticCode, Family, SafetyTier, Severity};
pub use ir::ParamMap;

View file

@ -138,6 +138,17 @@ pub(super) async fn apply_schema_with_lock(
}
SchemaMigrationStep::UpdateTypeMetadata { .. }
| SchemaMigrationStep::UpdatePropertyMetadata { .. } => {}
SchemaMigrationStep::DropType { .. } | SchemaMigrationStep::DropProperty { .. } => {
// Dormant — variants exist on the IR but the planner
// doesn't emit them yet. Implementation lands in a
// subsequent commit (see docs/schema-lint-v1-plan.md).
// If a SchemaIR JSON containing one of these arrives
// (e.g. round-tripped from a future tool), fail
// explicitly rather than silently misclassifying.
return Err(OmniError::manifest_internal(
"drop-step variant is not yet implemented in apply_schema",
));
}
step @ SchemaMigrationStep::UnsupportedChange { .. } => {
return Err(OmniError::manifest(
step.unsupported_error_message()