diff --git a/README.md b/README.md index def0ddf..c9936c4 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,51 @@ honest *convention* fallback (`record`); the parallel `slice_2_typed_lift.rs` proves the *lift* resolves it to `account_move_line`. Both stay in the test set — the diff between them is the value the bridge adds. +## Pulling the canonical classid (OGAR) + +Under the `ogar-emit` feature, `od-ontology` pulls the canonical OGAR classid +for each Odoo model straight from `ogar_vocab::ports::OdooPort` — no bridge +object, no registry, no TTL hydration. A pure static lookup over the shared +codebook (OGAR #94). This is the "pull OGAR via class" consumer-migration target +(lance-graph #589). + +### API + +```rust +// feature = "ogar-emit" +concept_classid("account_move") // Some(0x0202) COMMERCIAL_DOCUMENT +concept_classid("account_analytic_line") // Some(0x0103) BILLABLE_WORK_ENTRY +render_classid("account_move") // Some(0x0002_0202) APP 0x0002 (Odoo lens) ‖ concept +schema_classids(&schema) // Vec<(table, Option)> +``` + +### APP‖class codebook + +The `classid` is a 32-bit value: `APP (high u16) ‖ concept (low u16)`. The **low +u16** is WHAT it is — the shared cross-app concept (RBAC + ontology). The **high +u16** is WHOSE render — the per-app lens; Odoo's is `0x0002`. + +### Convergence pin + +Planning times align with billable hours through one codebook lookup: + +| Surface | Model / entity | Concept classid | +|---|---|---| +| Odoo (ERP) | `account.analytic.line` | `0x0103` `BILLABLE_WORK_ENTRY` | +| WoA / SMB | `Stundenzettel` | `0x0103` `BILLABLE_WORK_ENTRY` | +| OpenProject / Redmine | `TimeEntry` | `0x0103` `BILLABLE_WORK_ENTRY` | + +### Name-form caveat + +The SPO corpus names models in table form (`account_move`); `OdooPort` aliases +are model form (`account.move`); the bridge normalizes `_`→`.` (the canonical +Odoo `_name.replace('.', '_')` inverted). This is lossless for the codebook's +dot-separated single-word segments; Odoo localization and module classes with an +underscore inside a segment (`l10n_*`, `im_livechat_*`) are intentionally out of +scope and resolve to `None` — a fail-safe miss, never a wrong id. + +--- + ## The cut tail (deferred) A **codegen / migration convenience layer** — export the typed AST, snapshot it diff --git a/crates/od-ontology/Cargo.toml b/crates/od-ontology/Cargo.toml index cb00033..65dfe55 100644 --- a/crates/od-ontology/Cargo.toml +++ b/crates/od-ontology/Cargo.toml @@ -22,6 +22,12 @@ serde_json = "1" # (OdooPort). `ogar-adapter-surrealql` default features are dep-light: its # emit path is `ogar-vocab` + a hand-written formatter (zero surrealdb-core), # the same "mirror the catalog shape" design this crate already uses. +# Pin verification (2026-06-22, integration review R5): rev 08a9c979 IS the +# #94 merge commit — the earliest rev carrying OdooPort + all 9 ODOO_ALIASES +# (incl. account.analytic.line -> BILLABLE_WORK_ENTRY 0x0103). OGAR main has +# since advanced to 5ee87b5 (post-#96); the intervening #95/#96 commits are +# additive/test-only (no OdooPort API change), so no bump is required. A bump +# is a deliberate, reviewable, re-gated step — not a maintenance default. ogar-vocab = { git = "https://github.com/AdaWorldAPI/OGAR", rev = "08a9c979d8939e1303770ea32aa2010928e79318", optional = true } ogar-adapter-surrealql = { git = "https://github.com/AdaWorldAPI/OGAR", rev = "08a9c979d8939e1303770ea32aa2010928e79318", optional = true } @@ -42,6 +48,14 @@ name = "od-codegen" path = "src/bin/od_codegen.rs" required-features = ["cli"] +# End-to-end "pull OGAR via class" demo. Uses the `ogar-emit`-gated classid +# surface (concept_classid / render_classid / schema_classids / +# emit_via_ogar_annotated), so it only builds under the feature — otherwise a +# default `cargo build --examples` would fail on the gated symbols. +[[example]] +name = "classid_pull" +required-features = ["ogar-emit"] + [lints.clippy] all = { level = "deny", priority = -1 } pedantic = "warn" diff --git a/crates/od-ontology/examples/classid_pull.rs b/crates/od-ontology/examples/classid_pull.rs new file mode 100644 index 0000000..0df0f93 --- /dev/null +++ b/crates/od-ontology/examples/classid_pull.rs @@ -0,0 +1,63 @@ +//! End-to-end "pull OGAR via class": from an SPO corpus to canonical classids +//! and annotated `SurrealQL` DDL — the classid stamped into the `DEFINE TABLE` +//! `COMMENT` clause, so it survives into `SurrealDB`'s own catalog. +//! +//! ```bash +//! cargo run -p od-ontology --example classid_pull --features ogar-emit +//! ``` +//! +//! This is the consumer-migration target spelled out (lance-graph #589): the +//! canonical id is pulled for each Odoo model straight from `OdooPort` — no +//! bridge, no registry, no hydration. `account_analytic_line` resolves to the +//! SAME `BILLABLE_WORK_ENTRY` id WoA/SMB `Stundenzettel` and `OpenProject` / +//! `Redmine` `TimeEntry` resolve to — planner times align with billable hours +//! through one codebook lookup. + +use od_ontology::{ + concept_classid, corpus_to_schema, emit_via_ogar_annotated, parse_ndjson, render_classid, + schema_classids, +}; + +fn main() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../data/slice_2.spo.ndjson"); + let ndjson = std::fs::read_to_string(path).expect("slice_2 fixture present"); + let triples = parse_ndjson(&ndjson).expect("fixture parses"); + let schema = corpus_to_schema( + &triples, + Some(&[ + "account_move", + "account_move_line", + "res_partner", + "res_company", + ]), + None, + ); + + // 1. The table → canonical OGAR classid map (concept low-u16 + full render u32). + println!("== canonical classids (table → concept / render) =="); + for (table, concept) in schema_classids(&schema) { + match concept { + Some(lo) => { + let render = render_classid(&table).expect("render present when concept present"); + println!(" {table:<22} concept=0x{lo:04X} render=0x{render:08X}"); + } + None => println!(" {table:<22} (uncodified — no canonical analogue)"), + } + } + + // 2. The planner↔ERP convergence pin (a pure codebook lookup — needs no + // corpus, so it resolves even though account.analytic.line isn't in the + // slice-2 focus set above). + if let Some(id) = concept_classid("account_analytic_line") { + println!( + "\n== convergence pin ==\n \ + account_analytic_line → 0x{id:04X} (BILLABLE_WORK_ENTRY)\n \ + ≡ WoA/SMB Stundenzettel ≡ OpenProject/Redmine TimeEntry" + ); + } + + // 3. The annotated DDL — the render classid rides into the DEFINE TABLE + // COMMENT clause (queryable later via `INFO FOR TABLE`). + println!("\n== annotated SurrealQL DDL (classid in COMMENT) =="); + print!("{}", emit_via_ogar_annotated(&schema)); +} diff --git a/crates/od-ontology/specs/UPSTREAM_WISHLIST.md b/crates/od-ontology/specs/UPSTREAM_WISHLIST.md index a85d8f9..6841b4b 100644 --- a/crates/od-ontology/specs/UPSTREAM_WISHLIST.md +++ b/crates/od-ontology/specs/UPSTREAM_WISHLIST.md @@ -423,6 +423,40 @@ we can adopt your choice when it lands. schema-emit consumer. Does ClassView's design cleanly generalize, or is it tesseract-shaped (single-language-target by construction)? +## PROBE-OGAR-ID-TO-CONCEPT-NAME (P2 · gates the classid→COMMENT name enrichment) + +> Filed 2026-06-22 (classid-consume sprint, convergence-architect review R4). +> Concrete pass/fail successor to open-question #2 above. Decoupled from the +> hex-id stamp, which already shipped — see below. + +**Already shipped, no capability needed:** `emit_via_ogar_annotated` +(`ogar_bridge.rs`) stamps the full APP‖class render id into the +`DEFINE TABLE … COMMENT 'classid:0x00020202'` clause, so the id rides into +SurrealDB's own catalog (queryable via `INFO FOR TABLE`). This needs only the +forward `OdooPort::class_id` (name → id) we already consume. + +**The gap this probe captures:** to put the human-readable concept *name* +(`COMMERCIAL_DOCUMENT`) in that COMMENT — and to collapse `schema_classids` +into a derived view over `Class::canonical_id()` by populating +`Class.canonical_concept` at lowering time — odoo-rs needs a **reverse** +`u16 → &'static str` lookup that `OdooPort`/`class_ids` do NOT expose today +(`OdooPort::aliases()` is forward-only `&[(&str, u16)]`). + +- **Capability under test:** an OGAR-side `class_ids::name_of(0x0202) == + Some("COMMERCIAL_DOCUMENT")` (or equivalent reverse index), derivable from + the existing `class_ids` const module / `CODEBOOK`. +- **PASS:** `table_to_class` sets `class.canonical_concept` (+ a + `COMMERCIAL_DOCUMENT (classid:0x00020202)` COMMENT); `schema_classids` + becomes a one-liner over the populated shells (no second traversal); the + asymmetry guard holds — `sale.order` resolves to `COMMERCIAL_DOCUMENT`, + **not** lexical `order` (this is the regression that proves the fusion safe). +- **FAIL / stay deferred:** OGAR exposes no reverse map → keep the forward-only + `concept_classid` surface and the hex-only COMMENT (the current shipped state). +- **Decoupling note:** the hex-id COMMENT (shipped) has **no** dependency on + this probe; only the concept-*name* enrichment + the `canonical_concept` + fusion wait on it. They are the *same* gate (verified: both need the reverse + lookup), so one OGAR capability unblocks both. + ## Current state of odoo-rs (so the design session knows what to design against) - `corpus_to_schema` projects SPO triples → typed `Schema { tables, diff --git a/crates/od-ontology/src/bin/od_codegen.rs b/crates/od-ontology/src/bin/od_codegen.rs index 231256c..dec4f7a 100644 --- a/crates/od-ontology/src/bin/od_codegen.rs +++ b/crates/od-ontology/src/bin/od_codegen.rs @@ -152,9 +152,31 @@ fn main() { process::exit(2); } + // ── 4. `--classids` — print table → OGAR render classid map ── + if parsed.classids { + #[cfg(feature = "ogar-emit")] + { + for table in &schema.tables { + match od_ontology::render_classid(&table.name) { + Some(id) => println!("{}\t0x{id:08X}", table.name), + None => println!("{}\t(uncodified)", table.name), + } + } + return; + } + #[cfg(not(feature = "ogar-emit"))] + { + eprintln!( + "error: --classids requires the `ogar-emit` feature \ + (rebuild: cargo build -p od-ontology --features cli,ogar-emit)" + ); + process::exit(1); + } + } + let sql = schema.to_sql(); - // ── 4. `--validate` (deferred stub) ── + // ── 5. `--validate` (deferred stub) ── if parsed.validate { eprintln!( "warning: --validate is a deferred stub (surrealdb-core parser not wired yet); \ @@ -162,7 +184,7 @@ fn main() { ); } - // ── 5. Write ── + // ── 6. Write ── if let Err(e) = write_output(&parsed.output, &sql) { eprintln!("error writing output: {e}"); process::exit(1); @@ -181,6 +203,7 @@ struct ParsedArgs { relations: Option, stats: bool, validate: bool, + classids: bool, help: bool, } @@ -203,6 +226,7 @@ fn parse_args(args: &[String]) -> Result { let mut relations: Option = None; let mut stats = false; let mut validate = false; + let mut classids = false; let mut help = false; let mut i = 0; while i < args.len() { @@ -210,6 +234,7 @@ fn parse_args(args: &[String]) -> Result { "-h" | "--help" => help = true, "--stats" => stats = true, "--validate" => validate = true, + "--classids" => classids = true, "-f" | "--focus" => { i += 1; let value = args.get(i).ok_or_else(|| { @@ -251,6 +276,7 @@ fn parse_args(args: &[String]) -> Result { relations, stats, validate, + classids, help, }) } @@ -312,6 +338,8 @@ OPTIONS: --validate Reserved — when wired, routes the emitted DDL through `surrealdb_core::syn::parse` and exits 2 on syntax error. Currently a no-op stub (warns and passes through). + --classids Print the `table → canonical OGAR render classid (0xAABBCCDD)` + map instead of DDL. Requires the `ogar-emit` feature. -h, --help Show this help. EXIT CODES (lance-graph#512 convention): @@ -333,6 +361,13 @@ mod tests { assert!(p.relations.is_none()); assert!(!p.stats); assert!(!p.validate); + assert!(!p.classids); + } + + #[test] + fn parse_args_classids_flag() { + let p = parse_args(&["--classids".into()]).unwrap(); + assert!(p.classids); } #[test] diff --git a/crates/od-ontology/src/lib.rs b/crates/od-ontology/src/lib.rs index 4022029..f308ac6 100644 --- a/crates/od-ontology/src/lib.rs +++ b/crates/od-ontology/src/lib.rs @@ -58,10 +58,16 @@ mod triple; #[cfg(feature = "ogar-emit")] pub use ogar_bridge::{ - concept_classid, emit_via_ogar, render_classid, schema_classids, schema_to_classes, - ODOO_APP_PREFIX, + concept_classid, emit_via_ogar, emit_via_ogar_annotated, render_classid, schema_classids, + schema_to_classes, ODOO_APP_PREFIX, }; +/// Re-export the canonical OGAR codebook constants (e.g. +/// `class_ids::BILLABLE_WORK_ENTRY`) so consumers can symbol-bind to the +/// shared ids rather than copy hex literals. +#[cfg(feature = "ogar-emit")] +pub use ogar_vocab::class_ids; + pub use emit::corpus_to_schema; pub use inheritance::InheritanceMap; pub use recompute_dag::{MethodId, MethodKind, RecomputeDag}; diff --git a/crates/od-ontology/src/ogar_bridge.rs b/crates/od-ontology/src/ogar_bridge.rs index 3b068c8..d454d4c 100644 --- a/crates/od-ontology/src/ogar_bridge.rs +++ b/crates/od-ontology/src/ogar_bridge.rs @@ -68,6 +68,37 @@ pub fn emit_via_ogar(schema: &Schema) -> String { ogar_adapter_surrealql::emit_surrealql_ddl(&schema_to_classes(schema)) } +/// [`emit_via_ogar`] DDL, but with each codebook table's canonical OGAR render +/// classid stamped into its `DEFINE TABLE … COMMENT 'classid:0xAABBCCDD'` clause +/// — so the id rides into `SurrealDB`'s own catalog metadata (queryable via +/// `INFO FOR TABLE`), not just the emitted `.surql` text. A `--` line-comment +/// would evaporate at parse time; the `COMMENT` clause survives ingestion. +/// +/// Purely additive over [`schema_to_classes`]: a table outside the codebook +/// gets no classid comment (its structural DDL is byte-identical to the native +/// [`emit_via_ogar`]). Only the full APP‖class render id (the hex) is stamped +/// today — the human-readable concept *name* awaits an OGAR-side +/// `id → concept-name` reverse lookup (see `specs/UPSTREAM_WISHLIST.md`, +/// `PROBE-OGAR-ID-TO-CONCEPT-NAME`). +#[must_use] +pub fn emit_via_ogar_annotated(schema: &Schema) -> String { + let classes: Vec = schema + .tables + .iter() + .map(|table| { + let mut class = table_to_class(table); + // Identity stamp: the render classid rides into the catalog COMMENT + // (the emitter renders `class.description` as `… COMMENT ''`). + // Uncodified tables keep `description = None` → no classid clause. + if let Some(id) = render_classid(&table.name) { + class.description = Some(format!("classid:0x{id:08X}")); + } + class + }) + .collect(); + ogar_adapter_surrealql::emit_surrealql_ddl(&classes) +} + // ── Canonical classid pull (the "pull OGAR via class" deliverable) ────── // // `schema_to_classes` above lowers *structure* (tables → `Class` shells with @@ -107,6 +138,16 @@ pub const ODOO_APP_PREFIX: u16 = 0x0002; /// `account_move` → `0x0202` (`COMMERCIAL_DOCUMENT`), `account_analytic_line` → /// `0x0103` (`BILLABLE_WORK_ENTRY`, the cross-arm bridge), `res_partner` → /// `0x0204` (`BILLING_PARTY`). +/// +/// **Scope caveat — `_`→`.` is not a universal bijection.** The normalize is +/// lossless for all nine codebook aliases: each alias consists of dot-separated +/// single-word segments (no underscore inside a segment), so +/// `account_analytic_line` → `account.analytic.line` is an exact round-trip. +/// Odoo localization and multi-word module classes that carry an underscore +/// *inside* a segment (e.g. `l10n_es_edi_document`, `im_livechat_channel`) are +/// intentionally out of codebook scope and resolve to `None` — a fail-safe miss, +/// never a wrong id. Callers that need to map such names should maintain their +/// own alias table rather than extending the `_`→`.` heuristic. #[must_use] pub fn concept_classid(model: &str) -> Option { OdooPort::class_id(&model.replace('_', ".")).or_else(|| OdooPort::class_id(model)) @@ -354,4 +395,49 @@ mod tests { ] ); } + + // ── emit_via_ogar_annotated ───────────────────────────────────────── + + #[test] + fn emit_via_ogar_annotated_stamps_classid_into_comment_clause() { + let schema = Schema { + tables: vec![ + TableDefinition::new("account_move"), + TableDefinition::new("ir_cron"), + ], + functions: Vec::new(), + events: Vec::new(), + }; + let ddl = emit_via_ogar_annotated(&schema); + // Codebook hit: account_move render classid 0x0002_0202 rides into the + // catalog COMMENT (single-quoted SurrealQL string literal), so it + // survives SurrealDB ingestion rather than evaporating as a `--` line. + assert!( + ddl.contains("COMMENT 'classid:0x00020202'"), + "account_move must carry its render classid in a COMMENT clause; got:\n{ddl}" + ); + // Codebook miss: ir_cron stays unstamped — exactly one classid clause. + assert_eq!( + ddl.matches("classid:").count(), + 1, + "only the codebook table (account_move) is stamped, not ir_cron; got:\n{ddl}" + ); + } + + #[test] + fn emit_via_ogar_annotated_is_additive_over_native_emit() { + // For a wholly-uncodified schema, no classid is stamped, so the + // annotated emit is byte-identical to the native parallel emit — the + // stamp is strictly additive, never a structural rewrite. + let schema = Schema { + tables: vec![TableDefinition::new("ir_cron")], + functions: Vec::new(), + events: Vec::new(), + }; + assert_eq!( + emit_via_ogar_annotated(&schema), + emit_via_ogar(&schema), + "an uncodified-only schema must emit identical DDL via both paths" + ); + } } diff --git a/crates/od-ontology/tests/odoo_ogar_convergence.rs b/crates/od-ontology/tests/odoo_ogar_convergence.rs new file mode 100644 index 0000000..55305e8 --- /dev/null +++ b/crates/od-ontology/tests/odoo_ogar_convergence.rs @@ -0,0 +1,141 @@ +#![cfg(feature = "ogar-emit")] +//! Consumer-side OGAR convergence pin for `od-ontology`. +//! +//! This test file is the odoo-rs CONSUMER mirror of OGAR's own +//! `billable_work_entry_converges_across_all_five_ports` pin. +//! It guards that odoo-rs's view through `concept_classid` / +//! `render_classid` / `schema_classids` stays bit-identical to +//! the OGAR codebook constants re-exported via `od_ontology::class_ids`. +//! +//! Every test here is a boundary contract: if it breaks, the OGAR codebook +//! and the odoo-rs consumer have diverged and ALL downstream consumers +//! (`WoA`, `SMB`, `OpenProject`, `lance-graph-planner`) see a broken pin. +//! +//! # Feature gate +//! The whole module compiles to nothing when `ogar-emit` is off (the +//! symbols are feature-gated in `od-ontology`). CI enables the feature; +//! minimal builds do not. + +use od_ontology::{ + class_ids, concept_classid, render_classid, schema_classids, Schema, TableDefinition, +}; + +// --------------------------------------------------------------------------- +// Individual concept-classid pins (symbol-bound, not hex literals) +// --------------------------------------------------------------------------- + +#[test] +fn account_analytic_line_pins_billable_work_entry() { + // This is the planner↔ERP pin: the SAME class_ids::BILLABLE_WORK_ENTRY + // constant is what WoA/SMB Stundenzettel AND OpenProject/Redmine TimeEntry + // resolve to. If OGAR moves the constant this test breaks — that is the + // intended behaviour; any move must be co-ordinated across all consumers. + assert_eq!( + concept_classid("account_analytic_line"), + Some(class_ids::BILLABLE_WORK_ENTRY), + ); +} + +#[test] +fn account_move_pins_commercial_document() { + assert_eq!( + concept_classid("account_move"), + Some(class_ids::COMMERCIAL_DOCUMENT), + ); +} + +#[test] +fn account_move_line_pins_commercial_line_item() { + assert_eq!( + concept_classid("account_move_line"), + Some(class_ids::COMMERCIAL_LINE_ITEM), + ); +} + +#[test] +fn res_partner_pins_billing_party() { + assert_eq!( + concept_classid("res_partner"), + Some(class_ids::BILLING_PARTY), + ); +} + +// --------------------------------------------------------------------------- +// Full-surface render_classid pin (lens + concept in one u32) +// --------------------------------------------------------------------------- + +#[test] +fn render_classid_stamps_odoo_lens_over_concept() { + // render_classid encodes (odoo_lens << 16) | concept_classid. + // The high u16 (0x0002) is the Odoo lens; the low u16 matches the + // concept constant. Both fields must be stable together. + assert_eq!(render_classid("account_analytic_line"), Some(0x0002_0103)); + assert_eq!(render_classid("account_move"), Some(0x0002_0202)); + + // Assert the lens independently so a bit-shift bug is caught separately + // from a codebook-value bug. + assert_eq!( + render_classid("account_move").map(|id| id >> 16), + Some(0x0002), + "high u16 of render_classid must be the Odoo lens (0x0002)", + ); +} + +// --------------------------------------------------------------------------- +// Negative pin (R4 requirement) +// --------------------------------------------------------------------------- + +#[test] +fn uncodified_model_resolves_none() { + // `ir_cron` is deliberately outside the canonical-identity codebook. + // Structural lowering (DDL / schema projection) still covers such a table; + // only the canonical-identity pull is codebook-gated, and that gating + // must stay observable — resolving to Some(...) here would silently hide + // a codebook expansion that hasn't been reviewed. + assert_eq!( + concept_classid("ir_cron"), + None, + "ir_cron must NOT resolve to a concept classid; it is not in the OGAR codebook", + ); + assert_eq!( + render_classid("ir_cron"), + None, + "ir_cron must NOT resolve to a render classid; it is not in the OGAR codebook", + ); +} + +// --------------------------------------------------------------------------- +// Full-surface schema_classids pin +// --------------------------------------------------------------------------- + +#[test] +fn schema_classids_full_surface_in_order() { + // Mixed schema: two codebook hits and one miss. The function must + // preserve insertion order and propagate None for uncodified tables. + let schema = Schema { + tables: vec![ + TableDefinition::new("account_move"), + TableDefinition::new("account_analytic_line"), + TableDefinition::new("ir_cron"), + ], + functions: Vec::new(), + events: Vec::new(), + }; + + let got = schema_classids(&schema); + + let expected: Vec<(String, Option)> = vec![ + ("account_move".into(), Some(class_ids::COMMERCIAL_DOCUMENT)), + ( + "account_analytic_line".into(), + Some(class_ids::BILLABLE_WORK_ENTRY), + ), + ("ir_cron".into(), None), + ]; + + assert_eq!( + got, expected, + "schema_classids must return one entry per table, in insertion order, \ + with None for uncodified tables", + ); +} diff --git a/docs/ODOO-OGAR-MIGRATION-SPRINT.md b/docs/ODOO-OGAR-MIGRATION-SPRINT.md index 0aac2b4..3338cbe 100644 --- a/docs/ODOO-OGAR-MIGRATION-SPRINT.md +++ b/docs/ODOO-OGAR-MIGRATION-SPRINT.md @@ -99,3 +99,37 @@ subscribe to PR activity, continue autoattended. `target/` (keep lance-graph's 2.9 G unless space-critical). - **`ogar-emit`-gated.** Default build stays serde-only; the pull + tests compile only under `--features ogar-emit`. + +--- + +## Sprint outcome (2026-06-22) — SHIPPED + +The 5+3 sprint + 5+3 review ran to completion. Status against the plan: + +| Worker | Planned | Outcome | +|---|---|---| +| S1 | classid-in-DDL | ✅ `emit_via_ogar_annotated` — classid in the `DEFINE TABLE … COMMENT 'classid:0x00020202'` clause (review-corrected from a `--` header, which SurrealDB drops at parse time). + `class_ids` re-export. | +| S2 | convergence-pin test | ✅ `tests/odoo_ogar_convergence.rs` — symbol-bound, full-surface, + negative pin (7 tests). | +| S3 | CLI surface | ✅ `od-codegen --classids` — verified end-to-end on `data/slice_2.spo.ndjson`. | +| S4 | docs | ✅ README "Pulling the canonical classid (OGAR)" section. | +| S5 | rev-freshness | ✅ Cargo.toml pin-freshness annotation (08a9c979 = #94 merge; main 5ee87b5 additive/test-only). | +| +Wave B | — | ✅ `examples/classid_pull.rs` — the full pull end to end. | + +**5+3 review (two rounds, 10 specialist reviews):** unanimous LAND. +`brutally-honest-tester` (toolchain) · `baton-handoff-auditor` CLEAN · +`core-first-architect` TARGETS-CORE · `convergence-architect` OPPORTUNITY · +`integration-lead` LANDS CLEANLY. + +**Auto-resolved from review:** R4's COMMENT-clause seam (the classid now +survives into SurrealDB's catalog, not just the `.surql` file). + +**Filed, not forced:** `PROBE-OGAR-ID-TO-CONCEPT-NAME` +(`specs/UPSTREAM_WISHLIST.md`) — the concept-*name* enrichment + the +`canonical_concept`-on-`Class` fusion are gated on an OGAR-side `u16 → &str` +reverse lookup that doesn't exist yet. + +**Tests:** 32 lib + 6 bin + 7 convergence + 23 integration/doc green under +`--features cli,ogar-emit`; clippy exit 0 (zero new warnings); fmt-clean. + +Shipped as odoo-rs PR #3 on `claude/odoo-classid-consume` (builds on the +merged #2 classid pull).