From dd1ef95a774d0e3b98f5022db863e709e56e040a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 09:18:03 +0000 Subject: [PATCH 1/5] feat(ogar-vocab): mint 0x09XX Health codebook + HealthcarePort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote the medcare-rs Healthcare namespace into the canonical OGAR codebook (Northstar T9) so `MedcareBridge` can collapse to `UnifiedBridge` the same way OpenProject/Redmine collapsed in lance-graph#570. - class_ids / CODEBOOK / ALL: 7 Health concepts (0x0901..0x0907) — patient, diagnosis, lab_value, medication, treatment, visit, vital_sign — one per OGIT NTO/Healthcare entity. The 0x09XX domain block + ConceptDomain::Health were already reserved; this lands the concepts. Single-tenant today; a future FMA/SNOMED curator converges on these ids rather than re-minting. - ports::HealthcarePort: PortSpec impl (NAMESPACE="Healthcare", BRIDGE_ID="medcare") + HEALTHCARE_ALIASES mapping the 7 OGIT entity names (Patient, Diagnosis, LabValue, Medication, Treatment, Visit, VitalSign) onto the new class_ids. - tests: namespace/bridge_id, Health-domain membership, alias count (7), unknown->None, and extend each_alias_class_id_is_in_the_codebook to cover HealthcarePort. Existing CODEBOOK/ALL drift guards cover the new entries automatically. 64 unit + 1 doctest green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- crates/ogar-vocab/src/lib.rs | 49 +++++++++++++++++++ crates/ogar-vocab/src/ports.rs | 88 ++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index e8b9c69..6fcdfe2 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1126,6 +1126,23 @@ const CODEBOOK: &[(&str, u16)] = &[ ("billing_party", 0x0204), ("payment_record", 0x0205), ("currency_policy", 0x0206), + + // ── 0x09XX — Health domain (clinical / patient / care) ── + // medcare-rs Healthcare-namespace promotion (Northstar T9). The 7 + // entities the OGIT `NTO/Healthcare/entities/` TTL ships, projected + // onto canonical Health ids so `ports::HealthcarePort` resolves them + // through the `UnifiedBridge` codebook path (the same way OpenProject + // `WorkPackage` and Redmine `Issue` resolve through their ports). + // Single-tenant today — no cross-curator convergence yet — but the + // ids are minted into the shared codebook so a future second clinical + // curator (FMA / SNOMED import) converges here rather than re-mints. + ("patient", 0x0901), + ("diagnosis", 0x0902), + ("lab_value", 0x0903), + ("medication", 0x0904), + ("treatment", 0x0905), + ("visit", 0x0906), + ("vital_sign", 0x0907), ]; /// Codebook **domain** — the high byte of a canonical id (see @@ -1334,6 +1351,30 @@ pub mod class_ids { /// Odoo `res.currency`. pub const CURRENCY_POLICY: u16 = 0x0206; + // ── 0x09XX — health domain (medcare-rs Healthcare namespace) ── + + /// `patient` (`0x0901`) — the person receiving care. OGIT + /// `Healthcare:Patient`. + pub const PATIENT: u16 = 0x0901; + /// `diagnosis` (`0x0902`) — a clinical finding / condition. OGIT + /// `Healthcare:Diagnosis`. + pub const DIAGNOSIS: u16 = 0x0902; + /// `lab_value` (`0x0903`) — a laboratory measurement. OGIT + /// `Healthcare:LabValue`. + pub const LAB_VALUE: u16 = 0x0903; + /// `medication` (`0x0904`) — a prescribed / administered drug. OGIT + /// `Healthcare:Medication`. + pub const MEDICATION: u16 = 0x0904; + /// `treatment` (`0x0905`) — a therapeutic intervention. OGIT + /// `Healthcare:Treatment`. + pub const TREATMENT: u16 = 0x0905; + /// `visit` (`0x0906`) — a clinical encounter / episode. OGIT + /// `Healthcare:Visit`. + pub const VISIT: u16 = 0x0906; + /// `vital_sign` (`0x0907`) — a measured vital. OGIT + /// `Healthcare:VitalSign`. + pub const VITAL_SIGN: u16 = 0x0907; + /// Every `(canonical_concept_name, id)` pair the constants vouch for. /// Drift-guarded against [`super::CODEBOOK`] by tests in this module. pub const ALL: &[(&str, u16)] = &[ @@ -1371,6 +1412,14 @@ pub mod class_ids { ("billing_party", BILLING_PARTY), ("payment_record", PAYMENT_RECORD), ("currency_policy", CURRENCY_POLICY), + // 0x09XX — health + ("patient", PATIENT), + ("diagnosis", DIAGNOSIS), + ("lab_value", LAB_VALUE), + ("medication", MEDICATION), + ("treatment", TREATMENT), + ("visit", VISIT), + ("vital_sign", VITAL_SIGN), ]; #[cfg(test)] diff --git a/crates/ogar-vocab/src/ports.rs b/crates/ogar-vocab/src/ports.rs index 066bd44..38dc0a9 100644 --- a/crates/ogar-vocab/src/ports.rs +++ b/crates/ogar-vocab/src/ports.rs @@ -196,6 +196,49 @@ pub const REDMINE_ALIASES: &[(&str, u16)] = &[ ("EnabledModule", class_ids::PROJECT_ENABLED_MODULE), ]; +// ── Healthcare (medcare-rs) port ──────────────────────────────────── + +/// MedCare-rs's `PortSpec` — maps the `Healthcare` namespace's OGIT +/// entity names (`Patient`, `Diagnosis`, `LabValue`, `Medication`, +/// `Treatment`, `Visit`, `VitalSign`) onto the canonical OGAR Health +/// codebook (`0x09XX`). +/// +/// Unlike [`OpenProjectPort`] / [`RedminePort`] — which converge two +/// project-management forks on a shared codebook — Healthcare is a +/// single-tenant namespace today, so there is no cross-port convergence +/// pin yet. The port exists so `lance_graph_ontology`'s `MedcareBridge` +/// collapses to `UnifiedBridge`: the namespace, +/// bridge_id, and alias table are now **inherited from this canonical +/// class schema** instead of being re-declared per bridge in lance-graph. +/// Northstar T9 (Healthcare codebook promotion). +/// +/// When a second clinical curator lands (FMA / SNOMED / RadLex import, +/// `lance-graph-rdf-fma-snomed-v1`), its port maps onto these same +/// `class_ids::*` constants — at which point Healthcare gains the same +/// apple-meets-apple convergence the project-management ports have. +pub struct HealthcarePort; + +impl PortSpec for HealthcarePort { + const NAMESPACE: &'static str = "Healthcare"; + const BRIDGE_ID: &'static str = "medcare"; + fn aliases() -> &'static [(&'static str, u16)] { + HEALTHCARE_ALIASES + } +} + +/// The Healthcare port's `(public_name, class_id)` alias slice — the OGIT +/// `NTO/Healthcare/entities/` entity names projected onto `class_ids::*`. +/// `pub` for symmetry with [`OPENPROJECT_ALIASES`] / [`REDMINE_ALIASES`]. +pub const HEALTHCARE_ALIASES: &[(&str, u16)] = &[ + ("Patient", class_ids::PATIENT), + ("Diagnosis", class_ids::DIAGNOSIS), + ("LabValue", class_ids::LAB_VALUE), + ("Medication", class_ids::MEDICATION), + ("Treatment", class_ids::TREATMENT), + ("Visit", class_ids::VISIT), + ("VitalSign", class_ids::VITAL_SIGN), +]; + #[cfg(test)] mod tests { use super::*; @@ -212,6 +255,45 @@ mod tests { assert_eq!(RedminePort::BRIDGE_ID, "redmine"); } + #[test] + fn healthcare_namespace_and_bridge_id_match_canonical_strings() { + assert_eq!(HealthcarePort::NAMESPACE, "Healthcare"); + assert_eq!(HealthcarePort::BRIDGE_ID, "medcare"); + } + + #[test] + fn healthcare_entities_resolve_into_the_health_domain() { + use crate::{canonical_concept_domain, ConceptDomain}; + for &(name, _) in HealthcarePort::aliases() { + let id = HealthcarePort::class_id(name) + .unwrap_or_else(|| panic!("`{name}` must resolve")); + assert_eq!( + canonical_concept_domain(id), + ConceptDomain::Health, + "`{name}` -> 0x{id:04X} must live in the Health (0x09XX) domain", + ); + } + assert_eq!(HealthcarePort::class_id("Patient"), Some(class_ids::PATIENT)); + assert_eq!(HealthcarePort::class_id("Patient"), Some(0x0901)); + } + + #[test] + fn healthcare_alias_count_matches_ogit_entities() { + // Patient / Diagnosis / LabValue / Medication / Treatment / Visit + // / VitalSign — the 7 OGIT `NTO/Healthcare/entities/` classes. + assert_eq!( + HealthcarePort::aliases().len(), + 7, + "Healthcare alias count drift — re-count against OGIT entities" + ); + } + + #[test] + fn healthcare_unknown_public_names_resolve_to_none() { + assert_eq!(HealthcarePort::class_id("WorkPackage"), None); + assert_eq!(HealthcarePort::class_id(""), None); + } + #[test] fn openproject_workpackage_maps_to_project_work_item() { assert_eq!( @@ -314,6 +396,12 @@ mod tests { "RedminePort alias `{name}` -> 0x{id:04X} not in class_ids::ALL" ); } + for &(name, id) in HealthcarePort::aliases() { + assert!( + codebook_ids.contains(&id), + "HealthcarePort alias `{name}` -> 0x{id:04X} not in class_ids::ALL" + ); + } } #[test] From 4b0c08252b04ecb87b199dfe9d724317f6ed03a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 12:17:52 +0000 Subject: [PATCH 2/5] =?UTF-8?q?feat(ogar-vocab):=20concepts=5Fin=5Fdomain?= =?UTF-8?q?=20=E2=80=94=20reusable=20fail-closed=20coverage=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `concepts_in_domain(ConceptDomain) -> impl Iterator<(name, id)>`: the reusable enumeration primitive a domain-scoped consumer uses to inherit its full concept set from the canonical codebook instead of hand-maintaining a parallel list. This is the fail-closed foundation for RBAC marking-inheritance: a consumer that must access-control every concept in a sensitive domain (medcare-rs over ConceptDomain::Health) derives its required-coverage set from OGAR, so a concept newly promoted upstream surfaces as missing-coverage at the consumer's boot gate — never a silently fail-open row (the Treatment/Visit/VitalSign gap medcare-bridge flagged). Tests: concepts_in_domain(Health) yields exactly the 7 OGIT entities in codebook order; per-domain counts (Health=7, Commerce=6, ProjectMgmt=26, Osint=0); domain-membership invariant; doctest. Health concepts added to codebook_ids_are_domain_prefixed_and_consistent. 65 unit + 2 doctest green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- crates/ogar-vocab/src/lib.rs | 69 ++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 6fcdfe2..43cbba2 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1189,6 +1189,36 @@ pub fn canonical_concept_domain(id: u16) -> ConceptDomain { } } +/// Every promoted `(canonical_concept_name, id)` whose id lives in `domain`, +/// in codebook order. The **reusable enumeration hook** a domain-scoped +/// consumer uses to inherit its full concept set from the canonical layer +/// instead of hand-maintaining a parallel list. +/// +/// This is the fail-closed primitive behind RBAC marking-inheritance: a +/// consumer that must access-control every concept in a sensitive domain +/// (e.g. medcare-rs over [`ConceptDomain::Health`]) derives the required +/// coverage set from here, so a concept newly promoted into the codebook +/// upstream becomes a *missing-coverage* signal at the consumer's boot +/// gate — never a silently-uncovered (fail-open) row. +/// +/// ``` +/// use ogar_vocab::{concepts_in_domain, ConceptDomain}; +/// +/// let health: Vec<_> = concepts_in_domain(ConceptDomain::Health) +/// .map(|(name, _id)| name) +/// .collect(); +/// assert!(health.contains(&"patient")); +/// assert_eq!(health.len(), 7); // the 7 OGIT Healthcare entities +/// ``` +pub fn concepts_in_domain( + domain: ConceptDomain, +) -> impl Iterator { + CODEBOOK + .iter() + .copied() + .filter(move |&(_, id)| canonical_concept_domain(id) == domain) +} + /// Map a coarse [`Class::source_domain`] tag — as produced by the curator /// namespace classifier (`"project"`, `"erp"`, `"german-erp"`, …) — to the /// [`ConceptDomain`] its promotions live in. Returns `None` for an @@ -3405,6 +3435,15 @@ mod tests { assert_eq!(id >> 8, 0x02, "{commerce_concept} id {id:#06x} not in 0x02XX block"); assert_eq!(canonical_concept_domain(id), ConceptDomain::Commerce); } + for health_concept in [ + "patient", "diagnosis", "lab_value", "medication", + "treatment", "visit", "vital_sign", + ] { + let id = canonical_concept_id(health_concept) + .unwrap_or_else(|| panic!("{health_concept} missing from codebook")); + assert_eq!(id >> 8, 0x09, "{health_concept} id {id:#06x} not in 0x09XX block"); + assert_eq!(canonical_concept_domain(id), ConceptDomain::Health); + } // Reserved + named future-domain blocks. assert_eq!(canonical_concept_domain(0x0000), ConceptDomain::Reserved); assert_eq!(canonical_concept_domain(0x00FF), ConceptDomain::Reserved); @@ -3419,6 +3458,36 @@ mod tests { assert_eq!(canonical_concept_domain(0xFFFF), ConceptDomain::Unassigned); } + #[test] + fn concepts_in_domain_enumerates_exactly_each_domains_codebook_set() { + // The reusable fail-closed coverage hook: a domain-scoped consumer + // (e.g. medcare-rs over Health) inherits its required concept set + // from here. Drift = a concept promoted upstream without the + // consumer noticing => exactly what the boot-time coverage gate + // must catch. + let health: Vec<&str> = concepts_in_domain(ConceptDomain::Health) + .map(|(name, _)| name) + .collect(); + assert_eq!( + health, + [ + "patient", "diagnosis", "lab_value", "medication", + "treatment", "visit", "vital_sign", + ], + "Health domain set drift — re-sync the consumer coverage gate", + ); + // Every yielded id really is in-domain. + for (_, id) in concepts_in_domain(ConceptDomain::Health) { + assert_eq!(canonical_concept_domain(id), ConceptDomain::Health); + } + // Counts line up with the codebook blocks. + assert_eq!(concepts_in_domain(ConceptDomain::Health).count(), 7); + assert_eq!(concepts_in_domain(ConceptDomain::Commerce).count(), 6); + assert_eq!(concepts_in_domain(ConceptDomain::ProjectMgmt).count(), 26); + // An empty (reserved-but-unpopulated) domain yields nothing. + assert_eq!(concepts_in_domain(ConceptDomain::Osint).count(), 0); + } + #[test] fn project_mgmt_batch_promotions_each_have_a_codebook_id_and_shape() { // The 9 new project-mgmt concepts from the cross-curator overlap From d950e41459625e5f7fa60cb13754e8de8d2b361b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 14:07:32 +0000 Subject: [PATCH 3/5] feat(health): rich Active-Record + ClassView + inherited marking for 0x09XX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrich the Health domain into the full reusable OGAR stack, with diagnosis (0x0902) as the worked example. ogar-vocab — 7 Active-Record `Class` builders (patient, diagnosis, lab_value, medication, treatment, visit, vital_sign), same idiom as the project/commerce concepts: typed attributes + `family_edge`/ `family_has_many` associations + canonical_concept identity. diagnosis carries the fullest schema (ICD coding, FHIR-shaped clinical/ verification status, onset/resolution dates, primary flag, 2 edges); the six siblings are competent schemas. Field names are English schema labels — never German PII labels, never PHI values. ogar-class-view — wire all 7 into `all_canonical_classes()` so the ClassView registry covers the 0x09XX block. This FIXES a regression the earlier HealthcarePort commit introduced: Health ids were added to `class_ids::ALL` but had no Class body, so the registry's reverse-gate tests (every_codebook_id_appears_in_class_ids_all + stable-order) were failing (latent — ogar-class-view wasn't re-run that commit). ogar-class-view — add `OgarClassView::access_marking(class) -> Marking`: the reusable, fail-closed marking-inheritance hook. A class inherits its RBAC data-classification from its codebook domain (Health → Restricted/ PHI, Commerce → Financial, ProjectMgmt → Internal); any unclassified domain or unknown id → Restricted (fail-closed, never Public). Reuses the contract's existing `Marking` enum — no contract change. Tests: ogar-vocab 67 (+2 Health), ogar-class-view 12 (+3, 2 regressions fixed). clippy clean (no new warnings). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- crates/ogar-class-view/src/lib.rs | 109 ++++++++++++- crates/ogar-vocab/src/lib.rs | 252 ++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+), 7 deletions(-) diff --git a/crates/ogar-class-view/src/lib.rs b/crates/ogar-class-view/src/lib.rs index e22ae4d..3e02c5c 100644 --- a/crates/ogar-class-view/src/lib.rs +++ b/crates/ogar-class-view/src/lib.rs @@ -62,15 +62,17 @@ use std::collections::HashMap; use lance_graph_contract::{ class_view::{ClassId, ClassView}, ontology::{DisplayTemplate, FieldRef, ObjectView}, + property::Marking, }; use ogar_vocab::{ - billable_work_entry, billing_party, canonical_concept_id, commercial_document, - commercial_line_item, currency_policy, payment_record, priority, project, project_actor, - project_attachment, project_changeset, project_comment, project_custom_field, - project_custom_value, project_enabled_module, project_forum, project_journal, - project_member_role, project_membership, project_message, project_news, project_query, - project_relation, project_repository, project_role, project_status, project_type, - project_version, project_watcher, project_wiki_page, project_work_item, tax_policy, Class, + billable_work_entry, billing_party, canonical_concept_domain, canonical_concept_id, + commercial_document, commercial_line_item, currency_policy, diagnosis, lab_value, medication, + patient, payment_record, priority, project, project_actor, project_attachment, + project_changeset, project_comment, project_custom_field, project_custom_value, + project_enabled_module, project_forum, project_journal, project_member_role, project_membership, + project_message, project_news, project_query, project_relation, project_repository, + project_role, project_status, project_type, project_version, project_watcher, project_wiki_page, + project_work_item, tax_policy, treatment, visit, vital_sign, Class, ConceptDomain, }; /// All 32 promoted canonical concepts: `(canonical_concept_name, Class)`. @@ -115,6 +117,14 @@ fn all_canonical_classes() -> Vec<(&'static str, Class)> { ("billing_party", billing_party()), ("payment_record", payment_record()), ("currency_policy", currency_policy()), + // ── 0x09XX — health (OGIT Healthcare) ── + ("patient", patient()), + ("diagnosis", diagnosis()), + ("lab_value", lab_value()), + ("medication", medication()), + ("treatment", treatment()), + ("visit", visit()), + ("vital_sign", vital_sign()), ] } @@ -203,6 +213,39 @@ impl OgarClassView { pub fn object_view(&self, class: ClassId) -> Option<&ObjectView> { self.by_id.get(&class) } + + /// The RBAC data-classification [`Marking`] a class **inherits from + /// its codebook domain** — the reusable, fail-closed marking- + /// inheritance hook. + /// + /// The marking is derived from `class >> 8` (the domain byte) via + /// [`canonical_concept_domain`], so it is defined for *every* class + /// id, registered or not — a consumer never has to hand-author a + /// per-class sensitivity table: + /// + /// | Domain | Marking | Why | + /// |---------------|----------------------|-----| + /// | `Health` | [`Marking::Restricted`] | PHI — highest, explicit grant | + /// | `Commerce` | [`Marking::Financial`] | bookkeeping / tax-relevant | + /// | `ProjectMgmt` | [`Marking::Internal`] | internal, not externally shared | + /// | anything else | [`Marking::Restricted`] | **fail-closed** — deny until classified | + /// + /// The default arm is `Restricted`, not `Public`: an id whose domain + /// has no explicit classification (reserved blocks, a future domain, + /// an unknown id) is treated as maximally sensitive, so a concept + /// promoted upstream is access-controlled *before* anyone authors a + /// rule for it — never silently world-readable. + #[must_use] + pub fn access_marking(&self, class: ClassId) -> Marking { + match canonical_concept_domain(class) { + ConceptDomain::Health => Marking::Restricted, + ConceptDomain::Commerce => Marking::Financial, + ConceptDomain::ProjectMgmt => Marking::Internal, + // Reserved / Osint / Ocr / Unassigned and any unknown id: + // fail closed. + _ => Marking::Restricted, + } + } } impl Default for OgarClassView { @@ -420,4 +463,56 @@ mod tests { // project() has a `name` attribute, so primary_label is set. assert_eq!(view.primary_label.as_deref(), Some("name")); } + + #[test] + fn health_concepts_are_registered_with_their_fields() { + // The 7 OGIT Healthcare concepts resolve to registry entries — + // this is what makes `every_codebook_id_appears_in_class_ids_all` + // green for the 0x09XX block. + let v = OgarClassView::new(); + for concept in [ + "patient", "diagnosis", "lab_value", "medication", "treatment", "visit", "vital_sign", + ] { + let id = canonical_concept_id(concept).unwrap(); + let view = v.object_view(id).unwrap_or_else(|| panic!("{concept} not registered")); + assert!(!view.fields.is_empty(), "{concept} has no field basis"); + } + } + + #[test] + fn diagnosis_field_basis_is_ten_attributes_then_two_edges() { + // The 0x0902 worked example, projected: attributes first (icd_code + // leads), then the two family edges, in declaration order. + let v = OgarClassView::new(); + let id = canonical_concept_id("diagnosis").unwrap(); + let fields = v.fields(id); + assert_eq!(fields.len(), 12); // 10 attributes + 2 edges + assert_eq!(fields[0].predicate_iri, "icd_code"); + assert_eq!(fields[10].predicate_iri, "patient"); + assert_eq!(fields[11].predicate_iri, "encounter"); + } + + #[test] + fn access_marking_inherits_domain_sensitivity_fail_closed() { + let v = OgarClassView::new(); + // Health → Restricted (PHI), for every Health concept. + for concept in [ + "patient", "diagnosis", "lab_value", "medication", "treatment", "visit", "vital_sign", + ] { + let id = canonical_concept_id(concept).unwrap(); + assert_eq!(v.access_marking(id), Marking::Restricted, "{concept}"); + } + // Commerce → Financial; ProjectMgmt → Internal. + assert_eq!( + v.access_marking(canonical_concept_id("payment_record").unwrap()), + Marking::Financial + ); + assert_eq!( + v.access_marking(canonical_concept_id("project").unwrap()), + Marking::Internal + ); + // Unknown / unclassified id → fail-closed Restricted, NOT Public. + assert_eq!(v.access_marking(0xFFFF), Marking::Restricted); + assert_eq!(v.access_marking(0x0000), Marking::Restricted); + } } diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 43cbba2..938dbe3 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -2948,6 +2948,216 @@ pub fn currency_policy() -> Class { c } +// ───────────────────────────────────────────────────────────────────── +// 0x09XX — Health domain (OGIT Healthcare). The reusable Active-Record +// shape for the clinical concepts. `diagnosis` (0x0902) is the worked +// example carried to full fidelity; the six siblings are competent +// schemas in the same idiom. Field NAMES are English schema labels — +// never German PII labels, never PHI values (OGAR Non-negotiable: PII). +// ───────────────────────────────────────────────────────────────────── + +/// Patient — the clinical subject (OGIT `Patient`, `0x0901`). The root +/// of every Health family edge; diagnoses / visits / labs / medications +/// all `belongs_to` a patient. +#[must_use] +pub fn patient() -> Class { + let mut c = Class::new("Patient"); + c.language = Language::Unknown; + c.canonical_concept = Some("patient".to_string()); + c.associations = vec![ + family_has_many("diagnoses", "Diagnosis"), + family_has_many("visits", "Visit"), + ]; + let mut mrn = Attribute::new("mrn"); // medical record number (identity) + mrn.type_name = Some("string".to_string()); + let mut given_name = Attribute::new("given_name"); + given_name.type_name = Some("string".to_string()); + let mut family_name = Attribute::new("family_name"); + family_name.type_name = Some("string".to_string()); + let mut birth_date = Attribute::new("birth_date"); + birth_date.type_name = Some("date".to_string()); + let mut sex = Attribute::new("sex"); + sex.type_name = Some("string".to_string()); + c.attributes = vec![mrn, given_name, family_name, birth_date, sex]; + c +} + +/// Diagnosis — a clinical finding / condition (OGIT `Diagnosis`, +/// `0x0902`). **The worked example for the Health domain's reusable +/// stack:** a full typed-attribute schema (ICD coding, FHIR-shaped +/// clinical/verification status, onset/resolution dates, primary flag) +/// plus two family edges (`patient`, the subject; `encounter`, the +/// [`visit`] it was recorded in). A consumer (medcare-rs) maps this one +/// canonical shape onto its own SoA columns; the class_id (`0x0902`) is +/// the identity, the attribute set is the bit-basis, and the RBAC +/// sensitivity is inherited from the Health domain (see +/// `ogar_class_view::OgarClassView::access_marking`). +#[must_use] +pub fn diagnosis() -> Class { + let mut c = Class::new("Diagnosis"); + c.language = Language::Unknown; + c.canonical_concept = Some("diagnosis".to_string()); + c.description = Some("A clinical finding or condition attributed to a patient".to_string()); + c.associations = vec![ + family_edge("patient", "Patient"), + family_edge("encounter", "Visit"), + ]; + let mut icd_code = Attribute::new("icd_code"); // ICD-10/11 coded identity + icd_code.type_name = Some("string".to_string()); + let mut description = Attribute::new("description"); + description.type_name = Some("string".to_string()); + let mut category = Attribute::new("category"); + category.type_name = Some("string".to_string()); + let mut clinical_status = Attribute::new("clinical_status"); // active|recurrence|resolved + clinical_status.type_name = Some("string".to_string()); + let mut verification_status = Attribute::new("verification_status"); // provisional|confirmed + verification_status.type_name = Some("string".to_string()); + let mut severity = Attribute::new("severity"); + severity.type_name = Some("string".to_string()); + let mut onset_date = Attribute::new("onset_date"); + onset_date.type_name = Some("date".to_string()); + let mut resolved_date = Attribute::new("resolved_date"); + resolved_date.type_name = Some("date".to_string()); + let mut is_primary = Attribute::new("is_primary"); + is_primary.type_name = Some("boolean".to_string()); + let mut note = Attribute::new("note"); + note.type_name = Some("text".to_string()); + c.attributes = vec![ + icd_code, + description, + category, + clinical_status, + verification_status, + severity, + onset_date, + resolved_date, + is_primary, + note, + ]; + c +} + +/// LabValue — a laboratory measurement (OGIT `LabValue`, `0x0903`). +/// LOINC-coded, with value/unit/reference-range and an abnormal flag. +#[must_use] +pub fn lab_value() -> Class { + let mut c = Class::new("LabValue"); + c.language = Language::Unknown; + c.canonical_concept = Some("lab_value".to_string()); + c.associations = vec![ + family_edge("patient", "Patient"), + family_edge("encounter", "Visit"), + ]; + let mut loinc_code = Attribute::new("loinc_code"); + loinc_code.type_name = Some("string".to_string()); + let mut value = Attribute::new("value"); + value.type_name = Some("decimal".to_string()); + let mut unit = Attribute::new("unit"); + unit.type_name = Some("string".to_string()); + let mut reference_range = Attribute::new("reference_range"); + reference_range.type_name = Some("string".to_string()); + let mut abnormal_flag = Attribute::new("abnormal_flag"); + abnormal_flag.type_name = Some("string".to_string()); + let mut collected_at = Attribute::new("collected_at"); + collected_at.type_name = Some("datetime".to_string()); + c.attributes = vec![loinc_code, value, unit, reference_range, abnormal_flag, collected_at]; + c +} + +/// Medication — a prescribed / administered drug (OGIT `Medication`, +/// `0x0904`). ATC-coded, with dose / route / frequency and a date range. +#[must_use] +pub fn medication() -> Class { + let mut c = Class::new("Medication"); + c.language = Language::Unknown; + c.canonical_concept = Some("medication".to_string()); + c.associations = vec![family_edge("patient", "Patient")]; + let mut atc_code = Attribute::new("atc_code"); + atc_code.type_name = Some("string".to_string()); + let mut name = Attribute::new("name"); + name.type_name = Some("string".to_string()); + let mut dose = Attribute::new("dose"); + dose.type_name = Some("string".to_string()); + let mut route = Attribute::new("route"); + route.type_name = Some("string".to_string()); + let mut frequency = Attribute::new("frequency"); + frequency.type_name = Some("string".to_string()); + let mut start_date = Attribute::new("start_date"); + start_date.type_name = Some("date".to_string()); + let mut end_date = Attribute::new("end_date"); + end_date.type_name = Some("date".to_string()); + c.attributes = vec![atc_code, name, dose, route, frequency, start_date, end_date]; + c +} + +/// Treatment — a procedure / intervention performed (OGIT `Treatment`, +/// `0x0905`). Coded, with a performed-at timestamp and an outcome. +#[must_use] +pub fn treatment() -> Class { + let mut c = Class::new("Treatment"); + c.language = Language::Unknown; + c.canonical_concept = Some("treatment".to_string()); + c.associations = vec![ + family_edge("patient", "Patient"), + family_edge("encounter", "Visit"), + ]; + let mut code = Attribute::new("code"); + code.type_name = Some("string".to_string()); + let mut description = Attribute::new("description"); + description.type_name = Some("string".to_string()); + let mut performed_at = Attribute::new("performed_at"); + performed_at.type_name = Some("datetime".to_string()); + let mut outcome = Attribute::new("outcome"); + outcome.type_name = Some("string".to_string()); + c.attributes = vec![code, description, performed_at, outcome]; + c +} + +/// Visit — a clinical encounter (OGIT `Visit`, `0x0906`). The temporal +/// container diagnoses / labs / treatments / vitals are recorded within. +#[must_use] +pub fn visit() -> Class { + let mut c = Class::new("Visit"); + c.language = Language::Unknown; + c.canonical_concept = Some("visit".to_string()); + c.associations = vec![family_edge("patient", "Patient")]; + let mut visit_number = Attribute::new("visit_number"); + visit_number.type_name = Some("string".to_string()); + let mut visit_type = Attribute::new("visit_type"); // inpatient|outpatient|emergency + visit_type.type_name = Some("string".to_string()); + let mut department = Attribute::new("department"); + department.type_name = Some("string".to_string()); + let mut admitted_at = Attribute::new("admitted_at"); + admitted_at.type_name = Some("datetime".to_string()); + let mut discharged_at = Attribute::new("discharged_at"); + discharged_at.type_name = Some("datetime".to_string()); + c.attributes = vec![visit_number, visit_type, department, admitted_at, discharged_at]; + c +} + +/// VitalSign — a point-in-time physiological measurement (OGIT +/// `VitalSign`, `0x0907`). Coded value/unit with a measured-at timestamp. +#[must_use] +pub fn vital_sign() -> Class { + let mut c = Class::new("VitalSign"); + c.language = Language::Unknown; + c.canonical_concept = Some("vital_sign".to_string()); + c.associations = vec![ + family_edge("patient", "Patient"), + family_edge("encounter", "Visit"), + ]; + let mut code = Attribute::new("code"); // e.g. LOINC 8867-4 (heart rate) + code.type_name = Some("string".to_string()); + let mut value = Attribute::new("value"); + value.type_name = Some("decimal".to_string()); + let mut unit = Attribute::new("unit"); + unit.type_name = Some("string".to_string()); + let mut measured_at = Attribute::new("measured_at"); + measured_at.type_name = Some("datetime".to_string()); + c.attributes = vec![code, value, unit, measured_at]; + c +} + #[cfg(test)] mod tests { use super::*; @@ -3458,6 +3668,48 @@ mod tests { assert_eq!(canonical_concept_domain(0xFFFF), ConceptDomain::Unassigned); } + #[test] + fn health_classes_carry_their_canonical_codebook_identity() { + // Each Health AR builder resolves to its codebook id (the class_id + // is the identity; the PascalCase name is decorative). Mirrors the + // project/commerce gates. + for (builder, concept, id) in [ + (patient as fn() -> Class, "patient", 0x0901u16), + (diagnosis, "diagnosis", 0x0902), + (lab_value, "lab_value", 0x0903), + (medication, "medication", 0x0904), + (treatment, "treatment", 0x0905), + (visit, "visit", 0x0906), + (vital_sign, "vital_sign", 0x0907), + ] { + let c = builder(); + assert_eq!(c.canonical_concept.as_deref(), Some(concept)); + assert_eq!(c.canonical_id(), Some(id), "{concept} -> {id:#06x}"); + assert_eq!(canonical_concept_domain(id), ConceptDomain::Health); + } + } + + #[test] + fn diagnosis_is_the_rich_worked_example() { + // The 0x0902 worked example: full typed-attribute schema + two + // family edges. Pins the bit-basis so a downstream FieldMask + // producer notices a reorder. + let d = diagnosis(); + assert_eq!(d.name, "Diagnosis"); + // ICD code is the first attribute (the coded identity slot). + assert_eq!(d.attributes[0].name, "icd_code"); + assert_eq!(d.attributes[0].type_name.as_deref(), Some("string")); + // Two family edges: the subject and the encounter. + let edges: Vec<&str> = d.associations.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(edges, ["patient", "encounter"]); + // Every attribute carries a type (DDL adapters need the shape). + for a in &d.attributes { + assert!(a.type_name.is_some(), "{} has no type", a.name); + } + // Comfortably under the FieldMask u64 ceiling. + assert!(d.attributes.len() + d.associations.len() <= 64); + } + #[test] fn concepts_in_domain_enumerates_exactly_each_domains_codebook_set() { // The reusable fail-closed coverage hook: a domain-scoped consumer From 87e8dd23d811e68e35e2fe64116d0fc98df5fa67 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 14:42:52 +0000 Subject: [PATCH 4/5] =?UTF-8?q?revert(class-view):=20drop=20access=5Fmarki?= =?UTF-8?q?ng=20scalar=20=E2=80=94=20RBAC=20is=20classid::role::membership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The access_marking(class) -> Marking method collapsed access control into a per-class data-classification scalar, "inherited" via an override. Wrong framing: RBAC is a relation, not a stamp. Access is classid :: role :: membership — a class is reached by a role, a role by a membership — already modelled in the registry (project_role 0x0117, project_membership 0x0108, project_member_role 0x0118 = the membership↔role join) and in lance-graph-rbac. A class does not carry a sensitivity tag; it is keyed by classid into the role/membership relation like every other class. Keeps the Health Active-Record builders + ClassView registry wiring (d950e41) — those are correct and fix the registry reverse-gate regression. ogar-class-view 11 tests green, clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- crates/ogar-class-view/src/lib.rs | 73 ++++--------------------------- 1 file changed, 8 insertions(+), 65 deletions(-) diff --git a/crates/ogar-class-view/src/lib.rs b/crates/ogar-class-view/src/lib.rs index 3e02c5c..c4912bf 100644 --- a/crates/ogar-class-view/src/lib.rs +++ b/crates/ogar-class-view/src/lib.rs @@ -62,17 +62,16 @@ use std::collections::HashMap; use lance_graph_contract::{ class_view::{ClassId, ClassView}, ontology::{DisplayTemplate, FieldRef, ObjectView}, - property::Marking, }; use ogar_vocab::{ - billable_work_entry, billing_party, canonical_concept_domain, canonical_concept_id, - commercial_document, commercial_line_item, currency_policy, diagnosis, lab_value, medication, - patient, payment_record, priority, project, project_actor, project_attachment, - project_changeset, project_comment, project_custom_field, project_custom_value, - project_enabled_module, project_forum, project_journal, project_member_role, project_membership, - project_message, project_news, project_query, project_relation, project_repository, - project_role, project_status, project_type, project_version, project_watcher, project_wiki_page, - project_work_item, tax_policy, treatment, visit, vital_sign, Class, ConceptDomain, + billable_work_entry, billing_party, canonical_concept_id, commercial_document, + commercial_line_item, currency_policy, diagnosis, lab_value, medication, patient, + payment_record, priority, project, project_actor, project_attachment, project_changeset, + project_comment, project_custom_field, project_custom_value, project_enabled_module, + project_forum, project_journal, project_member_role, project_membership, project_message, + project_news, project_query, project_relation, project_repository, project_role, + project_status, project_type, project_version, project_watcher, project_wiki_page, + project_work_item, tax_policy, treatment, visit, vital_sign, Class, }; /// All 32 promoted canonical concepts: `(canonical_concept_name, Class)`. @@ -213,39 +212,6 @@ impl OgarClassView { pub fn object_view(&self, class: ClassId) -> Option<&ObjectView> { self.by_id.get(&class) } - - /// The RBAC data-classification [`Marking`] a class **inherits from - /// its codebook domain** — the reusable, fail-closed marking- - /// inheritance hook. - /// - /// The marking is derived from `class >> 8` (the domain byte) via - /// [`canonical_concept_domain`], so it is defined for *every* class - /// id, registered or not — a consumer never has to hand-author a - /// per-class sensitivity table: - /// - /// | Domain | Marking | Why | - /// |---------------|----------------------|-----| - /// | `Health` | [`Marking::Restricted`] | PHI — highest, explicit grant | - /// | `Commerce` | [`Marking::Financial`] | bookkeeping / tax-relevant | - /// | `ProjectMgmt` | [`Marking::Internal`] | internal, not externally shared | - /// | anything else | [`Marking::Restricted`] | **fail-closed** — deny until classified | - /// - /// The default arm is `Restricted`, not `Public`: an id whose domain - /// has no explicit classification (reserved blocks, a future domain, - /// an unknown id) is treated as maximally sensitive, so a concept - /// promoted upstream is access-controlled *before* anyone authors a - /// rule for it — never silently world-readable. - #[must_use] - pub fn access_marking(&self, class: ClassId) -> Marking { - match canonical_concept_domain(class) { - ConceptDomain::Health => Marking::Restricted, - ConceptDomain::Commerce => Marking::Financial, - ConceptDomain::ProjectMgmt => Marking::Internal, - // Reserved / Osint / Ocr / Unassigned and any unknown id: - // fail closed. - _ => Marking::Restricted, - } - } } impl Default for OgarClassView { @@ -492,27 +458,4 @@ mod tests { assert_eq!(fields[11].predicate_iri, "encounter"); } - #[test] - fn access_marking_inherits_domain_sensitivity_fail_closed() { - let v = OgarClassView::new(); - // Health → Restricted (PHI), for every Health concept. - for concept in [ - "patient", "diagnosis", "lab_value", "medication", "treatment", "visit", "vital_sign", - ] { - let id = canonical_concept_id(concept).unwrap(); - assert_eq!(v.access_marking(id), Marking::Restricted, "{concept}"); - } - // Commerce → Financial; ProjectMgmt → Internal. - assert_eq!( - v.access_marking(canonical_concept_id("payment_record").unwrap()), - Marking::Financial - ); - assert_eq!( - v.access_marking(canonical_concept_id("project").unwrap()), - Marking::Internal - ); - // Unknown / unclassified id → fail-closed Restricted, NOT Public. - assert_eq!(v.access_marking(0xFFFF), Marking::Restricted); - assert_eq!(v.access_marking(0x0000), Marking::Restricted); - } } From c679bccb7996abffdf8de0a46d6700da992527d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 16:05:04 +0000 Subject: [PATCH 5/5] fix(ogar-vocab): add Health arm to all_promoted_classes() (match class_ids::ALL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI (PR merge with main) failed: main added all_promoted_classes() — an enumerator pinned to class_ids::ALL — after this branch's base. This branch added the 7 Health concepts to class_ids::ALL (39) but the merged aggregator still returned 32, so all_promoted_classes_matches_class_ids_ all_{in_length,order} failed. Append the 7 Health builders (patient, diagnosis, lab_value, medication, treatment, visit, vital_sign) in class_ids::ALL order. ogar-vocab 71 + ogar-class-view 11 green; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EYvNjD8M8LMNYbRy3gq2FP --- crates/ogar-vocab/src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index ab2d53f..5862d45 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -2206,6 +2206,15 @@ pub fn all_promoted_classes() -> Vec { billing_party(), payment_record(), currency_policy(), + // 0x09XX — health arm (7 OGIT Healthcare concepts), in + // class_ids::ALL order. + patient(), + diagnosis(), + lab_value(), + medication(), + treatment(), + visit(), + vital_sign(), ] }