Skip to content

Commit 4a69c09

Browse files
committed
feat(lance-graph-ontology): OpenProjectBridge — impl NamespaceBridge (Northstar C4)
Sibling of MedcareBridge / WoaBridge. Locks to the `OpenProject` namespace; supplies the OpenProject port (`openproject-nexgen-rs` + `op-canon`) with the scoped registry view every consumer that touches OpenProject data on the unified bridge goes through. Tests cover the same contract `bridge_scope_lock.rs` pins for Woa/Medcare — scope lock both ways (OpenProject ↔ Healthcare), bridge_id, g_lock matching the registered namespace id, and construction failure when the namespace is missing. Files: - crates/lance-graph-ontology/src/bridges/openproject_bridge.rs (new) - crates/lance-graph-ontology/src/bridges/mod.rs (add re-export) - crates/lance-graph-ontology/tests/openproject_bridge_scope_lock.rs (new)
1 parent d865560 commit 4a69c09

3 files changed

Lines changed: 236 additions & 1 deletion

File tree

crates/lance-graph-ontology/src/bridges/mod.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Default tenant bridge implementations.
22
//!
3-
//! Five bridges ship today:
3+
//! Six bridges ship today:
44
//!
55
//! - [`OgitBridge`]: pass-through bridge for tools that already speak raw
66
//! OGIT URIs. `bridge_id = "ogit"`. Locks to whatever namespace its
@@ -17,19 +17,27 @@
1717
//! the Sharepoint→smb-office-rs content orchestrator (UploadIntent /
1818
//! DriveScope / ComplianceTagging) — distinct from EmailCorrespondance:
1919
//! one covers documents / drives / sites, the other covers mail.
20+
//! - [`OpenProjectBridge`]: locks to the `OpenProject` namespace.
21+
//! Public names like `WorkPackage` / `TimeEntry` / `Project` resolve
22+
//! to `ogit.OpenProject:*` URIs. Northstar plan §3 C4 — supplies the
23+
//! port (`openproject-nexgen-rs` + `op-canon`) with the scoped
24+
//! registry view every consumer that touches OpenProject data on the
25+
//! unified bridge goes through.
2026
//!
2127
//! The `smb-bridge` and `callcenter-bridge` are NOT created in this
2228
//! session: smb stays on its native ontology fallback, callcenter has its
2329
//! own auth + per-customer scoping concerns that need a separate design pass.
2430
2531
mod medcare_bridge;
2632
mod ogit_bridge;
33+
mod openproject_bridge;
2734
mod sharepoint_bridge;
2835
mod spear_bridge;
2936
mod woa_bridge;
3037

3138
pub use medcare_bridge::MedcareBridge;
3239
pub use ogit_bridge::OgitBridge;
40+
pub use openproject_bridge::OpenProjectBridge;
3341
pub use sharepoint_bridge::SharePointBridge;
3442
pub use spear_bridge::SpearBridge;
3543
pub use woa_bridge::WoaBridge;
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//! OpenProject tenant bridge — locks to the `OpenProject` namespace.
2+
//!
3+
//! Northstar plan §3, C4. Sibling of [`crate::bridges::MedcareBridge`] and
4+
//! [`crate::bridges::WoaBridge`]; supplies the OpenProject port
5+
//! (`openproject-nexgen-rs` + `op-canon`) with the scoped registry view
6+
//! every consumer that touches OpenProject data on the unified bridge
7+
//! goes through.
8+
//!
9+
//! # Scope
10+
//!
11+
//! The `OpenProject` namespace covers the **32 promoted concepts** from
12+
//! the OGAR codebook that the OpenProject corpus owns or shares — see
13+
//! `ogar_vocab::class_ids::ALL`. Public-name resolution
14+
//! (`bridge.entity("WorkPackage")` etc.) returns the `EntityRef` whose
15+
//! `entity_type_id()` is the codebook id (e.g. `0x0102` for
16+
//! `project_work_item`), so downstream consumers reach the same
17+
//! `OgarClassView` arm as Redmine or any other port that emits through
18+
//! the same codebook.
19+
//!
20+
//! # Sibling work
21+
//!
22+
//! - **C5** (RedmineBridge, redmine-rs) — symmetric "Redmine" namespace
23+
//! bridge, same codebook ids.
24+
//! - **C2** (op-canon::class_view) — the run-time projection layer over
25+
//! the same canonical concepts the bridge resolves names against.
26+
27+
use crate::bridge::{BridgeFromRegistry, NamespaceBridge};
28+
use crate::error::{Error, Result};
29+
use crate::namespace::NamespaceId;
30+
use crate::registry::OntologyRegistry;
31+
use std::sync::Arc;
32+
33+
/// Canonical namespace name for OpenProject. Matches the
34+
/// `ogit.OpenProject:` TTL prefix the corpus's per-entity files use.
35+
pub const NAMESPACE: &str = "OpenProject";
36+
37+
/// Scoped registry view locked to the `OpenProject` namespace. Built
38+
/// over an [`OntologyRegistry`] that has already hydrated the
39+
/// OpenProject namespace (via [`OntologyRegistry::hydrate_once_sync`]
40+
/// or a programmatic `register_*` call).
41+
pub struct OpenProjectBridge {
42+
registry: Arc<OntologyRegistry>,
43+
g_lock: NamespaceId,
44+
}
45+
46+
impl OpenProjectBridge {
47+
/// New bridge over the given registry. Returns
48+
/// [`Error::UnknownNamespace`] if the `OpenProject` namespace
49+
/// is not registered yet — callers must hydrate before
50+
/// constructing the bridge.
51+
///
52+
/// # Errors
53+
///
54+
/// - [`Error::UnknownNamespace`] when the registry has no
55+
/// `OpenProject` namespace registered.
56+
pub fn new(registry: Arc<OntologyRegistry>) -> Result<Self> {
57+
let g_lock = registry
58+
.namespace_id(NAMESPACE)
59+
.ok_or_else(|| Error::UnknownNamespace(NAMESPACE.to_string()))?;
60+
Ok(Self { registry, g_lock })
61+
}
62+
}
63+
64+
impl NamespaceBridge for OpenProjectBridge {
65+
fn bridge_id(&self) -> &'static str {
66+
"openproject"
67+
}
68+
fn registry(&self) -> &OntologyRegistry {
69+
&self.registry
70+
}
71+
fn g_lock(&self) -> NamespaceId {
72+
self.g_lock
73+
}
74+
}
75+
76+
impl BridgeFromRegistry for OpenProjectBridge {
77+
fn from_registry(registry: Arc<OntologyRegistry>) -> Result<Self> {
78+
Self::new(registry)
79+
}
80+
}
81+
82+
// Compile-only check that BridgeError is reachable through the entity
83+
// resolution path (matches the convention WoaBridge / MedcareBridge use).
84+
#[allow(dead_code)]
85+
fn _compile_check(b: &OpenProjectBridge) -> std::result::Result<(), crate::bridge::BridgeError> {
86+
let _ = b.entity("WorkPackage")?;
87+
Ok(())
88+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Skip under Miri — TTL fixtures use `tempfile::tempdir()` +
2+
// `std::fs::create_dir_all/write`, both blocked by Miri's isolation.
3+
#![cfg(not(miri))]
4+
5+
//! `OpenProjectBridge` scope-lock test — Northstar plan §3 C4.
6+
//!
7+
//! Verifies that a bridge locked to the `OpenProject` namespace
8+
//! resolves an OpenProject entity by URI, and that the same bridge
9+
//! refuses a `Healthcare` entity (cross-namespace leak refused).
10+
//! Mirrors the contract pinned by `bridge_scope_lock.rs` for the
11+
//! Woa/Medcare pair — symmetric coverage so the addition can't
12+
//! silently relax the scope-lock guarantee.
13+
14+
use lance_graph_ontology::bridges::{MedcareBridge, OpenProjectBridge};
15+
use lance_graph_ontology::{NamespaceBridge, OgitUri, OntologyRegistry};
16+
use std::fs;
17+
use std::sync::Arc;
18+
19+
const TTL: &str = r#"
20+
@prefix ogit: <http://www.purl.org/ogit/> .
21+
@prefix ogit.OpenProject: <http://www.purl.org/ogit/OpenProject/> .
22+
@prefix ogit.Healthcare: <http://www.purl.org/ogit/Healthcare/> .
23+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
24+
25+
ogit.OpenProject:WorkPackage
26+
a rdfs:Class;
27+
rdfs:subClassOf ogit:Entity;
28+
rdfs:label "WorkPackage";
29+
ogit:scope "NTO";
30+
ogit:parent ogit:Node;
31+
ogit:mandatory-attributes (
32+
ogit:id
33+
);
34+
ogit:optional-attributes ( ) ;
35+
.
36+
37+
ogit.Healthcare:Patient
38+
a rdfs:Class;
39+
rdfs:subClassOf ogit:Entity;
40+
rdfs:label "Patient";
41+
ogit:scope "NTO";
42+
ogit:parent ogit:Node;
43+
ogit:mandatory-attributes (
44+
ogit:id
45+
);
46+
ogit:optional-attributes ( ) ;
47+
.
48+
"#;
49+
50+
fn make_registry() -> Arc<OntologyRegistry> {
51+
let tmp = tempfile::tempdir().unwrap();
52+
fs::create_dir_all(tmp.path().join("OpenProject")).unwrap();
53+
fs::create_dir_all(tmp.path().join("Healthcare")).unwrap();
54+
fs::write(tmp.path().join("OpenProject").join("ents.ttl"), TTL).unwrap();
55+
fs::write(tmp.path().join("Healthcare").join("ents.ttl"), TTL).unwrap();
56+
let registry = Arc::new(OntologyRegistry::new_in_memory());
57+
registry
58+
.hydrate_once_sync(tmp.path(), &["OpenProject", "Healthcare"])
59+
.unwrap();
60+
// Keep the tempdir alive for the duration of the test by leaking; the
61+
// test process exits shortly after.
62+
std::mem::forget(tmp);
63+
registry
64+
}
65+
66+
#[test]
67+
fn openproject_bridge_resolves_openproject_entity_by_uri() {
68+
let registry = make_registry();
69+
let bridge = OpenProjectBridge::new(registry).unwrap();
70+
let uri = OgitUri::parse("ogit.OpenProject:WorkPackage").unwrap();
71+
let entity = bridge.entity_by_uri(&uri).expect("scoped URI resolution");
72+
assert_eq!(entity.schema_ptr.namespace_id(), bridge.g_lock());
73+
}
74+
75+
#[test]
76+
fn openproject_bridge_rejects_healthcare_entity_by_uri() {
77+
let registry = make_registry();
78+
let bridge = OpenProjectBridge::new(registry).unwrap();
79+
let uri = OgitUri::parse("ogit.Healthcare:Patient").unwrap();
80+
let result = bridge.entity_by_uri(&uri);
81+
assert!(
82+
result.is_err(),
83+
"expected scope lock to refuse cross-namespace, got {result:?}",
84+
);
85+
let err = result.unwrap_err();
86+
let msg = format!("{err:?}");
87+
assert!(
88+
msg.contains("CrossNamespaceLeak") || msg.contains("NotInScope"),
89+
"expected CrossNamespaceLeak or NotInScope, got {msg}",
90+
);
91+
}
92+
93+
#[test]
94+
fn medcare_bridge_rejects_openproject_entity_by_uri() {
95+
// Symmetry pin: the Healthcare bridge equally refuses an OpenProject
96+
// entity. Together with the previous test, the cross-namespace lock
97+
// is bidirectional.
98+
let registry = make_registry();
99+
let bridge = MedcareBridge::new(registry).unwrap();
100+
let uri = OgitUri::parse("ogit.OpenProject:WorkPackage").unwrap();
101+
let result = bridge.entity_by_uri(&uri);
102+
assert!(
103+
result.is_err(),
104+
"expected medcare scope lock to refuse OpenProject URI, got {result:?}",
105+
);
106+
let err = result.unwrap_err();
107+
let msg = format!("{err:?}");
108+
assert!(
109+
msg.contains("CrossNamespaceLeak") || msg.contains("NotInScope"),
110+
"expected CrossNamespaceLeak or NotInScope, got {msg}",
111+
);
112+
}
113+
114+
#[test]
115+
fn openproject_bridge_id_is_lowercase_openproject() {
116+
let registry = make_registry();
117+
let bridge = OpenProjectBridge::new(registry).unwrap();
118+
assert_eq!(bridge.bridge_id(), "openproject");
119+
}
120+
121+
#[test]
122+
fn openproject_bridge_g_lock_matches_openproject_namespace_id() {
123+
let registry = make_registry();
124+
let expected = registry.namespace_id("OpenProject").unwrap();
125+
let bridge = OpenProjectBridge::new(registry).unwrap();
126+
assert_eq!(bridge.g_lock(), expected);
127+
}
128+
129+
#[test]
130+
fn openproject_bridge_construction_fails_when_namespace_missing() {
131+
// No hydration -> no OpenProject namespace -> constructor returns
132+
// Error::UnknownNamespace.
133+
let registry = Arc::new(OntologyRegistry::new_in_memory());
134+
let result = OpenProjectBridge::new(registry);
135+
assert!(
136+
result.is_err(),
137+
"expected UnknownNamespace error, got {result:?}",
138+
);
139+
}

0 commit comments

Comments
 (0)