diff --git a/crates/od-ontology/src/lib.rs b/crates/od-ontology/src/lib.rs index bcbf669..4022029 100644 --- a/crates/od-ontology/src/lib.rs +++ b/crates/od-ontology/src/lib.rs @@ -57,7 +57,10 @@ mod surreal_ast; mod triple; #[cfg(feature = "ogar-emit")] -pub use ogar_bridge::{emit_via_ogar, schema_to_classes}; +pub use ogar_bridge::{ + concept_classid, emit_via_ogar, render_classid, schema_classids, schema_to_classes, + ODOO_APP_PREFIX, +}; pub use emit::corpus_to_schema; pub use inheritance::InheritanceMap; diff --git a/crates/od-ontology/src/ogar_bridge.rs b/crates/od-ontology/src/ogar_bridge.rs index 8d15155..3b068c8 100644 --- a/crates/od-ontology/src/ogar_bridge.rs +++ b/crates/od-ontology/src/ogar_bridge.rs @@ -46,6 +46,7 @@ //! - **`DEFINE INDEX`** (`_sql_constraints` unique tuples): not part of the //! OGAR IR; dropped. +use ogar_vocab::ports::{OdooPort, PortSpec}; use ogar_vocab::{Association, AssociationKind, Attribute, Class}; use crate::surreal_ast::{FieldDefinition, Kind, Schema, TableDefinition}; @@ -67,6 +68,71 @@ pub fn emit_via_ogar(schema: &Schema) -> String { ogar_adapter_surrealql::emit_surrealql_ddl(&schema_to_classes(schema)) } +// ── Canonical classid pull (the "pull OGAR via class" deliverable) ────── +// +// `schema_to_classes` above lowers *structure* (tables → `Class` shells with +// attributes + associations). The functions below lower *identity*: they pull +// the canonical OGAR `classid` for an Odoo model straight from the [`OdooPort`] +// alias table — NO bridge object, NO registry, NO TTL hydration, just a pure +// static lookup over the shared codebook (OGAR #94). This is the consumer +// migration target (lance-graph #589 / OGAR `CONSUMER-MIGRATION-HOWTO`): a +// consumer names a surface concept and gets back the shared id that WoA +// `Stundenzettel`, SMB `Stundenzettel`, OpenProject/Redmine `TimeEntry`, and +// Odoo `account.analytic.line` all converge on (`BILLABLE_WORK_ENTRY`, the +// planner↔ERP billable-hours pin). + +/// Odoo's APP-prefix — the high `u16` of the 32-bit *render* classid per OGAR's +/// `APP-CLASS-CODEBOOK-LAYOUT` (`classid = APP(hi) ‖ concept(lo)`). The low +/// `u16` is the shared cross-app concept (WHAT it is — RBAC + ontology); the +/// high `u16` is the per-app render lens (WHOSE template). `0x0002` is Odoo's +/// lens, so every Odoo-rendered id is `0x0002_`. +pub const ODOO_APP_PREFIX: u16 = 0x0002; + +/// Pull the canonical OGAR **concept** classid (the shared low `u16`) for an +/// Odoo model name, straight through [`OdooPort`] — the static alias table, no +/// bridge object, no registry, no hydration. +/// +/// The SPO corpus names models in Odoo's *table* form (`.` replaced by `_`: +/// `account_move`, `account_analytic_line`); [`OdooPort`]'s aliases are the +/// *model* form (`account.move`, `account.analytic.line`). Deriving the table +/// name as `_name.replace('.', '_')` is the canonical Odoo convention, so the +/// inverse `_`→`.` recovers the model name losslessly; a name already in model +/// form (no `_`) passes through unchanged. The raw name is also tried as a +/// fallback so a caller that already holds a dotted model name still resolves. +/// +/// Returns `None` for a model outside the codebook (e.g. `ir_cron`); the +/// structural lowering in [`schema_to_classes`] still covers such a table — only +/// the canonical-identity pull is codebook-gated. +/// +/// `account_move` → `0x0202` (`COMMERCIAL_DOCUMENT`), `account_analytic_line` → +/// `0x0103` (`BILLABLE_WORK_ENTRY`, the cross-arm bridge), `res_partner` → +/// `0x0204` (`BILLING_PARTY`). +#[must_use] +pub fn concept_classid(model: &str) -> Option { + OdooPort::class_id(&model.replace('_', ".")).or_else(|| OdooPort::class_id(model)) +} + +/// The full 32-bit **render** classid for an Odoo model: Odoo's APP prefix +/// ([`ODOO_APP_PREFIX`], `0x0002`) in the high `u16`, the shared canonical +/// [`concept_classid`] in the low `u16`. `account_move` → `0x0002_0202`; +/// `account_analytic_line` → `0x0002_0103`. `None` for an unaliased model. +#[must_use] +pub fn render_classid(model: &str) -> Option { + concept_classid(model).map(|lo| (u32::from(ODOO_APP_PREFIX) << 16) | u32::from(lo)) +} + +/// Resolve every table in `schema` to its canonical OGAR concept classid, +/// pairing the table name with its [`concept_classid`]. Tables outside the +/// `OdooPort` codebook resolve to `None`. Order mirrors `schema.tables`. +#[must_use] +pub fn schema_classids(schema: &Schema) -> Vec<(String, Option)> { + schema + .tables + .iter() + .map(|t| (t.name.clone(), concept_classid(&t.name))) + .collect() +} + fn table_to_class(table: &TableDefinition) -> Class { let mut class = Class::new(table.name.as_str()); for field in &table.fields { @@ -214,4 +280,78 @@ mod tests { assert_eq!(a.kind, AssociationKind::HasMany); assert_eq!(a.class_name.as_deref(), Some("account_move_line")); } + + // ── Canonical classid pull ────────────────────────────────────────── + + #[test] + fn concept_classid_pulls_commercial_document_for_account_move() { + // account_move (table form) → account.move (model form) → + // COMMERCIAL_DOCUMENT 0x0202, straight from the OdooPort alias table. + assert_eq!(concept_classid("account_move"), Some(0x0202)); + } + + #[test] + fn concept_classid_pulls_billable_work_entry_for_analytic_line() { + // The planner↔ERP convergence pin: account.analytic.line is the + // cross-arm bridge into the project domain. Same id WoA/SMB + // Stundenzettel + OpenProject/Redmine TimeEntry resolve to. + assert_eq!(concept_classid("account_analytic_line"), Some(0x0103)); + } + + #[test] + fn concept_classid_pulls_billing_party_for_res_partner() { + assert_eq!(concept_classid("res_partner"), Some(0x0204)); + } + + #[test] + fn concept_classid_resolves_multi_dot_model_names() { + // Two underscores → two dots: account_move_line → account.move.line + // → COMMERCIAL_LINE_ITEM 0x0201. + assert_eq!(concept_classid("account_move_line"), Some(0x0201)); + } + + #[test] + fn concept_classid_accepts_already_dotted_model_form() { + // A caller already holding the dotted model name resolves via the + // raw-name fallback (the normalize is a no-op without underscores). + assert_eq!(concept_classid("account.move"), Some(0x0202)); + } + + #[test] + fn concept_classid_is_none_outside_the_codebook() { + assert_eq!(concept_classid("ir_cron"), None); + assert_eq!(concept_classid(""), None); + } + + #[test] + fn render_classid_stamps_odoo_app_prefix() { + // 0x0002 (Odoo render lens) ‖ low concept. + assert_eq!(render_classid("account_move"), Some(0x0002_0202)); + assert_eq!(render_classid("account_analytic_line"), Some(0x0002_0103)); + assert_eq!(render_classid("res_partner"), Some(0x0002_0204)); + assert_eq!(render_classid("ir_cron"), None); + assert_eq!(ODOO_APP_PREFIX, 0x0002); + } + + #[test] + fn schema_classids_resolves_every_table_in_order() { + 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 ids = schema_classids(&schema); + assert_eq!( + ids, + vec![ + ("account_move".to_string(), Some(0x0202)), + ("account_analytic_line".to_string(), Some(0x0103)), + ("ir_cron".to_string(), None), + ] + ); + } } diff --git a/docs/ODOO-OGAR-MIGRATION-SPRINT.md b/docs/ODOO-OGAR-MIGRATION-SPRINT.md new file mode 100644 index 0000000..0aac2b4 --- /dev/null +++ b/docs/ODOO-OGAR-MIGRATION-SPRINT.md @@ -0,0 +1,101 @@ +# Odoo → OGAR migration — classid-pull sprint plan + +> **Goal (operator, 2026-06-21):** *"land where that open PR is +> [lance-graph #589], without bridge, only pulling OGAR via class"* — and +> *"planning (OpenProject) and ERP (Odoo) become reusable ontologies so the +> planner times align with billable hours."* +> +> **Method:** autoattended 5+3 — a parallel sprint of 5 worker + 3 meta +> (Sonnet) agents, then a 5+3 review council gates the PR. Never-stop loop; +> disk checked before every `cargo`; `target/` auto-cleared when tight. + +--- + +## The migration arc (OGAR #95 W3 — Odoo) + +The OGAR `APP-CODEBOOK-MIGRATION-PLAN` W3 spec for Odoo, with this repo's +status against each step: + +| Step | What | Status | +|---|---|---| +| **W3.1** | Lower `od-ontology` onto `ogar_vocab::Class`; map model objects onto canonical commerce classids **via `OdooPort`** | ✅ **DONE** — `schema_to_classes` (structure, commit c2618eb) + `concept_classid`/`render_classid`/`schema_classids` (identity, this PR) | +| **W3.2** | Emit via `ogar-adapter-surrealql` | ✅ DONE — `emit_via_ogar` (parallel emit, c2618eb) | +| **W3.3** | DELETE the fork (`surreal_ast` + `triple` + native emit) | ⛔ **GATED** — blocked on the shared emitter covering the documented Stage-2 gaps (One2many/Many2many `array`, computed `VALUE`, `DEFINE FUNCTION`/`EVENT`/`INDEX`). Until then the native path stays; deleting it now loses reactive wiring. | +| **W3.4** | `ir.model.access` ∧ `ir.rule` → `lance_graph_rbac::authorize` | ⛔ **GATED** — keystone (`CLASSID-RBAC-KEYSTONE-SPEC`) is CONJECTURE until `PROBE-OGAR-RBAC-AUTHORIZE` runs bit-for-bit vs Odoo. Not this repo's lane to mint the keystone. | +| **W3.5** | Private mint only if no canonical analogue | ✅ N/A — every Odoo model in scope has a canonical analogue (commerce arm `0x02XX` + cross-arm `0x0103`). No private mint needed. | + +**This PR closes W3.1's identity half** — the DoD #3 *"the classid pull is a +pure static function call"* that Stage 1 left open (`OdooPort` was named only +in a doc-comment). Steps 3 + 4 are honestly deferred behind their gates. + +--- + +## What shipped this PR (the classid pull) + +```rust +// crates/od-ontology/src/ogar_bridge.rs (feature = "ogar-emit") +pub const ODOO_APP_PREFIX: u16 = 0x0002; // Odoo render lens +pub fn concept_classid(model: &str) -> Option; // shared concept (low u16) +pub fn render_classid(model: &str) -> Option; // 0x0002 || concept +pub fn schema_classids(schema: &Schema) -> Vec<(String, Option)>; +``` + +`APP ‖ class` codebook (OGAR `APP-CLASS-CODEBOOK-LAYOUT`): the **low u16** is +WHAT it is (shared RBAC + ontology, cross-app); the **high u16** is WHOSE +render (per-app `ClassView`/template). Odoo's lens is `0x0002`. + +The convergence pin: `account_analytic_line → 0x0103` (`BILLABLE_WORK_ENTRY`) +— identical to WoA/SMB `Stundenzettel` and OpenProject/Redmine `TimeEntry`. +One codebook lookup at every planner→ERP→billing hop. + +--- + +## 5+3 sprint — the next additive increment ("consume the pull") + +The pull is exposed but not yet *consumed*. Five parallel Sonnet workers, +each scoped to **distinct files** (no shared-tree write race), edit-only; +the Opus orchestrator compiles/tests **once** centrally. + +| # | Worker (Sonnet) | Scope (distinct files) | Deliverable | +|---|---|---|---| +| **S1** | classid-in-DDL | `ogar_bridge.rs` (emit path only) | Stamp `concept_classid` into emitted DDL as a `DEFINE TABLE … COMMENT 'classid:0xAABB'` so the canonical id rides the schema. Gap-safe: tables with `None` emit no classid comment. | +| **S2** | convergence-pin test | `tests/odoo_ogar_convergence.rs` (new) | Cross-repo pin mirroring OGAR's 5-way test on the odoo-rs side: `concept_classid("account_analytic_line") == 0x0103`; documents the planner↔ERP alignment as a regression gate. | +| **S3** | CLI surface | `src/bin/od_codegen.rs` (`--classids` subcmd) | `od-codegen --classids` prints the `table → 0xAABB` map for a corpus, so the pull is operator-inspectable. `required-features = ["cli", "ogar-emit"]`. | +| **S4** | docs | `docs/ODOO-OGAR-MIGRATION-SPRINT.md` (this file) + root `README.md` | Keep the migration arc + status table current; add a "classid pull" section to the README. | +| **S5** | rev-freshness | `crates/od-ontology/Cargo.toml` (comment only) | Confirm the pinned OGAR rev `08a9c979` carries the full `OdooPort` alias set (it does — verified pre-sprint); annotate the pin comment with the verification + the post-#96 main sha for a future bump. | + +**+3 meta (Sonnet):** +- **M1 preflight-drift** — verify each S-scope against the actual tree before spawn (no stale assumptions). +- **M2 baton/boundary** — check the `OdooPort` classid contract is consumed identically to how OGAR exports it (no drift on the low-u16 concept ids). +- **M3 consolidation** — atomic-consolidation pass: one coherent commit set, no overlapping edits, board/docs updated in the same batch. + +--- + +## 5+3 review council — the PR gate (never-stop) + +Runs in parallel against the pushed branch; main thread (Opus) synthesizes +a LAND / REVISE / REJECT verdict and **auto-resolves** any P0 before merge. + +| # | Reviewer | Lens | +|---|---|---| +| R1 | brutally-honest-tester (PP-13) | Rust pre-merge gate: clippy/fmt/test, codex P1 anti-patterns | +| R2 | baton-handoff-auditor (PP-15) | OGAR↔odoo-rs classid boundary: does the pull roundtrip the codebook contract? | +| R3 | core-first-architect | Core-first doctrine: adapter ASSUMES the Core (OGAR), carries no state | +| R4 | convergence-architect (PP-14) | Is the `_`→`.` normalize the right seam? any 0-friction alignment missed? | +| R5 | integration-lead | Cross-session: does this land cleanly with OGAR #94/#95, lance-graph #587/#589? | +| +3 | meta synthesis (Opus main thread) | Consolidate findings → verdict → auto-resolve loop | + +**Gate rule:** any P0 → fix + re-push + re-gate (never-stop). On green → +subscribe to PR activity, continue autoattended. + +--- + +## Guardrails (operator-locked) + +- **Additive only.** Native `ToSql` emit untouched; W3.3 delete is gated. +- **No bridge.** The pull is a static `OdooPort::class_id` call — no registry, + no hydration, no `UnifiedBridge` on the consumer side. +- **Disk before cargo.** `df -h` before every build; auto-clear stale + `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`.