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
1,247 changes: 583 additions & 664 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/lance-graph-ogar/src/bridges/medcare_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ use crate::bridges::unified::UnifiedBridge;
// `HealthcarePort::NAMESPACE` / `::aliases()` are `PortSpec` associated
// items — the trait must be in scope for the resolution to work (codex
// P1 on PR #570). Same import in the test module below.
use ogar_vocab::ports::PortSpec;
pub use ogar_vocab::ports::HealthcarePort;
use ogar_vocab::ports::PortSpec;

/// MedCare `NamespaceBridge` — alias over the generic harness, locked to
/// the `Healthcare` namespace via [`HealthcarePort`].
Expand Down
35 changes: 30 additions & 5 deletions crates/lance-graph-ogar/src/bridges/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
//! `ogar_vocab::class_ids` (the OGAR codebook + `PortSpec` class schema),
//! so they live here in `lance-graph-ogar`, NOT in `lance-graph-ontology`
//! (which is OGIT and must not depend on `ogar-vocab`). The OGIT-side
//! legacy bridges (`WoaBridge` / `SpearBridge` / `SharePointBridge` /
//! `OgitBridge`) stay in `lance_graph_ontology::bridges`.
//! legacy bridges (`SpearBridge` / `SharePointBridge` / `OgitBridge` +
//! the legacy `WoaBridge` pass-through) stay in
//! `lance_graph_ontology::bridges`.
//!
//! # Two layers
//!
Expand All @@ -17,7 +18,8 @@
//! `ogar_vocab::ports::PortSpec`. Adding a port is `impl PortSpec for
//! FooPort {…}` in OGAR — no bridge boilerplate here.
//! - The **per-port aliases** ([`OpenProjectBridge`], [`RedmineBridge`],
//! [`MedcareBridge`]) are thin `type` aliases over the harness.
//! [`MedcareBridge`], [`WoaBridge`], [`SmbBridge`], [`OdooBridge`]) are
//! thin `type` aliases over the harness.
//!
//! # OGAR-driven ports (`UnifiedBridge<P>` aliases)
//!
Expand All @@ -28,23 +30,46 @@
//! - [`RedmineBridge`]: `UnifiedBridge<ogar_vocab::ports::RedminePort>` —
//! locks to the `Redmine` namespace. `Issue` / `TimeEntry` / `Project`
//! etc. resolve to the SAME OGAR canonical class_ids as the
//! OpenProject equivalents, so cross-fork convergence is the default
//! not the exception.
//! OpenProject equivalents.
//! - [`MedcareBridge`]: `UnifiedBridge<ogar_vocab::ports::HealthcarePort>`
//! — locks to the `Healthcare` namespace. `Patient` / `Diagnosis` /
//! `LabValue` / `Medication` / `Treatment` / `Visit` / `VitalSign`
//! resolve to the `0x09XX` Health codebook (Northstar T9).
//! - [`WoaBridge`]: `UnifiedBridge<ogar_vocab::ports::WoaPort>` — locks
//! to the `WorkOrder` namespace. `Customer` / `Vorgang` / `Position` /
//! `Stundenzettel` / `TimesheetActivity` / `TimeEntry` etc. resolve to
//! the canonical commerce `0x02XX` block + `BILLABLE_WORK_ENTRY`
//! (0x0103). The planner-side `TimeEntry` (OpenProject/Redmine) and
//! the WoA `Stundenzettel` collapse to the same id — the operator's
//! *"planner times align with billable hours"* statement realised as
//! data.
//! - [`SmbBridge`]: `UnifiedBridge<ogar_vocab::ports::SmbPort>` — locks
//! to the `SMB` namespace. `Kunde` / `Auftrag` / `Rechnung` /
//! `Stundenzettel` etc. resolve to the SAME commerce block + same
//! `BILLABLE_WORK_ENTRY`, giving smb-office-rs cross-fork convergence
//! with WoA + OpenProject + Odoo.
//! - [`OdooBridge`]: `UnifiedBridge<ogar_vocab::ports::OdooPort>` —
//! locks to the `Odoo` namespace. Odoo's Python class names
//! (`res.partner` / `account.move` / `account.move.line` /
//! `hr.attendance` / …) resolve to the same canonical block.
//! Closes the planner→ERP→billing convergence chain at every hop.

pub mod unified;

mod medcare_bridge;
mod odoo_bridge;
mod openproject_bridge;
mod redmine_bridge;
mod smb_bridge;
mod woa_bridge;

pub use medcare_bridge::{HealthcarePort, MedcareBridge};
pub use odoo_bridge::{OdooBridge, OdooPort};
pub use openproject_bridge::{OpenProjectBridge, OpenProjectPort};
pub use redmine_bridge::{RedmineBridge, RedminePort};
pub use smb_bridge::{SmbBridge, SmbPort};
pub use unified::UnifiedBridge;
pub use woa_bridge::{WoaBridge, WoaPort};

// Compatibility shims for the pre-migration constants. `bridges`
// previously re-exported `OPENPROJECT_CODEBOOK` / `REDMINE_CODEBOOK`
Expand Down
116 changes: 116 additions & 0 deletions crates/lance-graph-ogar/src/bridges/odoo_bridge.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! Odoo (ERP) tenant bridge — thin type alias over
//! [`crate::bridges::unified::UnifiedBridge`] parameterised by
//! [`ogar_vocab::ports::OdooPort`].
//!
//! Odoo's OGAR-driven port surface (OGAR PR #94, 2026-06-21). Companion
//! to the [`lance_graph_supervisor::actors::OdooConsumerActor`]
//! skeleton (lance-graph activation profile, supervisor slot G=50) and
//! the four-way alignment seam in
//! `lance_graph_callcenter::odoo_alignment` (Seam decision 1 /
//! Option B — odoo inherits FIBO/SKR family slots via
//! `owl:equivalentClass` routing; no new CAM codebook family minted).
//!
//! # The convergence pin (operator value statement 2026-06-21)
//!
//! Odoo's `HrAttendance` and `account.move.line(qty=hours)` resolve to
//! the SAME canonical [`ogar_vocab::class_ids::BILLABLE_WORK_ENTRY`]
//! the planner consumers (OpenProject `TimeEntry`, Redmine `TimeEntry`)
//! and the German ERP consumers (WoA `Stundenzettel`, SMB
//! `Stundenzettel`) all resolve to. The planner→ERP→billing chain
//! collapses into one codebook lookup at every hop.

use crate::bridges::unified::UnifiedBridge;
pub use ogar_vocab::ports::OdooPort;
use ogar_vocab::ports::PortSpec;

/// Odoo `NamespaceBridge` — alias over the generic harness, locked to
/// the `Odoo` namespace via [`OdooPort`].
pub type OdooBridge = UnifiedBridge<OdooPort>;

/// Canonical namespace name for Odoo. Mirrors `OdooPort::NAMESPACE`.
pub const NAMESPACE: &str = OdooPort::NAMESPACE;

#[cfg(test)]
mod tests {
use super::*;
use lance_graph_ontology::bridge::{BridgeError, NamespaceBridge};
use lance_graph_ontology::error::Error;
use lance_graph_ontology::namespace::NamespaceId;
use lance_graph_ontology::registry::OntologyRegistry;
use ogar_vocab::ports::PortSpec;
use std::fs;
use std::sync::Arc;

fn registry_with_odoo() -> Arc<OntologyRegistry> {
let ttl = r#"
@prefix ogit: <http://www.purl.org/ogit/> .
@prefix ogit.Odoo: <http://www.purl.org/ogit/Odoo/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

ogit.Odoo:Partner
a rdfs:Class;
rdfs:subClassOf ogit:Entity;
rdfs:label "Partner";
ogit:scope "NTO";
ogit:parent ogit:Node;
ogit:mandatory-attributes ( ogit:id );
ogit:optional-attributes ( ) ;
.
"#;
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join("Odoo")).unwrap();
fs::write(tmp.path().join("Odoo").join("ents.ttl"), ttl).unwrap();
let registry = Arc::new(OntologyRegistry::new_in_memory());
registry.hydrate_once_sync(tmp.path(), &["Odoo"]).unwrap();
registry
}

#[test]
fn new_succeeds_when_namespace_registered() {
let registry = registry_with_odoo();
let bridge = OdooBridge::new(registry).unwrap();
assert_ne!(bridge.g_lock(), NamespaceId::UNKNOWN);
}

#[test]
fn new_returns_unknown_namespace_when_not_registered() {
let registry = Arc::new(OntologyRegistry::new_in_memory());
match OdooBridge::new(registry) {
Ok(_) => panic!("expected UnknownNamespace, got Ok(_)"),
Err(Error::UnknownNamespace(name)) => assert_eq!(name, "Odoo"),
Err(other) => panic!("expected UnknownNamespace, got {other:?}"),
}
}

#[test]
fn bridge_id_is_lowercase_odoo() {
let bridge = OdooBridge::new(registry_with_odoo()).unwrap();
assert_eq!(bridge.bridge_id(), "odoo");
}

#[test]
fn entity_for_each_codebook_entry_returns_its_canonical_class_id() {
let bridge = OdooBridge::new(registry_with_odoo()).unwrap();
for &(public_name, expected_id) in OdooPort::aliases() {
let entity = bridge.entity(public_name).unwrap_or_else(|e| {
panic!("codebook entry `{public_name}` failed to resolve: {e:?}")
});
assert_eq!(
entity.schema_ptr.entity_type_id(),
expected_id,
"codebook entry `{public_name}` should resolve to 0x{expected_id:04X}",
);
}
}

#[test]
fn entity_for_non_codebook_name_falls_back_to_registry_lookup() {
let bridge = OdooBridge::new(registry_with_odoo()).unwrap();
match bridge.entity("NotAConcept") {
Err(BridgeError::NotInScope { public_name, .. }) => {
assert_eq!(public_name, "NotAConcept")
}
other => panic!("expected NotInScope, got {other:?}"),
}
}
}
2 changes: 1 addition & 1 deletion crates/lance-graph-ogar/src/bridges/openproject_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ use crate::bridges::unified::UnifiedBridge;
// `OpenProjectPort::NAMESPACE` / `::aliases()` are `PortSpec`
// associated items — the trait must be in scope for the resolution to
// work (codex P1 on PR #570). Same import in the test module below.
use ogar_vocab::ports::PortSpec;
pub use ogar_vocab::ports::OpenProjectPort;
use ogar_vocab::ports::PortSpec;

/// OpenProject `NamespaceBridge` — alias over the generic harness.
pub type OpenProjectBridge = UnifiedBridge<OpenProjectPort>;
Expand Down
143 changes: 143 additions & 0 deletions crates/lance-graph-ogar/src/bridges/smb_bridge.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//! SMB (small-and-medium-business German office ERP) tenant bridge —
//! thin type alias over [`crate::bridges::unified::UnifiedBridge`]
//! parameterised by [`ogar_vocab::ports::SmbPort`].
//!
//! SMB's OGAR-driven port surface (OGAR PR #93, 2026-06-21). The legacy
//! OGIT-side [`lance_graph_ontology::bridges::OgitBridge`] (pass-through
//! for raw OGIT URIs) stays in place for tools that don't need codebook
//! synthesis; this OGAR-side bridge gives smb-office-rs cross-fork
//! convergence with WoA + OpenProject + Odoo on the canonical class_ids.
//!
//! # The convergence pin (operator value statement 2026-06-21)
//!
//! SMB's `Stundenzettel` / `TimeEntry` / `Zeiterfassung` resolve to
//! [`ogar_vocab::class_ids::BILLABLE_WORK_ENTRY`] via this bridge's
//! `entity()` codebook synthesis path — the SAME id WoA's Stundenzettel,
//! OpenProject's TimeEntry, and Odoo's HrAttendance / account.move.line
//! (qty=hours) resolve to. *"Planner times align with billable hours"*
//! becomes a codebook lookup, not a translation layer. See
//! `ogar_vocab::ports::tests::time_entry_converges_across_planner_and_erp_ports`.

use crate::bridges::unified::UnifiedBridge;
use ogar_vocab::ports::PortSpec;
pub use ogar_vocab::ports::SmbPort;

/// SMB `NamespaceBridge` — alias over the generic harness, locked to
/// the `SMB` namespace via [`SmbPort`].
pub type SmbBridge = UnifiedBridge<SmbPort>;

/// Canonical namespace name for SMB. Mirrors `SmbPort::NAMESPACE`.
pub const NAMESPACE: &str = SmbPort::NAMESPACE;

#[cfg(test)]
mod tests {
use super::*;
use lance_graph_ontology::bridge::{BridgeError, NamespaceBridge};
use lance_graph_ontology::error::Error;
use lance_graph_ontology::namespace::NamespaceId;
use lance_graph_ontology::registry::OntologyRegistry;
use ogar_vocab::class_ids;
use ogar_vocab::ports::PortSpec;
use std::fs;
use std::sync::Arc;

fn registry_with_smb() -> Arc<OntologyRegistry> {
let ttl = r#"
@prefix ogit: <http://www.purl.org/ogit/> .
@prefix ogit.SMB: <http://www.purl.org/ogit/SMB/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .

ogit.SMB:Kunde
a rdfs:Class;
rdfs:subClassOf ogit:Entity;
rdfs:label "Kunde";
ogit:scope "NTO";
ogit:parent ogit:Node;
ogit:mandatory-attributes ( ogit:id );
ogit:optional-attributes ( ) ;
.
"#;
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join("SMB")).unwrap();
fs::write(tmp.path().join("SMB").join("ents.ttl"), ttl).unwrap();
let registry = Arc::new(OntologyRegistry::new_in_memory());
registry.hydrate_once_sync(tmp.path(), &["SMB"]).unwrap();
registry
}

#[test]
fn new_succeeds_when_namespace_registered() {
let registry = registry_with_smb();
let bridge = SmbBridge::new(registry).unwrap();
assert_ne!(bridge.g_lock(), NamespaceId::UNKNOWN);
}

#[test]
fn new_returns_unknown_namespace_when_not_registered() {
let registry = Arc::new(OntologyRegistry::new_in_memory());
match SmbBridge::new(registry) {
Ok(_) => panic!("expected UnknownNamespace, got Ok(_)"),
Err(Error::UnknownNamespace(name)) => assert_eq!(name, "SMB"),
Err(other) => panic!("expected UnknownNamespace, got {other:?}"),
}
}

#[test]
fn bridge_id_is_lowercase_smb() {
let bridge = SmbBridge::new(registry_with_smb()).unwrap();
assert_eq!(bridge.bridge_id(), "smb");
}

#[test]
fn entity_resolves_kunde_to_canonical_billing_party_class_id() {
let bridge = SmbBridge::new(registry_with_smb()).unwrap();
let entity = bridge.entity("Kunde").unwrap();
assert_eq!(entity.schema_ptr.entity_type_id(), class_ids::BILLING_PARTY);
assert_eq!(entity.schema_ptr.entity_type_id(), 0x0204);
}

#[test]
fn entity_resolves_stundenzettel_to_billable_work_entry_planner_convergence() {
let bridge = SmbBridge::new(registry_with_smb()).unwrap();
for public_name in ["Stundenzettel", "TimeEntry", "Zeiterfassung"] {
let entity = bridge
.entity(public_name)
.unwrap_or_else(|e| panic!("{public_name}: {e:?}"));
assert_eq!(
entity.schema_ptr.entity_type_id(),
class_ids::BILLABLE_WORK_ENTRY,
"SMB `{public_name}` must resolve to BILLABLE_WORK_ENTRY (planner-ERP convergence)",
);
}
}

#[test]
fn entity_for_each_codebook_entry_returns_its_canonical_class_id() {
let bridge = SmbBridge::new(registry_with_smb()).unwrap();
for &(public_name, expected_id) in SmbPort::aliases() {
let entity = bridge.entity(public_name).unwrap_or_else(|e| {
panic!("codebook entry `{public_name}` failed to resolve: {e:?}")
});
assert_eq!(
entity.schema_ptr.entity_type_id(),
expected_id,
"codebook entry `{public_name}` should resolve to 0x{expected_id:04X}",
);
}
}

#[test]
fn entity_for_non_codebook_name_falls_back_to_registry_lookup() {
let bridge = SmbBridge::new(registry_with_smb()).unwrap();
match bridge.entity("Artikel") {
// Artikel/Product/SKU isn't in the codebook yet (intentional —
// needs a `0x02XX` codebook extension). Until then it falls
// through to the registry-resolution path which returns
// NotInScope because the TTL fixture only hydrates `Kunde`.
Err(BridgeError::NotInScope { public_name, .. }) => {
assert_eq!(public_name, "Artikel")
}
other => panic!("expected NotInScope, got {other:?}"),
}
}
}
18 changes: 9 additions & 9 deletions crates/lance-graph-ogar/src/bridges/unified.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ impl<P: PortSpec> UnifiedBridge<P> {
/// on the convergence contract.
fn synthesize_codebook_entity(&self, class_id: u16) -> EntityRef {
let ctx_id = NamespaceRegistry::seed_context_id(P::NAMESPACE).unwrap_or(0);
let schema_ptr = SchemaPtr::new(self.g_lock, class_id, SchemaKind::Entity)
.with_context_id(ctx_id);
let schema_ptr =
SchemaPtr::new(self.g_lock, class_id, SchemaKind::Entity).with_context_id(ctx_id);
EntityRef { schema_ptr }
}
}
Expand Down Expand Up @@ -144,13 +144,13 @@ impl<P: PortSpec> NamespaceBridge for UnifiedBridge<P> {
}
}
}
let ptr = self
.registry()
.resolve_uri(uri.as_str())
.ok_or_else(|| BridgeError::NotInScope {
bridge_id: self.bridge_id_static(),
public_name: uri.as_str().to_string(),
})?;
let ptr =
self.registry()
.resolve_uri(uri.as_str())
.ok_or_else(|| BridgeError::NotInScope {
bridge_id: self.bridge_id_static(),
public_name: uri.as_str().to_string(),
})?;
if ptr.namespace_id() != self.g_lock() {
return Err(BridgeError::CrossNamespaceLeak {
bridge_id: self.bridge_id_static(),
Expand Down
Loading
Loading