Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/od-ontology/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
140 changes: 140 additions & 0 deletions crates/od-ontology/src/ogar_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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_<concept>`.
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<u16> {
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<u32> {
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<u16>)> {
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 {
Expand Down Expand Up @@ -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),
]
);
}
}
101 changes: 101 additions & 0 deletions docs/ODOO-OGAR-MIGRATION-SPRINT.md
Original file line number Diff line number Diff line change
@@ -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<record>`, 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<u16>; // shared concept (low u16)
pub fn render_classid(model: &str) -> Option<u32>; // 0x0002 || concept
pub fn schema_classids(schema: &Schema) -> Vec<(String, Option<u16>)>;
```

`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`.