From ddb6c8402b346335d6444b3ea73a2dcf8d4f12cc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 09:26:30 +0000 Subject: [PATCH 1/3] refactor(bridges): MedcareBridge = UnifiedBridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the last bespoke per-tenant bridge that #570 deferred. With Healthcare now promoted into the OGAR codebook (0x09XX) + a HealthcarePort PortSpec, MedcareBridge becomes a type alias over the generic UnifiedBridge harness — same way OpenProject/Redmine collapsed. - medcare_bridge.rs: drop the bespoke struct + hand-written NamespaceBridge/BridgeFromRegistry impls; `type MedcareBridge = UnifiedBridge`. NAMESPACE mirrors HealthcarePort::NAMESPACE. 8 co-located tests mirroring openproject_bridge::tests (constructor ok/err, bridge_id="medcare", g_lock, Patient->0x0901 codebook synth, seeded ctx_id=2, per-alias resolution, non-codebook fallback to registry). - mod.rs: re-export HealthcarePort; move MedcareBridge from the "legacy struct" list to the OGAR-driven-ports list. - Cargo.toml: repoint ogar-vocab git dep to the claude/medcare-bridge-lance-graph-wmx76z OGAR branch (carries HealthcarePort + the Health codebook). Cargo.lock updated. - openproject_bridge_scope_lock.rs: fix a #570 latent defect — the construction-failure test formatted a Result with {:?} but UnifiedBridge

is intentionally not Debug; assert is_err() without formatting the Ok value. Authorization/audit path is unaffected: it resolves via row() (registry-backed, not overridden by UnifiedBridge), so codebook synthesis on entity() doesn't change canonical-name keying. 295 ontology lib tests + integration + consumer-conformance E1 (medcare_bridge_conforms) green; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- Cargo.lock | 6 + crates/lance-graph-ontology/Cargo.toml | 10 +- .../src/bridges/medcare_bridge.rs | 192 ++++++++++++++---- .../lance-graph-ontology/src/bridges/mod.rs | 25 ++- .../tests/openproject_bridge_scope_lock.rs | 6 +- 5 files changed, 189 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d272dbd3..9c476c7f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4915,6 +4915,7 @@ dependencies = [ "futures", "lance", "lance-graph-contract", + "ogar-vocab", "once_cell", "oxrdf", "oxrdfxml", @@ -5999,6 +6000,11 @@ dependencies = [ "tokio", ] +[[package]] +name = "ogar-vocab" +version = "0.1.0" +source = "git+https://github.com/AdaWorldAPI/OGAR?branch=claude%2Fmedcare-bridge-lance-graph-wmx76z#dd1ef95a774d0e3b98f5022db863e709e56e040a" + [[package]] name = "once_cell" version = "1.21.4" diff --git a/crates/lance-graph-ontology/Cargo.toml b/crates/lance-graph-ontology/Cargo.toml index 5bb00bfee..476cd8c25 100644 --- a/crates/lance-graph-ontology/Cargo.toml +++ b/crates/lance-graph-ontology/Cargo.toml @@ -17,10 +17,12 @@ lance-graph-contract = { path = "../lance-graph-contract" } # (OpenProjectBridge, RedmineBridge, …) are type aliases over the # harness — the namespace / bridge_id / public-name-alias data all # come from OGAR class schema, not from this crate. -# Pinned to the OGAR `claude/port-spec-trait` branch until the OGAR PR -# adding the `ports` module merges to main; will move to `branch = -# "main"` in a follow-up. -ogar-vocab = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "claude/port-spec-trait" } +# Pinned to the OGAR `claude/medcare-bridge-lance-graph-wmx76z` branch: +# it carries `ports::PortSpec` + the project-mgmt ports (from +# port-spec-trait) AND the new `HealthcarePort` + 0x09XX Health codebook +# that `MedcareBridge = UnifiedBridge` needs. Will move +# to `branch = "main"` once both OGAR PRs merge. +ogar-vocab = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "claude/medcare-bridge-lance-graph-wmx76z" } # TTL parser. oxttl is the smallest streaming Turtle parser in the workspace's # dependency graph and matches the shape of OGIT's per-entity .ttl files. diff --git a/crates/lance-graph-ontology/src/bridges/medcare_bridge.rs b/crates/lance-graph-ontology/src/bridges/medcare_bridge.rs index 4a2ee6be0..79aecae91 100644 --- a/crates/lance-graph-ontology/src/bridges/medcare_bridge.rs +++ b/crates/lance-graph-ontology/src/bridges/medcare_bridge.rs @@ -1,44 +1,166 @@ -//! MedCare (healthcare) tenant bridge — locks to the `Healthcare` -//! namespace. The Healthcare namespace itself is reserved and will be -//! populated by a future session (the FMA / SNOMED / RadLex import is the -//! remit of `lance-graph-rdf` in `lance-graph-rdf-fma-snomed-v1`). - -use crate::bridge::{BridgeFromRegistry, NamespaceBridge}; -use crate::error::{Error, Result}; -use crate::namespace::NamespaceId; -use crate::registry::OntologyRegistry; -use std::sync::Arc; - -pub const NAMESPACE: &str = "Healthcare"; - -pub struct MedcareBridge { - registry: Arc, - g_lock: NamespaceId, -} +//! MedCare (healthcare) tenant bridge — now a thin type alias over +//! [`crate::bridges::unified::UnifiedBridge`] parameterised by +//! [`ogar_vocab::ports::HealthcarePort`]. +//! +//! Before the Healthcare codebook promotion (Northstar T9) this file +//! carried a bespoke `MedcareBridge` struct + hand-written +//! `NamespaceBridge` impl — the same boilerplate every per-tenant bridge +//! cloned. lance-graph#570 collapsed `OpenProjectBridge` / `RedmineBridge` +//! onto the generic [`UnifiedBridge

`] harness but explicitly deferred +//! `MedcareBridge` "until Healthcare gets promoted into the codebook". +//! That promotion has now landed: OGAR mints the `0x09XX` Health concepts +//! and `ports::HealthcarePort` carries the namespace / bridge_id / public- +//! name → class_id alias table, so this bridge becomes one line. +//! +//! The differences between bridges (namespace, bridge_id, alias table) +//! all come from the OGAR class schema. Codebook public names (`Patient`, +//! `Diagnosis`, …) synthesize an `EntityRef` whose `entity_type_id()` is +//! the canonical Health class_id; names outside the alias table fall +//! through to the registry-resolution path (so a hydrated TTL entity that +//! is not yet a codebook concept still resolves). The audit / authorization +//! path uses `row()` (registry-backed, not overridden here), so it is +//! unaffected by codebook synthesis on `entity()`. +//! +//! See `crate::bridges::unified::tests` for the generic-level coverage and +//! `tests/bridge_scope_lock.rs` for the Healthcare scope-lock pins. + +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; + +/// MedCare `NamespaceBridge` — alias over the generic harness, locked to +/// the `Healthcare` namespace via [`HealthcarePort`]. +pub type MedcareBridge = UnifiedBridge; + +/// Canonical namespace name for MedCare / Healthcare. Mirrors +/// `HealthcarePort::NAMESPACE` so existing consumers that imported the +/// constant from this module keep building. +pub const NAMESPACE: &str = HealthcarePort::NAMESPACE; + +#[cfg(test)] +mod tests { + //! Co-located unit tests for the migrated alias — constructor + //! success/failure, contract methods, codebook resolution, fallback + //! to registry. Mirrors `openproject_bridge::tests`; only the + //! `MedcareBridge` ident (now `UnifiedBridge`) and the + //! Healthcare fixtures differ. -impl MedcareBridge { - pub fn new(registry: Arc) -> Result { - let g_lock = registry - .namespace_id(NAMESPACE) - .ok_or_else(|| Error::UnknownNamespace(NAMESPACE.to_string()))?; - Ok(Self { registry, g_lock }) + use super::*; + use crate::bridge::{BridgeError, NamespaceBridge}; + use crate::error::Error; + use crate::namespace::NamespaceId; + use crate::namespace_registry::NamespaceRegistry; + use crate::registry::OntologyRegistry; + use ogar_vocab::class_ids; + // PortSpec needed in scope for `HealthcarePort::aliases()`. + use ogar_vocab::ports::PortSpec; + use std::fs; + use std::sync::Arc; + + fn registry_with_healthcare() -> Arc { + let ttl = r#" +@prefix ogit: . +@prefix ogit.Healthcare: . +@prefix rdfs: . + +ogit.Healthcare:Patient + a rdfs:Class; + rdfs:subClassOf ogit:Entity; + rdfs:label "Patient"; + 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("Healthcare")).unwrap(); + fs::write(tmp.path().join("Healthcare").join("ents.ttl"), ttl).unwrap(); + let registry = Arc::new(OntologyRegistry::new_in_memory()); + registry + .hydrate_once_sync(tmp.path(), &["Healthcare"]) + .unwrap(); + std::mem::forget(tmp); + registry } -} -impl NamespaceBridge for MedcareBridge { - fn bridge_id(&self) -> &'static str { - "medcare" + #[test] + fn new_succeeds_when_namespace_registered() { + let registry = registry_with_healthcare(); + let bridge = MedcareBridge::new(registry).unwrap(); + assert_ne!(bridge.g_lock(), NamespaceId::UNKNOWN); } - fn registry(&self) -> &OntologyRegistry { - &self.registry + + #[test] + fn new_returns_unknown_namespace_when_not_registered() { + let registry = Arc::new(OntologyRegistry::new_in_memory()); + // `unwrap_err()` would require `MedcareBridge: Debug`, which the + // underlying `UnifiedBridge

` intentionally doesn't impl. + match MedcareBridge::new(registry) { + Ok(_) => panic!("expected UnknownNamespace, got Ok(_)"), + Err(Error::UnknownNamespace(name)) => assert_eq!(name, "Healthcare"), + Err(other) => panic!("expected UnknownNamespace, got {other:?}"), + } } - fn g_lock(&self) -> NamespaceId { - self.g_lock + + #[test] + fn bridge_id_is_lowercase_medcare() { + let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap(); + assert_eq!(bridge.bridge_id(), "medcare"); + } + + #[test] + fn g_lock_matches_registry_namespace_id() { + let registry = registry_with_healthcare(); + let expected = registry.namespace_id(NAMESPACE).unwrap(); + let bridge = MedcareBridge::new(registry).unwrap(); + assert_eq!(bridge.g_lock(), expected); + } + + #[test] + fn entity_resolves_patient_to_canonical_class_id() { + let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap(); + let entity = bridge.entity("Patient").unwrap(); + assert_eq!(entity.schema_ptr.entity_type_id(), class_ids::PATIENT); + assert_eq!(entity.schema_ptr.entity_type_id(), 0x0901); + } + + #[test] + fn entity_synthesised_schema_ptr_stamps_seeded_context_id() { + let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap(); + let entity = bridge.entity("Patient").unwrap(); + let expected = NamespaceRegistry::seed_context_id("Healthcare").unwrap(); + assert_eq!(entity.schema_ptr.ontology_context_id(), expected); + assert_eq!(entity.schema_ptr.ontology_context_id(), 2); + } + + #[test] + fn entity_for_each_codebook_entry_returns_its_canonical_class_id() { + let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap(); + for &(public_name, expected_id) in HealthcarePort::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}", + ); + } } -} -impl BridgeFromRegistry for MedcareBridge { - fn from_registry(registry: Arc) -> Result { - Self::new(registry) + #[test] + fn entity_for_non_codebook_name_falls_back_to_registry_lookup() { + let bridge = MedcareBridge::new(registry_with_healthcare()).unwrap(); + let err = bridge.entity("NotAConcept").unwrap_err(); + match err { + BridgeError::NotInScope { public_name, .. } => { + assert_eq!(public_name, "NotAConcept") + } + other => panic!("expected NotInScope, got {other:?}"), + } } } diff --git a/crates/lance-graph-ontology/src/bridges/mod.rs b/crates/lance-graph-ontology/src/bridges/mod.rs index b8d2a80c9..a917bfe3c 100644 --- a/crates/lance-graph-ontology/src/bridges/mod.rs +++ b/crates/lance-graph-ontology/src/bridges/mod.rs @@ -8,15 +8,15 @@ //! (`NAMESPACE` / `BRIDGE_ID` / public-name → class_id aliases) from //! [`ogar_vocab::ports::PortSpec`]. Adding a port is `impl PortSpec //! for FooPort {…}` in OGAR — no bridge boilerplate here. -//! - The **legacy per-tenant bridges** ([`WoaBridge`], [`MedcareBridge`], -//! [`SpearBridge`], [`SharePointBridge`], [`OgitBridge`]) keep their -//! bespoke struct shape for now. They predate OGAR's codebook and -//! don't yet have a `PortSpec` impl in `ogar-vocab::ports`. When the -//! WorkOrder / Healthcare / EmailCorrespondance / SharePoint -//! namespaces get promoted into the codebook, these collapse the -//! same way OpenProject and Redmine just did. +//! - The **legacy per-tenant bridges** ([`WoaBridge`], [`SpearBridge`], +//! [`SharePointBridge`], [`OgitBridge`]) keep their bespoke struct +//! shape for now. They predate OGAR's codebook and don't yet have a +//! `PortSpec` impl in `ogar-vocab::ports`. When the WorkOrder / +//! EmailCorrespondance / SharePoint namespaces get promoted into the +//! codebook, these collapse the same way OpenProject, Redmine, and +//! MedCare already did. //! -//! # Project-management ports (`UnifiedBridge

` aliases) +//! # OGAR-driven ports (`UnifiedBridge

` aliases) //! //! - [`OpenProjectBridge`]: `UnifiedBridge` //! — locks to the `OpenProject` namespace. `WorkPackage` / `TimeEntry` @@ -27,6 +27,12 @@ //! etc. resolve to the SAME OGAR canonical class_ids as the //! OpenProject equivalents, so cross-fork convergence is the default //! not the exception. +//! - [`MedcareBridge`]: `UnifiedBridge` +//! — locks to the `Healthcare` namespace. `Patient` / `Diagnosis` / +//! `LabValue` / `Medication` / `Treatment` / `Visit` / `VitalSign` +//! resolve to the `0x09XX` Health codebook (Northstar T9). Single- +//! tenant today; a future FMA / SNOMED curator converges on the same +//! ids. //! //! # Per-tenant bridges (legacy struct shape) //! @@ -34,7 +40,6 @@ //! OGIT URIs. `bridge_id = "ogit"`. Locks to whatever namespace its //! constructor is called with. //! - [`WoaBridge`]: locks to the `WorkOrder` namespace. -//! - [`MedcareBridge`]: locks to the `Healthcare` namespace. //! - [`SpearBridge`]: locks to the `EmailCorrespondance` namespace. //! - [`SharePointBridge`]: locks to the `SharePoint` namespace. //! @@ -53,7 +58,7 @@ mod sharepoint_bridge; mod spear_bridge; mod woa_bridge; -pub use medcare_bridge::MedcareBridge; +pub use medcare_bridge::{HealthcarePort, MedcareBridge}; pub use ogit_bridge::OgitBridge; pub use openproject_bridge::{OpenProjectBridge, OpenProjectPort}; pub use redmine_bridge::{RedmineBridge, RedminePort}; diff --git a/crates/lance-graph-ontology/tests/openproject_bridge_scope_lock.rs b/crates/lance-graph-ontology/tests/openproject_bridge_scope_lock.rs index 9ed82bbbd..84675d01f 100644 --- a/crates/lance-graph-ontology/tests/openproject_bridge_scope_lock.rs +++ b/crates/lance-graph-ontology/tests/openproject_bridge_scope_lock.rs @@ -132,8 +132,12 @@ fn openproject_bridge_construction_fails_when_namespace_missing() { // Error::UnknownNamespace. let registry = Arc::new(OntologyRegistry::new_in_memory()); let result = OpenProjectBridge::new(registry); + // NB: `OpenProjectBridge` is `UnifiedBridge`, which + // intentionally does not implement `Debug`, so we assert on `is_err()` + // without formatting the `Ok` value (pre-#570 the bridge was a Debug + // struct and this message interpolated `{result:?}`). assert!( result.is_err(), - "expected UnknownNamespace error, got {result:?}", + "expected UnknownNamespace error from constructing over an empty registry", ); } From e1012aecf707d06899c798b452b16557cfbe86f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 12:58:45 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(contract):=20loosen=20Fisher-z=20clamp?= =?UTF-8?q?=20=C2=B10.999=20=E2=86=92=20=C2=B10.9999=20so=20self-match=20r?= =?UTF-8?q?eads=20~1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Distance::similarity_z` clamps the similarity away from ±1 to keep the `atanh` (the `ln` term) finite. The bound was ±0.999, which made `tanh(atanh(0.999)) = 0.999` the maximum value `cohort_similarity_z` could ever return — so a perfect self-match read ~0.99899, and any "self ≈ 1.0" assertion (`s > 0.999`) was unreachable. ±0.9999 keeps atanh finite (≈4.95) while letting a self-match round-trip to ≈0.99986, which reads as "essentially identical". Pure numerical guard, no semantic change for moderate similarities; the existing distance tests (s=0.8 roundtrip, z=0.5 averaging, sign-only checks) are unaffected. Fixes medcare-analytics graph_contract::cohort_similarity_z_ self_returns_one (latent — the lance-phase2-rbac suite had never compiled in the dev env until protoc was installed this session). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- crates/lance-graph-contract/src/distance.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/lance-graph-contract/src/distance.rs b/crates/lance-graph-contract/src/distance.rs index 94b338b55..5f65ef7ce 100644 --- a/crates/lance-graph-contract/src/distance.rs +++ b/crates/lance-graph-contract/src/distance.rs @@ -41,7 +41,13 @@ pub trait Distance: Sized { #[inline] fn similarity_z(&self, other: &Self) -> f32 { let s = self.similarity(other); - let clamped = s.clamp(-0.999, 0.999); + // Clamp away from ±1 so `atanh` (the `ln` below) stays finite. + // The bound is ±0.9999, not ±0.999: a self-match (s = 1.0) must + // round-trip back through `tanh(atanh(clamp)) = clamp` to a value + // that reads as "essentially identical" (≈0.99986), not be capped + // at 0.999 — otherwise `cohort_similarity_z(self) > 0.999` is + // unreachable. atanh(0.9999) ≈ 4.95 is comfortably finite. + let clamped = s.clamp(-0.9999, 0.9999); ((1.0 + clamped) / (1.0 - clamped)).ln() * 0.5 } } From 8b35dc0c6c756be2fbdc5270507647e216ec0769 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 14:55:15 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat(rbac):=20role=20carries=20a=20FieldMas?= =?UTF-8?q?k=20projection=20=E2=80=94=20distinct=20views,=20not=20depth=20?= =?UTF-8?q?levels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RBAC is classid :: role :: membership, where the role IS a distinct projection of the class — not a graduated access level. PermissionSpec already had max_depth (a scalar level: Identity < … < Full), which only expresses more-vs-less of the same fields. It could not express that two roles see DISJOINT views of one class — the actual HIPAA mechanism (health-personnel sees the clinical histogram; invoice sees billing fields; research sees de-identified aggregate — and that the research cross-correlation would be unlawful in the invoice purpose). - contract FieldMask: add FULL (all positions), intersect, is_disjoint — the ops a projection + a distinctness check need. - rbac PermissionSpec: add `projection: FieldMask` (default FULL = no narrowing, depth governs), `with_projection(mask)` builder, `projects(n)` accessor. The projection is resolved against the class's ClassView field basis (lance-graph-ogar pulls OgarClassView for that basis). The projection SLOT is reusable here; the consumer (medcare-rs) hand-rolls the distinctness ENFORCEMENT — the three clinical roles' masks, and the invariant that the research projection is disjoint from the identifier fields. Test: two same-depth roles on one class with disjoint projections. lance-graph-rbac 15 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- crates/lance-graph-contract/src/class_view.rs | 20 ++++++ crates/lance-graph-rbac/src/permission.rs | 70 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/crates/lance-graph-contract/src/class_view.rs b/crates/lance-graph-contract/src/class_view.rs index 636fa5bef..b4d0e753b 100644 --- a/crates/lance-graph-contract/src/class_view.rs +++ b/crates/lance-graph-contract/src/class_view.rs @@ -123,6 +123,26 @@ impl FieldMask { self.0 == 0 } + /// The full mask — every addressable field position present. The + /// "no projection constraint" default for an RBAC role that has not + /// narrowed its view (lance-graph-rbac `PermissionSpec::projection`). + pub const FULL: Self = Self(u64::MAX); + + /// Bitwise intersection — the field positions present in BOTH masks. + #[inline] + pub const fn intersect(self, other: Self) -> Self { + Self(self.0 & other.0) + } + + /// Do the two masks share NO field position? RBAC uses this to assert + /// two roles project **distinct** views of the same class — e.g. a + /// research projection must be disjoint from the identifier fields + /// (`classid :: role :: membership`, where the role is the projection). + #[inline] + pub const fn is_disjoint(self, other: Self) -> bool { + self.0 & other.0 == 0 + } + /// Inherit a parent class's presence into this mask — the **mask-inherits-as- /// delta** of the HHTL `subClassOf` walk (`wikidata-hhtl-load.md`). A child /// IS-A its parent, so its mask carries every field the parent declares diff --git a/crates/lance-graph-rbac/src/permission.rs b/crates/lance-graph-rbac/src/permission.rs index 81d4bfd60..149914a9b 100644 --- a/crates/lance-graph-rbac/src/permission.rs +++ b/crates/lance-graph-rbac/src/permission.rs @@ -1,8 +1,25 @@ //! Permission specifications tied to the ontology layer. +use lance_graph_contract::class_view::FieldMask; use lance_graph_contract::property::PrefetchDepth; /// What a role can do on a specific entity type. +/// +/// # Depth is a level; projection is a view +/// +/// [`max_depth`](Self::max_depth) is a *scalar level* (Identity < … < Full): +/// how far down the prefetch ladder a role may read — "more or less of the +/// same fields". It does NOT express **distinct** role-views of one class. +/// +/// [`projection`](Self::projection) is the orthogonal axis: a [`FieldMask`] +/// over the class's `ClassView` field basis naming exactly which fields the +/// role may see. Two roles at the *same* depth can carry *disjoint* +/// projections — the mechanism behind `classid :: role :: membership`, where +/// the role IS the projection. A consumer (e.g. medcare-rs) gives +/// `health-personnel`, `invoice`, and `research` three distinct projections +/// of one clinical class and enforces that they never collapse (the research +/// projection disjoint from the identifier fields, etc.). The distinctness +/// enforcement is the consumer's; the projection slot is here. #[derive(Clone, Debug)] pub struct PermissionSpec { /// Entity type this permission applies to (e.g. "Customer", "Invoice"). @@ -10,6 +27,11 @@ pub struct PermissionSpec { /// Maximum property prefetch depth this role can access. /// Identity = Required only, Full = everything including Free + episodic. pub max_depth: PrefetchDepth, + /// The role's lawful **field projection** over this entity's `ClassView` + /// field basis. [`FieldMask::FULL`] = no narrowing (depth governs); a + /// narrowed mask is the per-role view that makes roles distinct rather + /// than merely graduated. + pub projection: FieldMask, /// Predicates this role can write. Empty = read-only for this entity. pub writable_predicates: &'static [&'static str], /// ActionSpec names this role can trigger. Empty = no actions. @@ -22,6 +44,7 @@ impl PermissionSpec { Self { entity_type, max_depth: PrefetchDepth::Identity, + projection: FieldMask::FULL, writable_predicates: &[], allowed_actions: &[], } @@ -36,6 +59,7 @@ impl PermissionSpec { Self { entity_type, max_depth: PrefetchDepth::Full, + projection: FieldMask::FULL, writable_predicates: writable, allowed_actions: actions, } @@ -46,11 +70,27 @@ impl PermissionSpec { Self { entity_type, max_depth: depth, + projection: FieldMask::FULL, writable_predicates: &[], allowed_actions: &[], } } + /// Narrow this permission to a specific field projection — the per-role + /// view over the class's field basis. Builder; chains after any + /// constructor (`read_at(..).with_projection(mask)`). + pub const fn with_projection(mut self, projection: FieldMask) -> Self { + self.projection = projection; + self + } + + /// Is field position `n` (a bit in the class's `ClassView` field basis) + /// within this role's projection? `false` = the role may not see it, + /// regardless of depth. + pub const fn projects(&self, n: u8) -> bool { + self.projection.has(n) + } + /// Check if this permission allows reading a predicate at the given depth. pub fn can_read_at(&self, depth: PrefetchDepth) -> bool { depth <= self.max_depth @@ -76,10 +116,40 @@ mod tests { let p = PermissionSpec::read_only("Customer"); assert_eq!(p.entity_type, "Customer"); assert_eq!(p.max_depth, PrefetchDepth::Identity); + // No narrowing by default — projection governs nothing until set. + assert_eq!(p.projection, FieldMask::FULL); assert!(p.writable_predicates.is_empty()); assert!(p.allowed_actions.is_empty()); } + #[test] + fn distinct_roles_carry_disjoint_projections_of_one_class() { + // classid :: role :: membership — two roles, SAME entity, SAME + // depth, but DISTINCT views. Mirrors the medcare shape: a billing + // role sees the coded/amount fields; a research role sees only + // de-identified aggregate fields. The point isn't more-vs-less + // depth — it's that the two field projections are disjoint. + // + // (Field positions index the entity's ClassView field basis; here + // 0,1 are identifier/clinical slots, 5,6 the de-identified slots.) + let invoice = PermissionSpec::read_at("Diagnosis", PrefetchDepth::Detail) + .with_projection(FieldMask::from_positions(&[0, 1])); + let research = PermissionSpec::read_at("Diagnosis", PrefetchDepth::Detail) + .with_projection(FieldMask::from_positions(&[5, 6])); + + // Same level, distinct views. + assert_eq!(invoice.max_depth, research.max_depth); + assert!( + invoice.projection.is_disjoint(research.projection), + "the two roles must project distinct views of the class", + ); + // The slot the invoice role sees, the research role does not. + assert!(invoice.projects(0)); + assert!(!research.projects(0)); + assert!(research.projects(5)); + assert!(!invoice.projects(5)); + } + #[test] fn full_access_allows_writes() { let p = PermissionSpec::full("Invoice", &["status", "payment_date"], &["approve"]);