diff --git a/AGENTS.md b/AGENTS.md index 3b542b2..043de89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ Full diagram and concurrency model: [docs/architecture.md](docs/architecture.md) | Architecture, L1/L2 framing, concurrency model | [docs/architecture.md](docs/architecture.md) | | Storage layout, `__manifest` schema, URI schemes, S3 env vars | [docs/storage.md](docs/storage.md) | | `.pg` schema language, types, constraints, annotations, migration planning | [docs/schema-language.md](docs/schema-language.md) | +| Schema-lint codes (`OG-XXX-NNN`), families, severity, suppression | [docs/schema-lint.md](docs/schema-lint.md) | | `.gq` query language, MATCH/RETURN/ORDER, search funcs, mutations, IR ops, lint codes | [docs/query-language.md](docs/query-language.md) | | Indexes (BTREE / inverted / vector / graph topology) | [docs/indexes.md](docs/indexes.md) | | Embeddings (compiler + engine clients, env vars, `@embed`) | [docs/embeddings.md](docs/embeddings.md) | diff --git a/crates/omnigraph-cli/src/main.rs b/crates/omnigraph-cli/src/main.rs index 4fe89e0..35740cc 100644 --- a/crates/omnigraph-cli/src/main.rs +++ b/crates/omnigraph-cli/src/main.rs @@ -1035,9 +1035,14 @@ fn render_schema_plan_step(step: &SchemaMigrationStep) -> String { type_name, render_annotations(annotations) ), - SchemaMigrationStep::UnsupportedChange { entity, reason } => { - format!("unsupported change on {}: {}", entity, reason) - } + SchemaMigrationStep::UnsupportedChange { + entity, + reason, + code, + } => match code { + Some(c) => format!("unsupported change on {} [{}]: {}", entity, c, reason), + None => format!("unsupported change on {}: {}", entity, reason), + }, } } diff --git a/crates/omnigraph-compiler/src/catalog/schema_plan.rs b/crates/omnigraph-compiler/src/catalog/schema_plan.rs index 50334ae..835a1be 100644 --- a/crates/omnigraph-compiler/src/catalog/schema_plan.rs +++ b/crates/omnigraph-compiler/src/catalog/schema_plan.rs @@ -65,9 +65,36 @@ pub enum SchemaMigrationStep { UnsupportedChange { entity: String, reason: String, + /// Stable schema-lint code (`OG-XXX-NNN`) for this rejection, + /// or `None` if the path predates the chassis catalog. See + /// [`crate::lint::codes`] for the registry. Renderers should + /// prefix the message with `[code]` when present so operators + /// can suppress, look up docs, or filter on stable identifiers + /// rather than free-text prose. + /// + /// Stored as `String` (not `&'static str`) so the enum stays + /// serde-friendly. Emitters pass the catalog constant's + /// `.code` (a `&'static str`) and we own a clone here. + #[serde(default, skip_serializing_if = "Option::is_none")] + code: Option, }, } +impl SchemaMigrationStep { + /// Returns the formatted error message for an `UnsupportedChange` + /// step, prefixed with `[code] ` when a schema-lint code is attached. + /// Returns `None` for every other variant. + pub fn unsupported_error_message(&self) -> Option { + match self { + Self::UnsupportedChange { reason, code, .. } => Some(match code { + Some(c) => format!("[{}] {}", c, reason), + None => reason.clone(), + }), + _ => None, + } + } +} + pub fn plan_schema_migration( accepted: &SchemaIR, desired: &SchemaIR, @@ -130,6 +157,7 @@ fn plan_interfaces( "removing interface '{}' is not supported in schema migration v1", leftover.name ), + code: None, }); } @@ -171,6 +199,7 @@ fn plan_nodes( "node '{}' declares @rename_from(\"{}\") but no accepted node with that name exists", node.name, from ), + code: None, }); } else { steps.push(SchemaMigrationStep::AddType { @@ -200,6 +229,7 @@ fn plan_nodes( "changing implemented interfaces on node '{}' is not supported in schema migration v1", node.name ), + code: None, }); } @@ -237,6 +267,7 @@ fn plan_nodes( "removing node type '{}' is not supported in schema migration v1", leftover.name ), + code: Some(crate::lint::codes::OG_DS_102.code.to_string()), }); } @@ -277,6 +308,7 @@ fn plan_edges( "edge '{}' declares @rename_from(\"{}\") but no accepted edge with that name exists", edge.name, from ), + code: None, }); } else { steps.push(SchemaMigrationStep::AddType { @@ -305,6 +337,7 @@ fn plan_edges( "changing edge endpoints on '{}' is not supported in schema migration v1", edge.name ), + code: None, }); } if existing.cardinality != edge.cardinality { @@ -314,6 +347,7 @@ fn plan_edges( "changing cardinality on edge '{}' is not supported in schema migration v1", edge.name ), + code: None, }); } @@ -351,6 +385,7 @@ fn plan_edges( "removing edge type '{}' is not supported in schema migration v1", leftover.name ), + code: Some(crate::lint::codes::OG_DS_103.code.to_string()), }); } } @@ -396,6 +431,7 @@ fn plan_properties( "property '{}.{}' declares @rename_from(\"{}\") but no accepted property with that name exists", type_name, property.name, from ), + code: None, }); } else if property.prop_type.nullable { steps.push(SchemaMigrationStep::AddProperty { @@ -416,6 +452,7 @@ fn plan_properties( "adding required property '{}.{}' requires a backfill and is not supported in schema migration v1", type_name, property.name ), + code: Some(crate::lint::codes::OG_MF_103.code.to_string()), }); } continue; @@ -444,6 +481,7 @@ fn plan_properties( "changing property type for '{}.{}' is not supported in schema migration v1", type_name, property.name ), + code: Some(crate::lint::codes::OG_MF_106.code.to_string()), }); } @@ -472,6 +510,7 @@ fn plan_properties( "removing property '{}.{}' is not supported in schema migration v1", type_name, leftover.name ), + code: Some(crate::lint::codes::OG_DS_104.code.to_string()), }); } @@ -513,6 +552,7 @@ fn plan_constraints( "removing constraints from '{}' is not supported in schema migration v1", type_name ), + code: None, }); } @@ -532,6 +572,7 @@ fn plan_constraints( "adding constraint '{}' to '{}' is not supported in schema migration v1", key, type_name ), + code: None, }), } } @@ -557,6 +598,7 @@ fn plan_type_metadata( steps.push(SchemaMigrationStep::UnsupportedChange { entity: format!("{}:{}", schema_type_kind_key(type_kind), name), reason, + code: None, }); } } @@ -589,6 +631,7 @@ fn plan_property_metadata( property_name ), reason, + code: None, }); } } @@ -850,9 +893,9 @@ node Person { assert!(!plan.supported); assert!(plan.steps.iter().any(|step| matches!( step, - UnsupportedChange { entity, reason } + UnsupportedChange { entity, code, .. } if entity.contains("Person.age") - && reason.contains("adding required property") + && code.as_deref() == Some(crate::lint::codes::OG_MF_103.code) ))); } diff --git a/crates/omnigraph-compiler/src/lib.rs b/crates/omnigraph-compiler/src/lib.rs index 0b71ebd..102b479 100644 --- a/crates/omnigraph-compiler/src/lib.rs +++ b/crates/omnigraph-compiler/src/lib.rs @@ -3,6 +3,7 @@ pub mod embedding; pub mod error; pub mod ir; pub mod json_output; +pub mod lint; pub mod query; pub mod query_input; pub mod result; @@ -17,6 +18,7 @@ pub use catalog::schema_ir::{ pub use catalog::schema_plan::{ SchemaMigrationPlan, SchemaMigrationStep, SchemaTypeKind, plan_schema_migration, }; +pub use lint::{DiagnosticCode, Family, SafetyTier, Severity}; pub use ir::ParamMap; pub use ir::lower::{lower_mutation_query, lower_query}; pub use query::ast::Literal; diff --git a/crates/omnigraph-compiler/src/lint/codes.rs b/crates/omnigraph-compiler/src/lint/codes.rs new file mode 100644 index 0000000..e53bf31 --- /dev/null +++ b/crates/omnigraph-compiler/src/lint/codes.rs @@ -0,0 +1,195 @@ +//! Schema-lint code catalog (MR-694 v0). +//! +//! Codes have the form `OG-XXX-NNN` where `XXX` is the family prefix from +//! [`super::diagnostic::Family`]. Each code carries a default safety +//! tier, severity, and short description. Codes are stable: once +//! published, the meaning is frozen and `omnigraph.yaml` may override +//! severity but never the tier or family. +//! +//! ## v0 catalog +//! +//! This PR (chassis v0) seeds the catalog with the codes attached to +//! existing `UnsupportedChange` emissions in +//! `catalog::schema_plan`. Subsequent PRs add codes for new migration +//! variants (MR-695..702), the CD/VE/LK/NM families, and so on. + +use super::diagnostic::{Family, SafetyTier, Severity}; + +/// Static catalog entry for a single diagnostic code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiagnosticCode { + /// The stable code string, e.g. `"OG-DS-104"`. + pub code: &'static str, + pub family: Family, + pub tier: SafetyTier, + pub default_severity: Severity, + pub short: &'static str, +} + +// ─── Destructive (DS) — data-loss; always requires explicit opt-in ────────── + +/// Reserved: dropping an entire graph (schema-level). Not yet emitted. +pub const OG_DS_101: DiagnosticCode = DiagnosticCode { + code: "OG-DS-101", + family: Family::DS, + tier: SafetyTier::Destructive, + default_severity: Severity::Error, + short: "drop graph type with rows", +}; + +/// Drop a node type that has rows. +pub const OG_DS_102: DiagnosticCode = DiagnosticCode { + code: "OG-DS-102", + family: Family::DS, + tier: SafetyTier::Destructive, + default_severity: Severity::Error, + short: "drop node type with rows", +}; + +/// Drop an edge type that has rows. +pub const OG_DS_103: DiagnosticCode = DiagnosticCode { + code: "OG-DS-103", + family: Family::DS, + tier: SafetyTier::Destructive, + default_severity: Severity::Error, + short: "drop edge type with rows", +}; + +/// Drop a property (column) that has data. +pub const OG_DS_104: DiagnosticCode = DiagnosticCode { + code: "OG-DS-104", + family: Family::DS, + tier: SafetyTier::Destructive, + default_severity: Severity::Error, + short: "drop property with rows", +}; + +/// Reserved: dropping a populated vector / embedding column. Distinct +/// from a normal property drop because it invalidates downstream +/// `nearest()` / `@embed` references. +pub const OG_DS_105: DiagnosticCode = DiagnosticCode { + code: "OG-DS-105", + family: Family::DS, + tier: SafetyTier::Destructive, + default_severity: Severity::Error, + short: "drop populated vector column", +}; + +// ─── Maybe-fail (MF) — data-dependent; may fail on existing rows ──────────── + +/// Add a required (non-nullable) property to a populated type without +/// `@default`. Existing rows have no value to fill in. +pub const OG_MF_103: DiagnosticCode = DiagnosticCode { + code: "OG-MF-103", + family: Family::MF, + tier: SafetyTier::Validated, + default_severity: Severity::Error, + short: "add required property without @default to populated type", +}; + +/// Tighten nullable to non-nullable. May fail on existing null rows. +/// Reserved for a follow-up that wires the validated-tier scan; not +/// emitted in v0. +pub const OG_MF_104: DiagnosticCode = DiagnosticCode { + code: "OG-MF-104", + family: Family::MF, + tier: SafetyTier::Validated, + default_severity: Severity::Error, + short: "tighten nullable to non-nullable", +}; + +/// Narrow a scalar (e.g. I64 → I32, F64 → F32). Lossy cast that may +/// truncate or overflow. Today emitted on any `prop_type` change since +/// the v1 planner doesn't yet distinguish widening from narrowing. +pub const OG_MF_106: DiagnosticCode = DiagnosticCode { + code: "OG-MF-106", + family: Family::MF, + tier: SafetyTier::Destructive, + default_severity: Severity::Error, + short: "narrowing scalar type", +}; + +/// All v0 catalog entries. Used for chassis-level invariants +/// (uniqueness, family coverage). +pub const ALL_CODES: &[DiagnosticCode] = &[ + OG_DS_101, OG_DS_102, OG_DS_103, OG_DS_104, OG_DS_105, OG_MF_103, OG_MF_104, OG_MF_106, +]; + +/// Codes actually emitted by the planner in v0 (i.e. not reserved). +pub const EMITTED_IN_V0: &[&str] = &["OG-DS-102", "OG-DS-103", "OG-DS-104", "OG-MF-103", "OG-MF-106"]; + +/// Look up a code by its string identifier. +pub fn lookup(code: &str) -> Option<&'static DiagnosticCode> { + ALL_CODES.iter().find(|c| c.code == code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_are_unique() { + let mut seen = std::collections::HashSet::new(); + for c in ALL_CODES { + assert!(seen.insert(c.code), "duplicate code: {}", c.code); + } + } + + #[test] + fn code_strings_match_family_prefix() { + for c in ALL_CODES { + let expected_prefix = format!("OG-{}-", c.family.prefix()); + assert!( + c.code.starts_with(&expected_prefix), + "{} doesn't start with {}", + c.code, + expected_prefix + ); + } + } + + #[test] + fn code_strings_have_three_digit_suffix() { + for c in ALL_CODES { + let suffix = c.code.rsplit('-').next().unwrap(); + assert_eq!(suffix.len(), 3, "{}: suffix not 3 chars", c.code); + assert!( + suffix.chars().all(|ch| ch.is_ascii_digit()), + "{}: suffix not all digits", + c.code + ); + } + } + + #[test] + fn destructive_tier_defaults_to_error_severity() { + for c in ALL_CODES { + if c.tier == SafetyTier::Destructive { + assert_eq!( + c.default_severity, + Severity::Error, + "{}: destructive must default to Error", + c.code + ); + } + } + } + + #[test] + fn lookup_finds_known_codes() { + assert_eq!(lookup("OG-DS-104"), Some(&OG_DS_104)); + assert_eq!(lookup("OG-MF-103"), Some(&OG_MF_103)); + assert!(lookup("OG-XX-999").is_none()); + } + + #[test] + fn emitted_codes_exist_in_catalog() { + for code in EMITTED_IN_V0 { + assert!( + lookup(code).is_some(), + "EMITTED_IN_V0 contains {} but it isn't in ALL_CODES", + code + ); + } + } +} diff --git a/crates/omnigraph-compiler/src/lint/diagnostic.rs b/crates/omnigraph-compiler/src/lint/diagnostic.rs new file mode 100644 index 0000000..cdb4f9b --- /dev/null +++ b/crates/omnigraph-compiler/src/lint/diagnostic.rs @@ -0,0 +1,83 @@ +//! Diagnostic types for the schema-lint chassis (MR-694). +//! +//! Every schema-migration diagnostic carries a stable code (`OG-XXX-NNN`), +//! a family grouping (DS / MF / CD / …), and a safety tier +//! (safe / validated / destructive). The code is the public identity; +//! external tooling and operators reference rules by code, not by message. +//! +//! This module is the chassis-level vocabulary. The concrete code catalog +//! lives in [`super::codes`]; emission sites are in +//! `catalog::schema_plan` and (future) other lint passes. + +/// Family groupings for schema-lint rules. Mirrors the Atlas analyzer +/// families (DS / MF / CD / BC / NM) plus four omnigraph-native families +/// for vector/embedding (VE), edge topology (ED), lock/cost (LK), and +/// non-linear branch divergence (NL). Ownership (OW) is reserved for +/// per-resource Cedar policy integration (MR-722). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Family { + /// Destructive — data-loss class. Always requires explicit opt-in. + DS, + /// Maybe-fail — data-dependent, may fail on existing rows. + MF, + /// Constraint deletion — invariant relaxation; consumer-warning. + CD, + /// Backward incompatible — rename or shape change that breaks clients. + BC, + /// Naming conventions. + NM, + /// Ownership — per-resource access control. + OW, + /// Non-linear — branch-merge schema-state divergence. + NL, + /// Vector / embedding — omnigraph-native. + VE, + /// Edge / graph topology — omnigraph-native. + ED, + /// Lock duration / cost — omnigraph-native. + LK, +} + +impl Family { + pub fn prefix(self) -> &'static str { + match self { + Self::DS => "DS", + Self::MF => "MF", + Self::CD => "CD", + Self::BC => "BC", + Self::NM => "NM", + Self::OW => "OW", + Self::NL => "NL", + Self::VE => "VE", + Self::ED => "ED", + Self::LK => "LK", + } + } +} + +/// Tier classification for a migration step. Determines apply-path +/// behavior: +/// - `Safe`: applies without scan or flag. +/// - `Validated`: requires a single-pass scan of existing rows; fails on +/// the first violation. +/// - `Destructive`: requires explicit `--allow-data-loss` (or equivalent +/// opt-in) at apply time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SafetyTier { + Safe, + Validated, + Destructive, +} + +/// Severity for a diagnostic at the user-facing surface. Defaults are set +/// per code in [`super::codes`]; operators override via `omnigraph.yaml` +/// (planned for a follow-up PR). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Severity { + /// Blocks apply. + Error, + /// Reported but doesn't block. + Warn, + /// Informational; doesn't block. + Info, +} diff --git a/crates/omnigraph-compiler/src/lint/mod.rs b/crates/omnigraph-compiler/src/lint/mod.rs new file mode 100644 index 0000000..79e9986 --- /dev/null +++ b/crates/omnigraph-compiler/src/lint/mod.rs @@ -0,0 +1,28 @@ +//! Schema-lint chassis (MR-694). +//! +//! Stable diagnostic codes (`OG-XXX-NNN`) for schema-migration plans, +//! the foundation for per-rule severity config, suppression directives, +//! and pre-migration checks that subsequent PRs layer on. +//! +//! ## v0 surface +//! +//! - [`diagnostic`] defines [`Family`](diagnostic::Family), +//! [`SafetyTier`](diagnostic::SafetyTier), and +//! [`Severity`](diagnostic::Severity). +//! - [`codes`] holds the catalog of [`DiagnosticCode`](codes::DiagnosticCode) +//! entries; the planner attaches `code: Option<&'static str>` to each +//! `UnsupportedChange` emission. +//! - The CLI renders the code in `omnigraph schema plan` output; the +//! apply path includes it in the user-visible error message. +//! +//! Future PRs add: severity config from `omnigraph.yaml`, `@allow(...)` +//! suppression annotations, pre-migration checks (MR-941), the CD / VE / +//! LK / NM families (MR-942..945), and CI integration (MR-946). +//! +//! See: docs/schema-lint.md, https://atlasgo.io/lint/analyzers + +pub mod codes; +pub mod diagnostic; + +pub use codes::{lookup, DiagnosticCode, ALL_CODES}; +pub use diagnostic::{Family, SafetyTier, Severity}; diff --git a/crates/omnigraph/src/db/omnigraph/schema_apply.rs b/crates/omnigraph/src/db/omnigraph/schema_apply.rs index 39b1bfd..a01cbc8 100644 --- a/crates/omnigraph/src/db/omnigraph/schema_apply.rs +++ b/crates/omnigraph/src/db/omnigraph/schema_apply.rs @@ -53,15 +53,12 @@ pub(super) async fn apply_schema_with_lock( let plan = plan_schema_migration(&accepted_ir, &desired_ir) .map_err(|err| OmniError::manifest(err.to_string()))?; if !plan.supported { - let reason = plan + let message = plan .steps .iter() - .find_map(|step| match step { - SchemaMigrationStep::UnsupportedChange { reason, .. } => Some(reason.as_str()), - _ => None, - }) - .unwrap_or("unsupported schema migration plan"); - return Err(OmniError::manifest(reason.to_string())); + .find_map(|step| step.unsupported_error_message()) + .unwrap_or_else(|| "unsupported schema migration plan".to_string()); + return Err(OmniError::manifest(message)); } if plan.steps.is_empty() { return Ok(SchemaApplyResult { @@ -141,8 +138,11 @@ pub(super) async fn apply_schema_with_lock( } SchemaMigrationStep::UpdateTypeMetadata { .. } | SchemaMigrationStep::UpdatePropertyMetadata { .. } => {} - SchemaMigrationStep::UnsupportedChange { reason, .. } => { - return Err(OmniError::manifest(reason.clone())); + step @ SchemaMigrationStep::UnsupportedChange { .. } => { + return Err(OmniError::manifest( + step.unsupported_error_message() + .unwrap_or_else(|| "unsupported schema migration step".to_string()), + )); } } } diff --git a/crates/omnigraph/tests/schema_apply.rs b/crates/omnigraph/tests/schema_apply.rs index a1aea0f..fac5ab4 100644 --- a/crates/omnigraph/tests/schema_apply.rs +++ b/crates/omnigraph/tests/schema_apply.rs @@ -126,9 +126,10 @@ async fn apply_schema_rejects_dropping_a_property_with_data() { // the column is nullable — it would silently destroy data. let desired = TEST_SCHEMA.replace(" age: I32?\n", ""); let err = db.apply_schema(&desired).await.unwrap_err(); + let msg = err.to_string(); assert!( - err.to_string().contains("removing property"), - "expected 'removing property' in error, got: {err}" + msg.contains("OG-DS-104"), + "expected schema-lint code OG-DS-104 in error, got: {msg}" ); // Manifest didn't advance and existing rows are untouched. @@ -166,8 +167,8 @@ edge Knows: Person -> Person { let err = db.apply_schema(desired).await.unwrap_err(); let msg = err.to_string(); assert!( - msg.contains("removing node type") || msg.contains("removing edge type"), - "expected drop-type error, got: {msg}" + msg.contains("OG-DS-102") || msg.contains("OG-DS-103"), + "expected schema-lint code OG-DS-102 or OG-DS-103 in error, got: {msg}" ); assert_eq!( db.snapshot_of(ReadTarget::branch("main")) @@ -191,9 +192,10 @@ async fn apply_schema_rejects_dropping_an_edge_type() { // Drop only the `WorksAt` edge. let desired = TEST_SCHEMA.replace("\nedge WorksAt: Person -> Company", ""); let err = db.apply_schema(&desired).await.unwrap_err(); + let msg = err.to_string(); assert!( - err.to_string().contains("removing edge type"), - "expected 'removing edge type' error, got: {err}" + msg.contains("OG-DS-103"), + "expected schema-lint code OG-DS-103 in error, got: {msg}" ); assert_eq!( db.snapshot_of(ReadTarget::branch("main")) @@ -221,9 +223,10 @@ async fn apply_schema_rejects_adding_a_required_property_without_backfill() { " age: I32?\n email: String\n}", ); let err = db.apply_schema(&desired).await.unwrap_err(); + let msg = err.to_string(); assert!( - err.to_string().contains("adding required property"), - "expected 'adding required property' error, got: {err}" + msg.contains("OG-MF-103"), + "expected schema-lint code OG-MF-103 in error, got: {msg}" ); assert_eq!( db.snapshot_of(ReadTarget::branch("main")) @@ -253,8 +256,8 @@ async fn plan_schema_for_property_type_narrowing_is_not_supported() { assert!(!plan.supported, "narrowing I64 -> I32 must not be supported"); assert!(plan.steps.iter().any(|step| matches!( step, - SchemaMigrationStep::UnsupportedChange { reason, .. } - if reason.contains("changing property type") + SchemaMigrationStep::UnsupportedChange { code, .. } + if code.as_deref() == Some("OG-MF-106") ))); } diff --git a/docs/schema-lint.md b/docs/schema-lint.md new file mode 100644 index 0000000..a1495fd --- /dev/null +++ b/docs/schema-lint.md @@ -0,0 +1,61 @@ +# Schema lint + +The migration planner emits **code-tagged diagnostics** for every schema change it rejects. Codes have the form `OG-XXX-NNN` and identify the rule (not the message); operators reference them in suppression directives, severity overrides, and CI reports. + +This page is the catalog of codes shipped today. The chassis behind it is tracked in [MR-694](https://linear.app/modernrelay/issue/MR-694). + +## What's shipped in v0 + +- Stable code attached to every rejection the planner emits (today: 5 of 17 paths — the rest carry `code: None` and are tagged as future work). +- Code appears in the user-visible error message: `[OG-DS-104] removing property 'Person.age' is not supported …`. +- CLI `omnigraph schema plan` shows the code on `unsupported change …` lines. +- Tests in `tests/schema_apply.rs` assert on codes, not on free-text prose. + +## What's not shipped yet + +- Severity configuration in `omnigraph.yaml` (planned: `lint: { OG-DS-103: error }`). +- `@allow(OG-XXX-NNN, "rationale")` suppression directives. +- Pre-migration checks (the `migration_check { … }` block — MR-941). +- The CD / VE / LK / NM families (MR-942..945). +- CI integration (MR-946). +- Cost-class annotations (MR-944). + +See the parent chassis issue (MR-694) for the design and the per-family sub-issues for what's planned. + +## Code catalog (v0) + +The chassis defines ten families. Today only DS and MF have emitted codes. The remaining families are reserved for future PRs. + +| Code | Family | Tier | Default severity | Meaning | +|---|---|---|---|---| +| `OG-DS-101` | Destructive | destructive | error | drop graph type with rows (reserved; not yet emitted) | +| `OG-DS-102` | Destructive | destructive | error | drop node type with rows | +| `OG-DS-103` | Destructive | destructive | error | drop edge type with rows | +| `OG-DS-104` | Destructive | destructive | error | drop property with rows | +| `OG-DS-105` | Destructive | destructive | error | drop populated vector column (reserved) | +| `OG-MF-103` | Maybe-fail | validated | error | add required property without `@default` to populated type | +| `OG-MF-104` | Maybe-fail | validated | error | tighten nullable to non-nullable (reserved) | +| `OG-MF-106` | Maybe-fail | destructive | error | narrowing scalar type | + +The full code catalog source of truth lives in `crates/omnigraph-compiler/src/lint/codes.rs`. CI-level invariants (uniqueness, format, family coverage) are unit-tested in the same module. + +## Families + +The ten chassis families: + +| Prefix | Family | Status | +|---|---|---| +| **DS** | Destructive (data-loss) | shipped, v0 | +| **MF** | Maybe-fail / data-dependent | shipped, v0 | +| **CD** | Constraint deletion (relaxation warning) | tracked in MR-942 | +| **BC** | Backward-incompatible (rename) | implicit in `@rename_from`; codify later | +| **NM** | Naming conventions | tracked in MR-945 | +| **OW** | Ownership (per-resource Cedar) | tracked in MR-722 | +| **NL** | Non-linear (branch-merge divergence) | stubbed in MR-947 | +| **VE** | Vector / embedding | tracked in MR-943 | +| **ED** | Edge / graph topology | tracked in MR-701, MR-943 | +| **LK** | Lock duration / cost | tracked in MR-944 | + +## Prior art + +The chassis is modeled on [Atlas's `sqlcheck` analyzers](https://atlasgo.io/lint/analyzers) (DS / MF / CD / BC / NM families). Atlas was the direct inspiration for stable codes, per-rule severity, suppression directives with rationale, and pre-migration checks. omnigraph adapts the chassis to a typed-IR substrate (no SQL injection vector, no per-engine locking, native vector / edge / embedding types Atlas doesn't have).